This commit is contained in:
2026-02-04 01:05:00 +00:00
parent f9222627ef
commit d02d95e680
30 changed files with 2449 additions and 326 deletions

View File

@@ -153,19 +153,19 @@ export default function Page() {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Features</CardTitle>
<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">Keywords</CardTitle>
<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">Personas</CardTitle>
<CardTitle className="text-sm">Target Users</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">{analysis.personas.length}</CardContent>
</Card>
@@ -185,7 +185,7 @@ export default function Page() {
<Card>
<CardHeader>
<CardTitle className="text-base">Data Sources</CardTitle>
<CardTitle className="text-base">Sources</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
{dataSources && dataSources.length > 0 ? (
@@ -219,7 +219,7 @@ export default function Page() {
{analysisJobs && analysisJobs.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Analysis Jobs</CardTitle>
<CardTitle className="text-base">Runs</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
{analysisJobs.slice(0, 5).map((job: any) => {
@@ -269,7 +269,7 @@ export default function Page() {
{searchContext?.context && (
<Card>
<CardHeader>
<CardTitle className="text-base">Aggregated Context</CardTitle>
<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>
@@ -283,7 +283,7 @@ export default function Page() {
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Top Features</CardTitle>
<CardTitle className="text-base">Key Features</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{analysis.features.slice(0, 6).map((feature, index) => (
@@ -299,7 +299,7 @@ export default function Page() {
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Top Problems Solved</CardTitle>
<CardTitle className="text-base">Top Problems</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{analysis.problemsSolved.slice(0, 6).map((problem, index) => (

View File

@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dialog"
import { Settings } from "lucide-react"
import * as React from "react"
import { ProfileSectionEditor, SectionEditor } from "@/components/analysis-section-editor"
function formatDate(timestamp?: number) {
if (!timestamp) return "Not analyzed yet";
@@ -34,10 +35,22 @@ export default function DataSourceDetailPage() {
api.analyses.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 [isDeleting, setIsDeleting] = React.useState(false)
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
const sectionMap = React.useMemo(() => {
const map = new Map<string, any>()
sections?.forEach((section: any) => {
map.set(section.sectionKey, section.items)
})
return map
}, [sections])
if (dataSource === undefined) {
return (
<div className="p-8">
@@ -49,7 +62,7 @@ export default function DataSourceDetailPage() {
if (!dataSource) {
return (
<div className="p-8">
<h1 className="text-2xl font-semibold">Data source not found</h1>
<h1 className="text-2xl font-semibold">Data source not found</h1>
<p className="mt-2 text-sm text-muted-foreground">
This data source may have been removed or you no longer have access.
</p>
@@ -101,7 +114,7 @@ export default function DataSourceDetailPage() {
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Features</CardTitle>
<CardTitle className="text-sm">Key Features</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">
{analysis.features.length}
@@ -109,7 +122,7 @@ export default function DataSourceDetailPage() {
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Keywords</CardTitle>
<CardTitle className="text-sm">Search Keywords</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">
{analysis.keywords.length}
@@ -117,7 +130,7 @@ export default function DataSourceDetailPage() {
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Personas</CardTitle>
<CardTitle className="text-sm">Target Users</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">
{analysis.personas.length}
@@ -127,7 +140,7 @@ export default function DataSourceDetailPage() {
<Card>
<CardHeader>
<CardTitle className="text-base">Overview</CardTitle>
<CardTitle className="text-base">Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div>
@@ -153,7 +166,7 @@ export default function DataSourceDetailPage() {
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Top Features</CardTitle>
<CardTitle className="text-base">Key Features</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{analysis.features.slice(0, 6).map((feature) => (
@@ -168,7 +181,7 @@ export default function DataSourceDetailPage() {
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Pain Points</CardTitle>
<CardTitle className="text-base">Problems</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{analysis.problemsSolved.slice(0, 6).map((problem) => (
@@ -186,7 +199,7 @@ export default function DataSourceDetailPage() {
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Personas</CardTitle>
<CardTitle className="text-base">Target Users</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{analysis.personas.slice(0, 4).map((persona) => (
@@ -203,7 +216,7 @@ export default function DataSourceDetailPage() {
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Keywords</CardTitle>
<CardTitle className="text-base">Search Keywords</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{analysis.keywords.slice(0, 12).map((keyword) => (
@@ -214,11 +227,71 @@ export default function DataSourceDetailPage() {
</CardContent>
</Card>
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold">Edit Sections</h2>
<div className="grid gap-4 lg:grid-cols-2">
<ProfileSectionEditor
analysisId={analysis._id as any}
items={
sectionMap.get("profile") || {
productName: analysis.productName,
tagline: analysis.tagline,
description: analysis.description,
category: analysis.category,
positioning: analysis.positioning,
}
}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="features"
title="Key Features"
items={sectionMap.get("features") || analysis.features}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="competitors"
title="Competitors"
items={sectionMap.get("competitors") || analysis.competitors}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="keywords"
title="Search Keywords"
items={sectionMap.get("keywords") || analysis.keywords}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="problems"
title="Problems"
items={sectionMap.get("problems") || analysis.problemsSolved}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="personas"
title="Target Users"
items={sectionMap.get("personas") || analysis.personas}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="useCases"
title="Use Cases"
items={sectionMap.get("useCases") || analysis.useCases}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="dorkQueries"
title="Search Queries"
items={sectionMap.get("dorkQueries") || analysis.dorkQueries}
/>
</div>
</div>
</>
) : (
<Card>
<CardHeader>
<CardTitle className="text-base">Analysis</CardTitle>
<CardTitle className="text-base">Full Analysis</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
No analysis available yet. Trigger a new analysis to populate this

View File

@@ -6,7 +6,7 @@ export default function HelpPage() {
return (
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-8">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Help</h1>
<h1 className="text-2xl font-semibold">Support</h1>
<p className="text-muted-foreground">Tips for getting the most out of Sanati.</p>
</div>

304
app/(app)/leads/page.tsx Normal file
View File

@@ -0,0 +1,304 @@
'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

@@ -36,20 +36,18 @@ import {
Search,
Loader2,
ExternalLink,
MessageSquare,
Twitter,
Globe,
Users,
HelpCircle,
Filter,
ChevronDown,
ChevronUp,
Settings,
Target,
Zap,
AlertCircle,
BarChart3,
CheckCircle2,
Eye,
Plus,
X,
Users,
Copy
} from 'lucide-react'
import { useProject } from '@/components/project-context'
@@ -90,6 +88,74 @@ const STRATEGY_GROUPS: { title: string; description: string; strategies: SearchS
},
]
function ChannelLogo({ id }: { id: string }) {
const common = "h-4 w-4 text-foreground"
const wrapper = "flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-background"
const icons: Record<string, JSX.Element> = {
reddit: (
<svg viewBox="0 0 24 24" className={common} aria-hidden="true">
<path
fill="currentColor"
d="M14.87 2.5a.75.75 0 0 0-.85.62l-.52 3.24a9.49 9.49 0 0 0-3.86.03L7.7 5.1a.75.75 0 1 0-1.4.53l1.95 5.18A5.98 5.98 0 0 0 6 12.25c0 2.7 2.69 4.88 6 4.88s6-2.18 6-4.88a5.98 5.98 0 0 0-2.17-1.46l1.3-5.1a.75.75 0 1 0-1.46-.37l-1.26 4.94a9.6 9.6 0 0 0-2.32-.28c-.6 0-1.18.06-1.73.17l.35-2.18a.75.75 0 0 0-.62-.85l-.22-.03 4.04-.63a.75.75 0 0 0 .62-.85Z"
/>
<circle cx="9" cy="12.7" r="1.1" fill="currentColor" />
<circle cx="15" cy="12.7" r="1.1" fill="currentColor" />
<path fill="currentColor" d="M8.7 15.4a6 6 0 0 0 6.6 0 .6.6 0 0 0-.7-1 4.8 4.8 0 0 1-5.2 0 .6.6 0 0 0-.7 1Z" />
</svg>
),
twitter: (
<svg viewBox="0 0 24 24" className={common} aria-hidden="true">
<path
fill="currentColor"
d="M17.5 3h3.1l-6.8 7.8L21 21h-5.6l-4.4-5.7L5.9 21H2.8l7.3-8.4L3 3h5.7l4 5.3L17.5 3Zm-1 16h1.7L7.6 4.9H5.8L16.5 19Z"
/>
</svg>
),
hackernews: (
<svg viewBox="0 0 24 24" className={common} aria-hidden="true">
<path fill="currentColor" d="M4 3h16v18H4V3Zm3.2 4.2h2l2.8 4.7 2.8-4.7h2l-3.8 6v3.6h-1.8V13l-4-5.8Z" />
</svg>
),
indiehackers: (
<svg viewBox="0 0 24 24" className={common} aria-hidden="true">
<path fill="currentColor" d="M4 4h16v4H4V4Zm0 6h16v4H4v-4Zm0 6h16v4H4v-4Z" />
</svg>
),
quora: (
<svg viewBox="0 0 24 24" className={common} aria-hidden="true">
<path
fill="currentColor"
d="M12 3.5a8.5 8.5 0 1 0 5.7 14.8l2 2.2a.8.8 0 1 0 1.2-1.1l-2.1-2.3A8.5 8.5 0 0 0 12 3.5Zm0 1.8a6.7 6.7 0 1 1-4.7 11.4 6.7 6.7 0 0 1 4.7-11.4Z"
/>
</svg>
),
stackoverflow: (
<svg viewBox="0 0 24 24" className={common} aria-hidden="true">
<path
fill="currentColor"
d="M17.9 21H7.4v-5.6h1.8v3.8h6.9v-3.8H17.9V21Zm-7.6-6.2.3-1.8 6.9 1.1-.3 1.8-6.9-1.1Zm1-3.7.7-1.7 6.4 2.7-.7 1.7-6.4-2.7Zm2-3.4 1.1-1.4 5.5 4.2-1.1 1.4-5.5-4.2Zm3.2-3.3 1.4-1.1 4.1 5.6-1.4 1.1-4.1-5.6Z"
/>
</svg>
),
linkedin: (
<svg viewBox="0 0 24 24" className={common} aria-hidden="true">
<path
fill="currentColor"
d="M5.2 8.2H2.6V21h2.6V8.2Zm-.1-4.7a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3ZM21 13.2c0-3.1-1.6-4.6-4-4.6-1.8 0-2.6 1-3.1 1.7V8.2h-2.6c.1 1.3 0 12.8 0 12.8h2.6v-7.1c0-.4 0-.8.1-1.1.3-.8 1-1.6 2.1-1.6 1.5 0 2.1 1.2 2.1 2.9V21H21v-7.8Z"
/>
</svg>
),
}
const icon = icons[id]
return (
<div className={wrapper}>
{icon || <Globe className={common} />}
</div>
)
}
const GOAL_PRESETS: {
id: string
title: string
@@ -147,11 +213,15 @@ export default function OpportunitiesPage() {
const [lastSearchConfig, setLastSearchConfig] = useState<SearchConfig | null>(null)
const [statusFilter, setStatusFilter] = useState('all')
const [intentFilter, setIntentFilter] = useState('all')
const [minScore, setMinScore] = useState(0)
const [limit, setLimit] = useState(50)
const [statusInput, setStatusInput] = useState('new')
const [notesInput, setNotesInput] = useState('')
const [tagsInput, setTagsInput] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [showSourceDialog, setShowSourceDialog] = useState(false)
const [customSourceName, setCustomSourceName] = useState('')
const [customSourceSite, setCustomSourceSite] = useState('')
const [customSourceTemplate, setCustomSourceTemplate] = useState('{site} {term} {intent}')
const planLoadedRef = useRef<string | null>(null)
const defaultPlatformsRef = useRef<PlatformConfig[] | null>(null)
@@ -168,7 +238,6 @@ export default function OpportunitiesPage() {
projectId: selectedProjectId as any,
status: statusFilter === 'all' ? undefined : statusFilter,
intent: intentFilter === 'all' ? undefined : intentFilter,
minScore: minScore > 0 ? minScore / 100 : undefined,
limit,
}
: 'skip'
@@ -229,7 +298,7 @@ export default function OpportunitiesPage() {
})
.then(data => {
if (data) {
setPlatforms(data.platforms)
setPlatforms((prev) => (prev.length > 0 ? prev : data.platforms))
defaultPlatformsRef.current = data.platforms
}
})
@@ -254,7 +323,9 @@ export default function OpportunitiesPage() {
if (typeof parsed.goalPreset === 'string') {
setGoalPreset(parsed.goalPreset)
}
if (Array.isArray(parsed.platformIds)) {
if (Array.isArray(parsed.platforms)) {
setPlatforms(parsed.platforms)
} else if (Array.isArray(parsed.platformIds)) {
setPlatforms((prev) =>
prev.map((platform) => ({
...platform,
@@ -290,7 +361,7 @@ export default function OpportunitiesPage() {
goalPreset,
strategies,
maxQueries,
platformIds: platforms.filter((platform) => platform.enabled).map((platform) => platform.id),
platforms,
}
localStorage.setItem(key, JSON.stringify(payload))
}, [selectedProjectId, goalPreset, strategies, maxQueries, platforms])
@@ -307,6 +378,10 @@ export default function OpportunitiesPage() {
))
}
const removeCustomSource = (platformId: string) => {
setPlatforms(prev => prev.filter((platform) => platform.id !== platformId))
}
const toggleStrategy = (strategy: SearchStrategy) => {
setStrategies(prev =>
prev.includes(strategy)
@@ -323,6 +398,34 @@ export default function OpportunitiesPage() {
setMaxQueries(preset.maxQueries)
}
const addCustomSource = () => {
const name = customSourceName.trim()
const site = customSourceSite.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '')
if (!name || !site) return
const idSeed = `${name}-${site}`.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
const customId = `custom-${idSeed}-${Date.now().toString(36)}`
setPlatforms((prev) => [
...prev,
{
id: customId,
name,
icon: 'Globe',
enabled: true,
searchTemplate: customSourceTemplate.trim() || '{site} {term} {intent}',
rateLimit: 20,
site,
custom: true,
},
])
setCustomSourceName('')
setCustomSourceSite('')
setCustomSourceTemplate('{site} {term} {intent}')
setShowSourceDialog(false)
}
const executeSearch = async (overrideConfig?: SearchConfig) => {
if (!analysis) return
if (!selectedProjectId) return
@@ -452,55 +555,32 @@ export default function OpportunitiesPage() {
{/* Sidebar */}
<div className="w-96 border-r border-border bg-card flex flex-col">
<div className="p-4 border-b border-border space-y-1">
<h2 className="font-semibold flex items-center gap-2">
<Target className="h-5 w-5" />
Search Plan
</h2>
<div className="flex items-center justify-between">
<h2 className="font-semibold flex items-center gap-2">
<Target className="h-5 w-5" />
Search Setup
</h2>
<Button
variant="ghost"
size="icon"
onClick={() => setShowAdvanced(true)}
aria-label="Advanced settings"
>
<Settings className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Pick a goal, tune channels, then run the scan.
Pick a goal, tune sources, then run the scan.
</p>
</div>
<ScrollArea className="flex-1 p-4 space-y-6">
{/* Goal presets */}
<div className="space-y-3">
<Label className="text-sm font-medium uppercase text-muted-foreground">Goal</Label>
<div className="grid gap-2">
{GOAL_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => applyGoalPreset(preset.id)}
className={`rounded-lg border px-3 py-2 text-left transition ${
goalPreset === preset.id
? "border-foreground/60 bg-muted/50"
: "border-border/60 hover:border-foreground/30 hover:bg-muted/40"
}`}
>
<div className="text-sm font-semibold">{preset.title}</div>
<div className="text-xs text-muted-foreground">{preset.description}</div>
</button>
))}
</div>
</div>
<Separator />
{/* Platforms */}
<div className="space-y-3">
<Label className="text-sm font-medium uppercase text-muted-foreground">Channels</Label>
<Label className="text-sm font-medium uppercase text-muted-foreground">Sources</Label>
<div className="grid gap-2">
{platforms.map(platform => {
const isEnabled = platform.enabled
const iconMap: Record<string, JSX.Element> = {
reddit: <MessageSquare className="h-4 w-4" />,
twitter: <Twitter className="h-4 w-4" />,
hackernews: <Zap className="h-4 w-4" />,
indiehackers: <Users className="h-4 w-4" />,
quora: <HelpCircle className="h-4 w-4" />,
stackoverflow: <Filter className="h-4 w-4" />,
linkedin: <Globe className="h-4 w-4" />
}
return (
<button
key={platform.id}
@@ -510,12 +590,10 @@ export default function OpportunitiesPage() {
isEnabled
? "border-foreground/50 bg-muted/50"
: "border-border/60 hover:border-foreground/30 hover:bg-muted/40"
}`}
}`}
>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-background">
{iconMap[platform.id] || <Globe className="h-4 w-4" />}
</div>
<ChannelLogo id={platform.id} />
<div>
<div className="text-sm font-medium">{platform.name}</div>
<div className="text-xs text-muted-foreground">
@@ -523,50 +601,46 @@ export default function OpportunitiesPage() {
</div>
</div>
</div>
<Checkbox checked={isEnabled} />
<div className="flex items-center gap-2">
{platform.custom && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={(event) => {
event.stopPropagation()
removeCustomSource(platform.id)
}}
className="h-7 w-7"
>
<X className="h-4 w-4" />
</Button>
)}
<Checkbox checked={isEnabled} />
</div>
</button>
)
})}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowSourceDialog(true)}
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
Add source
</Button>
</div>
<Separator />
{/* Strategies */}
<div className="space-y-4">
<Label className="text-sm font-medium uppercase text-muted-foreground">Signals</Label>
{STRATEGY_GROUPS.map((group) => (
<div key={group.title} className="space-y-2">
<div>
<div className="text-sm font-semibold">{group.title}</div>
<div className="text-xs text-muted-foreground">{group.description}</div>
</div>
<div className="space-y-2">
{group.strategies.map((strategy) => (
<div key={strategy} className="flex items-start gap-2 rounded-md border border-border/60 px-2 py-2">
<Checkbox
id={strategy}
checked={strategies.includes(strategy)}
onCheckedChange={() => toggleStrategy(strategy)}
/>
<div className="flex-1">
<Label htmlFor={strategy} className="cursor-pointer text-sm font-medium">
{STRATEGY_INFO[strategy].name}
</Label>
<p className="text-xs text-muted-foreground">{STRATEGY_INFO[strategy].description}</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
</ScrollArea>
<Separator />
{/* Queries */}
<div className="p-4 border-t border-border space-y-2">
<div className="space-y-2">
<Label className="text-sm font-medium uppercase text-muted-foreground">Max Queries</Label>
<Label className="text-sm font-medium uppercase text-muted-foreground">Search Volume</Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Max Queries</span>
@@ -584,9 +658,6 @@ export default function OpportunitiesPage() {
</div>
</div>
</div>
</ScrollArea>
<div className="p-4 border-t border-border space-y-2">
<Button
onClick={() => executeSearch()}
disabled={
@@ -596,17 +667,17 @@ export default function OpportunitiesPage() {
}
className="w-full"
>
{isSearching ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Searching...</> : <><Search className="mr-2 h-4 w-4" /> Find Opportunities</>}
{isSearching ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Searching...</> : <><Search className="mr-2 h-4 w-4" /> Run Search</>}
</Button>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{enabledPlatforms.length || 0} channels</span>
<span>{enabledPlatforms.length || 0} sources</span>
<span>·</span>
<span>{strategies.length} signals</span>
<span>{strategies.length} triggers</span>
<span>·</span>
<span>max {maxQueries} queries</span>
</div>
{platforms.filter(p => p.enabled).length === 0 && (
<p className="text-xs text-muted-foreground">Select at least one platform to search.</p>
<p className="text-xs text-muted-foreground">Select at least one source to search.</p>
)}
{selectedSourceIds.length === 0 && (
<p className="text-xs text-muted-foreground">Select data sources to build search context.</p>
@@ -614,13 +685,127 @@ export default function OpportunitiesPage() {
</div>
</div>
<Dialog open={showAdvanced} onOpenChange={setShowAdvanced}>
<DialogContent className="max-w-xl max-h-[80vh] overflow-hidden">
<DialogHeader>
<DialogTitle>Advanced settings</DialogTitle>
<DialogDescription>Fine-tune goals and triggers for this search.</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh] pr-4">
<div className="space-y-6">
<div className="space-y-3">
<Label className="text-sm font-medium uppercase text-muted-foreground">Search Goal</Label>
<div className="grid gap-2">
{GOAL_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => applyGoalPreset(preset.id)}
className={`rounded-lg border px-3 py-2 text-left transition ${
goalPreset === preset.id
? "border-foreground/60 bg-muted/50"
: "border-border/60 hover:border-foreground/30 hover:bg-muted/40"
}`}
>
<div className="text-sm font-semibold">{preset.title}</div>
<div className="text-xs text-muted-foreground">{preset.description}</div>
</button>
))}
</div>
</div>
<div className="space-y-4">
<Label className="text-sm font-medium uppercase text-muted-foreground">Triggers</Label>
{STRATEGY_GROUPS.map((group) => (
<div key={group.title} className="space-y-2">
<div>
<div className="text-sm font-semibold">{group.title}</div>
<div className="text-xs text-muted-foreground">{group.description}</div>
</div>
<div className="space-y-2">
{group.strategies.map((strategy) => (
<div key={strategy} className="flex items-start gap-2 rounded-md border border-border/60 px-2 py-2">
<Checkbox
id={strategy}
checked={strategies.includes(strategy)}
onCheckedChange={() => toggleStrategy(strategy)}
/>
<div className="flex-1">
<Label htmlFor={strategy} className="cursor-pointer text-sm font-medium">
{STRATEGY_INFO[strategy].name}
</Label>
<p className="text-xs text-muted-foreground">{STRATEGY_INFO[strategy].description}</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
<Dialog open={showSourceDialog} onOpenChange={setShowSourceDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add source</DialogTitle>
<DialogDescription>Add a custom site to search.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="custom-source-name">Source name</Label>
<input
id="custom-source-name"
value={customSourceName}
onChange={(event) => setCustomSourceName(event.target.value)}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="e.g. Product Hunt"
/>
</div>
<div className="space-y-2">
<Label htmlFor="custom-source-site">Website domain</Label>
<input
id="custom-source-site"
value={customSourceSite}
onChange={(event) => setCustomSourceSite(event.target.value)}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="producthunt.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="custom-source-template">Search template</Label>
<Textarea
id="custom-source-template"
value={customSourceTemplate}
onChange={(event) => setCustomSourceTemplate(event.target.value)}
rows={3}
placeholder="{site} {term} {intent}"
/>
<p className="text-xs text-muted-foreground">
Use &#123;site&#125;, &#123;term&#125;, and &#123;intent&#125;.
</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowSourceDialog(false)}>
Cancel
</Button>
<Button onClick={addCustomSource} disabled={!customSourceName.trim() || !customSourceSite.trim()}>
Add source
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Main Content */}
<div className="flex-1 overflow-auto p-6">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">Opportunity Finder</h1>
<h1 className="text-2xl font-bold">Search Results</h1>
<p className="text-muted-foreground">Discover potential customers for {analysis.productName}</p>
</div>
{stats && (
@@ -629,10 +814,6 @@ export default function OpportunitiesPage() {
<div className="font-semibold">{stats.opportunitiesFound}</div>
<div className="text-muted-foreground">Found</div>
</div>
<div className="text-center">
<div className="font-semibold text-green-400">{stats.highRelevance}</div>
<div className="text-muted-foreground">High Quality</div>
</div>
</div>
)}
</div>
@@ -643,7 +824,7 @@ export default function OpportunitiesPage() {
{latestJob && (latestJob.status === "running" || latestJob.status === "pending") && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Search in progress</CardTitle>
<CardTitle className="text-sm">Search running</CardTitle>
<CardDescription>
Current job: {latestJob.status}
</CardDescription>
@@ -688,7 +869,7 @@ export default function OpportunitiesPage() {
{activeSources.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Active Data Sources</CardTitle>
<CardTitle className="text-sm">Inputs in use</CardTitle>
<CardDescription>
Sources selected for this project will drive opportunity search.
</CardDescription>
@@ -734,7 +915,7 @@ export default function OpportunitiesPage() {
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">Generated Queries ({generatedQueries.length})</CardTitle>
<CardTitle className="text-sm">Search Queries ({generatedQueries.length})</CardTitle>
{showQueries ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
</CardHeader>
@@ -757,7 +938,7 @@ export default function OpportunitiesPage() {
<Card>
<div className="flex flex-wrap items-center gap-3 border-b border-border p-4">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">Status</Label>
<Label className="text-xs text-muted-foreground">Lead Status</Label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
@@ -773,7 +954,7 @@ export default function OpportunitiesPage() {
</select>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">Intent</Label>
<Label className="text-xs text-muted-foreground">Buyer Intent</Label>
<select
value={intentFilter}
onChange={(e) => setIntentFilter(e.target.value)}
@@ -787,17 +968,6 @@ export default function OpportunitiesPage() {
<option value="looking">Looking</option>
</select>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">Min Score</Label>
<input
type="number"
min={0}
max={100}
value={minScore}
onChange={(e) => setMinScore(Number(e.target.value))}
className="h-8 w-20 rounded-md border border-input bg-background px-2 text-xs"
/>
</div>
<Button
variant="outline"
size="sm"
@@ -809,10 +979,9 @@ export default function OpportunitiesPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Platform</TableHead>
<TableHead>Intent</TableHead>
<TableHead>Score</TableHead>
<TableHead>Status</TableHead>
<TableHead>Source</TableHead>
<TableHead>Buyer Intent</TableHead>
<TableHead>Lead Status</TableHead>
<TableHead className="w-1/2">Post</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
@@ -827,11 +996,6 @@ export default function OpportunitiesPage() {
<span className="capitalize text-sm">{opp.intent}</span>
</div>
</TableCell>
<TableCell>
<Badge className={opp.relevanceScore >= 0.8 ? 'bg-green-500/20 text-green-400' : opp.relevanceScore >= 0.6 ? 'bg-amber-500/20 text-amber-400' : 'bg-red-500/20 text-red-400'}>
{Math.round(opp.relevanceScore * 100)}%
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{opp.status || 'new'}
@@ -866,7 +1030,7 @@ export default function OpportunitiesPage() {
<Card className="p-12 text-center">
<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 platforms and strategies, then click Find Opportunities</p>
<p className="text-muted-foreground">Select sources and triggers, then click Run Search</p>
</Card>
)}
</div>
@@ -879,9 +1043,6 @@ export default function OpportunitiesPage() {
<>
<DialogHeader>
<div className="flex items-center gap-2">
<Badge className={selectedOpportunity.relevanceScore >= 0.8 ? 'bg-green-500/20 text-green-400' : selectedOpportunity.relevanceScore >= 0.6 ? 'bg-amber-500/20 text-amber-400' : 'bg-red-500/20 text-red-400'}>
{Math.round(selectedOpportunity.relevanceScore * 100)}% Match
</Badge>
<Badge variant="outline">{selectedOpportunity.platform}</Badge>
<Badge variant="secondary" className="capitalize">{selectedOpportunity.intent}</Badge>
</div>
@@ -892,7 +1053,7 @@ export default function OpportunitiesPage() {
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Status</Label>
<Label>Lead Status</Label>
<select
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
@@ -929,7 +1090,7 @@ export default function OpportunitiesPage() {
{selectedOpportunity.matchedKeywords.length > 0 && (
<div>
<Label className="text-sm text-muted-foreground">Matched Keywords</Label>
<Label className="text-sm text-muted-foreground">Keyword Matches</Label>
<div className="flex flex-wrap gap-2 mt-1">
{selectedOpportunity.matchedKeywords.map((kw, i) => (
<Badge key={i} variant="secondary">{kw}</Badge>
@@ -939,13 +1100,13 @@ export default function OpportunitiesPage() {
)}
<div className="bg-muted p-4 rounded-lg">
<Label className="text-sm text-muted-foreground">Suggested Approach</Label>
<Label className="text-sm text-muted-foreground">Suggested Outreach</Label>
<p className="text-sm mt-1">{selectedOpportunity.suggestedApproach}</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Generated Reply</Label>
<Label>Draft Reply</Label>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => generateReply(selectedOpportunity)}>
<Zap className="h-3 w-3 mr-1" /> Generate

View File

@@ -1,53 +1,220 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import * as React from "react"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
export default function SettingsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const profile = useQuery(api.users.getCurrentProfile)
const user = profile?.user
const accounts = profile?.accounts ?? []
const allowedTabs = React.useMemo(() => ["account", "billing"], [])
const queryTab = searchParams.get("tab")
const initialTab = allowedTabs.includes(queryTab ?? "") ? (queryTab as string) : "account"
const [tab, setTab] = React.useState(initialTab)
React.useEffect(() => {
if (initialTab !== tab) {
setTab(initialTab)
}
}, [initialTab, tab])
const checkoutHref = "/api/checkout"
return (
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-8">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Settings</h1>
<p className="text-muted-foreground">Manage account details and API configuration.</p>
<div className="relative flex flex-1 flex-col gap-6 overflow-hidden p-4 lg:p-8">
<div className="pointer-events-none absolute inset-0">
<div className="absolute -left-40 top-20 h-72 w-72 rounded-full bg-[radial-gradient(circle_at_center,rgba(251,191,36,0.18),transparent_65%)] blur-2xl" />
<div className="absolute right-10 top-10 h-64 w-64 rounded-full bg-[radial-gradient(circle_at_center,rgba(16,185,129,0.16),transparent_65%)] blur-2xl" />
<div className="absolute bottom-0 left-1/3 h-64 w-96 rounded-full bg-[radial-gradient(circle_at_center,rgba(56,189,248,0.14),transparent_70%)] blur-3xl" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Account</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>Signed in with Convex Auth.</p>
<p>Profile management can be added here in a later phase.</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">API Keys</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="space-y-1">
<p className="font-medium text-foreground">OpenAI</p>
<p>Set `OPENAI_API_KEY` in your `.env` file.</p>
</div>
<div className="space-y-1">
<p className="font-medium text-foreground">Serper (optional)</p>
<p>Set `SERPER_API_KEY` in your `.env` file for more reliable search.</p>
</div>
<Badge variant="outline">Reload server after changes</Badge>
</CardContent>
</Card>
<div className="relative space-y-2">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="outline">Account Center</Badge>
<Badge className="bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20">Active</Badge>
</div>
<h1 className="text-2xl font-semibold">Account & Billing</h1>
<p className="text-muted-foreground">
Manage your subscription, billing, and account details in one place.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Billing</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Billing is not configured yet. Add Stripe or another provider when ready.
</CardContent>
</Card>
<Tabs
value={tab}
onValueChange={(value) => {
setTab(value)
router.replace(`/settings?tab=${value}`)
}}
className="relative"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="bg-muted/60">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Current plan</span>
<span className="text-foreground">Starter</span>
</div>
</div>
<TabsContent value="account">
<div className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2 border-border/60 bg-card/70 backdrop-blur">
<CardHeader>
<CardTitle className="text-base">Profile</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-foreground font-medium">{user?.name || user?.email || "Not provided"}</p>
<p>{user?.email || "Not provided"}</p>
</div>
{/* TODO: Wire profile editing flow. */}
<Button variant="secondary">Coming soon.</Button>
</div>
<Separator />
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-foreground font-medium">Name</p>
<p>{user?.name || "Not provided"}</p>
</div>
<div className="space-y-1">
<p className="text-foreground font-medium">Email</p>
<p>{user?.email || "Not provided"}</p>
</div>
<div className="space-y-1">
<p className="text-foreground font-medium">Phone</p>
<p>{user?.phone || "Not provided"}</p>
</div>
<div className="space-y-1">
<p className="text-foreground font-medium">Sign-in Methods</p>
<p>
{accounts.length > 0
? Array.from(new Set(accounts.map((account) => {
if (account.provider === "password") return "Password";
if (account.provider === "google") return "Google";
return account.provider;
}))).join(", ")
: "Not provided"}
</p>
</div>
<div className="space-y-1">
<p className="text-foreground font-medium">Email Verified</p>
<p>
{user?.emailVerificationTime
? new Date(user.emailVerificationTime).toLocaleDateString()
: "Not verified"}
</p>
</div>
<div className="space-y-1">
<p className="text-foreground font-medium">User ID</p>
<p className="break-all">{user?._id || "Not provided"}</p>
</div>
</div>
{/* TODO: Wire security management flow. */}
<Button className="w-full sm:w-auto">Coming soon.</Button>
</CardContent>
</Card>
<Card className="border-border/60 bg-card/70 backdrop-blur">
<CardHeader>
<CardTitle className="text-base">Integrations</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<div>
{/* TODO: Replace with real provider status. */}
<p className="text-foreground font-medium">Coming soon.</p>
<p>Coming soon.</p>
</div>
<Badge variant="outline">Linked</Badge>
</div>
<div className="flex items-center justify-between">
<div>
{/* TODO: Replace with real provider status. */}
<p className="text-foreground font-medium">Coming soon.</p>
<p>Coming soon.</p>
</div>
{/* TODO: Wire provider disconnect. */}
<Button variant="ghost" size="sm">Coming soon.</Button>
</div>
<div className="flex items-center justify-between">
<div>
{/* TODO: Replace with real provider status. */}
<p className="text-foreground font-medium">Coming soon.</p>
<p>Coming soon.</p>
</div>
{/* TODO: Wire provider connect. */}
<Button variant="outline" size="sm">Coming soon.</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="billing">
<div className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2 border-border/60 bg-card/70 backdrop-blur">
<CardHeader>
<CardTitle className="text-base">Plan</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-foreground font-medium">Starter</p>
<p>Upgrade to unlock full opportunity search and automation.</p>
</div>
<Button asChild>
<Link href={checkoutHref}>Subscribe to Pro</Link>
</Button>
</div>
<Separator />
<div className="space-y-2">
<p className="text-foreground font-medium">Pro includes</p>
<div className="grid gap-1 text-sm text-muted-foreground">
<p>Unlimited projects and data sources</p>
<p>Advanced opportunity search</p>
<p>Priority analysis queue</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-card/70 backdrop-blur">
<CardHeader>
<CardTitle className="text-base">Billing History</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<div>
<p className="text-foreground font-medium">No invoices yet</p>
<p>Invoices will appear after your first payment.</p>
</div>
<Badge variant="outline">Pro</Badge>
</div>
<div className="rounded-lg border border-border/60 bg-muted/40 p-3 text-xs text-muted-foreground">
Need a receipt? Complete checkout to generate your first invoice.
</div>
<Button asChild className="w-full" variant="secondary">
<Link href={checkoutHref}>Subscribe to Pro</Link>
</Button>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { convexAuthNextjsToken, isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server";
import { fetchMutation, fetchQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { scrapeWebsite, analyzeFromText } from "@/lib/scraper";
import { repromptSection } from "@/lib/analysis-pipeline";
const bodySchema = z.object({
analysisId: z.string().min(1),
sectionKey: z.enum([
"profile",
"features",
"competitors",
"keywords",
"problems",
"personas",
"useCases",
"dorkQueries",
]),
prompt: z.string().optional(),
});
export async function POST(request: NextRequest) {
if (!(await isAuthenticatedNextjs())) {
const redirectUrl = new URL("/auth", request.url);
const referer = request.headers.get("referer");
const nextPath = referer ? new URL(referer).pathname + new URL(referer).search : "/";
redirectUrl.searchParams.set("next", nextPath);
return NextResponse.redirect(redirectUrl);
}
const body = await request.json();
const parsed = bodySchema.parse(body);
const token = await convexAuthNextjsToken();
const analysis = await fetchQuery(
api.analyses.getById,
{ analysisId: parsed.analysisId as any },
{ token }
);
if (!analysis) {
return NextResponse.json({ error: "Analysis not found." }, { status: 404 });
}
const dataSource = await fetchQuery(
api.dataSources.getById,
{ dataSourceId: analysis.dataSourceId as any },
{ token }
);
if (!dataSource) {
return NextResponse.json({ error: "Data source not found." }, { status: 404 });
}
const isManual = dataSource.url.startsWith("manual:") || dataSource.url === "manual-input";
const featureText = (analysis.features || []).map((f: any) => f.name).join("\n");
const content = isManual
? await analyzeFromText(
analysis.productName,
analysis.description || "",
featureText
)
: await scrapeWebsite(dataSource.url);
const items = await repromptSection(
parsed.sectionKey,
content,
analysis as any,
parsed.prompt
);
await fetchMutation(
api.analysisSections.replaceSection,
{
analysisId: parsed.analysisId as any,
sectionKey: parsed.sectionKey,
items,
lastPrompt: parsed.prompt,
source: "ai",
},
{ token }
);
return NextResponse.json({ success: true, items });
}

21
app/api/checkout/route.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Checkout } from "@polar-sh/nextjs";
import { NextResponse } from "next/server";
export const GET = async () => {
if (!process.env.POLAR_ACCESS_TOKEN || !process.env.POLAR_SUCCESS_URL) {
return NextResponse.json(
{
error:
"Missing POLAR_ACCESS_TOKEN or POLAR_SUCCESS_URL environment variables.",
},
{ status: 400 }
);
}
const handler = Checkout({
accessToken: process.env.POLAR_ACCESS_TOKEN,
successUrl: process.env.POLAR_SUCCESS_URL,
});
return handler();
};

View File

@@ -17,7 +17,9 @@ const searchSchema = z.object({
icon: z.string().optional(),
enabled: z.boolean(),
searchTemplate: z.string().optional(),
rateLimit: z.number()
rateLimit: z.number(),
site: z.string().optional(),
custom: z.boolean().optional()
})),
strategies: z.array(z.string()),
maxResults: z.number().default(50)
@@ -115,9 +117,21 @@ export async function POST(request: NextRequest) {
);
}
const resultUrls = Array.from(
new Set(searchResults.map((result) => result.url).filter(Boolean))
)
const existingUrls = await fetchQuery(
api.seenUrls.listExisting,
{ projectId: projectId as any, urls: resultUrls },
{ token }
)
const existingSet = new Set(existingUrls)
const newUrls = resultUrls.filter((url) => !existingSet.has(url))
const filteredResults = searchResults.filter((result) => !existingSet.has(result.url))
// Score and rank
console.log(' Scoring opportunities...')
const opportunities = scoreOpportunities(searchResults, analysis as EnhancedProductAnalysis)
const opportunities = scoreOpportunities(filteredResults, analysis as EnhancedProductAnalysis)
console.log(` ✓ Scored ${opportunities.length} opportunities`)
if (jobId) {
await fetchMutation(
@@ -135,18 +149,22 @@ export async function POST(request: NextRequest) {
);
}
if (newUrls.length > 0) {
await fetchMutation(
api.seenUrls.markSeenBatch,
{ projectId: projectId as any, urls: newUrls, source: "search" },
{ token }
);
}
return NextResponse.json({
success: true,
data: {
opportunities: opportunities.slice(0, 50),
stats: {
queriesGenerated: queries.length,
rawResults: searchResults.length,
opportunitiesFound: opportunities.length,
highRelevance: opportunities.filter(o => o.relevanceScore >= 0.7).length,
averageScore: opportunities.length > 0
? opportunities.reduce((a, o) => a + o.relevanceScore, 0) / opportunities.length
: 0
rawResults: filteredResults.length,
opportunitiesFound: opportunities.length
},
queries: queries.map(q => ({
query: q.query,

View File

@@ -356,7 +356,7 @@ export default function OnboardingPage() {
<Card className="border-border/50 shadow-none">
<CardHeader>
<CardTitle>Describe Your Product</CardTitle>
<CardTitle>Product Details</CardTitle>
<CardDescription>
Enter your product details and we&apos;ll extract the key information.
</CardDescription>
@@ -373,7 +373,7 @@ export default function OnboardingPage() {
</div>
<div className="space-y-2">
<Label htmlFor="description">Description *</Label>
<Label htmlFor="description">Product Summary *</Label>
<Textarea
id="description"
placeholder="What does your product do? Who is it for? What problem does it solve?"
@@ -384,7 +384,7 @@ export default function OnboardingPage() {
</div>
<div className="space-y-2">
<Label htmlFor="features">Key Features (one per line)</Label>
<Label htmlFor="features">Key Features</Label>
<Textarea
id="features"
placeholder="- Feature 1&#10;- Feature 2&#10;- Feature 3"
@@ -489,7 +489,7 @@ export default function OnboardingPage() {
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="url">Website URL</Label>
<Label htmlFor="url">Website</Label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input