Compare commits

...

10 Commits

66 changed files with 8750 additions and 1103 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
.git
.next
node_modules
npm-debug.log
yarn-error.log
.env
.env.*
.DS_Store
*.log
*.local
docs
scripts

View File

@@ -1,5 +1,8 @@
# Required: OpenAI API key
OPENAI_API_KEY=sk-...
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=...
SERPER_API_KEY=340b89a61031688347bd8dc06e4c55020be7a2b8

39
AGENTS.md Normal file
View File

@@ -0,0 +1,39 @@
# Repository Guidelines
This document provides contributor guidance for this repository.
## Project Structure & Module Organization
- `app/`: Next.js App Router pages, layouts, and API routes (e.g., `app/api/...`).
- `components/`: Reusable UI components and app-level widgets.
- `lib/`: Shared utilities, API helpers, and domain logic.
- `convex/`: Convex backend functions, schema, and auth helpers.
- `public/`: Static assets served by Next.js.
- `docs/` and `scripts/`: Reference docs and maintenance scripts.
## Build, Test, and Development Commands
- `npm run dev`: Start the Next.js dev server.
- `npm run build`: Production build.
- `npm run start`: Run the production server locally after build.
- `npm run lint`: Run Next.js/ESLint linting.
## Coding Style & Naming Conventions
- TypeScript + React (Next.js App Router). Use 2-space indentation as seen in existing files.
- Prefer file and folder names in `kebab-case` and React components in `PascalCase`.
- Tailwind CSS is used for styling; keep class lists ordered for readability.
- Linting: `next lint` (no Prettier config is present).
## Testing Guidelines
- No dedicated test framework is configured yet.
- If you add tests, document the runner and add a script to `package.json`.
## Commit & Pull Request Guidelines
- Commit history uses Conventional Commits (e.g., `feat:`, `fix:`). Follow that pattern.
- Commit changes made during a request, and only include files touched for that request.
- PRs should include:
- Clear description of changes and motivation.
- Linked issue/task if available.
- Screenshots for UI changes (before/after if relevant).
## Security & Configuration Tips
- Copy `.env.example` to `.env.local` for local development.
- Never commit secrets (API keys, tokens). Keep them in `.env.local`.

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
FROM node:20-bookworm-slim AS base
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV PORT=3000
ENV PUPPETEER_SKIP_DOWNLOAD=1
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
RUN apt-get update && apt-get install -y --no-install-recommends \
chromium \
fonts-liberation \
libasound2 \
libatk1.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgtk-3-0 \
libnss3 \
libx11-xcb1 \
libxss1 \
libxtst6 \
libu2f-udev \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -1,22 +0,0 @@
'use client'
import { Sidebar } from '@/components/sidebar'
import { useSearchParams } from 'next/navigation'
export default function AppLayout({
children,
}: {
children: React.ReactNode
}) {
const searchParams = useSearchParams()
const productName = searchParams.get('product') || undefined
return (
<div className="flex h-screen bg-background">
<Sidebar productName={productName} />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
)
}

View File

@@ -1,423 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
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 { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter
} from '@/components/ui/dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Search,
Loader2,
ExternalLink,
MessageSquare,
Twitter,
Users,
HelpCircle,
Filter,
ChevronDown,
ChevronUp,
Target,
Zap,
AlertCircle,
BarChart3,
CheckCircle2,
Eye,
Copy
} from 'lucide-react'
import type {
EnhancedProductAnalysis,
Opportunity,
PlatformConfig,
SearchStrategy
} from '@/lib/types'
const STRATEGY_INFO: Record<SearchStrategy, { name: string; description: string }> = {
'direct-keywords': { name: 'Direct Keywords', description: 'People looking for your product category' },
'problem-pain': { name: 'Problem/Pain', description: 'People experiencing problems you solve' },
'competitor-alternative': { name: 'Competitor Alternatives', description: 'People switching from competitors' },
'how-to': { name: 'How-To/Tutorials', description: 'People learning about solutions' },
'emotional-frustrated': { name: 'Frustration Posts', description: 'Emotional posts about pain points' },
'comparison': { name: 'Comparisons', description: '"X vs Y" comparison posts' },
'recommendation': { name: 'Recommendations', description: '"What do you use" requests' }
}
export default function OpportunitiesPage() {
const router = useRouter()
const [analysis, setAnalysis] = useState<EnhancedProductAnalysis | null>(null)
const [platforms, setPlatforms] = useState<PlatformConfig[]>([])
const [strategies, setStrategies] = useState<SearchStrategy[]>([
'direct-keywords',
'problem-pain',
'competitor-alternative'
])
const [intensity, setIntensity] = useState<'broad' | 'balanced' | 'targeted'>('balanced')
const [isSearching, setIsSearching] = useState(false)
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
const [generatedQueries, setGeneratedQueries] = useState<any[]>([])
const [showQueries, setShowQueries] = useState(false)
const [selectedOpportunity, setSelectedOpportunity] = useState<Opportunity | null>(null)
const [replyText, setReplyText] = useState('')
const [stats, setStats] = useState<any>(null)
useEffect(() => {
const stored = localStorage.getItem('productAnalysis')
if (stored) {
setAnalysis(JSON.parse(stored))
} else {
router.push('/onboarding')
}
fetch('/api/opportunities')
.then(r => r.json())
.then(data => setPlatforms(data.platforms))
}, [router])
const togglePlatform = (platformId: string) => {
setPlatforms(prev => prev.map(p =>
p.id === platformId ? { ...p, enabled: !p.enabled } : p
))
}
const toggleStrategy = (strategy: SearchStrategy) => {
setStrategies(prev =>
prev.includes(strategy)
? prev.filter(s => s !== strategy)
: [...prev, strategy]
)
}
const executeSearch = async () => {
if (!analysis) return
setIsSearching(true)
setOpportunities([])
try {
const config = {
platforms,
strategies,
intensity,
maxResults: intensity === 'broad' ? 80 : intensity === 'balanced' ? 50 : 30
}
const response = await fetch('/api/opportunities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ analysis, config })
})
const data = await response.json()
if (data.success) {
setOpportunities(data.data.opportunities)
setGeneratedQueries(data.data.queries)
setStats(data.data.stats)
}
} catch (error) {
console.error('Search error:', error)
} finally {
setIsSearching(false)
}
}
const generateReply = (opp: Opportunity) => {
const template = opp.softPitch
? `Hey, I saw your post about ${opp.matchedProblems[0] || 'your challenge'}. We faced something similar and ended up building ${analysis?.productName} specifically for this. Happy to share what worked for us.`
: `Hi! I noticed you're looking for solutions to ${opp.matchedProblems[0]}. I work on ${analysis?.productName} that helps teams with this - specifically ${opp.matchedKeywords.slice(0, 2).join(' and ')}. Would love to show you how it works.`
setReplyText(template)
}
const getIntentIcon = (intent: string) => {
switch (intent) {
case 'frustrated': return <AlertCircle className="h-4 w-4 text-red-400" />
case 'comparing': return <BarChart3 className="h-4 w-4 text-amber-400" />
case 'learning': return <Users className="h-4 w-4 text-blue-400" />
default: return <Target className="h-4 w-4 text-muted-foreground" />
}
}
if (!analysis) return null
return (
<div className="flex h-screen">
{/* Sidebar */}
<div className="w-80 border-r border-border bg-card flex flex-col">
<div className="p-4 border-b border-border">
<h2 className="font-semibold flex items-center gap-2">
<Target className="h-5 w-5" />
Search Configuration
</h2>
</div>
<ScrollArea className="flex-1 p-4 space-y-6">
{/* Platforms */}
<div className="space-y-3">
<Label className="text-sm font-medium uppercase text-muted-foreground">Platforms</Label>
<div className="space-y-2">
{platforms.map(platform => (
<div key={platform.id} className="flex items-center space-x-2">
<Checkbox
id={platform.id}
checked={platform.enabled}
onCheckedChange={() => togglePlatform(platform.id)}
/>
<Label htmlFor={platform.id} className="cursor-pointer flex-1">{platform.name}</Label>
</div>
))}
</div>
</div>
<Separator />
{/* Strategies */}
<div className="space-y-3">
<Label className="text-sm font-medium uppercase text-muted-foreground">Strategies</Label>
<div className="space-y-2">
{(Object.keys(STRATEGY_INFO) as SearchStrategy[]).map(strategy => (
<div key={strategy} className="flex items-start space-x-2">
<Checkbox
id={strategy}
checked={strategies.includes(strategy)}
onCheckedChange={() => toggleStrategy(strategy)}
/>
<div className="flex-1">
<Label htmlFor={strategy} className="cursor-pointer">
{STRATEGY_INFO[strategy].name}
</Label>
<p className="text-xs text-muted-foreground">{STRATEGY_INFO[strategy].description}</p>
</div>
</div>
))}
</div>
</div>
<Separator />
{/* Intensity */}
<div className="space-y-3">
<Label className="text-sm font-medium uppercase text-muted-foreground">Intensity</Label>
<Slider
value={[intensity === 'broad' ? 0 : intensity === 'balanced' ? 50 : 100]}
onValueChange={([v]) => setIntensity(v < 33 ? 'broad' : v < 66 ? 'balanced' : 'targeted')}
max={100}
step={50}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Broad</span>
<span>Targeted</span>
</div>
</div>
</ScrollArea>
<div className="p-4 border-t border-border">
<Button
onClick={executeSearch}
disabled={isSearching || platforms.filter(p => p.enabled).length === 0}
className="w-full"
>
{isSearching ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Searching...</> : <><Search className="mr-2 h-4 w-4" /> Find Opportunities</>}
</Button>
</div>
</div>
{/* Main Content */}
<div className="flex-1 overflow-auto p-6">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Opportunity Finder</h1>
<p className="text-muted-foreground">Discover potential customers for {analysis.productName}</p>
</div>
{stats && (
<div className="flex gap-4 text-sm">
<div className="text-center">
<div className="font-semibold">{stats.opportunitiesFound}</div>
<div className="text-muted-foreground">Found</div>
</div>
<div className="text-center">
<div className="font-semibold text-green-400">{stats.highRelevance}</div>
<div className="text-muted-foreground">High Quality</div>
</div>
</div>
)}
</div>
{/* Generated Queries */}
{generatedQueries.length > 0 && (
<Collapsible open={showQueries} onOpenChange={setShowQueries}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">Generated Queries ({generatedQueries.length})</CardTitle>
{showQueries ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent>
<div className="space-y-1 max-h-48 overflow-auto text-xs font-mono">
{generatedQueries.slice(0, 20).map((q, i) => (
<div key={i} className="bg-muted px-2 py-1 rounded">{q.query}</div>
))}
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)}
{/* Results Table */}
{opportunities.length > 0 ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Platform</TableHead>
<TableHead>Intent</TableHead>
<TableHead>Score</TableHead>
<TableHead className="w-1/2">Post</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{opportunities.slice(0, 50).map((opp) => (
<TableRow key={opp.id}>
<TableCell><Badge variant="outline">{opp.platform}</Badge></TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getIntentIcon(opp.intent)}
<span className="capitalize text-sm">{opp.intent}</span>
</div>
</TableCell>
<TableCell>
<Badge className={opp.relevanceScore >= 0.8 ? 'bg-green-500/20 text-green-400' : opp.relevanceScore >= 0.6 ? 'bg-amber-500/20 text-amber-400' : 'bg-red-500/20 text-red-400'}>
{Math.round(opp.relevanceScore * 100)}%
</Badge>
</TableCell>
<TableCell>
<p className="font-medium line-clamp-1">{opp.title}</p>
<p className="text-sm text-muted-foreground line-clamp-2">{opp.snippet}</p>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => setSelectedOpportunity(opp)}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => window.open(opp.url, '_blank')}>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : isSearching ? (
<Card className="p-12 text-center">
<Loader2 className="mx-auto h-12 w-12 text-muted-foreground/50 mb-4 animate-spin" />
<h3 className="text-lg font-medium">Searching...</h3>
<p className="text-muted-foreground">Scanning platforms for opportunities</p>
</Card>
) : (
<Card className="p-12 text-center">
<Search className="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium">Ready to Search</h3>
<p className="text-muted-foreground">Select platforms and strategies, then click Find Opportunities</p>
</Card>
)}
</div>
</div>
{/* Detail Dialog */}
<Dialog open={!!selectedOpportunity} onOpenChange={() => setSelectedOpportunity(null)}>
<DialogContent className="max-w-2xl">
{selectedOpportunity && (
<>
<DialogHeader>
<div className="flex items-center gap-2">
<Badge className={selectedOpportunity.relevanceScore >= 0.8 ? 'bg-green-500/20 text-green-400' : selectedOpportunity.relevanceScore >= 0.6 ? 'bg-amber-500/20 text-amber-400' : 'bg-red-500/20 text-red-400'}>
{Math.round(selectedOpportunity.relevanceScore * 100)}% Match
</Badge>
<Badge variant="outline">{selectedOpportunity.platform}</Badge>
<Badge variant="secondary" className="capitalize">{selectedOpportunity.intent}</Badge>
</div>
<DialogTitle className="text-lg pt-2">{selectedOpportunity.title}</DialogTitle>
<DialogDescription>{selectedOpportunity.snippet}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{selectedOpportunity.matchedKeywords.length > 0 && (
<div>
<Label className="text-sm text-muted-foreground">Matched Keywords</Label>
<div className="flex flex-wrap gap-2 mt-1">
{selectedOpportunity.matchedKeywords.map((kw, i) => (
<Badge key={i} variant="secondary">{kw}</Badge>
))}
</div>
</div>
)}
<div className="bg-muted p-4 rounded-lg">
<Label className="text-sm text-muted-foreground">Suggested Approach</Label>
<p className="text-sm mt-1">{selectedOpportunity.suggestedApproach}</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Generated Reply</Label>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => generateReply(selectedOpportunity)}>
<Zap className="h-3 w-3 mr-1" /> Generate
</Button>
{replyText && (
<Button variant="ghost" size="sm" onClick={() => navigator.clipboard.writeText(replyText)}>
<Copy className="h-3 w-3 mr-1" /> Copy
</Button>
)}
</div>
</div>
<Textarea value={replyText} onChange={(e) => setReplyText(e.target.value)} placeholder="Click Generate to create a reply..." rows={4} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => window.open(selectedOpportunity.url, '_blank')}>
<ExternalLink className="h-4 w-4 mr-2" /> View Post
</Button>
<Button onClick={() => setSelectedOpportunity(null)}>
<CheckCircle2 className="h-4 w-4 mr-2" /> Mark as Viewed
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { convexAuthNextjsToken, isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server";
import { fetchMutation, fetchQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { scrapeWebsite, analyzeFromText } from "@/lib/scraper";
import { repromptSection } from "@/lib/analysis-pipeline";
const bodySchema = z.object({
analysisId: z.string().min(1),
sectionKey: z.enum([
"profile",
"features",
"competitors",
"keywords",
"problems",
"personas",
"useCases",
"dorkQueries",
]),
prompt: z.string().optional(),
});
export async function POST(request: NextRequest) {
if (!(await isAuthenticatedNextjs())) {
const redirectUrl = new URL("/auth", request.url);
const referer = request.headers.get("referer");
const nextPath = referer ? new URL(referer).pathname + new URL(referer).search : "/";
redirectUrl.searchParams.set("next", nextPath);
return NextResponse.redirect(redirectUrl);
}
const body = await request.json();
const parsed = bodySchema.parse(body);
const token = await convexAuthNextjsToken();
const analysis = await fetchQuery(
api.analyses.getById,
{ analysisId: parsed.analysisId as any },
{ token }
);
if (!analysis) {
return NextResponse.json({ error: "Analysis not found." }, { status: 404 });
}
const dataSource = await fetchQuery(
api.dataSources.getById,
{ dataSourceId: analysis.dataSourceId as any },
{ token }
);
if (!dataSource) {
return NextResponse.json({ error: "Data source not found." }, { status: 404 });
}
const isManual = dataSource.url.startsWith("manual:") || dataSource.url === "manual-input";
const featureText = (analysis.features || []).map((f: any) => f.name).join("\n");
const content = isManual
? await analyzeFromText(
analysis.productName,
analysis.description || "",
featureText
)
: await scrapeWebsite(dataSource.url);
const items = await repromptSection(
parsed.sectionKey,
content,
analysis as any,
parsed.prompt
);
await fetchMutation(
api.analysisSections.replaceSection,
{
analysisId: parsed.analysisId as any,
sectionKey: parsed.sectionKey,
items,
lastPrompt: parsed.prompt,
source: "ai",
},
{ token }
);
return NextResponse.json({ success: true, items });
}

View File

@@ -1,36 +1,231 @@
import { NextRequest, NextResponse } from 'next/server'
import { convexAuthNextjsToken, isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server";
import { fetchMutation, fetchQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { z } from 'zod'
import { analyzeFromText } from '@/lib/scraper'
import { performDeepAnalysis } from '@/lib/analysis-pipeline'
import { logServer } from "@/lib/server-logger";
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 {
const requestId = request.headers.get("x-request-id") ?? undefined;
if (!(await isAuthenticatedNextjs())) {
const redirectUrl = new URL("/auth", request.url);
const referer = request.headers.get("referer");
const nextPath = referer ? new URL(referer).pathname + new URL(referer).search : "/";
redirectUrl.searchParams.set("next", nextPath);
return NextResponse.redirect(redirectUrl);
}
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 }
)
}
console.log('📝 Creating content from manual input...')
await logServer({
level: "info",
message: "Preparing manual input for analysis",
labels: ["api", "analyze-manual", "scrape"],
payload: { productName },
requestId,
source: "api/analyze-manual",
});
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)
console.log(` ✓ Analysis complete: ${analysis.features.length} features, ${analysis.keywords.length} keywords`)
await logServer({
level: "info",
message: "Starting enhanced analysis",
labels: ["api", "analyze-manual", "analysis"],
requestId,
source: "api/analyze-manual",
});
const progressMap: Record<string, number> = {
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,
})
})
await logServer({
level: "info",
message: "Analysis complete",
labels: ["api", "analyze-manual", "analysis"],
payload: {
features: analysis.features.length,
keywords: analysis.keywords.length,
},
requestId,
source: "api/analyze-manual",
});
if (jobId) {
await updateTimeline({
key: "finalize",
status: "running",
progress: 98,
})
}
if (jobId) {
await updateTimeline({
key: "finalize",
status: "completed",
progress: 100,
finalStatus: "completed",
})
}
let persisted = false
if (jobId) {
try {
const job = await fetchQuery(
api.analysisJobs.getById,
{ jobId: jobId as any },
{ token }
)
if (job?.dataSourceId && job.projectId) {
const existing = await fetchQuery(
api.analyses.getLatestByDataSource,
{ dataSourceId: job.dataSourceId as any },
{ token }
)
if (!existing || existing.createdAt < job.createdAt) {
await fetchMutation(
api.analyses.createAnalysis,
{
projectId: job.projectId as any,
dataSourceId: job.dataSourceId as any,
analysis,
},
{ token }
)
}
await fetchMutation(
api.dataSources.updateDataSourceStatus,
{
dataSourceId: job.dataSourceId as any,
analysisStatus: "completed",
lastError: undefined,
lastAnalyzedAt: Date.now(),
},
{ token }
)
persisted = true
}
} catch (persistError) {
await logServer({
level: "error",
message: "Failed to persist manual analysis",
labels: ["api", "analyze-manual", "persist", "error"],
payload: { error: String(persistError) },
requestId,
source: "api/analyze-manual",
});
}
}
return NextResponse.json({
success: true,
data: analysis,
persisted,
stats: {
features: analysis.features.length,
keywords: analysis.keywords.length,
@@ -42,7 +237,58 @@ export async function POST(request: NextRequest) {
})
} catch (error: any) {
console.error('❌ Manual analysis error:', error)
await logServer({
level: "error",
message: "Manual analysis error",
labels: ["api", "analyze-manual", "error"],
payload: {
message: error?.message,
stack: error?.stack,
},
requestId: request.headers.get("x-request-id") ?? undefined,
source: "api/analyze-manual",
});
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 }
);
try {
const job = await fetchQuery(
api.analysisJobs.getById,
{ jobId: jobId as any },
{ token }
)
if (job?.dataSourceId) {
await fetchMutation(
api.dataSources.updateDataSourceStatus,
{
dataSourceId: job.dataSourceId as any,
analysisStatus: "failed",
lastError: error.message || "Manual analysis failed",
lastAnalyzedAt: Date.now(),
},
{ token }
)
}
} catch {
// Best-effort data source update only.
}
} catch {
// Best-effort job update only.
}
}
if (error.name === 'ZodError') {
return NextResponse.json(

View File

@@ -1,35 +1,242 @@
import { NextRequest, NextResponse } from 'next/server'
import { convexAuthNextjsToken, isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server";
import { fetchMutation, fetchQuery } 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'
import { logServer } from "@/lib/server-logger";
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 {
const requestId = request.headers.get("x-request-id") ?? undefined;
if (!(await isAuthenticatedNextjs())) {
const redirectUrl = new URL("/auth", request.url);
const referer = request.headers.get("referer");
const nextPath = referer ? new URL(referer).pathname + new URL(referer).search : "/";
redirectUrl.searchParams.set("next", nextPath);
return NextResponse.redirect(redirectUrl);
}
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 }
)
}
console.log(`🌐 Scraping: ${url}`)
await logServer({
level: "info",
message: "Scraping website",
labels: ["api", "analyze", "scrape"],
payload: { url },
requestId,
source: "api/analyze",
});
const scrapedContent = await scrapeWebsite(url)
console.log(` ✓ Scraped ${scrapedContent.headings.length} headings, ${scrapedContent.paragraphs.length} paragraphs`)
await logServer({
level: "info",
message: "Scrape complete",
labels: ["api", "analyze", "scrape"],
payload: {
headings: scrapedContent.headings.length,
paragraphs: scrapedContent.paragraphs.length,
},
requestId,
source: "api/analyze",
});
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)
console.log(` ✓ Analysis complete: ${analysis.features.length} features, ${analysis.keywords.length} keywords, ${analysis.dorkQueries.length} queries`)
await logServer({
level: "info",
message: "Starting enhanced analysis",
labels: ["api", "analyze", "analysis"],
requestId,
source: "api/analyze",
});
const progressMap: Record<string, number> = {
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,
})
})
await logServer({
level: "info",
message: "Analysis complete",
labels: ["api", "analyze", "analysis"],
payload: {
features: analysis.features.length,
keywords: analysis.keywords.length,
dorkQueries: analysis.dorkQueries.length,
},
requestId,
source: "api/analyze",
});
if (jobId) {
await updateTimeline({
key: "finalize",
status: "running",
progress: 98,
})
}
if (jobId) {
await updateTimeline({
key: "finalize",
status: "completed",
progress: 100,
finalStatus: "completed",
})
}
let persisted = false
if (jobId) {
try {
const job = await fetchQuery(
api.analysisJobs.getById,
{ jobId: jobId as any },
{ token }
)
if (job?.dataSourceId && job.projectId) {
const existing = await fetchQuery(
api.analyses.getLatestByDataSource,
{ dataSourceId: job.dataSourceId as any },
{ token }
)
if (!existing || existing.createdAt < job.createdAt) {
await fetchMutation(
api.analyses.createAnalysis,
{
projectId: job.projectId as any,
dataSourceId: job.dataSourceId as any,
analysis,
},
{ token }
)
}
await fetchMutation(
api.dataSources.updateDataSourceStatus,
{
dataSourceId: job.dataSourceId as any,
analysisStatus: "completed",
lastError: undefined,
lastAnalyzedAt: Date.now(),
},
{ token }
)
persisted = true
}
} catch (persistError) {
await logServer({
level: "error",
message: "Failed to persist analysis",
labels: ["api", "analyze", "persist", "error"],
payload: { error: String(persistError) },
requestId,
source: "api/analyze",
});
}
}
return NextResponse.json({
success: true,
data: analysis,
persisted,
stats: {
features: analysis.features.length,
keywords: analysis.keywords.length,
@@ -41,7 +248,58 @@ export async function POST(request: NextRequest) {
})
} catch (error: any) {
console.error('❌ Analysis error:', error)
await logServer({
level: "error",
message: "Analysis error",
labels: ["api", "analyze", "error"],
payload: {
message: error?.message,
stack: error?.stack,
},
requestId: request.headers.get("x-request-id") ?? undefined,
source: "api/analyze",
});
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 }
);
try {
const job = await fetchQuery(
api.analysisJobs.getById,
{ jobId: jobId as any },
{ token }
)
if (job?.dataSourceId) {
await fetchMutation(
api.dataSources.updateDataSourceStatus,
{
dataSourceId: job.dataSourceId as any,
analysisStatus: "failed",
lastError: error.message || "Analysis failed",
lastAnalyzedAt: Date.now(),
},
{ token }
)
}
} catch {
// Best-effort data source update only.
}
} catch {
// Best-effort job update only.
}
}
if (error instanceof ScrapingError) {
return NextResponse.json(

21
app/api/checkout/route.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Checkout } from "@polar-sh/nextjs";
import { NextResponse } from "next/server";
export const GET = async () => {
if (!process.env.POLAR_ACCESS_TOKEN || !process.env.POLAR_SUCCESS_URL) {
return NextResponse.json(
{
error:
"Missing POLAR_ACCESS_TOKEN or POLAR_SUCCESS_URL environment variables.",
},
{ status: 400 }
);
}
const handler = Checkout({
accessToken: process.env.POLAR_ACCESS_TOKEN,
successUrl: process.env.POLAR_SUCCESS_URL,
});
return handler();
};

View File

@@ -1,82 +1,229 @@
import { NextRequest, NextResponse } from 'next/server'
import { convexAuthNextjsToken, isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server";
import { fetchMutation, fetchQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { z } from 'zod'
import { generateSearchQueries, getDefaultPlatforms } from '@/lib/query-generator'
import { executeSearches, scoreOpportunities } from '@/lib/search-executor'
import type { EnhancedProductAnalysis, SearchConfig, PlatformConfig } from '@/lib/types'
import { logServer } from "@/lib/server-logger";
const searchSchema = z.object({
analysis: z.object({
productName: z.string(),
tagline: z.string(),
description: z.string(),
features: z.array(z.object({
name: z.string(),
description: z.string(),
benefits: z.array(z.string()),
useCases: z.array(z.string())
})),
problemsSolved: z.array(z.object({
problem: z.string(),
severity: z.enum(['high', 'medium', 'low']),
currentWorkarounds: z.array(z.string()),
emotionalImpact: z.string(),
searchTerms: z.array(z.string())
})),
keywords: z.array(z.object({
term: z.string(),
type: z.string(),
searchVolume: z.string(),
intent: z.string(),
funnel: z.string(),
emotionalIntensity: z.string()
})),
competitors: z.array(z.object({
name: z.string(),
differentiator: z.string(),
theirStrength: z.string(),
switchTrigger: z.string(),
theirWeakness: z.string()
}))
}),
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(),
rateLimit: z.number()
searchTemplate: z.string().optional(),
rateLimit: z.number(),
site: z.string().optional(),
custom: z.boolean().optional()
})),
strategies: z.array(z.string()),
intensity: z.enum(['broad', 'balanced', 'targeted']),
maxResults: z.number().default(50)
minAgeDays: z.number().min(0).max(365).optional(),
maxAgeDays: z.number().min(0).max(365).optional()
})
})
export async function POST(request: NextRequest) {
let jobId: string | undefined
try {
const body = await request.json()
const { analysis, config } = searchSchema.parse(body)
const requestId = request.headers.get("x-request-id") ?? undefined;
if (!(await isAuthenticatedNextjs())) {
const redirectUrl = new URL("/auth", request.url);
const referer = request.headers.get("referer");
const nextPath = referer ? new URL(referer).pathname + new URL(referer).search : "/";
redirectUrl.searchParams.set("next", nextPath);
return NextResponse.redirect(redirectUrl);
}
console.log('🔍 Starting opportunity search...')
console.log(` Product: ${analysis.productName}`)
console.log(` Platforms: ${config.platforms.filter(p => p.enabled).map(p => p.name).join(', ')}`)
console.log(` Strategies: ${config.strategies.join(', ')}`)
const body = await request.json()
const parsed = searchSchema.parse(body)
const { projectId, config } = parsed
jobId = parsed.jobId
const ageFilters = {
minAgeDays: config.minAgeDays,
maxAgeDays: config.maxAgeDays,
}
if (!process.env.SERPER_API_KEY) {
const errorMessage = "SERPER_API_KEY is not configured. Add it to your environment to run searches."
await logServer({
level: "warn",
message: "Serper API key missing",
labels: ["api", "opportunities", "config", "warn"],
payload: { projectId },
requestId,
source: "api/opportunities",
});
if (jobId) {
await fetchMutation(
api.searchJobs.update,
{ jobId: jobId as any, status: "failed", error: errorMessage },
{ token: await convexAuthNextjsToken() }
)
}
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
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 },
{ token }
);
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 }
);
}
const analysis = searchContext.context as EnhancedProductAnalysis
await logServer({
level: "info",
message: "Starting opportunity search",
labels: ["api", "opportunities", "start"],
payload: {
projectId,
productName: analysis.productName,
platforms: config.platforms.filter((p) => p.enabled).map((p) => p.name),
strategies: config.strategies,
filters: ageFilters,
},
requestId,
source: "api/opportunities",
});
// Generate queries
console.log(' Generating search queries...')
const queries = generateSearchQueries(analysis as EnhancedProductAnalysis, config as SearchConfig)
console.log(` ✓ Generated ${queries.length} queries`)
await logServer({
level: "info",
message: "Generating search queries",
labels: ["api", "opportunities", "queries"],
payload: { projectId },
requestId,
source: "api/opportunities",
});
const enforcedConfig: SearchConfig = {
...(config as SearchConfig),
maxResults: Math.min((config as SearchConfig).maxResults || 50, 50),
}
const queries = generateSearchQueries(analysis as EnhancedProductAnalysis, enforcedConfig)
await logServer({
level: "info",
message: "Generated search queries",
labels: ["api", "opportunities", "queries"],
payload: { projectId, count: queries.length },
requestId,
source: "api/opportunities",
});
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`)
await logServer({
level: "info",
message: "Executing searches",
labels: ["api", "opportunities", "search"],
payload: { projectId, queryCount: queries.length },
requestId,
source: "api/opportunities",
});
const searchResults = await executeSearches(queries, ageFilters)
await logServer({
level: "info",
message: "Searches complete",
labels: ["api", "opportunities", "search"],
payload: { projectId, rawResults: searchResults.length },
requestId,
source: "api/opportunities",
});
if (jobId) {
await fetchMutation(
api.searchJobs.update,
{ jobId: jobId as any, status: "running", progress: 70 },
{ token }
);
}
const resultUrls = Array.from(
new Set(searchResults.map((result) => result.url).filter(Boolean))
)
const existingUrls = await fetchQuery(
api.seenUrls.listExisting,
{ projectId: projectId as any, urls: resultUrls },
{ token }
)
const existingSet = new Set(existingUrls)
const newUrls = resultUrls.filter((url) => !existingSet.has(url))
const filteredResults = searchResults.filter((result) => !existingSet.has(result.url))
// Score and rank
console.log(' Scoring opportunities...')
const opportunities = scoreOpportunities(searchResults, analysis as EnhancedProductAnalysis)
console.log(` ✓ Scored ${opportunities.length} opportunities`)
await logServer({
level: "info",
message: "Scoring opportunities",
labels: ["api", "opportunities", "score"],
payload: { projectId, candidateResults: filteredResults.length },
requestId,
source: "api/opportunities",
});
const opportunities = scoreOpportunities(filteredResults, analysis as EnhancedProductAnalysis)
await logServer({
level: "info",
message: "Opportunities scored",
labels: ["api", "opportunities", "score"],
payload: { projectId, scored: opportunities.length },
requestId,
source: "api/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 }
);
}
if (newUrls.length > 0) {
await fetchMutation(
api.seenUrls.markSeenBatch,
{ projectId: projectId as any, urls: newUrls, source: "search" },
{ token }
);
}
return NextResponse.json({
success: true,
@@ -84,41 +231,72 @@ export async function POST(request: NextRequest) {
opportunities: opportunities.slice(0, 50),
stats: {
queriesGenerated: queries.length,
rawResults: searchResults.length,
opportunitiesFound: opportunities.length,
highRelevance: opportunities.filter(o => o.relevanceScore >= 0.7).length,
averageScore: opportunities.length > 0
? opportunities.reduce((a, o) => a + o.relevanceScore, 0) / opportunities.length
: 0
rawResults: filteredResults.length,
opportunitiesFound: opportunities.length
},
queries: queries.map(q => ({
query: q.query,
platform: q.platform,
strategy: q.strategy,
priority: q.priority
}))
})),
missingSources: searchContext.missingSources ?? []
}
})
} catch (error: any) {
console.error('❌ Opportunity search error:', error)
const errorMessage =
error instanceof Error ? error.message : typeof error === "string" ? error : "Search failed"
await logServer({
level: "error",
message: "Opportunity search error",
labels: ["api", "opportunities", "error"],
payload: { message: errorMessage },
requestId: request.headers.get("x-request-id") ?? undefined,
source: "api/opportunities",
});
if (error.name === 'ZodError') {
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') {
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 }
)
}
}
// Get default configuration
export async function GET() {
export async function GET(request: NextRequest) {
if (!(await isAuthenticatedNextjs())) {
const redirectUrl = new URL("/auth", request.url);
const referer = request.headers.get("referer");
const nextPath = referer ? new URL(referer).pathname + new URL(referer).search : "/";
redirectUrl.searchParams.set("next", nextPath);
return NextResponse.redirect(redirectUrl);
}
const defaultPlatforms = getDefaultPlatforms()
return NextResponse.json({

View File

@@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server";
import { z } from 'zod'
import type { EnhancedProductAnalysis, Opportunity, DorkQuery } from '@/lib/types'
import { logServer } from "@/lib/server-logger";
import { appendSerperAgeModifiers, SerperAgeFilter } from "@/lib/serper-date-filters";
// Search result from any source
interface SearchResult {
@@ -30,15 +33,51 @@ const bodySchema = z.object({
problem: z.string(),
searchTerms: z.array(z.string())
}))
})
}),
minAgeDays: z.number().min(0).max(365).optional(),
maxAgeDays: z.number().min(0).max(365).optional(),
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { analysis } = bodySchema.parse(body)
const requestId = request.headers.get("x-request-id") ?? undefined;
if (!(await isAuthenticatedNextjs())) {
const redirectUrl = new URL("/auth", request.url);
const referer = request.headers.get("referer");
const nextPath = referer ? new URL(referer).pathname + new URL(referer).search : "/";
redirectUrl.searchParams.set("next", nextPath);
return NextResponse.redirect(redirectUrl);
}
console.log(`🔍 Finding opportunities for: ${analysis.productName}`)
const body = await request.json()
const { analysis, minAgeDays, maxAgeDays } = bodySchema.parse(body)
const ageFilters: SerperAgeFilter = {
minAgeDays,
maxAgeDays,
}
if (!process.env.SERPER_API_KEY) {
await logServer({
level: "warn",
message: "Serper API key missing",
labels: ["api", "search", "config", "warn"],
requestId,
source: "api/search",
});
return NextResponse.json(
{ error: 'SERPER_API_KEY is not configured. Add it to your environment to run searches.' },
{ status: 400 }
)
}
await logServer({
level: "info",
message: "Finding opportunities",
labels: ["api", "search", "start"],
payload: { productName: analysis.productName, filters: ageFilters },
requestId,
source: "api/search",
});
// Sort queries by priority
const sortedQueries = analysis.dorkQueries
@@ -53,22 +92,50 @@ export async function POST(request: NextRequest) {
// Execute searches
for (const query of sortedQueries) {
try {
console.log(` Searching: ${query.query.substring(0, 60)}...`)
const results = await searchGoogle(query.query, 5)
await logServer({
level: "info",
message: "Searching query",
labels: ["api", "search", "query"],
payload: { query: query.query, platform: query.platform },
requestId,
source: "api/search",
});
const results = await searchGoogle(query.query, 5, ageFilters, requestId)
allResults.push(...results)
// Small delay to avoid rate limiting
await new Promise(r => setTimeout(r, 500))
} catch (e) {
console.error(` Search failed for query: ${query.query.substring(0, 40)}`)
await logServer({
level: "error",
message: "Search failed for query",
labels: ["api", "search", "query", "error"],
payload: { query: query.query, error: String(e) },
requestId,
source: "api/search",
});
}
}
console.log(` Found ${allResults.length} raw results`)
await logServer({
level: "info",
message: "Search complete",
labels: ["api", "search", "results"],
payload: { rawResults: allResults.length },
requestId,
source: "api/search",
});
// Analyze and score opportunities
const opportunities = await analyzeOpportunities(allResults, analysis as EnhancedProductAnalysis)
console.log(` ✓ Analyzed ${opportunities.length} opportunities`)
await logServer({
level: "info",
message: "Opportunities analyzed",
labels: ["api", "search", "analyze"],
payload: { analyzed: opportunities.length },
requestId,
source: "api/search",
});
return NextResponse.json({
success: true,
@@ -84,7 +151,18 @@ export async function POST(request: NextRequest) {
})
} catch (error: any) {
console.error('❌ Search error:', error)
await logServer({
level: "error",
message: "Search error",
labels: ["api", "search", "error"],
payload: {
message: error?.message,
stack: error?.stack,
filters: ageFilters,
},
requestId: request.headers.get("x-request-id") ?? undefined,
source: "api/search",
});
return NextResponse.json(
{ error: error.message || 'Failed to find opportunities' },
@@ -93,31 +171,42 @@ export async function POST(request: NextRequest) {
}
}
async function searchGoogle(query: string, num: number): Promise<SearchResult[]> {
// Try Serper first
if (process.env.SERPER_API_KEY) {
try {
return await searchSerper(query, num)
} catch (e) {
console.error('Serper failed, falling back to direct')
}
}
return searchDirect(query, num)
async function searchGoogle(
query: string,
num: number,
filters?: SerperAgeFilter,
requestId?: string
): Promise<SearchResult[]> {
return searchSerper(query, num, filters, requestId)
}
async function searchSerper(query: string, num: number): Promise<SearchResult[]> {
async function searchSerper(
query: string,
num: number,
filters?: SerperAgeFilter,
requestId?: string
): Promise<SearchResult[]> {
const filteredQuery = appendSerperAgeModifiers(query, filters)
const response = await fetch('https://google.serper.dev/search', {
method: 'POST',
headers: {
'X-API-KEY': process.env.SERPER_API_KEY!,
'Content-Type': 'application/json'
},
body: JSON.stringify({ q: query, num })
body: JSON.stringify({ q: filteredQuery, num })
})
if (!response.ok) throw new Error('Serper API error')
const data = await response.json()
await logServer({
level: "info",
message: "Serper response received",
labels: ["api", "search", "serper", "response"],
payload: { query: filteredQuery, num, filters, data },
requestId,
source: "api/search",
});
return (data.organic || []).map((r: any) => ({
title: r.title,
url: r.link,

View File

@@ -0,0 +1,238 @@
"use client"
import { useEffect, useState } from "react"
import { useMutation, 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"
import { Button } from "@/components/ui/button"
export default function Page() {
const { selectedProjectId } = useProject()
const projects = useQuery(api.projects.getProjects)
const dataSources = useQuery(
api.dataSources.getProjectDataSources,
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
)
const analysisJobs = useQuery(
api.analysisJobs.listByProject,
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
)
const opportunities = useQuery(
api.opportunities.listByProject,
selectedProjectId ? { projectId: selectedProjectId as any, limit: 200 } : "skip"
)
const leadsPerDay = useQuery(
api.opportunities.countByDay,
selectedProjectId ? { projectId: selectedProjectId as any, days: 7, metric: "created" } : "skip"
)
const sentPerDay = useQuery(
api.opportunities.countByDay,
selectedProjectId ? { projectId: selectedProjectId as any, days: 7, metric: "sent" } : "skip"
)
const archivedPerDay = useQuery(
api.opportunities.countByDay,
selectedProjectId ? { projectId: selectedProjectId as any, days: 7, metric: "archived" } : "skip"
)
const touchUser = useMutation(api.users.touch)
const userActivity = useQuery(api.users.getActivity)
const [statusTab, setStatusTab] = useState<"sent" | "archived">("sent")
const selectedProject = projects?.find((project) => project._id === selectedProjectId)
const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || []
const activeSources = dataSources?.filter((source: any) => selectedSourceIds.includes(source._id)) ?? []
useEffect(() => {
if (!selectedProjectId) return
void touchUser()
}, [selectedProjectId, touchUser])
const totalRuns = analysisJobs?.length ?? 0
const recentAnalysis = analysisJobs?.slice(0, 5) ?? []
const maxCount = (series?: { count: number }[]) =>
Math.max(1, ...(series?.map((item) => item.count) ?? [0]))
const formatDay = (date: string) =>
new Date(date).toLocaleDateString(undefined, { weekday: "short" })
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 (selectedProjectId && opportunities === undefined) {
return (
<div className="flex flex-1 items-center justify-center p-10 text-center text-muted-foreground">
Loading overview...
</div>
)
}
return (
<div className="flex h-svh flex-1 flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">{selectedProject?.name || "Overview"}</h1>
<p className="text-sm text-muted-foreground">Keep track of outreach momentum.</p>
</div>
<Badge variant="outline">{activeSources.length} sources</Badge>
</div>
<div className="grid flex-1 grid-cols-12 grid-rows-6 gap-4 overflow-hidden">
<Card className="col-span-7 row-span-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Inbox leads per day</CardTitle>
</CardHeader>
<CardContent className="flex h-full flex-col gap-4">
{leadsPerDay && leadsPerDay.every((item) => item.count === 0) ? (
<div className="flex h-28 items-center justify-center text-xs text-muted-foreground">
Nothing to show yet.
</div>
) : (
<div className="flex h-28 items-end gap-2">
{(leadsPerDay ?? []).map((item) => (
<div key={item.date} className="flex flex-1 flex-col items-center gap-2">
<div
className="w-full rounded-md bg-foreground/70"
style={{
height: `${(item.count / maxCount(leadsPerDay)) * 100}%`,
minHeight: item.count > 0 ? 12 : 2,
}}
/>
<span className="text-[10px] text-muted-foreground">{formatDay(item.date)}</span>
</div>
))}
</div>
)}
<div className="text-xs text-muted-foreground">
{opportunities?.length ?? 0} total leads in inbox.
</div>
</CardContent>
</Card>
<Card className="col-span-5 row-span-3">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm">Status momentum</CardTitle>
<div className="flex rounded-full border border-border/60 p-1 text-xs">
<button
type="button"
onClick={() => setStatusTab("sent")}
className={`rounded-full px-3 py-1 transition ${
statusTab === "sent" ? "bg-muted text-foreground" : "text-muted-foreground"
}`}
>
Sent
</button>
<button
type="button"
onClick={() => setStatusTab("archived")}
className={`rounded-full px-3 py-1 transition ${
statusTab === "archived" ? "bg-muted text-foreground" : "text-muted-foreground"
}`}
>
Archived
</button>
</div>
</CardHeader>
<CardContent className="flex h-full flex-col gap-4">
{(statusTab === "sent" ? sentPerDay : archivedPerDay) &&
(statusTab === "sent" ? sentPerDay : archivedPerDay)?.every(
(item) => item.count === 0
) ? (
<div className="flex h-28 items-center justify-center text-xs text-muted-foreground">
Nothing to show yet.
</div>
) : (
<div className="flex h-28 items-end gap-2">
{((statusTab === "sent" ? sentPerDay : archivedPerDay) ?? []).map((item) => (
<div key={item.date} className="flex flex-1 flex-col items-center gap-2">
<div
className="w-full rounded-md bg-foreground/70"
style={{
height: `${
(item.count /
maxCount(statusTab === "sent" ? sentPerDay : archivedPerDay)) *
100
}%`,
minHeight: item.count > 0 ? 12 : 2,
}}
/>
<span className="text-[10px] text-muted-foreground">{formatDay(item.date)}</span>
</div>
))}
</div>
)}
<div className="text-xs text-muted-foreground">
{statusTab === "sent" ? "Leads marked sent this week." : "Leads archived this week."}
</div>
</CardContent>
</Card>
<Card className="col-span-7 row-span-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Recent analysis runs</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{recentAnalysis.length === 0 && (
<p className="text-sm text-muted-foreground">No analysis runs yet.</p>
)}
{recentAnalysis.map((job) => {
const source = dataSources?.find((item: any) => item._id === job.dataSourceId)
return (
<div
key={job._id}
className="flex items-center justify-between rounded-lg border border-border/60 px-4 py-3"
>
<div>
<div className="text-sm font-medium">
{source?.name || source?.url || "Project analysis"}
</div>
<div className="text-xs text-muted-foreground">
{new Date(job.createdAt).toLocaleString()}
</div>
</div>
<Badge variant="secondary" className="capitalize">
{job.status}
</Badge>
</div>
)
})}
</CardContent>
</Card>
<Card className="col-span-5 row-span-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Activity</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-border/60 px-4 py-4">
<div className="text-xs uppercase text-muted-foreground">Login streak</div>
<div className="mt-2 text-3xl font-semibold">
{userActivity?.streak ?? 0} days
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-border/60 px-4 py-4">
<div className="text-xs uppercase text-muted-foreground">Data sources</div>
<div className="mt-2 text-2xl font-semibold">{dataSources?.length ?? 0}</div>
</div>
<div className="rounded-lg border border-border/60 px-4 py-4">
<div className="text-xs uppercase text-muted-foreground">Runs</div>
<div className="mt-2 text-2xl font-semibold">{totalRuns}</div>
</div>
</div>
<Button variant="secondary" className="w-full" asChild>
<a href="/app/search">Run search</a>
</Button>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,421 @@
"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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Settings } from "lucide-react"
import * as React from "react"
import { ProfileSectionEditor, SectionEditor } from "@/components/analysis-section-editor"
import { AnalysisTimeline } from "@/components/analysis-timeline"
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 analysisJob = useQuery(
api.analysisJobs.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 updateDataSourceStatus = useMutation(api.dataSources.updateDataSourceStatus)
const createAnalysisJob = useMutation(api.analysisJobs.create)
const [isDeleting, setIsDeleting] = React.useState(false)
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
const [isReanalyzing, setIsReanalyzing] = React.useState(false)
const [reanalysisError, setReanalysisError] = React.useState<string | null>(null)
const sectionMap = React.useMemo(() => {
const map = new Map<string, any>()
sections?.forEach((section: any) => {
map.set(section.sectionKey, section.items)
})
return map
}, [sections])
if (dataSource === undefined) {
return (
<div className="p-8">
<div className="text-sm text-muted-foreground">Loading data source</div>
</div>
)
}
if (!dataSource) {
return (
<div className="p-8">
<h1 className="text-2xl font-semibold">Data source not found</h1>
<p className="mt-2 text-sm text-muted-foreground">
This data source may have been removed or you no longer have access.
</p>
</div>
)
}
const handleReanalyze = async () => {
if (!dataSource || !dataSource.url) return
if (dataSource.url.startsWith("manual:")) {
setReanalysisError("Manual sources cant be reanalyzed automatically.");
return;
}
setIsReanalyzing(true)
setReanalysisError(null)
try {
await updateDataSourceStatus({
dataSourceId: dataSource._id as any,
analysisStatus: "pending",
lastError: undefined,
lastAnalyzedAt: undefined,
})
const jobId = await createAnalysisJob({
projectId: dataSource.projectId,
dataSourceId: dataSource._id,
})
await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: dataSource.url, jobId }),
})
} catch (err: any) {
setReanalysisError(err?.message || "Failed to reanalyze data source.");
await updateDataSourceStatus({
dataSourceId: dataSource._id as any,
analysisStatus: "failed",
lastError: err?.message || "Failed to reanalyze data source.",
lastAnalyzedAt: Date.now(),
})
} finally {
setIsReanalyzing(false)
}
}
const statusVariant =
dataSource.analysisStatus === "completed"
? "secondary"
: dataSource.analysisStatus === "failed"
? "destructive"
: "outline"
return (
<div className="flex flex-1 flex-col gap-6 p-8">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-start justify-between gap-3">
<h1 className="text-3xl font-semibold">
{dataSource.name || "Data Source"}
</h1>
<div className="flex items-center gap-2">
<Badge variant={statusVariant}>
{dataSource.analysisStatus}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Data source settings"
>
<Settings className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleReanalyze} disabled={isReanalyzing}>
{isReanalyzing ? "Reanalyzing..." : "Run reanalysis"}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setIsDialogOpen(true)}
className="text-destructive focus:text-destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<p className="text-sm text-muted-foreground">{dataSource.url}</p>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span>Last analyzed: {formatDate(dataSource.lastAnalyzedAt)}</span>
{reanalysisError && (
<span className="text-destructive">{reanalysisError}</span>
)}
{dataSource.lastError && (
<span className="text-destructive">
Error: {dataSource.lastError}
</span>
)}
</div>
</div>
{analysisJob?.timeline?.length && analysisJob.status !== "completed" ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Analysis Progress</CardTitle>
</CardHeader>
<CardContent>
<AnalysisTimeline items={analysisJob.timeline} />
</CardContent>
</Card>
) : null}
{analysis ? (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Key Features</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">
{analysis.features.length}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Search Keywords</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">
{analysis.keywords.length}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Target Users</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-semibold">
{analysis.personas.length}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div>
<span className="font-medium text-foreground">Product:</span>{" "}
{analysis.productName}
</div>
<div>
<span className="font-medium text-foreground">Tagline:</span>{" "}
{analysis.tagline}
</div>
<div>
<span className="font-medium text-foreground">Category:</span>{" "}
{analysis.category}
</div>
<div>
<span className="font-medium text-foreground">Positioning:</span>{" "}
{analysis.positioning}
</div>
<div className="pt-2">{analysis.description}</div>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Key Features</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{analysis.features.slice(0, 6).map((feature) => (
<div key={feature.name}>
<div className="font-medium">{feature.name}</div>
<div className="text-muted-foreground">
{feature.description}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Problems</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{analysis.problemsSolved.slice(0, 6).map((problem) => (
<div key={problem.problem}>
<div className="font-medium">{problem.problem}</div>
<div className="text-muted-foreground">
Severity: {problem.severity} · {problem.emotionalImpact}
</div>
</div>
))}
</CardContent>
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Target Users</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{analysis.personas.slice(0, 4).map((persona) => (
<div key={`${persona.name}-${persona.role}`}>
<div className="font-medium">
{persona.name} · {persona.role}
</div>
<div className="text-muted-foreground">
{persona.industry} · {persona.companySize}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Search Keywords</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{analysis.keywords.slice(0, 12).map((keyword) => (
<Badge key={keyword.term} variant="outline">
{keyword.term}
</Badge>
))}
</CardContent>
</Card>
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold">Edit Sections</h2>
<div className="grid gap-4 lg:grid-cols-3">
<ProfileSectionEditor
analysisId={analysis._id as any}
items={
sectionMap.get("profile") || {
productName: analysis.productName,
tagline: analysis.tagline,
description: analysis.description,
category: analysis.category,
positioning: analysis.positioning,
}
}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="features"
title="Key Features"
items={sectionMap.get("features") || analysis.features}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="competitors"
title="Competitors"
items={sectionMap.get("competitors") || analysis.competitors}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="keywords"
title="Search Keywords"
items={sectionMap.get("keywords") || analysis.keywords}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="problems"
title="Problems"
items={sectionMap.get("problems") || analysis.problemsSolved}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="personas"
title="Target Users"
items={sectionMap.get("personas") || analysis.personas}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="useCases"
title="Use Cases"
items={sectionMap.get("useCases") || analysis.useCases}
/>
<SectionEditor
analysisId={analysis._id as any}
sectionKey="dorkQueries"
title="Search Queries"
items={sectionMap.get("dorkQueries") || analysis.dorkQueries}
/>
</div>
</div>
</>
) : (
<Card>
<CardHeader>
<CardTitle className="text-base">Full Analysis</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
No analysis available yet. Trigger a new analysis to populate this
data source.
</CardContent>
</Card>
)}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete data source</DialogTitle>
<DialogDescription>
This removes the data source and its analyses from the project. This
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={async () => {
if (!dataSourceId) return
setIsDeleting(true)
await removeDataSource({ dataSourceId: dataSourceId as any })
router.push("/app/dashboard")
}}
disabled={isDeleting}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View 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">Support</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 Search 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>
)
}

View File

@@ -0,0 +1,564 @@
'use client'
import { useEffect, useMemo, useRef, 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 { Archive, Check, ExternalLink, HelpCircle, Mail, Target } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
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<string | null>(null)
const [updatingId, setUpdatingId] = useState<string | null>(null)
const [optimisticHiddenIds, setOptimisticHiddenIds] = useState<Set<string>>(
() => new Set()
)
const [toast, setToast] = useState<{
message: string
variant?: 'success' | 'error'
actionLabel?: string
onAction?: () => void
} | null>(null)
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [sourceFilter, setSourceFilter] = useState('all')
const [ageFilter, setAgeFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('active')
const [isShortcutsOpen, setIsShortcutsOpen] = useState(false)
const leadRefs = useRef<Map<string, HTMLDivElement | null>>(new Map())
const getStatusLabel = (status?: string) => {
if (!status) return 'new'
if (status === 'ignored') return 'archived'
if (status === 'converted') return 'sent'
return status
}
const sortedLeads = useMemo<Lead[]>(() => {
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
if (optimisticHiddenIds.has(lead._id)) return false
const normalizedStatus = getStatusLabel(lead.status)
if (statusFilter === 'archived' && normalizedStatus !== 'archived') return false
if (statusFilter === 'sent' && normalizedStatus !== 'sent') return false
if (
statusFilter === 'active' &&
(normalizedStatus === 'archived' || normalizedStatus === 'sent')
)
return false
return true
})
return [...filtered].sort((a, b) => b.createdAt - a.createdAt)
}, [leads, sourceFilter, ageFilter, statusFilter, optimisticHiddenIds])
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 getMatchReason = (lead: Lead) => {
const keyword = lead.matchedKeywords?.[0]
const problem = lead.matchedProblems?.[0]
if (keyword && problem) return `Matched "${keyword}" + ${problem}`
if (problem) return `Matched problem: ${problem}`
if (keyword) return `Matched keyword: "${keyword}"`
return 'Matched by relevance'
}
const selectedLead = useMemo<Lead | null>(() => {
if (!sortedLeads.length) return null
const found = sortedLeads.find((lead) => lead._id === selectedId)
return found ?? sortedLeads[0]
}, [sortedLeads, selectedId])
const handleSelect = (lead: Lead) => {
setSelectedId(lead._id)
}
useEffect(() => {
if (!selectedLead) return
if (selectedLead._id !== selectedId) {
setSelectedId(selectedLead._id)
}
}, [selectedLead, selectedId])
useEffect(() => {
if (!selectedLead) return
const node = leadRefs.current.get(selectedLead._id)
if (node) {
node.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}, [selectedLead])
const handleQuickStatus = async (lead: Lead, status: string) => {
const shouldHide = status === 'archived' || status === 'sent'
const previousStatus = lead.status || 'new'
if (shouldHide && selectedLead?._id === lead._id) {
const currentIndex = sortedLeads.findIndex((item) => item._id === lead._id)
const nextLead =
sortedLeads[currentIndex + 1] ?? sortedLeads[currentIndex - 1] ?? null
setSelectedId(nextLead?._id ?? null)
}
if (shouldHide) {
setOptimisticHiddenIds((current) => {
const next = new Set(current)
next.add(lead._id)
return next
})
}
setUpdatingId(lead._id)
try {
await updateOpportunity({
id: lead._id as any,
status,
notes: lead.notes,
tags: lead.tags,
})
if (toastTimerRef.current) {
clearTimeout(toastTimerRef.current)
}
setToast({
message: status === 'archived' ? 'Lead archived.' : 'Lead marked sent.',
variant: 'success',
actionLabel: 'Undo',
onAction: async () => {
setToast(null)
if (shouldHide) {
setOptimisticHiddenIds((current) => {
const next = new Set(current)
next.delete(lead._id)
return next
})
}
setUpdatingId(lead._id)
try {
await updateOpportunity({
id: lead._id as any,
status: previousStatus,
notes: lead.notes,
tags: lead.tags,
})
setToast({ message: 'Lead restored.', variant: 'success' })
} catch (error) {
setToast({ message: 'Failed to undo update.', variant: 'error' })
} finally {
setUpdatingId((current) => (current === lead._id ? null : current))
toastTimerRef.current = setTimeout(() => setToast(null), 3000)
}
},
})
toastTimerRef.current = setTimeout(() => setToast(null), 5000)
} catch (error) {
if (shouldHide) {
setOptimisticHiddenIds((current) => {
const next = new Set(current)
next.delete(lead._id)
return next
})
}
if (toastTimerRef.current) {
clearTimeout(toastTimerRef.current)
}
setToast({ message: 'Failed to update lead.', variant: 'error' })
toastTimerRef.current = setTimeout(() => setToast(null), 3000)
} finally {
setUpdatingId((current) => (current === lead._id ? null : current))
}
}
useEffect(() => {
return () => {
if (toastTimerRef.current) {
clearTimeout(toastTimerRef.current)
}
}
}, [])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!sortedLeads.length) return
if (event.metaKey || event.ctrlKey || event.altKey) return
const target = event.target as HTMLElement | null
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable)
) {
return
}
const key = event.key.toLowerCase()
const currentIndex = selectedLead
? sortedLeads.findIndex((lead) => lead._id === selectedLead._id)
: -1
if (key === 'j' || key === 'k') {
event.preventDefault()
const nextIndex =
key === 'j'
? Math.min(sortedLeads.length - 1, currentIndex + 1)
: Math.max(0, currentIndex - 1)
const nextLead = sortedLeads[nextIndex]
if (nextLead) {
setSelectedId(nextLead._id)
}
return
}
if (key === 'o' || event.key === 'Enter') {
if (!selectedLead) return
event.preventDefault()
window.open(selectedLead.url, '_blank')
return
}
if (key === 'a') {
if (!selectedLead) return
event.preventDefault()
handleQuickStatus(selectedLead, 'archived')
return
}
if (key === 's') {
if (!selectedLead) return
event.preventDefault()
handleQuickStatus(selectedLead, 'sent')
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedLead, sortedLeads])
return (
<div className="flex h-screen">
{toast && (
<div className="fixed bottom-6 right-6 z-50">
<div
className={`flex items-center gap-3 rounded-lg border px-4 py-3 text-sm shadow-lg ${
toast.variant === 'error'
? 'border-destructive/40 bg-destructive/10 text-destructive'
: 'border-border bg-card text-foreground'
}`}
>
<span>{toast.message}</span>
{toast.actionLabel && toast.onAction && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={toast.onAction}
>
{toast.actionLabel}
</Button>
)}
</div>
</div>
)}
<div className="w-[420px] border-r border-border bg-card">
<div className="border-b border-border px-4 py-3">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-semibold">
<Mail className="h-4 w-4 text-muted-foreground" />
Inbox
</div>
<p className="text-xs text-muted-foreground">Newest opportunities ready for outreach.</p>
</div>
<Button
variant="outline"
size="icon"
className="h-7 w-7 rounded-full border-border/60 text-muted-foreground hover:text-foreground"
onClick={() => setIsShortcutsOpen(true)}
aria-label="View inbox shortcuts"
>
<HelpCircle className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="border-b border-border px-4 py-3">
<div className="flex flex-wrap items-center gap-4">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="uppercase tracking-wide text-[10px]">Source</span>
<select
value={sourceFilter}
onChange={(event) => setSourceFilter(event.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="all">All sources</option>
{sourceOptions.map((source) => (
<option key={source} value={source}>
{source}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="uppercase tracking-wide text-[10px]">Age</span>
<select
value={ageFilter}
onChange={(event) => setAgeFilter(event.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="all">Any time</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
</select>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="uppercase tracking-wide text-[10px]">Status</span>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="active">Active</option>
<option value="archived">Archived</option>
<option value="sent">Sent</option>
<option value="all">All</option>
</select>
</div>
</div>
</div>
<ScrollArea className="h-[calc(100vh-128px)]">
<div className="space-y-2 p-4">
{sortedLeads.length === 0 && (
<Card className="p-6 text-center text-sm text-muted-foreground">
No leads yet. Run a search to populate the inbox.
</Card>
)}
{sortedLeads.map((lead) => (
<div
key={lead._id}
ref={(node) => {
leadRefs.current.set(lead._id, node)
}}
role="button"
aria-selected={selectedLead?._id === lead._id}
tabIndex={0}
onClick={() => handleSelect(lead as Lead)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleSelect(lead as Lead)
}
}}
className={`group relative w-full cursor-pointer rounded-lg border px-3 py-3 text-left transition ${
selectedLead?._id === lead._id
? "border-foreground/80 bg-foreground/15 shadow-[0_0_0_2px_rgba(255,255,255,0.18)]"
: "border-border/60 hover:border-foreground/30"
}`}
>
{selectedLead?._id === lead._id && (
<span className="absolute left-0 top-0 h-full w-1.5 rounded-l-lg bg-foreground" />
)}
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold line-clamp-1">{lead.title}</div>
<Badge variant="outline" className="text-[10px] uppercase">
{lead.platform}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{lead.snippet}</p>
<p className="mt-2 text-xs text-muted-foreground">{getMatchReason(lead)}</p>
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
<Target className="h-3 w-3" />
<span className="capitalize">{lead.intent}</span>
<span></span>
<span className="capitalize">{getStatusLabel(lead.status)}</span>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
<div className="flex-1">
<div className="border-b border-border px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-semibold">Lead</div>
<p className="text-xs text-muted-foreground">Review the thread before reaching out.</p>
</div>
{selectedLead && (
<Button
variant="outline"
size="sm"
onClick={() => window.open(selectedLead.url, '_blank')}
>
<ExternalLink className="mr-2 h-4 w-4" />
Open Source
</Button>
)}
</div>
</div>
<div className="space-y-6 px-6 py-6">
{selectedLead ? (
<>
<Card className="relative p-6">
<div className="absolute right-4 top-4 flex items-center gap-2">
<Button
variant="secondary"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleQuickStatus(selectedLead, 'archived')}
aria-label="Archive lead"
disabled={updatingId === selectedLead._id}
>
<Archive className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleQuickStatus(selectedLead, 'sent')}
aria-label="Mark lead sent"
disabled={updatingId === selectedLead._id}
>
<Check className="h-4 w-4" />
</Button>
</div>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge className="bg-muted text-foreground">
{Math.round(selectedLead.relevanceScore * 100)}% match
</Badge>
<Badge variant="outline" className="capitalize">
{selectedLead.intent}
</Badge>
<Badge variant="secondary" className="capitalize">
{getStatusLabel(selectedLead.status)}
</Badge>
</div>
<div className="text-xl font-semibold">{selectedLead.title}</div>
<p className="text-sm text-muted-foreground">{selectedLead.snippet}</p>
<p className="text-xs text-muted-foreground">{getMatchReason(selectedLead)}</p>
</div>
</Card>
<Card className="p-6 space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label className="text-xs uppercase text-muted-foreground">Keyword Matches</Label>
<div className="mt-2 flex flex-wrap gap-2">
{selectedLead.matchedKeywords.map((keyword, index) => (
<Badge key={`${keyword}-${index}`} variant="secondary">
{keyword}
</Badge>
))}
</div>
</div>
<div>
<Label className="text-xs uppercase text-muted-foreground">Problem Matches</Label>
<div className="mt-2 flex flex-wrap gap-2">
{selectedLead.matchedProblems.map((problem, index) => (
<Badge key={`${problem}-${index}`} variant="outline">
{problem}
</Badge>
))}
</div>
</div>
</div>
<Separator />
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label className="text-xs uppercase text-muted-foreground">Suggested Outreach</Label>
<p className="mt-2 text-sm text-muted-foreground">{selectedLead.suggestedApproach}</p>
</div>
<div>
<Label className="text-xs uppercase text-muted-foreground">Outreach Tone</Label>
<p className="mt-2 text-sm text-muted-foreground">
{selectedLead.softPitch ? 'Use a softer, story-led opener.' : 'Lead with a direct solution.'}
</p>
</div>
</div>
</Card>
</>
) : (
<Card className="p-10 text-center text-sm text-muted-foreground">
Select a lead to view the details.
</Card>
)}
</div>
</div>
<Dialog open={isShortcutsOpen} onOpenChange={setIsShortcutsOpen}>
<DialogContent className="max-w-xs">
<DialogHeader>
<DialogTitle>Inbox shortcuts</DialogTitle>
</DialogHeader>
<div className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Next lead</span>
<span className="font-medium text-foreground">J</span>
</div>
<div className="flex items-center justify-between">
<span>Previous lead</span>
<span className="font-medium text-foreground">K</span>
</div>
<div className="flex items-center justify-between">
<span>Open source</span>
<span className="font-medium text-foreground">O / Enter</span>
</div>
<div className="flex items-center justify-between">
<span>Archive</span>
<span className="font-medium text-foreground">A</span>
</div>
<div className="flex items-center justify-between">
<span>Mark sent</span>
<span className="font-medium text-foreground">S</span>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}

27
app/app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,27 @@
'use client'
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
}) {
return (
<SidebarProvider>
<ProjectProvider>
<AppSidebar />
<SidebarInset>
<div className="flex min-h-svh flex-1 flex-col bg-background">
{children}
</div>
</SidebarInset>
</ProjectProvider>
</SidebarProvider>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import * as React from "react"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
export default function SettingsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const profile = useQuery(api.users.getCurrentProfile)
const user = profile?.user
const accounts = profile?.accounts ?? []
const allowedTabs = React.useMemo(() => ["account", "billing"], [])
const queryTab = searchParams.get("tab")
const initialTab = allowedTabs.includes(queryTab ?? "") ? (queryTab as string) : "account"
const [tab, setTab] = React.useState(initialTab)
React.useEffect(() => {
if (initialTab !== tab) {
setTab(initialTab)
}
}, [initialTab, tab])
const checkoutHref = "/api/checkout"
return (
<div className="relative flex flex-1 flex-col gap-6 overflow-hidden p-4 lg:p-8">
<div className="pointer-events-none absolute inset-0">
<div className="absolute -left-40 top-20 h-72 w-72 rounded-full bg-[radial-gradient(circle_at_center,rgba(251,191,36,0.18),transparent_65%)] blur-2xl" />
<div className="absolute right-10 top-10 h-64 w-64 rounded-full bg-[radial-gradient(circle_at_center,rgba(16,185,129,0.16),transparent_65%)] blur-2xl" />
<div className="absolute bottom-0 left-1/3 h-64 w-96 rounded-full bg-[radial-gradient(circle_at_center,rgba(56,189,248,0.14),transparent_70%)] blur-3xl" />
</div>
<div className="relative space-y-2">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="outline">Account Center</Badge>
<Badge className="bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20">Active</Badge>
</div>
<h1 className="text-2xl font-semibold">Account & Billing</h1>
<p className="text-muted-foreground">
Manage your subscription, billing, and account details in one place.
</p>
</div>
<Tabs
value={tab}
onValueChange={(value) => {
setTab(value)
router.replace(`/app/settings?tab=${value}`)
}}
className="relative"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="bg-muted/60">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Current plan</span>
<span className="text-foreground">Starter</span>
</div>
</div>
<TabsContent value="account">
<div className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2 border-border/60 bg-card/70 backdrop-blur">
<CardHeader>
<CardTitle className="text-base">Profile</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-foreground font-medium">{user?.name || user?.email || "Not provided"}</p>
<p>{user?.email || "Not provided"}</p>
</div>
{/* TODO: Wire profile editing flow. */}
<Button variant="secondary">Coming soon.</Button>
</div>
<Separator />
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-foreground font-medium">Name</p>
<p>{user?.name || "Not provided"}</p>
</div>
<div className="space-y-1">
<p className="text-foreground font-medium">Email</p>
<p>{user?.email || "Not provided"}</p>
</div>
<div className="space-y-1">
<p className="text-foreground font-medium">Phone</p>
<p>{user?.phone || "Not provided"}</p>
</div>
<div className="space-y-1">
<p className="text-foreground font-medium">Sign-in Methods</p>
<p>
{accounts.length > 0
? Array.from(new Set(accounts.map((account) => {
if (account.provider === "password") return "Password";
if (account.provider === "google") return "Google";
return account.provider;
}))).join(", ")
: "Not provided"}
</p>
</div>
<div className="space-y-1">
<p className="text-foreground font-medium">Email Verified</p>
<p>
{user?.emailVerificationTime
? new Date(user.emailVerificationTime).toLocaleDateString()
: "Not verified"}
</p>
</div>
<div className="space-y-1">
<p className="text-foreground font-medium">User ID</p>
<p className="break-all">{user?._id || "Not provided"}</p>
</div>
</div>
{/* TODO: Wire security management flow. */}
<Button className="w-full sm:w-auto">Coming soon.</Button>
</CardContent>
</Card>
<Card className="border-border/60 bg-card/70 backdrop-blur">
<CardHeader>
<CardTitle className="text-base">Integrations</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<div>
{/* TODO: Replace with real provider status. */}
<p className="text-foreground font-medium">Coming soon.</p>
<p>Coming soon.</p>
</div>
<Badge variant="outline">Linked</Badge>
</div>
<div className="flex items-center justify-between">
<div>
{/* TODO: Replace with real provider status. */}
<p className="text-foreground font-medium">Coming soon.</p>
<p>Coming soon.</p>
</div>
{/* TODO: Wire provider disconnect. */}
<Button variant="ghost" size="sm">Coming soon.</Button>
</div>
<div className="flex items-center justify-between">
<div>
{/* TODO: Replace with real provider status. */}
<p className="text-foreground font-medium">Coming soon.</p>
<p>Coming soon.</p>
</div>
{/* TODO: Wire provider connect. */}
<Button variant="outline" size="sm">Coming soon.</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="billing">
<div className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2 border-border/60 bg-card/70 backdrop-blur">
<CardHeader>
<CardTitle className="text-base">Plan</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-foreground font-medium">Starter</p>
<p>Upgrade to unlock full opportunity search and automation.</p>
</div>
<Button asChild>
<Link href={checkoutHref}>Subscribe to Pro</Link>
</Button>
</div>
<Separator />
<div className="space-y-2">
<p className="text-foreground font-medium">Pro includes</p>
<div className="grid gap-1 text-sm text-muted-foreground">
<p>Unlimited projects and data sources</p>
<p>Advanced opportunity search</p>
<p>Priority analysis queue</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-card/70 backdrop-blur">
<CardHeader>
<CardTitle className="text-base">Billing History</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<div>
<p className="text-foreground font-medium">No invoices yet</p>
<p>Invoices will appear after your first payment.</p>
</div>
<Badge variant="outline">Pro</Badge>
</div>
<div className="rounded-lg border border-border/60 bg-muted/40 p-3 text-xs text-muted-foreground">
Need a receipt? Complete checkout to generate your first invoice.
</div>
<Button asChild className="w-full" variant="secondary">
<Link href={checkoutHref}>Subscribe to Pro</Link>
</Button>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -3,11 +3,13 @@
import { SignIn } from "@/components/auth/SignIn";
import { Authenticated, Unauthenticated, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
export default function AuthPage() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = searchParams.get("next");
return (
<div className="min-h-screen flex items-center justify-center bg-background">
@@ -15,17 +17,17 @@ export default function AuthPage() {
<SignIn />
</Unauthenticated>
<Authenticated>
<RedirectToDashboard />
<RedirectToDashboard nextPath={nextPath} />
</Authenticated>
</div>
);
}
function RedirectToDashboard() {
function RedirectToDashboard({ nextPath }: { nextPath: string | null }) {
const router = useRouter();
useEffect(() => {
router.push("/dashboard");
}, [router]);
router.push(nextPath || "/app/dashboard");
}, [router, nextPath]);
return (
<div className="text-center">

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -3,32 +3,157 @@
@tailwind utilities;
:root {
color-scheme: dark;
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--radius: 0.5rem;
--color-1: 0 100% 63%;
--color-2: 270 100% 63%;
--color-3: 210 100% 63%;
--color-4: 195 100% 63%;
--color-5: 90 100% 63%;
--background: oklch(0.91 0.05 82.78);
--foreground: oklch(0.41 0.08 78.86);
--card: oklch(0.92 0.04 84.56);
--card-foreground: oklch(0.41 0.08 74.04);
--popover: oklch(0.92 0.04 84.56);
--popover-foreground: oklch(0.41 0.08 74.04);
--primary: oklch(0.71 0.10 111.96);
--primary-foreground: oklch(0.98 0.01 2.18);
--secondary: oklch(0.88 0.05 83.32);
--secondary-foreground: oklch(0.51 0.08 78.21);
--muted: oklch(0.86 0.06 82.94);
--muted-foreground: oklch(0.51 0.08 74.78);
--accent: oklch(0.86 0.05 85.12);
--accent-foreground: oklch(0.26 0.02 356.72);
--destructive: oklch(0.63 0.24 29.21);
--border: oklch(0.74 0.06 79.64);
--input: oklch(0.74 0.06 79.64);
--ring: oklch(0.51 0.08 74.78);
--chart-1: oklch(0.66 0.19 41.68);
--chart-2: oklch(0.70 0.12 183.58);
--chart-3: oklch(0.48 0.08 211.35);
--chart-4: oklch(0.84 0.17 84.99);
--chart-5: oklch(0.74 0.17 60.02);
--sidebar: oklch(0.87 0.06 84.46);
--sidebar-foreground: oklch(0.41 0.08 78.86);
--sidebar-primary: oklch(0.26 0.02 356.72);
--sidebar-primary-foreground: oklch(0.98 0.01 2.18);
--sidebar-accent: oklch(0.83 0.06 84.44);
--sidebar-accent-foreground: oklch(0.26 0.02 356.72);
--sidebar-border: oklch(0.91 0 0);
--sidebar-ring: oklch(0.71 0 0);
--font-sans: Nunito, sans-serif;
--font-serif: PT Serif, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.625rem;
--shadow-2xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05);
--shadow-xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05);
--shadow-sm: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
0 1px 2px -1px oklch(0.00 0 0 / 0.10);
--shadow: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
0 1px 2px -1px oklch(0.00 0 0 / 0.10);
--shadow-md: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
0 2px 4px -1px oklch(0.00 0 0 / 0.10);
--shadow-lg: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
0 4px 6px -1px oklch(0.00 0 0 / 0.10);
--shadow-xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
0 8px 10px -1px oklch(0.00 0 0 / 0.10);
--shadow-2xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.25);
}
.dark {
--background: oklch(0.20 0.01 52.89);
--foreground: oklch(0.88 0.05 79.11);
--card: oklch(0.25 0.01 48.28);
--card-foreground: oklch(0.88 0.05 79.11);
--popover: oklch(0.25 0.01 48.28);
--popover-foreground: oklch(0.88 0.05 79.11);
--primary: oklch(0.64 0.05 114.58);
--primary-foreground: oklch(0.98 0.01 2.18);
--secondary: oklch(0.33 0.02 60.70);
--secondary-foreground: oklch(0.88 0.05 83.32);
--muted: oklch(0.27 0.01 39.35);
--muted-foreground: oklch(0.74 0.06 79.64);
--accent: oklch(0.33 0.02 60.70);
--accent-foreground: oklch(0.86 0.05 85.12);
--destructive: oklch(0.63 0.24 29.21);
--border: oklch(0.33 0.02 60.70);
--input: oklch(0.33 0.02 60.70);
--ring: oklch(0.64 0.05 114.58);
--chart-1: oklch(0.66 0.19 41.68);
--chart-2: oklch(0.70 0.12 183.58);
--chart-3: oklch(0.48 0.08 211.35);
--chart-4: oklch(0.84 0.17 84.99);
--chart-5: oklch(0.74 0.17 60.02);
--sidebar: oklch(0.23 0.01 60.90);
--sidebar-foreground: oklch(0.88 0.05 79.11);
--sidebar-primary: oklch(0.64 0.05 114.58);
--sidebar-primary-foreground: oklch(0.98 0.01 2.18);
--sidebar-accent: oklch(0.33 0.02 60.70);
--sidebar-accent-foreground: oklch(0.86 0.05 85.12);
--sidebar-border: oklch(0.33 0.02 60.70);
--sidebar-ring: oklch(0.64 0.05 114.58);
--shadow-2xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05);
--shadow-xs: 0 1px 3px 0px oklch(0.00 0 0 / 0.05);
--shadow-sm: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
0 1px 2px -1px oklch(0.00 0 0 / 0.10);
--shadow: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
0 1px 2px -1px oklch(0.00 0 0 / 0.10);
--shadow-md: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
0 2px 4px -1px oklch(0.00 0 0 / 0.10);
--shadow-lg: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
0 4px 6px -1px oklch(0.00 0 0 / 0.10);
--shadow-xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.10),
0 8px 10px -1px oklch(0.00 0 0 / 0.10);
--shadow-2xl: 0 1px 3px 0px oklch(0.00 0 0 / 0.25);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@layer base {
@@ -42,24 +167,24 @@
}
:root {
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-background: var(--sidebar);
--sidebar-foreground: var(--sidebar-foreground);
--sidebar-primary: var(--sidebar-primary);
--sidebar-primary-foreground: var(--sidebar-primary-foreground);
--sidebar-accent: var(--sidebar-accent);
--sidebar-accent-foreground: var(--sidebar-accent-foreground);
--sidebar-border: var(--sidebar-border);
--sidebar-ring: var(--sidebar-ring);
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-background: var(--sidebar);
--sidebar-foreground: var(--sidebar-foreground);
--sidebar-primary: var(--sidebar-primary);
--sidebar-primary-foreground: var(--sidebar-primary-foreground);
--sidebar-accent: var(--sidebar-accent);
--sidebar-accent-foreground: var(--sidebar-accent-foreground);
--sidebar-border: var(--sidebar-border);
--sidebar-ring: var(--sidebar-ring);
}
}

View File

@@ -1,14 +1,11 @@
import type { Metadata } from 'next'
import { Montserrat } from 'next/font/google'
import { Inter } from 'next/font/google'
import './globals.css'
import ConvexClientProvider from './ConvexClientProvider'
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
import { ThemeProvider } from "@/components/theme-provider";
const montserrat = Montserrat({
subsets: ['latin'],
weight: ['300', '400', '500', '600', '700'],
})
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Sanati - Find Product Opportunities',
@@ -23,7 +20,7 @@ export default function RootLayout({
return (
<ConvexAuthNextjsServerProvider>
<html lang="en" suppressHydrationWarning>
<body className={montserrat.className}>
<body className={inter.className}>
<ThemeProvider
attribute="class"
defaultTheme="dark"

View File

@@ -10,7 +10,10 @@ import { Label } from '@/components/ui/label'
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 { ProductAnalysis } from '@/lib/types'
import type { EnhancedProductAnalysis, Keyword } from '@/lib/types'
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' },
@@ -21,6 +24,10 @@ const examples = [
export default function OnboardingPage() {
const router = useRouter()
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('')
@@ -31,6 +38,57 @@ export default function OnboardingPage() {
const [manualProductName, setManualProductName] = useState('')
const [manualDescription, setManualDescription] = useState('')
const [manualFeatures, setManualFeatures] = useState('')
const [pendingSourceId, setPendingSourceId] = useState<string | null>(null)
const [pendingProjectId, setPendingProjectId] = useState<string | null>(null)
const [pendingJobId, setPendingJobId] = useState<string | null>(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 resolved = projectId && dataSourceId
? { projectId, sourceId: dataSourceId }
: await addDataSource({
url: sourceUrl,
name: sourceName,
type: 'website',
})
try {
await createAnalysis({
projectId: resolved.projectId,
dataSourceId: resolved.sourceId,
analysis,
})
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() {
if (!url) return
@@ -38,20 +96,49 @@ 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) {
router.push('/auth?next=/onboarding')
return
}
const data = await response.json()
if (!response.ok) {
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')
@@ -63,14 +150,37 @@ export default function OnboardingPage() {
localStorage.setItem('productAnalysis', JSON.stringify(data.data))
localStorage.setItem('analysisStats', JSON.stringify(data.stats))
if (!data.persisted) {
setProgress('Saving analysis...')
await persistAnalysis({
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
const params = new URLSearchParams({ product: data.data.productName })
router.push(`/dashboard?${params.toString()}`)
router.push(`/app/dashboard?${params.toString()}`)
} 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)
}
@@ -85,34 +195,95 @@ export default function OnboardingPage() {
try {
// 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,
tagline: manualDescription.split('.')[0],
description: manualDescription,
features: manualFeatures.split('\n').filter(f => f.trim()),
category: '',
positioning: '',
features: manualFeaturesList.map((name) => ({
name,
description: '',
benefits: [],
useCases: [],
})),
problemsSolved: [],
targetAudience: [],
valuePropositions: [],
keywords: manualProductName.toLowerCase().split(' '),
scrapedAt: new Date().toISOString()
personas: [],
keywords: manualKeywords,
useCases: [],
competitors: [],
dorkQueries: [],
scrapedAt: new Date().toISOString(),
analysisVersion: 'manual',
}
// 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,
}),
})
if (response.redirected) {
router.push('/auth?next=/onboarding')
return
}
let finalAnalysis = manualAnalysis
let persisted = false
if (response.ok) {
const data = await response.json()
finalAnalysis = data.data
persisted = Boolean(data.persisted)
}
// Store in localStorage for dashboard
@@ -126,12 +297,39 @@ export default function OnboardingPage() {
dorkQueries: finalAnalysis.dorkQueries.length
}))
if (!persisted) {
setProgress('Saving analysis...')
const manualSourceUrl = pendingSourceId
? 'manual-input'
: `manual:${finalAnalysis.productName}`
await persistAnalysis({
analysis: finalAnalysis,
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()}`)
router.push(`/app/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)
}
@@ -164,7 +362,7 @@ export default function OnboardingPage() {
<Card className="border-border/50 shadow-none">
<CardHeader>
<CardTitle>Describe Your Product</CardTitle>
<CardTitle>Product Details</CardTitle>
<CardDescription>
Enter your product details and we&apos;ll extract the key information.
</CardDescription>
@@ -181,7 +379,7 @@ export default function OnboardingPage() {
</div>
<div className="space-y-2">
<Label htmlFor="description">Description *</Label>
<Label htmlFor="description">Product Summary *</Label>
<Textarea
id="description"
placeholder="What does your product do? Who is it for? What problem does it solve?"
@@ -192,7 +390,7 @@ export default function OnboardingPage() {
</div>
<div className="space-y-2">
<Label htmlFor="features">Key Features (one per line)</Label>
<Label htmlFor="features">Key Features</Label>
<Textarea
id="features"
placeholder="- Feature 1&#10;- Feature 2&#10;- Feature 3"
@@ -208,10 +406,14 @@ export default function OnboardingPage() {
<Loader2 className="h-4 w-4 animate-spin" />
{progress}
</div>
{analysisJob?.timeline?.length ? (
<AnalysisTimeline items={analysisJob.timeline} />
) : (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
)}
</div>
)}
@@ -293,7 +495,7 @@ export default function OnboardingPage() {
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="url">Website URL</Label>
<Label htmlFor="url">Website</Label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
@@ -314,11 +516,15 @@ export default function OnboardingPage() {
<Loader2 className="h-4 w-4 animate-spin" />
{progress}
</div>
{analysisJob?.timeline?.length ? (
<AnalysisTimeline items={analysisJob.timeline} />
) : (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/5" />
</div>
)}
</div>
)}

View File

@@ -27,7 +27,7 @@ export default function LandingPage() {
<span className="font-semibold text-foreground tracking-tight">Sanati</span>
</div>
<nav className="flex items-center gap-4">
<Link href="/dashboard" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
<Link href="/app/dashboard" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
Dashboard
</Link>
<Link href="/auth">

View File

@@ -0,0 +1,326 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { useMutation } from "convex/react"
import { api } from "@/convex/_generated/api"
type SectionKey =
| "profile"
| "features"
| "competitors"
| "keywords"
| "problems"
| "personas"
| "useCases"
| "dorkQueries"
function summarizeItem(item: any) {
if (typeof item === "string") return item
if (!item || typeof item !== "object") return String(item)
if (item.name && item.role) return `${item.name} · ${item.role}`
if (item.name) return item.name
if (item.term) return item.term
if (item.problem) return item.problem
if (item.scenario) return item.scenario
if (item.query) return item.query
return JSON.stringify(item)
}
export function SectionEditor({
analysisId,
sectionKey,
title,
items,
}: {
analysisId: string
sectionKey: SectionKey
title: string
items: any[]
}) {
const addItem = useMutation(api.analysisSections.addItem)
const removeItem = useMutation(api.analysisSections.removeItem)
const [isRepromptOpen, setIsRepromptOpen] = React.useState(false)
const [repromptText, setRepromptText] = React.useState("")
const [isAddOpen, setIsAddOpen] = React.useState(false)
const [addText, setAddText] = React.useState("")
const [isBusy, setIsBusy] = React.useState(false)
const handleAdd = async () => {
setIsBusy(true)
try {
let parsed: any = addText
if (addText.trim().startsWith("{") || addText.trim().startsWith("[")) {
parsed = JSON.parse(addText)
}
await addItem({ analysisId: analysisId as any, sectionKey, item: parsed })
setAddText("")
setIsAddOpen(false)
} finally {
setIsBusy(false)
}
}
const handleReprompt = async () => {
setIsBusy(true)
try {
const response = await fetch("/api/analysis/reprompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
analysisId,
sectionKey,
prompt: repromptText.trim() || undefined,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || "Reprompt failed")
}
setRepromptText("")
setIsRepromptOpen(false)
} finally {
setIsBusy(false)
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 px-4 py-3">
<CardTitle className="text-sm">{title}</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsAddOpen(true)}>
Add
</Button>
<Button variant="secondary" size="sm" onClick={() => setIsRepromptOpen(true)}>
Reprompt
</Button>
</div>
</CardHeader>
<CardContent className="max-h-[260px] space-y-2 overflow-auto px-4 pb-4 text-xs">
{items.length === 0 ? (
<div className="text-muted-foreground">No items yet.</div>
) : (
items.map((item, index) => (
<div
key={`${sectionKey}-${index}`}
className="flex flex-wrap items-start justify-between gap-2 rounded-md border border-border/60 p-2"
>
<div className="space-y-1">
<div className="font-medium text-sm">{summarizeItem(item)}</div>
<Badge variant="outline">#{index + 1}</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeItem({ analysisId: analysisId as any, sectionKey, index })}
>
Remove
</Button>
</div>
))
)}
</CardContent>
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add to {title}</DialogTitle>
<DialogDescription>
Paste JSON for an item, or plain text for a string entry.
</DialogDescription>
</DialogHeader>
<Textarea
value={addText}
onChange={(event) => setAddText(event.target.value)}
placeholder='{"name":"...", "description":"..."}'
className="min-h-[160px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleAdd} disabled={isBusy || !addText.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isRepromptOpen} onOpenChange={setIsRepromptOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reprompt {title}</DialogTitle>
<DialogDescription>
Provide guidance to regenerate just this section.
</DialogDescription>
</DialogHeader>
<Textarea
value={repromptText}
onChange={(event) => setRepromptText(event.target.value)}
placeholder="Focus on B2B teams in healthcare..."
className="min-h-[140px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRepromptOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleReprompt} disabled={isBusy}>
Reprompt
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}
export function ProfileSectionEditor({
analysisId,
items,
}: {
analysisId: string
items: Record<string, any>
}) {
const replaceSection = useMutation(api.analysisSections.replaceSection)
const [isRepromptOpen, setIsRepromptOpen] = React.useState(false)
const [isEditOpen, setIsEditOpen] = React.useState(false)
const [repromptText, setRepromptText] = React.useState("")
const [editText, setEditText] = React.useState(JSON.stringify(items, null, 2))
const [isBusy, setIsBusy] = React.useState(false)
React.useEffect(() => {
setEditText(JSON.stringify(items, null, 2))
}, [items])
const handleSave = async () => {
setIsBusy(true)
try {
const parsed = JSON.parse(editText)
await replaceSection({
analysisId: analysisId as any,
sectionKey: "profile",
items: parsed,
source: "mixed",
})
setIsEditOpen(false)
} finally {
setIsBusy(false)
}
}
const handleReprompt = async () => {
setIsBusy(true)
try {
const response = await fetch("/api/analysis/reprompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
analysisId,
sectionKey: "profile",
prompt: repromptText.trim() || undefined,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || "Reprompt failed")
}
setRepromptText("")
setIsRepromptOpen(false)
} finally {
setIsBusy(false)
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 px-4 py-3">
<CardTitle className="text-sm">Product Profile</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsEditOpen(true)}>
Edit
</Button>
<Button variant="secondary" size="sm" onClick={() => setIsRepromptOpen(true)}>
Reprompt
</Button>
</div>
</CardHeader>
<CardContent className="max-h-[260px] space-y-2 overflow-auto px-4 pb-4 text-xs text-muted-foreground">
<div>
<span className="font-medium text-foreground">Product:</span>{" "}
{items.productName || "Not set"}
</div>
<div>
<span className="font-medium text-foreground">Tagline:</span>{" "}
{items.tagline || "Not set"}
</div>
<div>
<span className="font-medium text-foreground">Category:</span>{" "}
{items.category || "Not set"}
</div>
<div>
<span className="font-medium text-foreground">Positioning:</span>{" "}
{items.positioning || "Not set"}
</div>
</CardContent>
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update the profile JSON.</DialogDescription>
</DialogHeader>
<Textarea
value={editText}
onChange={(event) => setEditText(event.target.value)}
className="min-h-[200px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isBusy}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isRepromptOpen} onOpenChange={setIsRepromptOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reprompt Profile</DialogTitle>
<DialogDescription>
Provide guidance to regenerate the profile.
</DialogDescription>
</DialogHeader>
<Textarea
value={repromptText}
onChange={(event) => setRepromptText(event.target.value)}
className="min-h-[140px]"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRepromptOpen(false)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handleReprompt} disabled={isBusy}>
Reprompt
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@@ -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 <CheckCircle2 className="h-4 w-4 text-foreground" />
}
if (status === "failed") {
return <AlertTriangle className="h-4 w-4 text-destructive" />
}
if (status === "running") {
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
}
return <span className="h-2.5 w-2.5 rounded-full border border-muted-foreground/60" />
}
export function AnalysisTimeline({ items }: { items: TimelineItem[] }) {
if (!items.length) return null
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Analysis timeline
</div>
<div className="mt-3 space-y-3">
{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 (
<div key={item.key} className="relative pl-6">
<span
className={cn(
"absolute left-[6px] top-3 bottom-[-12px] w-px",
index === items.length - 1 ? "hidden" : "bg-border/40",
isStrongLine && "bg-foreground/60"
)}
/>
<div
className={cn(
"flex items-start gap-3 text-sm transition",
isPending && "scale-[0.96] opacity-60"
)}
>
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-background shadow-sm">
<StatusIcon status={item.status} />
</div>
<div className="min-w-0">
<div
className={cn(
"font-medium",
item.status === "failed" && "text-destructive"
)}
>
{item.label}
</div>
{item.detail && (
<div className="text-xs text-muted-foreground">
{item.detail}
</div>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -1,12 +1,18 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
Command,
Frame,
Settings2,
Settings,
Terminal,
Plus
Target,
Inbox,
Plus,
ArrowUpRight,
ChevronsUpDown
} from "lucide-react"
import { NavUser } from "@/components/nav-user"
@@ -22,15 +28,74 @@ 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"
import { Label } from "@/components/ui/label"
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<typeof Sidebar>) {
const pathname = usePathname()
const projects = useQuery(api.projects.getProjects);
const [selectedProjectId, setSelectedProjectId] = React.useState<string | null>(null);
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<string | null>(null);
const [isSubmittingProject, setIsSubmittingProject] = React.useState(false);
const createProject = useMutation(api.projects.createProject);
const updateProject = useMutation(api.projects.updateProject);
const deleteProject = useMutation(api.projects.deleteProject);
const [isEditingProject, setIsEditingProject] = React.useState(false);
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
const [editingProjectName, setEditingProjectName] = React.useState("");
const [editingProjectDefault, setEditingProjectDefault] = React.useState(false);
const [editingProjectError, setEditingProjectError] = React.useState<string | null>(null);
const [deleteConfirmName, setDeleteConfirmName] = React.useState("");
const [deleteProjectError, setDeleteProjectError] = React.useState<string | null>(null);
const [isDeletingProject, setIsDeletingProject] = React.useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = React.useState(false);
const [isSubmittingEdit, setIsSubmittingEdit] = React.useState(false);
const [sourceUrl, setSourceUrl] = React.useState("");
const [sourceName, setSourceName] = React.useState("");
const [sourceError, setSourceError] = React.useState<string | null>(null);
const [sourceNotice, setSourceNotice] = React.useState<string | null>(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<string | null>(null);
const [pendingProjectId, setPendingProjectId] = React.useState<string | null>(null);
const [pendingJobId, setPendingJobId] = React.useState<string | null>(null);
const sourceUrlRef = React.useRef<HTMLInputElement | null>(null);
const sourceNameRef = React.useRef<HTMLInputElement | null>(null);
const manualProductNameRef = React.useRef<HTMLInputElement | null>(null);
const manualDescriptionRef = React.useRef<HTMLTextAreaElement | null>(null);
const analysisJob = useQuery(
api.analysisJobs.getById,
pendingJobId ? { jobId: pendingJobId as any } : "skip"
);
// Set default selected project
React.useEffect(() => {
@@ -50,7 +115,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const toggleConfig = useMutation(api.projects.toggleDataSourceConfig);
const selectedProject = projects?.find(p => p._id === selectedProjectId);
const editingProject = projects?.find((project) => project._id === editingProjectId);
const canDeleteProject = (projects?.length ?? 0) > 1;
const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || [];
const selectedProjectName = selectedProject?.name || "Select Project";
const handleToggle = async (sourceId: string, checked: boolean) => {
if (!selectedProjectId) return;
@@ -61,67 +129,295 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
});
};
const handleAddSource = async () => {
if (!sourceUrl) {
setSourceError("Please enter a URL.");
return;
}
setSourceError(null);
setIsSubmittingSource(true);
try {
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: result.sourceId,
analysisStatus: "pending",
lastError: undefined,
lastAnalyzedAt: undefined,
});
const response = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
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: result.sourceId,
analysisStatus: "failed",
lastError: data.error || "Analysis failed",
lastAnalyzedAt: Date.now(),
});
throw new Error(data.error || "Analysis failed");
}
if (!data.persisted) {
await createAnalysis({
projectId: result.projectId,
dataSourceId: result.sourceId,
analysis: data.data,
});
await updateDataSourceStatus({
dataSourceId: result.sourceId,
analysisStatus: "completed",
lastError: undefined,
lastAnalyzedAt: Date.now(),
});
}
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.");
} finally {
setIsSubmittingSource(false);
}
};
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");
}
if (!data.persisted) {
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);
}
};
const handleInputEnter = (
event: React.KeyboardEvent<HTMLInputElement>,
next?: React.RefObject<HTMLElement>,
onSubmit?: () => void
) => {
if (event.key !== "Enter" || isSubmittingSource) return;
event.preventDefault();
if (next?.current) {
next.current.focus();
return;
}
onSubmit?.();
};
return (
<Sidebar variant="inset" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="#">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Command className="size-4" />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg">
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold uppercase">Sanati</span>
<span className="truncate text-xs">Pro</span>
<span className="truncate font-semibold">{selectedProjectName}</span>
<span className="truncate text-xs text-muted-foreground">Projects</span>
</div>
</a>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="w-64">
<DropdownMenuLabel>Switch project</DropdownMenuLabel>
<DropdownMenuSeparator />
{projects?.map((project) => (
<DropdownMenuItem
key={project._id}
className="justify-between"
onSelect={(event) => {
if (event.defaultPrevented) return
setSelectedProjectId(project._id)
}}
>
<div className="flex items-center gap-2">
<Frame className="text-muted-foreground" />
<span className="truncate">{project.name}</span>
</div>
<button
type="button"
data-project-settings
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation();
setEditingProjectId(project._id);
setEditingProjectName(project.name);
setEditingProjectDefault(project.isDefault);
setEditingProjectError(null);
setDeleteConfirmName("");
setDeleteProjectError(null);
setIsDeleteConfirmOpen(false);
setIsEditingProject(true);
}}
aria-label={`Project settings for ${project.name}`}
>
<Settings className="size-4" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setIsCreatingProject(true)}>
<Plus />
Create Project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{/* Platform Nav */}
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupLabel>Main</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Dashboard" isActive>
<Terminal />
<span>Dashboard</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Settings">
<Settings2 />
<span>Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Projects (Simple List for now, can be switcher) */}
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{projects?.map((project) => (
<SidebarMenuItem key={project._id}>
<SidebarMenuButton
onClick={() => setSelectedProjectId(project._id)}
isActive={selectedProjectId === project._id}
asChild
tooltip="Overview"
isActive={pathname === "/app/dashboard"}
>
<Frame className="text-muted-foreground" />
<span>{project.name}</span>
<Link href="/app/dashboard">
<Terminal />
<span>Overview</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-muted-foreground">
<Plus />
<span>Create Project</span>
<SidebarMenuButton
asChild
tooltip="Search"
isActive={pathname === "/app/search"}
>
<Link href="/app/search">
<Target />
<span>Search</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip="Inbox"
isActive={pathname === "/app/inbox"}
>
<Link href="/app/inbox">
<Inbox />
<span>Inbox</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -132,7 +428,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{selectedProjectId && (
<SidebarGroup>
<SidebarGroupLabel>
Active Data Sources
Selected Sources
<span className="ml-2 text-xs font-normal text-muted-foreground">({selectedProject?.name})</span>
</SidebarGroupLabel>
<SidebarGroupContent>
@@ -141,29 +437,420 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<div className="text-sm text-muted-foreground pl-2">No data sources yet.</div>
)}
{dataSources?.map((source) => (
<div key={source._id} className="flex items-center space-x-2">
<div
key={source._id}
className="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-muted/40"
>
<div className="flex min-w-0 items-center gap-2">
<Checkbox
id={source._id}
checked={selectedSourceIds.includes(source._id)}
onCheckedChange={(checked) => handleToggle(source._id, checked === true)}
/>
<Label htmlFor={source._id} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 truncate cursor-pointer">
<Label
htmlFor={source._id}
className="truncate text-sm font-medium leading-none cursor-pointer"
>
{source.name || source.url}
</Label>
</div>
<Link
href={`/app/data-sources/${source._id}`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
Details
<ArrowUpRight className="size-3" />
</Link>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => setIsAdding(true)}
>
Add Data Source
</Button>
</div>
</SidebarGroupContent>
</SidebarGroup>
)}
</SidebarContent>
<SidebarFooter>
<NavUser user={{
name: "User",
email: "user@example.com",
avatar: ""
}} />
{currentUser && (currentUser.name || currentUser.email) && (
<NavUser
user={{
name: currentUser.name || currentUser.email || "",
email: currentUser.email || "",
avatar: currentUser.image || "",
}}
/>
)}
</SidebarFooter>
<Dialog
open={isAdding}
onOpenChange={(open) => {
if (!open && manualMode && pendingSourceId) {
updateDataSourceStatus({
dataSourceId: pendingSourceId as any,
analysisStatus: "failed",
lastError: "Manual input cancelled",
lastAnalyzedAt: Date.now(),
});
}
if (!open && manualMode && pendingJobId) {
updateAnalysisJob({
jobId: pendingJobId as any,
status: "failed",
error: "Manual input cancelled",
});
}
if (!open) {
setManualMode(false);
setSourceError(null);
setSourceNotice(null);
setPendingSourceId(null);
setPendingProjectId(null);
setPendingJobId(null);
}
setIsAdding(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Data Source</DialogTitle>
<DialogDescription>
Add a website to analyze for this project.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{!manualMode && (
<>
<div className="space-y-2">
<Label htmlFor="sourceUrl">Website URL</Label>
<Input
id="sourceUrl"
placeholder="https://example.com"
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
onKeyDown={(event) => handleInputEnter(event, sourceNameRef)}
disabled={isSubmittingSource}
ref={sourceUrlRef}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sourceName">Name (optional)</Label>
<Input
id="sourceName"
placeholder="Product name"
value={sourceName}
onChange={(event) => setSourceName(event.target.value)}
onKeyDown={(event) => handleInputEnter(event, undefined, handleAddSource)}
disabled={isSubmittingSource}
ref={sourceNameRef}
/>
</div>
</>
)}
{manualMode && (
<>
<div className="space-y-2">
<Label htmlFor="manualProductName">Product Name</Label>
<Input
id="manualProductName"
value={manualProductName}
onChange={(event) => setManualProductName(event.target.value)}
onKeyDown={(event) => handleInputEnter(event, manualDescriptionRef)}
disabled={isSubmittingSource}
ref={manualProductNameRef}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manualDescription">Description</Label>
<Textarea
id="manualDescription"
value={manualDescription}
onChange={(event) => setManualDescription(event.target.value)}
disabled={isSubmittingSource}
rows={3}
ref={manualDescriptionRef}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manualFeatures">Key Features (one per line)</Label>
<Textarea
id="manualFeatures"
value={manualFeatures}
onChange={(event) => setManualFeatures(event.target.value)}
disabled={isSubmittingSource}
rows={3}
/>
</div>
</>
)}
{sourceNotice && (
<div className="text-sm text-muted-foreground">{sourceNotice}</div>
)}
{sourceError && (
<div className="text-sm text-destructive">{sourceError}</div>
)}
{analysisJob?.timeline?.length ? (
<AnalysisTimeline items={analysisJob.timeline} />
) : null}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsAdding(false)}
disabled={isSubmittingSource}
>
Cancel
</Button>
{manualMode ? (
<Button onClick={handleManualAnalyze} disabled={isSubmittingSource}>
{isSubmittingSource ? "Analyzing..." : "Analyze Manually"}
</Button>
) : (
<Button onClick={handleAddSource} disabled={isSubmittingSource}>
{isSubmittingSource ? "Analyzing..." : "Add Source"}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isEditingProject} onOpenChange={setIsEditingProject}>
<DialogContent>
<DialogHeader>
<DialogTitle>Project Settings</DialogTitle>
<DialogDescription>
Update the project name and default status.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="editProjectName">Project Name</Label>
<Input
id="editProjectName"
value={editingProjectName}
onChange={(event) => setEditingProjectName(event.target.value)}
disabled={isSubmittingEdit}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="editProjectDefault"
checked={editingProjectDefault}
onCheckedChange={(checked) => setEditingProjectDefault(checked === true)}
disabled={isSubmittingEdit}
/>
<Label htmlFor="editProjectDefault">Set as default</Label>
</div>
{editingProjectError && (
<div className="text-sm text-destructive">{editingProjectError}</div>
)}
<div className="border-t border-border pt-4 space-y-2">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-destructive">Delete project</div>
<p className="text-xs text-muted-foreground">
This removes the project and all related data sources, analyses, and opportunities.
</p>
</div>
<Button
variant="destructive"
size="sm"
disabled={!canDeleteProject}
onClick={() => {
setDeleteConfirmName("");
setDeleteProjectError(null);
setIsDeleteConfirmOpen(true);
}}
>
Delete
</Button>
</div>
{!canDeleteProject && (
<div className="text-xs text-muted-foreground">
You must keep at least one project.
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsEditingProject(false)}
disabled={isSubmittingEdit}
>
Cancel
</Button>
<Button
onClick={async () => {
if (!editingProjectId) return;
if (!editingProjectName.trim()) {
setEditingProjectError("Project name is required.");
return;
}
setIsSubmittingEdit(true);
setEditingProjectError(null);
try {
await updateProject({
projectId: editingProjectId as any,
name: editingProjectName.trim(),
isDefault: editingProjectDefault,
});
setIsEditingProject(false);
} catch (err: any) {
setEditingProjectError(err?.message || "Failed to update project.");
} finally {
setIsSubmittingEdit(false);
}
}}
disabled={isSubmittingEdit}
>
Save Changes
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isDeleteConfirmOpen} onOpenChange={setIsDeleteConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete project</DialogTitle>
<DialogDescription>
This action is permanent. You are deleting{" "}
<span className="font-semibold text-foreground">
{editingProject?.name || "this project"}
</span>
. Type the project name to confirm deletion.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="deleteProjectConfirm">Project name</Label>
<Input
id="deleteProjectConfirm"
value={deleteConfirmName}
onChange={(event) => setDeleteConfirmName(event.target.value)}
disabled={isDeletingProject || !canDeleteProject}
/>
</div>
{deleteProjectError && (
<div className="text-sm text-destructive">{deleteProjectError}</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsDeleteConfirmOpen(false)}
disabled={isDeletingProject}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={
isDeletingProject ||
!canDeleteProject ||
!editingProject ||
deleteConfirmName.trim() !== editingProject.name
}
onClick={async () => {
if (!editingProjectId || !editingProject) return;
if (deleteConfirmName.trim() !== editingProject.name) {
setDeleteProjectError("Project name does not match.");
return;
}
setDeleteProjectError(null);
setIsDeletingProject(true);
try {
const result = await deleteProject({
projectId: editingProjectId as any,
});
if (selectedProjectId === editingProjectId && result?.newDefaultProjectId) {
setSelectedProjectId(result.newDefaultProjectId);
}
setIsDeleteConfirmOpen(false);
setIsEditingProject(false);
} catch (err: any) {
setDeleteProjectError(err?.message || "Failed to delete project.");
} finally {
setIsDeletingProject(false);
}
}}
>
{isDeletingProject ? "Deleting..." : "Delete Project"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isCreatingProject} onOpenChange={setIsCreatingProject}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription>
Add a new project for a separate product or workflow.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="projectName">Project Name</Label>
<Input
id="projectName"
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
disabled={isSubmittingProject}
/>
</div>
<div className="flex items-center gap-2 text-sm">
<Checkbox
id="projectDefault"
checked={projectDefault}
onCheckedChange={(checked) => setProjectDefault(checked === true)}
/>
<Label htmlFor="projectDefault">Make this the default project</Label>
</div>
{projectError && (
<div className="text-sm text-destructive">{projectError}</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsCreatingProject(false)}
disabled={isSubmittingProject}
>
Cancel
</Button>
<Button
onClick={async () => {
if (!projectName.trim()) {
setProjectError("Project name is required.");
return;
}
setProjectError(null);
setIsSubmittingProject(true);
try {
const projectId = await createProject({
name: projectName.trim(),
isDefault: projectDefault,
});
setSelectedProjectId(projectId as any);
setProjectName("");
setProjectDefault(true);
setIsCreatingProject(false);
} catch (err: any) {
setProjectError(err?.message || "Failed to create project.");
} finally {
setIsSubmittingProject(false);
}
}}
disabled={isSubmittingProject}
>
{isSubmittingProject ? "Creating..." : "Create Project"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</Sidebar>
)
}

View File

@@ -1,6 +1,6 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
@@ -10,6 +10,8 @@ import { Separator } from "@/components/ui/separator";
export function SignIn() {
const { signIn } = useAuthActions();
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = searchParams.get("next");
const [step, setStep] = useState<"signIn" | "signUp">("signIn");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -21,10 +23,11 @@ export function SignIn() {
try {
const flow = step === "signIn" ? "signIn" : "signUp";
await signIn("password", { email, password, flow });
const next = nextPath || (flow === "signIn" ? "/app/dashboard" : "/onboarding");
if (flow === "signIn") {
router.push("/dashboard");
router.push(next);
} else {
router.push("/onboarding");
router.push(next);
}
} catch (err: any) {
console.error(err);
@@ -38,7 +41,8 @@ export function SignIn() {
};
const handleGoogleSignIn = () => {
void signIn("google", { redirectTo: "/dashboard" });
const next = nextPath || "/app/dashboard";
void signIn("google", { redirectTo: next });
};
return (

View File

@@ -16,8 +16,10 @@ export function HeroShader() {
let animationFrameId: number;
const resize = () => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
window.addEventListener("resize", resize);
@@ -36,15 +38,25 @@ export function HeroShader() {
ctx.fillStyle = "rgba(255, 255, 255, 0.5)"; // stroke(w, 116) approx white with alpha
// Model space tuned for ~400x400 tweetcart output.
const baseSize = 400;
const scale = Math.min(canvas.width, canvas.height) / baseSize;
const canvasWidth = canvas.offsetWidth;
const canvasHeight = canvas.offsetHeight;
// Center of drawing - positioned to the right and aligned with content
const cx = canvas.width * 0.7;
const cy = canvas.height * 0.4;
const cx = canvasWidth * 0.7;
const cy = canvasHeight * 0.52;
// Loop for points
// for(t+=PI/90,i=1e4;i--;)a()
t += Math.PI / 90;
for (let i = 10000; i > 0; i--) {
const density = Math.max(1, scale);
const pointCount = Math.min(18000, Math.floor(10000 * density));
for (let i = pointCount; i > 0; i--) {
// y = i / 790
let y = i / 790;
@@ -87,17 +99,17 @@ export function HeroShader() {
const c = d / 4 - t / 2 + (i % 2) * 3;
// q = y * k / 5 * (2 + sin(d*2 + y - t*4)) + 80
const q = y * k / 5 * (2 + Math.sin(d * 2 + y - t * 4)) + 300;
const q = y * k / 5 * (2 + Math.sin(d * 2 + y - t * 4)) + 80;
// x = q * cos(c) + 200
// y_out = q * sin(c) + d * 9 + 60
// 200 and 60 are likely offsets for 400x400 canvas.
// We should center it.
// Original offsets assume a 400x400 canvas; map from model space to screen space.
const modelX = q * Math.cos(c) + 200;
const modelY = q * Math.sin(c) + d * 9 + 60;
const x = (q * Math.cos(c)) + cx;
const y_out = (q * Math.sin(c) + d * 9) + cy;
const x = cx + (modelX - 200) * scale;
const y_out = cy + (modelY - 200) * scale;
const pointSize = Math.min(2 * scale, 3.5);
ctx.fillRect(x, y_out, 2.5, 2.5);
ctx.fillRect(x, y_out, pointSize, pointSize);
}
animationFrameId = requestAnimationFrame(draw);

View File

@@ -1,10 +1,12 @@
"use client"
import * as React from "react"
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
HelpCircle,
LogOut,
Sparkles,
} from "lucide-react"
@@ -30,6 +32,7 @@ import {
useSidebar,
} from "@/components/ui/sidebar"
import { useAuthActions } from "@convex-dev/auth/react"
import { useRouter } from "next/navigation"
export function NavUser({
user,
@@ -42,6 +45,20 @@ export function NavUser({
}) {
const { isMobile } = useSidebar()
const { signOut } = useAuthActions()
const router = useRouter()
const seed = React.useMemo(() => {
const base = user.email || user.name || "";
return base.trim() || "user";
}, [user.email, user.name]);
const avatarUrl = user.avatar || `https://api.dicebear.com/7.x/bottts/svg?seed=${encodeURIComponent(seed)}`;
const fallbackText = React.useMemo(() => {
const base = user.name || user.email || "";
const trimmed = base.trim();
if (!trimmed) return "";
const parts = trimmed.split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}, [user.name, user.email])
return (
<SidebarMenu>
@@ -53,8 +70,8 @@ export function NavUser({
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarImage src={avatarUrl} alt={user.name} />
<AvatarFallback className="rounded-lg">{fallbackText}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
@@ -72,8 +89,8 @@ export function NavUser({
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarImage src={avatarUrl} alt={user.name} />
<AvatarFallback className="rounded-lg">{fallbackText}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
@@ -83,27 +100,28 @@ export function NavUser({
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<DropdownMenuItem onSelect={() => router.push("/app/settings?tab=upgrade")}>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<DropdownMenuItem onSelect={() => router.push("/app/settings?tab=account")}>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem onSelect={() => router.push("/app/settings?tab=billing")}>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => router.push("/app/help")}>
<HelpCircle />
Support
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut()}>
<LogOut />
Log out

View 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;
}

View File

@@ -8,9 +8,6 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
LayoutDashboard,
Search,
Settings,
HelpCircle,
LogOut,
Sparkles,
Target
@@ -25,29 +22,10 @@ export function Sidebar({ productName }: SidebarProps) {
const routes = [
{
label: 'Dashboard',
label: 'Overview',
icon: LayoutDashboard,
href: '/dashboard',
active: pathname === '/dashboard',
},
{
label: 'Opportunities',
icon: Target,
href: '/opportunities',
active: pathname === '/opportunities',
},
]
const bottomRoutes = [
{
label: 'Settings',
icon: Settings,
href: '/settings',
},
{
label: 'Help',
icon: HelpCircle,
href: '/help',
href: '/app/dashboard',
active: pathname === '/app/dashboard',
},
]
@@ -89,22 +67,32 @@ export function Sidebar({ productName }: SidebarProps) {
{route.label}
</Link>
))}
<Link
href="/app/search"
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
pathname === '/app/search'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<Target className="h-4 w-4" />
Search
</Link>
<Link
href="/app/inbox"
className={cn(
'flex items-center rounded-md px-3 py-2 pl-9 text-sm font-medium transition-colors',
pathname === '/app/inbox'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
Inbox
</Link>
</nav>
<Separator className="my-4" />
<nav className="space-y-1 px-2">
{bottomRoutes.map((route) => (
<Link
key={route.href}
href={route.href}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<route.icon className="h-4 w-4" />
{route.label}
</Link>
))}
</nav>
</ScrollArea>
{/* Bottom */}

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
type ProgressProps = React.HTMLAttributes<HTMLDivElement> & {
value?: number
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<div
className="h-full bg-primary transition-all"
style={{ width: `${Math.min(Math.max(value, 0), 100)}%` }}
/>
</div>
)
)
Progress.displayName = "Progress"
export { Progress }

View File

@@ -8,10 +8,18 @@
* @module
*/
import type * as analyses from "../analyses.js";
import type * as analysisJobs from "../analysisJobs.js";
import type * as analysisSections from "../analysisSections.js";
import type * as auth from "../auth.js";
import type * as dataSources from "../dataSources.js";
import type * as http from "../http.js";
import type * as logs from "../logs.js";
import type * as opportunities from "../opportunities.js";
import type * as projects from "../projects.js";
import type * as searchJobs from "../searchJobs.js";
import type * as seenUrls from "../seenUrls.js";
import type * as users from "../users.js";
import type {
ApiFromModules,
@@ -20,10 +28,18 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
analyses: typeof analyses;
analysisJobs: typeof analysisJobs;
analysisSections: typeof analysisSections;
auth: typeof auth;
dataSources: typeof dataSources;
http: typeof http;
logs: typeof logs;
opportunities: typeof opportunities;
projects: typeof projects;
searchJobs: typeof searchJobs;
seenUrls: typeof seenUrls;
users: typeof users;
}>;
/**

213
convex/analyses.ts Normal file
View File

@@ -0,0 +1,213 @@
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 getLatestByDataSource = query({
args: { dataSourceId: v.id("dataSources") },
handler: async (ctx, { dataSourceId }) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const dataSource = await ctx.db.get(dataSourceId);
if (!dataSource) return null;
const project = await ctx.db.get(dataSource.projectId);
if (!project || project.userId !== userId) return null;
return await ctx.db
.query("analyses")
.withIndex("by_dataSource_createdAt", (q) =>
q.eq("dataSourceId", dataSourceId)
)
.order("desc")
.first();
},
});
export const getById = query({
args: { analysisId: v.id("analyses") },
handler: async (ctx, { analysisId }) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const analysis = await ctx.db.get(analysisId);
if (!analysis) return null;
const project = await ctx.db.get(analysis.projectId);
if (!project || project.userId !== userId) return null;
return analysis;
},
});
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");
}
const analysisId = 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,
});
const now = Date.now();
const sections = [
{
sectionKey: "profile",
items: {
productName: args.analysis.productName,
tagline: args.analysis.tagline,
description: args.analysis.description,
category: args.analysis.category,
positioning: args.analysis.positioning,
},
},
{ sectionKey: "features", items: args.analysis.features },
{ sectionKey: "competitors", items: args.analysis.competitors },
{ sectionKey: "keywords", items: args.analysis.keywords },
{ sectionKey: "problems", items: args.analysis.problemsSolved },
{ sectionKey: "personas", items: args.analysis.personas },
{ sectionKey: "useCases", items: args.analysis.useCases },
{ sectionKey: "dorkQueries", items: args.analysis.dorkQueries },
];
for (const section of sections) {
await ctx.db.insert("analysisSections", {
analysisId,
sectionKey: section.sectionKey,
items: section.items,
source: args.analysis.analysisVersion === "manual" ? "manual" : "ai",
updatedAt: now,
});
}
return analysisId;
},
});

154
convex/analysisJobs.ts Normal file
View File

@@ -0,0 +1,154 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
export const create = mutation({
args: {
projectId: v.id("projects"),
dataSourceId: v.optional(v.id("dataSources")),
},
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");
}
const now = Date.now();
return await ctx.db.insert("analysisJobs", {
projectId: args.projectId,
dataSourceId: args.dataSourceId,
status: "pending",
progress: 0,
stage: undefined,
timeline: [],
createdAt: now,
updatedAt: now,
});
},
});
export const update = mutation({
args: {
jobId: v.id("analysisJobs"),
status: v.union(
v.literal("pending"),
v.literal("running"),
v.literal("completed"),
v.literal("failed")
),
progress: v.optional(v.number()),
stage: v.optional(v.string()),
timeline: v.optional(v.array(v.object({
key: v.string(),
label: v.string(),
status: v.union(
v.literal("pending"),
v.literal("running"),
v.literal("completed"),
v.literal("failed")
),
detail: v.optional(v.string()),
}))),
error: v.optional(v.string()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const job = await ctx.db.get(args.jobId);
if (!job) throw new Error("Job not found");
const project = await ctx.db.get(job.projectId);
if (!project || project.userId !== userId) {
throw new Error("Project not found or unauthorized");
}
const patch: Record<string, unknown> = {
status: args.status,
updatedAt: Date.now(),
};
if (args.progress !== undefined) patch.progress = args.progress;
if (args.error !== undefined) patch.error = args.error;
if (args.stage !== undefined) patch.stage = args.stage;
if (args.timeline !== undefined) patch.timeline = args.timeline;
await ctx.db.patch(args.jobId, patch);
},
});
export const getById = query({
args: {
jobId: v.id("analysisJobs"),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const job = await ctx.db.get(args.jobId);
if (!job) return null;
const project = await ctx.db.get(job.projectId);
if (!project || project.userId !== userId) return null;
return job;
},
});
export const listByProject = query({
args: {
projectId: v.id("projects"),
status: v.optional(v.string()),
},
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 [];
if (args.status) {
return await ctx.db
.query("analysisJobs")
.withIndex("by_project_status", (q) =>
q.eq("projectId", args.projectId).eq("status", args.status!)
)
.order("desc")
.collect();
}
return await ctx.db
.query("analysisJobs")
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
.order("desc")
.collect();
},
});
export const getLatestByDataSource = query({
args: {
dataSourceId: v.id("dataSources"),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const dataSource = await ctx.db.get(args.dataSourceId);
if (!dataSource) return null;
const project = await ctx.db.get(dataSource.projectId);
if (!project || project.userId !== userId) return null;
const jobs = await ctx.db
.query("analysisJobs")
.withIndex("by_dataSource_createdAt", (q) =>
q.eq("dataSourceId", args.dataSourceId)
)
.order("desc")
.take(1);
return jobs[0] ?? null;
},
});

214
convex/analysisSections.ts Normal file
View File

@@ -0,0 +1,214 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
const SECTION_KEYS = [
"profile",
"features",
"competitors",
"keywords",
"problems",
"personas",
"useCases",
"dorkQueries",
] as const;
function assertSectionKey(sectionKey: string) {
if (!SECTION_KEYS.includes(sectionKey as any)) {
throw new Error(`Invalid section key: ${sectionKey}`);
}
}
async function getOwnedAnalysis(ctx: any, analysisId: any) {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const analysis = await ctx.db.get(analysisId);
if (!analysis) throw new Error("Analysis not found");
const project = await ctx.db.get(analysis.projectId);
if (!project || project.userId !== userId) {
throw new Error("Project not found or unauthorized");
}
return analysis;
}
async function patchAnalysisFromSection(
ctx: any,
analysisId: any,
sectionKey: string,
items: any
) {
if (sectionKey === "profile" && items && typeof items === "object") {
const patch: Record<string, any> = {};
if (typeof items.productName === "string") patch.productName = items.productName;
if (typeof items.tagline === "string") patch.tagline = items.tagline;
if (typeof items.description === "string") patch.description = items.description;
if (typeof items.category === "string") patch.category = items.category;
if (typeof items.positioning === "string") patch.positioning = items.positioning;
if (Object.keys(patch).length > 0) {
await ctx.db.patch(analysisId, patch);
}
return;
}
if (sectionKey === "features") {
await ctx.db.patch(analysisId, { features: items });
return;
}
if (sectionKey === "competitors") {
await ctx.db.patch(analysisId, { competitors: items });
return;
}
if (sectionKey === "keywords") {
await ctx.db.patch(analysisId, { keywords: items });
return;
}
if (sectionKey === "problems") {
await ctx.db.patch(analysisId, { problemsSolved: items });
return;
}
if (sectionKey === "personas") {
await ctx.db.patch(analysisId, { personas: items });
return;
}
if (sectionKey === "useCases") {
await ctx.db.patch(analysisId, { useCases: items });
return;
}
if (sectionKey === "dorkQueries") {
await ctx.db.patch(analysisId, { dorkQueries: items });
}
}
export const listByAnalysis = query({
args: { analysisId: v.id("analyses") },
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
return await ctx.db
.query("analysisSections")
.withIndex("by_analysis", (q) => q.eq("analysisId", args.analysisId))
.collect();
},
});
export const getSection = query({
args: { analysisId: v.id("analyses"), sectionKey: v.string() },
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
return await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
},
});
export const replaceSection = mutation({
args: {
analysisId: v.id("analyses"),
sectionKey: v.string(),
items: v.any(),
lastPrompt: v.optional(v.string()),
source: v.union(v.literal("ai"), v.literal("manual"), v.literal("mixed")),
},
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
const existing = await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
if (existing) {
await ctx.db.patch(existing._id, {
items: args.items,
lastPrompt: args.lastPrompt,
source: args.source,
updatedAt: Date.now(),
});
} else {
await ctx.db.insert("analysisSections", {
analysisId: args.analysisId,
sectionKey: args.sectionKey,
items: args.items,
lastPrompt: args.lastPrompt,
source: args.source,
updatedAt: Date.now(),
});
}
await patchAnalysisFromSection(ctx, args.analysisId, args.sectionKey, args.items);
return { success: true };
},
});
export const addItem = mutation({
args: {
analysisId: v.id("analyses"),
sectionKey: v.string(),
item: v.any(),
},
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
const section = await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
if (!section || !Array.isArray(section.items)) {
throw new Error("Section is not editable as a list.");
}
const updated = [...section.items, args.item];
await ctx.db.patch(section._id, {
items: updated,
source: "mixed",
updatedAt: Date.now(),
});
await patchAnalysisFromSection(ctx, args.analysisId, args.sectionKey, updated);
return { success: true };
},
});
export const removeItem = mutation({
args: {
analysisId: v.id("analyses"),
sectionKey: v.string(),
index: v.number(),
},
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
const section = await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
if (!section || !Array.isArray(section.items)) {
throw new Error("Section is not editable as a list.");
}
const updated = section.items.filter((_: any, idx: number) => idx !== args.index);
await ctx.db.patch(section._id, {
items: updated,
source: "mixed",
updatedAt: Date.now(),
});
await patchAnalysisFromSection(ctx, args.analysisId, args.sectionKey, updated);
return { success: true };
},
});

View File

@@ -19,6 +19,22 @@ export const getProjectDataSources = query({
},
});
export const getById = query({
args: { dataSourceId: v.id("dataSources") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const dataSource = await ctx.db.get(args.dataSourceId);
if (!dataSource) return null;
const project = await ctx.db.get(dataSource.projectId);
if (!project || project.userId !== userId) return null;
return dataSource;
},
});
export const addDataSource = mutation({
args: {
projectId: v.optional(v.id("projects")), // Optional, if not provided, use default
@@ -30,6 +46,14 @@ export const addDataSource = mutation({
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
let normalizedUrl = args.url.trim();
if (normalizedUrl.startsWith("manual:")) {
// Keep manual sources as-is.
} else if (!normalizedUrl.startsWith("http")) {
normalizedUrl = `https://${normalizedUrl}`;
}
normalizedUrl = normalizedUrl.replace(/\/+$/, "");
let projectId = args.projectId;
// Use default project if not provided
@@ -52,12 +76,23 @@ export const addDataSource = mutation({
}
}
const sourceId = await ctx.db.insert("dataSources", {
const existing = await ctx.db
.query("dataSources")
.withIndex("by_project_url", (q) =>
q.eq("projectId", projectId!).eq("url", normalizedUrl)
)
.first();
const sourceId = existing
? existing._id
: await ctx.db.insert("dataSources", {
projectId: projectId!, // Assert exists
type: args.type,
url: args.url,
url: normalizedUrl,
name: args.name,
analysisStatus: "pending",
lastAnalyzedAt: undefined,
lastError: undefined,
// analysisResults not set initially
});
@@ -65,11 +100,94 @@ export const addDataSource = mutation({
const project = await ctx.db.get(projectId!);
if (project) {
const currentSelected = project.dorkingConfig.selectedSourceIds;
if (!currentSelected.includes(sourceId)) {
await ctx.db.patch(projectId!, {
dorkingConfig: { selectedSourceIds: [...currentSelected, sourceId] }
});
}
}
return sourceId;
return { sourceId, projectId: projectId!, isExisting: Boolean(existing) };
},
});
export const updateDataSourceStatus = mutation({
args: {
dataSourceId: v.id("dataSources"),
analysisStatus: v.union(
v.literal("pending"),
v.literal("completed"),
v.literal("failed")
),
lastError: v.optional(v.string()),
lastAnalyzedAt: v.optional(v.number()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const dataSource = await ctx.db.get(args.dataSourceId);
if (!dataSource) throw new Error("Data source not found");
const project = await ctx.db.get(dataSource.projectId);
if (!project || project.userId !== userId) {
throw new Error("Project not found or unauthorized");
}
await ctx.db.patch(args.dataSourceId, {
analysisStatus: args.analysisStatus,
lastError: args.lastError,
lastAnalyzedAt: args.lastAnalyzedAt,
});
},
});
export const remove = mutation({
args: { dataSourceId: v.id("dataSources") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const dataSource = await ctx.db.get(args.dataSourceId);
if (!dataSource) throw new Error("Data source not found");
const project = await ctx.db.get(dataSource.projectId);
if (!project || project.userId !== userId) {
throw new Error("Project not found or unauthorized");
}
const updatedSelected = project.dorkingConfig.selectedSourceIds.filter(
(id) => id !== args.dataSourceId
);
await ctx.db.patch(project._id, {
dorkingConfig: { selectedSourceIds: updatedSelected },
});
const analyses = await ctx.db
.query("analyses")
.withIndex("by_dataSource_createdAt", (q) =>
q.eq("dataSourceId", args.dataSourceId)
)
.collect();
for (const analysis of analyses) {
const sections = await ctx.db
.query("analysisSections")
.withIndex("by_analysis", (q) => q.eq("analysisId", analysis._id))
.collect();
for (const section of sections) {
await ctx.db.delete(section._id);
}
await ctx.db.delete(analysis._id);
}
const analysisJobs = await ctx.db
.query("analysisJobs")
.filter((q) => q.eq(q.field("dataSourceId"), args.dataSourceId))
.collect();
for (const job of analysisJobs) {
await ctx.db.delete(job._id);
}
await ctx.db.delete(args.dataSourceId);
},
});

32
convex/logs.ts Normal file
View File

@@ -0,0 +1,32 @@
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
export const createLog = mutation({
args: {
level: v.union(
v.literal("debug"),
v.literal("info"),
v.literal("warn"),
v.literal("error")
),
message: v.string(),
labels: v.array(v.string()),
payload: v.optional(v.any()),
source: v.optional(v.string()),
requestId: v.optional(v.string()),
projectId: v.optional(v.id("projects")),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
const base = {
...args,
createdAt: Date.now(),
};
if (userId) {
await ctx.db.insert("logs", { ...base, userId });
return;
}
await ctx.db.insert("logs", base);
},
});

259
convex/opportunities.ts Normal file
View File

@@ -0,0 +1,259 @@
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()),
searchJobId: v.optional(v.id("searchJobs")),
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.searchJobId
? ctx.db
.query("opportunities")
.withIndex("by_project_searchJob", (q) =>
q.eq("projectId", args.projectId).eq("searchJobId", args.searchJobId!)
)
: 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")),
searchJobId: v.optional(v.id("searchJobs")),
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,
searchJobId: args.searchJobId,
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,
searchJobId: args.searchJobId,
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;
}
const seenExisting = await ctx.db
.query("seenUrls")
.withIndex("by_project_url", (q) =>
q.eq("projectId", args.projectId).eq("url", opp.url)
)
.first();
if (seenExisting) {
await ctx.db.patch(seenExisting._id, {
lastSeenAt: now,
source: "opportunities",
});
} else {
await ctx.db.insert("seenUrls", {
projectId: args.projectId,
url: opp.url,
firstSeenAt: now,
lastSeenAt: now,
source: "opportunities",
});
}
}
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");
}
const now = Date.now();
const patch: Record<string, unknown> = {
status: args.status,
notes: args.notes,
tags: args.tags,
updatedAt: now,
};
if (args.status === "sent") patch.sentAt = now;
if (args.status === "archived") patch.archivedAt = now;
await ctx.db.patch(args.id, patch);
},
});
export const countByDay = query({
args: {
projectId: v.id("projects"),
days: v.optional(v.number()),
metric: v.union(
v.literal("created"),
v.literal("sent"),
v.literal("archived")
),
},
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 days = args.days ?? 14;
const now = Date.now();
const start = now - days * 24 * 60 * 60 * 1000;
const results = await ctx.db
.query("opportunities")
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
.order("desc")
.collect();
const counts = new Map<string, number>();
const toDateKey = (timestamp: number) =>
new Date(timestamp).toISOString().slice(0, 10);
for (const opp of results) {
let timestamp: number | null = null;
if (args.metric === "created") {
timestamp = opp.createdAt;
} else if (args.metric === "sent") {
timestamp =
opp.sentAt ??
(opp.status === "sent" || opp.status === "converted"
? opp.updatedAt
: null);
} else if (args.metric === "archived") {
timestamp =
opp.archivedAt ??
(opp.status === "archived" || opp.status === "ignored"
? opp.updatedAt
: null);
}
if (!timestamp || timestamp < start) continue;
const key = toDateKey(timestamp);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const series = [];
for (let i = days - 1; i >= 0; i -= 1) {
const date = new Date(now - i * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
series.push({ date, count: counts.get(date) ?? 0 });
}
return series;
},
});

View File

@@ -9,8 +9,7 @@ export const getProjects = query({
if (!userId) return [];
return await ctx.db
.query("projects")
.withIndex("by_owner", (q) => q.eq("userId", userId)) // Note: Need to add index to schema too? Or just filter? Schema doesn't define indexes yet. Will rely on filter for now or filter in memory if small. Actually, will rely on simple filter or add index later.
.filter((q) => q.eq(q.field("userId"), userId))
.withIndex("by_owner", (q) => q.eq("userId", userId))
.collect();
},
});
@@ -33,8 +32,17 @@ export const createProject = mutation({
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
// If setting as default, unset other defaults? For now assume handled by UI or logic
// Actually simplicity: just create.
if (args.isDefault) {
const existingDefaults = await ctx.db
.query("projects")
.withIndex("by_owner", (q) => q.eq("userId", userId))
.collect();
for (const project of existingDefaults) {
if (project.isDefault) {
await ctx.db.patch(project._id, { isDefault: false });
}
}
}
return await ctx.db.insert("projects", {
userId,
@@ -45,6 +53,127 @@ export const createProject = mutation({
},
});
export const updateProject = mutation({
args: { projectId: v.id("projects"), name: v.string(), isDefault: v.boolean() },
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");
}
if (args.isDefault) {
const existingDefaults = await ctx.db
.query("projects")
.withIndex("by_owner", (q) => q.eq("userId", userId))
.collect();
for (const item of existingDefaults) {
if (item.isDefault && item._id !== args.projectId) {
await ctx.db.patch(item._id, { isDefault: false });
}
}
}
await ctx.db.patch(args.projectId, {
name: args.name,
isDefault: args.isDefault,
});
},
});
export const deleteProject = mutation({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const projects = await ctx.db
.query("projects")
.withIndex("by_owner", (q) => q.eq("userId", userId))
.collect();
if (projects.length <= 1) {
throw new Error("You must keep at least one project.");
}
const project = projects.find((item) => item._id === args.projectId);
if (!project || project.userId !== userId) {
throw new Error("Project not found or unauthorized");
}
const remainingProjects = projects.filter((item) => item._id !== args.projectId);
let newDefaultId: typeof args.projectId | null = null;
const remainingDefault = remainingProjects.find((item) => item.isDefault);
if (project.isDefault) {
newDefaultId = remainingProjects[0]?._id ?? null;
} else if (!remainingDefault) {
newDefaultId = remainingProjects[0]?._id ?? null;
}
if (newDefaultId) {
await ctx.db.patch(newDefaultId, { isDefault: true });
}
const dataSources = await ctx.db
.query("dataSources")
.filter((q) => q.eq(q.field("projectId"), args.projectId))
.collect();
for (const source of dataSources) {
await ctx.db.delete(source._id);
}
const analyses = await ctx.db
.query("analyses")
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
.collect();
for (const analysis of analyses) {
await ctx.db.delete(analysis._id);
}
const analysisJobs = await ctx.db
.query("analysisJobs")
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
.collect();
for (const job of analysisJobs) {
await ctx.db.delete(job._id);
}
const searchJobs = await ctx.db
.query("searchJobs")
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
.collect();
for (const job of searchJobs) {
await ctx.db.delete(job._id);
}
const opportunities = await ctx.db
.query("opportunities")
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
.collect();
for (const opportunity of opportunities) {
await ctx.db.delete(opportunity._id);
}
const seenUrls = await ctx.db
.query("seenUrls")
.withIndex("by_project_lastSeen", (q) => q.eq("projectId", args.projectId))
.collect();
for (const seen of seenUrls) {
await ctx.db.delete(seen._id);
}
await ctx.db.delete(args.projectId);
return {
deletedProjectId: args.projectId,
newDefaultProjectId: newDefaultId ?? remainingDefault?._id ?? remainingProjects[0]?._id ?? null,
};
},
});
export const toggleDataSourceConfig = mutation({
args: { projectId: v.id("projects"), sourceId: v.id("dataSources"), selected: v.boolean() },
handler: async (ctx, args) => {
@@ -68,3 +197,162 @@ export const toggleDataSourceConfig = mutation({
});
},
});
export const getSearchContext = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) return { context: null, selectedSourceIds: [], missingSources: [] };
const project = await ctx.db.get(args.projectId);
if (!project || project.userId !== userId) {
return { context: null, selectedSourceIds: [], missingSources: [] };
}
const selectedSourceIds = project.dorkingConfig.selectedSourceIds || [];
if (selectedSourceIds.length === 0) {
return { context: null, selectedSourceIds, missingSources: [] };
}
const analyses = [];
const missingSources: { sourceId: string; reason: string }[] = [];
for (const sourceId of selectedSourceIds) {
const dataSource = await ctx.db.get(sourceId);
if (!dataSource || dataSource.projectId !== project._id) {
missingSources.push({ sourceId: sourceId as string, reason: "not_found" });
continue;
}
const latest = await ctx.db
.query("analyses")
.withIndex("by_dataSource_createdAt", (q) => q.eq("dataSourceId", sourceId))
.order("desc")
.first();
if (!latest) {
missingSources.push({ sourceId: sourceId as string, reason: "no_analysis" });
continue;
}
analyses.push(latest);
}
if (analyses.length === 0) {
return { context: null, selectedSourceIds, missingSources };
}
const merged = mergeAnalyses(analyses, project.name);
return { context: merged, selectedSourceIds, missingSources };
},
});
function normalizeKey(value: string) {
return value.trim().toLowerCase();
}
function severityRank(severity: "high" | "medium" | "low") {
if (severity === "high") return 3;
if (severity === "medium") return 2;
return 1;
}
function mergeAnalyses(analyses: any[], fallbackName: string) {
const keywordMap = new Map<string, any>();
const problemMap = new Map<string, any>();
const competitorMap = new Map<string, any>();
const personaMap = new Map<string, any>();
const useCaseMap = new Map<string, any>();
const featureMap = new Map<string, any>();
let latestScrapedAt = analyses[0].scrapedAt;
for (const analysis of analyses) {
if (analysis.scrapedAt > latestScrapedAt) {
latestScrapedAt = analysis.scrapedAt;
}
for (const keyword of analysis.keywords || []) {
const key = normalizeKey(keyword.term);
if (!keywordMap.has(key)) {
keywordMap.set(key, keyword);
} else if (keywordMap.get(key)?.type !== "differentiator" && keyword.type === "differentiator") {
keywordMap.set(key, keyword);
}
}
for (const problem of analysis.problemsSolved || []) {
const key = normalizeKey(problem.problem);
const existing = problemMap.get(key);
if (!existing || severityRank(problem.severity) > severityRank(existing.severity)) {
problemMap.set(key, problem);
}
}
for (const competitor of analysis.competitors || []) {
const key = normalizeKey(competitor.name);
if (!competitorMap.has(key)) {
competitorMap.set(key, competitor);
}
}
for (const persona of analysis.personas || []) {
const key = normalizeKey(`${persona.name}:${persona.role}`);
if (!personaMap.has(key)) {
personaMap.set(key, persona);
}
}
for (const useCase of analysis.useCases || []) {
const key = normalizeKey(useCase.scenario);
if (!useCaseMap.has(key)) {
useCaseMap.set(key, useCase);
}
}
for (const feature of analysis.features || []) {
const key = normalizeKey(feature.name);
if (!featureMap.has(key)) {
featureMap.set(key, feature);
}
}
}
const keywords = Array.from(keywordMap.values())
.sort((a, b) => {
const aDiff = a.type === "differentiator" ? 0 : 1;
const bDiff = b.type === "differentiator" ? 0 : 1;
if (aDiff !== bDiff) return aDiff - bDiff;
return a.term.length - b.term.length;
})
.slice(0, 80);
const problemsSolved = Array.from(problemMap.values())
.sort((a, b) => severityRank(b.severity) - severityRank(a.severity))
.slice(0, 15);
const competitors = Array.from(competitorMap.values()).slice(0, 10);
const personas = Array.from(personaMap.values()).slice(0, 6);
const useCases = Array.from(useCaseMap.values()).slice(0, 10);
const features = Array.from(featureMap.values()).slice(0, 20);
const base = analyses[0];
return {
productName: base.productName || fallbackName,
tagline: base.tagline || "",
description: base.description || "",
category: base.category || "",
positioning: base.positioning || "",
features,
problemsSolved,
personas,
keywords,
useCases,
competitors,
dorkQueries: [],
scrapedAt: latestScrapedAt,
analysisVersion: "aggregated",
};
}

View File

@@ -11,7 +11,7 @@ const schema = defineSchema({
dorkingConfig: v.object({
selectedSourceIds: v.array(v.id("dataSources")),
}),
}),
}).index("by_owner", ["userId"]),
dataSources: defineTable({
projectId: v.id("projects"),
type: v.literal("website"),
@@ -22,6 +22,8 @@ const schema = defineSchema({
v.literal("completed"),
v.literal("failed")
),
lastAnalyzedAt: v.optional(v.number()),
lastError: v.optional(v.string()),
analysisResults: v.optional(
v.object({
features: v.array(v.string()),
@@ -31,7 +33,212 @@ const schema = defineSchema({
})
),
metadata: v.optional(v.any()),
}),
}).index("by_project_url", ["projectId", "url"]),
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"])
.index("by_dataSource_createdAt", ["dataSourceId", "createdAt"]),
analysisSections: defineTable({
analysisId: v.id("analyses"),
sectionKey: v.string(),
items: v.any(),
lastPrompt: v.optional(v.string()),
source: v.union(v.literal("ai"), v.literal("manual"), v.literal("mixed")),
updatedAt: v.number(),
})
.index("by_analysis", ["analysisId"])
.index("by_analysis_section", ["analysisId", "sectionKey"]),
opportunities: defineTable({
projectId: v.id("projects"),
analysisId: v.optional(v.id("analyses")),
searchJobId: v.optional(v.id("searchJobs")),
url: v.string(),
platform: v.string(),
title: v.string(),
snippet: v.string(),
relevanceScore: v.number(),
intent: v.string(),
status: v.string(),
sentAt: v.optional(v.number()),
archivedAt: v.optional(v.number()),
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"])
.index("by_project_searchJob", ["projectId", "searchJobId"]),
userActivity: defineTable({
userId: v.id("users"),
lastActiveDate: v.string(),
streak: v.number(),
updatedAt: v.number(),
}).index("by_user", ["userId"]),
seenUrls: defineTable({
projectId: v.id("projects"),
url: v.string(),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
source: v.optional(v.string()),
})
.index("by_project_url", ["projectId", "url"])
.index("by_project_lastSeen", ["projectId", "lastSeenAt"]),
analysisJobs: defineTable({
projectId: v.id("projects"),
dataSourceId: v.optional(v.id("dataSources")),
status: v.union(
v.literal("pending"),
v.literal("running"),
v.literal("completed"),
v.literal("failed")
),
progress: v.optional(v.number()),
stage: v.optional(v.string()),
timeline: v.optional(v.array(v.object({
key: v.string(),
label: v.string(),
status: v.union(
v.literal("pending"),
v.literal("running"),
v.literal("completed"),
v.literal("failed")
),
detail: v.optional(v.string()),
}))),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_project_status", ["projectId", "status"])
.index("by_project_createdAt", ["projectId", "createdAt"])
.index("by_dataSource_createdAt", ["dataSourceId", "createdAt"]),
searchJobs: defineTable({
projectId: v.id("projects"),
status: v.union(
v.literal("pending"),
v.literal("running"),
v.literal("completed"),
v.literal("failed")
),
config: v.optional(v.any()),
progress: v.optional(v.number()),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_project_status", ["projectId", "status"])
.index("by_project_createdAt", ["projectId", "createdAt"]),
logs: defineTable({
level: v.union(
v.literal("debug"),
v.literal("info"),
v.literal("warn"),
v.literal("error")
),
message: v.string(),
labels: v.array(v.string()),
payload: v.optional(v.any()),
source: v.optional(v.string()),
requestId: v.optional(v.string()),
projectId: v.optional(v.id("projects")),
userId: v.optional(v.id("users")),
createdAt: v.number(),
})
.index("by_createdAt", ["createdAt"])
.index("by_project_createdAt", ["projectId", "createdAt"]),
});
export default schema;

92
convex/searchJobs.ts Normal file
View File

@@ -0,0 +1,92 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
export const create = mutation({
args: {
projectId: v.id("projects"),
config: v.optional(v.any()),
},
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");
}
const now = Date.now();
return await ctx.db.insert("searchJobs", {
projectId: args.projectId,
status: "pending",
config: args.config,
progress: 0,
createdAt: now,
updatedAt: now,
});
},
});
export const update = mutation({
args: {
jobId: v.id("searchJobs"),
status: v.union(
v.literal("pending"),
v.literal("running"),
v.literal("completed"),
v.literal("failed")
),
progress: v.optional(v.number()),
error: v.optional(v.string()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const job = await ctx.db.get(args.jobId);
if (!job) throw new Error("Job not found");
const project = await ctx.db.get(job.projectId);
if (!project || project.userId !== userId) {
throw new Error("Project not found or unauthorized");
}
await ctx.db.patch(args.jobId, {
status: args.status,
progress: args.progress,
error: args.error,
updatedAt: Date.now(),
});
},
});
export const listByProject = query({
args: {
projectId: v.id("projects"),
status: v.optional(v.string()),
},
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 [];
if (args.status) {
return await ctx.db
.query("searchJobs")
.withIndex("by_project_status", (q) =>
q.eq("projectId", args.projectId).eq("status", args.status!)
)
.order("desc")
.collect();
}
return await ctx.db
.query("searchJobs")
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
.order("desc")
.collect();
},
});

71
convex/seenUrls.ts Normal file
View File

@@ -0,0 +1,71 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
export const listExisting = query({
args: {
projectId: v.id("projects"),
urls: v.array(v.string()),
},
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 existing: string[] = [];
for (const url of args.urls) {
const match = await ctx.db
.query("seenUrls")
.withIndex("by_project_url", (q) =>
q.eq("projectId", args.projectId).eq("url", url)
)
.first();
if (match) existing.push(url);
}
return existing;
},
});
export const markSeenBatch = mutation({
args: {
projectId: v.id("projects"),
urls: v.array(v.string()),
source: v.optional(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");
}
const now = Date.now();
for (const url of args.urls) {
const existing = await ctx.db
.query("seenUrls")
.withIndex("by_project_url", (q) =>
q.eq("projectId", args.projectId).eq("url", url)
)
.first();
if (existing) {
await ctx.db.patch(existing._id, {
lastSeenAt: now,
source: args.source ?? existing.source,
});
} else {
await ctx.db.insert("seenUrls", {
projectId: args.projectId,
url,
firstSeenAt: now,
lastSeenAt: now,
source: args.source,
});
}
}
},
});

85
convex/users.ts Normal file
View File

@@ -0,0 +1,85 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
export const getCurrent = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
return await ctx.db.get(userId);
},
});
export const getCurrentProfile = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const user = await ctx.db.get(userId);
if (!user) return null;
const accounts = await ctx.db
.query("authAccounts")
.withIndex("userIdAndProvider", (q) => q.eq("userId", userId))
.collect();
return { user, accounts };
},
});
export const touch = mutation({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const existing = await ctx.db
.query("userActivity")
.withIndex("by_user", (q) => q.eq("userId", userId))
.first();
if (!existing) {
await ctx.db.insert("userActivity", {
userId,
lastActiveDate: today,
streak: 1,
updatedAt: Date.now(),
});
return { lastActiveDate: today, streak: 1 };
}
if (existing.lastActiveDate === today) {
return { lastActiveDate: existing.lastActiveDate, streak: existing.streak };
}
const streak =
existing.lastActiveDate === yesterday ? existing.streak + 1 : 1;
await ctx.db.patch(existing._id, {
lastActiveDate: today,
streak,
updatedAt: Date.now(),
});
return { lastActiveDate: today, streak };
},
});
export const getActivity = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const activity = await ctx.db
.query("userActivity")
.withIndex("by_user", (q) => q.eq("userId", userId))
.first();
return activity ?? null;
},
});

View File

@@ -1,3 +1,17 @@
2026-02-03 16:43:46 - Fixed 'NoAuthProvider' error by adding Password provider to `convex/auth.config.ts`.
2026-02-03 16:46:52 - Added Google OAuth provider to `convex/auth.config.ts` to fix 'Provider google is not configured' error.
2026-02-03 17:05:00 - Exported 'isAuthenticated' from 'convex/auth.ts' to fix missing export error.
## 2026-02-03 18:54:04 - Fixed convex-auth isAuthenticated export
- Updated `convex/auth.ts` to export `isAuthenticated` directly from `convexAuth()` as required by convex-auth 0.0.76+
- Removed manual fallback query for `isAuthenticated`
- Cleaned up unused imports (`getAuthUserId`, `query`)
- Ran `npx convex dev --once` to regenerate Convex API types and sync the new `isAuthenticated` export
- 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
- Changed global font from Montserrat back to Inter in `app/layout.tsx`

View 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 `/app/dashboard`, `/app/search`, 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 `/app/dashboard`, `/app/search`, `/app/settings`, `/app/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.

View 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.

View 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.

View 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 `/app/settings` and `/app/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 13 to stabilize core flows.
## Acceptance Criteria
- `/app/settings` and `/app/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
View 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
- `/app/settings` and `/app/help` load under app layout.
- Sidebar links navigate correctly.

View File

@@ -10,11 +10,24 @@ import type {
Competitor,
DorkQuery
} from './types'
import { logServer } from "@/lib/server-logger";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
})
type ProductProfile = {
productName: string
category: string
primaryJTBD: string
targetPersona: string
scopeBoundary: string
nonGoals: string[]
differentiators: string[]
evidence: { claim: string; snippet: string }[]
confidence: number
}
async function aiGenerate<T>(prompt: string, systemPrompt: string, temperature: number = 0.3): Promise<T> {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
@@ -34,12 +47,64 @@ async function aiGenerate<T>(prompt: string, systemPrompt: string, temperature:
try { return JSON.parse(jsonStr) as T }
catch (e) {
console.error('Failed to parse JSON:', jsonStr.substring(0, 200))
await logServer({
level: "error",
message: "Failed to parse JSON from AI response",
labels: ["analysis-pipeline", "ai", "error"],
payload: { sample: jsonStr.substring(0, 200) },
source: "lib/analysis-pipeline",
});
throw new Error('Invalid JSON response from AI')
}
}
async function extractFeatures(content: ScrapedContent): Promise<Feature[]> {
function buildEvidenceContext(content: ScrapedContent) {
return [
`Title: ${content.title}`,
`Description: ${content.metaDescription}`,
`Headings: ${content.headings.slice(0, 20).join('\n')}`,
`Feature Lists: ${content.featureList.slice(0, 20).join('\n')}`,
`Paragraphs: ${content.paragraphs.slice(0, 12).join('\n\n')}`,
].join('\n\n')
}
async function extractProductProfile(content: ScrapedContent, extraPrompt?: string): Promise<ProductProfile> {
const systemPrompt = `You are a strict product analyst. Only use provided evidence. If uncertain, answer "unknown" and lower confidence. Return JSON only.`
const prompt = `Analyze the product using evidence only.
${buildEvidenceContext(content)}
Return JSON:
{
"productName": "...",
"category": "...",
"primaryJTBD": "...",
"targetPersona": "...",
"scopeBoundary": "...",
"nonGoals": ["..."],
"differentiators": ["..."],
"evidence": [{"claim": "...", "snippet": "..."}],
"confidence": 0.0
}
Rules:
- "category" should be a concrete market category, not "software/tool/platform".
- "scopeBoundary" must state what the product does NOT do.
- "nonGoals" should be explicit exclusions inferred from evidence.
- "evidence.snippet" must quote or paraphrase short evidence from the text above.
${extraPrompt ? `\nUser guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<ProductProfile>(prompt, systemPrompt, 0.2)
return {
...result,
nonGoals: result.nonGoals?.slice(0, 6) ?? [],
differentiators: result.differentiators?.slice(0, 6) ?? [],
evidence: result.evidence?.slice(0, 6) ?? [],
confidence: typeof result.confidence === "number" ? result.confidence : 0.3,
}
}
async function extractFeatures(content: ScrapedContent, extraPrompt?: string): Promise<Feature[]> {
const systemPrompt = `Extract EVERY feature from website content. Be exhaustive.`
const prompt = `Extract features from:
Title: ${content.title}
@@ -49,19 +114,79 @@ Paragraphs: ${content.paragraphs.slice(0, 10).join('\n\n')}
Feature Lists: ${content.featureList.slice(0, 15).join('\n')}
Return JSON: {"features": [{"name": "...", "description": "...", "benefits": ["..."], "useCases": ["..."]}]}
Aim for 10-15 features.`
Aim for 10-15 features.
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ features: Feature[] }>(prompt, systemPrompt, 0.4)
return result.features.slice(0, 20)
}
async function identifyCompetitors(content: ScrapedContent): Promise<Competitor[]> {
const systemPrompt = `Identify real, named competitors. Use actual company/product names like "Asana", "Jira", "Monday.com", "Trello", "Notion". Never use generic names like "Competitor A".`
async function generateCompetitorCandidates(
profile: ProductProfile,
extraPrompt?: string
): Promise<{ candidates: { name: string; type: "direct" | "adjacent" | "generic"; rationale: string; confidence: number }[] }> {
const systemPrompt = `Generate candidate competitors based only on the product profile. Return JSON only.`
const prompt = `Product profile:
Name: ${profile.productName}
Category: ${profile.category}
JTBD: ${profile.primaryJTBD}
Target persona: ${profile.targetPersona}
Scope boundary: ${profile.scopeBoundary}
Non-goals: ${profile.nonGoals.join(", ") || "unknown"}
Differentiators: ${profile.differentiators.join(", ") || "unknown"}
const prompt = `Identify 5-6 real competitors for: ${content.title}
Description: ${content.metaDescription}
Evidence (for context):
${profile.evidence.map(e => `- ${e.claim}: ${e.snippet}`).join("\n")}
Return EXACT JSON format:
Rules:
- Output real product/company names only.
- Classify as "direct" if same JTBD + same persona + same category.
- "adjacent" if overlap partially.
- "generic" for broad tools people misuse for this (only include if evidence suggests).
- Avoid broad suites unless the category is that suite.
${extraPrompt ? `User guidance: ${extraPrompt}\n` : ""}
Return JSON:
{
"candidates": [
{ "name": "Product", "type": "direct|adjacent|generic", "rationale": "...", "confidence": 0.0 }
]
}`
return await aiGenerate<{ candidates: { name: string; type: "direct" | "adjacent" | "generic"; rationale: string; confidence: number }[] }>(
prompt,
systemPrompt,
0.2
)
}
async function selectDirectCompetitors(
profile: ProductProfile,
candidates: { name: string; type: "direct" | "adjacent" | "generic"; rationale: string; confidence: number }[],
extraPrompt?: string
): Promise<Competitor[]> {
const systemPrompt = `You are a strict verifier. Only accept direct competitors. Return JSON only.`
const prompt = `Product profile:
Name: ${profile.productName}
Category: ${profile.category}
JTBD: ${profile.primaryJTBD}
Target persona: ${profile.targetPersona}
Scope boundary: ${profile.scopeBoundary}
Non-goals: ${profile.nonGoals.join(", ") || "unknown"}
Differentiators: ${profile.differentiators.join(", ") || "unknown"}
Candidates:
${candidates.map(c => `- ${c.name} (${c.type}) : ${c.rationale}`).join("\n")}
Rules:
- Only keep DIRECT competitors (same JTBD + persona + category).
- Reject "generic" tools unless the category itself is generic.
- Provide 3-6 competitors. If fewer, include the closest adjacent but label as direct only if truly overlapping.
${extraPrompt ? `User guidance: ${extraPrompt}\n` : ""}
Return JSON:
{
"competitors": [
{
@@ -72,42 +197,73 @@ Return EXACT JSON format:
"theirWeakness": "Their main weakness"
}
]
}
}`
Include: Direct competitors (same space), Big players, Popular alternatives, Tools people misuse for this. Use ONLY real product names.`
const result = await aiGenerate<{ competitors: Competitor[] }>(prompt, systemPrompt, 0.3)
// Validate competitor names aren't generic
return result.competitors.map(c => ({
const result = await aiGenerate<{ competitors: Competitor[] }>(prompt, systemPrompt, 0.2)
return result.competitors
.map(c => ({
...c,
name: c.name.replace(/^Competitor\s+[A-Z]$/i, 'Alternative Solution').replace(/^Generic\s+/i, '')
})).filter(c => c.name.length > 1)
}))
.filter(c => c.name.length > 1)
}
async function generateKeywords(features: Feature[], content: ScrapedContent, competitors: Competitor[]): Promise<Keyword[]> {
const systemPrompt = `Generate SEO keywords. PRIORITY: 1) Single words, 2) Differentiation keywords showing competitive advantage.`
async function generateKeywords(
features: Feature[],
content: ScrapedContent,
competitors: Competitor[],
extraPrompt?: string
): Promise<Keyword[]> {
const systemPrompt = `Generate search-ready phrases users would actually type.`
const featuresText = features.map(f => f.name).join(', ')
const competitorNames = competitors.map(c => c.name).filter(n => n.length > 1).join(', ') || 'Jira, Asana, Monday, Trello'
const competitorNames = competitors.map(c => c.name).filter(n => n.length > 1).join(', ')
const prompt = `Generate 60-80 keywords for: ${content.title}
const differentiatorGuidance = competitorNames
? `Generate 20+ differentiator phrases comparing to: ${competitorNames}`
: `If no competitors are provided, do not invent them. Reduce differentiator share to 5% using generic phrases like "alternatives to X category".`
const prompt = `Generate 60-80 search phrases for: ${content.title}
Features: ${featuresText}
Competitors: ${competitorNames}
Competitors: ${competitorNames || "None"}
CRITICAL - Follow this priority:
1. 40% SINGLE WORDS (e.g., "tracker", "automate", "sync", "fast")
2. 30% DIFFERENTIATION keywords (e.g., "vs-jira", "asana-alternative", "faster", "simpler")
3. 30% Short 2-word phrases only when needed
1. 60% 2-4 word phrases (e.g., "client onboarding checklist", "bug triage workflow")
2. 25% differentiation phrases (e.g., "asana alternative", "faster than jira")
3. 15% single-word brand terms only (product/competitor names)
Return JSON: {"keywords": [{"term": "word", "type": "differentiator|product|feature|problem|solution|competitor", "searchVolume": "high|medium|low", "intent": "informational|navigational|transactional", "funnel": "awareness|consideration|decision", "emotionalIntensity": "frustrated|curious|ready"}]}
Return JSON: {"keywords": [{"term": "phrase", "type": "differentiator|product|feature|problem|solution|competitor", "searchVolume": "high|medium|low", "intent": "informational|navigational|transactional", "funnel": "awareness|consideration|decision", "emotionalIntensity": "frustrated|curious|ready"}]}
Generate 20+ differentiator keywords comparing to: ${competitorNames}`
${differentiatorGuidance}
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ keywords: Keyword[] }>(prompt, systemPrompt, 0.5)
const result = await aiGenerate<{ keywords: Keyword[] }>(prompt, systemPrompt, 0.4)
const stopTerms = new Set([
'platform',
'solution',
'tool',
'software',
'app',
'system',
'product',
'service',
])
const normalized = result.keywords
.map((keyword) => ({ ...keyword, term: keyword.term.trim() }))
.filter((keyword) => keyword.term.length > 2)
.filter((keyword) => {
const words = keyword.term.split(/\s+/).filter(Boolean)
if (words.length === 1) {
return keyword.type === 'product' || keyword.type === 'competitor' || keyword.type === 'differentiator'
}
return words.length <= 4
})
.filter((keyword) => !stopTerms.has(keyword.term.toLowerCase()))
// Sort: differentiators first, then by word count
return result.keywords.sort((a, b) => {
return normalized.sort((a, b) => {
const aDiff = a.type === 'differentiator' ? 0 : 1
const bDiff = b.type === 'differentiator' ? 0 : 1
if (aDiff !== bDiff) return aDiff - bDiff
@@ -120,36 +276,52 @@ Generate 20+ differentiator keywords comparing to: ${competitorNames}`
}).slice(0, 80)
}
async function identifyProblems(features: Feature[], content: ScrapedContent): Promise<Problem[]> {
async function identifyProblems(
features: Feature[],
content: ScrapedContent,
extraPrompt?: string
): Promise<Problem[]> {
const systemPrompt = `Identify problems using JTBD framework.`
const prompt = `Identify 8-12 problems solved by: ${features.map(f => f.name).join(', ')}
Content: ${content.rawText.slice(0, 3000)}
Return JSON: {"problems": [{"problem": "...", "severity": "high|medium|low", "currentWorkarounds": ["..."], "emotionalImpact": "...", "searchTerms": ["..."]}]}`
Return JSON: {"problems": [{"problem": "...", "severity": "high|medium|low", "currentWorkarounds": ["..."], "emotionalImpact": "...", "searchTerms": ["..."]}]}
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ problems: Problem[] }>(prompt, systemPrompt, 0.4)
return result.problems
}
async function generatePersonas(content: ScrapedContent, problems: Problem[]): Promise<Persona[]> {
async function generatePersonas(
content: ScrapedContent,
problems: Problem[],
extraPrompt?: string
): Promise<Persona[]> {
const systemPrompt = `Create diverse user personas with search behavior.`
const prompt = `Create 4-5 personas for: ${content.title}
Description: ${content.metaDescription}
Problems: ${problems.map(p => p.problem).slice(0, 5).join(', ')}
Return JSON: {"personas": [{"name": "Descriptive name", "role": "Job title", "companySize": "e.g. 10-50 employees", "industry": "...", "painPoints": ["..."], "goals": ["..."], "techSavvy": "low|medium|high", "objections": ["..."], "searchBehavior": ["..."]}]}`
Return JSON: {"personas": [{"name": "Descriptive name", "role": "Job title", "companySize": "e.g. 10-50 employees", "industry": "...", "painPoints": ["..."], "goals": ["..."], "techSavvy": "low|medium|high", "objections": ["..."], "searchBehavior": ["..."]}]}
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ personas: Persona[] }>(prompt, systemPrompt, 0.5)
return result.personas
}
async function generateUseCases(features: Feature[], personas: Persona[], problems: Problem[]): Promise<UseCase[]> {
async function generateUseCases(
features: Feature[],
personas: Persona[],
problems: Problem[],
extraPrompt?: string
): Promise<UseCase[]> {
const systemPrompt = `Create JTBD use case scenarios.`
const prompt = `Create 10 use cases.
Features: ${features.map(f => f.name).slice(0, 5).join(', ')}
Problems: ${problems.map(p => p.problem).slice(0, 3).join(', ')}
Return JSON: {"useCases": [{"scenario": "...", "trigger": "...", "emotionalState": "...", "currentWorkflow": ["..."], "desiredOutcome": "...", "alternativeProducts": ["..."], "whyThisProduct": "...", "churnRisk": ["..."]}]}`
Return JSON: {"useCases": [{"scenario": "...", "trigger": "...", "emotionalState": "...", "currentWorkflow": ["..."], "desiredOutcome": "...", "alternativeProducts": ["..."], "whyThisProduct": "...", "churnRisk": ["..."]}]}
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ useCases: UseCase[] }>(prompt, systemPrompt, 0.5)
return result.useCases
@@ -209,45 +381,284 @@ function generateDorkQueries(keywords: Keyword[], problems: Problem[], useCases:
return queries
}
export async function performDeepAnalysis(content: ScrapedContent): Promise<EnhancedProductAnalysis> {
console.log('🔍 Starting deep analysis...')
async function generateDorkQueriesWithAI(
analysis: EnhancedProductAnalysis,
extraPrompt?: string
): Promise<DorkQuery[]> {
const systemPrompt = `Generate high-signal search queries for forums. Return JSON only.`
const prompt = `Create 40-60 dork queries.
Product: ${analysis.productName}
Category: ${analysis.category}
Positioning: ${analysis.positioning}
Keywords: ${analysis.keywords.map(k => k.term).slice(0, 25).join(", ")}
Problems: ${analysis.problemsSolved.map(p => p.problem).slice(0, 10).join(", ")}
Competitors: ${analysis.competitors.map(c => c.name).slice(0, 10).join(", ")}
Use cases: ${analysis.useCases.map(u => u.scenario).slice(0, 8).join(", ")}
console.log(' 📦 Pass 1: Features...')
Rules:
- Use these platforms only: reddit, hackernews, indiehackers, quora, stackoverflow, twitter.
- Include intent: looking-for, frustrated, alternative, comparison, problem-solving, tutorial.
- Prefer query patterns like site:reddit.com "phrase" ...
Return JSON:
{"dorkQueries": [{"query": "...", "platform": "reddit|hackernews|indiehackers|twitter|quora|stackoverflow", "intent": "looking-for|frustrated|alternative|comparison|problem-solving|tutorial", "priority": "high|medium|low"}]}
${extraPrompt ? `User guidance: ${extraPrompt}` : ""}`
const result = await aiGenerate<{ dorkQueries: DorkQuery[] }>(prompt, systemPrompt, 0.3)
return result.dorkQueries
}
type AnalysisProgressUpdate = {
key: "features" | "competitors" | "keywords" | "problems" | "useCases" | "dorkQueries"
status: "running" | "completed"
detail?: string
}
export async function repromptSection(
sectionKey: "profile" | "features" | "competitors" | "keywords" | "problems" | "personas" | "useCases" | "dorkQueries",
content: ScrapedContent,
analysis: EnhancedProductAnalysis,
extraPrompt?: string
): Promise<any> {
if (sectionKey === "profile") {
const profile = await extractProductProfile(content, extraPrompt);
const tagline = content.metaDescription.split(".")[0];
const positioning = [
profile.primaryJTBD && profile.primaryJTBD !== "unknown"
? profile.primaryJTBD
: "",
profile.targetPersona && profile.targetPersona !== "unknown"
? `for ${profile.targetPersona}`
: "",
].filter(Boolean).join(" ");
return {
productName: profile.productName && profile.productName !== "unknown" ? profile.productName : analysis.productName,
tagline,
description: content.metaDescription,
category: profile.category && profile.category !== "unknown" ? profile.category : analysis.category,
positioning,
primaryJTBD: profile.primaryJTBD,
targetPersona: profile.targetPersona,
scopeBoundary: profile.scopeBoundary,
nonGoals: profile.nonGoals,
differentiators: profile.differentiators,
evidence: profile.evidence,
confidence: profile.confidence,
};
}
if (sectionKey === "features") {
return await extractFeatures(content, extraPrompt);
}
if (sectionKey === "competitors") {
const profile = await extractProductProfile(content, extraPrompt);
const candidateSet = await generateCompetitorCandidates(profile, extraPrompt);
return await selectDirectCompetitors(profile, candidateSet.candidates, extraPrompt);
}
if (sectionKey === "keywords") {
const features = analysis.features?.length ? analysis.features : await extractFeatures(content);
return await generateKeywords(features, content, analysis.competitors || [], extraPrompt);
}
if (sectionKey === "problems") {
const features = analysis.features?.length ? analysis.features : await extractFeatures(content);
return await identifyProblems(features, content, extraPrompt);
}
if (sectionKey === "personas") {
const problems = analysis.problemsSolved?.length
? analysis.problemsSolved
: await identifyProblems(analysis.features || [], content);
return await generatePersonas(content, problems, extraPrompt);
}
if (sectionKey === "useCases") {
const features = analysis.features?.length ? analysis.features : await extractFeatures(content);
const problems = analysis.problemsSolved?.length
? analysis.problemsSolved
: await identifyProblems(features, content);
const personas = analysis.personas?.length
? analysis.personas
: await generatePersonas(content, problems);
return await generateUseCases(features, personas, problems, extraPrompt);
}
if (sectionKey === "dorkQueries") {
return await generateDorkQueriesWithAI(analysis, extraPrompt);
}
throw new Error(`Unsupported section key: ${sectionKey}`);
}
export async function performDeepAnalysis(
content: ScrapedContent,
onProgress?: (update: AnalysisProgressUpdate) => void | Promise<void>
): Promise<EnhancedProductAnalysis> {
await logServer({
level: "info",
message: "Starting deep analysis",
labels: ["analysis-pipeline", "start"],
source: "lib/analysis-pipeline",
});
await logServer({
level: "info",
message: "Product profiling",
labels: ["analysis-pipeline", "profile"],
source: "lib/analysis-pipeline",
});
const productProfile = await extractProductProfile(content)
await logServer({
level: "info",
message: "Product profile complete",
labels: ["analysis-pipeline", "profile"],
payload: {
category: productProfile.category,
targetPersona: productProfile.targetPersona,
confidence: productProfile.confidence,
},
source: "lib/analysis-pipeline",
});
await logServer({
level: "info",
message: "Pass 1: Features",
labels: ["analysis-pipeline", "features"],
source: "lib/analysis-pipeline",
});
await onProgress?.({ key: "features", status: "running" })
const features = await extractFeatures(content)
console.log(`${features.length} features`)
await logServer({
level: "info",
message: "Features extracted",
labels: ["analysis-pipeline", "features"],
payload: { count: features.length },
source: "lib/analysis-pipeline",
});
await onProgress?.({ key: "features", status: "completed", detail: `${features.length} features` })
console.log(' 🏆 Pass 2: Competitors...')
const competitors = await identifyCompetitors(content)
console.log(`${competitors.length} competitors: ${competitors.map(c => c.name).join(', ')}`)
await logServer({
level: "info",
message: "Pass 2: Competitors",
labels: ["analysis-pipeline", "competitors"],
source: "lib/analysis-pipeline",
});
await onProgress?.({ key: "competitors", status: "running" })
const candidateSet = await generateCompetitorCandidates(productProfile)
const competitors = await selectDirectCompetitors(productProfile, candidateSet.candidates)
await logServer({
level: "info",
message: "Competitors extracted",
labels: ["analysis-pipeline", "competitors"],
payload: { count: competitors.length, names: competitors.map((c) => c.name) },
source: "lib/analysis-pipeline",
});
await onProgress?.({
key: "competitors",
status: "completed",
detail: `${competitors.length} competitors: ${competitors.map(c => c.name).join(', ')}`
})
console.log(' 🔑 Pass 3: Keywords...')
await logServer({
level: "info",
message: "Pass 3: Keywords",
labels: ["analysis-pipeline", "keywords"],
source: "lib/analysis-pipeline",
});
await onProgress?.({ key: "keywords", status: "running" })
const keywords = await generateKeywords(features, content, competitors)
console.log(`${keywords.length} keywords (${keywords.filter(k => k.type === 'differentiator').length} differentiators)`)
await logServer({
level: "info",
message: "Keywords extracted",
labels: ["analysis-pipeline", "keywords"],
payload: {
count: keywords.length,
differentiators: keywords.filter((k) => k.type === "differentiator").length,
},
source: "lib/analysis-pipeline",
});
await onProgress?.({
key: "keywords",
status: "completed",
detail: `${keywords.length} keywords (${keywords.filter(k => k.type === 'differentiator').length} differentiators)`
})
console.log(' 🎯 Pass 4: Problems...')
const [problems, personas] = await Promise.all([
identifyProblems(features, content),
generatePersonas(content, [])
])
console.log(`${problems.length} problems, ${personas.length} personas`)
await logServer({
level: "info",
message: "Pass 4: Problems",
labels: ["analysis-pipeline", "problems"],
source: "lib/analysis-pipeline",
});
await onProgress?.({ key: "problems", status: "running" })
const problems = await identifyProblems(features, content)
const personas = await generatePersonas(content, problems)
await logServer({
level: "info",
message: "Problems and personas extracted",
labels: ["analysis-pipeline", "problems"],
payload: { problems: problems.length, personas: personas.length },
source: "lib/analysis-pipeline",
});
await onProgress?.({
key: "problems",
status: "completed",
detail: `${problems.length} problems, ${personas.length} personas`
})
console.log(' 💡 Pass 5: Use cases...')
await logServer({
level: "info",
message: "Pass 5: Use cases",
labels: ["analysis-pipeline", "use-cases"],
source: "lib/analysis-pipeline",
});
await onProgress?.({ key: "useCases", status: "running" })
const useCases = await generateUseCases(features, personas, problems)
console.log(`${useCases.length} use cases`)
await logServer({
level: "info",
message: "Use cases extracted",
labels: ["analysis-pipeline", "use-cases"],
payload: { count: useCases.length },
source: "lib/analysis-pipeline",
});
await onProgress?.({ key: "useCases", status: "completed", detail: `${useCases.length} use cases` })
console.log(' 🔎 Pass 6: Dork queries...')
await logServer({
level: "info",
message: "Pass 6: Dork queries",
labels: ["analysis-pipeline", "dork-queries"],
source: "lib/analysis-pipeline",
});
await onProgress?.({ key: "dorkQueries", status: "running" })
const dorkQueries = generateDorkQueries(keywords, problems, useCases, competitors)
console.log(`${dorkQueries.length} queries`)
await logServer({
level: "info",
message: "Dork queries extracted",
labels: ["analysis-pipeline", "dork-queries"],
payload: { count: dorkQueries.length },
source: "lib/analysis-pipeline",
});
await onProgress?.({ key: "dorkQueries", status: "completed", detail: `${dorkQueries.length} queries` })
const productName = content.title.split(/[\|\-–—:]/)[0].trim()
const tagline = content.metaDescription.split('.')[0]
const positioning = [
productProfile.primaryJTBD && productProfile.primaryJTBD !== "unknown"
? productProfile.primaryJTBD
: "",
productProfile.targetPersona && productProfile.targetPersona !== "unknown"
? `for ${productProfile.targetPersona}`
: "",
].filter(Boolean).join(" ")
return {
productName,
productName: productProfile.productName && productProfile.productName !== "unknown" ? productProfile.productName : productName,
tagline,
description: content.metaDescription,
category: '',
positioning: '',
category: productProfile.category && productProfile.category !== "unknown" ? productProfile.category : '',
positioning,
features,
problemsSolved: problems,
personas,
@@ -256,6 +667,6 @@ export async function performDeepAnalysis(content: ScrapedContent): Promise<Enha
competitors,
dorkQueries,
scrapedAt: new Date().toISOString(),
analysisVersion: '2.1-optimized'
analysisVersion: '2.2-profiled'
}
}

View File

@@ -1,5 +1,6 @@
import OpenAI from 'openai'
import type { ProductAnalysis, ScrapedContent, Opportunity } from './types'
import { logServer } from "@/lib/server-logger";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
@@ -111,7 +112,13 @@ export async function findOpportunities(analysis: ProductAnalysis): Promise<Oppo
}
}
} catch (e) {
console.error('Search failed:', e)
await logServer({
level: "error",
message: "Search failed",
labels: ["openai", "search", "error"],
payload: { error: String(e) },
source: "lib/openai",
});
}
}
@@ -213,7 +220,13 @@ async function searchGoogle(query: string, num: number): Promise<SearchResult[]>
const results = await searchSerper(query, num)
if (results.length > 0) return results
} catch (e) {
console.error('Serper search failed:', e)
await logServer({
level: "error",
message: "Serper search failed",
labels: ["openai", "serper", "error"],
payload: { error: String(e) },
source: "lib/openai",
});
}
}
@@ -234,6 +247,13 @@ async function searchSerper(query: string, num: number): Promise<SearchResult[]>
if (!response.ok) throw new Error('Serper API error')
const data = await response.json()
await logServer({
level: "info",
message: "Serper response received",
labels: ["openai", "serper", "response"],
payload: { query, num, data },
source: "lib/openai",
});
return (data.organic || []).map((r: any) => ({
title: r.title,
url: r.link,

View File

@@ -5,26 +5,59 @@ import type {
SearchStrategy,
PlatformId
} from './types'
import { logServer } from "@/lib/server-logger";
const DEFAULT_PLATFORMS: Record<PlatformId, { name: string; rateLimit: number }> = {
reddit: { name: 'Reddit', rateLimit: 30 },
twitter: { name: 'X/Twitter', rateLimit: 20 },
hackernews: { name: 'Hacker News', rateLimit: 30 },
indiehackers: { name: 'Indie Hackers', rateLimit: 20 },
quora: { name: 'Quora', rateLimit: 20 },
stackoverflow: { name: 'Stack Overflow', rateLimit: 30 },
linkedin: { name: 'LinkedIn', rateLimit: 15 }
}
export function getDefaultPlatforms(): Record<PlatformId, { name: string; icon: string; rateLimit: number; enabled: boolean }> {
export function getDefaultPlatforms(): Record<PlatformId, { name: string; icon: string; rateLimit: number; enabled: boolean; searchTemplate: string }> {
return {
reddit: { name: 'Reddit', icon: 'MessageSquare', rateLimit: 30, enabled: true },
twitter: { name: 'X/Twitter', icon: 'Twitter', rateLimit: 20, enabled: true },
hackernews: { name: 'Hacker News', icon: 'HackerIcon', rateLimit: 30, enabled: true },
indiehackers: { name: 'Indie Hackers', icon: 'Users', rateLimit: 20, enabled: false },
quora: { name: 'Quora', icon: 'HelpCircle', rateLimit: 20, enabled: false },
stackoverflow: { name: 'Stack Overflow', rateLimit: 30, enabled: false },
linkedin: { name: 'LinkedIn', rateLimit: 15, enabled: false }
reddit: {
name: 'Reddit',
icon: 'MessageSquare',
rateLimit: 30,
enabled: true,
searchTemplate: '{site} {term} {intent}',
},
twitter: {
name: 'X/Twitter',
icon: 'Twitter',
rateLimit: 20,
enabled: true,
searchTemplate: '{site} {term} {intent}',
},
hackernews: {
name: 'Hacker News',
icon: 'HackerIcon',
rateLimit: 30,
enabled: true,
searchTemplate: '{site} ("Ask HN" OR "Show HN") {term} {intent}',
},
indiehackers: {
name: 'Indie Hackers',
icon: 'Users',
rateLimit: 20,
enabled: false,
searchTemplate: '{site} {term} {intent}',
},
quora: {
name: 'Quora',
icon: 'HelpCircle',
rateLimit: 20,
enabled: false,
searchTemplate: '{site} {term} {intent}',
},
stackoverflow: {
name: 'Stack Overflow',
icon: 'Filter',
rateLimit: 30,
enabled: false,
searchTemplate: '{site} {term} {intent}',
},
linkedin: {
name: 'LinkedIn',
icon: 'Globe',
rateLimit: 15,
enabled: false,
searchTemplate: '{site} {term} {intent}',
}
}
}
@@ -38,18 +71,27 @@ export function generateSearchQueries(
enabledPlatforms.forEach(platform => {
config.strategies.forEach(strategy => {
const strategyQueries = buildStrategyQueries(strategy, analysis, platform.id)
const strategyQueries = buildStrategyQueries(strategy, analysis, platform)
queries.push(...strategyQueries)
})
})
return sortAndDedupeQueries(queries).slice(0, config.maxResults || 50)
const deduped = sortAndDedupeQueries(queries)
const limited = deduped.slice(0, config.maxResults || 50)
void logServer({
level: "info",
message: "Search queries generated",
labels: ["query-generator", "queries"],
payload: { generated: queries.length, deduped: deduped.length, limited: limited.length },
source: "lib/query-generator",
});
return limited
}
function buildStrategyQueries(
strategy: SearchStrategy,
analysis: EnhancedProductAnalysis,
platform: PlatformId
platform: { id: PlatformId; searchTemplate?: string }
): GeneratedQuery[] {
switch (strategy) {
@@ -79,25 +121,24 @@ function buildStrategyQueries(
}
}
function buildDirectKeywordQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
function buildDirectKeywordQueries(
analysis: EnhancedProductAnalysis,
platform: { id: PlatformId; searchTemplate?: string }
): GeneratedQuery[] {
const keywords = analysis.keywords
.filter(k => k.type === 'product' || k.type === 'feature')
.filter(k => k.type === 'product' || k.type === 'feature' || k.type === 'solution')
.slice(0, 8)
const templates: Record<PlatformId, string[]> = {
reddit: ['("looking for" OR "need" OR "recommendation")', '("what do you use" OR "suggestion")'],
twitter: ['("looking for" OR "need")', '("any recommendations" OR "suggestions")'],
hackernews: ['("Ask HN")'],
indiehackers: ['("looking for" OR "need")'],
quora: ['("what is the best" OR "how do I choose")'],
stackoverflow: ['("best tool for" OR "recommendation")'],
linkedin: ['("looking for" OR "seeking")']
}
const intentPhrases = [
'("looking for" OR "need" OR "recommendation")',
'("what do you use" OR "suggestions")',
'("any alternatives" OR "best tool")',
]
return keywords.flatMap(kw =>
(templates[platform] || templates.reddit).map(template => ({
query: buildPlatformQuery(platform, `"${kw.term}" ${template}`),
platform,
return keywords.flatMap((kw) =>
intentPhrases.map((intent) => ({
query: buildPlatformQuery(platform, quoteTerm(kw.term), intent),
platform: platform.id,
strategy: 'direct-keywords' as SearchStrategy,
priority: 3,
expectedIntent: 'looking'
@@ -105,72 +146,99 @@ function buildDirectKeywordQueries(analysis: EnhancedProductAnalysis, platform:
)
}
function buildProblemQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
const highSeverityProblems = analysis.problemsSolved
.filter(p => p.severity === 'high')
.slice(0, 5)
function buildProblemQueries(
analysis: EnhancedProductAnalysis,
platform: { id: PlatformId; searchTemplate?: string }
): GeneratedQuery[] {
const problems = analysis.problemsSolved
.filter((p) => p.severity === 'high' || p.severity === 'medium')
.slice(0, 6)
return highSeverityProblems.flatMap(problem => [
{
query: buildPlatformQuery(platform, `"${problem.problem}" ("how to" OR "fix" OR "solve")`),
platform,
strategy: 'problem-pain',
const intentPhrases = [
'("how do I" OR "how to" OR "fix")',
'("frustrated" OR "stuck" OR "struggling")',
'("best way" OR "recommendation")',
]
return problems.flatMap((problem) => {
const terms = problem.searchTerms && problem.searchTerms.length > 0
? problem.searchTerms.slice(0, 3)
: [problem.problem]
return terms.flatMap((term) =>
intentPhrases.map((intent) => ({
query: buildPlatformQuery(platform, quoteTerm(term), intent),
platform: platform.id,
strategy: 'problem-pain' as SearchStrategy,
priority: 5,
expectedIntent: 'frustrated'
},
...problem.searchTerms.slice(0, 2).map(term => ({
query: buildPlatformQuery(platform, `"${term}"`),
platform,
strategy: 'problem-pain',
priority: 4,
expectedIntent: 'frustrated'
expectedIntent: 'frustrated',
}))
])
)
})
}
function buildCompetitorQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
const competitors = analysis.competitors.slice(0, 5)
function buildCompetitorQueries(
analysis: EnhancedProductAnalysis,
platform: { id: PlatformId; searchTemplate?: string }
): GeneratedQuery[] {
const competitors = analysis.competitors
.filter((comp) => comp.name && comp.name.length > 2)
.slice(0, 6)
return competitors.flatMap(comp => [
const switchIntent = '("switching" OR "moving from" OR "left" OR "canceling")'
const compareIntent = '("vs" OR "versus" OR "compared to" OR "better than")'
const painIntent = '("frustrated" OR "issues with" OR "problems with")'
return competitors.flatMap((comp) => [
{
query: buildPlatformQuery(platform, `"${comp.name}" ("alternative" OR "switching from" OR "moving away")`),
platform,
strategy: 'competitor-alternative',
query: buildPlatformQuery(platform, quoteTerm(comp.name), switchIntent),
platform: platform.id,
strategy: 'competitor-alternative' as SearchStrategy,
priority: 5,
expectedIntent: 'comparing'
expectedIntent: 'comparing',
},
{
query: buildPlatformQuery(platform, `"${comp.name}" ("vs" OR "versus" OR "compared to" OR "better than")`),
platform,
strategy: 'competitor-alternative',
query: buildPlatformQuery(platform, quoteTerm(comp.name), compareIntent),
platform: platform.id,
strategy: 'competitor-alternative' as SearchStrategy,
priority: 4,
expectedIntent: 'comparing'
expectedIntent: 'comparing',
},
{
query: buildPlatformQuery(platform, `"${comp.name}" ("frustrated" OR "disappointed" OR "problems with")`),
platform,
strategy: 'competitor-alternative',
query: buildPlatformQuery(platform, quoteTerm(comp.name), painIntent),
platform: platform.id,
strategy: 'competitor-alternative' as SearchStrategy,
priority: 5,
expectedIntent: 'frustrated'
}
expectedIntent: 'frustrated',
},
])
}
function buildHowToQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
function buildHowToQueries(
analysis: EnhancedProductAnalysis,
platform: { id: PlatformId; searchTemplate?: string }
): GeneratedQuery[] {
const keywords = analysis.keywords
.filter(k => k.type === 'feature' || k.type === 'solution')
.slice(0, 6)
return keywords.map(kw => ({
query: buildPlatformQuery(platform, `"how to" "${kw.term}" ("tutorial" OR "guide" OR "help")`),
query: buildPlatformQuery(
platform,
`"how to" ${quoteTerm(kw.term)}`,
'("tutorial" OR "guide" OR "help")'
),
platform: platform.id,
strategy: 'how-to',
priority: 2,
expectedIntent: 'learning'
}))
}
function buildEmotionalQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
function buildEmotionalQueries(
analysis: EnhancedProductAnalysis,
platform: { id: PlatformId; searchTemplate?: string }
): GeneratedQuery[] {
const keywords = analysis.keywords
.filter(k => k.type === 'product' || k.type === 'problem')
.slice(0, 5)
@@ -179,8 +247,8 @@ function buildEmotionalQueries(analysis: EnhancedProductAnalysis, platform: Plat
return keywords.flatMap(kw =>
emotionalTerms.slice(0, 3).map(term => ({
query: buildPlatformQuery(platform, `"${kw.term}" ${term}`),
platform,
query: buildPlatformQuery(platform, quoteTerm(kw.term), term),
platform: platform.id,
strategy: 'emotional-frustrated',
priority: 4,
expectedIntent: 'frustrated'
@@ -188,46 +256,86 @@ function buildEmotionalQueries(analysis: EnhancedProductAnalysis, platform: Plat
)
}
function buildComparisonQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
function buildComparisonQueries(
analysis: EnhancedProductAnalysis,
platform: { id: PlatformId; searchTemplate?: string }
): GeneratedQuery[] {
const keywords = analysis.keywords
.filter(k => k.type === 'product' || k.type === 'differentiator')
.slice(0, 4)
return keywords.map(kw => ({
query: buildPlatformQuery(platform, `"${kw.term}" ("vs" OR "versus" OR "or" OR "comparison")`),
platform,
query: buildPlatformQuery(platform, quoteTerm(kw.term), '("vs" OR "versus" OR "or" OR "comparison")'),
platform: platform.id,
strategy: 'comparison',
priority: 3,
expectedIntent: 'comparing'
}))
}
function buildRecommendationQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
function buildRecommendationQueries(
analysis: EnhancedProductAnalysis,
platform: { id: PlatformId; searchTemplate?: string }
): GeneratedQuery[] {
const keywords = analysis.keywords
.filter(k => k.type === 'product' || k.type === 'feature')
.slice(0, 5)
return keywords.map(kw => ({
query: buildPlatformQuery(platform, `("what do you use" OR "recommendation" OR "suggest") "${kw.term}"`),
platform,
query: buildPlatformQuery(platform, quoteTerm(kw.term), '("what do you use" OR "recommendation" OR "suggest")'),
platform: platform.id,
strategy: 'recommendation',
priority: 3,
expectedIntent: 'recommending'
}))
}
function buildPlatformQuery(platform: PlatformId, query: string): string {
const siteOperators: Record<PlatformId, string> = {
const SITE_OPERATORS: Record<string, string> = {
reddit: 'site:reddit.com',
twitter: 'site:twitter.com OR site:x.com',
hackernews: 'site:news.ycombinator.com',
indiehackers: 'site:indiehackers.com',
quora: 'site:quora.com',
stackoverflow: 'site:stackoverflow.com',
linkedin: 'site:linkedin.com'
linkedin: 'site:linkedin.com',
}
return `${siteOperators[platform]} ${query}`
const DEFAULT_TEMPLATES: Record<string, string> = {
reddit: '{site} {term} {intent}',
twitter: '{site} {term} {intent}',
hackernews: '{site} ("Ask HN" OR "Show HN") {term} {intent}',
indiehackers: '{site} {term} {intent}',
quora: '{site} {term} {intent}',
stackoverflow: '{site} {term} {intent}',
linkedin: '{site} {term} {intent}',
}
function applyTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_match, key) => vars[key] || '')
}
function buildPlatformQuery(
platform: { id: PlatformId; searchTemplate?: string; site?: string },
term: string,
intent: string
): string {
const template = (platform.searchTemplate && platform.searchTemplate.trim().length > 0)
? platform.searchTemplate
: DEFAULT_TEMPLATES[platform.id] ?? '{site} {term} {intent}'
const siteOperator = platform.site && platform.site.trim().length > 0
? `site:${platform.site.trim()}`
: (SITE_OPERATORS[platform.id] ?? '')
const raw = applyTemplate(template, {
site: siteOperator,
term,
intent,
})
return raw.replace(/\s+/g, ' ').trim()
}
function quoteTerm(term: string): string {
const cleaned = term.replace(/["]+/g, '').trim()
return cleaned.includes(' ') ? `"${cleaned}"` : `"${cleaned}"`
}
function sortAndDedupeQueries(queries: GeneratedQuery[]): GeneratedQuery[] {

View File

@@ -1,5 +1,6 @@
import puppeteer from 'puppeteer'
import type { ScrapedContent } from './types'
import { logServer } from "@/lib/server-logger";
export class ScrapingError extends Error {
constructor(message: string, public code: string) {
@@ -94,7 +95,13 @@ export async function scrapeWebsite(url: string): Promise<ScrapedContent> {
}
} catch (error: any) {
console.error('Scraping error:', error)
await logServer({
level: "error",
message: "Scraping error",
labels: ["scraper", "error"],
payload: { url: validatedUrl, error: String(error) },
source: "lib/scraper",
});
if (error.message?.includes('ERR_NAME_NOT_RESOLVED') || error.message?.includes('net::ERR')) {
throw new ScrapingError(

View File

@@ -1,4 +1,6 @@
import type { GeneratedQuery, Opportunity, EnhancedProductAnalysis } from './types'
import { logServer } from "@/lib/server-logger";
import { appendSerperAgeModifiers, SerperAgeFilter } from "@/lib/serper-date-filters";
interface SearchResult {
title: string
@@ -10,6 +12,7 @@ interface SearchResult {
export async function executeSearches(
queries: GeneratedQuery[],
filters?: SerperAgeFilter,
onProgress?: (progress: { current: number; total: number; platform: string }) => void
): Promise<SearchResult[]> {
const results: SearchResult[] = []
@@ -24,11 +27,17 @@ export async function executeSearches(
let completed = 0
for (const [platform, platformQueries] of byPlatform) {
console.log(`Searching ${platform}: ${platformQueries.length} queries`)
await logServer({
level: "info",
message: "Searching platform",
labels: ["search-executor", "platform", "start"],
payload: { platform, queries: platformQueries.length },
source: "lib/search-executor",
});
for (const query of platformQueries) {
try {
const searchResults = await executeSingleSearch(query)
const searchResults = await executeSingleSearch(query, filters)
results.push(...searchResults)
completed++
@@ -37,7 +46,13 @@ export async function executeSearches(
// Rate limiting - 1 second between requests
await delay(1000)
} catch (err) {
console.error(`Search failed for ${platform}:`, err)
await logServer({
level: "error",
message: "Search failed for platform",
labels: ["search-executor", "platform", "error"],
payload: { platform, error: String(err) },
source: "lib/search-executor",
});
}
}
}
@@ -45,17 +60,15 @@ export async function executeSearches(
return results
}
async function executeSingleSearch(query: GeneratedQuery): Promise<SearchResult[]> {
// Use Serper API if available
if (process.env.SERPER_API_KEY) {
return searchWithSerper(query)
async function executeSingleSearch(query: GeneratedQuery, filters?: SerperAgeFilter): Promise<SearchResult[]> {
if (!process.env.SERPER_API_KEY) {
throw new Error('SERPER_API_KEY is not configured.')
}
// Fallback to direct scraping (less reliable)
return searchDirect(query)
return searchWithSerper(query, filters)
}
async function searchWithSerper(query: GeneratedQuery): Promise<SearchResult[]> {
async function searchWithSerper(query: GeneratedQuery, filters?: SerperAgeFilter): Promise<SearchResult[]> {
const response = await fetch('https://google.serper.dev/search', {
method: 'POST',
headers: {
@@ -63,7 +76,7 @@ async function searchWithSerper(query: GeneratedQuery): Promise<SearchResult[]>
'Content-Type': 'application/json'
},
body: JSON.stringify({
q: query.query,
q: appendSerperAgeModifiers(query.query, filters),
num: 5,
gl: 'us',
hl: 'en'
@@ -75,6 +88,13 @@ async function searchWithSerper(query: GeneratedQuery): Promise<SearchResult[]>
}
const data = await response.json()
await logServer({
level: "info",
message: "Serper response received",
labels: ["search-executor", "serper", "response"],
payload: { query: query.query, platform: query.platform, data },
source: "lib/search-executor",
});
return (data.organic || []).map((r: any) => ({
title: r.title,
@@ -85,46 +105,6 @@ async function searchWithSerper(query: GeneratedQuery): Promise<SearchResult[]>
}))
}
async function searchDirect(query: GeneratedQuery): Promise<SearchResult[]> {
// Simple direct search as fallback
const encodedQuery = encodeURIComponent(query.query)
const url = `https://www.google.com/search?q=${encodedQuery}&num=5`
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
})
const html = await response.text()
const results: SearchResult[] = []
// Basic regex parsing
const resultBlocks = html.match(/<div class="g"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/g) || []
for (const block of resultBlocks.slice(0, 5)) {
const titleMatch = block.match(/<h3[^>]*>(.*?)<\/h3>/)
const linkMatch = block.match(/<a href="([^"]+)"/)
const snippetMatch = block.match(/<div class="VwiC3b[^"]*"[^>]*>(.*?)<\/div>/)
if (titleMatch && linkMatch) {
results.push({
title: titleMatch[1].replace(/<[^>]+>/g, ''),
url: linkMatch[1],
snippet: snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '') : '',
platform: query.platform
})
}
}
return results
} catch (err) {
console.error('Direct search failed:', err)
return []
}
}
export function scoreOpportunities(
results: SearchResult[],
analysis: EnhancedProductAnalysis
@@ -137,10 +117,8 @@ export function scoreOpportunities(
seen.add(result.url)
const scored = scoreSingleOpportunity(result, analysis)
if (scored.relevanceScore >= 0.3) {
opportunities.push(scored)
}
}
return opportunities.sort((a, b) => b.relevanceScore - a.relevanceScore)
}

View File

@@ -0,0 +1,28 @@
export type SerperAgeFilter = {
maxAgeDays?: number
minAgeDays?: number
}
const normalizeDays = (value?: number) => {
if (typeof value !== 'number') return undefined
if (!Number.isFinite(value)) return undefined
if (value <= 0) return undefined
return Math.floor(value)
}
export function appendSerperAgeModifiers(query: string, filters?: SerperAgeFilter): string {
if (!filters) return query
const modifiers: string[] = []
const normalizedMax = normalizeDays(filters.maxAgeDays)
const normalizedMin = normalizeDays(filters.minAgeDays)
if (typeof normalizedMax === 'number') {
modifiers.push(`newer_than:${normalizedMax}d`)
}
if (typeof normalizedMin === 'number') {
modifiers.push(`older_than:${normalizedMin}d`)
}
if (modifiers.length === 0) return query
return `${query} ${modifiers.join(' ')}`
}

57
lib/server-logger.ts Normal file
View File

@@ -0,0 +1,57 @@
import { fetchMutation } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
type LogLevel = "debug" | "info" | "warn" | "error";
type LogParams = {
level: LogLevel;
message: string;
labels: string[];
payload?: unknown;
source?: string;
requestId?: string;
projectId?: Id<"projects">;
token?: string;
};
function writeConsole(level: LogLevel, message: string, payload?: unknown) {
const tag = `[${level}]`;
if (payload === undefined) {
if (level === "error") {
console.error(tag, message);
return;
}
if (level === "warn") {
console.warn(tag, message);
return;
}
console.log(tag, message);
return;
}
if (level === "error") {
console.error(tag, message, payload);
return;
}
if (level === "warn") {
console.warn(tag, message, payload);
return;
}
console.log(tag, message, payload);
}
export async function logServer({
token,
...args
}: LogParams): Promise<void> {
writeConsole(args.level, args.message, args.payload);
try {
if (token) {
await fetchMutation(api.logs.createLog, args, { token });
return;
}
await fetchMutation(api.logs.createLog, args);
} catch (error) {
console.error("[logger] Failed to write log", error);
}
}

View File

@@ -1,6 +1,6 @@
// Enhanced Types for Deep Analysis
export type PlatformId = 'reddit' | 'twitter' | 'hackernews' | 'indiehackers' | 'quora' | 'stackoverflow' | 'linkedin'
export type PlatformId = string
export type SearchStrategy =
| 'direct-keywords'
@@ -18,13 +18,16 @@ export interface PlatformConfig {
enabled: boolean
searchTemplate: string
rateLimit: number
site?: string
custom?: boolean
}
export interface SearchConfig {
platforms: PlatformConfig[]
strategies: SearchStrategy[]
intensity: 'broad' | 'balanced' | 'targeted'
maxResults: number
minAgeDays?: number
maxAgeDays?: number
timeFilter?: 'past-day' | 'past-week' | 'past-month' | 'past-year' | 'all'
}

View File

@@ -4,20 +4,46 @@ import {
isAuthenticatedNextjs,
nextjsMiddlewareRedirect,
} from "@convex-dev/auth/nextjs/server";
import { NextResponse } from "next/server";
const isSignInPage = createRouteMatcher(["/auth"]);
const isProtectedPage = createRouteMatcher([
"/dashboard(.*)",
"/app(.*)",
"/onboarding(.*)",
"/opportunities(.*)",
]);
export default convexAuthNextjsMiddleware(async (request) => {
const { pathname, search } = request.nextUrl;
if (pathname === "/app" || pathname === "/app/") {
return NextResponse.redirect(new URL(`/app/dashboard${search || ""}`, request.url));
}
const legacyRedirects: Record<string, string> = {
"/dashboard": "/app/dashboard",
"/search": "/app/search",
"/inbox": "/app/inbox",
"/settings": "/app/settings",
"/data-sources": "/app/data-sources",
"/help": "/app/help",
"/leads": "/app/inbox",
"/opportunities": "/app/search",
};
const legacyMatch = Object.keys(legacyRedirects).find((path) =>
pathname === path || pathname.startsWith(`${path}/`)
);
if (legacyMatch) {
const targetBase = legacyRedirects[legacyMatch];
const suffix = pathname.slice(legacyMatch.length);
const target = `${targetBase}${suffix}${search || ""}`;
return NextResponse.redirect(new URL(target, request.url));
}
if (isSignInPage(request) && (await isAuthenticatedNextjs())) {
return nextjsMiddlewareRedirect(request, "/dashboard");
return nextjsMiddlewareRedirect(request, "/app/dashboard");
}
if (isProtectedPage(request) && !(await isAuthenticatedNextjs())) {
return nextjsMiddlewareRedirect(request, "/auth");
const nextUrl = new URL("/auth", request.url);
nextUrl.searchParams.set("next", request.nextUrl.pathname + request.nextUrl.search);
return NextResponse.redirect(nextUrl);
}
});

View File

@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
experimental: {
serverComponentsExternalPackages: ['puppeteer']
}

135
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@auth/core": "^0.37.4",
"@convex-dev/auth": "^0.0.90",
"@polar-sh/nextjs": "^0.9.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -55,6 +56,7 @@
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -616,6 +618,7 @@
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -624,6 +627,7 @@
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -631,10 +635,12 @@
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -787,6 +793,7 @@
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -798,6 +805,7 @@
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -805,6 +813,7 @@
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -854,6 +863,39 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@polar-sh/adapter-utils": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@polar-sh/adapter-utils/-/adapter-utils-0.4.3.tgz",
"integrity": "sha512-9xjOyaAVWRFaFZKkSdZr8J6i5LToUoFKeoczXCEu6+uxAhKmkjA3Qlc8otI4be1DSouWgrYB/VUGHOW7dIgCuQ==",
"license": "Apache-2.0",
"dependencies": {
"@polar-sh/sdk": "^0.42.1"
}
},
"node_modules/@polar-sh/nextjs": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/@polar-sh/nextjs/-/nextjs-0.9.3.tgz",
"integrity": "sha512-3laah76J2qqt/dln/GFL5XaVFdwVaY5xsrMqIX2nNf/5/6frrodth88eGie0UOIhA6OUvmZMuKnW2tGaroMi5g==",
"dependencies": {
"@polar-sh/adapter-utils": "0.4.3",
"@polar-sh/sdk": "^0.42.1"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"next": "^15.0.0 || ^16.0.0"
}
},
"node_modules/@polar-sh/sdk": {
"version": "0.42.5",
"resolved": "https://registry.npmjs.org/@polar-sh/sdk/-/sdk-0.42.5.tgz",
"integrity": "sha512-GzC3/ElCtMO55+KeXwFTANlydZzw5qI3DU/F9vAFIsUKuegSmh+Xu03KCL+ct9/imJOvLUQucYhUSsNKqo2j2Q==",
"dependencies": {
"standardwebhooks": "^1.0.0",
"zod": "^3.25.65 || ^4.0.0"
}
},
"node_modules/@puppeteer/browsers": {
"version": "2.3.0",
"license": "Apache-2.0",
@@ -1879,6 +1921,12 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.93.3",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.93.3.tgz",
@@ -2012,12 +2060,12 @@
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -2026,7 +2074,7 @@
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -2108,10 +2156,12 @@
},
"node_modules/any-promise": {
"version": "1.3.0",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -2125,6 +2175,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -2135,6 +2186,7 @@
},
"node_modules/arg": {
"version": "5.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -2328,6 +2380,7 @@
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2338,6 +2391,7 @@
},
"node_modules/braces": {
"version": "3.0.3",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -2436,6 +2490,7 @@
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -2461,6 +2516,7 @@
},
"node_modules/chokidar": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -2483,6 +2539,7 @@
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -2571,6 +2628,7 @@
},
"node_modules/commander": {
"version": "4.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -2648,6 +2706,7 @@
},
"node_modules/cssesc": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -2658,7 +2717,7 @@
},
"node_modules/csstype": {
"version": "3.2.3",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
@@ -2714,10 +2773,12 @@
},
"node_modules/didyoumean": {
"version": "1.2.2",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"dev": true,
"license": "MIT"
},
"node_modules/dunder-proto": {
@@ -2929,6 +2990,7 @@
},
"node_modules/fast-glob": {
"version": "3.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -2943,6 +3005,7 @@
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -2951,8 +3014,15 @@
"node": ">= 6"
}
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": {
"version": "1.20.1",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -2967,6 +3037,7 @@
},
"node_modules/fdir": {
"version": "6.5.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -2982,6 +3053,7 @@
},
"node_modules/fill-range": {
"version": "7.1.1",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -3062,6 +3134,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -3155,6 +3228,7 @@
},
"node_modules/glob-parent": {
"version": "6.0.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@@ -3293,6 +3367,7 @@
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -3303,6 +3378,7 @@
},
"node_modules/is-core-module": {
"version": "2.16.1",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -3316,6 +3392,7 @@
},
"node_modules/is-extglob": {
"version": "2.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3330,6 +3407,7 @@
},
"node_modules/is-glob": {
"version": "4.0.3",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -3352,6 +3430,7 @@
},
"node_modules/is-number": {
"version": "7.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -3359,6 +3438,7 @@
},
"node_modules/jiti": {
"version": "1.21.7",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -3402,6 +3482,7 @@
},
"node_modules/lilconfig": {
"version": "3.1.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -3460,6 +3541,7 @@
},
"node_modules/merge2": {
"version": "1.4.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -3467,6 +3549,7 @@
},
"node_modules/micromatch": {
"version": "4.0.8",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -3480,6 +3563,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -3539,6 +3623,7 @@
},
"node_modules/mz": {
"version": "2.7.0",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@@ -3691,6 +3776,7 @@
},
"node_modules/normalize-path": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3707,6 +3793,7 @@
},
"node_modules/object-assign": {
"version": "4.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3714,6 +3801,7 @@
},
"node_modules/object-hash": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3821,6 +3909,7 @@
},
"node_modules/path-parse": {
"version": "1.0.7",
"dev": true,
"license": "MIT"
},
"node_modules/path-to-regexp": {
@@ -3841,6 +3930,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -3851,6 +3941,7 @@
},
"node_modules/pify": {
"version": "2.3.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3858,6 +3949,7 @@
},
"node_modules/pirates": {
"version": "4.0.7",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3865,6 +3957,7 @@
},
"node_modules/postcss": {
"version": "8.5.6",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3891,6 +3984,7 @@
},
"node_modules/postcss-import": {
"version": "15.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@@ -3906,6 +4000,7 @@
},
"node_modules/postcss-js": {
"version": "4.1.0",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3929,6 +4024,7 @@
},
"node_modules/postcss-load-config": {
"version": "6.0.1",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3969,6 +4065,7 @@
},
"node_modules/postcss-nested": {
"version": "6.2.0",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3992,6 +4089,7 @@
},
"node_modules/postcss-selector-parser": {
"version": "6.1.2",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -4003,6 +4101,7 @@
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"dev": true,
"license": "MIT"
},
"node_modules/preact": {
@@ -4108,6 +4207,7 @@
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"dev": true,
"funding": [
{
"type": "github",
@@ -4216,6 +4316,7 @@
},
"node_modules/read-cache": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@@ -4223,6 +4324,7 @@
},
"node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -4235,6 +4337,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -4252,6 +4355,7 @@
},
"node_modules/resolve": {
"version": "1.22.11",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@@ -4277,6 +4381,7 @@
},
"node_modules/reusify": {
"version": "1.1.0",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -4285,6 +4390,7 @@
},
"node_modules/run-parallel": {
"version": "1.2.0",
"dev": true,
"funding": [
{
"type": "github",
@@ -4374,6 +4480,16 @@
"node": ">=0.10.0"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"engines": {
@@ -4434,6 +4550,7 @@
},
"node_modules/sucrase": {
"version": "3.35.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@@ -4454,6 +4571,7 @@
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4474,6 +4592,7 @@
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -4544,6 +4663,7 @@
},
"node_modules/thenify": {
"version": "3.3.1",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@@ -4551,6 +4671,7 @@
},
"node_modules/thenify-all": {
"version": "1.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@@ -4565,6 +4686,7 @@
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -4579,6 +4701,7 @@
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -4593,6 +4716,7 @@
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
@@ -4601,7 +4725,7 @@
},
"node_modules/typescript": {
"version": "5.9.3",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -4710,6 +4834,7 @@
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/web-streams-polyfill": {

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@auth/core": "^0.37.4",
"@convex-dev/auth": "^0.0.90",
"@polar-sh/nextjs": "^0.9.3",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",

View File

@@ -18,48 +18,48 @@ const config: Config = {
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
border: 'var(--border)',
input: 'var(--input)',
ring: 'var(--ring)',
background: 'var(--background)',
foreground: 'var(--foreground)',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
DEFAULT: 'var(--primary)',
foreground: 'var(--primary-foreground)'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
DEFAULT: 'var(--secondary)',
foreground: 'var(--secondary-foreground)'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
DEFAULT: 'var(--destructive)',
foreground: 'var(--destructive-foreground)'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
DEFAULT: 'var(--muted)',
foreground: 'var(--muted-foreground)'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
DEFAULT: 'var(--accent)',
foreground: 'var(--accent-foreground)'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
DEFAULT: 'var(--popover)',
foreground: 'var(--popover-foreground)'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
DEFAULT: 'var(--card)',
foreground: 'var(--card-foreground)'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
DEFAULT: 'var(--sidebar-background)',
foreground: 'var(--sidebar-foreground)',
primary: 'var(--sidebar-primary)',
'primary-foreground': 'var(--sidebar-primary-foreground)',
accent: 'var(--sidebar-accent)',
'accent-foreground': 'var(--sidebar-accent-foreground)',
border: 'var(--sidebar-border)',
ring: 'var(--sidebar-ring)'
}
},
borderRadius: {