lots of changes

This commit is contained in:
2026-02-04 11:18:33 +00:00
parent d02d95e680
commit 4fdbfb0fb3
30 changed files with 1796 additions and 822 deletions

39
AGENTS.md Normal file
View 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`.

View File

@@ -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&apos;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>
)
}

View File

@@ -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>
)
}

View File

@@ -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.
}

View File

@@ -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.
}

View 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>
)
}

View File

@@ -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 cant 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}
>

View File

@@ -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>

View 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>
)
}

View File

@@ -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>

View File

@@ -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"
>

View File

@@ -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 (

View File

@@ -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);
}
}
}

View File

@@ -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')

View File

@@ -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">

View File

@@ -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"}

View File

@@ -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">

View File

@@ -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 });
};

View File

@@ -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>

View File

@@ -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'
)}

View File

@@ -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;
},
});

View File

@@ -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;
},
});

View File

@@ -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(

View File

@@ -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;
},
});

View File

@@ -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.

View File

@@ -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 13 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.

View File

@@ -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.

View File

@@ -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);

View File

@@ -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: {