From 885bbbf954ecfb1142e6763573739f1c80501ef0 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Tue, 3 Feb 2026 20:05:30 +0000 Subject: [PATCH] feat: Implement core application structure with new dashboard, settings, and help pages, and enhance opportunities management with persistence and filtering. --- app/(app)/dashboard/page.tsx | 132 +++++++++++++ app/(app)/help/page.tsx | 54 +++++ app/(app)/layout.tsx | 27 +-- app/(app)/opportunities/page.tsx | 219 ++++++++++++++++++++- app/(app)/settings/page.tsx | 53 +++++ app/dashboard/layout.tsx | 49 ----- app/dashboard/page.tsx | 17 -- app/layout.tsx | 9 +- app/onboarding/page.tsx | 83 +++++++- components/app-sidebar.tsx | 57 +++++- components/project-context.tsx | 46 +++++ convex/analyses.ts | 142 +++++++++++++ convex/dataSources.ts | 2 +- convex/opportunities.ts | 157 +++++++++++++++ convex/schema.ts | 112 +++++++++++ docs/changelog.md | 2 + docs/phase-1-foundation.md | 59 ++++++ docs/phase-2-onboarding-and-dashboard.md | 49 +++++ docs/phase-3-opportunities-and-workflow.md | 48 +++++ docs/phase-4-settings-help-reliability.md | 44 +++++ docs/qa-checklist.md | 27 +++ 21 files changed, 1282 insertions(+), 106 deletions(-) create mode 100644 app/(app)/dashboard/page.tsx create mode 100644 app/(app)/help/page.tsx create mode 100644 app/(app)/settings/page.tsx delete mode 100644 app/dashboard/layout.tsx delete mode 100644 app/dashboard/page.tsx create mode 100644 components/project-context.tsx create mode 100644 convex/analyses.ts create mode 100644 convex/opportunities.ts create mode 100644 docs/phase-1-foundation.md create mode 100644 docs/phase-2-onboarding-and-dashboard.md create mode 100644 docs/phase-3-opportunities-and-workflow.md create mode 100644 docs/phase-4-settings-help-reliability.md create mode 100644 docs/qa-checklist.md 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" + /> +
+
+ +
+ +