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

@@ -1,5 +1,8 @@
# Required: OpenAI API key
OPENAI_API_KEY=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
# For Polar.sh
POLAR_ACCESS_TOKEN=polar_oat_0ftgnD1xSFxecMlEd7Er5tCehRWLA8anXLZ820ggiKn
POLAR_SUCCESS_URL=http://localhost:3000/settings?tab=billing&checkout_id={CHECKOUT_ID}
# Optional: Serper.dev API key for reliable Google search
SERPER_API_KEY=f9ae0a793cbac4116edd6482e377330e75c4db22
SERPER_API_KEY=340b89a61031688347bd8dc06e4c55020be7a2b8

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

View File

@@ -0,0 +1,326 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { useMutation } from "convex/react"
import { api } from "@/convex/_generated/api"
type SectionKey =
| "profile"
| "features"
| "competitors"
| "keywords"
| "problems"
| "personas"
| "useCases"
| "dorkQueries"
function summarizeItem(item: any) {
if (typeof item === "string") return item
if (!item || typeof item !== "object") return String(item)
if (item.name && item.role) return `${item.name} · ${item.role}`
if (item.name) return item.name
if (item.term) return item.term
if (item.problem) return item.problem
if (item.scenario) return item.scenario
if (item.query) return item.query
return JSON.stringify(item)
}
export function SectionEditor({
analysisId,
sectionKey,
title,
items,
}: {
analysisId: string
sectionKey: SectionKey
title: string
items: any[]
}) {
const addItem = useMutation(api.analysisSections.addItem)
const removeItem = useMutation(api.analysisSections.removeItem)
const [isRepromptOpen, setIsRepromptOpen] = React.useState(false)
const [repromptText, setRepromptText] = React.useState("")
const [isAddOpen, setIsAddOpen] = React.useState(false)
const [addText, setAddText] = React.useState("")
const [isBusy, setIsBusy] = React.useState(false)
const handleAdd = async () => {
setIsBusy(true)
try {
let parsed: any = addText
if (addText.trim().startsWith("{") || addText.trim().startsWith("[")) {
parsed = JSON.parse(addText)
}
await addItem({ analysisId: analysisId as any, sectionKey, item: parsed })
setAddText("")
setIsAddOpen(false)
} finally {
setIsBusy(false)
}
}
const handleReprompt = async () => {
setIsBusy(true)
try {
const response = await fetch("/api/analysis/reprompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
analysisId,
sectionKey,
prompt: repromptText.trim() || undefined,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || "Reprompt failed")
}
setRepromptText("")
setIsRepromptOpen(false)
} finally {
setIsBusy(false)
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle className="text-base">{title}</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsAddOpen(true)}>
Add
</Button>
<Button variant="secondary" size="sm" onClick={() => setIsRepromptOpen(true)}>
Reprompt
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{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"
>
<div className="space-y-2">
<div className="font-medium">{summarizeItem(item)}</div>
<Badge variant="outline">#{index + 1}</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeItem({ analysisId: analysisId as any, sectionKey, index })}
>
Remove
</Button>
</div>
))
)}
</CardContent>
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add to {title}</DialogTitle>
<DialogDescription>
Paste JSON for an item, or plain text for a string entry.
</DialogDescription>
</DialogHeader>
<Textarea
value={addText}
onChange={(event) => setAddText(event.target.value)}
placeholder='{"name":"...", "description":"..."}'
className="min-h-[160px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleAdd} disabled={isBusy || !addText.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isRepromptOpen} onOpenChange={setIsRepromptOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reprompt {title}</DialogTitle>
<DialogDescription>
Provide guidance to regenerate just this section.
</DialogDescription>
</DialogHeader>
<Textarea
value={repromptText}
onChange={(event) => setRepromptText(event.target.value)}
placeholder="Focus on B2B teams in healthcare..."
className="min-h-[140px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRepromptOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleReprompt} disabled={isBusy}>
Reprompt
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}
export function ProfileSectionEditor({
analysisId,
items,
}: {
analysisId: string
items: Record<string, any>
}) {
const replaceSection = useMutation(api.analysisSections.replaceSection)
const [isRepromptOpen, setIsRepromptOpen] = React.useState(false)
const [isEditOpen, setIsEditOpen] = React.useState(false)
const [repromptText, setRepromptText] = React.useState("")
const [editText, setEditText] = React.useState(JSON.stringify(items, null, 2))
const [isBusy, setIsBusy] = React.useState(false)
React.useEffect(() => {
setEditText(JSON.stringify(items, null, 2))
}, [items])
const handleSave = async () => {
setIsBusy(true)
try {
const parsed = JSON.parse(editText)
await replaceSection({
analysisId: analysisId as any,
sectionKey: "profile",
items: parsed,
source: "mixed",
})
setIsEditOpen(false)
} finally {
setIsBusy(false)
}
}
const handleReprompt = async () => {
setIsBusy(true)
try {
const response = await fetch("/api/analysis/reprompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
analysisId,
sectionKey: "profile",
prompt: repromptText.trim() || undefined,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || "Reprompt failed")
}
setRepromptText("")
setIsRepromptOpen(false)
} finally {
setIsBusy(false)
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle className="text-base">Product Profile</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsEditOpen(true)}>
Edit
</Button>
<Button variant="secondary" size="sm" onClick={() => setIsRepromptOpen(true)}>
Reprompt
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div>
<span className="font-medium text-foreground">Product:</span>{" "}
{items.productName || "Not set"}
</div>
<div>
<span className="font-medium text-foreground">Tagline:</span>{" "}
{items.tagline || "Not set"}
</div>
<div>
<span className="font-medium text-foreground">Category:</span>{" "}
{items.category || "Not set"}
</div>
<div>
<span className="font-medium text-foreground">Positioning:</span>{" "}
{items.positioning || "Not set"}
</div>
</CardContent>
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update the profile JSON.</DialogDescription>
</DialogHeader>
<Textarea
value={editText}
onChange={(event) => setEditText(event.target.value)}
className="min-h-[200px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isBusy}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isRepromptOpen} onOpenChange={setIsRepromptOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reprompt Profile</DialogTitle>
<DialogDescription>
Provide guidance to regenerate the profile.
</DialogDescription>
</DialogHeader>
<Textarea
value={repromptText}
onChange={(event) => setRepromptText(event.target.value)}
className="min-h-[140px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRepromptOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleReprompt} disabled={isBusy}>
Reprompt
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@@ -6,9 +6,7 @@ import { usePathname } from "next/navigation"
import {
Command,
Frame,
HelpCircle,
Settings,
Settings2,
Terminal,
Target,
Plus,
@@ -66,11 +64,16 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const [isSubmittingProject, setIsSubmittingProject] = React.useState(false);
const createProject = useMutation(api.projects.createProject);
const updateProject = useMutation(api.projects.updateProject);
const deleteProject = useMutation(api.projects.deleteProject);
const [isEditingProject, setIsEditingProject] = React.useState(false);
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
const [editingProjectName, setEditingProjectName] = React.useState("");
const [editingProjectDefault, setEditingProjectDefault] = React.useState(false);
const [editingProjectError, setEditingProjectError] = React.useState<string | null>(null);
const [deleteConfirmName, setDeleteConfirmName] = React.useState("");
const [deleteProjectError, setDeleteProjectError] = React.useState<string | null>(null);
const [isDeletingProject, setIsDeletingProject] = React.useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = React.useState(false);
const [isSubmittingEdit, setIsSubmittingEdit] = React.useState(false);
const [sourceUrl, setSourceUrl] = React.useState("");
const [sourceName, setSourceName] = React.useState("");
@@ -107,6 +110,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const toggleConfig = useMutation(api.projects.toggleDataSourceConfig);
const selectedProject = projects?.find(p => p._id === selectedProjectId);
const editingProject = projects?.find((project) => project._id === editingProjectId);
const canDeleteProject = (projects?.length ?? 0) > 1;
const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || [];
const selectedProjectName = selectedProject?.name || "Select Project";
@@ -291,9 +296,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Command className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{selectedProjectName}</span>
<span className="truncate text-xs text-muted-foreground">Projects</span>
@@ -332,6 +334,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
setEditingProjectName(project.name);
setEditingProjectDefault(project.isDefault);
setEditingProjectError(null);
setDeleteConfirmName("");
setDeleteProjectError(null);
setIsDeleteConfirmOpen(false);
setIsEditingProject(true);
}}
aria-label={`Project settings for ${project.name}`}
@@ -353,54 +358,41 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarContent>
{/* Platform Nav */}
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupLabel>Main</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip="Dashboard"
tooltip="Overview"
isActive={pathname === "/dashboard"}
>
<Link href="/dashboard">
<Terminal />
<span>Dashboard</span>
<span>Overview</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip="Opportunities"
tooltip="Search"
isActive={pathname === "/opportunities"}
>
<Link href="/opportunities">
<Target />
<span>Opportunities</span>
<span>Search</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip="Settings"
isActive={pathname === "/settings"}
tooltip="Inbox"
isActive={pathname === "/leads"}
>
<Link href="/settings">
<Settings2 />
<span>Settings</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip="Help"
isActive={pathname === "/help"}
>
<Link href="/help">
<HelpCircle />
<span>Help</span>
<Link href="/leads" className="pl-8 text-sm text-muted-foreground hover:text-foreground">
<span>Inbox</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -412,7 +404,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{selectedProjectId && (
<SidebarGroup>
<SidebarGroupLabel>
Active Data Sources
Selected Sources
<span className="ml-2 text-xs font-normal text-muted-foreground">({selectedProject?.name})</span>
</SidebarGroupLabel>
<SidebarGroupContent>
@@ -624,6 +616,33 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{editingProjectError && (
<div className="text-sm text-destructive">{editingProjectError}</div>
)}
<div className="border-t border-border pt-4 space-y-2">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-destructive">Delete project</div>
<p className="text-xs text-muted-foreground">
This removes the project and all related data sources, analyses, and opportunities.
</p>
</div>
<Button
variant="destructive"
size="sm"
disabled={!canDeleteProject}
onClick={() => {
setDeleteConfirmName("");
setDeleteProjectError(null);
setIsDeleteConfirmOpen(true);
}}
>
Delete
</Button>
</div>
{!canDeleteProject && (
<div className="text-xs text-muted-foreground">
You must keep at least one project.
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
@@ -662,6 +681,77 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</div>
</DialogContent>
</Dialog>
<Dialog open={isDeleteConfirmOpen} onOpenChange={setIsDeleteConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete project</DialogTitle>
<DialogDescription>
This action is permanent. You are deleting{" "}
<span className="font-semibold text-foreground">
{editingProject?.name || "this project"}
</span>
. Type the project name to confirm deletion.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="deleteProjectConfirm">Project name</Label>
<Input
id="deleteProjectConfirm"
value={deleteConfirmName}
onChange={(event) => setDeleteConfirmName(event.target.value)}
disabled={isDeletingProject || !canDeleteProject}
/>
</div>
{deleteProjectError && (
<div className="text-sm text-destructive">{deleteProjectError}</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsDeleteConfirmOpen(false)}
disabled={isDeletingProject}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={
isDeletingProject ||
!canDeleteProject ||
!editingProject ||
deleteConfirmName.trim() !== editingProject.name
}
onClick={async () => {
if (!editingProjectId || !editingProject) return;
if (deleteConfirmName.trim() !== editingProject.name) {
setDeleteProjectError("Project name does not match.");
return;
}
setDeleteProjectError(null);
setIsDeletingProject(true);
try {
const result = await deleteProject({
projectId: editingProjectId as any,
});
if (selectedProjectId === editingProjectId && result?.newDefaultProjectId) {
setSelectedProjectId(result.newDefaultProjectId);
}
setIsDeleteConfirmOpen(false);
setIsEditingProject(false);
} catch (err: any) {
setDeleteProjectError(err?.message || "Failed to delete project.");
} finally {
setIsDeletingProject(false);
}
}}
>
{isDeletingProject ? "Deleting..." : "Delete Project"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isCreatingProject} onOpenChange={setIsCreatingProject}>
<DialogContent>
<DialogHeader>

View File

@@ -4,9 +4,9 @@ import * as React from "react"
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
HelpCircle,
LogOut,
Sparkles,
} from "lucide-react"
@@ -32,6 +32,7 @@ import {
useSidebar,
} from "@/components/ui/sidebar"
import { useAuthActions } from "@convex-dev/auth/react"
import { useRouter } from "next/navigation"
export function NavUser({
user,
@@ -44,6 +45,7 @@ export function NavUser({
}) {
const { isMobile } = useSidebar()
const { signOut } = useAuthActions()
const router = useRouter()
const seed = React.useMemo(() => {
const base = user.email || user.name || "";
return base.trim() || "user";
@@ -98,27 +100,28 @@ export function NavUser({
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<DropdownMenuItem onSelect={() => router.push("/settings?tab=upgrade")}>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<DropdownMenuItem onSelect={() => router.push("/settings?tab=account")}>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem onSelect={() => router.push("/settings?tab=billing")}>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => router.push("/help")}>
<HelpCircle />
Support
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut()}>
<LogOut />
Log out

View File

@@ -8,9 +8,6 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
LayoutDashboard,
Search,
Settings,
HelpCircle,
LogOut,
Sparkles,
Target
@@ -25,30 +22,11 @@ export function Sidebar({ productName }: SidebarProps) {
const routes = [
{
label: 'Dashboard',
label: 'Overview',
icon: LayoutDashboard,
href: '/dashboard',
active: pathname === '/dashboard',
},
{
label: 'Opportunities',
icon: Target,
href: '/opportunities',
active: pathname === '/opportunities',
},
]
const bottomRoutes = [
{
label: 'Settings',
icon: Settings,
href: '/settings',
},
{
label: 'Help',
icon: HelpCircle,
href: '/help',
},
]
return (
@@ -89,22 +67,32 @@ export function Sidebar({ productName }: SidebarProps) {
{route.label}
</Link>
))}
<Link
href="/opportunities"
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
pathname === '/opportunities'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<Target className="h-4 w-4" />
Search
</Link>
<Link
href="/leads"
className={cn(
'flex items-center rounded-md px-3 py-2 pl-9 text-sm font-medium transition-colors',
pathname === '/leads'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
Inbox
</Link>
</nav>
<Separator className="my-4" />
<nav className="space-y-1 px-2">
{bottomRoutes.map((route) => (
<Link
key={route.href}
href={route.href}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<route.icon className="h-4 w-4" />
{route.label}
</Link>
))}
</nav>
</ScrollArea>
{/* Bottom */}

View File

@@ -10,12 +10,14 @@
import type * as analyses from "../analyses.js";
import type * as analysisJobs from "../analysisJobs.js";
import type * as analysisSections from "../analysisSections.js";
import type * as auth from "../auth.js";
import type * as dataSources from "../dataSources.js";
import type * as http from "../http.js";
import type * as opportunities from "../opportunities.js";
import type * as projects from "../projects.js";
import type * as searchJobs from "../searchJobs.js";
import type * as seenUrls from "../seenUrls.js";
import type * as users from "../users.js";
import type {
@@ -27,12 +29,14 @@ import type {
declare const fullApi: ApiFromModules<{
analyses: typeof analyses;
analysisJobs: typeof analysisJobs;
analysisSections: typeof analysisSections;
auth: typeof auth;
dataSources: typeof dataSources;
http: typeof http;
opportunities: typeof opportunities;
projects: typeof projects;
searchJobs: typeof searchJobs;
seenUrls: typeof seenUrls;
users: typeof users;
}>;

View File

@@ -41,6 +41,22 @@ export const getLatestByDataSource = query({
},
});
export const getById = query({
args: { analysisId: v.id("analyses") },
handler: async (ctx, { analysisId }) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const analysis = await ctx.db.get(analysisId);
if (!analysis) return null;
const project = await ctx.db.get(analysis.projectId);
if (!project || project.userId !== userId) return null;
return analysis;
},
});
export const createAnalysis = mutation({
args: {
projectId: v.id("projects"),
@@ -141,7 +157,7 @@ export const createAnalysis = mutation({
throw new Error("Project not found or unauthorized");
}
return await ctx.db.insert("analyses", {
const analysisId = await ctx.db.insert("analyses", {
projectId: args.projectId,
dataSourceId: args.dataSourceId,
createdAt: Date.now(),
@@ -160,5 +176,38 @@ export const createAnalysis = mutation({
dorkQueries: args.analysis.dorkQueries,
scrapedAt: args.analysis.scrapedAt,
});
const now = Date.now();
const sections = [
{
sectionKey: "profile",
items: {
productName: args.analysis.productName,
tagline: args.analysis.tagline,
description: args.analysis.description,
category: args.analysis.category,
positioning: args.analysis.positioning,
},
},
{ sectionKey: "features", items: args.analysis.features },
{ sectionKey: "competitors", items: args.analysis.competitors },
{ sectionKey: "keywords", items: args.analysis.keywords },
{ sectionKey: "problems", items: args.analysis.problemsSolved },
{ sectionKey: "personas", items: args.analysis.personas },
{ sectionKey: "useCases", items: args.analysis.useCases },
{ sectionKey: "dorkQueries", items: args.analysis.dorkQueries },
];
for (const section of sections) {
await ctx.db.insert("analysisSections", {
analysisId,
sectionKey: section.sectionKey,
items: section.items,
source: args.analysis.analysisVersion === "manual" ? "manual" : "ai",
updatedAt: now,
});
}
return analysisId;
},
});

214
convex/analysisSections.ts Normal file
View File

@@ -0,0 +1,214 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
const SECTION_KEYS = [
"profile",
"features",
"competitors",
"keywords",
"problems",
"personas",
"useCases",
"dorkQueries",
] as const;
function assertSectionKey(sectionKey: string) {
if (!SECTION_KEYS.includes(sectionKey as any)) {
throw new Error(`Invalid section key: ${sectionKey}`);
}
}
async function getOwnedAnalysis(ctx: any, analysisId: any) {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const analysis = await ctx.db.get(analysisId);
if (!analysis) throw new Error("Analysis not found");
const project = await ctx.db.get(analysis.projectId);
if (!project || project.userId !== userId) {
throw new Error("Project not found or unauthorized");
}
return analysis;
}
async function patchAnalysisFromSection(
ctx: any,
analysisId: any,
sectionKey: string,
items: any
) {
if (sectionKey === "profile" && items && typeof items === "object") {
const patch: Record<string, any> = {};
if (typeof items.productName === "string") patch.productName = items.productName;
if (typeof items.tagline === "string") patch.tagline = items.tagline;
if (typeof items.description === "string") patch.description = items.description;
if (typeof items.category === "string") patch.category = items.category;
if (typeof items.positioning === "string") patch.positioning = items.positioning;
if (Object.keys(patch).length > 0) {
await ctx.db.patch(analysisId, patch);
}
return;
}
if (sectionKey === "features") {
await ctx.db.patch(analysisId, { features: items });
return;
}
if (sectionKey === "competitors") {
await ctx.db.patch(analysisId, { competitors: items });
return;
}
if (sectionKey === "keywords") {
await ctx.db.patch(analysisId, { keywords: items });
return;
}
if (sectionKey === "problems") {
await ctx.db.patch(analysisId, { problemsSolved: items });
return;
}
if (sectionKey === "personas") {
await ctx.db.patch(analysisId, { personas: items });
return;
}
if (sectionKey === "useCases") {
await ctx.db.patch(analysisId, { useCases: items });
return;
}
if (sectionKey === "dorkQueries") {
await ctx.db.patch(analysisId, { dorkQueries: items });
}
}
export const listByAnalysis = query({
args: { analysisId: v.id("analyses") },
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
return await ctx.db
.query("analysisSections")
.withIndex("by_analysis", (q) => q.eq("analysisId", args.analysisId))
.collect();
},
});
export const getSection = query({
args: { analysisId: v.id("analyses"), sectionKey: v.string() },
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
return await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
},
});
export const replaceSection = mutation({
args: {
analysisId: v.id("analyses"),
sectionKey: v.string(),
items: v.any(),
lastPrompt: v.optional(v.string()),
source: v.union(v.literal("ai"), v.literal("manual"), v.literal("mixed")),
},
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
const existing = await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
if (existing) {
await ctx.db.patch(existing._id, {
items: args.items,
lastPrompt: args.lastPrompt,
source: args.source,
updatedAt: Date.now(),
});
} else {
await ctx.db.insert("analysisSections", {
analysisId: args.analysisId,
sectionKey: args.sectionKey,
items: args.items,
lastPrompt: args.lastPrompt,
source: args.source,
updatedAt: Date.now(),
});
}
await patchAnalysisFromSection(ctx, args.analysisId, args.sectionKey, args.items);
return { success: true };
},
});
export const addItem = mutation({
args: {
analysisId: v.id("analyses"),
sectionKey: v.string(),
item: v.any(),
},
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
const section = await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
if (!section || !Array.isArray(section.items)) {
throw new Error("Section is not editable as a list.");
}
const updated = [...section.items, args.item];
await ctx.db.patch(section._id, {
items: updated,
source: "mixed",
updatedAt: Date.now(),
});
await patchAnalysisFromSection(ctx, args.analysisId, args.sectionKey, updated);
return { success: true };
},
});
export const removeItem = mutation({
args: {
analysisId: v.id("analyses"),
sectionKey: v.string(),
index: v.number(),
},
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
const section = await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
if (!section || !Array.isArray(section.items)) {
throw new Error("Section is not editable as a list.");
}
const updated = section.items.filter((_: any, idx: number) => idx !== args.index);
await ctx.db.patch(section._id, {
items: updated,
source: "mixed",
updatedAt: Date.now(),
});
await patchAnalysisFromSection(ctx, args.analysisId, args.sectionKey, updated);
return { success: true };
},
});

View File

@@ -170,6 +170,13 @@ export const remove = mutation({
)
.collect();
for (const analysis of analyses) {
const sections = await ctx.db
.query("analysisSections")
.withIndex("by_analysis", (q) => q.eq("analysisId", analysis._id))
.collect();
for (const section of sections) {
await ctx.db.delete(section._id);
}
await ctx.db.delete(analysis._id);
}

View File

@@ -122,6 +122,28 @@ export const upsertBatch = mutation({
});
created += 1;
}
const seenExisting = await ctx.db
.query("seenUrls")
.withIndex("by_project_url", (q) =>
q.eq("projectId", args.projectId).eq("url", opp.url)
)
.first();
if (seenExisting) {
await ctx.db.patch(seenExisting._id, {
lastSeenAt: now,
source: "opportunities",
});
} else {
await ctx.db.insert("seenUrls", {
projectId: args.projectId,
url: opp.url,
firstSeenAt: now,
lastSeenAt: now,
source: "opportunities",
});
}
}
return { created, updated };

View File

@@ -83,6 +83,97 @@ export const updateProject = mutation({
},
});
export const deleteProject = mutation({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const projects = await ctx.db
.query("projects")
.withIndex("by_owner", (q) => q.eq("userId", userId))
.collect();
if (projects.length <= 1) {
throw new Error("You must keep at least one project.");
}
const project = projects.find((item) => item._id === args.projectId);
if (!project || project.userId !== userId) {
throw new Error("Project not found or unauthorized");
}
const remainingProjects = projects.filter((item) => item._id !== args.projectId);
let newDefaultId: typeof args.projectId | null = null;
const remainingDefault = remainingProjects.find((item) => item.isDefault);
if (project.isDefault) {
newDefaultId = remainingProjects[0]?._id ?? null;
} else if (!remainingDefault) {
newDefaultId = remainingProjects[0]?._id ?? null;
}
if (newDefaultId) {
await ctx.db.patch(newDefaultId, { isDefault: true });
}
const dataSources = await ctx.db
.query("dataSources")
.filter((q) => q.eq(q.field("projectId"), args.projectId))
.collect();
for (const source of dataSources) {
await ctx.db.delete(source._id);
}
const analyses = await ctx.db
.query("analyses")
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
.collect();
for (const analysis of analyses) {
await ctx.db.delete(analysis._id);
}
const analysisJobs = await ctx.db
.query("analysisJobs")
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
.collect();
for (const job of analysisJobs) {
await ctx.db.delete(job._id);
}
const searchJobs = await ctx.db
.query("searchJobs")
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
.collect();
for (const job of searchJobs) {
await ctx.db.delete(job._id);
}
const opportunities = await ctx.db
.query("opportunities")
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
.collect();
for (const opportunity of opportunities) {
await ctx.db.delete(opportunity._id);
}
const seenUrls = await ctx.db
.query("seenUrls")
.withIndex("by_project_lastSeen", (q) => q.eq("projectId", args.projectId))
.collect();
for (const seen of seenUrls) {
await ctx.db.delete(seen._id);
}
await ctx.db.delete(args.projectId);
return {
deletedProjectId: args.projectId,
newDefaultProjectId: newDefaultId ?? remainingDefault?._id ?? remainingProjects[0]?._id ?? null,
};
},
});
export const toggleDataSourceConfig = mutation({
args: { projectId: v.id("projects"), sourceId: v.id("dataSources"), selected: v.boolean() },
handler: async (ctx, args) => {

View File

@@ -125,6 +125,16 @@ const schema = defineSchema({
})
.index("by_project_createdAt", ["projectId", "createdAt"])
.index("by_dataSource_createdAt", ["dataSourceId", "createdAt"]),
analysisSections: defineTable({
analysisId: v.id("analyses"),
sectionKey: v.string(),
items: v.any(),
lastPrompt: v.optional(v.string()),
source: v.union(v.literal("ai"), v.literal("manual"), v.literal("mixed")),
updatedAt: v.number(),
})
.index("by_analysis", ["analysisId"])
.index("by_analysis_section", ["analysisId", "sectionKey"]),
opportunities: defineTable({
projectId: v.id("projects"),
analysisId: v.optional(v.id("analyses")),
@@ -147,6 +157,15 @@ const schema = defineSchema({
.index("by_project_status", ["projectId", "status"])
.index("by_project_createdAt", ["projectId", "createdAt"])
.index("by_project_url", ["projectId", "url"]),
seenUrls: defineTable({
projectId: v.id("projects"),
url: v.string(),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
source: v.optional(v.string()),
})
.index("by_project_url", ["projectId", "url"])
.index("by_project_lastSeen", ["projectId", "lastSeenAt"]),
analysisJobs: defineTable({
projectId: v.id("projects"),
dataSourceId: v.optional(v.id("dataSources")),

71
convex/seenUrls.ts Normal file
View File

@@ -0,0 +1,71 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
export const listExisting = query({
args: {
projectId: v.id("projects"),
urls: v.array(v.string()),
},
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 existing: string[] = [];
for (const url of args.urls) {
const match = await ctx.db
.query("seenUrls")
.withIndex("by_project_url", (q) =>
q.eq("projectId", args.projectId).eq("url", url)
)
.first();
if (match) existing.push(url);
}
return existing;
},
});
export const markSeenBatch = mutation({
args: {
projectId: v.id("projects"),
urls: v.array(v.string()),
source: v.optional(v.string()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const project = await ctx.db.get(args.projectId);
if (!project || project.userId !== userId) {
throw new Error("Project not found or unauthorized");
}
const now = Date.now();
for (const url of args.urls) {
const existing = await ctx.db
.query("seenUrls")
.withIndex("by_project_url", (q) =>
q.eq("projectId", args.projectId).eq("url", url)
)
.first();
if (existing) {
await ctx.db.patch(existing._id, {
lastSeenAt: now,
source: args.source ?? existing.source,
});
} else {
await ctx.db.insert("seenUrls", {
projectId: args.projectId,
url,
firstSeenAt: now,
lastSeenAt: now,
source: args.source,
});
}
}
},
});

View File

@@ -9,3 +9,18 @@ export const getCurrent = query({
return await ctx.db.get(userId);
},
});
export const getCurrentProfile = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const user = await ctx.db.get(userId);
if (!user) return null;
const accounts = await ctx.db
.query("authAccounts")
.withIndex("userIdAndProvider", (q) => q.eq("userId", userId))
.collect();
return { user, accounts };
},
});

View File

@@ -15,6 +15,18 @@ const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
})
type ProductProfile = {
productName: string
category: string
primaryJTBD: string
targetPersona: string
scopeBoundary: string
nonGoals: string[]
differentiators: string[]
evidence: { claim: string; snippet: string }[]
confidence: number
}
async function aiGenerate<T>(prompt: string, systemPrompt: string, temperature: number = 0.3): Promise<T> {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
@@ -39,7 +51,53 @@ async function aiGenerate<T>(prompt: string, systemPrompt: string, temperature:
}
}
async function extractFeatures(content: ScrapedContent): Promise<Feature[]> {
function buildEvidenceContext(content: ScrapedContent) {
return [
`Title: ${content.title}`,
`Description: ${content.metaDescription}`,
`Headings: ${content.headings.slice(0, 20).join('\n')}`,
`Feature Lists: ${content.featureList.slice(0, 20).join('\n')}`,
`Paragraphs: ${content.paragraphs.slice(0, 12).join('\n\n')}`,
].join('\n\n')
}
async function extractProductProfile(content: ScrapedContent, extraPrompt?: string): Promise<ProductProfile> {
const systemPrompt = `You are a strict product analyst. Only use provided evidence. If uncertain, answer "unknown" and lower confidence. Return JSON only.`
const prompt = `Analyze the product using evidence only.
${buildEvidenceContext(content)}
Return JSON:
{
"productName": "...",
"category": "...",
"primaryJTBD": "...",
"targetPersona": "...",
"scopeBoundary": "...",
"nonGoals": ["..."],
"differentiators": ["..."],
"evidence": [{"claim": "...", "snippet": "..."}],
"confidence": 0.0
}
Rules:
- "category" should be a concrete market category, not "software/tool/platform".
- "scopeBoundary" must state what the product does NOT do.
- "nonGoals" should be explicit exclusions inferred from evidence.
- "evidence.snippet" must quote or paraphrase short evidence from the text above.
${extraPrompt ? `\nUser guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<ProductProfile>(prompt, systemPrompt, 0.2)
return {
...result,
nonGoals: result.nonGoals?.slice(0, 6) ?? [],
differentiators: result.differentiators?.slice(0, 6) ?? [],
evidence: result.evidence?.slice(0, 6) ?? [],
confidence: typeof result.confidence === "number" ? result.confidence : 0.3,
}
}
async function extractFeatures(content: ScrapedContent, extraPrompt?: string): Promise<Feature[]> {
const systemPrompt = `Extract EVERY feature from website content. Be exhaustive.`
const prompt = `Extract features from:
Title: ${content.title}
@@ -49,19 +107,79 @@ Paragraphs: ${content.paragraphs.slice(0, 10).join('\n\n')}
Feature Lists: ${content.featureList.slice(0, 15).join('\n')}
Return JSON: {"features": [{"name": "...", "description": "...", "benefits": ["..."], "useCases": ["..."]}]}
Aim for 10-15 features.`
Aim for 10-15 features.
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ features: Feature[] }>(prompt, systemPrompt, 0.4)
return result.features.slice(0, 20)
}
async function identifyCompetitors(content: ScrapedContent): Promise<Competitor[]> {
const systemPrompt = `Identify real, named competitors. Use actual company/product names like "Asana", "Jira", "Monday.com", "Trello", "Notion". Never use generic names like "Competitor A".`
async function generateCompetitorCandidates(
profile: ProductProfile,
extraPrompt?: string
): Promise<{ candidates: { name: string; type: "direct" | "adjacent" | "generic"; rationale: string; confidence: number }[] }> {
const systemPrompt = `Generate candidate competitors based only on the product profile. Return JSON only.`
const prompt = `Product profile:
Name: ${profile.productName}
Category: ${profile.category}
JTBD: ${profile.primaryJTBD}
Target persona: ${profile.targetPersona}
Scope boundary: ${profile.scopeBoundary}
Non-goals: ${profile.nonGoals.join(", ") || "unknown"}
Differentiators: ${profile.differentiators.join(", ") || "unknown"}
const prompt = `Identify 5-6 real competitors for: ${content.title}
Description: ${content.metaDescription}
Evidence (for context):
${profile.evidence.map(e => `- ${e.claim}: ${e.snippet}`).join("\n")}
Return EXACT JSON format:
Rules:
- Output real product/company names only.
- Classify as "direct" if same JTBD + same persona + same category.
- "adjacent" if overlap partially.
- "generic" for broad tools people misuse for this (only include if evidence suggests).
- Avoid broad suites unless the category is that suite.
${extraPrompt ? `User guidance: ${extraPrompt}\n` : ""}
Return JSON:
{
"candidates": [
{ "name": "Product", "type": "direct|adjacent|generic", "rationale": "...", "confidence": 0.0 }
]
}`
return await aiGenerate<{ candidates: { name: string; type: "direct" | "adjacent" | "generic"; rationale: string; confidence: number }[] }>(
prompt,
systemPrompt,
0.2
)
}
async function selectDirectCompetitors(
profile: ProductProfile,
candidates: { name: string; type: "direct" | "adjacent" | "generic"; rationale: string; confidence: number }[],
extraPrompt?: string
): Promise<Competitor[]> {
const systemPrompt = `You are a strict verifier. Only accept direct competitors. Return JSON only.`
const prompt = `Product profile:
Name: ${profile.productName}
Category: ${profile.category}
JTBD: ${profile.primaryJTBD}
Target persona: ${profile.targetPersona}
Scope boundary: ${profile.scopeBoundary}
Non-goals: ${profile.nonGoals.join(", ") || "unknown"}
Differentiators: ${profile.differentiators.join(", ") || "unknown"}
Candidates:
${candidates.map(c => `- ${c.name} (${c.type}) : ${c.rationale}`).join("\n")}
Rules:
- Only keep DIRECT competitors (same JTBD + persona + category).
- Reject "generic" tools unless the category itself is generic.
- Provide 3-6 competitors. If fewer, include the closest adjacent but label as direct only if truly overlapping.
${extraPrompt ? `User guidance: ${extraPrompt}\n` : ""}
Return JSON:
{
"competitors": [
{
@@ -72,28 +190,35 @@ Return EXACT JSON format:
"theirWeakness": "Their main weakness"
}
]
}`
const result = await aiGenerate<{ competitors: Competitor[] }>(prompt, systemPrompt, 0.2)
return result.competitors
.map(c => ({
...c,
name: c.name.replace(/^Competitor\s+[A-Z]$/i, 'Alternative Solution').replace(/^Generic\s+/i, '')
}))
.filter(c => c.name.length > 1)
}
Include: Direct competitors (same space), Big players, Popular alternatives, Tools people misuse for this. Use ONLY real product names.`
const result = await aiGenerate<{ competitors: Competitor[] }>(prompt, systemPrompt, 0.3)
// Validate competitor names aren't generic
return result.competitors.map(c => ({
...c,
name: c.name.replace(/^Competitor\s+[A-Z]$/i, 'Alternative Solution').replace(/^Generic\s+/i, '')
})).filter(c => c.name.length > 1)
}
async function generateKeywords(features: Feature[], content: ScrapedContent, competitors: Competitor[]): Promise<Keyword[]> {
async function generateKeywords(
features: Feature[],
content: ScrapedContent,
competitors: Competitor[],
extraPrompt?: string
): Promise<Keyword[]> {
const systemPrompt = `Generate search-ready phrases users would actually type.`
const featuresText = features.map(f => f.name).join(', ')
const competitorNames = competitors.map(c => c.name).filter(n => n.length > 1).join(', ') || 'Jira, Asana, Monday, Trello'
const competitorNames = competitors.map(c => c.name).filter(n => n.length > 1).join(', ')
const differentiatorGuidance = competitorNames
? `Generate 20+ differentiator phrases comparing to: ${competitorNames}`
: `If no competitors are provided, do not invent them. Reduce differentiator share to 5% using generic phrases like "alternatives to X category".`
const prompt = `Generate 60-80 search phrases for: ${content.title}
Features: ${featuresText}
Competitors: ${competitorNames}
Competitors: ${competitorNames || "None"}
CRITICAL - Follow this priority:
1. 60% 2-4 word phrases (e.g., "client onboarding checklist", "bug triage workflow")
@@ -102,7 +227,8 @@ CRITICAL - Follow this priority:
Return JSON: {"keywords": [{"term": "phrase", "type": "differentiator|product|feature|problem|solution|competitor", "searchVolume": "high|medium|low", "intent": "informational|navigational|transactional", "funnel": "awareness|consideration|decision", "emotionalIntensity": "frustrated|curious|ready"}]}
Generate 20+ differentiator phrases comparing to: ${competitorNames}`
${differentiatorGuidance}
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ keywords: Keyword[] }>(prompt, systemPrompt, 0.4)
@@ -143,36 +269,52 @@ Generate 20+ differentiator phrases comparing to: ${competitorNames}`
}).slice(0, 80)
}
async function identifyProblems(features: Feature[], content: ScrapedContent): Promise<Problem[]> {
async function identifyProblems(
features: Feature[],
content: ScrapedContent,
extraPrompt?: string
): Promise<Problem[]> {
const systemPrompt = `Identify problems using JTBD framework.`
const prompt = `Identify 8-12 problems solved by: ${features.map(f => f.name).join(', ')}
Content: ${content.rawText.slice(0, 3000)}
Return JSON: {"problems": [{"problem": "...", "severity": "high|medium|low", "currentWorkarounds": ["..."], "emotionalImpact": "...", "searchTerms": ["..."]}]}`
Return JSON: {"problems": [{"problem": "...", "severity": "high|medium|low", "currentWorkarounds": ["..."], "emotionalImpact": "...", "searchTerms": ["..."]}]}
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ problems: Problem[] }>(prompt, systemPrompt, 0.4)
return result.problems
}
async function generatePersonas(content: ScrapedContent, problems: Problem[]): Promise<Persona[]> {
async function generatePersonas(
content: ScrapedContent,
problems: Problem[],
extraPrompt?: string
): Promise<Persona[]> {
const systemPrompt = `Create diverse user personas with search behavior.`
const prompt = `Create 4-5 personas for: ${content.title}
Description: ${content.metaDescription}
Problems: ${problems.map(p => p.problem).slice(0, 5).join(', ')}
Return JSON: {"personas": [{"name": "Descriptive name", "role": "Job title", "companySize": "e.g. 10-50 employees", "industry": "...", "painPoints": ["..."], "goals": ["..."], "techSavvy": "low|medium|high", "objections": ["..."], "searchBehavior": ["..."]}]}`
Return JSON: {"personas": [{"name": "Descriptive name", "role": "Job title", "companySize": "e.g. 10-50 employees", "industry": "...", "painPoints": ["..."], "goals": ["..."], "techSavvy": "low|medium|high", "objections": ["..."], "searchBehavior": ["..."]}]}
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ personas: Persona[] }>(prompt, systemPrompt, 0.5)
return result.personas
}
async function generateUseCases(features: Feature[], personas: Persona[], problems: Problem[]): Promise<UseCase[]> {
async function generateUseCases(
features: Feature[],
personas: Persona[],
problems: Problem[],
extraPrompt?: string
): Promise<UseCase[]> {
const systemPrompt = `Create JTBD use case scenarios.`
const prompt = `Create 10 use cases.
Features: ${features.map(f => f.name).slice(0, 5).join(', ')}
Problems: ${problems.map(p => p.problem).slice(0, 3).join(', ')}
Return JSON: {"useCases": [{"scenario": "...", "trigger": "...", "emotionalState": "...", "currentWorkflow": ["..."], "desiredOutcome": "...", "alternativeProducts": ["..."], "whyThisProduct": "...", "churnRisk": ["..."]}]}`
Return JSON: {"useCases": [{"scenario": "...", "trigger": "...", "emotionalState": "...", "currentWorkflow": ["..."], "desiredOutcome": "...", "alternativeProducts": ["..."], "whyThisProduct": "...", "churnRisk": ["..."]}]}
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ useCases: UseCase[] }>(prompt, systemPrompt, 0.5)
return result.useCases
@@ -232,18 +374,128 @@ function generateDorkQueries(keywords: Keyword[], problems: Problem[], useCases:
return queries
}
async function generateDorkQueriesWithAI(
analysis: EnhancedProductAnalysis,
extraPrompt?: string
): Promise<DorkQuery[]> {
const systemPrompt = `Generate high-signal search queries for forums. Return JSON only.`
const prompt = `Create 40-60 dork queries.
Product: ${analysis.productName}
Category: ${analysis.category}
Positioning: ${analysis.positioning}
Keywords: ${analysis.keywords.map(k => k.term).slice(0, 25).join(", ")}
Problems: ${analysis.problemsSolved.map(p => p.problem).slice(0, 10).join(", ")}
Competitors: ${analysis.competitors.map(c => c.name).slice(0, 10).join(", ")}
Use cases: ${analysis.useCases.map(u => u.scenario).slice(0, 8).join(", ")}
Rules:
- Use these platforms only: reddit, hackernews, indiehackers, quora, stackoverflow, twitter.
- Include intent: looking-for, frustrated, alternative, comparison, problem-solving, tutorial.
- Prefer query patterns like site:reddit.com "phrase" ...
Return JSON:
{"dorkQueries": [{"query": "...", "platform": "reddit|hackernews|indiehackers|twitter|quora|stackoverflow", "intent": "looking-for|frustrated|alternative|comparison|problem-solving|tutorial", "priority": "high|medium|low"}]}
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ dorkQueries: DorkQuery[] }>(prompt, systemPrompt, 0.3)
return result.dorkQueries
}
type AnalysisProgressUpdate = {
key: "features" | "competitors" | "keywords" | "problems" | "useCases" | "dorkQueries"
status: "running" | "completed"
detail?: string
}
export async function repromptSection(
sectionKey: "profile" | "features" | "competitors" | "keywords" | "problems" | "personas" | "useCases" | "dorkQueries",
content: ScrapedContent,
analysis: EnhancedProductAnalysis,
extraPrompt?: string
): Promise<any> {
if (sectionKey === "profile") {
const profile = await extractProductProfile(content, extraPrompt);
const tagline = content.metaDescription.split(".")[0];
const positioning = [
profile.primaryJTBD && profile.primaryJTBD !== "unknown"
? profile.primaryJTBD
: "",
profile.targetPersona && profile.targetPersona !== "unknown"
? `for ${profile.targetPersona}`
: "",
].filter(Boolean).join(" ");
return {
productName: profile.productName && profile.productName !== "unknown" ? profile.productName : analysis.productName,
tagline,
description: content.metaDescription,
category: profile.category && profile.category !== "unknown" ? profile.category : analysis.category,
positioning,
primaryJTBD: profile.primaryJTBD,
targetPersona: profile.targetPersona,
scopeBoundary: profile.scopeBoundary,
nonGoals: profile.nonGoals,
differentiators: profile.differentiators,
evidence: profile.evidence,
confidence: profile.confidence,
};
}
if (sectionKey === "features") {
return await extractFeatures(content, extraPrompt);
}
if (sectionKey === "competitors") {
const profile = await extractProductProfile(content, extraPrompt);
const candidateSet = await generateCompetitorCandidates(profile, extraPrompt);
return await selectDirectCompetitors(profile, candidateSet.candidates, extraPrompt);
}
if (sectionKey === "keywords") {
const features = analysis.features?.length ? analysis.features : await extractFeatures(content);
return await generateKeywords(features, content, analysis.competitors || [], extraPrompt);
}
if (sectionKey === "problems") {
const features = analysis.features?.length ? analysis.features : await extractFeatures(content);
return await identifyProblems(features, content, extraPrompt);
}
if (sectionKey === "personas") {
const problems = analysis.problemsSolved?.length
? analysis.problemsSolved
: await identifyProblems(analysis.features || [], content);
return await generatePersonas(content, problems, extraPrompt);
}
if (sectionKey === "useCases") {
const features = analysis.features?.length ? analysis.features : await extractFeatures(content);
const problems = analysis.problemsSolved?.length
? analysis.problemsSolved
: await identifyProblems(features, content);
const personas = analysis.personas?.length
? analysis.personas
: await generatePersonas(content, problems);
return await generateUseCases(features, personas, problems, extraPrompt);
}
if (sectionKey === "dorkQueries") {
return await generateDorkQueriesWithAI(analysis, extraPrompt);
}
throw new Error(`Unsupported section key: ${sectionKey}`);
}
export async function performDeepAnalysis(
content: ScrapedContent,
onProgress?: (update: AnalysisProgressUpdate) => void | Promise<void>
): Promise<EnhancedProductAnalysis> {
console.log('🔍 Starting deep analysis...')
console.log(' 🧭 Product profiling...')
const productProfile = await extractProductProfile(content)
console.log(` ✓ Profiled as ${productProfile.category} for ${productProfile.targetPersona} (conf ${productProfile.confidence})`)
console.log(' 📦 Pass 1: Features...')
await onProgress?.({ key: "features", status: "running" })
const features = await extractFeatures(content)
@@ -252,7 +504,8 @@ export async function performDeepAnalysis(
console.log(' 🏆 Pass 2: Competitors...')
await onProgress?.({ key: "competitors", status: "running" })
const competitors = await identifyCompetitors(content)
const candidateSet = await generateCompetitorCandidates(productProfile)
const competitors = await selectDirectCompetitors(productProfile, candidateSet.candidates)
console.log(`${competitors.length} competitors: ${competitors.map(c => c.name).join(', ')}`)
await onProgress?.({
key: "competitors",
@@ -295,13 +548,21 @@ export async function performDeepAnalysis(
const productName = content.title.split(/[\|\-–—:]/)[0].trim()
const tagline = content.metaDescription.split('.')[0]
const positioning = [
productProfile.primaryJTBD && productProfile.primaryJTBD !== "unknown"
? productProfile.primaryJTBD
: "",
productProfile.targetPersona && productProfile.targetPersona !== "unknown"
? `for ${productProfile.targetPersona}`
: "",
].filter(Boolean).join(" ")
return {
productName,
productName: productProfile.productName && productProfile.productName !== "unknown" ? productProfile.productName : productName,
tagline,
description: content.metaDescription,
category: '',
positioning: '',
category: productProfile.category && productProfile.category !== "unknown" ? productProfile.category : '',
positioning,
features,
problemsSolved: problems,
personas,
@@ -310,6 +571,6 @@ export async function performDeepAnalysis(
competitors,
dorkQueries,
scrapedAt: new Date().toISOString(),
analysisVersion: '2.1-optimized'
analysisVersion: '2.2-profiled'
}
}

View File

@@ -6,7 +6,7 @@ import type {
PlatformId
} from './types'
export function getDefaultPlatforms(): Record<PlatformId, { name: string; icon: string; rateLimit: number; enabled: boolean }> {
export function getDefaultPlatforms(): Record<PlatformId, { name: string; icon: string; rateLimit: number; enabled: boolean; searchTemplate: string }> {
return {
reddit: {
name: 'Reddit',
@@ -285,7 +285,7 @@ function buildRecommendationQueries(
}))
}
const SITE_OPERATORS: Record<PlatformId, string> = {
const SITE_OPERATORS: Record<string, string> = {
reddit: 'site:reddit.com',
twitter: 'site:twitter.com OR site:x.com',
hackernews: 'site:news.ycombinator.com',
@@ -295,7 +295,7 @@ const SITE_OPERATORS: Record<PlatformId, string> = {
linkedin: 'site:linkedin.com',
}
const DEFAULT_TEMPLATES: Record<PlatformId, string> = {
const DEFAULT_TEMPLATES: Record<string, string> = {
reddit: '{site} {term} {intent}',
twitter: '{site} {term} {intent}',
hackernews: '{site} ("Ask HN" OR "Show HN") {term} {intent}',
@@ -310,15 +310,18 @@ function applyTemplate(template: string, vars: Record<string, string>): string {
}
function buildPlatformQuery(
platform: { id: PlatformId; searchTemplate?: string },
platform: { id: PlatformId; searchTemplate?: string; site?: string },
term: string,
intent: string
): string {
const template = (platform.searchTemplate && platform.searchTemplate.trim().length > 0)
? platform.searchTemplate
: DEFAULT_TEMPLATES[platform.id]
: DEFAULT_TEMPLATES[platform.id] ?? '{site} {term} {intent}'
const siteOperator = platform.site && platform.site.trim().length > 0
? `site:${platform.site.trim()}`
: (SITE_OPERATORS[platform.id] ?? '')
const raw = applyTemplate(template, {
site: SITE_OPERATORS[platform.id],
site: siteOperator,
term,
intent,
})

View File

@@ -95,9 +95,7 @@ export function scoreOpportunities(
seen.add(result.url)
const scored = scoreSingleOpportunity(result, analysis)
if (scored.relevanceScore >= 0.3) {
opportunities.push(scored)
}
opportunities.push(scored)
}
return opportunities.sort((a, b) => b.relevanceScore - a.relevanceScore)

View File

@@ -1,6 +1,6 @@
// Enhanced Types for Deep Analysis
export type PlatformId = 'reddit' | 'twitter' | 'hackernews' | 'indiehackers' | 'quora' | 'stackoverflow' | 'linkedin'
export type PlatformId = string
export type SearchStrategy =
| 'direct-keywords'
@@ -18,6 +18,8 @@ export interface PlatformConfig {
enabled: boolean
searchTemplate: string
rateLimit: number
site?: string
custom?: boolean
}
export interface SearchConfig {

135
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@auth/core": "^0.37.4",
"@convex-dev/auth": "^0.0.90",
"@polar-sh/nextjs": "^0.9.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -55,6 +56,7 @@
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -616,6 +618,7 @@
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -624,6 +627,7 @@
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -631,10 +635,12 @@
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -787,6 +793,7 @@
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -798,6 +805,7 @@
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -805,6 +813,7 @@
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -854,6 +863,39 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@polar-sh/adapter-utils": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@polar-sh/adapter-utils/-/adapter-utils-0.4.3.tgz",
"integrity": "sha512-9xjOyaAVWRFaFZKkSdZr8J6i5LToUoFKeoczXCEu6+uxAhKmkjA3Qlc8otI4be1DSouWgrYB/VUGHOW7dIgCuQ==",
"license": "Apache-2.0",
"dependencies": {
"@polar-sh/sdk": "^0.42.1"
}
},
"node_modules/@polar-sh/nextjs": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/@polar-sh/nextjs/-/nextjs-0.9.3.tgz",
"integrity": "sha512-3laah76J2qqt/dln/GFL5XaVFdwVaY5xsrMqIX2nNf/5/6frrodth88eGie0UOIhA6OUvmZMuKnW2tGaroMi5g==",
"dependencies": {
"@polar-sh/adapter-utils": "0.4.3",
"@polar-sh/sdk": "^0.42.1"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"next": "^15.0.0 || ^16.0.0"
}
},
"node_modules/@polar-sh/sdk": {
"version": "0.42.5",
"resolved": "https://registry.npmjs.org/@polar-sh/sdk/-/sdk-0.42.5.tgz",
"integrity": "sha512-GzC3/ElCtMO55+KeXwFTANlydZzw5qI3DU/F9vAFIsUKuegSmh+Xu03KCL+ct9/imJOvLUQucYhUSsNKqo2j2Q==",
"dependencies": {
"standardwebhooks": "^1.0.0",
"zod": "^3.25.65 || ^4.0.0"
}
},
"node_modules/@puppeteer/browsers": {
"version": "2.3.0",
"license": "Apache-2.0",
@@ -1879,6 +1921,12 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.93.3",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.93.3.tgz",
@@ -2012,12 +2060,12 @@
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -2026,7 +2074,7 @@
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -2108,10 +2156,12 @@
},
"node_modules/any-promise": {
"version": "1.3.0",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -2125,6 +2175,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -2135,6 +2186,7 @@
},
"node_modules/arg": {
"version": "5.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -2328,6 +2380,7 @@
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2338,6 +2391,7 @@
},
"node_modules/braces": {
"version": "3.0.3",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -2436,6 +2490,7 @@
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -2461,6 +2516,7 @@
},
"node_modules/chokidar": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -2483,6 +2539,7 @@
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -2571,6 +2628,7 @@
},
"node_modules/commander": {
"version": "4.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -2648,6 +2706,7 @@
},
"node_modules/cssesc": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -2658,7 +2717,7 @@
},
"node_modules/csstype": {
"version": "3.2.3",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
@@ -2714,10 +2773,12 @@
},
"node_modules/didyoumean": {
"version": "1.2.2",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"dev": true,
"license": "MIT"
},
"node_modules/dunder-proto": {
@@ -2929,6 +2990,7 @@
},
"node_modules/fast-glob": {
"version": "3.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -2943,6 +3005,7 @@
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -2951,8 +3014,15 @@
"node": ">= 6"
}
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": {
"version": "1.20.1",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -2967,6 +3037,7 @@
},
"node_modules/fdir": {
"version": "6.5.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -2982,6 +3053,7 @@
},
"node_modules/fill-range": {
"version": "7.1.1",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -3062,6 +3134,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -3155,6 +3228,7 @@
},
"node_modules/glob-parent": {
"version": "6.0.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@@ -3293,6 +3367,7 @@
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -3303,6 +3378,7 @@
},
"node_modules/is-core-module": {
"version": "2.16.1",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -3316,6 +3392,7 @@
},
"node_modules/is-extglob": {
"version": "2.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3330,6 +3407,7 @@
},
"node_modules/is-glob": {
"version": "4.0.3",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -3352,6 +3430,7 @@
},
"node_modules/is-number": {
"version": "7.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -3359,6 +3438,7 @@
},
"node_modules/jiti": {
"version": "1.21.7",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -3402,6 +3482,7 @@
},
"node_modules/lilconfig": {
"version": "3.1.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -3460,6 +3541,7 @@
},
"node_modules/merge2": {
"version": "1.4.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -3467,6 +3549,7 @@
},
"node_modules/micromatch": {
"version": "4.0.8",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -3480,6 +3563,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -3539,6 +3623,7 @@
},
"node_modules/mz": {
"version": "2.7.0",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@@ -3691,6 +3776,7 @@
},
"node_modules/normalize-path": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3707,6 +3793,7 @@
},
"node_modules/object-assign": {
"version": "4.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3714,6 +3801,7 @@
},
"node_modules/object-hash": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3821,6 +3909,7 @@
},
"node_modules/path-parse": {
"version": "1.0.7",
"dev": true,
"license": "MIT"
},
"node_modules/path-to-regexp": {
@@ -3841,6 +3930,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -3851,6 +3941,7 @@
},
"node_modules/pify": {
"version": "2.3.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3858,6 +3949,7 @@
},
"node_modules/pirates": {
"version": "4.0.7",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3865,6 +3957,7 @@
},
"node_modules/postcss": {
"version": "8.5.6",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3891,6 +3984,7 @@
},
"node_modules/postcss-import": {
"version": "15.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@@ -3906,6 +4000,7 @@
},
"node_modules/postcss-js": {
"version": "4.1.0",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3929,6 +4024,7 @@
},
"node_modules/postcss-load-config": {
"version": "6.0.1",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3969,6 +4065,7 @@
},
"node_modules/postcss-nested": {
"version": "6.2.0",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3992,6 +4089,7 @@
},
"node_modules/postcss-selector-parser": {
"version": "6.1.2",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -4003,6 +4101,7 @@
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"dev": true,
"license": "MIT"
},
"node_modules/preact": {
@@ -4108,6 +4207,7 @@
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"dev": true,
"funding": [
{
"type": "github",
@@ -4216,6 +4316,7 @@
},
"node_modules/read-cache": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@@ -4223,6 +4324,7 @@
},
"node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -4235,6 +4337,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -4252,6 +4355,7 @@
},
"node_modules/resolve": {
"version": "1.22.11",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@@ -4277,6 +4381,7 @@
},
"node_modules/reusify": {
"version": "1.1.0",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -4285,6 +4390,7 @@
},
"node_modules/run-parallel": {
"version": "1.2.0",
"dev": true,
"funding": [
{
"type": "github",
@@ -4374,6 +4480,16 @@
"node": ">=0.10.0"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"engines": {
@@ -4434,6 +4550,7 @@
},
"node_modules/sucrase": {
"version": "3.35.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@@ -4454,6 +4571,7 @@
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4474,6 +4592,7 @@
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -4544,6 +4663,7 @@
},
"node_modules/thenify": {
"version": "3.3.1",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@@ -4551,6 +4671,7 @@
},
"node_modules/thenify-all": {
"version": "1.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@@ -4565,6 +4686,7 @@
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -4579,6 +4701,7 @@
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -4593,6 +4716,7 @@
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
@@ -4601,7 +4725,7 @@
},
"node_modules/typescript": {
"version": "5.9.3",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -4710,6 +4834,7 @@
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/web-streams-polyfill": {

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@auth/core": "^0.37.4",
"@convex-dev/auth": "^0.0.90",
"@polar-sh/nextjs": "^0.9.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",