lots of changes
This commit is contained in:
39
AGENTS.md
Normal file
39
AGENTS.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Repository Guidelines
|
||||
|
||||
This document provides contributor guidance for this repository.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `app/`: Next.js App Router pages, layouts, and API routes (e.g., `app/api/...`).
|
||||
- `components/`: Reusable UI components and app-level widgets.
|
||||
- `lib/`: Shared utilities, API helpers, and domain logic.
|
||||
- `convex/`: Convex backend functions, schema, and auth helpers.
|
||||
- `public/`: Static assets served by Next.js.
|
||||
- `docs/` and `scripts/`: Reference docs and maintenance scripts.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `npm run dev`: Start the Next.js dev server.
|
||||
- `npm run build`: Production build.
|
||||
- `npm run start`: Run the production server locally after build.
|
||||
- `npm run lint`: Run Next.js/ESLint linting.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- TypeScript + React (Next.js App Router). Use 2-space indentation as seen in existing files.
|
||||
- Prefer file and folder names in `kebab-case` and React components in `PascalCase`.
|
||||
- Tailwind CSS is used for styling; keep class lists ordered for readability.
|
||||
- Linting: `next lint` (no Prettier config is present).
|
||||
|
||||
## Testing Guidelines
|
||||
- No dedicated test framework is configured yet.
|
||||
- If you add tests, document the runner and add a script to `package.json`.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Commit history uses Conventional Commits (e.g., `feat:`, `fix:`). Follow that pattern.
|
||||
- Commit changes made during a request, and only include files touched for that request.
|
||||
- PRs should include:
|
||||
- Clear description of changes and motivation.
|
||||
- Linked issue/task if available.
|
||||
- Screenshots for UI changes (before/after if relevant).
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Copy `.env.example` to `.env.local` for local development.
|
||||
- Never commit secrets (API keys, tokens). Keep them in `.env.local`.
|
||||
@@ -1,319 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useQuery } from "convex/react"
|
||||
import { useState } from "react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useProject } from "@/components/project-context"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useMutation } from "convex/react"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
export default function Page() {
|
||||
const { selectedProjectId } = useProject()
|
||||
const projects = useQuery(api.projects.getProjects)
|
||||
const dataSources = useQuery(
|
||||
api.dataSources.getProjectDataSources,
|
||||
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
||||
)
|
||||
const searchContext = useQuery(
|
||||
api.projects.getSearchContext,
|
||||
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
||||
)
|
||||
const analysisJobs = useQuery(
|
||||
api.analysisJobs.listByProject,
|
||||
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
||||
)
|
||||
const updateDataSourceStatus = useMutation(api.dataSources.updateDataSourceStatus)
|
||||
const createAnalysis = useMutation(api.analyses.createAnalysis)
|
||||
const createAnalysisJob = useMutation(api.analysisJobs.create)
|
||||
const [reanalyzingId, setReanalyzingId] = useState<string | null>(null)
|
||||
const analysis = useQuery(
|
||||
api.analyses.getLatestByProject,
|
||||
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
||||
)
|
||||
|
||||
const selectedProject = projects?.find((project) => project._id === selectedProjectId)
|
||||
const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || []
|
||||
const isLoading = selectedProjectId && analysis === undefined
|
||||
|
||||
if (!selectedProjectId && projects && projects.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-10 text-center">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">No projects yet</h2>
|
||||
<p className="text-muted-foreground">Complete onboarding to create your first project.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-10 text-center text-muted-foreground">
|
||||
Loading analysis...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!analysis) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-10 text-center">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">No analysis yet</h2>
|
||||
<p className="text-muted-foreground">Run onboarding to analyze a product for this project.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleReanalyze = async (source: any) => {
|
||||
if (!selectedProjectId) return
|
||||
|
||||
setReanalyzingId(source._id)
|
||||
await updateDataSourceStatus({
|
||||
dataSourceId: source._id,
|
||||
analysisStatus: "pending",
|
||||
lastError: undefined,
|
||||
lastAnalyzedAt: undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
const jobId = await createAnalysisJob({
|
||||
projectId: selectedProjectId as any,
|
||||
dataSourceId: source._id,
|
||||
})
|
||||
|
||||
const response = await fetch("/api/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: source.url, jobId }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
await updateDataSourceStatus({
|
||||
dataSourceId: source._id,
|
||||
analysisStatus: "failed",
|
||||
lastError: data.error || "Analysis failed",
|
||||
lastAnalyzedAt: Date.now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await createAnalysis({
|
||||
projectId: selectedProjectId as any,
|
||||
dataSourceId: source._id,
|
||||
analysis: data.data,
|
||||
})
|
||||
|
||||
await updateDataSourceStatus({
|
||||
dataSourceId: source._id,
|
||||
analysisStatus: "completed",
|
||||
lastError: undefined,
|
||||
lastAnalyzedAt: Date.now(),
|
||||
})
|
||||
} finally {
|
||||
setReanalyzingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-8">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{selectedProject?.name || analysis.productName}
|
||||
</h1>
|
||||
<Badge variant="outline">{analysis.productName}</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{analysis.tagline}</p>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">{analysis.description}</p>
|
||||
</div>
|
||||
|
||||
{searchContext?.missingSources?.length > 0 && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Some selected sources don't have analysis yet:{" "}
|
||||
{searchContext.missingSources
|
||||
.map((missing: any) =>
|
||||
dataSources?.find((source: any) => source._id === missing.sourceId)?.name ||
|
||||
dataSources?.find((source: any) => source._id === missing.sourceId)?.url ||
|
||||
missing.sourceId
|
||||
)
|
||||
.join(", ")}
|
||||
. Run onboarding or re-analyze them for best results.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Key Features</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-semibold">{analysis.features.length}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Search Keywords</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-semibold">{analysis.keywords.length}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Target Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-semibold">{analysis.personas.length}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Competitors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-semibold">{analysis.competitors.length}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Use Cases</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-2xl font-semibold">{analysis.useCases.length}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Sources</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
{dataSources && dataSources.length > 0 ? (
|
||||
dataSources.map((source: any) => (
|
||||
<div key={source._id} className="flex items-center justify-between gap-3">
|
||||
<span className="truncate">{source.name || source.url}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={selectedSourceIds.includes(source._id) ? "secondary" : "outline"}>
|
||||
{selectedSourceIds.includes(source._id) ? "active" : "inactive"}
|
||||
</Badge>
|
||||
<Badge variant={source.analysisStatus === "completed" ? "secondary" : "outline"}>
|
||||
{source.analysisStatus}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleReanalyze(source)}
|
||||
disabled={reanalyzingId === source._id}
|
||||
>
|
||||
{reanalyzingId === source._id ? "Analyzing..." : "Re-analyze"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>No data sources yet. Add a source during onboarding.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{analysisJobs && analysisJobs.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Runs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
{analysisJobs.slice(0, 5).map((job: any) => {
|
||||
const sourceName = dataSources?.find((source: any) => source._id === job.dataSourceId)?.name
|
||||
|| dataSources?.find((source: any) => source._id === job.dataSourceId)?.url
|
||||
|| "Unknown source"
|
||||
return (
|
||||
<div key={job._id} className="space-y-2 rounded-md border border-border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="truncate">
|
||||
<span className="font-medium text-foreground">{sourceName}</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({job.status})
|
||||
</span>
|
||||
</div>
|
||||
{job.status === "failed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const source = dataSources?.find((s: any) => s._id === job.dataSourceId)
|
||||
if (source) void handleReanalyze(source)
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(job.status === "running" || job.status === "pending") && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={typeof job.progress === "number" ? job.progress : 10} />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{typeof job.progress === "number" ? `${job.progress}% complete` : "Starting..."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{job.status === "failed" && job.error && (
|
||||
<div className="text-xs text-destructive">{job.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{searchContext?.context && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Combined Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm text-muted-foreground md:grid-cols-2">
|
||||
<div>Keywords: <span className="text-foreground font-medium">{searchContext.context.keywords.length}</span></div>
|
||||
<div>Problems: <span className="text-foreground font-medium">{searchContext.context.problemsSolved.length}</span></div>
|
||||
<div>Competitors: <span className="text-foreground font-medium">{searchContext.context.competitors.length}</span></div>
|
||||
<div>Use Cases: <span className="text-foreground font-medium">{searchContext.context.useCases.length}</span></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Key Features</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{analysis.features.slice(0, 6).map((feature, index) => (
|
||||
<div key={`${feature.name}-${index}`} className="flex items-start gap-2">
|
||||
<span className="mt-1 h-2 w-2 rounded-full bg-primary" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{feature.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Top Problems</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{analysis.problemsSolved.slice(0, 6).map((problem, index) => (
|
||||
<div key={`${problem.problem}-${index}`} className="flex items-start gap-2">
|
||||
<span className="mt-1 h-2 w-2 rounded-full bg-primary" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{problem.problem}</p>
|
||||
<p className="text-xs text-muted-foreground">{problem.emotionalImpact}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery, useMutation } from 'convex/react'
|
||||
import { api } from '@/convex/_generated/api'
|
||||
import { useProject } from '@/components/project-context'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ExternalLink, Mail, Tag, Target } from 'lucide-react'
|
||||
type Lead = {
|
||||
_id: string
|
||||
title: string
|
||||
url: string
|
||||
snippet: string
|
||||
platform: string
|
||||
relevanceScore: number
|
||||
intent: string
|
||||
status?: string
|
||||
matchedKeywords: string[]
|
||||
matchedProblems: string[]
|
||||
suggestedApproach: string
|
||||
softPitch: boolean
|
||||
createdAt: number
|
||||
notes?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export default function LeadsPage() {
|
||||
const { selectedProjectId } = useProject()
|
||||
const leads = useQuery(
|
||||
api.opportunities.listByProject,
|
||||
selectedProjectId
|
||||
? {
|
||||
projectId: selectedProjectId as any,
|
||||
limit: 200,
|
||||
}
|
||||
: "skip"
|
||||
)
|
||||
const updateOpportunity = useMutation(api.opportunities.updateStatus)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const [sourceFilter, setSourceFilter] = useState('all')
|
||||
const [ageFilter, setAgeFilter] = useState('all')
|
||||
|
||||
const sortedLeads = useMemo<Lead[]>(() => {
|
||||
if (!leads) return []
|
||||
const normalized = leads as Lead[]
|
||||
const now = Date.now()
|
||||
const ageLimit =
|
||||
ageFilter === '24h'
|
||||
? now - 24 * 60 * 60 * 1000
|
||||
: ageFilter === '7d'
|
||||
? now - 7 * 24 * 60 * 60 * 1000
|
||||
: ageFilter === '30d'
|
||||
? now - 30 * 24 * 60 * 60 * 1000
|
||||
: null
|
||||
const filtered = normalized.filter((lead) => {
|
||||
if (sourceFilter !== 'all' && lead.platform !== sourceFilter) return false
|
||||
if (ageLimit && lead.createdAt < ageLimit) return false
|
||||
return true
|
||||
})
|
||||
return [...filtered].sort((a, b) => b.createdAt - a.createdAt)
|
||||
}, [leads, sourceFilter, ageFilter])
|
||||
|
||||
const sourceOptions = useMemo(() => {
|
||||
if (!leads) return []
|
||||
const set = new Set((leads as Lead[]).map((lead) => lead.platform))
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||
}, [leads])
|
||||
|
||||
const selectedLead = useMemo<Lead | null>(() => {
|
||||
if (!sortedLeads.length) return null
|
||||
const found = sortedLeads.find((lead) => lead._id === selectedId)
|
||||
return found ?? sortedLeads[0]
|
||||
}, [sortedLeads, selectedId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedLead) return
|
||||
setNotes(selectedLead.notes || '')
|
||||
setTags(selectedLead.tags?.join(', ') || '')
|
||||
}, [selectedLead])
|
||||
|
||||
const handleSelect = (lead: Lead) => {
|
||||
setSelectedId(lead._id)
|
||||
setNotes(lead.notes || '')
|
||||
setTags(lead.tags?.join(', ') || '')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedLead) return
|
||||
const tagList = tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
await updateOpportunity({
|
||||
id: selectedLead._id as any,
|
||||
status: selectedLead.status || 'new',
|
||||
notes: notes || undefined,
|
||||
tags: tagList.length ? tagList : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="w-[420px] border-r border-border bg-card">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
Inbox
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Newest opportunities ready for outreach.</p>
|
||||
</div>
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Source</Label>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(event) => setSourceFilter(event.target.value)}
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="all">All sources</option>
|
||||
{sourceOptions.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{source}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Age</Label>
|
||||
<select
|
||||
value={ageFilter}
|
||||
onChange={(event) => setAgeFilter(event.target.value)}
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="all">Any time</option>
|
||||
<option value="24h">Last 24 hours</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[calc(100vh-128px)]">
|
||||
<div className="space-y-2 p-4">
|
||||
{sortedLeads.length === 0 && (
|
||||
<Card className="p-6 text-center text-sm text-muted-foreground">
|
||||
No leads yet. Run a search to populate the inbox.
|
||||
</Card>
|
||||
)}
|
||||
{sortedLeads.map((lead) => (
|
||||
<button
|
||||
key={lead._id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(lead as Lead)}
|
||||
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
|
||||
selectedLead?._id === lead._id
|
||||
? "border-foreground/40 bg-muted/50"
|
||||
: "border-border/60 hover:border-foreground/30 hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold line-clamp-1">{lead.title}</div>
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{lead.platform}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{lead.snippet}</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Target className="h-3 w-3" />
|
||||
<span className="capitalize">{lead.intent}</span>
|
||||
<span>•</span>
|
||||
<span>{Math.round(lead.relevanceScore * 100)}% match</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Lead</div>
|
||||
<p className="text-xs text-muted-foreground">Review the thread before reaching out.</p>
|
||||
</div>
|
||||
{selectedLead && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(selectedLead.url, '_blank')}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Source
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 px-6 py-6">
|
||||
{selectedLead ? (
|
||||
<>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-muted text-foreground">
|
||||
{Math.round(selectedLead.relevanceScore * 100)}% match
|
||||
</Badge>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{selectedLead.intent}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{selectedLead.status || 'new'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xl font-semibold">{selectedLead.title}</div>
|
||||
<p className="text-sm text-muted-foreground">{selectedLead.snippet}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Keyword Matches</Label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{selectedLead.matchedKeywords.map((keyword, index) => (
|
||||
<Badge key={`${keyword}-${index}`} variant="secondary">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Problem Matches</Label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{selectedLead.matchedProblems.map((problem, index) => (
|
||||
<Badge key={`${problem}-${index}`} variant="outline">
|
||||
{problem}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Suggested Outreach</Label>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{selectedLead.suggestedApproach}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Outreach Tone</Label>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{selectedLead.softPitch ? 'Use a softer, story-led opener.' : 'Lead with a direct solution.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Notes</Label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
rows={4}
|
||||
className="mt-2"
|
||||
placeholder="Add notes about this lead..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Tags</Label>
|
||||
<div className="relative mt-2">
|
||||
<Tag className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-input bg-background pl-10 pr-3 text-sm"
|
||||
placeholder="e.g. high intent, quick win"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave}>Save Notes</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-10 text-center text-sm text-muted-foreground">
|
||||
Select a lead to view the details.
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { convexAuthNextjsToken, isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server";
|
||||
import { fetchMutation } from "convex/nextjs";
|
||||
import { fetchMutation, fetchQuery } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { z } from 'zod'
|
||||
import { analyzeFromText } from '@/lib/scraper'
|
||||
@@ -146,9 +146,54 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
let persisted = false
|
||||
if (jobId) {
|
||||
try {
|
||||
const job = await fetchQuery(
|
||||
api.analysisJobs.getById,
|
||||
{ jobId: jobId as any },
|
||||
{ token }
|
||||
)
|
||||
|
||||
if (job?.dataSourceId && job.projectId) {
|
||||
const existing = await fetchQuery(
|
||||
api.analyses.getLatestByDataSource,
|
||||
{ dataSourceId: job.dataSourceId as any },
|
||||
{ token }
|
||||
)
|
||||
if (!existing || existing.createdAt < job.createdAt) {
|
||||
await fetchMutation(
|
||||
api.analyses.createAnalysis,
|
||||
{
|
||||
projectId: job.projectId as any,
|
||||
dataSourceId: job.dataSourceId as any,
|
||||
analysis,
|
||||
},
|
||||
{ token }
|
||||
)
|
||||
}
|
||||
|
||||
await fetchMutation(
|
||||
api.dataSources.updateDataSourceStatus,
|
||||
{
|
||||
dataSourceId: job.dataSourceId as any,
|
||||
analysisStatus: "completed",
|
||||
lastError: undefined,
|
||||
lastAnalyzedAt: Date.now(),
|
||||
},
|
||||
{ token }
|
||||
)
|
||||
persisted = true
|
||||
}
|
||||
} catch (persistError) {
|
||||
console.error("Failed to persist manual analysis:", persistError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: analysis,
|
||||
persisted,
|
||||
stats: {
|
||||
features: analysis.features.length,
|
||||
keywords: analysis.keywords.length,
|
||||
@@ -177,6 +222,27 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
{ token }
|
||||
);
|
||||
try {
|
||||
const job = await fetchQuery(
|
||||
api.analysisJobs.getById,
|
||||
{ jobId: jobId as any },
|
||||
{ token }
|
||||
)
|
||||
if (job?.dataSourceId) {
|
||||
await fetchMutation(
|
||||
api.dataSources.updateDataSourceStatus,
|
||||
{
|
||||
dataSourceId: job.dataSourceId as any,
|
||||
analysisStatus: "failed",
|
||||
lastError: error.message || "Manual analysis failed",
|
||||
lastAnalyzedAt: Date.now(),
|
||||
},
|
||||
{ token }
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort data source update only.
|
||||
}
|
||||
} catch {
|
||||
// Best-effort job update only.
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { convexAuthNextjsToken, isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server";
|
||||
import { fetchMutation } from "convex/nextjs";
|
||||
import { fetchMutation, fetchQuery } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { z } from 'zod'
|
||||
import { scrapeWebsite, ScrapingError } from '@/lib/scraper'
|
||||
@@ -146,9 +146,54 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
let persisted = false
|
||||
if (jobId) {
|
||||
try {
|
||||
const job = await fetchQuery(
|
||||
api.analysisJobs.getById,
|
||||
{ jobId: jobId as any },
|
||||
{ token }
|
||||
)
|
||||
|
||||
if (job?.dataSourceId && job.projectId) {
|
||||
const existing = await fetchQuery(
|
||||
api.analyses.getLatestByDataSource,
|
||||
{ dataSourceId: job.dataSourceId as any },
|
||||
{ token }
|
||||
)
|
||||
if (!existing || existing.createdAt < job.createdAt) {
|
||||
await fetchMutation(
|
||||
api.analyses.createAnalysis,
|
||||
{
|
||||
projectId: job.projectId as any,
|
||||
dataSourceId: job.dataSourceId as any,
|
||||
analysis,
|
||||
},
|
||||
{ token }
|
||||
)
|
||||
}
|
||||
|
||||
await fetchMutation(
|
||||
api.dataSources.updateDataSourceStatus,
|
||||
{
|
||||
dataSourceId: job.dataSourceId as any,
|
||||
analysisStatus: "completed",
|
||||
lastError: undefined,
|
||||
lastAnalyzedAt: Date.now(),
|
||||
},
|
||||
{ token }
|
||||
)
|
||||
persisted = true
|
||||
}
|
||||
} catch (persistError) {
|
||||
console.error("Failed to persist analysis:", persistError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: analysis,
|
||||
persisted,
|
||||
stats: {
|
||||
features: analysis.features.length,
|
||||
keywords: analysis.keywords.length,
|
||||
@@ -177,6 +222,27 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
{ token }
|
||||
);
|
||||
try {
|
||||
const job = await fetchQuery(
|
||||
api.analysisJobs.getById,
|
||||
{ jobId: jobId as any },
|
||||
{ token }
|
||||
)
|
||||
if (job?.dataSourceId) {
|
||||
await fetchMutation(
|
||||
api.dataSources.updateDataSourceStatus,
|
||||
{
|
||||
dataSourceId: job.dataSourceId as any,
|
||||
analysisStatus: "failed",
|
||||
lastError: error.message || "Analysis failed",
|
||||
lastAnalyzedAt: Date.now(),
|
||||
},
|
||||
{ token }
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort data source update only.
|
||||
}
|
||||
} catch {
|
||||
// Best-effort job update only.
|
||||
}
|
||||
|
||||
238
app/app/(app)/dashboard/page.tsx
Normal file
238
app/app/(app)/dashboard/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useProject } from "@/components/project-context"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export default function Page() {
|
||||
const { selectedProjectId } = useProject()
|
||||
const projects = useQuery(api.projects.getProjects)
|
||||
const dataSources = useQuery(
|
||||
api.dataSources.getProjectDataSources,
|
||||
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
||||
)
|
||||
const analysisJobs = useQuery(
|
||||
api.analysisJobs.listByProject,
|
||||
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
||||
)
|
||||
const opportunities = useQuery(
|
||||
api.opportunities.listByProject,
|
||||
selectedProjectId ? { projectId: selectedProjectId as any, limit: 200 } : "skip"
|
||||
)
|
||||
const leadsPerDay = useQuery(
|
||||
api.opportunities.countByDay,
|
||||
selectedProjectId ? { projectId: selectedProjectId as any, days: 7, metric: "created" } : "skip"
|
||||
)
|
||||
const sentPerDay = useQuery(
|
||||
api.opportunities.countByDay,
|
||||
selectedProjectId ? { projectId: selectedProjectId as any, days: 7, metric: "sent" } : "skip"
|
||||
)
|
||||
const archivedPerDay = useQuery(
|
||||
api.opportunities.countByDay,
|
||||
selectedProjectId ? { projectId: selectedProjectId as any, days: 7, metric: "archived" } : "skip"
|
||||
)
|
||||
const touchUser = useMutation(api.users.touch)
|
||||
const userActivity = useQuery(api.users.getActivity)
|
||||
const [statusTab, setStatusTab] = useState<"sent" | "archived">("sent")
|
||||
|
||||
const selectedProject = projects?.find((project) => project._id === selectedProjectId)
|
||||
const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || []
|
||||
const activeSources = dataSources?.filter((source: any) => selectedSourceIds.includes(source._id)) ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) return
|
||||
void touchUser()
|
||||
}, [selectedProjectId, touchUser])
|
||||
|
||||
const totalRuns = analysisJobs?.length ?? 0
|
||||
const recentAnalysis = analysisJobs?.slice(0, 5) ?? []
|
||||
const maxCount = (series?: { count: number }[]) =>
|
||||
Math.max(1, ...(series?.map((item) => item.count) ?? [0]))
|
||||
const formatDay = (date: string) =>
|
||||
new Date(date).toLocaleDateString(undefined, { weekday: "short" })
|
||||
|
||||
if (!selectedProjectId && projects && projects.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-10 text-center">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">No projects yet</h2>
|
||||
<p className="text-muted-foreground">Complete onboarding to create your first project.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedProjectId && opportunities === undefined) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-10 text-center text-muted-foreground">
|
||||
Loading overview...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-svh flex-1 flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{selectedProject?.name || "Overview"}</h1>
|
||||
<p className="text-sm text-muted-foreground">Keep track of outreach momentum.</p>
|
||||
</div>
|
||||
<Badge variant="outline">{activeSources.length} sources</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-12 grid-rows-6 gap-4 overflow-hidden">
|
||||
<Card className="col-span-7 row-span-3">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Inbox leads per day</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-full flex-col gap-4">
|
||||
{leadsPerDay && leadsPerDay.every((item) => item.count === 0) ? (
|
||||
<div className="flex h-28 items-center justify-center text-xs text-muted-foreground">
|
||||
Nothing to show yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-28 items-end gap-2">
|
||||
{(leadsPerDay ?? []).map((item) => (
|
||||
<div key={item.date} className="flex flex-1 flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-full rounded-md bg-foreground/70"
|
||||
style={{
|
||||
height: `${(item.count / maxCount(leadsPerDay)) * 100}%`,
|
||||
minHeight: item.count > 0 ? 12 : 2,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">{formatDay(item.date)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{opportunities?.length ?? 0} total leads in inbox.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-5 row-span-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm">Status momentum</CardTitle>
|
||||
<div className="flex rounded-full border border-border/60 p-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusTab("sent")}
|
||||
className={`rounded-full px-3 py-1 transition ${
|
||||
statusTab === "sent" ? "bg-muted text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Sent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusTab("archived")}
|
||||
className={`rounded-full px-3 py-1 transition ${
|
||||
statusTab === "archived" ? "bg-muted text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Archived
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-full flex-col gap-4">
|
||||
{(statusTab === "sent" ? sentPerDay : archivedPerDay) &&
|
||||
(statusTab === "sent" ? sentPerDay : archivedPerDay)?.every(
|
||||
(item) => item.count === 0
|
||||
) ? (
|
||||
<div className="flex h-28 items-center justify-center text-xs text-muted-foreground">
|
||||
Nothing to show yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-28 items-end gap-2">
|
||||
{((statusTab === "sent" ? sentPerDay : archivedPerDay) ?? []).map((item) => (
|
||||
<div key={item.date} className="flex flex-1 flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-full rounded-md bg-foreground/70"
|
||||
style={{
|
||||
height: `${
|
||||
(item.count /
|
||||
maxCount(statusTab === "sent" ? sentPerDay : archivedPerDay)) *
|
||||
100
|
||||
}%`,
|
||||
minHeight: item.count > 0 ? 12 : 2,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">{formatDay(item.date)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{statusTab === "sent" ? "Leads marked sent this week." : "Leads archived this week."}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-7 row-span-3">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Recent analysis runs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{recentAnalysis.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No analysis runs yet.</p>
|
||||
)}
|
||||
{recentAnalysis.map((job) => {
|
||||
const source = dataSources?.find((item: any) => item._id === job.dataSourceId)
|
||||
return (
|
||||
<div
|
||||
key={job._id}
|
||||
className="flex items-center justify-between rounded-lg border border-border/60 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{source?.name || source?.url || "Project analysis"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(job.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{job.status}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-5 row-span-3">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg border border-border/60 px-4 py-4">
|
||||
<div className="text-xs uppercase text-muted-foreground">Login streak</div>
|
||||
<div className="mt-2 text-3xl font-semibold">
|
||||
{userActivity?.streak ?? 0} days
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-border/60 px-4 py-4">
|
||||
<div className="text-xs uppercase text-muted-foreground">Data sources</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{dataSources?.length ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 px-4 py-4">
|
||||
<div className="text-xs uppercase text-muted-foreground">Runs</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{totalRuns}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" className="w-full" asChild>
|
||||
<a href="/app/search">Run search</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,9 +14,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Settings } from "lucide-react"
|
||||
import * as React from "react"
|
||||
import { ProfileSectionEditor, SectionEditor } from "@/components/analysis-section-editor"
|
||||
import { AnalysisTimeline } from "@/components/analysis-timeline"
|
||||
|
||||
function formatDate(timestamp?: number) {
|
||||
if (!timestamp) return "Not analyzed yet";
|
||||
@@ -35,13 +42,21 @@ export default function DataSourceDetailPage() {
|
||||
api.analyses.getLatestByDataSource,
|
||||
dataSourceId ? { dataSourceId: dataSourceId as any } : "skip"
|
||||
)
|
||||
const analysisJob = useQuery(
|
||||
api.analysisJobs.getLatestByDataSource,
|
||||
dataSourceId ? { dataSourceId: dataSourceId as any } : "skip"
|
||||
)
|
||||
const sections = useQuery(
|
||||
api.analysisSections.listByAnalysis,
|
||||
analysis?._id ? { analysisId: analysis._id as any } : "skip"
|
||||
)
|
||||
const removeDataSource = useMutation(api.dataSources.remove)
|
||||
const updateDataSourceStatus = useMutation(api.dataSources.updateDataSourceStatus)
|
||||
const createAnalysisJob = useMutation(api.analysisJobs.create)
|
||||
const [isDeleting, setIsDeleting] = React.useState(false)
|
||||
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
|
||||
const [isReanalyzing, setIsReanalyzing] = React.useState(false)
|
||||
const [reanalysisError, setReanalysisError] = React.useState<string | null>(null)
|
||||
|
||||
const sectionMap = React.useMemo(() => {
|
||||
const map = new Map<string, any>()
|
||||
@@ -70,6 +85,47 @@ export default function DataSourceDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleReanalyze = async () => {
|
||||
if (!dataSource || !dataSource.url) return
|
||||
if (dataSource.url.startsWith("manual:")) {
|
||||
setReanalysisError("Manual sources can’t be reanalyzed automatically.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReanalyzing(true)
|
||||
setReanalysisError(null)
|
||||
|
||||
try {
|
||||
await updateDataSourceStatus({
|
||||
dataSourceId: dataSource._id as any,
|
||||
analysisStatus: "pending",
|
||||
lastError: undefined,
|
||||
lastAnalyzedAt: undefined,
|
||||
})
|
||||
|
||||
const jobId = await createAnalysisJob({
|
||||
projectId: dataSource.projectId,
|
||||
dataSourceId: dataSource._id,
|
||||
})
|
||||
|
||||
await fetch("/api/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: dataSource.url, jobId }),
|
||||
})
|
||||
} catch (err: any) {
|
||||
setReanalysisError(err?.message || "Failed to reanalyze data source.");
|
||||
await updateDataSourceStatus({
|
||||
dataSourceId: dataSource._id as any,
|
||||
analysisStatus: "failed",
|
||||
lastError: err?.message || "Failed to reanalyze data source.",
|
||||
lastAnalyzedAt: Date.now(),
|
||||
})
|
||||
} finally {
|
||||
setIsReanalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusVariant =
|
||||
dataSource.analysisStatus === "completed"
|
||||
? "secondary"
|
||||
@@ -88,19 +144,36 @@ export default function DataSourceDetailPage() {
|
||||
<Badge variant={statusVariant}>
|
||||
{dataSource.analysisStatus}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
aria-label="Data source settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Data source settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={handleReanalyze} disabled={isReanalyzing}>
|
||||
{isReanalyzing ? "Reanalyzing..." : "Run reanalysis"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setIsDialogOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{dataSource.url}</p>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span>Last analyzed: {formatDate(dataSource.lastAnalyzedAt)}</span>
|
||||
{reanalysisError && (
|
||||
<span className="text-destructive">{reanalysisError}</span>
|
||||
)}
|
||||
{dataSource.lastError && (
|
||||
<span className="text-destructive">
|
||||
Error: {dataSource.lastError}
|
||||
@@ -109,6 +182,17 @@ export default function DataSourceDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysisJob?.timeline?.length && analysisJob.status !== "completed" ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Analysis Progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnalysisTimeline items={analysisJob.timeline} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{analysis ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
@@ -230,7 +314,7 @@ export default function DataSourceDetailPage() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Edit Sections</h2>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<ProfileSectionEditor
|
||||
analysisId={analysis._id as any}
|
||||
items={
|
||||
@@ -323,7 +407,7 @@ export default function DataSourceDetailPage() {
|
||||
if (!dataSourceId) return
|
||||
setIsDeleting(true)
|
||||
await removeDataSource({ dataSourceId: dataSourceId as any })
|
||||
router.push("/dashboard")
|
||||
router.push("/app/dashboard")
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
@@ -17,7 +17,7 @@ export default function HelpPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>1. Run onboarding with your product URL or manual details.</p>
|
||||
<p>2. Open Opportunities and pick platforms + strategies.</p>
|
||||
<p>2. Open Search and pick platforms + strategies.</p>
|
||||
<p>3. Review matches, generate replies, and track status.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
564
app/app/(app)/inbox/page.tsx
Normal file
564
app/app/(app)/inbox/page.tsx
Normal file
@@ -0,0 +1,564 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery, useMutation } from 'convex/react'
|
||||
import { api } from '@/convex/_generated/api'
|
||||
import { useProject } from '@/components/project-context'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Archive, Check, ExternalLink, HelpCircle, Mail, Target } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
type Lead = {
|
||||
_id: string
|
||||
title: string
|
||||
url: string
|
||||
snippet: string
|
||||
platform: string
|
||||
relevanceScore: number
|
||||
intent: string
|
||||
status?: string
|
||||
matchedKeywords: string[]
|
||||
matchedProblems: string[]
|
||||
suggestedApproach: string
|
||||
softPitch: boolean
|
||||
createdAt: number
|
||||
notes?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export default function LeadsPage() {
|
||||
const { selectedProjectId } = useProject()
|
||||
const leads = useQuery(
|
||||
api.opportunities.listByProject,
|
||||
selectedProjectId
|
||||
? {
|
||||
projectId: selectedProjectId as any,
|
||||
limit: 200,
|
||||
}
|
||||
: "skip"
|
||||
)
|
||||
const updateOpportunity = useMutation(api.opportunities.updateStatus)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
||||
const [optimisticHiddenIds, setOptimisticHiddenIds] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const [toast, setToast] = useState<{
|
||||
message: string
|
||||
variant?: 'success' | 'error'
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
} | null>(null)
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [sourceFilter, setSourceFilter] = useState('all')
|
||||
const [ageFilter, setAgeFilter] = useState('all')
|
||||
const [statusFilter, setStatusFilter] = useState('active')
|
||||
const [isShortcutsOpen, setIsShortcutsOpen] = useState(false)
|
||||
const leadRefs = useRef<Map<string, HTMLDivElement | null>>(new Map())
|
||||
|
||||
const getStatusLabel = (status?: string) => {
|
||||
if (!status) return 'new'
|
||||
if (status === 'ignored') return 'archived'
|
||||
if (status === 'converted') return 'sent'
|
||||
return status
|
||||
}
|
||||
|
||||
const sortedLeads = useMemo<Lead[]>(() => {
|
||||
if (!leads) return []
|
||||
const normalized = leads as Lead[]
|
||||
const now = Date.now()
|
||||
const ageLimit =
|
||||
ageFilter === '24h'
|
||||
? now - 24 * 60 * 60 * 1000
|
||||
: ageFilter === '7d'
|
||||
? now - 7 * 24 * 60 * 60 * 1000
|
||||
: ageFilter === '30d'
|
||||
? now - 30 * 24 * 60 * 60 * 1000
|
||||
: null
|
||||
const filtered = normalized.filter((lead) => {
|
||||
if (sourceFilter !== 'all' && lead.platform !== sourceFilter) return false
|
||||
if (ageLimit && lead.createdAt < ageLimit) return false
|
||||
if (optimisticHiddenIds.has(lead._id)) return false
|
||||
const normalizedStatus = getStatusLabel(lead.status)
|
||||
if (statusFilter === 'archived' && normalizedStatus !== 'archived') return false
|
||||
if (statusFilter === 'sent' && normalizedStatus !== 'sent') return false
|
||||
if (
|
||||
statusFilter === 'active' &&
|
||||
(normalizedStatus === 'archived' || normalizedStatus === 'sent')
|
||||
)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
return [...filtered].sort((a, b) => b.createdAt - a.createdAt)
|
||||
}, [leads, sourceFilter, ageFilter, statusFilter, optimisticHiddenIds])
|
||||
|
||||
const sourceOptions = useMemo(() => {
|
||||
if (!leads) return []
|
||||
const set = new Set((leads as Lead[]).map((lead) => lead.platform))
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||
}, [leads])
|
||||
|
||||
const getMatchReason = (lead: Lead) => {
|
||||
const keyword = lead.matchedKeywords?.[0]
|
||||
const problem = lead.matchedProblems?.[0]
|
||||
if (keyword && problem) return `Matched "${keyword}" + ${problem}`
|
||||
if (problem) return `Matched problem: ${problem}`
|
||||
if (keyword) return `Matched keyword: "${keyword}"`
|
||||
return 'Matched by relevance'
|
||||
}
|
||||
|
||||
const selectedLead = useMemo<Lead | null>(() => {
|
||||
if (!sortedLeads.length) return null
|
||||
const found = sortedLeads.find((lead) => lead._id === selectedId)
|
||||
return found ?? sortedLeads[0]
|
||||
}, [sortedLeads, selectedId])
|
||||
|
||||
const handleSelect = (lead: Lead) => {
|
||||
setSelectedId(lead._id)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedLead) return
|
||||
if (selectedLead._id !== selectedId) {
|
||||
setSelectedId(selectedLead._id)
|
||||
}
|
||||
}, [selectedLead, selectedId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedLead) return
|
||||
const node = leadRefs.current.get(selectedLead._id)
|
||||
if (node) {
|
||||
node.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
}, [selectedLead])
|
||||
|
||||
const handleQuickStatus = async (lead: Lead, status: string) => {
|
||||
const shouldHide = status === 'archived' || status === 'sent'
|
||||
const previousStatus = lead.status || 'new'
|
||||
if (shouldHide && selectedLead?._id === lead._id) {
|
||||
const currentIndex = sortedLeads.findIndex((item) => item._id === lead._id)
|
||||
const nextLead =
|
||||
sortedLeads[currentIndex + 1] ?? sortedLeads[currentIndex - 1] ?? null
|
||||
setSelectedId(nextLead?._id ?? null)
|
||||
}
|
||||
if (shouldHide) {
|
||||
setOptimisticHiddenIds((current) => {
|
||||
const next = new Set(current)
|
||||
next.add(lead._id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
setUpdatingId(lead._id)
|
||||
try {
|
||||
await updateOpportunity({
|
||||
id: lead._id as any,
|
||||
status,
|
||||
notes: lead.notes,
|
||||
tags: lead.tags,
|
||||
})
|
||||
if (toastTimerRef.current) {
|
||||
clearTimeout(toastTimerRef.current)
|
||||
}
|
||||
setToast({
|
||||
message: status === 'archived' ? 'Lead archived.' : 'Lead marked sent.',
|
||||
variant: 'success',
|
||||
actionLabel: 'Undo',
|
||||
onAction: async () => {
|
||||
setToast(null)
|
||||
if (shouldHide) {
|
||||
setOptimisticHiddenIds((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(lead._id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
setUpdatingId(lead._id)
|
||||
try {
|
||||
await updateOpportunity({
|
||||
id: lead._id as any,
|
||||
status: previousStatus,
|
||||
notes: lead.notes,
|
||||
tags: lead.tags,
|
||||
})
|
||||
setToast({ message: 'Lead restored.', variant: 'success' })
|
||||
} catch (error) {
|
||||
setToast({ message: 'Failed to undo update.', variant: 'error' })
|
||||
} finally {
|
||||
setUpdatingId((current) => (current === lead._id ? null : current))
|
||||
toastTimerRef.current = setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
},
|
||||
})
|
||||
toastTimerRef.current = setTimeout(() => setToast(null), 5000)
|
||||
} catch (error) {
|
||||
if (shouldHide) {
|
||||
setOptimisticHiddenIds((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(lead._id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (toastTimerRef.current) {
|
||||
clearTimeout(toastTimerRef.current)
|
||||
}
|
||||
setToast({ message: 'Failed to update lead.', variant: 'error' })
|
||||
toastTimerRef.current = setTimeout(() => setToast(null), 3000)
|
||||
} finally {
|
||||
setUpdatingId((current) => (current === lead._id ? null : current))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (toastTimerRef.current) {
|
||||
clearTimeout(toastTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!sortedLeads.length) return
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return
|
||||
const target = event.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
const currentIndex = selectedLead
|
||||
? sortedLeads.findIndex((lead) => lead._id === selectedLead._id)
|
||||
: -1
|
||||
|
||||
if (key === 'j' || key === 'k') {
|
||||
event.preventDefault()
|
||||
const nextIndex =
|
||||
key === 'j'
|
||||
? Math.min(sortedLeads.length - 1, currentIndex + 1)
|
||||
: Math.max(0, currentIndex - 1)
|
||||
const nextLead = sortedLeads[nextIndex]
|
||||
if (nextLead) {
|
||||
setSelectedId(nextLead._id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'o' || event.key === 'Enter') {
|
||||
if (!selectedLead) return
|
||||
event.preventDefault()
|
||||
window.open(selectedLead.url, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'a') {
|
||||
if (!selectedLead) return
|
||||
event.preventDefault()
|
||||
handleQuickStatus(selectedLead, 'archived')
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 's') {
|
||||
if (!selectedLead) return
|
||||
event.preventDefault()
|
||||
handleQuickStatus(selectedLead, 'sent')
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedLead, sortedLeads])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg border px-4 py-3 text-sm shadow-lg ${
|
||||
toast.variant === 'error'
|
||||
? 'border-destructive/40 bg-destructive/10 text-destructive'
|
||||
: 'border-border bg-card text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span>{toast.message}</span>
|
||||
{toast.actionLabel && toast.onAction && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={toast.onAction}
|
||||
>
|
||||
{toast.actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-[420px] border-r border-border bg-card">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
Inbox
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Newest opportunities ready for outreach.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full border-border/60 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setIsShortcutsOpen(true)}
|
||||
aria-label="View inbox shortcuts"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="uppercase tracking-wide text-[10px]">Source</span>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(event) => setSourceFilter(event.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="all">All sources</option>
|
||||
{sourceOptions.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{source}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="uppercase tracking-wide text-[10px]">Age</span>
|
||||
<select
|
||||
value={ageFilter}
|
||||
onChange={(event) => setAgeFilter(event.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="all">Any time</option>
|
||||
<option value="24h">Last 24 hours</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="uppercase tracking-wide text-[10px]">Status</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="archived">Archived</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[calc(100vh-128px)]">
|
||||
<div className="space-y-2 p-4">
|
||||
{sortedLeads.length === 0 && (
|
||||
<Card className="p-6 text-center text-sm text-muted-foreground">
|
||||
No leads yet. Run a search to populate the inbox.
|
||||
</Card>
|
||||
)}
|
||||
{sortedLeads.map((lead) => (
|
||||
<div
|
||||
key={lead._id}
|
||||
ref={(node) => {
|
||||
leadRefs.current.set(lead._id, node)
|
||||
}}
|
||||
role="button"
|
||||
aria-selected={selectedLead?._id === lead._id}
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelect(lead as Lead)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
handleSelect(lead as Lead)
|
||||
}
|
||||
}}
|
||||
className={`group relative w-full cursor-pointer rounded-lg border px-3 py-3 text-left transition ${
|
||||
selectedLead?._id === lead._id
|
||||
? "border-foreground/80 bg-foreground/15 shadow-[0_0_0_2px_rgba(255,255,255,0.18)]"
|
||||
: "border-border/60 hover:border-foreground/30"
|
||||
}`}
|
||||
>
|
||||
{selectedLead?._id === lead._id && (
|
||||
<span className="absolute left-0 top-0 h-full w-1.5 rounded-l-lg bg-foreground" />
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold line-clamp-1">{lead.title}</div>
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{lead.platform}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{lead.snippet}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{getMatchReason(lead)}</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Target className="h-3 w-3" />
|
||||
<span className="capitalize">{lead.intent}</span>
|
||||
<span>•</span>
|
||||
<span className="capitalize">{getStatusLabel(lead.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Lead</div>
|
||||
<p className="text-xs text-muted-foreground">Review the thread before reaching out.</p>
|
||||
</div>
|
||||
{selectedLead && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(selectedLead.url, '_blank')}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Source
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 px-6 py-6">
|
||||
{selectedLead ? (
|
||||
<>
|
||||
<Card className="relative p-6">
|
||||
<div className="absolute right-4 top-4 flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleQuickStatus(selectedLead, 'archived')}
|
||||
aria-label="Archive lead"
|
||||
disabled={updatingId === selectedLead._id}
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleQuickStatus(selectedLead, 'sent')}
|
||||
aria-label="Mark lead sent"
|
||||
disabled={updatingId === selectedLead._id}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-muted text-foreground">
|
||||
{Math.round(selectedLead.relevanceScore * 100)}% match
|
||||
</Badge>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{selectedLead.intent}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{getStatusLabel(selectedLead.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xl font-semibold">{selectedLead.title}</div>
|
||||
<p className="text-sm text-muted-foreground">{selectedLead.snippet}</p>
|
||||
<p className="text-xs text-muted-foreground">{getMatchReason(selectedLead)}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Keyword Matches</Label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{selectedLead.matchedKeywords.map((keyword, index) => (
|
||||
<Badge key={`${keyword}-${index}`} variant="secondary">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Problem Matches</Label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{selectedLead.matchedProblems.map((problem, index) => (
|
||||
<Badge key={`${problem}-${index}`} variant="outline">
|
||||
{problem}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Suggested Outreach</Label>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{selectedLead.suggestedApproach}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Outreach Tone</Label>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{selectedLead.softPitch ? 'Use a softer, story-led opener.' : 'Lead with a direct solution.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-10 text-center text-sm text-muted-foreground">
|
||||
Select a lead to view the details.
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={isShortcutsOpen} onOpenChange={setIsShortcutsOpen}>
|
||||
<DialogContent className="max-w-xs">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Inbox shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Next lead</span>
|
||||
<span className="font-medium text-foreground">J</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Previous lead</span>
|
||||
<span className="font-medium text-foreground">K</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Open source</span>
|
||||
<span className="font-medium text-foreground">O / Enter</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Archive</span>
|
||||
<span className="font-medium text-foreground">A</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Mark sent</span>
|
||||
<span className="font-medium text-foreground">S</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useMutation, useQuery } from 'convex/react'
|
||||
import { api } from '@/convex/_generated/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -22,6 +23,12 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -48,7 +55,8 @@ import {
|
||||
Plus,
|
||||
X,
|
||||
Users,
|
||||
Copy
|
||||
Copy,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import { useProject } from '@/components/project-context'
|
||||
import type {
|
||||
@@ -191,6 +199,7 @@ export default function OpportunitiesPage() {
|
||||
const { selectedProjectId } = useProject()
|
||||
const upsertOpportunities = useMutation(api.opportunities.upsertBatch)
|
||||
const updateOpportunity = useMutation(api.opportunities.updateStatus)
|
||||
const toggleDataSourceConfig = useMutation(api.projects.toggleDataSourceConfig)
|
||||
const createSearchJob = useMutation(api.searchJobs.create)
|
||||
const [analysis, setAnalysis] = useState<EnhancedProductAnalysis | null>(null)
|
||||
const [platforms, setPlatforms] = useState<PlatformConfig[]>([])
|
||||
@@ -209,6 +218,7 @@ export default function OpportunitiesPage() {
|
||||
const [replyText, setReplyText] = useState('')
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
const [searchError, setSearchError] = useState('')
|
||||
const [showInboxCta, setShowInboxCta] = useState(false)
|
||||
const [missingSources, setMissingSources] = useState<any[]>([])
|
||||
const [lastSearchConfig, setLastSearchConfig] = useState<SearchConfig | null>(null)
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
@@ -222,6 +232,7 @@ export default function OpportunitiesPage() {
|
||||
const [customSourceName, setCustomSourceName] = useState('')
|
||||
const [customSourceSite, setCustomSourceSite] = useState('')
|
||||
const [customSourceTemplate, setCustomSourceTemplate] = useState('{site} {term} {intent}')
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
|
||||
const planLoadedRef = useRef<string | null>(null)
|
||||
const defaultPlatformsRef = useRef<PlatformConfig[] | null>(null)
|
||||
|
||||
@@ -238,6 +249,7 @@ export default function OpportunitiesPage() {
|
||||
projectId: selectedProjectId as any,
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
intent: intentFilter === 'all' ? undefined : intentFilter,
|
||||
searchJobId: selectedRunId ? (selectedRunId as any) : undefined,
|
||||
limit,
|
||||
}
|
||||
: 'skip'
|
||||
@@ -279,6 +291,12 @@ export default function OpportunitiesPage() {
|
||||
const activeSources = selectedSources?.filter((source: any) =>
|
||||
selectedSourceIds.includes(source._id)
|
||||
) || []
|
||||
const latestRun = searchJobs?.[0]
|
||||
const recentCompletedRuns = searchJobs
|
||||
? searchJobs
|
||||
.filter((job: any) => job.status === "completed" && job._id !== latestRun?._id)
|
||||
.slice(0, 2)
|
||||
: []
|
||||
const enabledPlatforms = platforms.filter((platform) => platform.enabled)
|
||||
const estimatedMinutes = estimateSearchTime(Math.max(maxQueries, 1), enabledPlatforms.map((platform) => platform.id))
|
||||
|
||||
@@ -291,7 +309,7 @@ export default function OpportunitiesPage() {
|
||||
fetch('/api/opportunities')
|
||||
.then(r => {
|
||||
if (r.redirected) {
|
||||
router.push('/auth?next=/opportunities')
|
||||
router.push('/auth?next=/app/search')
|
||||
return null
|
||||
}
|
||||
return r.json()
|
||||
@@ -372,6 +390,12 @@ export default function OpportunitiesPage() {
|
||||
}
|
||||
}, [analysis, latestAnalysis, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchJobs || searchJobs.length === 0) return
|
||||
if (selectedRunId) return
|
||||
setSelectedRunId(searchJobs[0]._id as string)
|
||||
}, [searchJobs, selectedRunId])
|
||||
|
||||
const togglePlatform = (platformId: string) => {
|
||||
setPlatforms(prev => prev.map(p =>
|
||||
p.id === platformId ? { ...p, enabled: !p.enabled } : p
|
||||
@@ -398,6 +422,23 @@ export default function OpportunitiesPage() {
|
||||
setMaxQueries(preset.maxQueries)
|
||||
}
|
||||
|
||||
const formatRunTitle = (job: any) => {
|
||||
if (!job) return "Search run"
|
||||
const created = new Date(job.createdAt || Date.now())
|
||||
const time = created.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
const date = created.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
return `${date} · ${time}`
|
||||
}
|
||||
|
||||
const getMatchReason = (opp: Opportunity) => {
|
||||
const keyword = opp.matchedKeywords?.[0]
|
||||
const problem = opp.matchedProblems?.[0]
|
||||
if (keyword && problem) return `Matched "${keyword}" + ${problem}`
|
||||
if (problem) return `Matched problem: ${problem}`
|
||||
if (keyword) return `Matched keyword: "${keyword}"`
|
||||
return 'Matched by relevance'
|
||||
}
|
||||
|
||||
const addCustomSource = () => {
|
||||
const name = customSourceName.trim()
|
||||
const site = customSourceSite.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '')
|
||||
@@ -450,6 +491,8 @@ export default function OpportunitiesPage() {
|
||||
projectId: selectedProjectId as any,
|
||||
config,
|
||||
})
|
||||
setSelectedRunId(jobId as string)
|
||||
setShowInboxCta(true)
|
||||
|
||||
const response = await fetch('/api/opportunities', {
|
||||
method: 'POST',
|
||||
@@ -458,7 +501,7 @@ export default function OpportunitiesPage() {
|
||||
})
|
||||
|
||||
if (response.redirected) {
|
||||
router.push('/auth?next=/opportunities')
|
||||
router.push('/auth?next=/app/search')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -482,6 +525,7 @@ export default function OpportunitiesPage() {
|
||||
await upsertOpportunities({
|
||||
projectId: selectedProjectId as any,
|
||||
analysisId: latestAnalysis?._id,
|
||||
searchJobId: jobId as any,
|
||||
opportunities: mapped.map((opp: Opportunity) => ({
|
||||
url: opp.url,
|
||||
platform: opp.platform,
|
||||
@@ -530,6 +574,54 @@ export default function OpportunitiesPage() {
|
||||
}
|
||||
}, [selectedOpportunity])
|
||||
|
||||
const visibleOpportunities = useMemo(() => {
|
||||
return displayOpportunities.slice(0, limit)
|
||||
}, [displayOpportunities, limit])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!event.ctrlKey) return
|
||||
if (event.metaKey || event.altKey) return
|
||||
|
||||
if (visibleOpportunities.length === 0) return
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (key === 'enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (selectedOpportunity?.url) {
|
||||
window.open(selectedOpportunity.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement | null
|
||||
const tagName = target?.tagName?.toLowerCase()
|
||||
const isEditable =
|
||||
tagName === 'input' ||
|
||||
tagName === 'textarea' ||
|
||||
target?.getAttribute('contenteditable') === 'true'
|
||||
if (isEditable) return
|
||||
|
||||
if (key === 'j' || key === 'k') {
|
||||
event.preventDefault()
|
||||
const currentIndex = selectedOpportunity
|
||||
? visibleOpportunities.findIndex((opp) => opp.id === selectedOpportunity.id)
|
||||
: -1
|
||||
const direction = key === 'j' ? 1 : -1
|
||||
let nextIndex = currentIndex + direction
|
||||
if (nextIndex < 0) nextIndex = visibleOpportunities.length - 1
|
||||
if (nextIndex >= visibleOpportunities.length) nextIndex = 0
|
||||
setSelectedOpportunity(visibleOpportunities[nextIndex])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
}, [visibleOpportunities, selectedOpportunity])
|
||||
|
||||
const handleSaveOpportunity = async () => {
|
||||
if (!selectedOpportunity?.id) return
|
||||
|
||||
@@ -546,6 +638,7 @@ export default function OpportunitiesPage() {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
if (!analysis) return null
|
||||
|
||||
const latestJob = searchJobs && searchJobs.length > 0 ? searchJobs[0] : null
|
||||
@@ -817,6 +910,39 @@ export default function OpportunitiesPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showInboxCta && (
|
||||
<Alert className="flex flex-wrap items-center justify-between gap-2">
|
||||
<AlertDescription className="text-sm">
|
||||
Search created. Head to your inbox to review new leads as they arrive.
|
||||
</AlertDescription>
|
||||
<Button asChild size="sm" variant="secondary">
|
||||
<Link href="/app/inbox">Go to inbox</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Showing results for a single search run.
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Label className="text-xs">Run</Label>
|
||||
<select
|
||||
value={selectedRunId ?? ''}
|
||||
onChange={(event) => setSelectedRunId(event.target.value || null)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
disabled={!searchJobs || searchJobs.length === 0}
|
||||
>
|
||||
{(!searchJobs || searchJobs.length === 0) && (
|
||||
<option value="">No runs yet</option>
|
||||
)}
|
||||
{searchJobs?.map((job: any) => (
|
||||
<option key={job._id} value={job._id}>
|
||||
{formatRunTitle(job)} · {job.status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">Goal: {GOAL_PRESETS.find((preset) => preset.id === goalPreset)?.title || "Custom"}</Badge>
|
||||
<Badge variant="outline">Max queries: {maxQueries}</Badge>
|
||||
@@ -866,22 +992,74 @@ export default function OpportunitiesPage() {
|
||||
)}
|
||||
|
||||
{/* Active Sources */}
|
||||
{activeSources.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Inputs in use</CardTitle>
|
||||
<CardDescription>
|
||||
Sources selected for this project will drive opportunity search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{activeSources.map((source: any) => (
|
||||
<Badge key={source._id} variant="secondary">
|
||||
{source.name || source.url}
|
||||
</Badge>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{(selectedSources?.length || latestRun || recentCompletedRuns.length > 0) && (
|
||||
<div className="rounded-lg border border-border bg-card/60 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="uppercase tracking-wide text-[10px]">Sources</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
{activeSources.length > 0 ? `${activeSources.length} selected` : 'Select sources'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
{selectedSources?.map((source: any) => {
|
||||
const checked = selectedSourceIds.includes(source._id)
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={source._id}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => {
|
||||
if (!selectedProjectId) return
|
||||
toggleDataSourceConfig({
|
||||
projectId: selectedProjectId as any,
|
||||
sourceId: source._id,
|
||||
selected: Boolean(value),
|
||||
})
|
||||
}}
|
||||
>
|
||||
{source.name || source.url}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{(latestRun || recentCompletedRuns.length > 0) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="uppercase tracking-wide text-[10px]">Recent runs</span>
|
||||
{latestRun && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRunId(latestRun._id)}
|
||||
className={`rounded-full border px-2 py-1 text-xs transition ${
|
||||
selectedRunId === latestRun._id
|
||||
? "border-foreground/60 bg-muted/60 text-foreground"
|
||||
: "border-border/60 hover:border-foreground/30 hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
{formatRunTitle(latestRun)}
|
||||
</button>
|
||||
)}
|
||||
{recentCompletedRuns.map((run: any) => (
|
||||
<button
|
||||
key={run._id}
|
||||
type="button"
|
||||
onClick={() => setSelectedRunId(run._id)}
|
||||
className={`rounded-full border px-2 py-1 text-xs transition ${
|
||||
selectedRunId === run._id
|
||||
? "border-foreground/60 bg-muted/60 text-foreground"
|
||||
: "border-border/60 hover:border-foreground/30 hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
{formatRunTitle(run)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSources && selectedSourceIds.length === 0 && (
|
||||
@@ -1004,6 +1182,7 @@ export default function OpportunitiesPage() {
|
||||
<TableCell>
|
||||
<p className="font-medium line-clamp-1">{opp.title}</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{opp.snippet}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{getMatchReason(opp)}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
@@ -1027,7 +1206,7 @@ export default function OpportunitiesPage() {
|
||||
<p className="text-muted-foreground">Scanning platforms for opportunities</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-12 text-center">
|
||||
<Card className="p-12 text-center border-0 shadow-none bg-transparent">
|
||||
<Search className="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium">Ready to Search</h3>
|
||||
<p className="text-muted-foreground">Select sources and triggers, then click Run Search</p>
|
||||
@@ -53,7 +53,7 @@ export default function SettingsPage() {
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
setTab(value)
|
||||
router.replace(`/settings?tab=${value}`)
|
||||
router.replace(`/app/settings?tab=${value}`)
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
@@ -26,7 +26,7 @@ export default function AuthPage() {
|
||||
function RedirectToDashboard({ nextPath }: { nextPath: string | null }) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.push(nextPath || "/dashboard");
|
||||
router.push(nextPath || "/app/dashboard");
|
||||
}, [router, nextPath]);
|
||||
|
||||
return (
|
||||
|
||||
211
app/globals.css
211
app/globals.css
@@ -3,32 +3,157 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--radius: 0.5rem;
|
||||
--color-1: 0 100% 63%;
|
||||
--color-2: 270 100% 63%;
|
||||
--color-3: 210 100% 63%;
|
||||
--color-4: 195 100% 63%;
|
||||
--color-5: 90 100% 63%;
|
||||
--background: oklch(0.91 0.05 82.78);
|
||||
--foreground: oklch(0.41 0.08 78.86);
|
||||
--card: oklch(0.92 0.04 84.56);
|
||||
--card-foreground: oklch(0.41 0.08 74.04);
|
||||
--popover: oklch(0.92 0.04 84.56);
|
||||
--popover-foreground: oklch(0.41 0.08 74.04);
|
||||
--primary: oklch(0.71 0.10 111.96);
|
||||
--primary-foreground: oklch(0.98 0.01 2.18);
|
||||
--secondary: oklch(0.88 0.05 83.32);
|
||||
--secondary-foreground: oklch(0.51 0.08 78.21);
|
||||
--muted: oklch(0.86 0.06 82.94);
|
||||
--muted-foreground: oklch(0.51 0.08 74.78);
|
||||
--accent: oklch(0.86 0.05 85.12);
|
||||
--accent-foreground: oklch(0.26 0.02 356.72);
|
||||
--destructive: oklch(0.63 0.24 29.21);
|
||||
--border: oklch(0.74 0.06 79.64);
|
||||
--input: oklch(0.74 0.06 79.64);
|
||||
--ring: oklch(0.51 0.08 74.78);
|
||||
--chart-1: oklch(0.66 0.19 41.68);
|
||||
--chart-2: oklch(0.70 0.12 183.58);
|
||||
--chart-3: oklch(0.48 0.08 211.35);
|
||||
--chart-4: oklch(0.84 0.17 84.99);
|
||||
--chart-5: oklch(0.74 0.17 60.02);
|
||||
--sidebar: oklch(0.87 0.06 84.46);
|
||||
--sidebar-foreground: oklch(0.41 0.08 78.86);
|
||||
--sidebar-primary: oklch(0.26 0.02 356.72);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.01 2.18);
|
||||
--sidebar-accent: oklch(0.83 0.06 84.44);
|
||||
--sidebar-accent-foreground: oklch(0.26 0.02 356.72);
|
||||
--sidebar-border: oklch(0.91 0 0);
|
||||
--sidebar-ring: oklch(0.71 0 0);
|
||||
|
||||
--font-sans: Nunito, sans-serif;
|
||||
--font-serif: PT Serif, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
|
||||
--radius: 0.625rem;
|
||||
|
||||
--shadow-2xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
|
||||
0 1px 2px -1px oklch(0.00 0 0 / 0.10);
|
||||
--shadow: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
|
||||
0 1px 2px -1px oklch(0.00 0 0 / 0.10);
|
||||
--shadow-md: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
|
||||
0 2px 4px -1px oklch(0.00 0 0 / 0.10);
|
||||
--shadow-lg: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
|
||||
0 4px 6px -1px oklch(0.00 0 0 / 0.10);
|
||||
--shadow-xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
|
||||
0 8px 10px -1px oklch(0.00 0 0 / 0.10);
|
||||
--shadow-2xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.20 0.01 52.89);
|
||||
--foreground: oklch(0.88 0.05 79.11);
|
||||
--card: oklch(0.25 0.01 48.28);
|
||||
--card-foreground: oklch(0.88 0.05 79.11);
|
||||
--popover: oklch(0.25 0.01 48.28);
|
||||
--popover-foreground: oklch(0.88 0.05 79.11);
|
||||
--primary: oklch(0.64 0.05 114.58);
|
||||
--primary-foreground: oklch(0.98 0.01 2.18);
|
||||
--secondary: oklch(0.33 0.02 60.70);
|
||||
--secondary-foreground: oklch(0.88 0.05 83.32);
|
||||
--muted: oklch(0.27 0.01 39.35);
|
||||
--muted-foreground: oklch(0.74 0.06 79.64);
|
||||
--accent: oklch(0.33 0.02 60.70);
|
||||
--accent-foreground: oklch(0.86 0.05 85.12);
|
||||
--destructive: oklch(0.63 0.24 29.21);
|
||||
--border: oklch(0.33 0.02 60.70);
|
||||
--input: oklch(0.33 0.02 60.70);
|
||||
--ring: oklch(0.64 0.05 114.58);
|
||||
--chart-1: oklch(0.66 0.19 41.68);
|
||||
--chart-2: oklch(0.70 0.12 183.58);
|
||||
--chart-3: oklch(0.48 0.08 211.35);
|
||||
--chart-4: oklch(0.84 0.17 84.99);
|
||||
--chart-5: oklch(0.74 0.17 60.02);
|
||||
--sidebar: oklch(0.23 0.01 60.90);
|
||||
--sidebar-foreground: oklch(0.88 0.05 79.11);
|
||||
--sidebar-primary: oklch(0.64 0.05 114.58);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.01 2.18);
|
||||
--sidebar-accent: oklch(0.33 0.02 60.70);
|
||||
--sidebar-accent-foreground: oklch(0.86 0.05 85.12);
|
||||
--sidebar-border: oklch(0.33 0.02 60.70);
|
||||
--sidebar-ring: oklch(0.64 0.05 114.58);
|
||||
|
||||
--shadow-2xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
|
||||
0 1px 2px -1px oklch(0.00 0 0 / 0.10);
|
||||
--shadow: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
|
||||
0 1px 2px -1px oklch(0.00 0 0 / 0.10);
|
||||
--shadow-md: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
|
||||
0 2px 4px -1px oklch(0.00 0 0 / 0.10);
|
||||
--shadow-lg: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
|
||||
0 4px 6px -1px oklch(0.00 0 0 / 0.10);
|
||||
--shadow-xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
|
||||
0 8px 10px -1px oklch(0.00 0 0 / 0.10);
|
||||
--shadow-2xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.25);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -42,24 +167,24 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-background: var(--sidebar);
|
||||
--sidebar-foreground: var(--sidebar-foreground);
|
||||
--sidebar-primary: var(--sidebar-primary);
|
||||
--sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--sidebar-accent: var(--sidebar-accent);
|
||||
--sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--sidebar-border: var(--sidebar-border);
|
||||
--sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-background: var(--sidebar);
|
||||
--sidebar-foreground: var(--sidebar-foreground);
|
||||
--sidebar-primary: var(--sidebar-primary);
|
||||
--sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--sidebar-accent: var(--sidebar-accent);
|
||||
--sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--sidebar-border: var(--sidebar-border);
|
||||
--sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,14 +150,16 @@ export default function OnboardingPage() {
|
||||
localStorage.setItem('productAnalysis', JSON.stringify(data.data))
|
||||
localStorage.setItem('analysisStats', JSON.stringify(data.stats))
|
||||
|
||||
setProgress('Saving analysis...')
|
||||
await persistAnalysis({
|
||||
analysis: data.data,
|
||||
sourceUrl: url,
|
||||
sourceName: data.data.productName,
|
||||
projectId,
|
||||
dataSourceId: sourceId,
|
||||
})
|
||||
if (!data.persisted) {
|
||||
setProgress('Saving analysis...')
|
||||
await persistAnalysis({
|
||||
analysis: data.data,
|
||||
sourceUrl: url,
|
||||
sourceName: data.data.productName,
|
||||
projectId,
|
||||
dataSourceId: sourceId,
|
||||
})
|
||||
}
|
||||
|
||||
setPendingSourceId(null)
|
||||
setPendingProjectId(null)
|
||||
@@ -167,7 +169,7 @@ export default function OnboardingPage() {
|
||||
|
||||
// Redirect to dashboard with product name in query
|
||||
const params = new URLSearchParams({ product: data.data.productName })
|
||||
router.push(`/dashboard?${params.toString()}`)
|
||||
router.push(`/app/dashboard?${params.toString()}`)
|
||||
} catch (err: any) {
|
||||
console.error('Analysis error:', err)
|
||||
setError(err.message || 'Failed to analyze website')
|
||||
@@ -277,9 +279,11 @@ export default function OnboardingPage() {
|
||||
|
||||
let finalAnalysis = manualAnalysis
|
||||
|
||||
let persisted = false
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
finalAnalysis = data.data
|
||||
persisted = Boolean(data.persisted)
|
||||
}
|
||||
|
||||
// Store in localStorage for dashboard
|
||||
@@ -293,18 +297,20 @@ export default function OnboardingPage() {
|
||||
dorkQueries: finalAnalysis.dorkQueries.length
|
||||
}))
|
||||
|
||||
setProgress('Saving analysis...')
|
||||
const manualSourceUrl = pendingSourceId
|
||||
? 'manual-input'
|
||||
: `manual:${finalAnalysis.productName}`
|
||||
if (!persisted) {
|
||||
setProgress('Saving analysis...')
|
||||
const manualSourceUrl = pendingSourceId
|
||||
? 'manual-input'
|
||||
: `manual:${finalAnalysis.productName}`
|
||||
|
||||
await persistAnalysis({
|
||||
analysis: finalAnalysis,
|
||||
sourceUrl: manualSourceUrl,
|
||||
sourceName: finalAnalysis.productName,
|
||||
projectId: resolvedProjectId || undefined,
|
||||
dataSourceId: resolvedSourceId || undefined,
|
||||
})
|
||||
await persistAnalysis({
|
||||
analysis: finalAnalysis,
|
||||
sourceUrl: manualSourceUrl,
|
||||
sourceName: finalAnalysis.productName,
|
||||
projectId: resolvedProjectId || undefined,
|
||||
dataSourceId: resolvedSourceId || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
setPendingSourceId(null)
|
||||
setPendingProjectId(null)
|
||||
@@ -312,7 +318,7 @@ export default function OnboardingPage() {
|
||||
|
||||
// Redirect to dashboard
|
||||
const params = new URLSearchParams({ product: finalAnalysis.productName })
|
||||
router.push(`/dashboard?${params.toString()}`)
|
||||
router.push(`/app/dashboard?${params.toString()}`)
|
||||
} catch (err: any) {
|
||||
console.error('Manual analysis error:', err)
|
||||
setError(err.message || 'Failed to analyze')
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function LandingPage() {
|
||||
<span className="font-semibold text-foreground tracking-tight">Sanati</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link href="/dashboard" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Link href="/app/dashboard" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link href="/auth">
|
||||
|
||||
@@ -98,8 +98,8 @@ export function SectionEditor({
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 px-4 py-3">
|
||||
<CardTitle className="text-sm">{title}</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsAddOpen(true)}>
|
||||
Add
|
||||
@@ -109,17 +109,17 @@ export function SectionEditor({
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<CardContent className="max-h-[260px] space-y-2 overflow-auto px-4 pb-4 text-xs">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-muted-foreground">No items yet.</div>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
key={`${sectionKey}-${index}`}
|
||||
className="flex flex-wrap items-start justify-between gap-2 rounded-md border border-border/60 p-3"
|
||||
className="flex flex-wrap items-start justify-between gap-2 rounded-md border border-border/60 p-2"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">{summarizeItem(item)}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-sm">{summarizeItem(item)}</div>
|
||||
<Badge variant="outline">#{index + 1}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
@@ -246,8 +246,8 @@ export function ProfileSectionEditor({
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">Product Profile</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 px-4 py-3">
|
||||
<CardTitle className="text-sm">Product Profile</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditOpen(true)}>
|
||||
Edit
|
||||
@@ -257,7 +257,7 @@ export function ProfileSectionEditor({
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<CardContent className="max-h-[260px] space-y-2 overflow-auto px-4 pb-4 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Product:</span>{" "}
|
||||
{items.productName || "Not set"}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Settings,
|
||||
Terminal,
|
||||
Target,
|
||||
Inbox,
|
||||
Plus,
|
||||
ArrowUpRight,
|
||||
ChevronsUpDown
|
||||
@@ -87,6 +88,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const [pendingSourceId, setPendingSourceId] = React.useState<string | null>(null);
|
||||
const [pendingProjectId, setPendingProjectId] = React.useState<string | null>(null);
|
||||
const [pendingJobId, setPendingJobId] = React.useState<string | null>(null);
|
||||
const sourceUrlRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const sourceNameRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const manualProductNameRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const manualDescriptionRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const analysisJob = useQuery(
|
||||
api.analysisJobs.getById,
|
||||
pendingJobId ? { jobId: pendingJobId as any } : "skip"
|
||||
@@ -189,18 +194,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
throw new Error(data.error || "Analysis failed");
|
||||
}
|
||||
|
||||
await createAnalysis({
|
||||
projectId: result.projectId,
|
||||
dataSourceId: result.sourceId,
|
||||
analysis: data.data,
|
||||
});
|
||||
if (!data.persisted) {
|
||||
await createAnalysis({
|
||||
projectId: result.projectId,
|
||||
dataSourceId: result.sourceId,
|
||||
analysis: data.data,
|
||||
});
|
||||
|
||||
await updateDataSourceStatus({
|
||||
dataSourceId: result.sourceId,
|
||||
analysisStatus: "completed",
|
||||
lastError: undefined,
|
||||
lastAnalyzedAt: Date.now(),
|
||||
});
|
||||
await updateDataSourceStatus({
|
||||
dataSourceId: result.sourceId,
|
||||
analysisStatus: "completed",
|
||||
lastError: undefined,
|
||||
lastAnalyzedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
setSourceUrl("");
|
||||
setSourceName("");
|
||||
@@ -258,18 +265,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
throw new Error(data.error || "Manual analysis failed");
|
||||
}
|
||||
|
||||
await createAnalysis({
|
||||
projectId: pendingProjectId as any,
|
||||
dataSourceId: pendingSourceId as any,
|
||||
analysis: data.data,
|
||||
});
|
||||
if (!data.persisted) {
|
||||
await createAnalysis({
|
||||
projectId: pendingProjectId as any,
|
||||
dataSourceId: pendingSourceId as any,
|
||||
analysis: data.data,
|
||||
});
|
||||
|
||||
await updateDataSourceStatus({
|
||||
dataSourceId: pendingSourceId as any,
|
||||
analysisStatus: "completed",
|
||||
lastError: undefined,
|
||||
lastAnalyzedAt: Date.now(),
|
||||
});
|
||||
await updateDataSourceStatus({
|
||||
dataSourceId: pendingSourceId as any,
|
||||
analysisStatus: "completed",
|
||||
lastError: undefined,
|
||||
lastAnalyzedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
setSourceUrl("");
|
||||
setSourceName("");
|
||||
@@ -288,6 +297,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputEnter = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
next?: React.RefObject<HTMLElement>,
|
||||
onSubmit?: () => void
|
||||
) => {
|
||||
if (event.key !== "Enter" || isSubmittingSource) return;
|
||||
event.preventDefault();
|
||||
if (next?.current) {
|
||||
next.current.focus();
|
||||
return;
|
||||
}
|
||||
onSubmit?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset" {...props}>
|
||||
<SidebarHeader>
|
||||
@@ -365,9 +388,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip="Overview"
|
||||
isActive={pathname === "/dashboard"}
|
||||
isActive={pathname === "/app/dashboard"}
|
||||
>
|
||||
<Link href="/dashboard">
|
||||
<Link href="/app/dashboard">
|
||||
<Terminal />
|
||||
<span>Overview</span>
|
||||
</Link>
|
||||
@@ -377,9 +400,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip="Search"
|
||||
isActive={pathname === "/opportunities"}
|
||||
isActive={pathname === "/app/search"}
|
||||
>
|
||||
<Link href="/opportunities">
|
||||
<Link href="/app/search">
|
||||
<Target />
|
||||
<span>Search</span>
|
||||
</Link>
|
||||
@@ -389,9 +412,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip="Inbox"
|
||||
isActive={pathname === "/leads"}
|
||||
isActive={pathname === "/app/inbox"}
|
||||
>
|
||||
<Link href="/leads" className="pl-8 text-sm text-muted-foreground hover:text-foreground">
|
||||
<Link href="/app/inbox">
|
||||
<Inbox />
|
||||
<span>Inbox</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
@@ -431,7 +455,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</Label>
|
||||
</div>
|
||||
<Link
|
||||
href={`/data-sources/${source._id}`}
|
||||
href={`/app/data-sources/${source._id}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Details
|
||||
@@ -508,7 +532,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
placeholder="https://example.com"
|
||||
value={sourceUrl}
|
||||
onChange={(event) => setSourceUrl(event.target.value)}
|
||||
onKeyDown={(event) => handleInputEnter(event, sourceNameRef)}
|
||||
disabled={isSubmittingSource}
|
||||
ref={sourceUrlRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -518,7 +544,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
placeholder="Product name"
|
||||
value={sourceName}
|
||||
onChange={(event) => setSourceName(event.target.value)}
|
||||
onKeyDown={(event) => handleInputEnter(event, undefined, handleAddSource)}
|
||||
disabled={isSubmittingSource}
|
||||
ref={sourceNameRef}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -531,7 +559,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
id="manualProductName"
|
||||
value={manualProductName}
|
||||
onChange={(event) => setManualProductName(event.target.value)}
|
||||
onKeyDown={(event) => handleInputEnter(event, manualDescriptionRef)}
|
||||
disabled={isSubmittingSource}
|
||||
ref={manualProductNameRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -542,6 +572,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
onChange={(event) => setManualDescription(event.target.value)}
|
||||
disabled={isSubmittingSource}
|
||||
rows={3}
|
||||
ref={manualDescriptionRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -23,7 +23,7 @@ export function SignIn() {
|
||||
try {
|
||||
const flow = step === "signIn" ? "signIn" : "signUp";
|
||||
await signIn("password", { email, password, flow });
|
||||
const next = nextPath || (flow === "signIn" ? "/dashboard" : "/onboarding");
|
||||
const next = nextPath || (flow === "signIn" ? "/app/dashboard" : "/onboarding");
|
||||
if (flow === "signIn") {
|
||||
router.push(next);
|
||||
} else {
|
||||
@@ -41,7 +41,7 @@ export function SignIn() {
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
const next = nextPath || "/dashboard";
|
||||
const next = nextPath || "/app/dashboard";
|
||||
void signIn("google", { redirectTo: next });
|
||||
};
|
||||
|
||||
|
||||
@@ -100,24 +100,24 @@ export function NavUser({
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={() => router.push("/settings?tab=upgrade")}>
|
||||
<DropdownMenuItem onSelect={() => router.push("/app/settings?tab=upgrade")}>
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={() => router.push("/settings?tab=account")}>
|
||||
<DropdownMenuItem onSelect={() => router.push("/app/settings?tab=account")}>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => router.push("/settings?tab=billing")}>
|
||||
<DropdownMenuItem onSelect={() => router.push("/app/settings?tab=billing")}>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => router.push("/help")}>
|
||||
<DropdownMenuItem onSelect={() => router.push("/app/help")}>
|
||||
<HelpCircle />
|
||||
Support
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -24,8 +24,8 @@ export function Sidebar({ productName }: SidebarProps) {
|
||||
{
|
||||
label: 'Overview',
|
||||
icon: LayoutDashboard,
|
||||
href: '/dashboard',
|
||||
active: pathname === '/dashboard',
|
||||
href: '/app/dashboard',
|
||||
active: pathname === '/app/dashboard',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -68,10 +68,10 @@ export function Sidebar({ productName }: SidebarProps) {
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href="/opportunities"
|
||||
href="/app/search"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
pathname === '/opportunities'
|
||||
pathname === '/app/search'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
@@ -80,10 +80,10 @@ export function Sidebar({ productName }: SidebarProps) {
|
||||
Search
|
||||
</Link>
|
||||
<Link
|
||||
href="/leads"
|
||||
href="/app/inbox"
|
||||
className={cn(
|
||||
'flex items-center rounded-md px-3 py-2 pl-9 text-sm font-medium transition-colors',
|
||||
pathname === '/leads'
|
||||
pathname === '/app/inbox'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
|
||||
@@ -126,3 +126,29 @@ export const listByProject = query({
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
|
||||
export const getLatestByDataSource = query({
|
||||
args: {
|
||||
dataSourceId: v.id("dataSources"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return null;
|
||||
|
||||
const dataSource = await ctx.db.get(args.dataSourceId);
|
||||
if (!dataSource) return null;
|
||||
|
||||
const project = await ctx.db.get(dataSource.projectId);
|
||||
if (!project || project.userId !== userId) return null;
|
||||
|
||||
const jobs = await ctx.db
|
||||
.query("analysisJobs")
|
||||
.withIndex("by_dataSource_createdAt", (q) =>
|
||||
q.eq("dataSourceId", args.dataSourceId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(1);
|
||||
|
||||
return jobs[0] ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ export const listByProject = query({
|
||||
projectId: v.id("projects"),
|
||||
status: v.optional(v.string()),
|
||||
intent: v.optional(v.string()),
|
||||
searchJobId: v.optional(v.id("searchJobs")),
|
||||
minScore: v.optional(v.number()),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
@@ -31,17 +32,23 @@ export const listByProject = query({
|
||||
if (!project || project.userId !== userId) return [];
|
||||
|
||||
const limit = args.limit ?? 50;
|
||||
let queryBuilder = args.status
|
||||
let queryBuilder = args.searchJobId
|
||||
? ctx.db
|
||||
.query("opportunities")
|
||||
.withIndex("by_project_status", (q) =>
|
||||
q.eq("projectId", args.projectId).eq("status", args.status!)
|
||||
.withIndex("by_project_searchJob", (q) =>
|
||||
q.eq("projectId", args.projectId).eq("searchJobId", args.searchJobId!)
|
||||
)
|
||||
: ctx.db
|
||||
.query("opportunities")
|
||||
.withIndex("by_project_createdAt", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
);
|
||||
: args.status
|
||||
? ctx.db
|
||||
.query("opportunities")
|
||||
.withIndex("by_project_status", (q) =>
|
||||
q.eq("projectId", args.projectId).eq("status", args.status!)
|
||||
)
|
||||
: ctx.db
|
||||
.query("opportunities")
|
||||
.withIndex("by_project_createdAt", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
);
|
||||
|
||||
if (args.intent) {
|
||||
queryBuilder = queryBuilder.filter((q) =>
|
||||
@@ -64,6 +71,7 @@ export const upsertBatch = mutation({
|
||||
args: {
|
||||
projectId: v.id("projects"),
|
||||
analysisId: v.optional(v.id("analyses")),
|
||||
searchJobId: v.optional(v.id("searchJobs")),
|
||||
opportunities: v.array(opportunityInput),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -90,6 +98,7 @@ export const upsertBatch = mutation({
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
analysisId: args.analysisId,
|
||||
searchJobId: args.searchJobId,
|
||||
platform: opp.platform,
|
||||
title: opp.title,
|
||||
snippet: opp.snippet,
|
||||
@@ -106,6 +115,7 @@ export const upsertBatch = mutation({
|
||||
await ctx.db.insert("opportunities", {
|
||||
projectId: args.projectId,
|
||||
analysisId: args.analysisId,
|
||||
searchJobId: args.searchJobId,
|
||||
url: opp.url,
|
||||
platform: opp.platform,
|
||||
title: opp.title,
|
||||
@@ -169,11 +179,81 @@ export const updateStatus = mutation({
|
||||
throw new Error("Project not found or unauthorized");
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.id, {
|
||||
const now = Date.now();
|
||||
const patch: Record<string, unknown> = {
|
||||
status: args.status,
|
||||
notes: args.notes,
|
||||
tags: args.tags,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
updatedAt: now,
|
||||
};
|
||||
if (args.status === "sent") patch.sentAt = now;
|
||||
if (args.status === "archived") patch.archivedAt = now;
|
||||
await ctx.db.patch(args.id, patch);
|
||||
},
|
||||
});
|
||||
|
||||
export const countByDay = query({
|
||||
args: {
|
||||
projectId: v.id("projects"),
|
||||
days: v.optional(v.number()),
|
||||
metric: v.union(
|
||||
v.literal("created"),
|
||||
v.literal("sent"),
|
||||
v.literal("archived")
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return [];
|
||||
|
||||
const project = await ctx.db.get(args.projectId);
|
||||
if (!project || project.userId !== userId) return [];
|
||||
|
||||
const days = args.days ?? 14;
|
||||
const now = Date.now();
|
||||
const start = now - days * 24 * 60 * 60 * 1000;
|
||||
|
||||
const results = await ctx.db
|
||||
.query("opportunities")
|
||||
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
|
||||
.order("desc")
|
||||
.collect();
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const toDateKey = (timestamp: number) =>
|
||||
new Date(timestamp).toISOString().slice(0, 10);
|
||||
|
||||
for (const opp of results) {
|
||||
let timestamp: number | null = null;
|
||||
if (args.metric === "created") {
|
||||
timestamp = opp.createdAt;
|
||||
} else if (args.metric === "sent") {
|
||||
timestamp =
|
||||
opp.sentAt ??
|
||||
(opp.status === "sent" || opp.status === "converted"
|
||||
? opp.updatedAt
|
||||
: null);
|
||||
} else if (args.metric === "archived") {
|
||||
timestamp =
|
||||
opp.archivedAt ??
|
||||
(opp.status === "archived" || opp.status === "ignored"
|
||||
? opp.updatedAt
|
||||
: null);
|
||||
}
|
||||
|
||||
if (!timestamp || timestamp < start) continue;
|
||||
const key = toDateKey(timestamp);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const series = [];
|
||||
for (let i = days - 1; i >= 0; i -= 1) {
|
||||
const date = new Date(now - i * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
series.push({ date, count: counts.get(date) ?? 0 });
|
||||
}
|
||||
|
||||
return series;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -138,6 +138,7 @@ const schema = defineSchema({
|
||||
opportunities: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
analysisId: v.optional(v.id("analyses")),
|
||||
searchJobId: v.optional(v.id("searchJobs")),
|
||||
url: v.string(),
|
||||
platform: v.string(),
|
||||
title: v.string(),
|
||||
@@ -145,6 +146,8 @@ const schema = defineSchema({
|
||||
relevanceScore: v.number(),
|
||||
intent: v.string(),
|
||||
status: v.string(),
|
||||
sentAt: v.optional(v.number()),
|
||||
archivedAt: v.optional(v.number()),
|
||||
suggestedApproach: v.string(),
|
||||
matchedKeywords: v.array(v.string()),
|
||||
matchedProblems: v.array(v.string()),
|
||||
@@ -156,7 +159,14 @@ const schema = defineSchema({
|
||||
})
|
||||
.index("by_project_status", ["projectId", "status"])
|
||||
.index("by_project_createdAt", ["projectId", "createdAt"])
|
||||
.index("by_project_url", ["projectId", "url"]),
|
||||
.index("by_project_url", ["projectId", "url"])
|
||||
.index("by_project_searchJob", ["projectId", "searchJobId"]),
|
||||
userActivity: defineTable({
|
||||
userId: v.id("users"),
|
||||
lastActiveDate: v.string(),
|
||||
streak: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_user", ["userId"]),
|
||||
seenUrls: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
url: v.string(),
|
||||
@@ -193,7 +203,8 @@ const schema = defineSchema({
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project_status", ["projectId", "status"])
|
||||
.index("by_project_createdAt", ["projectId", "createdAt"]),
|
||||
.index("by_project_createdAt", ["projectId", "createdAt"])
|
||||
.index("by_dataSource_createdAt", ["dataSourceId", "createdAt"]),
|
||||
searchJobs: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
status: v.union(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { query } from "./_generated/server";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
|
||||
export const getCurrent = query({
|
||||
@@ -24,3 +25,61 @@ export const getCurrentProfile = query({
|
||||
return { user, accounts };
|
||||
},
|
||||
});
|
||||
|
||||
export const touch = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return null;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("userActivity")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.first();
|
||||
|
||||
if (!existing) {
|
||||
await ctx.db.insert("userActivity", {
|
||||
userId,
|
||||
lastActiveDate: today,
|
||||
streak: 1,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { lastActiveDate: today, streak: 1 };
|
||||
}
|
||||
|
||||
if (existing.lastActiveDate === today) {
|
||||
return { lastActiveDate: existing.lastActiveDate, streak: existing.streak };
|
||||
}
|
||||
|
||||
const streak =
|
||||
existing.lastActiveDate === yesterday ? existing.streak + 1 : 1;
|
||||
|
||||
await ctx.db.patch(existing._id, {
|
||||
lastActiveDate: today,
|
||||
streak,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
return { lastActiveDate: today, streak };
|
||||
},
|
||||
});
|
||||
|
||||
export const getActivity = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return null;
|
||||
|
||||
const activity = await ctx.db
|
||||
.query("userActivity")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.first();
|
||||
|
||||
return activity ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
## Scope
|
||||
- Choose a single sidebar layout system.
|
||||
- Move or consolidate routes so `/dashboard`, `/opportunities`, and other app pages live under the same layout.
|
||||
- Move or consolidate routes so `/app/dashboard`, `/app/search`, and other app pages live under the same layout.
|
||||
- Remove unused layout/components or mark deprecated ones.
|
||||
- Extend Convex schema to support analysis + opportunity storage.
|
||||
- Add indices required for efficient queries.
|
||||
@@ -21,7 +21,7 @@
|
||||
- Remove or archive the unused layout and sidebar component to avoid confusion.
|
||||
|
||||
2. **Route structure alignment**
|
||||
- Ensure `/dashboard`, `/opportunities`, `/settings`, `/help` sit under the chosen layout.
|
||||
- Ensure `/app/dashboard`, `/app/search`, `/app/settings`, `/app/help` sit under the chosen layout.
|
||||
- Update sidebar links to match actual routes.
|
||||
- Confirm middleware protects all app routes consistently.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- Add verification steps and basic test coverage.
|
||||
|
||||
## Scope
|
||||
- Implement `/settings` and `/help` pages.
|
||||
- Implement `/app/settings` and `/app/help` pages.
|
||||
- Add progress and error handling improvements for analysis/search.
|
||||
- Document manual QA checklist.
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
- Depends on Phases 1–3 to stabilize core flows.
|
||||
|
||||
## Acceptance Criteria
|
||||
- `/settings` and `/help` routes exist and are linked.
|
||||
- `/app/settings` and `/app/help` routes exist and are linked.
|
||||
- Background tasks reduce timeouts and improve UX.
|
||||
- QA checklist is documented and executable.
|
||||
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
- Load more increases result count.
|
||||
|
||||
## Settings / Help
|
||||
- `/settings` and `/help` load under app layout.
|
||||
- `/app/settings` and `/app/help` load under app layout.
|
||||
- Sidebar links navigate correctly.
|
||||
|
||||
@@ -8,14 +8,37 @@ import { NextResponse } from "next/server";
|
||||
|
||||
const isSignInPage = createRouteMatcher(["/auth"]);
|
||||
const isProtectedPage = createRouteMatcher([
|
||||
"/dashboard(.*)",
|
||||
"/app(.*)",
|
||||
"/onboarding(.*)",
|
||||
"/opportunities(.*)",
|
||||
]);
|
||||
|
||||
export default convexAuthNextjsMiddleware(async (request) => {
|
||||
const { pathname, search } = request.nextUrl;
|
||||
if (pathname === "/app" || pathname === "/app/") {
|
||||
return NextResponse.redirect(new URL(`/app/dashboard${search || ""}`, request.url));
|
||||
}
|
||||
const legacyRedirects: Record<string, string> = {
|
||||
"/dashboard": "/app/dashboard",
|
||||
"/search": "/app/search",
|
||||
"/inbox": "/app/inbox",
|
||||
"/settings": "/app/settings",
|
||||
"/data-sources": "/app/data-sources",
|
||||
"/help": "/app/help",
|
||||
"/leads": "/app/inbox",
|
||||
"/opportunities": "/app/search",
|
||||
};
|
||||
const legacyMatch = Object.keys(legacyRedirects).find((path) =>
|
||||
pathname === path || pathname.startsWith(`${path}/`)
|
||||
);
|
||||
if (legacyMatch) {
|
||||
const targetBase = legacyRedirects[legacyMatch];
|
||||
const suffix = pathname.slice(legacyMatch.length);
|
||||
const target = `${targetBase}${suffix}${search || ""}`;
|
||||
return NextResponse.redirect(new URL(target, request.url));
|
||||
}
|
||||
|
||||
if (isSignInPage(request) && (await isAuthenticatedNextjs())) {
|
||||
return nextjsMiddlewareRedirect(request, "/dashboard");
|
||||
return nextjsMiddlewareRedirect(request, "/app/dashboard");
|
||||
}
|
||||
if (isProtectedPage(request) && !(await isAuthenticatedNextjs())) {
|
||||
const nextUrl = new URL("/auth", request.url);
|
||||
|
||||
@@ -18,48 +18,48 @@ const config: Config = {
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
border: 'var(--border)',
|
||||
input: 'var(--input)',
|
||||
ring: 'var(--ring)',
|
||||
background: 'var(--background)',
|
||||
foreground: 'var(--foreground)',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
DEFAULT: 'var(--primary)',
|
||||
foreground: 'var(--primary-foreground)'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
DEFAULT: 'var(--secondary)',
|
||||
foreground: 'var(--secondary-foreground)'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
DEFAULT: 'var(--destructive)',
|
||||
foreground: 'var(--destructive-foreground)'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
DEFAULT: 'var(--muted)',
|
||||
foreground: 'var(--muted-foreground)'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
DEFAULT: 'var(--accent)',
|
||||
foreground: 'var(--accent-foreground)'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
DEFAULT: 'var(--popover)',
|
||||
foreground: 'var(--popover-foreground)'
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
DEFAULT: 'var(--card)',
|
||||
foreground: 'var(--card-foreground)'
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
foreground: 'hsl(var(--sidebar-foreground))',
|
||||
primary: 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
accent: 'hsl(var(--sidebar-accent))',
|
||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||
border: 'hsl(var(--sidebar-border))',
|
||||
ring: 'hsl(var(--sidebar-ring))'
|
||||
DEFAULT: 'var(--sidebar-background)',
|
||||
foreground: 'var(--sidebar-foreground)',
|
||||
primary: 'var(--sidebar-primary)',
|
||||
'primary-foreground': 'var(--sidebar-primary-foreground)',
|
||||
accent: 'var(--sidebar-accent)',
|
||||
'accent-foreground': 'var(--sidebar-accent-foreground)',
|
||||
border: 'var(--sidebar-border)',
|
||||
ring: 'var(--sidebar-ring)'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
|
||||
Reference in New Issue
Block a user