diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx
new file mode 100644
index 0000000..0cd6ffd
--- /dev/null
+++ b/app/(app)/dashboard/page.tsx
@@ -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 (
+
+
+
No projects yet
+
Complete onboarding to create your first project.
+
+
+ )
+ }
+
+ if (isLoading) {
+ return (
+
+ Loading analysis...
+
+ )
+ }
+
+ if (!analysis) {
+ return (
+
+
+
No analysis yet
+
Run onboarding to analyze a product for this project.
+
+
+ )
+ }
+
+ return (
+
+
+
+
{analysis.productName}
+ {selectedProject?.name && (
+ {selectedProject.name}
+ )}
+
+
{analysis.tagline}
+
{analysis.description}
+
+
+
+
+
+ Features
+
+ {analysis.features.length}
+
+
+
+ Keywords
+
+ {analysis.keywords.length}
+
+
+
+ Personas
+
+ {analysis.personas.length}
+
+
+
+ Competitors
+
+ {analysis.competitors.length}
+
+
+
+ Use Cases
+
+ {analysis.useCases.length}
+
+
+
+
+
+
+ Top Features
+
+
+ {analysis.features.slice(0, 6).map((feature, index) => (
+
+
+
+
{feature.name}
+
{feature.description}
+
+
+ ))}
+
+
+
+
+ Top Problems Solved
+
+
+ {analysis.problemsSolved.slice(0, 6).map((problem, index) => (
+
+
+
+
{problem.problem}
+
{problem.emotionalImpact}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/app/(app)/help/page.tsx b/app/(app)/help/page.tsx
new file mode 100644
index 0000000..471e1ac
--- /dev/null
+++ b/app/(app)/help/page.tsx
@@ -0,0 +1,54 @@
+"use client"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+
+export default function HelpPage() {
+ return (
+
+
+
Help
+
Tips for getting the most out of Sanati.
+
+
+
+
+
+ Quickstart
+
+
+ 1. Run onboarding with your product URL or manual details.
+ 2. Open Opportunities and pick platforms + strategies.
+ 3. Review matches, generate replies, and track status.
+
+
+
+
+
+ Outreach Tips
+
+
+ Lead with empathy and context, not a hard pitch.
+ Reference the specific problem the post mentions.
+ Offer help or a quick walkthrough before asking for a call.
+
+
+
+
+
+
+ Common Issues
+
+
+
+
Scrape fails
+
Use manual input or try a different URL (homepage works best).
+
+
+
Search returns few results
+
Enable Serper API key or broaden strategies in the search config.
+
+
+
+
+ )
+}
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index bca8e5c..78638dd 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -1,22 +1,27 @@
'use client'
-import { Sidebar } from '@/components/sidebar'
-import { useSearchParams } from 'next/navigation'
+import { AppSidebar } from "@/components/app-sidebar"
+import {
+ SidebarInset,
+ SidebarProvider,
+} from "@/components/ui/sidebar"
+import { ProjectProvider } from "@/components/project-context"
export default function AppLayout({
children,
}: {
children: React.ReactNode
}) {
- const searchParams = useSearchParams()
- const productName = searchParams.get('product') || undefined
-
return (
-
-
-
- {children}
-
-
+
+
+
+
+
+ {children}
+
+
+
+
)
}
diff --git a/app/(app)/opportunities/page.tsx b/app/(app)/opportunities/page.tsx
index 16d10c1..b7b163f 100644
--- a/app/(app)/opportunities/page.tsx
+++ b/app/(app)/opportunities/page.tsx
@@ -1,12 +1,15 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
+import { useMutation, useQuery } from 'convex/react'
+import { api } from '@/convex/_generated/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Slider } from '@/components/ui/slider'
+import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
@@ -47,6 +50,7 @@ import {
Eye,
Copy
} from 'lucide-react'
+import { useProject } from '@/components/project-context'
import type {
EnhancedProductAnalysis,
Opportunity,
@@ -66,6 +70,9 @@ const STRATEGY_INFO: Record(null)
const [platforms, setPlatforms] = useState([])
const [strategies, setStrategies] = useState([
@@ -81,6 +88,54 @@ export default function OpportunitiesPage() {
const [selectedOpportunity, setSelectedOpportunity] = useState(null)
const [replyText, setReplyText] = useState('')
const [stats, setStats] = useState(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(() => {
const stored = localStorage.getItem('productAnalysis')
@@ -121,9 +176,11 @@ export default function OpportunitiesPage() {
const executeSearch = async () => {
if (!analysis) return
+ if (!selectedProjectId) return
setIsSearching(true)
setOpportunities([])
+ setSearchError('')
try {
const config = {
@@ -147,12 +204,34 @@ export default function OpportunitiesPage() {
const data = await response.json()
if (data.success) {
- setOpportunities(data.data.opportunities)
+ const mapped = data.data.opportunities.map((opp: Opportunity) => ({
+ ...opp,
+ status: 'new',
+ }))
+ setOpportunities(mapped)
setGeneratedQueries(data.data.queries)
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)
+ setSearchError(error?.message || 'Search failed. Please try again.')
} finally {
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
return (
@@ -280,6 +383,11 @@ export default function OpportunitiesPage() {
)}
+ {searchError && (
+
+ {searchError}
+
+ )}
{/* Generated Queries */}
{generatedQueries.length > 0 && (
@@ -307,20 +415,72 @@ export default function OpportunitiesPage() {
)}
{/* Results Table */}
- {opportunities.length > 0 ? (
+ {displayOpportunities.length > 0 ? (
+
+
+
+
+
+
+
+
+
+
+
+ setMinScore(Number(e.target.value))}
+ className="h-8 w-20 rounded-md border border-input bg-background px-2 text-xs"
+ />
+
+
+
Platform
Intent
Score
+ Status
Post
Actions
- {opportunities.slice(0, 50).map((opp) => (
+ {displayOpportunities.slice(0, limit).map((opp) => (
{opp.platform}
@@ -334,6 +494,11 @@ export default function OpportunitiesPage() {
{Math.round(opp.relevanceScore * 100)}%
+
+
+ {opp.status || 'new'}
+
+
{opp.title}
{opp.snippet}
@@ -387,6 +552,43 @@ export default function OpportunitiesPage() {
+
+
+
+
+
+
+
+ setTagsInput(e.target.value)}
+ className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
+ placeholder="reddit, high-intent"
+ />
+
+
+
+
+
+
+
{selectedOpportunity.matchedKeywords.length > 0 && (
@@ -425,8 +627,11 @@ export default function OpportunitiesPage() {
-