From d02d95e680e7f4fc25f0bdebb1ed2a52fba30a1b Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Wed, 4 Feb 2026 01:05:00 +0000 Subject: [PATCH] a --- .env.example | 5 +- app/(app)/dashboard/page.tsx | 16 +- app/(app)/data-sources/[id]/page.tsx | 93 +++++- app/(app)/help/page.tsx | 2 +- app/(app)/leads/page.tsx | 304 ++++++++++++++++++ app/(app)/opportunities/page.tsx | 427 +++++++++++++++++-------- app/(app)/settings/page.tsx | 247 +++++++++++--- app/api/analysis/reprompt/route.ts | 87 +++++ app/api/checkout/route.ts | 21 ++ app/api/opportunities/route.ts | 34 +- app/onboarding/page.tsx | 8 +- components/analysis-section-editor.tsx | 326 +++++++++++++++++++ components/app-sidebar.tsx | 146 +++++++-- components/nav-user.tsx | 19 +- components/sidebar.tsx | 60 ++-- convex/_generated/api.d.ts | 4 + convex/analyses.ts | 51 ++- convex/analysisSections.ts | 214 +++++++++++++ convex/dataSources.ts | 7 + convex/opportunities.ts | 22 ++ convex/projects.ts | 91 ++++++ convex/schema.ts | 19 ++ convex/seenUrls.ts | 71 ++++ convex/users.ts | 15 + lib/analysis-pipeline.ts | 327 +++++++++++++++++-- lib/query-generator.ts | 15 +- lib/search-executor.ts | 4 +- lib/types.ts | 4 +- package-lock.json | 135 +++++++- package.json | 1 + 30 files changed, 2449 insertions(+), 326 deletions(-) create mode 100644 app/(app)/leads/page.tsx create mode 100644 app/api/analysis/reprompt/route.ts create mode 100644 app/api/checkout/route.ts create mode 100644 components/analysis-section-editor.tsx create mode 100644 convex/analysisSections.ts create mode 100644 convex/seenUrls.ts diff --git a/.env.example b/.env.example index 747d55d..963fca8 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # Required: OpenAI API key OPENAI_API_KEY=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 +# For Polar.sh +POLAR_ACCESS_TOKEN=polar_oat_0ftgnD1xSFxecMlEd7Er5tCehRWLA8anXLZ820ggiKn +POLAR_SUCCESS_URL=http://localhost:3000/settings?tab=billing&checkout_id={CHECKOUT_ID} # Optional: Serper.dev API key for reliable Google search -SERPER_API_KEY=f9ae0a793cbac4116edd6482e377330e75c4db22 +SERPER_API_KEY=340b89a61031688347bd8dc06e4c55020be7a2b8 diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index e70c449..9ead0da 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -153,19 +153,19 @@ export default function Page() {
- Features + Key Features {analysis.features.length} - Keywords + Search Keywords {analysis.keywords.length} - Personas + Target Users {analysis.personas.length} @@ -185,7 +185,7 @@ export default function Page() { - Data Sources + Sources {dataSources && dataSources.length > 0 ? ( @@ -219,7 +219,7 @@ export default function Page() { {analysisJobs && analysisJobs.length > 0 && ( - Analysis Jobs + Runs {analysisJobs.slice(0, 5).map((job: any) => { @@ -269,7 +269,7 @@ export default function Page() { {searchContext?.context && ( - Aggregated Context + Combined Summary
Keywords: {searchContext.context.keywords.length}
@@ -283,7 +283,7 @@ export default function Page() {
- Top Features + Key Features {analysis.features.slice(0, 6).map((feature, index) => ( @@ -299,7 +299,7 @@ export default function Page() { - Top Problems Solved + Top Problems {analysis.problemsSolved.slice(0, 6).map((problem, index) => ( diff --git a/app/(app)/data-sources/[id]/page.tsx b/app/(app)/data-sources/[id]/page.tsx index 102015b..85bfc55 100644 --- a/app/(app)/data-sources/[id]/page.tsx +++ b/app/(app)/data-sources/[id]/page.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/dialog" import { Settings } from "lucide-react" import * as React from "react" +import { ProfileSectionEditor, SectionEditor } from "@/components/analysis-section-editor" function formatDate(timestamp?: number) { if (!timestamp) return "Not analyzed yet"; @@ -34,10 +35,22 @@ export default function DataSourceDetailPage() { api.analyses.getLatestByDataSource, dataSourceId ? { dataSourceId: dataSourceId as any } : "skip" ) + const sections = useQuery( + api.analysisSections.listByAnalysis, + analysis?._id ? { analysisId: analysis._id as any } : "skip" + ) const removeDataSource = useMutation(api.dataSources.remove) const [isDeleting, setIsDeleting] = React.useState(false) const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const sectionMap = React.useMemo(() => { + const map = new Map() + sections?.forEach((section: any) => { + map.set(section.sectionKey, section.items) + }) + return map + }, [sections]) + if (dataSource === undefined) { return (
@@ -49,7 +62,7 @@ export default function DataSourceDetailPage() { if (!dataSource) { return (
-

Data source not found

+

Data source not found

This data source may have been removed or you no longer have access.

@@ -101,7 +114,7 @@ export default function DataSourceDetailPage() {
- Features + Key Features {analysis.features.length} @@ -109,7 +122,7 @@ export default function DataSourceDetailPage() { - Keywords + Search Keywords {analysis.keywords.length} @@ -117,7 +130,7 @@ export default function DataSourceDetailPage() { - Personas + Target Users {analysis.personas.length} @@ -127,7 +140,7 @@ export default function DataSourceDetailPage() { - Overview + Summary
@@ -153,7 +166,7 @@ export default function DataSourceDetailPage() {
- Top Features + Key Features {analysis.features.slice(0, 6).map((feature) => ( @@ -168,7 +181,7 @@ export default function DataSourceDetailPage() { - Pain Points + Problems {analysis.problemsSolved.slice(0, 6).map((problem) => ( @@ -186,7 +199,7 @@ export default function DataSourceDetailPage() {
- Personas + Target Users {analysis.personas.slice(0, 4).map((persona) => ( @@ -203,7 +216,7 @@ export default function DataSourceDetailPage() { - Keywords + Search Keywords {analysis.keywords.slice(0, 12).map((keyword) => ( @@ -214,11 +227,71 @@ export default function DataSourceDetailPage() {
+ +
+

Edit Sections

+
+ + + + + + + + +
+
) : ( - Analysis + Full Analysis No analysis available yet. Trigger a new analysis to populate this diff --git a/app/(app)/help/page.tsx b/app/(app)/help/page.tsx index 471e1ac..6098fff 100644 --- a/app/(app)/help/page.tsx +++ b/app/(app)/help/page.tsx @@ -6,7 +6,7 @@ export default function HelpPage() { return (
-

Help

+

Support

Tips for getting the most out of Sanati.

diff --git a/app/(app)/leads/page.tsx b/app/(app)/leads/page.tsx new file mode 100644 index 0000000..b398564 --- /dev/null +++ b/app/(app)/leads/page.tsx @@ -0,0 +1,304 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useQuery, useMutation } from 'convex/react' +import { api } from '@/convex/_generated/api' +import { useProject } from '@/components/project-context' +import { Card } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Separator } from '@/components/ui/separator' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { ExternalLink, Mail, Tag, Target } from 'lucide-react' +type Lead = { + _id: string + title: string + url: string + snippet: string + platform: string + relevanceScore: number + intent: string + status?: string + matchedKeywords: string[] + matchedProblems: string[] + suggestedApproach: string + softPitch: boolean + createdAt: number + notes?: string + tags?: string[] +} + +export default function LeadsPage() { + const { selectedProjectId } = useProject() + const leads = useQuery( + api.opportunities.listByProject, + selectedProjectId + ? { + projectId: selectedProjectId as any, + limit: 200, + } + : "skip" + ) + const updateOpportunity = useMutation(api.opportunities.updateStatus) + const [selectedId, setSelectedId] = useState(null) + const [notes, setNotes] = useState('') + const [tags, setTags] = useState('') + const [sourceFilter, setSourceFilter] = useState('all') + const [ageFilter, setAgeFilter] = useState('all') + + const sortedLeads = useMemo(() => { + if (!leads) return [] + const normalized = leads as Lead[] + const now = Date.now() + const ageLimit = + ageFilter === '24h' + ? now - 24 * 60 * 60 * 1000 + : ageFilter === '7d' + ? now - 7 * 24 * 60 * 60 * 1000 + : ageFilter === '30d' + ? now - 30 * 24 * 60 * 60 * 1000 + : null + const filtered = normalized.filter((lead) => { + if (sourceFilter !== 'all' && lead.platform !== sourceFilter) return false + if (ageLimit && lead.createdAt < ageLimit) return false + return true + }) + return [...filtered].sort((a, b) => b.createdAt - a.createdAt) + }, [leads, sourceFilter, ageFilter]) + + const sourceOptions = useMemo(() => { + if (!leads) return [] + const set = new Set((leads as Lead[]).map((lead) => lead.platform)) + return Array.from(set).sort((a, b) => a.localeCompare(b)) + }, [leads]) + + const selectedLead = useMemo(() => { + if (!sortedLeads.length) return null + const found = sortedLeads.find((lead) => lead._id === selectedId) + return found ?? sortedLeads[0] + }, [sortedLeads, selectedId]) + + useEffect(() => { + if (!selectedLead) return + setNotes(selectedLead.notes || '') + setTags(selectedLead.tags?.join(', ') || '') + }, [selectedLead]) + + const handleSelect = (lead: Lead) => { + setSelectedId(lead._id) + setNotes(lead.notes || '') + setTags(lead.tags?.join(', ') || '') + } + + const handleSave = async () => { + if (!selectedLead) return + const tagList = tags + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean) + await updateOpportunity({ + id: selectedLead._id as any, + status: selectedLead.status || 'new', + notes: notes || undefined, + tags: tagList.length ? tagList : undefined, + }) + } + + return ( +
+
+
+
+ + Inbox +
+

Newest opportunities ready for outreach.

+
+
+
+
+ + +
+
+ + +
+
+
+ +
+ {sortedLeads.length === 0 && ( + + No leads yet. Run a search to populate the inbox. + + )} + {sortedLeads.map((lead) => ( + + ))} +
+
+
+ +
+
+
+
+
Lead
+

Review the thread before reaching out.

+
+ {selectedLead && ( + + )} +
+
+ +
+ {selectedLead ? ( + <> + +
+
+ + {Math.round(selectedLead.relevanceScore * 100)}% match + + + {selectedLead.intent} + + + {selectedLead.status || 'new'} + +
+
{selectedLead.title}
+

{selectedLead.snippet}

+
+
+ + +
+
+ +
+ {selectedLead.matchedKeywords.map((keyword, index) => ( + + {keyword} + + ))} +
+
+
+ +
+ {selectedLead.matchedProblems.map((problem, index) => ( + + {problem} + + ))} +
+
+
+ +
+
+ +

{selectedLead.suggestedApproach}

+
+
+ +

+ {selectedLead.softPitch ? 'Use a softer, story-led opener.' : 'Lead with a direct solution.'} +

+
+
+
+ + +
+ +