a
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
304
app/(app)/leads/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 {site}, {term}, and {intent}.
|
||||
</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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
87
app/api/analysis/reprompt/route.ts
Normal file
87
app/api/analysis/reprompt/route.ts
Normal 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
21
app/api/checkout/route.ts
Normal 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();
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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'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 - Feature 2 - 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
|
||||
|
||||
326
components/analysis-section-editor.tsx
Normal file
326
components/analysis-section-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -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
214
convex/analysisSections.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
71
convex/seenUrls.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
135
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user