From 358f2a42dda56e06a74b5b6cd628f279713e3c4e Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Tue, 3 Feb 2026 22:43:27 +0000 Subject: [PATCH] feat: Implement analysis job tracking with progress timeline and enhanced data source status management. --- .env.example | 4 +- app/(app)/dashboard/page.tsx | 81 +++- app/(app)/data-sources/[id]/page.tsx | 264 +++++++++++++ app/(app)/opportunities/page.tsx | 407 ++++++++++++++++--- app/api/analyze-manual/route.ts | 137 ++++++- app/api/analyze/route.ts | 138 ++++++- app/api/opportunities/route.ts | 88 ++++- app/onboarding/page.tsx | 170 ++++++-- components/analysis-timeline.tsx | 82 ++++ components/app-sidebar.tsx | 565 +++++++++++++++++++++++---- components/hero-shader.tsx | 38 +- components/nav-user.tsx | 23 +- components/ui/progress.tsx | 30 ++ convex/_generated/api.d.ts | 10 + convex/analysisJobs.ts | 128 ++++++ convex/dataSources.ts | 73 +++- convex/projects.ts | 43 +- convex/schema.ts | 44 +++ convex/searchJobs.ts | 92 +++++ convex/users.ts | 11 + lib/analysis-pipeline.ts | 35 +- lib/query-generator.ts | 7 +- 22 files changed, 2251 insertions(+), 219 deletions(-) create mode 100644 app/(app)/data-sources/[id]/page.tsx create mode 100644 components/analysis-timeline.tsx create mode 100644 components/ui/progress.tsx create mode 100644 convex/analysisJobs.ts create mode 100644 convex/searchJobs.ts create mode 100644 convex/users.ts diff --git a/.env.example b/.env.example index c0c1828..747d55d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Required: OpenAI API key -OPENAI_API_KEY=sk-... +OPENAI_API_KEY=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 # Optional: Serper.dev API key for reliable Google search -SERPER_API_KEY=... +SERPER_API_KEY=f9ae0a793cbac4116edd6482e377330e75c4db22 diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 49bfbad..e70c449 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge" import { Alert, AlertDescription } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { useMutation } from "convex/react" +import { Progress } from "@/components/ui/progress" export default function Page() { const { selectedProjectId } = useProject() @@ -21,8 +22,13 @@ export default function Page() { api.projects.getSearchContext, selectedProjectId ? { projectId: selectedProjectId as any } : "skip" ) + const analysisJobs = useQuery( + api.analysisJobs.listByProject, + selectedProjectId ? { projectId: selectedProjectId as any } : "skip" + ) const updateDataSourceStatus = useMutation(api.dataSources.updateDataSourceStatus) const createAnalysis = useMutation(api.analyses.createAnalysis) + const createAnalysisJob = useMutation(api.analysisJobs.create) const [reanalyzingId, setReanalyzingId] = useState(null) const analysis = useQuery( api.analyses.getLatestByProject, @@ -75,10 +81,15 @@ export default function Page() { }) try { + const jobId = await createAnalysisJob({ + projectId: selectedProjectId as any, + dataSourceId: source._id, + }) + const response = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: source.url }), + body: JSON.stringify({ url: source.url, jobId }), }) const data = await response.json() @@ -114,10 +125,10 @@ export default function Page() {
-

{analysis.productName}

- {selectedProject?.name && ( - {selectedProject.name} - )} +

+ {selectedProject?.name || analysis.productName} +

+ {analysis.productName}

{analysis.tagline}

{analysis.description}

@@ -126,7 +137,15 @@ export default function Page() { {searchContext?.missingSources?.length > 0 && ( - Some selected sources don't have analysis yet. Run onboarding or re-analyze them for best results. + Some selected sources don't have analysis yet:{" "} + {searchContext.missingSources + .map((missing: any) => + dataSources?.find((source: any) => source._id === missing.sourceId)?.name || + dataSources?.find((source: any) => source._id === missing.sourceId)?.url || + missing.sourceId + ) + .join(", ")} + . Run onboarding or re-analyze them for best results. )} @@ -197,6 +216,56 @@ export default function Page() { + {analysisJobs && analysisJobs.length > 0 && ( + + + Analysis Jobs + + + {analysisJobs.slice(0, 5).map((job: any) => { + const sourceName = dataSources?.find((source: any) => source._id === job.dataSourceId)?.name + || dataSources?.find((source: any) => source._id === job.dataSourceId)?.url + || "Unknown source" + return ( +
+
+
+ {sourceName}{" "} + + ({job.status}) + +
+ {job.status === "failed" && ( + + )} +
+ {(job.status === "running" || job.status === "pending") && ( +
+ +
+ {typeof job.progress === "number" ? `${job.progress}% complete` : "Starting..."} +
+
+ )} + {job.status === "failed" && job.error && ( +
{job.error}
+ )} +
+ ) + })} +
+
+ )} + {searchContext?.context && ( diff --git a/app/(app)/data-sources/[id]/page.tsx b/app/(app)/data-sources/[id]/page.tsx new file mode 100644 index 0000000..102015b --- /dev/null +++ b/app/(app)/data-sources/[id]/page.tsx @@ -0,0 +1,264 @@ +"use client" + +import { useParams, useRouter } from "next/navigation" +import { useMutation, useQuery } from "convex/react" +import { api } from "@/convex/_generated/api" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Settings } from "lucide-react" +import * as React from "react" + +function formatDate(timestamp?: number) { + if (!timestamp) return "Not analyzed yet"; + return new Date(timestamp).toLocaleString(); +} + +export default function DataSourceDetailPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const dataSourceId = params?.id + const dataSource = useQuery( + api.dataSources.getById, + dataSourceId ? { dataSourceId: dataSourceId as any } : "skip" + ) + const analysis = useQuery( + api.analyses.getLatestByDataSource, + dataSourceId ? { dataSourceId: dataSourceId as any } : "skip" + ) + const removeDataSource = useMutation(api.dataSources.remove) + const [isDeleting, setIsDeleting] = React.useState(false) + const [isDialogOpen, setIsDialogOpen] = React.useState(false) + + if (dataSource === undefined) { + return ( +
+
Loading data source…
+
+ ) + } + + if (!dataSource) { + return ( +
+

Data source not found

+

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

+
+ ) + } + + const statusVariant = + dataSource.analysisStatus === "completed" + ? "secondary" + : dataSource.analysisStatus === "failed" + ? "destructive" + : "outline" + + return ( +
+
+
+

+ {dataSource.name || "Data Source"} +

+
+ + {dataSource.analysisStatus} + + +
+
+

{dataSource.url}

+
+ Last analyzed: {formatDate(dataSource.lastAnalyzedAt)} + {dataSource.lastError && ( + + Error: {dataSource.lastError} + + )} +
+
+ + {analysis ? ( + <> +
+ + + Features + + + {analysis.features.length} + + + + + Keywords + + + {analysis.keywords.length} + + + + + Personas + + + {analysis.personas.length} + + +
+ + + + Overview + + +
+ Product:{" "} + {analysis.productName} +
+
+ Tagline:{" "} + {analysis.tagline} +
+
+ Category:{" "} + {analysis.category} +
+
+ Positioning:{" "} + {analysis.positioning} +
+
{analysis.description}
+
+
+ +
+ + + Top Features + + + {analysis.features.slice(0, 6).map((feature) => ( +
+
{feature.name}
+
+ {feature.description} +
+
+ ))} +
+
+ + + Pain Points + + + {analysis.problemsSolved.slice(0, 6).map((problem) => ( +
+
{problem.problem}
+
+ Severity: {problem.severity} · {problem.emotionalImpact} +
+
+ ))} +
+
+
+ +
+ + + Personas + + + {analysis.personas.slice(0, 4).map((persona) => ( +
+
+ {persona.name} · {persona.role} +
+
+ {persona.industry} · {persona.companySize} +
+
+ ))} +
+
+ + + Keywords + + + {analysis.keywords.slice(0, 12).map((keyword) => ( + + {keyword.term} + + ))} + + +
+ + ) : ( + + + Analysis + + + No analysis available yet. Trigger a new analysis to populate this + data source. + + + )} + + + + + Delete data source + + This removes the data source and its analyses from the project. This + cannot be undone. + + + + + + + + +
+ ) +} diff --git a/app/(app)/opportunities/page.tsx b/app/(app)/opportunities/page.tsx index ee6d738..9ce504a 100644 --- a/app/(app)/opportunities/page.tsx +++ b/app/(app)/opportunities/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/navigation' import { useMutation, useQuery } from 'convex/react' import { api } from '@/convex/_generated/api' @@ -10,6 +10,7 @@ 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 { Progress } from '@/components/ui/progress' import { Separator } from '@/components/ui/separator' import { ScrollArea } from '@/components/ui/scroll-area' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' @@ -37,6 +38,7 @@ import { ExternalLink, MessageSquare, Twitter, + Globe, Users, HelpCircle, Filter, @@ -55,8 +57,10 @@ import type { EnhancedProductAnalysis, Opportunity, PlatformConfig, - SearchStrategy + SearchStrategy, + SearchConfig } from '@/lib/types' +import { estimateSearchTime } from '@/lib/query-generator' const STRATEGY_INFO: Record = { 'direct-keywords': { name: 'Direct Keywords', description: 'People looking for your product category' }, @@ -68,11 +72,64 @@ const STRATEGY_INFO: Record(null) const [platforms, setPlatforms] = useState([]) const [strategies, setStrategies] = useState([ @@ -81,6 +138,8 @@ export default function OpportunitiesPage() { 'competitor-alternative' ]) const [intensity, setIntensity] = useState<'broad' | 'balanced' | 'targeted'>('balanced') + const [maxQueries, setMaxQueries] = useState(50) + const [goalPreset, setGoalPreset] = useState('high-intent') const [isSearching, setIsSearching] = useState(false) const [opportunities, setOpportunities] = useState([]) const [generatedQueries, setGeneratedQueries] = useState([]) @@ -90,6 +149,7 @@ export default function OpportunitiesPage() { const [stats, setStats] = useState(null) const [searchError, setSearchError] = useState('') const [missingSources, setMissingSources] = useState([]) + const [lastSearchConfig, setLastSearchConfig] = useState(null) const [statusFilter, setStatusFilter] = useState('all') const [intentFilter, setIntentFilter] = useState('all') const [minScore, setMinScore] = useState(0) @@ -97,6 +157,8 @@ export default function OpportunitiesPage() { const [statusInput, setStatusInput] = useState('new') const [notesInput, setNotesInput] = useState('') const [tagsInput, setTagsInput] = useState('') + const planLoadedRef = useRef(null) + const defaultPlatformsRef = useRef(null) const projects = useQuery(api.projects.getProjects) const latestAnalysis = useQuery( @@ -143,12 +205,18 @@ export default function OpportunitiesPage() { api.dataSources.getProjectDataSources, selectedProjectId ? { projectId: selectedProjectId as any } : "skip" ) + const searchJobs = useQuery( + api.searchJobs.listByProject, + selectedProjectId ? { projectId: selectedProjectId as any } : "skip" + ) const selectedProject = projects?.find((project: any) => project._id === selectedProjectId) const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || [] const activeSources = selectedSources?.filter((source: any) => selectedSourceIds.includes(source._id) ) || [] + const enabledPlatforms = platforms.filter((platform) => platform.enabled) + const estimatedMinutes = estimateSearchTime(Math.max(maxQueries, 1), enabledPlatforms.map((platform) => platform.id)) useEffect(() => { const stored = localStorage.getItem('productAnalysis') @@ -167,16 +235,76 @@ export default function OpportunitiesPage() { .then(data => { if (data) { setPlatforms(data.platforms) + defaultPlatformsRef.current = data.platforms } }) }, [router]) + useEffect(() => { + if (!selectedProjectId) return + if (platforms.length === 0) return + if (planLoadedRef.current === selectedProjectId) return + + const key = `searchPlan:${selectedProjectId}` + const stored = localStorage.getItem(key) + if (stored) { + try { + const parsed = JSON.parse(stored) + if (Array.isArray(parsed.strategies)) { + setStrategies(parsed.strategies) + } + if (parsed.intensity === 'broad' || parsed.intensity === 'balanced' || parsed.intensity === 'targeted') { + setIntensity(parsed.intensity) + } + if (typeof parsed.maxQueries === 'number') { + setMaxQueries(Math.min(Math.max(parsed.maxQueries, 10), 50)) + } + if (typeof parsed.goalPreset === 'string') { + setGoalPreset(parsed.goalPreset) + } + if (Array.isArray(parsed.platformIds)) { + setPlatforms((prev) => + prev.map((platform) => ({ + ...platform, + enabled: parsed.platformIds.includes(platform.id), + })) + ) + } + } catch { + // Ignore invalid cached config. + } + } else if (defaultPlatformsRef.current) { + setStrategies(['direct-keywords', 'problem-pain', 'competitor-alternative']) + setIntensity('balanced') + setMaxQueries(50) + setGoalPreset('high-intent') + setPlatforms(defaultPlatformsRef.current) + } + + planLoadedRef.current = selectedProjectId + }, [selectedProjectId, platforms]) + useEffect(() => { if (!analysis && latestAnalysis) { setAnalysis(latestAnalysis as any) } }, [analysis, latestAnalysis]) + useEffect(() => { + if (!selectedProjectId) return + if (planLoadedRef.current !== selectedProjectId) return + + const key = `searchPlan:${selectedProjectId}` + const payload = { + goalPreset, + strategies, + intensity, + maxQueries, + platformIds: platforms.filter((platform) => platform.enabled).map((platform) => platform.id), + } + localStorage.setItem(key, JSON.stringify(payload)) + }, [selectedProjectId, goalPreset, strategies, intensity, maxQueries, platforms]) + useEffect(() => { if (!analysis && latestAnalysis === null) { router.push('/onboarding') @@ -197,7 +325,16 @@ export default function OpportunitiesPage() { ) } - const executeSearch = async () => { + const applyGoalPreset = (presetId: string) => { + const preset = GOAL_PRESETS.find((item) => item.id === presetId) + if (!preset) return + setGoalPreset(preset.id) + setStrategies(preset.strategies) + setIntensity(preset.intensity) + setMaxQueries(preset.maxQueries) + } + + const executeSearch = async (overrideConfig?: SearchConfig) => { if (!analysis) return if (!selectedProjectId) return @@ -206,17 +343,27 @@ export default function OpportunitiesPage() { setSearchError('') try { - const config = { - platforms, + const config = overrideConfig ?? { + platforms: platforms.map((platform) => ({ + ...platform, + icon: platform.icon ?? "", + searchTemplate: platform.searchTemplate ?? "", + })), strategies, intensity, - maxResults: intensity === 'broad' ? 80 : intensity === 'balanced' ? 50 : 30 + maxResults: Math.min(maxQueries, 50) } + setLastSearchConfig(config as SearchConfig) + + const jobId = await createSearchJob({ + projectId: selectedProjectId as any, + config, + }) const response = await fetch('/api/opportunities', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ projectId: selectedProjectId, config }) + body: JSON.stringify({ projectId: selectedProjectId, config, jobId }) }) if (response.redirected) { @@ -224,13 +371,14 @@ export default function OpportunitiesPage() { return } - const data = await response.json() + const responseText = await response.text() + const data = responseText ? JSON.parse(responseText) : null if (!response.ok) { - throw new Error(data.error || 'Failed to search for opportunities') + throw new Error(data?.error || 'Failed to search for opportunities') } - if (data.success) { + if (data?.success) { const mapped = data.data.opportunities.map((opp: Opportunity) => ({ ...opp, status: 'new', @@ -256,6 +404,8 @@ export default function OpportunitiesPage() { softPitch: opp.softPitch, })), }) + } else { + throw new Error('Search returned no data') } } catch (error: any) { console.error('Search error:', error) @@ -307,80 +457,162 @@ export default function OpportunitiesPage() { if (!analysis) return null + const latestJob = searchJobs && searchJobs.length > 0 ? searchJobs[0] : null + return (
{/* Sidebar */} -
-
+
+

- Search Configuration + Search Plan

+

+ Pick a goal, tune channels, then run the scan. +

+ {/* Goal presets */} +
+ +
+ {GOAL_PRESETS.map((preset) => ( + + ))} +
+
+ + + {/* Platforms */}
- -
- {platforms.map(platform => ( -
- togglePlatform(platform.id)} - /> - -
- ))} + +
+ {platforms.map(platform => { + const isEnabled = platform.enabled + const iconMap: Record = { + reddit: , + twitter: , + hackernews: , + indiehackers: , + quora: , + stackoverflow: , + linkedin: + } + return ( + + ) + })}
{/* Strategies */} -
- -
- {(Object.keys(STRATEGY_INFO) as SearchStrategy[]).map(strategy => ( -
- toggleStrategy(strategy)} - /> -
- -

{STRATEGY_INFO[strategy].description}

-
+
+ + {STRATEGY_GROUPS.map((group) => ( +
+
+
{group.title}
+
{group.description}
- ))} -
+
+ {group.strategies.map((strategy) => ( +
+ toggleStrategy(strategy)} + /> +
+ +

{STRATEGY_INFO[strategy].description}

+
+
+ ))} +
+
+ ))}
- {/* Intensity */} -
- - setIntensity(v < 33 ? 'broad' : v < 66 ? 'balanced' : 'targeted')} - max={100} - step={50} - /> -
- Broad - Targeted + {/* Depth + Queries */} +
+ +
+
+ Broad + Targeted +
+ setIntensity(v < 33 ? 'broad' : v < 66 ? 'balanced' : 'targeted')} + max={100} + step={50} + /> +
+
+
+ + {maxQueries} +
+ setMaxQueries(v)} + min={10} + max={50} + step={5} + /> +
+ Up to {maxQueries} queries · est. {estimatedMinutes} min +
-
+
+
+ {enabledPlatforms.length || 0} channels + · + {strategies.length} signals + · + max {maxQueries} queries +
+ {platforms.filter(p => p.enabled).length === 0 && ( +

Select at least one platform to search.

+ )} + {selectedSourceIds.length === 0 && ( +

Select data sources to build search context.

+ )}
@@ -397,7 +642,7 @@ export default function OpportunitiesPage() {
{/* Header */} -
+

Opportunity Finder

Discover potential customers for {analysis.productName}

@@ -415,6 +660,42 @@ export default function OpportunitiesPage() {
)}
+
+ Goal: {GOAL_PRESETS.find((preset) => preset.id === goalPreset)?.title || "Custom"} + Intensity: {intensity} + Max queries: {maxQueries} +
+ {latestJob && (latestJob.status === "running" || latestJob.status === "pending") && ( + + + Search in progress + + Current job: {latestJob.status} + + + + +
+ {typeof latestJob.progress === "number" ? `${latestJob.progress}% complete` : "Starting..."} +
+
+
+ )} + {latestJob && latestJob.status === "failed" && ( + + + Search failed: {latestJob.error || "Unknown error"}.{" "} + + + + )} {searchError && ( {searchError} @@ -451,7 +732,15 @@ export default function OpportunitiesPage() { {missingSources.length > 0 && ( - Some selected sources don't have analysis yet. Run onboarding or re-analyze them for best results. + Some selected sources don't have analysis yet:{" "} + {missingSources + .map((missing) => + activeSources.find((source: any) => source._id === missing.sourceId)?.name || + activeSources.find((source: any) => source._id === missing.sourceId)?.url || + missing.sourceId + ) + .join(", ")} + . Run onboarding or re-analyze them for best results. )} diff --git a/app/api/analyze-manual/route.ts b/app/api/analyze-manual/route.ts index 8346016..5c86d70 100644 --- a/app/api/analyze-manual/route.ts +++ b/app/api/analyze-manual/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' -import { isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server"; +import { convexAuthNextjsToken, isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server"; +import { fetchMutation } from "convex/nextjs"; +import { api } from "@/convex/_generated/api"; import { z } from 'zod' import { analyzeFromText } from '@/lib/scraper' import { performDeepAnalysis } from '@/lib/analysis-pipeline' @@ -7,10 +9,18 @@ import { performDeepAnalysis } from '@/lib/analysis-pipeline' const bodySchema = z.object({ productName: z.string().min(1), description: z.string().min(1), - features: z.string() + features: z.string(), + jobId: z.optional(z.string()) }) export async function POST(request: NextRequest) { + let jobId: string | undefined + let timeline: { + key: string + label: string + status: "pending" | "running" | "completed" | "failed" + detail?: string + }[] = [] try { if (!(await isAuthenticatedNextjs())) { const redirectUrl = new URL("/auth", request.url); @@ -21,9 +31,69 @@ export async function POST(request: NextRequest) { } const body = await request.json() - const { productName, description, features } = bodySchema.parse(body) + const parsed = bodySchema.parse(body) + const { productName, description, features } = parsed + jobId = parsed.jobId + + const token = await convexAuthNextjsToken(); + timeline = [ + { key: "scrape", label: "Prepare input", status: "pending" }, + { key: "features", label: "Pass 1: Features", status: "pending" }, + { key: "competitors", label: "Pass 2: Competitors", status: "pending" }, + { key: "keywords", label: "Pass 3: Keywords", status: "pending" }, + { key: "problems", label: "Pass 4: Problems & Personas", status: "pending" }, + { key: "useCases", label: "Pass 5: Use cases", status: "pending" }, + { key: "dorkQueries", label: "Pass 6: Dork queries", status: "pending" }, + { key: "finalize", label: "Finalize analysis", status: "pending" }, + ] + const updateTimeline = async ({ + key, + status, + detail, + progress, + finalStatus, + }: { + key: string + status: "pending" | "running" | "completed" | "failed" + detail?: string + progress?: number + finalStatus?: "running" | "completed" | "failed" + }) => { + if (!jobId) return + timeline = timeline.map((item) => + item.key === key ? { ...item, status, detail: detail ?? item.detail } : item + ) + await fetchMutation( + api.analysisJobs.update, + { + jobId: jobId as any, + status: finalStatus || "running", + progress, + stage: key, + timeline, + }, + { token } + ) + } + if (jobId) { + await updateTimeline({ key: "scrape", status: "running", progress: 10 }) + } if (!process.env.OPENAI_API_KEY) { + if (jobId) { + await fetchMutation( + api.analysisJobs.update, + { + jobId: jobId as any, + status: "failed", + error: "OpenAI API key not configured", + timeline: timeline.map((item) => + item.status === "running" ? { ...item, status: "failed" } : item + ), + }, + { token } + ); + } return NextResponse.json( { error: 'OpenAI API key not configured' }, { status: 500 } @@ -32,10 +102,49 @@ export async function POST(request: NextRequest) { console.log('📝 Creating content from manual input...') const scrapedContent = await analyzeFromText(productName, description, features) + if (jobId) { + await updateTimeline({ + key: "scrape", + status: "completed", + detail: "Manual input prepared", + progress: 20, + }) + } console.log('🤖 Starting enhanced analysis...') - const analysis = await performDeepAnalysis(scrapedContent) + const progressMap: Record = { + features: 35, + competitors: 50, + keywords: 65, + problems: 78, + useCases: 88, + dorkQueries: 95, + } + const analysis = await performDeepAnalysis(scrapedContent, async (update) => { + await updateTimeline({ + key: update.key, + status: update.status, + detail: update.detail, + progress: progressMap[update.key] ?? 80, + }) + }) console.log(` ✓ Analysis complete: ${analysis.features.length} features, ${analysis.keywords.length} keywords`) + if (jobId) { + await updateTimeline({ + key: "finalize", + status: "running", + progress: 98, + }) + } + + if (jobId) { + await updateTimeline({ + key: "finalize", + status: "completed", + progress: 100, + finalStatus: "completed", + }) + } return NextResponse.json({ success: true, @@ -52,6 +161,26 @@ export async function POST(request: NextRequest) { } catch (error: any) { console.error('❌ Manual analysis error:', error) + + if (jobId) { + try { + const token = await convexAuthNextjsToken(); + await fetchMutation( + api.analysisJobs.update, + { + jobId: jobId as any, + status: "failed", + error: error.message || "Manual analysis failed", + timeline: timeline.map((item) => + item.status === "running" ? { ...item, status: "failed" } : item + ), + }, + { token } + ); + } catch { + // Best-effort job update only. + } + } if (error.name === 'ZodError') { return NextResponse.json( diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts index e6b3eaf..c4384b5 100644 --- a/app/api/analyze/route.ts +++ b/app/api/analyze/route.ts @@ -1,14 +1,24 @@ import { NextRequest, NextResponse } from 'next/server' -import { isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server"; +import { convexAuthNextjsToken, isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server"; +import { fetchMutation } from "convex/nextjs"; +import { api } from "@/convex/_generated/api"; import { z } from 'zod' import { scrapeWebsite, ScrapingError } from '@/lib/scraper' import { performDeepAnalysis } from '@/lib/analysis-pipeline' const bodySchema = z.object({ - url: z.string().min(1) + url: z.string().min(1), + jobId: z.optional(z.string()) }) export async function POST(request: NextRequest) { + let jobId: string | undefined + let timeline: { + key: string + label: string + status: "pending" | "running" | "completed" | "failed" + detail?: string + }[] = [] try { if (!(await isAuthenticatedNextjs())) { const redirectUrl = new URL("/auth", request.url); @@ -19,9 +29,70 @@ export async function POST(request: NextRequest) { } const body = await request.json() - const { url } = bodySchema.parse(body) + const parsed = bodySchema.parse(body) + const { url } = parsed + jobId = parsed.jobId + + const token = await convexAuthNextjsToken(); + timeline = [ + { key: "scrape", label: "Scrape website", status: "pending" }, + { key: "features", label: "Pass 1: Features", status: "pending" }, + { key: "competitors", label: "Pass 2: Competitors", status: "pending" }, + { key: "keywords", label: "Pass 3: Keywords", status: "pending" }, + { key: "problems", label: "Pass 4: Problems & Personas", status: "pending" }, + { key: "useCases", label: "Pass 5: Use cases", status: "pending" }, + { key: "dorkQueries", label: "Pass 6: Dork queries", status: "pending" }, + { key: "finalize", label: "Finalize analysis", status: "pending" }, + ] + + const updateTimeline = async ({ + key, + status, + detail, + progress, + finalStatus, + }: { + key: string + status: "pending" | "running" | "completed" | "failed" + detail?: string + progress?: number + finalStatus?: "running" | "completed" | "failed" + }) => { + if (!jobId) return + timeline = timeline.map((item) => + item.key === key ? { ...item, status, detail: detail ?? item.detail } : item + ) + await fetchMutation( + api.analysisJobs.update, + { + jobId: jobId as any, + status: finalStatus || "running", + progress, + stage: key, + timeline, + }, + { token } + ) + } + if (jobId) { + await updateTimeline({ key: "scrape", status: "running", progress: 10 }) + } if (!process.env.OPENAI_API_KEY) { + if (jobId) { + await fetchMutation( + api.analysisJobs.update, + { + jobId: jobId as any, + status: "failed", + error: "OpenAI API key not configured", + timeline: timeline.map((item) => + item.status === "running" ? { ...item, status: "failed" } : item + ), + }, + { token } + ); + } return NextResponse.json( { error: 'OpenAI API key not configured' }, { status: 500 } @@ -31,10 +102,49 @@ export async function POST(request: NextRequest) { console.log(`🌐 Scraping: ${url}`) const scrapedContent = await scrapeWebsite(url) console.log(` ✓ Scraped ${scrapedContent.headings.length} headings, ${scrapedContent.paragraphs.length} paragraphs`) + if (jobId) { + await updateTimeline({ + key: "scrape", + status: "completed", + detail: `${scrapedContent.headings.length} headings, ${scrapedContent.paragraphs.length} paragraphs`, + progress: 20, + }) + } console.log('🤖 Starting enhanced analysis...') - const analysis = await performDeepAnalysis(scrapedContent) + const progressMap: Record = { + features: 35, + competitors: 50, + keywords: 65, + problems: 78, + useCases: 88, + dorkQueries: 95, + } + const analysis = await performDeepAnalysis(scrapedContent, async (update) => { + await updateTimeline({ + key: update.key, + status: update.status, + detail: update.detail, + progress: progressMap[update.key] ?? 80, + }) + }) console.log(` ✓ Analysis complete: ${analysis.features.length} features, ${analysis.keywords.length} keywords, ${analysis.dorkQueries.length} queries`) + if (jobId) { + await updateTimeline({ + key: "finalize", + status: "running", + progress: 98, + }) + } + + if (jobId) { + await updateTimeline({ + key: "finalize", + status: "completed", + progress: 100, + finalStatus: "completed", + }) + } return NextResponse.json({ success: true, @@ -51,6 +161,26 @@ export async function POST(request: NextRequest) { } catch (error: any) { console.error('❌ Analysis error:', error) + + if (jobId) { + try { + const token = await convexAuthNextjsToken(); + await fetchMutation( + api.analysisJobs.update, + { + jobId: jobId as any, + status: "failed", + error: error.message || "Analysis failed", + timeline: timeline.map((item) => + item.status === "running" ? { ...item, status: "failed" } : item + ), + }, + { token } + ); + } catch { + // Best-effort job update only. + } + } if (error instanceof ScrapingError) { return NextResponse.json( diff --git a/app/api/opportunities/route.ts b/app/api/opportunities/route.ts index f3df2ce..18c3f3c 100644 --- a/app/api/opportunities/route.ts +++ b/app/api/opportunities/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { convexAuthNextjsToken, isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server"; -import { fetchQuery } from "convex/nextjs"; +import { fetchMutation, fetchQuery } from "convex/nextjs"; import { api } from "@/convex/_generated/api"; import { z } from 'zod' import { generateSearchQueries, getDefaultPlatforms } from '@/lib/query-generator' @@ -9,13 +9,14 @@ import type { EnhancedProductAnalysis, SearchConfig, PlatformConfig } from '@/li const searchSchema = z.object({ projectId: z.string(), + jobId: z.optional(z.string()), config: z.object({ platforms: z.array(z.object({ id: z.string(), name: z.string(), - icon: z.string(), + icon: z.string().optional(), enabled: z.boolean(), - searchTemplate: z.string(), + searchTemplate: z.string().optional(), rateLimit: z.number() })), strategies: z.array(z.string()), @@ -25,6 +26,7 @@ const searchSchema = z.object({ }) export async function POST(request: NextRequest) { + let jobId: string | undefined try { if (!(await isAuthenticatedNextjs())) { const redirectUrl = new URL("/auth", request.url); @@ -35,9 +37,18 @@ export async function POST(request: NextRequest) { } const body = await request.json() - const { projectId, config } = searchSchema.parse(body) + const parsed = searchSchema.parse(body) + const { projectId, config } = parsed + jobId = parsed.jobId const token = await convexAuthNextjsToken(); + if (jobId) { + await fetchMutation( + api.searchJobs.update, + { jobId: jobId as any, status: "running", progress: 10 }, + { token } + ); + } const searchContext = await fetchQuery( api.projects.getSearchContext, { projectId: projectId as any }, @@ -45,6 +56,13 @@ export async function POST(request: NextRequest) { ); if (!searchContext.context) { + if (jobId) { + await fetchMutation( + api.searchJobs.update, + { jobId: jobId as any, status: "failed", error: "No analysis available." }, + { token } + ); + } return NextResponse.json( { error: 'No analysis available for selected sources.' }, { status: 400 } @@ -60,18 +78,51 @@ export async function POST(request: NextRequest) { // Generate queries console.log(' Generating search queries...') - const queries = generateSearchQueries(analysis as EnhancedProductAnalysis, config as SearchConfig) + const enforcedConfig: SearchConfig = { + ...(config as SearchConfig), + maxResults: Math.min((config as SearchConfig).maxResults || 50, 50), + } + const queries = generateSearchQueries(analysis as EnhancedProductAnalysis, enforcedConfig) console.log(` ✓ Generated ${queries.length} queries`) + if (jobId) { + await fetchMutation( + api.searchJobs.update, + { jobId: jobId as any, status: "running", progress: 40 }, + { token } + ); + } // Execute searches console.log(' Executing searches...') const searchResults = await executeSearches(queries) console.log(` ✓ Found ${searchResults.length} raw results`) + if (jobId) { + await fetchMutation( + api.searchJobs.update, + { jobId: jobId as any, status: "running", progress: 70 }, + { token } + ); + } // Score and rank console.log(' Scoring opportunities...') const opportunities = scoreOpportunities(searchResults, analysis as EnhancedProductAnalysis) console.log(` ✓ Scored ${opportunities.length} opportunities`) + if (jobId) { + await fetchMutation( + api.searchJobs.update, + { jobId: jobId as any, status: "running", progress: 90 }, + { token } + ); + } + + if (jobId) { + await fetchMutation( + api.searchJobs.update, + { jobId: jobId as any, status: "completed", progress: 100 }, + { token } + ); + } return NextResponse.json({ success: true, @@ -97,17 +148,36 @@ export async function POST(request: NextRequest) { }) } catch (error: any) { - console.error('❌ Opportunity search error:', error) + const errorMessage = + error instanceof Error ? error.message : typeof error === "string" ? error : "Search failed" + console.error("❌ Opportunity search error:", errorMessage) + + if (jobId) { + try { + const token = await convexAuthNextjsToken(); + await fetchMutation( + api.searchJobs.update, + { + jobId: jobId as any, + status: "failed", + error: errorMessage + }, + { token } + ); + } catch { + // Best-effort job update only. + } + } - if (error.name === 'ZodError') { + if (error?.name === 'ZodError') { return NextResponse.json( - { error: 'Invalid request format', details: error.errors }, + { error: 'Invalid request format', details: error?.errors }, { status: 400 } ) } return NextResponse.json( - { error: error.message || 'Failed to search for opportunities' }, + { error: errorMessage || 'Failed to search for opportunities' }, { status: 500 } ) } diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index 1cc7485..fa89e09 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -11,8 +11,9 @@ import { Skeleton } from '@/components/ui/skeleton' import { Alert, AlertDescription } from '@/components/ui/alert' import { ArrowRight, Globe, Loader2, Sparkles, AlertCircle, ArrowLeft } from 'lucide-react' import type { EnhancedProductAnalysis, Keyword } from '@/lib/types' -import { useMutation } from 'convex/react' +import { useMutation, useQuery } from 'convex/react' import { api } from '@/convex/_generated/api' +import { AnalysisTimeline } from '@/components/analysis-timeline' const examples = [ { name: 'Notion', url: 'https://notion.so' }, @@ -26,6 +27,7 @@ export default function OnboardingPage() { const addDataSource = useMutation(api.dataSources.addDataSource) const updateDataSourceStatus = useMutation(api.dataSources.updateDataSourceStatus) const createAnalysis = useMutation(api.analyses.createAnalysis) + const createAnalysisJob = useMutation(api.analysisJobs.create) const [url, setUrl] = useState('') const [loading, setLoading] = useState(false) const [progress, setProgress] = useState('') @@ -36,33 +38,56 @@ export default function OnboardingPage() { const [manualProductName, setManualProductName] = useState('') const [manualDescription, setManualDescription] = useState('') const [manualFeatures, setManualFeatures] = useState('') + const [pendingSourceId, setPendingSourceId] = useState(null) + const [pendingProjectId, setPendingProjectId] = useState(null) + const [pendingJobId, setPendingJobId] = useState(null) + const analysisJob = useQuery( + api.analysisJobs.getById, + pendingJobId ? { jobId: pendingJobId as any } : "skip" + ) const persistAnalysis = async ({ analysis, sourceUrl, sourceName, + projectId, + dataSourceId, }: { analysis: EnhancedProductAnalysis sourceUrl: string sourceName: string + projectId?: string + dataSourceId?: string }) => { - const { sourceId, projectId } = await addDataSource({ - url: sourceUrl, - name: sourceName, - type: 'website', - }) + const resolved = projectId && dataSourceId + ? { projectId, sourceId: dataSourceId } + : await addDataSource({ + url: sourceUrl, + name: sourceName, + type: 'website', + }) - await createAnalysis({ - projectId, - dataSourceId: sourceId, - analysis, - }) + try { + await createAnalysis({ + projectId: resolved.projectId, + dataSourceId: resolved.sourceId, + analysis, + }) - await updateDataSourceStatus({ - dataSourceId: sourceId, - analysisStatus: 'completed', - lastAnalyzedAt: Date.now(), - }) + await updateDataSourceStatus({ + dataSourceId: resolved.sourceId, + analysisStatus: 'completed', + lastAnalyzedAt: Date.now(), + }) + } catch (err: any) { + await updateDataSourceStatus({ + dataSourceId: resolved.sourceId, + analysisStatus: 'failed', + lastError: err?.message || 'Failed to save analysis', + lastAnalyzedAt: Date.now(), + }) + throw err + } } async function analyzeWebsite() { @@ -71,12 +96,35 @@ export default function OnboardingPage() { setLoading(true) setError('') setProgress('Scraping website...') + let manualFallback = false try { + const { sourceId, projectId } = await addDataSource({ + url, + name: url.replace(/^https?:\/\//, '').replace(/\/$/, ''), + type: 'website', + }) + + await updateDataSourceStatus({ + dataSourceId: sourceId, + analysisStatus: 'pending', + lastError: undefined, + lastAnalyzedAt: undefined, + }) + + setPendingSourceId(sourceId) + setPendingProjectId(projectId) + + const jobId = await createAnalysisJob({ + projectId, + dataSourceId: sourceId, + }) + setPendingJobId(jobId) + const response = await fetch('/api/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url }), + body: JSON.stringify({ url, jobId }), }) if (response.redirected) { @@ -90,6 +138,7 @@ export default function OnboardingPage() { if (data.needsManualInput) { setShowManualInput(true) setManualProductName(url.replace(/^https?:\/\//, '').replace(/\/$/, '')) + manualFallback = true throw new Error(data.error) } throw new Error(data.error || 'Failed to analyze') @@ -106,8 +155,14 @@ export default function OnboardingPage() { analysis: data.data, sourceUrl: url, sourceName: data.data.productName, + projectId, + dataSourceId: sourceId, }) + setPendingSourceId(null) + setPendingProjectId(null) + setPendingJobId(null) + setProgress('Redirecting to dashboard...') // Redirect to dashboard with product name in query @@ -116,6 +171,14 @@ export default function OnboardingPage() { } catch (err: any) { console.error('Analysis error:', err) setError(err.message || 'Failed to analyze website') + if (pendingSourceId && !manualFallback) { + await updateDataSourceStatus({ + dataSourceId: pendingSourceId, + analysisStatus: 'failed', + lastError: err?.message || 'Failed to analyze', + lastAnalyzedAt: Date.now(), + }) + } } finally { setLoading(false) } @@ -172,13 +235,38 @@ export default function OnboardingPage() { } // Send to API to enhance with AI + let resolvedProjectId = pendingProjectId + let resolvedSourceId = pendingSourceId + let resolvedJobId = pendingJobId + + if (!resolvedProjectId || !resolvedSourceId) { + const created = await addDataSource({ + url: `manual:${manualProductName}`, + name: manualProductName, + type: 'website', + }) + resolvedProjectId = created.projectId + resolvedSourceId = created.sourceId + setPendingProjectId(created.projectId) + setPendingSourceId(created.sourceId) + } + + if (!resolvedJobId && resolvedProjectId && resolvedSourceId) { + resolvedJobId = await createAnalysisJob({ + projectId: resolvedProjectId, + dataSourceId: resolvedSourceId, + }) + setPendingJobId(resolvedJobId) + } + const response = await fetch('/api/analyze-manual', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productName: manualProductName, description: manualDescription, - features: manualFeatures + features: manualFeatures, + jobId: resolvedJobId || undefined, }), }) @@ -206,18 +294,36 @@ export default function OnboardingPage() { })) setProgress('Saving analysis...') + const manualSourceUrl = pendingSourceId + ? 'manual-input' + : `manual:${finalAnalysis.productName}` + await persistAnalysis({ analysis: finalAnalysis, - sourceUrl: 'manual-input', + sourceUrl: manualSourceUrl, sourceName: finalAnalysis.productName, + projectId: resolvedProjectId || undefined, + dataSourceId: resolvedSourceId || undefined, }) + setPendingSourceId(null) + setPendingProjectId(null) + setPendingJobId(null) + // Redirect to dashboard const params = new URLSearchParams({ product: finalAnalysis.productName }) router.push(`/dashboard?${params.toString()}`) } catch (err: any) { console.error('Manual analysis error:', err) setError(err.message || 'Failed to analyze') + if (pendingSourceId) { + await updateDataSourceStatus({ + dataSourceId: pendingSourceId, + analysisStatus: 'failed', + lastError: err?.message || 'Failed to analyze', + lastAnalyzedAt: Date.now(), + }) + } } finally { setLoading(false) } @@ -294,10 +400,14 @@ export default function OnboardingPage() { {progress}
-
- - -
+ {analysisJob?.timeline?.length ? ( + + ) : ( +
+ + +
+ )}
)} @@ -400,11 +510,15 @@ export default function OnboardingPage() { {progress}
-
- - - -
+ {analysisJob?.timeline?.length ? ( + + ) : ( +
+ + + +
+ )}
)} diff --git a/components/analysis-timeline.tsx b/components/analysis-timeline.tsx new file mode 100644 index 0000000..e85ebeb --- /dev/null +++ b/components/analysis-timeline.tsx @@ -0,0 +1,82 @@ +"use client" + +import { CheckCircle2, AlertTriangle, Loader2 } from "lucide-react" +import { cn } from "@/lib/utils" + +type TimelineItem = { + key: string + label: string + status: "pending" | "running" | "completed" | "failed" + detail?: string +} + +function StatusIcon({ status }: { status: TimelineItem["status"] }) { + if (status === "completed") { + return + } + if (status === "failed") { + return + } + if (status === "running") { + return + } + return +} + +export function AnalysisTimeline({ items }: { items: TimelineItem[] }) { + if (!items.length) return null + + return ( +
+
+ Analysis timeline +
+
+ {items.map((item, index) => { + const isPending = item.status === "pending" + const nextStatus = items[index + 1]?.status + const isStrongLine = + nextStatus && + (item.status === "completed" || item.status === "running") && + (nextStatus === "completed" || nextStatus === "running") + return ( +
+ +
+
+ +
+
+
+ {item.label} +
+ {item.detail && ( +
+ {item.detail} +
+ )} +
+
+
+ ) + })} +
+
+ ) +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 72a2eae..b75ded3 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -7,10 +7,13 @@ import { Command, Frame, HelpCircle, + Settings, Settings2, Terminal, Target, - Plus + Plus, + ArrowUpRight, + ChevronsUpDown } from "lucide-react" import { NavUser } from "@/components/nav-user" @@ -26,6 +29,14 @@ import { SidebarGroupLabel, SidebarGroupContent, } from "@/components/ui/sidebar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { useQuery, useMutation } from "convex/react" import { api } from "@/convex/_generated/api" import { Checkbox } from "@/components/ui/checkbox" @@ -34,19 +45,49 @@ import { useProject } from "@/components/project-context" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { AnalysisTimeline } from "@/components/analysis-timeline" export function AppSidebar({ ...props }: React.ComponentProps) { const pathname = usePathname() const projects = useQuery(api.projects.getProjects); + const currentUser = useQuery(api.users.getCurrent); const { selectedProjectId, setSelectedProjectId } = useProject(); const addDataSource = useMutation(api.dataSources.addDataSource); const updateDataSourceStatus = useMutation(api.dataSources.updateDataSourceStatus); const createAnalysis = useMutation(api.analyses.createAnalysis); + const createAnalysisJob = useMutation(api.analysisJobs.create); + const updateAnalysisJob = useMutation(api.analysisJobs.update); const [isAdding, setIsAdding] = React.useState(false); + const [isCreatingProject, setIsCreatingProject] = React.useState(false); + const [projectName, setProjectName] = React.useState(""); + const [projectDefault, setProjectDefault] = React.useState(true); + const [projectError, setProjectError] = React.useState(null); + const [isSubmittingProject, setIsSubmittingProject] = React.useState(false); + const createProject = useMutation(api.projects.createProject); + const updateProject = useMutation(api.projects.updateProject); + const [isEditingProject, setIsEditingProject] = React.useState(false); + const [editingProjectId, setEditingProjectId] = React.useState(null); + const [editingProjectName, setEditingProjectName] = React.useState(""); + const [editingProjectDefault, setEditingProjectDefault] = React.useState(false); + const [editingProjectError, setEditingProjectError] = React.useState(null); + const [isSubmittingEdit, setIsSubmittingEdit] = React.useState(false); const [sourceUrl, setSourceUrl] = React.useState(""); const [sourceName, setSourceName] = React.useState(""); const [sourceError, setSourceError] = React.useState(null); + const [sourceNotice, setSourceNotice] = React.useState(null); const [isSubmittingSource, setIsSubmittingSource] = React.useState(false); + const [manualMode, setManualMode] = React.useState(false); + const [manualProductName, setManualProductName] = React.useState(""); + const [manualDescription, setManualDescription] = React.useState(""); + const [manualFeatures, setManualFeatures] = React.useState(""); + const [pendingSourceId, setPendingSourceId] = React.useState(null); + const [pendingProjectId, setPendingProjectId] = React.useState(null); + const [pendingJobId, setPendingJobId] = React.useState(null); + const analysisJob = useQuery( + api.analysisJobs.getById, + pendingJobId ? { jobId: pendingJobId as any } : "skip" + ); // Set default selected project React.useEffect(() => { @@ -67,6 +108,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const selectedProject = projects?.find(p => p._id === selectedProjectId); const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || []; + const selectedProjectName = selectedProject?.name || "Select Project"; const handleToggle = async (sourceId: string, checked: boolean) => { if (!selectedProjectId) return; @@ -87,15 +129,27 @@ export function AppSidebar({ ...props }: React.ComponentProps) { setIsSubmittingSource(true); try { - const { sourceId, projectId } = await addDataSource({ + const result = await addDataSource({ projectId: selectedProjectId as any, url: sourceUrl, name: sourceName || sourceUrl, type: "website", }); + if ((result as any).isExisting) { + setSourceNotice("This source already exists and was reused."); + } + + const jobId = await createAnalysisJob({ + projectId: result.projectId, + dataSourceId: result.sourceId, + }); + setPendingSourceId(result.sourceId); + setPendingProjectId(result.projectId); + setPendingJobId(jobId); + await updateDataSourceStatus({ - dataSourceId: sourceId, + dataSourceId: result.sourceId, analysisStatus: "pending", lastError: undefined, lastAnalyzedAt: undefined, @@ -104,14 +158,25 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const response = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: sourceUrl }), + body: JSON.stringify({ url: sourceUrl, jobId }), }); const data = await response.json(); if (!response.ok) { + if (data.needsManualInput) { + setManualMode(true); + setManualProductName( + sourceName || sourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") + ); + setPendingSourceId(result.sourceId); + setPendingProjectId(result.projectId); + setSourceError(data.error || "Manual input required."); + return; + } + await updateDataSourceStatus({ - dataSourceId: sourceId, + dataSourceId: result.sourceId, analysisStatus: "failed", lastError: data.error || "Analysis failed", lastAnalyzedAt: Date.now(), @@ -120,13 +185,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) { } await createAnalysis({ - projectId, - dataSourceId: sourceId, + projectId: result.projectId, + dataSourceId: result.sourceId, analysis: data.data, }); await updateDataSourceStatus({ - dataSourceId: sourceId, + dataSourceId: result.sourceId, analysisStatus: "completed", lastError: undefined, lastAnalyzedAt: Date.now(), @@ -134,6 +199,14 @@ export function AppSidebar({ ...props }: React.ComponentProps) { setSourceUrl(""); setSourceName(""); + setSourceNotice(null); + setManualMode(false); + setManualProductName(""); + setManualDescription(""); + setManualFeatures(""); + setPendingSourceId(null); + setPendingProjectId(null); + setPendingJobId(null); setIsAdding(false); } catch (err: any) { setSourceError(err?.message || "Failed to add source."); @@ -142,22 +215,138 @@ export function AppSidebar({ ...props }: React.ComponentProps) { } }; + const handleManualAnalyze = async () => { + if (!manualProductName || !manualDescription) { + setSourceError("Product name and description are required."); + return; + } + + if (!pendingSourceId || !pendingProjectId) { + setSourceError("Missing pending source."); + return; + } + + setIsSubmittingSource(true); + setSourceError(null); + + try { + const response = await fetch("/api/analyze-manual", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + productName: manualProductName, + description: manualDescription, + features: manualFeatures, + jobId: pendingJobId || undefined, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + await updateDataSourceStatus({ + dataSourceId: pendingSourceId as any, + analysisStatus: "failed", + lastError: data.error || "Manual analysis failed", + lastAnalyzedAt: Date.now(), + }); + throw new Error(data.error || "Manual analysis failed"); + } + + await createAnalysis({ + projectId: pendingProjectId as any, + dataSourceId: pendingSourceId as any, + analysis: data.data, + }); + + await updateDataSourceStatus({ + dataSourceId: pendingSourceId as any, + analysisStatus: "completed", + lastError: undefined, + lastAnalyzedAt: Date.now(), + }); + + setSourceUrl(""); + setSourceName(""); + setManualMode(false); + setManualProductName(""); + setManualDescription(""); + setManualFeatures(""); + setPendingSourceId(null); + setPendingProjectId(null); + setPendingJobId(null); + setIsAdding(false); + } catch (err: any) { + setSourceError(err?.message || "Manual analysis failed."); + } finally { + setIsSubmittingSource(false); + } + }; + return ( - - -
- -
-
- Sanati - Pro -
-
-
+ + + +
+ +
+
+ {selectedProjectName} + Projects +
+ +
+
+ + Switch project + + {projects?.map((project) => ( + { + if (event.defaultPrevented) return + setSelectedProjectId(project._id) + }} + > +
+ + {project.name} +
+ +
+ ))} + + setIsCreatingProject(true)}> + + Create Project + +
+
@@ -219,32 +408,6 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - {/* Projects (Simple List for now, can be switcher) */} - - Projects - - - {projects?.map((project) => ( - - setSelectedProjectId(project._id)} - isActive={selectedProjectId === project._id} - > - - {project.name} - - - ))} - - - - Create Project - - - - - - {/* Data Sources Config */} {selectedProjectId && ( @@ -258,15 +421,30 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
No data sources yet.
)} {dataSources?.map((source) => ( -
- handleToggle(source._id, checked === true)} - /> - +
+
+ handleToggle(source._id, checked === true)} + /> + +
+ + Details + +
))}