feat: Implement core application structure with new dashboard, settings, and help pages, and enhance opportunities management with persistence and filtering.
This commit is contained in:
132
app/(app)/dashboard/page.tsx
Normal file
132
app/(app)/dashboard/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQuery } from "convex/react"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { useProject } from "@/components/project-context"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { selectedProjectId } = useProject()
|
||||||
|
const projects = useQuery(api.projects.getProjects)
|
||||||
|
const analysis = useQuery(
|
||||||
|
api.analyses.getLatestByProject,
|
||||||
|
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedProject = projects?.find((project) => project._id === selectedProjectId)
|
||||||
|
const isLoading = selectedProjectId && analysis === undefined
|
||||||
|
|
||||||
|
if (!selectedProjectId && projects && projects.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-10 text-center">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-xl font-semibold">No projects yet</h2>
|
||||||
|
<p className="text-muted-foreground">Complete onboarding to create your first project.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-10 text-center text-muted-foreground">
|
||||||
|
Loading analysis...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!analysis) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-10 text-center">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-xl font-semibold">No analysis yet</h2>
|
||||||
|
<p className="text-muted-foreground">Run onboarding to analyze a product for this project.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold">{analysis.productName}</h1>
|
||||||
|
{selectedProject?.name && (
|
||||||
|
<Badge variant="outline">{selectedProject.name}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">{analysis.tagline}</p>
|
||||||
|
<p className="max-w-3xl text-sm text-muted-foreground">{analysis.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">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>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-semibold">{analysis.keywords.length}</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Personas</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-semibold">{analysis.personas.length}</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Competitors</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-semibold">{analysis.competitors.length}</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Use Cases</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-semibold">{analysis.useCases.length}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Top Features</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{analysis.features.slice(0, 6).map((feature, index) => (
|
||||||
|
<div key={`${feature.name}-${index}`} className="flex items-start gap-2">
|
||||||
|
<span className="mt-1 h-2 w-2 rounded-full bg-primary" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{feature.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Top Problems Solved</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{analysis.problemsSolved.slice(0, 6).map((problem, index) => (
|
||||||
|
<div key={`${problem.problem}-${index}`} className="flex items-start gap-2">
|
||||||
|
<span className="mt-1 h-2 w-2 rounded-full bg-primary" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{problem.problem}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{problem.emotionalImpact}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
app/(app)/help/page.tsx
Normal file
54
app/(app)/help/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
||||||
|
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>
|
||||||
|
<p className="text-muted-foreground">Tips for getting the most out of Sanati.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Quickstart</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>1. Run onboarding with your product URL or manual details.</p>
|
||||||
|
<p>2. Open Opportunities and pick platforms + strategies.</p>
|
||||||
|
<p>3. Review matches, generate replies, and track status.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Outreach Tips</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>Lead with empathy and context, not a hard pitch.</p>
|
||||||
|
<p>Reference the specific problem the post mentions.</p>
|
||||||
|
<p>Offer help or a quick walkthrough before asking for a call.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Common Issues</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Scrape fails</p>
|
||||||
|
<p>Use manual input or try a different URL (homepage works best).</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Search returns few results</p>
|
||||||
|
<p>Enable Serper API key or broaden strategies in the search config.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,22 +1,27 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Sidebar } from '@/components/sidebar'
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
import { useSearchParams } from 'next/navigation'
|
import {
|
||||||
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
import { ProjectProvider } from "@/components/project-context"
|
||||||
|
|
||||||
export default function AppLayout({
|
export default function AppLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const productName = searchParams.get('product') || undefined
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<SidebarProvider>
|
||||||
<Sidebar productName={productName} />
|
<ProjectProvider>
|
||||||
<main className="flex-1 overflow-auto">
|
<AppSidebar />
|
||||||
|
<SidebarInset>
|
||||||
|
<div className="flex min-h-svh flex-1 flex-col bg-background">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
</SidebarInset>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useMutation, useQuery } from 'convex/react'
|
||||||
|
import { api } from '@/convex/_generated/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||||
@@ -47,6 +50,7 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Copy
|
Copy
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { useProject } from '@/components/project-context'
|
||||||
import type {
|
import type {
|
||||||
EnhancedProductAnalysis,
|
EnhancedProductAnalysis,
|
||||||
Opportunity,
|
Opportunity,
|
||||||
@@ -66,6 +70,9 @@ const STRATEGY_INFO: Record<SearchStrategy, { name: string; description: string
|
|||||||
|
|
||||||
export default function OpportunitiesPage() {
|
export default function OpportunitiesPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { selectedProjectId } = useProject()
|
||||||
|
const upsertOpportunities = useMutation(api.opportunities.upsertBatch)
|
||||||
|
const updateOpportunity = useMutation(api.opportunities.updateStatus)
|
||||||
const [analysis, setAnalysis] = useState<EnhancedProductAnalysis | null>(null)
|
const [analysis, setAnalysis] = useState<EnhancedProductAnalysis | null>(null)
|
||||||
const [platforms, setPlatforms] = useState<PlatformConfig[]>([])
|
const [platforms, setPlatforms] = useState<PlatformConfig[]>([])
|
||||||
const [strategies, setStrategies] = useState<SearchStrategy[]>([
|
const [strategies, setStrategies] = useState<SearchStrategy[]>([
|
||||||
@@ -81,6 +88,54 @@ export default function OpportunitiesPage() {
|
|||||||
const [selectedOpportunity, setSelectedOpportunity] = useState<Opportunity | null>(null)
|
const [selectedOpportunity, setSelectedOpportunity] = useState<Opportunity | null>(null)
|
||||||
const [replyText, setReplyText] = useState('')
|
const [replyText, setReplyText] = useState('')
|
||||||
const [stats, setStats] = useState<any>(null)
|
const [stats, setStats] = useState<any>(null)
|
||||||
|
const [searchError, setSearchError] = useState('')
|
||||||
|
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 latestAnalysis = useQuery(
|
||||||
|
api.analyses.getLatestByProject,
|
||||||
|
selectedProjectId ? { projectId: selectedProjectId as any } : 'skip'
|
||||||
|
)
|
||||||
|
|
||||||
|
const savedOpportunities = useQuery(
|
||||||
|
api.opportunities.listByProject,
|
||||||
|
selectedProjectId
|
||||||
|
? {
|
||||||
|
projectId: selectedProjectId as any,
|
||||||
|
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||||
|
intent: intentFilter === 'all' ? undefined : intentFilter,
|
||||||
|
minScore: minScore > 0 ? minScore / 100 : undefined,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
: 'skip'
|
||||||
|
)
|
||||||
|
|
||||||
|
const displayOpportunities = useMemo(() => {
|
||||||
|
if (!savedOpportunities || savedOpportunities.length === 0) return opportunities
|
||||||
|
return savedOpportunities.map((opp: any) => ({
|
||||||
|
id: opp._id,
|
||||||
|
title: opp.title,
|
||||||
|
url: opp.url,
|
||||||
|
snippet: opp.snippet,
|
||||||
|
platform: opp.platform,
|
||||||
|
source: opp.platform,
|
||||||
|
relevanceScore: opp.relevanceScore,
|
||||||
|
emotionalIntensity: 'low',
|
||||||
|
intent: opp.intent,
|
||||||
|
matchedKeywords: opp.matchedKeywords,
|
||||||
|
matchedProblems: opp.matchedProblems,
|
||||||
|
suggestedApproach: opp.suggestedApproach,
|
||||||
|
softPitch: opp.softPitch,
|
||||||
|
status: opp.status,
|
||||||
|
notes: opp.notes,
|
||||||
|
tags: opp.tags,
|
||||||
|
})) as Opportunity[]
|
||||||
|
}, [savedOpportunities, opportunities])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem('productAnalysis')
|
const stored = localStorage.getItem('productAnalysis')
|
||||||
@@ -121,9 +176,11 @@ export default function OpportunitiesPage() {
|
|||||||
|
|
||||||
const executeSearch = async () => {
|
const executeSearch = async () => {
|
||||||
if (!analysis) return
|
if (!analysis) return
|
||||||
|
if (!selectedProjectId) return
|
||||||
|
|
||||||
setIsSearching(true)
|
setIsSearching(true)
|
||||||
setOpportunities([])
|
setOpportunities([])
|
||||||
|
setSearchError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = {
|
const config = {
|
||||||
@@ -147,12 +204,34 @@ export default function OpportunitiesPage() {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setOpportunities(data.data.opportunities)
|
const mapped = data.data.opportunities.map((opp: Opportunity) => ({
|
||||||
|
...opp,
|
||||||
|
status: 'new',
|
||||||
|
}))
|
||||||
|
setOpportunities(mapped)
|
||||||
setGeneratedQueries(data.data.queries)
|
setGeneratedQueries(data.data.queries)
|
||||||
setStats(data.data.stats)
|
setStats(data.data.stats)
|
||||||
|
|
||||||
|
await upsertOpportunities({
|
||||||
|
projectId: selectedProjectId as any,
|
||||||
|
analysisId: latestAnalysis?._id,
|
||||||
|
opportunities: mapped.map((opp: Opportunity) => ({
|
||||||
|
url: opp.url,
|
||||||
|
platform: opp.platform,
|
||||||
|
title: opp.title,
|
||||||
|
snippet: opp.snippet,
|
||||||
|
relevanceScore: opp.relevanceScore,
|
||||||
|
intent: opp.intent,
|
||||||
|
suggestedApproach: opp.suggestedApproach,
|
||||||
|
matchedKeywords: opp.matchedKeywords,
|
||||||
|
matchedProblems: opp.matchedProblems,
|
||||||
|
softPitch: opp.softPitch,
|
||||||
|
})),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Search error:', error)
|
console.error('Search error:', error)
|
||||||
|
setSearchError(error?.message || 'Search failed. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearching(false)
|
setIsSearching(false)
|
||||||
}
|
}
|
||||||
@@ -174,6 +253,30 @@ export default function OpportunitiesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedOpportunity) {
|
||||||
|
setStatusInput(selectedOpportunity.status || 'new')
|
||||||
|
setNotesInput(selectedOpportunity.notes || '')
|
||||||
|
setTagsInput(selectedOpportunity.tags?.join(', ') || '')
|
||||||
|
}
|
||||||
|
}, [selectedOpportunity])
|
||||||
|
|
||||||
|
const handleSaveOpportunity = async () => {
|
||||||
|
if (!selectedOpportunity?.id) return
|
||||||
|
|
||||||
|
const tags = tagsInput
|
||||||
|
.split(',')
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
await updateOpportunity({
|
||||||
|
id: selectedOpportunity.id as any,
|
||||||
|
status: statusInput,
|
||||||
|
notes: notesInput || undefined,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!analysis) return null
|
if (!analysis) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -280,6 +383,11 @@ export default function OpportunitiesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{searchError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{searchError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Generated Queries */}
|
{/* Generated Queries */}
|
||||||
{generatedQueries.length > 0 && (
|
{generatedQueries.length > 0 && (
|
||||||
@@ -307,20 +415,72 @@ export default function OpportunitiesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results Table */}
|
{/* Results Table */}
|
||||||
{opportunities.length > 0 ? (
|
{displayOpportunities.length > 0 ? (
|
||||||
<Card>
|
<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>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="new">New</option>
|
||||||
|
<option value="viewed">Viewed</option>
|
||||||
|
<option value="contacted">Contacted</option>
|
||||||
|
<option value="responded">Responded</option>
|
||||||
|
<option value="converted">Converted</option>
|
||||||
|
<option value="ignored">Ignored</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Intent</Label>
|
||||||
|
<select
|
||||||
|
value={intentFilter}
|
||||||
|
onChange={(e) => setIntentFilter(e.target.value)}
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="frustrated">Frustrated</option>
|
||||||
|
<option value="comparing">Comparing</option>
|
||||||
|
<option value="learning">Learning</option>
|
||||||
|
<option value="recommending">Recommending</option>
|
||||||
|
<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"
|
||||||
|
onClick={() => setLimit((prev) => prev + 50)}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Platform</TableHead>
|
<TableHead>Platform</TableHead>
|
||||||
<TableHead>Intent</TableHead>
|
<TableHead>Intent</TableHead>
|
||||||
<TableHead>Score</TableHead>
|
<TableHead>Score</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="w-1/2">Post</TableHead>
|
<TableHead className="w-1/2">Post</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{opportunities.slice(0, 50).map((opp) => (
|
{displayOpportunities.slice(0, limit).map((opp) => (
|
||||||
<TableRow key={opp.id}>
|
<TableRow key={opp.id}>
|
||||||
<TableCell><Badge variant="outline">{opp.platform}</Badge></TableCell>
|
<TableCell><Badge variant="outline">{opp.platform}</Badge></TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -334,6 +494,11 @@ export default function OpportunitiesPage() {
|
|||||||
{Math.round(opp.relevanceScore * 100)}%
|
{Math.round(opp.relevanceScore * 100)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{opp.status || 'new'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<p className="font-medium line-clamp-1">{opp.title}</p>
|
<p className="font-medium line-clamp-1">{opp.title}</p>
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">{opp.snippet}</p>
|
<p className="text-sm text-muted-foreground line-clamp-2">{opp.snippet}</p>
|
||||||
@@ -387,6 +552,43 @@ export default function OpportunitiesPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<select
|
||||||
|
value={statusInput}
|
||||||
|
onChange={(e) => setStatusInput(e.target.value)}
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="new">New</option>
|
||||||
|
<option value="viewed">Viewed</option>
|
||||||
|
<option value="contacted">Contacted</option>
|
||||||
|
<option value="responded">Responded</option>
|
||||||
|
<option value="converted">Converted</option>
|
||||||
|
<option value="ignored">Ignored</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tags (comma separated)</Label>
|
||||||
|
<input
|
||||||
|
value={tagsInput}
|
||||||
|
onChange={(e) => setTagsInput(e.target.value)}
|
||||||
|
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
placeholder="reddit, high-intent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
value={notesInput}
|
||||||
|
onChange={(e) => setNotesInput(e.target.value)}
|
||||||
|
placeholder="Add notes about this lead..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedOpportunity.matchedKeywords.length > 0 && (
|
{selectedOpportunity.matchedKeywords.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm text-muted-foreground">Matched Keywords</Label>
|
<Label className="text-sm text-muted-foreground">Matched Keywords</Label>
|
||||||
@@ -425,8 +627,11 @@ export default function OpportunitiesPage() {
|
|||||||
<Button variant="outline" onClick={() => window.open(selectedOpportunity.url, '_blank')}>
|
<Button variant="outline" onClick={() => window.open(selectedOpportunity.url, '_blank')}>
|
||||||
<ExternalLink className="h-4 w-4 mr-2" /> View Post
|
<ExternalLink className="h-4 w-4 mr-2" /> View Post
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setSelectedOpportunity(null)}>
|
<Button onClick={async () => {
|
||||||
<CheckCircle2 className="h-4 w-4 mr-2" /> Mark as Viewed
|
await handleSaveOpportunity()
|
||||||
|
setSelectedOpportunity(null)
|
||||||
|
}}>
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-2" /> Save Updates
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
|
|||||||
53
app/(app)/settings/page.tsx
Normal file
53
app/(app)/settings/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { AppSidebar } from "@/components/app-sidebar"
|
|
||||||
import {
|
|
||||||
SidebarInset,
|
|
||||||
SidebarProvider,
|
|
||||||
SidebarTrigger,
|
|
||||||
} from "@/components/ui/sidebar"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@/components/ui/breadcrumb"
|
|
||||||
|
|
||||||
export default function DashboardLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SidebarProvider>
|
|
||||||
<AppSidebar />
|
|
||||||
<SidebarInset>
|
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
|
||||||
<SidebarTrigger className="-ml-1" />
|
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList>
|
|
||||||
<BreadcrumbItem className="hidden md:block">
|
|
||||||
<BreadcrumbLink href="#">
|
|
||||||
Platform
|
|
||||||
</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbPage>Dashboard</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
</header>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 lg:p-8">
|
|
||||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
|
||||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
|
||||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
|
||||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
|
||||||
</div>
|
|
||||||
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50" >
|
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground p-10 text-center">
|
|
||||||
<h2 className="text-xl font-semibold">Your Leads will appear here</h2>
|
|
||||||
<p>Select data sources in the sidebar to start finding opportunities dorked from the web.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { Montserrat } from 'next/font/google'
|
import { Inter } from 'next/font/google'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import ConvexClientProvider from './ConvexClientProvider'
|
import ConvexClientProvider from './ConvexClientProvider'
|
||||||
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
|
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
const montserrat = Montserrat({
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
subsets: ['latin'],
|
|
||||||
weight: ['300', '400', '500', '600', '700'],
|
|
||||||
})
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Sanati - Find Product Opportunities',
|
title: 'Sanati - Find Product Opportunities',
|
||||||
@@ -23,7 +20,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<ConvexAuthNextjsServerProvider>
|
<ConvexAuthNextjsServerProvider>
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={montserrat.className}>
|
<body className={inter.className}>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="dark"
|
defaultTheme="dark"
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { ArrowRight, Globe, Loader2, Sparkles, AlertCircle, ArrowLeft } from 'lucide-react'
|
import { ArrowRight, Globe, Loader2, Sparkles, AlertCircle, ArrowLeft } from 'lucide-react'
|
||||||
import type { ProductAnalysis } from '@/lib/types'
|
import type { EnhancedProductAnalysis, Keyword } from '@/lib/types'
|
||||||
|
import { useMutation } from 'convex/react'
|
||||||
|
import { api } from '@/convex/_generated/api'
|
||||||
|
|
||||||
const examples = [
|
const examples = [
|
||||||
{ name: 'Notion', url: 'https://notion.so' },
|
{ name: 'Notion', url: 'https://notion.so' },
|
||||||
@@ -21,6 +23,8 @@ const examples = [
|
|||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const addDataSource = useMutation(api.dataSources.addDataSource)
|
||||||
|
const createAnalysis = useMutation(api.analyses.createAnalysis)
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [progress, setProgress] = useState('')
|
const [progress, setProgress] = useState('')
|
||||||
@@ -32,6 +36,28 @@ export default function OnboardingPage() {
|
|||||||
const [manualDescription, setManualDescription] = useState('')
|
const [manualDescription, setManualDescription] = useState('')
|
||||||
const [manualFeatures, setManualFeatures] = useState('')
|
const [manualFeatures, setManualFeatures] = useState('')
|
||||||
|
|
||||||
|
const persistAnalysis = async ({
|
||||||
|
analysis,
|
||||||
|
sourceUrl,
|
||||||
|
sourceName,
|
||||||
|
}: {
|
||||||
|
analysis: EnhancedProductAnalysis
|
||||||
|
sourceUrl: string
|
||||||
|
sourceName: string
|
||||||
|
}) => {
|
||||||
|
const { sourceId, projectId } = await addDataSource({
|
||||||
|
url: sourceUrl,
|
||||||
|
name: sourceName,
|
||||||
|
type: 'website',
|
||||||
|
})
|
||||||
|
|
||||||
|
await createAnalysis({
|
||||||
|
projectId,
|
||||||
|
dataSourceId: sourceId,
|
||||||
|
analysis,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function analyzeWebsite() {
|
async function analyzeWebsite() {
|
||||||
if (!url) return
|
if (!url) return
|
||||||
|
|
||||||
@@ -68,6 +94,13 @@ export default function OnboardingPage() {
|
|||||||
localStorage.setItem('productAnalysis', JSON.stringify(data.data))
|
localStorage.setItem('productAnalysis', JSON.stringify(data.data))
|
||||||
localStorage.setItem('analysisStats', JSON.stringify(data.stats))
|
localStorage.setItem('analysisStats', JSON.stringify(data.stats))
|
||||||
|
|
||||||
|
setProgress('Saving analysis...')
|
||||||
|
await persistAnalysis({
|
||||||
|
analysis: data.data,
|
||||||
|
sourceUrl: url,
|
||||||
|
sourceName: data.data.productName,
|
||||||
|
})
|
||||||
|
|
||||||
setProgress('Redirecting to dashboard...')
|
setProgress('Redirecting to dashboard...')
|
||||||
|
|
||||||
// Redirect to dashboard with product name in query
|
// Redirect to dashboard with product name in query
|
||||||
@@ -90,16 +123,45 @@ export default function OnboardingPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a mock analysis from manual input
|
// Create a mock analysis from manual input
|
||||||
const manualAnalysis: ProductAnalysis = {
|
const manualFeaturesList = manualFeatures
|
||||||
|
.split('\n')
|
||||||
|
.map((feature) => feature.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const keywordSeed = manualProductName
|
||||||
|
.toLowerCase()
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const manualKeywords: Keyword[] = keywordSeed.map((term) => ({
|
||||||
|
term,
|
||||||
|
type: 'product',
|
||||||
|
searchVolume: 'low',
|
||||||
|
intent: 'informational',
|
||||||
|
funnel: 'awareness',
|
||||||
|
emotionalIntensity: 'curious',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const manualAnalysis: EnhancedProductAnalysis = {
|
||||||
productName: manualProductName,
|
productName: manualProductName,
|
||||||
tagline: manualDescription.split('.')[0],
|
tagline: manualDescription.split('.')[0],
|
||||||
description: manualDescription,
|
description: manualDescription,
|
||||||
features: manualFeatures.split('\n').filter(f => f.trim()),
|
category: '',
|
||||||
|
positioning: '',
|
||||||
|
features: manualFeaturesList.map((name) => ({
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
benefits: [],
|
||||||
|
useCases: [],
|
||||||
|
})),
|
||||||
problemsSolved: [],
|
problemsSolved: [],
|
||||||
targetAudience: [],
|
personas: [],
|
||||||
valuePropositions: [],
|
keywords: manualKeywords,
|
||||||
keywords: manualProductName.toLowerCase().split(' '),
|
useCases: [],
|
||||||
scrapedAt: new Date().toISOString()
|
competitors: [],
|
||||||
|
dorkQueries: [],
|
||||||
|
scrapedAt: new Date().toISOString(),
|
||||||
|
analysisVersion: 'manual',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to API to enhance with AI
|
// Send to API to enhance with AI
|
||||||
@@ -136,6 +198,13 @@ export default function OnboardingPage() {
|
|||||||
dorkQueries: finalAnalysis.dorkQueries.length
|
dorkQueries: finalAnalysis.dorkQueries.length
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
setProgress('Saving analysis...')
|
||||||
|
await persistAnalysis({
|
||||||
|
analysis: finalAnalysis,
|
||||||
|
sourceUrl: 'manual-input',
|
||||||
|
sourceName: finalAnalysis.productName,
|
||||||
|
})
|
||||||
|
|
||||||
// Redirect to dashboard
|
// Redirect to dashboard
|
||||||
const params = new URLSearchParams({ product: finalAnalysis.productName })
|
const params = new URLSearchParams({ product: finalAnalysis.productName })
|
||||||
router.push(`/dashboard?${params.toString()}`)
|
router.push(`/dashboard?${params.toString()}`)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
Frame,
|
Frame,
|
||||||
|
HelpCircle,
|
||||||
Settings2,
|
Settings2,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
Target,
|
||||||
Plus
|
Plus
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
@@ -26,11 +30,12 @@ import { useQuery, useMutation } from "convex/react"
|
|||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Button } from "@/components/ui/button"
|
import { useProject } from "@/components/project-context"
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const pathname = usePathname()
|
||||||
const projects = useQuery(api.projects.getProjects);
|
const projects = useQuery(api.projects.getProjects);
|
||||||
const [selectedProjectId, setSelectedProjectId] = React.useState<string | null>(null);
|
const { selectedProjectId, setSelectedProjectId } = useProject();
|
||||||
|
|
||||||
// Set default selected project
|
// Set default selected project
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -87,15 +92,51 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton tooltip="Dashboard" isActive>
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
tooltip="Dashboard"
|
||||||
|
isActive={pathname === "/dashboard"}
|
||||||
|
>
|
||||||
|
<Link href="/dashboard">
|
||||||
<Terminal />
|
<Terminal />
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton tooltip="Settings">
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
tooltip="Opportunities"
|
||||||
|
isActive={pathname === "/opportunities"}
|
||||||
|
>
|
||||||
|
<Link href="/opportunities">
|
||||||
|
<Target />
|
||||||
|
<span>Opportunities</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
tooltip="Settings"
|
||||||
|
isActive={pathname === "/settings"}
|
||||||
|
>
|
||||||
|
<Link href="/settings">
|
||||||
<Settings2 />
|
<Settings2 />
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
tooltip="Help"
|
||||||
|
isActive={pathname === "/help"}
|
||||||
|
>
|
||||||
|
<Link href="/help">
|
||||||
|
<HelpCircle />
|
||||||
|
<span>Help</span>
|
||||||
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
|||||||
46
components/project-context.tsx
Normal file
46
components/project-context.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "selectedProjectId";
|
||||||
|
|
||||||
|
type ProjectContextValue = {
|
||||||
|
selectedProjectId: string | null;
|
||||||
|
setSelectedProjectId: (id: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectContext = createContext<ProjectContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ProjectProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
setSelectedProjectId(stored);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProjectId) {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, selectedProjectId);
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}, [selectedProjectId]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ selectedProjectId, setSelectedProjectId }),
|
||||||
|
[selectedProjectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProject() {
|
||||||
|
const context = useContext(ProjectContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useProject must be used within a ProjectProvider.");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
142
convex/analyses.ts
Normal file
142
convex/analyses.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||||
|
|
||||||
|
export const getLatestByProject = query({
|
||||||
|
args: { projectId: v.id("projects") },
|
||||||
|
handler: async (ctx, { projectId }) => {
|
||||||
|
const userId = await getAuthUserId(ctx);
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
const project = await ctx.db.get(projectId);
|
||||||
|
if (!project || project.userId !== userId) return null;
|
||||||
|
|
||||||
|
return await ctx.db
|
||||||
|
.query("analyses")
|
||||||
|
.withIndex("by_project_createdAt", (q) => q.eq("projectId", projectId))
|
||||||
|
.order("desc")
|
||||||
|
.first();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createAnalysis = mutation({
|
||||||
|
args: {
|
||||||
|
projectId: v.id("projects"),
|
||||||
|
dataSourceId: v.id("dataSources"),
|
||||||
|
analysis: v.object({
|
||||||
|
productName: v.string(),
|
||||||
|
tagline: v.string(),
|
||||||
|
description: v.string(),
|
||||||
|
category: v.string(),
|
||||||
|
positioning: v.string(),
|
||||||
|
features: v.array(v.object({
|
||||||
|
name: v.string(),
|
||||||
|
description: v.string(),
|
||||||
|
benefits: v.array(v.string()),
|
||||||
|
useCases: v.array(v.string()),
|
||||||
|
})),
|
||||||
|
problemsSolved: v.array(v.object({
|
||||||
|
problem: v.string(),
|
||||||
|
severity: v.union(v.literal("high"), v.literal("medium"), v.literal("low")),
|
||||||
|
currentWorkarounds: v.array(v.string()),
|
||||||
|
emotionalImpact: v.string(),
|
||||||
|
searchTerms: v.array(v.string()),
|
||||||
|
})),
|
||||||
|
personas: v.array(v.object({
|
||||||
|
name: v.string(),
|
||||||
|
role: v.string(),
|
||||||
|
companySize: v.string(),
|
||||||
|
industry: v.string(),
|
||||||
|
painPoints: v.array(v.string()),
|
||||||
|
goals: v.array(v.string()),
|
||||||
|
techSavvy: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
|
||||||
|
objections: v.array(v.string()),
|
||||||
|
searchBehavior: v.array(v.string()),
|
||||||
|
})),
|
||||||
|
keywords: v.array(v.object({
|
||||||
|
term: v.string(),
|
||||||
|
type: v.union(
|
||||||
|
v.literal("product"),
|
||||||
|
v.literal("problem"),
|
||||||
|
v.literal("solution"),
|
||||||
|
v.literal("competitor"),
|
||||||
|
v.literal("feature"),
|
||||||
|
v.literal("longtail"),
|
||||||
|
v.literal("differentiator")
|
||||||
|
),
|
||||||
|
searchVolume: v.union(v.literal("high"), v.literal("medium"), v.literal("low")),
|
||||||
|
intent: v.union(v.literal("informational"), v.literal("navigational"), v.literal("transactional")),
|
||||||
|
funnel: v.union(v.literal("awareness"), v.literal("consideration"), v.literal("decision")),
|
||||||
|
emotionalIntensity: v.union(v.literal("frustrated"), v.literal("curious"), v.literal("ready")),
|
||||||
|
})),
|
||||||
|
useCases: v.array(v.object({
|
||||||
|
scenario: v.string(),
|
||||||
|
trigger: v.string(),
|
||||||
|
emotionalState: v.string(),
|
||||||
|
currentWorkflow: v.array(v.string()),
|
||||||
|
desiredOutcome: v.string(),
|
||||||
|
alternativeProducts: v.array(v.string()),
|
||||||
|
whyThisProduct: v.string(),
|
||||||
|
churnRisk: v.array(v.string()),
|
||||||
|
})),
|
||||||
|
competitors: v.array(v.object({
|
||||||
|
name: v.string(),
|
||||||
|
differentiator: v.string(),
|
||||||
|
theirStrength: v.string(),
|
||||||
|
switchTrigger: v.string(),
|
||||||
|
theirWeakness: v.string(),
|
||||||
|
})),
|
||||||
|
dorkQueries: v.array(v.object({
|
||||||
|
query: v.string(),
|
||||||
|
platform: v.union(
|
||||||
|
v.literal("reddit"),
|
||||||
|
v.literal("hackernews"),
|
||||||
|
v.literal("indiehackers"),
|
||||||
|
v.literal("twitter"),
|
||||||
|
v.literal("quora"),
|
||||||
|
v.literal("stackoverflow")
|
||||||
|
),
|
||||||
|
intent: v.union(
|
||||||
|
v.literal("looking-for"),
|
||||||
|
v.literal("frustrated"),
|
||||||
|
v.literal("alternative"),
|
||||||
|
v.literal("comparison"),
|
||||||
|
v.literal("problem-solving"),
|
||||||
|
v.literal("tutorial")
|
||||||
|
),
|
||||||
|
priority: v.union(v.literal("high"), v.literal("medium"), v.literal("low")),
|
||||||
|
})),
|
||||||
|
scrapedAt: v.string(),
|
||||||
|
analysisVersion: 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db.insert("analyses", {
|
||||||
|
projectId: args.projectId,
|
||||||
|
dataSourceId: args.dataSourceId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
analysisVersion: args.analysis.analysisVersion,
|
||||||
|
productName: args.analysis.productName,
|
||||||
|
tagline: args.analysis.tagline,
|
||||||
|
description: args.analysis.description,
|
||||||
|
category: args.analysis.category,
|
||||||
|
positioning: args.analysis.positioning,
|
||||||
|
features: args.analysis.features,
|
||||||
|
problemsSolved: args.analysis.problemsSolved,
|
||||||
|
personas: args.analysis.personas,
|
||||||
|
keywords: args.analysis.keywords,
|
||||||
|
useCases: args.analysis.useCases,
|
||||||
|
competitors: args.analysis.competitors,
|
||||||
|
dorkQueries: args.analysis.dorkQueries,
|
||||||
|
scrapedAt: args.analysis.scrapedAt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -70,6 +70,6 @@ export const addDataSource = mutation({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return sourceId;
|
return { sourceId, projectId: projectId! };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
157
convex/opportunities.ts
Normal file
157
convex/opportunities.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||||
|
|
||||||
|
const opportunityInput = v.object({
|
||||||
|
url: v.string(),
|
||||||
|
platform: v.string(),
|
||||||
|
title: v.string(),
|
||||||
|
snippet: v.string(),
|
||||||
|
relevanceScore: v.number(),
|
||||||
|
intent: v.string(),
|
||||||
|
suggestedApproach: v.string(),
|
||||||
|
matchedKeywords: v.array(v.string()),
|
||||||
|
matchedProblems: v.array(v.string()),
|
||||||
|
softPitch: v.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listByProject = query({
|
||||||
|
args: {
|
||||||
|
projectId: v.id("projects"),
|
||||||
|
status: v.optional(v.string()),
|
||||||
|
intent: v.optional(v.string()),
|
||||||
|
minScore: v.optional(v.number()),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
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 limit = args.limit ?? 50;
|
||||||
|
let queryBuilder = args.status
|
||||||
|
? ctx.db
|
||||||
|
.query("opportunities")
|
||||||
|
.withIndex("by_project_status", (q) =>
|
||||||
|
q.eq("projectId", args.projectId).eq("status", args.status!)
|
||||||
|
)
|
||||||
|
: ctx.db
|
||||||
|
.query("opportunities")
|
||||||
|
.withIndex("by_project_createdAt", (q) =>
|
||||||
|
q.eq("projectId", args.projectId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (args.intent) {
|
||||||
|
queryBuilder = queryBuilder.filter((q) =>
|
||||||
|
q.eq(q.field("intent"), args.intent)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.minScore !== undefined) {
|
||||||
|
queryBuilder = queryBuilder.filter((q) =>
|
||||||
|
q.gte(q.field("relevanceScore"), args.minScore)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await queryBuilder.order("desc").collect();
|
||||||
|
return results.slice(0, limit);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const upsertBatch = mutation({
|
||||||
|
args: {
|
||||||
|
projectId: v.id("projects"),
|
||||||
|
analysisId: v.optional(v.id("analyses")),
|
||||||
|
opportunities: v.array(opportunityInput),
|
||||||
|
},
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const opp of args.opportunities) {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("opportunities")
|
||||||
|
.withIndex("by_project_url", (q) =>
|
||||||
|
q.eq("projectId", args.projectId).eq("url", opp.url)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
analysisId: args.analysisId,
|
||||||
|
platform: opp.platform,
|
||||||
|
title: opp.title,
|
||||||
|
snippet: opp.snippet,
|
||||||
|
relevanceScore: opp.relevanceScore,
|
||||||
|
intent: opp.intent,
|
||||||
|
suggestedApproach: opp.suggestedApproach,
|
||||||
|
matchedKeywords: opp.matchedKeywords,
|
||||||
|
matchedProblems: opp.matchedProblems,
|
||||||
|
softPitch: opp.softPitch,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
updated += 1;
|
||||||
|
} else {
|
||||||
|
await ctx.db.insert("opportunities", {
|
||||||
|
projectId: args.projectId,
|
||||||
|
analysisId: args.analysisId,
|
||||||
|
url: opp.url,
|
||||||
|
platform: opp.platform,
|
||||||
|
title: opp.title,
|
||||||
|
snippet: opp.snippet,
|
||||||
|
relevanceScore: opp.relevanceScore,
|
||||||
|
intent: opp.intent,
|
||||||
|
status: "new",
|
||||||
|
suggestedApproach: opp.suggestedApproach,
|
||||||
|
matchedKeywords: opp.matchedKeywords,
|
||||||
|
matchedProblems: opp.matchedProblems,
|
||||||
|
softPitch: opp.softPitch,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateStatus = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("opportunities"),
|
||||||
|
status: v.string(),
|
||||||
|
notes: v.optional(v.string()),
|
||||||
|
tags: v.optional(v.array(v.string())),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const userId = await getAuthUserId(ctx);
|
||||||
|
if (!userId) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const opportunity = await ctx.db.get(args.id);
|
||||||
|
if (!opportunity) throw new Error("Opportunity not found");
|
||||||
|
|
||||||
|
const project = await ctx.db.get(opportunity.projectId);
|
||||||
|
if (!project || project.userId !== userId) {
|
||||||
|
throw new Error("Project not found or unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.id, {
|
||||||
|
status: args.status,
|
||||||
|
notes: args.notes,
|
||||||
|
tags: args.tags,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
112
convex/schema.ts
112
convex/schema.ts
@@ -32,6 +32,118 @@ const schema = defineSchema({
|
|||||||
),
|
),
|
||||||
metadata: v.optional(v.any()),
|
metadata: v.optional(v.any()),
|
||||||
}),
|
}),
|
||||||
|
analyses: defineTable({
|
||||||
|
projectId: v.id("projects"),
|
||||||
|
dataSourceId: v.id("dataSources"),
|
||||||
|
createdAt: v.number(),
|
||||||
|
analysisVersion: v.string(),
|
||||||
|
productName: v.string(),
|
||||||
|
tagline: v.string(),
|
||||||
|
description: v.string(),
|
||||||
|
category: v.string(),
|
||||||
|
positioning: v.string(),
|
||||||
|
features: v.array(v.object({
|
||||||
|
name: v.string(),
|
||||||
|
description: v.string(),
|
||||||
|
benefits: v.array(v.string()),
|
||||||
|
useCases: v.array(v.string()),
|
||||||
|
})),
|
||||||
|
problemsSolved: v.array(v.object({
|
||||||
|
problem: v.string(),
|
||||||
|
severity: v.union(v.literal("high"), v.literal("medium"), v.literal("low")),
|
||||||
|
currentWorkarounds: v.array(v.string()),
|
||||||
|
emotionalImpact: v.string(),
|
||||||
|
searchTerms: v.array(v.string()),
|
||||||
|
})),
|
||||||
|
personas: v.array(v.object({
|
||||||
|
name: v.string(),
|
||||||
|
role: v.string(),
|
||||||
|
companySize: v.string(),
|
||||||
|
industry: v.string(),
|
||||||
|
painPoints: v.array(v.string()),
|
||||||
|
goals: v.array(v.string()),
|
||||||
|
techSavvy: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
|
||||||
|
objections: v.array(v.string()),
|
||||||
|
searchBehavior: v.array(v.string()),
|
||||||
|
})),
|
||||||
|
keywords: v.array(v.object({
|
||||||
|
term: v.string(),
|
||||||
|
type: v.union(
|
||||||
|
v.literal("product"),
|
||||||
|
v.literal("problem"),
|
||||||
|
v.literal("solution"),
|
||||||
|
v.literal("competitor"),
|
||||||
|
v.literal("feature"),
|
||||||
|
v.literal("longtail"),
|
||||||
|
v.literal("differentiator")
|
||||||
|
),
|
||||||
|
searchVolume: v.union(v.literal("high"), v.literal("medium"), v.literal("low")),
|
||||||
|
intent: v.union(v.literal("informational"), v.literal("navigational"), v.literal("transactional")),
|
||||||
|
funnel: v.union(v.literal("awareness"), v.literal("consideration"), v.literal("decision")),
|
||||||
|
emotionalIntensity: v.union(v.literal("frustrated"), v.literal("curious"), v.literal("ready")),
|
||||||
|
})),
|
||||||
|
useCases: v.array(v.object({
|
||||||
|
scenario: v.string(),
|
||||||
|
trigger: v.string(),
|
||||||
|
emotionalState: v.string(),
|
||||||
|
currentWorkflow: v.array(v.string()),
|
||||||
|
desiredOutcome: v.string(),
|
||||||
|
alternativeProducts: v.array(v.string()),
|
||||||
|
whyThisProduct: v.string(),
|
||||||
|
churnRisk: v.array(v.string()),
|
||||||
|
})),
|
||||||
|
competitors: v.array(v.object({
|
||||||
|
name: v.string(),
|
||||||
|
differentiator: v.string(),
|
||||||
|
theirStrength: v.string(),
|
||||||
|
switchTrigger: v.string(),
|
||||||
|
theirWeakness: v.string(),
|
||||||
|
})),
|
||||||
|
dorkQueries: v.array(v.object({
|
||||||
|
query: v.string(),
|
||||||
|
platform: v.union(
|
||||||
|
v.literal("reddit"),
|
||||||
|
v.literal("hackernews"),
|
||||||
|
v.literal("indiehackers"),
|
||||||
|
v.literal("twitter"),
|
||||||
|
v.literal("quora"),
|
||||||
|
v.literal("stackoverflow")
|
||||||
|
),
|
||||||
|
intent: v.union(
|
||||||
|
v.literal("looking-for"),
|
||||||
|
v.literal("frustrated"),
|
||||||
|
v.literal("alternative"),
|
||||||
|
v.literal("comparison"),
|
||||||
|
v.literal("problem-solving"),
|
||||||
|
v.literal("tutorial")
|
||||||
|
),
|
||||||
|
priority: v.union(v.literal("high"), v.literal("medium"), v.literal("low")),
|
||||||
|
})),
|
||||||
|
scrapedAt: v.string(),
|
||||||
|
})
|
||||||
|
.index("by_project_createdAt", ["projectId", "createdAt"]),
|
||||||
|
opportunities: defineTable({
|
||||||
|
projectId: v.id("projects"),
|
||||||
|
analysisId: v.optional(v.id("analyses")),
|
||||||
|
url: v.string(),
|
||||||
|
platform: v.string(),
|
||||||
|
title: v.string(),
|
||||||
|
snippet: v.string(),
|
||||||
|
relevanceScore: v.number(),
|
||||||
|
intent: v.string(),
|
||||||
|
status: v.string(),
|
||||||
|
suggestedApproach: v.string(),
|
||||||
|
matchedKeywords: v.array(v.string()),
|
||||||
|
matchedProblems: v.array(v.string()),
|
||||||
|
tags: v.optional(v.array(v.string())),
|
||||||
|
notes: v.optional(v.string()),
|
||||||
|
softPitch: v.boolean(),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_project_status", ["projectId", "status"])
|
||||||
|
.index("by_project_createdAt", ["projectId", "createdAt"])
|
||||||
|
.index("by_project_url", ["projectId", "url"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default schema;
|
export default schema;
|
||||||
|
|||||||
@@ -13,3 +13,5 @@
|
|||||||
- Added missing `by_owner` index on `userId` to the `projects` table in `convex/schema.ts`
|
- Added missing `by_owner` index on `userId` to the `projects` table in `convex/schema.ts`
|
||||||
- Removed redundant `.filter()` call in `convex/projects.ts` getProjects query
|
- Removed redundant `.filter()` call in `convex/projects.ts` getProjects query
|
||||||
|
|
||||||
|
- Changed global font from Montserrat back to Inter in `app/layout.tsx`
|
||||||
|
|
||||||
|
|||||||
59
docs/phase-1-foundation.md
Normal file
59
docs/phase-1-foundation.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Phase 1 — Foundation & Navigation Unification
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Resolve the split navigation/layout systems and establish a single app shell.
|
||||||
|
- Align routing so all authenticated app routes share a consistent layout.
|
||||||
|
- Prepare the data model to store analysis and opportunities in Convex.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Choose a single sidebar layout system.
|
||||||
|
- Move or consolidate routes so `/dashboard`, `/opportunities`, and other app pages live under the same layout.
|
||||||
|
- Remove unused layout/components or mark deprecated ones.
|
||||||
|
- Extend Convex schema to support analysis + opportunity storage.
|
||||||
|
- Add indices required for efficient queries.
|
||||||
|
|
||||||
|
## Detailed Tasks
|
||||||
|
1. **Layout decision & consolidation**
|
||||||
|
- Pick one layout approach:
|
||||||
|
- Option A: Use `(app)` group layout (`app/(app)/layout.tsx`) + `components/sidebar.tsx`.
|
||||||
|
- Option B: Use `app/dashboard/layout.tsx` + `components/app-sidebar.tsx`.
|
||||||
|
- Confirm all protected routes render inside the chosen layout.
|
||||||
|
- Remove or archive the unused layout and sidebar component to avoid confusion.
|
||||||
|
|
||||||
|
2. **Route structure alignment**
|
||||||
|
- Ensure `/dashboard`, `/opportunities`, `/settings`, `/help` sit under the chosen layout.
|
||||||
|
- Update sidebar links to match actual routes.
|
||||||
|
- Confirm middleware protects all app routes consistently.
|
||||||
|
|
||||||
|
3. **Convex schema expansion (analysis/opportunities)**
|
||||||
|
- Add `analyses` table:
|
||||||
|
- `projectId`, `dataSourceId`, `createdAt`, `analysisVersion`, `productName`, `tagline`, `description`, `features`, `problemsSolved`, `personas`, `keywords`, `useCases`, `competitors`, `dorkQueries`.
|
||||||
|
- Add `opportunities` table:
|
||||||
|
- `projectId`, `analysisId`, `url`, `platform`, `title`, `snippet`, `relevanceScore`, `intent`, `status`, `suggestedApproach`, `matchedKeywords`, `matchedProblems`, `tags`, `notes`, `createdAt`, `updatedAt`.
|
||||||
|
- Indices:
|
||||||
|
- `analyses.by_project_createdAt`
|
||||||
|
- `opportunities.by_project_status`
|
||||||
|
- `opportunities.by_project_createdAt`
|
||||||
|
- Optional: `opportunities.by_project_url` for dedupe.
|
||||||
|
|
||||||
|
4. **Convex API scaffolding**
|
||||||
|
- Queries:
|
||||||
|
- `analyses.getLatestByProject`
|
||||||
|
- `opportunities.listByProject` (filters: status, intent, minScore, pagination).
|
||||||
|
- Mutations:
|
||||||
|
- `opportunities.upsertBatch` (dedupe by URL + project).
|
||||||
|
- `opportunities.updateStatus`.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- None. This phase unblocks persistence and UI work.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- All app routes share one layout and sidebar.
|
||||||
|
- Convex schema includes analysis + opportunities tables with indices.
|
||||||
|
- Basic Convex queries/mutations exist for later phases.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- Choosing a layout path may require minor refactors to route locations.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Confirm with product direction which sidebar design to keep.
|
||||||
49
docs/phase-2-onboarding-and-dashboard.md
Normal file
49
docs/phase-2-onboarding-and-dashboard.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Phase 2 — Onboarding Persistence & Dashboard
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Persist analysis results in Convex instead of localStorage.
|
||||||
|
- Connect onboarding to project + data source records.
|
||||||
|
- Build a functional dashboard that renders saved analysis.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Update onboarding flow to create or select a project.
|
||||||
|
- Save analysis results to Convex and store IDs.
|
||||||
|
- Render dashboard from Convex queries with empty/loading states.
|
||||||
|
|
||||||
|
## Detailed Tasks
|
||||||
|
1. **Onboarding persistence**
|
||||||
|
- When analysis completes:
|
||||||
|
- Ensure a default project exists (create if missing).
|
||||||
|
- Create a `dataSources` entry for the URL or manual input.
|
||||||
|
- Insert a new `analyses` record linked to project + data source.
|
||||||
|
- Stop using localStorage as the source of truth (can keep as cache if needed).
|
||||||
|
- Store analysisId in router state or query param if useful.
|
||||||
|
|
||||||
|
2. **Manual input integration**
|
||||||
|
- Same persistence path as URL analysis.
|
||||||
|
- Mark data source type + metadata to indicate manual origin.
|
||||||
|
|
||||||
|
3. **Dashboard implementation**
|
||||||
|
- Fetch latest analysis for selected project.
|
||||||
|
- Render:
|
||||||
|
- Product name + tagline + description.
|
||||||
|
- Summary cards (features, keywords, personas, competitors, use cases).
|
||||||
|
- Top features list + top problems solved.
|
||||||
|
- Add empty state if no analysis exists.
|
||||||
|
|
||||||
|
4. **Project selection behavior**
|
||||||
|
- When project changes in sidebar, dashboard should re-query and update.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Requires Phase 1 schema and layout decisions.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Onboarding persists analysis to Convex.
|
||||||
|
- Dashboard displays real data from Convex.
|
||||||
|
- Refreshing the page keeps data intact.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- Larger analysis objects may need pagination/partial display.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Keep UI fast by showing a small, curated subset of analysis data.
|
||||||
48
docs/phase-3-opportunities-and-workflow.md
Normal file
48
docs/phase-3-opportunities-and-workflow.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Phase 3 — Opportunities Persistence & Workflow
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Persist opportunity search results in Convex.
|
||||||
|
- Enable lead management (status, notes, tags).
|
||||||
|
- Add filtering, pagination, and basic bulk actions.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Write opportunities to Convex during search.
|
||||||
|
- Read opportunities from Convex in the UI.
|
||||||
|
- Implement lead status changes and notes/tags.
|
||||||
|
|
||||||
|
## Detailed Tasks
|
||||||
|
1. **Persist opportunities**
|
||||||
|
- Update `/api/opportunities` to call `opportunities.upsertBatch` after scoring.
|
||||||
|
- Dedupe by URL + projectId.
|
||||||
|
- Store analysisId + projectId on each opportunity.
|
||||||
|
|
||||||
|
2. **Load opportunities from Convex**
|
||||||
|
- Replace local state-only data with Convex query.
|
||||||
|
- Add pagination and “Load more” to avoid giant tables.
|
||||||
|
|
||||||
|
3. **Filtering & sorting**
|
||||||
|
- Filters: status, intent, platform, min relevance score.
|
||||||
|
- Sorting: relevance score, createdAt.
|
||||||
|
|
||||||
|
4. **Lead workflow actions**
|
||||||
|
- Status change: new → viewed/contacted/responded/converted/ignored.
|
||||||
|
- Add notes + tags; persist via mutation.
|
||||||
|
- Optional: quick bulk action for selected rows.
|
||||||
|
|
||||||
|
5. **UI feedback**
|
||||||
|
- Show counts by status.
|
||||||
|
- Empty states for no results or no saved opportunities.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Requires Phase 1 schema and Phase 2 project + analysis persistence.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Opportunities persist across refresh and sessions.
|
||||||
|
- Status/notes/tags are stored and reflected in UI.
|
||||||
|
- Filters and pagination are usable.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- Serper or direct Google scraping limits; need clear UX for failed searches.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Keep raw search results transient; only store scored opportunities.
|
||||||
44
docs/phase-4-settings-help-reliability.md
Normal file
44
docs/phase-4-settings-help-reliability.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Phase 4 — Settings, Help, Reliability, QA
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Provide basic Settings and Help pages.
|
||||||
|
- Improve reliability for long-running operations.
|
||||||
|
- Add verification steps and basic test coverage.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Implement `/settings` and `/help` pages.
|
||||||
|
- Add progress and error handling improvements for analysis/search.
|
||||||
|
- Document manual QA checklist.
|
||||||
|
|
||||||
|
## Detailed Tasks
|
||||||
|
1. **Settings page**
|
||||||
|
- Account info display (name/email).
|
||||||
|
- API key setup instructions (OpenAI, Serper).
|
||||||
|
- Placeholder billing section (if needed).
|
||||||
|
|
||||||
|
2. **Help page**
|
||||||
|
- Quickstart steps.
|
||||||
|
- Outreach best practices.
|
||||||
|
- FAQ for common errors (scrape failures, auth, API keys).
|
||||||
|
|
||||||
|
3. **Reliability improvements**
|
||||||
|
- Move long-running tasks to Convex actions or background jobs.
|
||||||
|
- Track job status: pending → running → completed/failed.
|
||||||
|
- Provide progress UI and retry on failure.
|
||||||
|
|
||||||
|
4. **QA checklist**
|
||||||
|
- Auth flow: sign up, sign in, sign out.
|
||||||
|
- Onboarding: URL analysis + manual analysis.
|
||||||
|
- Dashboard: correct rendering for project switch.
|
||||||
|
- Opportunities: search, save, status change.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Depends on Phases 1–3 to stabilize core flows.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- `/settings` and `/help` routes exist and are linked.
|
||||||
|
- Background tasks reduce timeouts and improve UX.
|
||||||
|
- QA checklist is documented and executable.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Keep Settings minimal until billing/teams are defined.
|
||||||
27
docs/qa-checklist.md
Normal file
27
docs/qa-checklist.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# QA Checklist
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
- Sign up with email/password.
|
||||||
|
- Sign in with email/password.
|
||||||
|
- Sign in with Google.
|
||||||
|
- Redirect honors `next` query param.
|
||||||
|
|
||||||
|
## Onboarding
|
||||||
|
- URL analysis completes and redirects to dashboard.
|
||||||
|
- Manual input analysis completes and redirects to dashboard.
|
||||||
|
- Analysis persists after refresh.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
- Shows latest analysis for selected project.
|
||||||
|
- Project switch updates dashboard data.
|
||||||
|
- Empty states render when no analysis exists.
|
||||||
|
|
||||||
|
## Opportunities
|
||||||
|
- Search executes and persists results.
|
||||||
|
- Status/notes/tags save and reload correctly.
|
||||||
|
- Filters (status, intent, min score) work.
|
||||||
|
- Load more increases result count.
|
||||||
|
|
||||||
|
## Settings / Help
|
||||||
|
- `/settings` and `/help` load under app layout.
|
||||||
|
- Sidebar links navigate correctly.
|
||||||
Reference in New Issue
Block a user