initialised repo

This commit is contained in:
2026-02-02 15:58:45 +00:00
commit b060e7f008
46 changed files with 8574 additions and 0 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Required: OpenAI API key
OPENAI_API_KEY=sk-...
# Optional: Serper.dev API key for reliable Google search
SERPER_API_KEY=...

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Next.js
.next/
out/
# Production
build
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
.env
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts

51
README.md Normal file
View File

@@ -0,0 +1,51 @@
# AutoDork - Next.js + shadcn/ui
AI-powered product research tool with a landing page, onboarding flow, and dashboard.
## URL Structure
| Route | Description |
|-------|-------------|
| `/` | Landing page with marketing content |
| `/onboarding` | Enter website URL & analyze product |
| `/dashboard` | View analysis & find opportunities |
## Flow
1. **Landing** (`/`) - Marketing page with CTA
2. **Onboarding** (`/onboarding`) - Input URL, AI analyzes website
3. **Dashboard** (`/dashboard`) - Sidebar layout with analysis table + opportunities
## Quick Start
```bash
npm install
npm run dev
```
Set `OPENAI_API_KEY` in `.env` file.
## Project Structure
```
app/
├── page.tsx # Landing page
├── layout.tsx # Root layout
├── globals.css # Dark mode styles
├── (app)/ # App group (with sidebar)
│ ├── layout.tsx # App layout with sidebar
│ ├── onboarding/
│ │ └── page.tsx # URL input & analysis
│ └── dashboard/
│ └── page.tsx # Analysis display
├── api/
│ ├── analyze/route.ts # Scrape & analyze
│ └── search/route.ts # Find opportunities
components/
├── sidebar.tsx # App sidebar navigation
└── ui/ # shadcn components
lib/
├── scraper.ts # Puppeteer scraping
├── openai.ts # AI analysis
└── types.ts # TypeScript types
```

View File

@@ -0,0 +1,516 @@
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import {
Search,
Loader2,
ExternalLink,
RefreshCw,
Users,
Target,
Zap,
TrendingUp,
Lightbulb,
AlertCircle,
CheckCircle2,
BarChart3,
Sparkles
} from 'lucide-react'
import type { EnhancedProductAnalysis, Opportunity } from '@/lib/types'
export default function DashboardPage() {
const router = useRouter()
const searchParams = useSearchParams()
const productName = searchParams.get('product')
const [analysis, setAnalysis] = useState<EnhancedProductAnalysis | null>(null)
const [opportunities, setOpportunities] = useState<Opportunity[]>([])
const [loadingOpps, setLoadingOpps] = useState(false)
const [stats, setStats] = useState<any>(null)
useEffect(() => {
const stored = localStorage.getItem('productAnalysis')
const storedStats = localStorage.getItem('analysisStats')
if (stored) {
setAnalysis(JSON.parse(stored))
}
if (storedStats) {
setStats(JSON.parse(storedStats))
}
}, [])
async function findOpportunities() {
if (!analysis) return
setLoadingOpps(true)
try {
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ analysis }),
})
if (!response.ok) throw new Error('Failed to search')
const data = await response.json()
setOpportunities(data.data.opportunities)
} catch (error) {
console.error('Search error:', error)
} finally {
setLoadingOpps(false)
}
}
function getScoreColor(score: number) {
if (score >= 0.8) return 'bg-green-500/20 text-green-400 border-green-500/30'
if (score >= 0.6) return 'bg-amber-500/20 text-amber-400 border-amber-500/30'
return 'bg-red-500/20 text-red-400 border-red-500/30'
}
function getIntentIcon(intent: string) {
switch (intent) {
case 'frustrated': return <AlertCircle className="h-4 w-4" />
case 'alternative': return <RefreshCw className="h-4 w-4" />
case 'comparison': return <BarChart3 className="h-4 w-4" />
case 'problem-solving': return <Lightbulb className="h-4 w-4" />
default: return <Search className="h-4 w-4" />
}
}
// Separate keywords by type for prioritization
const differentiatorKeywords = analysis?.keywords.filter(k =>
k.type === 'differentiator' ||
k.term.includes('vs') ||
k.term.includes('alternative') ||
k.term.includes('better')
) || []
const otherKeywords = analysis?.keywords.filter(k =>
!differentiatorKeywords.includes(k)
) || []
// Sort: single words first, then short phrases
const sortedKeywords = [...differentiatorKeywords, ...otherKeywords].sort((a, b) => {
const aWords = a.term.split(/\s+/).length
const bWords = b.term.split(/\s+/).length
return aWords - bWords || a.term.length - b.term.length
})
if (!analysis) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">No product analyzed yet.</p>
<Button onClick={() => router.push('/onboarding')}>
<Search className="mr-2 h-4 w-4" />
Analyze Product
</Button>
</div>
</div>
)
}
return (
<div className="p-8">
{/* Centered container */}
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold">{analysis.productName}</h1>
<p className="text-lg text-muted-foreground mt-1">{analysis.tagline}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.push('/onboarding')}>
<RefreshCw className="mr-2 h-4 w-4" />
New Analysis
</Button>
<Button onClick={findOpportunities} disabled={loadingOpps}>
{loadingOpps && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Search className="mr-2 h-4 w-4" />
Find Opportunities
</Button>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<Card className="p-4 text-center">
<Zap className="mx-auto h-5 w-5 text-blue-400 mb-2" />
<div className="text-2xl font-bold">{stats.features}</div>
<div className="text-xs text-muted-foreground">Features</div>
</Card>
<Card className="p-4 text-center">
<Target className="mx-auto h-5 w-5 text-purple-400 mb-2" />
<div className="text-2xl font-bold">{stats.keywords}</div>
<div className="text-xs text-muted-foreground">Keywords</div>
</Card>
<Card className="p-4 text-center">
<Users className="mx-auto h-5 w-5 text-green-400 mb-2" />
<div className="text-2xl font-bold">{stats.personas}</div>
<div className="text-xs text-muted-foreground">Personas</div>
</Card>
<Card className="p-4 text-center">
<Lightbulb className="mx-auto h-5 w-5 text-amber-400 mb-2" />
<div className="text-2xl font-bold">{stats.useCases}</div>
<div className="text-xs text-muted-foreground">Use Cases</div>
</Card>
<Card className="p-4 text-center">
<TrendingUp className="mx-auto h-5 w-5 text-red-400 mb-2" />
<div className="text-2xl font-bold">{stats.competitors}</div>
<div className="text-xs text-muted-foreground">Competitors</div>
</Card>
<Card className="p-4 text-center">
<Search className="mx-auto h-5 w-5 text-cyan-400 mb-2" />
<div className="text-2xl font-bold">{stats.dorkQueries}</div>
<div className="text-xs text-muted-foreground">Queries</div>
</Card>
</div>
)}
{/* Main Tabs */}
<Tabs defaultValue="overview" className="space-y-6">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="features">Features</TabsTrigger>
<TabsTrigger value="personas">Personas</TabsTrigger>
<TabsTrigger value="keywords">Keywords</TabsTrigger>
<TabsTrigger value="opportunities">
Opportunities {opportunities.length > 0 && `(${opportunities.length})`}
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Product Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground leading-relaxed">{analysis.description}</p>
</CardContent>
</Card>
<div className="grid md:grid-cols-2 gap-6">
{/* Problems Solved */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-red-400" />
Problems Solved
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{analysis.problemsSolved.slice(0, 5).map((problem, i) => (
<div key={i} className="border-l-2 border-red-500/30 pl-4">
<div className="flex items-center gap-2">
<span className="font-medium">{problem.problem}</span>
<Badge variant="outline" className={
problem.severity === 'high' ? 'border-red-500/30 text-red-400' :
problem.severity === 'medium' ? 'border-amber-500/30 text-amber-400' :
'border-green-500/30 text-green-400'
}>
{problem.severity}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Feeling: {problem.emotionalImpact}
</p>
</div>
))}
</CardContent>
</Card>
{/* Competitors */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-cyan-400" />
Competitive Landscape
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{analysis.competitors.slice(0, 4).map((comp, i) => (
<div key={i} className="border-l-2 border-cyan-500/30 pl-4">
<div className="font-medium text-lg">{comp.name}</div>
<p className="text-sm text-muted-foreground mt-1">
<span className="text-green-400">Your edge:</span> {comp.differentiator}
</p>
<p className="text-xs text-muted-foreground mt-1">
Their strength: {comp.theirStrength}
</p>
</div>
))}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Features Tab */}
<TabsContent value="features" className="space-y-4">
{analysis.features.map((feature, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{feature.name}
</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Benefits</h4>
<ul className="space-y-1">
{feature.benefits.map((b, j) => (
<li key={j} className="text-sm flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-400 mt-0.5 shrink-0" />
{b}
</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Use Cases</h4>
<ul className="space-y-1">
{feature.useCases.map((u, j) => (
<li key={j} className="text-sm text-muted-foreground">
{u}
</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
))}
</TabsContent>
{/* Personas Tab */}
<TabsContent value="personas" className="space-y-4">
{analysis.personas.map((persona, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-primary" />
{persona.name}
</CardTitle>
<CardDescription>{persona.role} {persona.companySize}</CardDescription>
</div>
<Badge variant="outline">{persona.techSavvy} tech</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Pain Points</h4>
<div className="flex flex-wrap gap-2">
{persona.painPoints.map((p, j) => (
<Badge key={j} variant="secondary" className="bg-red-500/10 text-red-400">
{p}
</Badge>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Goals</h4>
<ul className="text-sm space-y-1">
{persona.goals.map((g, j) => (
<li key={j} className="text-muted-foreground"> {g}</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Search Behavior</h4>
<div className="space-y-1">
{persona.searchBehavior.map((s, j) => (
<div key={j} className="text-sm font-mono bg-muted px-2 py-1 rounded">
"{s}"
</div>
))}
</div>
</div>
</CardContent>
</Card>
))}
</TabsContent>
{/* Keywords Tab */}
<TabsContent value="keywords" className="space-y-6">
{/* Differentiation Keywords First */}
{differentiatorKeywords.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-primary" />
Differentiation Keywords
</CardTitle>
<CardDescription>
Keywords that highlight your competitive advantage
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{differentiatorKeywords.map((kw, i) => (
<Badge
key={i}
className="bg-primary/20 text-primary border-primary/30 text-sm px-3 py-1"
>
{kw.term}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* All Keywords */}
<Card>
<CardHeader>
<CardTitle>All Keywords ({sortedKeywords.length})</CardTitle>
<CardDescription>
Sorted by word count - single words first
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{sortedKeywords.map((kw, i) => (
<Badge
key={i}
variant={kw.type === 'differentiator' ? 'default' : 'outline'}
className={kw.type === 'differentiator' ? 'bg-primary/20 text-primary border-primary/30' : ''}
>
{kw.term}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* Keywords by Type */}
<div className="grid md:grid-cols-2 gap-4">
{['product', 'problem', 'solution', 'feature', 'competitor'].map(type => {
const typeKeywords = analysis.keywords.filter(k => k.type === type)
if (typeKeywords.length === 0) return null
return (
<Card key={type}>
<CardHeader className="pb-3">
<CardTitle className="text-sm uppercase tracking-wide">{type}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{typeKeywords.slice(0, 20).map((kw, i) => (
<Badge key={i} variant="secondary">
{kw.term}
</Badge>
))}
</div>
</CardContent>
</Card>
)
})}
</div>
</TabsContent>
{/* Opportunities Tab */}
<TabsContent value="opportunities" className="space-y-4">
{opportunities.length > 0 ? (
<>
{opportunities.map((opp) => (
<Card key={opp.url} className="hover:border-muted-foreground/50 transition-colors">
<CardContent className="pt-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-medium line-clamp-2">{opp.title}</h3>
</div>
<Badge className={getScoreColor(opp.relevanceScore)}>
{Math.round(opp.relevanceScore * 100)}%
</Badge>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline">{opp.source}</Badge>
<Badge variant="secondary" className="gap-1">
{getIntentIcon(opp.intent)}
{opp.intent}
</Badge>
{opp.matchedPersona && (
<Badge className="bg-blue-500/20 text-blue-400">
{opp.matchedPersona}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground line-clamp-3">
{opp.snippet}
</p>
{opp.matchedKeywords.length > 0 && (
<div className="flex flex-wrap gap-2">
{opp.matchedKeywords.map((k, i) => (
<Badge key={i} className="bg-purple-500/20 text-purple-400 text-xs">
{k}
</Badge>
))}
</div>
)}
<div className="bg-muted rounded-lg p-3">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide mb-1">
Suggested Approach
</p>
<p className="text-sm text-muted-foreground">{opp.suggestedApproach}</p>
</div>
<a
href={opp.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-primary hover:underline"
>
View Post
<ExternalLink className="ml-1 h-3 w-3" />
</a>
</CardContent>
</Card>
))}
</>
) : (
<Card className="bg-muted/50">
<CardContent className="py-12 text-center">
<Search className="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">No opportunities yet</h3>
<p className="text-muted-foreground mb-4">
Click &quot;Find Opportunities&quot; to search for potential customers
</p>
<Button onClick={findOpportunities} disabled={loadingOpps}>
{loadingOpps && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Search className="mr-2 h-4 w-4" />
Find Opportunities
</Button>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
</div>
)
}

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

@@ -0,0 +1,22 @@
'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

@@ -0,0 +1,365 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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'
const examples = [
{ name: 'Notion', url: 'https://notion.so' },
{ name: 'Stripe', url: 'https://stripe.com' },
{ name: 'Figma', url: 'https://figma.com' },
{ name: 'Linear', url: 'https://linear.app' },
]
export default function OnboardingPage() {
const router = useRouter()
const [url, setUrl] = useState('')
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState('')
const [error, setError] = useState('')
const [showManualInput, setShowManualInput] = useState(false)
// Manual input fields
const [manualProductName, setManualProductName] = useState('')
const [manualDescription, setManualDescription] = useState('')
const [manualFeatures, setManualFeatures] = useState('')
async function analyzeWebsite() {
if (!url) return
setLoading(true)
setError('')
setProgress('Scraping website...')
try {
const response = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
})
const data = await response.json()
if (!response.ok) {
if (data.needsManualInput) {
setShowManualInput(true)
setManualProductName(url.replace(/^https?:\/\//, '').replace(/\/$/, ''))
throw new Error(data.error)
}
throw new Error(data.error || 'Failed to analyze')
}
setProgress('Analyzing with AI...')
// Store in localStorage for dashboard
localStorage.setItem('productAnalysis', JSON.stringify(data.data))
localStorage.setItem('analysisStats', JSON.stringify(data.stats))
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()}`)
} catch (err: any) {
console.error('Analysis error:', err)
setError(err.message || 'Failed to analyze website')
} finally {
setLoading(false)
}
}
async function analyzeManually() {
if (!manualProductName || !manualDescription) return
setLoading(true)
setError('')
setProgress('Analyzing with AI...')
try {
// Create a mock analysis from manual input
const manualAnalysis: ProductAnalysis = {
productName: manualProductName,
tagline: manualDescription.split('.')[0],
description: manualDescription,
features: manualFeatures.split('\n').filter(f => f.trim()),
problemsSolved: [],
targetAudience: [],
valuePropositions: [],
keywords: manualProductName.toLowerCase().split(' '),
scrapedAt: new Date().toISOString()
}
// Send to API to enhance with AI
const response = await fetch('/api/analyze-manual', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productName: manualProductName,
description: manualDescription,
features: manualFeatures
}),
})
let finalAnalysis = manualAnalysis
if (response.ok) {
const data = await response.json()
finalAnalysis = data.data
}
// Store in localStorage for dashboard
localStorage.setItem('productAnalysis', JSON.stringify(finalAnalysis))
localStorage.setItem('analysisStats', JSON.stringify({
features: finalAnalysis.features.length,
keywords: finalAnalysis.keywords.length,
personas: finalAnalysis.personas.length,
useCases: finalAnalysis.useCases.length,
competitors: finalAnalysis.competitors.length,
dorkQueries: finalAnalysis.dorkQueries.length
}))
// Redirect to dashboard
const params = new URLSearchParams({ product: finalAnalysis.productName })
router.push(`/dashboard?${params.toString()}`)
} catch (err: any) {
console.error('Manual analysis error:', err)
setError(err.message || 'Failed to analyze')
} finally {
setLoading(false)
}
}
if (showManualInput) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-lg space-y-6">
<div className="text-center space-y-2">
<div className="flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-500/20 text-amber-400">
<AlertCircle className="h-6 w-6" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground">Couldn&apos;t Reach Website</h1>
<p className="text-muted-foreground">
No problem! Tell us about your product and we&apos;ll analyze it manually.
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>Describe Your Product</CardTitle>
<CardDescription>
Enter your product details and we&apos;ll extract the key information.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="productName">Product Name *</Label>
<Input
id="productName"
placeholder="My Awesome Product"
value={manualProductName}
onChange={(e) => setManualProductName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
placeholder="What does your product do? Who is it for? What problem does it solve?"
value={manualDescription}
onChange={(e) => setManualDescription(e.target.value)}
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="features">Key Features (one per line)</Label>
<Textarea
id="features"
placeholder="- Feature 1&#10;- Feature 2&#10;- Feature 3"
value={manualFeatures}
onChange={(e) => setManualFeatures(e.target.value)}
rows={4}
/>
</div>
{loading && (
<div className="space-y-3 py-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{progress}
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setShowManualInput(false)
setError('')
}}
disabled={loading}
className="flex-1"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={analyzeManually}
disabled={!manualProductName || !manualDescription || loading}
className="flex-1 gap-2"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Analyzing...
</>
) : (
<>
Analyze
<ArrowRight className="h-4 w-4" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-lg space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<div className="flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<Sparkles className="h-6 w-6" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome to AutoDork</h1>
<p className="text-muted-foreground">
Enter your website URL and we&apos;ll analyze your product to find opportunities.
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Input Card */}
<Card>
<CardHeader>
<CardTitle>Analyze Your Product</CardTitle>
<CardDescription>
We&apos;ll scrape your site and use AI to extract key information.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="url">Website URL</Label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="url"
placeholder="https://yourproduct.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !loading && analyzeWebsite()}
className="pl-10"
disabled={loading}
/>
</div>
</div>
{loading && (
<div className="space-y-3 py-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{progress}
</div>
<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>
)}
<Button
onClick={analyzeWebsite}
disabled={!url || loading}
className="w-full gap-2"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Analyzing...
</>
) : (
<>
Analyze Website
<ArrowRight className="h-4 w-4" />
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowManualInput(true)}
className="w-full text-muted-foreground"
>
Or enter details manually
</Button>
</CardContent>
</Card>
{/* Examples */}
<div className="text-center">
<p className="text-sm text-muted-foreground mb-3">Or try with an example:</p>
<div className="flex flex-wrap justify-center gap-2">
{examples.map((example) => (
<Button
key={example.url}
variant="outline"
size="sm"
onClick={() => setUrl(example.url)}
disabled={loading}
>
{example.name}
</Button>
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,423 @@
'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,59 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { analyzeFromText } from '@/lib/scraper'
import { performDeepAnalysis } from '@/lib/analysis-pipeline'
const bodySchema = z.object({
productName: z.string().min(1),
description: z.string().min(1),
features: z.string()
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { productName, description, features } = bodySchema.parse(body)
if (!process.env.OPENAI_API_KEY) {
return NextResponse.json(
{ error: 'OpenAI API key not configured' },
{ status: 500 }
)
}
console.log('📝 Creating content from manual input...')
const scrapedContent = await analyzeFromText(productName, description, features)
console.log('🤖 Starting enhanced analysis...')
const analysis = await performDeepAnalysis(scrapedContent)
console.log(` ✓ Analysis complete: ${analysis.features.length} features, ${analysis.keywords.length} keywords`)
return NextResponse.json({
success: true,
data: analysis,
stats: {
features: analysis.features.length,
keywords: analysis.keywords.length,
personas: analysis.personas.length,
useCases: analysis.useCases.length,
competitors: analysis.competitors.length,
dorkQueries: analysis.dorkQueries.length
}
})
} catch (error: any) {
console.error('❌ Manual analysis error:', error)
if (error.name === 'ZodError') {
return NextResponse.json(
{ error: 'Please provide product name and description' },
{ status: 400 }
)
}
return NextResponse.json(
{ error: error.message || 'Failed to analyze' },
{ status: 500 }
)
}
}

69
app/api/analyze/route.ts Normal file
View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { scrapeWebsite, ScrapingError } from '@/lib/scraper'
import { performDeepAnalysis } from '@/lib/analysis-pipeline'
const bodySchema = z.object({
url: z.string().min(1)
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { url } = bodySchema.parse(body)
if (!process.env.OPENAI_API_KEY) {
return NextResponse.json(
{ error: 'OpenAI API key not configured' },
{ status: 500 }
)
}
console.log(`🌐 Scraping: ${url}`)
const scrapedContent = await scrapeWebsite(url)
console.log(` ✓ Scraped ${scrapedContent.headings.length} headings, ${scrapedContent.paragraphs.length} paragraphs`)
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`)
return NextResponse.json({
success: true,
data: analysis,
stats: {
features: analysis.features.length,
keywords: analysis.keywords.length,
personas: analysis.personas.length,
useCases: analysis.useCases.length,
competitors: analysis.competitors.length,
dorkQueries: analysis.dorkQueries.length
}
})
} catch (error: any) {
console.error('❌ Analysis error:', error)
if (error instanceof ScrapingError) {
return NextResponse.json(
{
error: error.message,
code: error.code,
needsManualInput: true
},
{ status: 400 }
)
}
if (error.name === 'ZodError') {
return NextResponse.json(
{ error: 'Invalid URL provided' },
{ status: 400 }
)
}
return NextResponse.json(
{ error: error.message || 'Failed to analyze website' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server'
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'
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()
}))
}),
config: z.object({
platforms: z.array(z.object({
id: z.string(),
name: z.string(),
icon: z.string(),
enabled: z.boolean(),
searchTemplate: z.string(),
rateLimit: z.number()
})),
strategies: z.array(z.string()),
intensity: z.enum(['broad', 'balanced', 'targeted']),
maxResults: z.number().default(50)
})
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { analysis, config } = searchSchema.parse(body)
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(', ')}`)
// Generate queries
console.log(' Generating search queries...')
const queries = generateSearchQueries(analysis as EnhancedProductAnalysis, config as SearchConfig)
console.log(` ✓ Generated ${queries.length} queries`)
// Execute searches
console.log(' Executing searches...')
const searchResults = await executeSearches(queries)
console.log(` ✓ Found ${searchResults.length} raw results`)
// Score and rank
console.log(' Scoring opportunities...')
const opportunities = scoreOpportunities(searchResults, analysis as EnhancedProductAnalysis)
console.log(` ✓ Scored ${opportunities.length} opportunities`)
return NextResponse.json({
success: true,
data: {
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
},
queries: queries.map(q => ({
query: q.query,
platform: q.platform,
strategy: q.strategy,
priority: q.priority
}))
}
})
} catch (error: any) {
console.error('❌ Opportunity search error:', error)
if (error.name === 'ZodError') {
return NextResponse.json(
{ error: 'Invalid request format', details: error.errors },
{ status: 400 }
)
}
return NextResponse.json(
{ error: error.message || 'Failed to search for opportunities' },
{ status: 500 }
)
}
}
// Get default configuration
export async function GET() {
const defaultPlatforms = getDefaultPlatforms()
return NextResponse.json({
platforms: Object.entries(defaultPlatforms).map(([id, config]) => ({
id,
...config
})),
strategies: [
{ id: 'direct-keywords', name: 'Direct Keywords', description: 'Search for people looking for your product category' },
{ id: 'problem-pain', name: 'Problem/Pain', description: 'Find people experiencing problems you solve' },
{ id: 'competitor-alternative', name: 'Competitor Alternatives', description: 'People looking to switch from competitors' },
{ id: 'how-to', name: 'How-To/Tutorials', description: 'People learning about solutions' },
{ id: 'emotional-frustrated', name: 'Frustration Posts', description: 'Emotional posts about pain points' },
{ id: 'comparison', name: 'Comparisons', description: '"X vs Y" comparison posts' },
{ id: 'recommendation', name: 'Recommendations', description: '"What do you use" recommendation requests' }
]
})
}

245
app/api/search/route.ts Normal file
View File

@@ -0,0 +1,245 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import type { EnhancedProductAnalysis, Opportunity, DorkQuery } from '@/lib/types'
// Search result from any source
interface SearchResult {
title: string
url: string
snippet: string
source: string
}
const bodySchema = z.object({
analysis: z.object({
productName: z.string(),
dorkQueries: z.array(z.object({
query: z.string(),
platform: z.string(),
intent: z.string(),
priority: z.string()
})),
keywords: z.array(z.object({
term: z.string()
})),
personas: z.array(z.object({
name: z.string(),
searchBehavior: z.array(z.string())
})),
problemsSolved: z.array(z.object({
problem: z.string(),
searchTerms: z.array(z.string())
}))
})
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { analysis } = bodySchema.parse(body)
console.log(`🔍 Finding opportunities for: ${analysis.productName}`)
// Sort queries by priority
const sortedQueries = analysis.dorkQueries
.sort((a, b) => {
const priorityOrder = { high: 0, medium: 1, low: 2 }
return priorityOrder[a.priority as keyof typeof priorityOrder] - priorityOrder[b.priority as keyof typeof priorityOrder]
})
.slice(0, 15) // Limit to top 15 queries
const allResults: SearchResult[] = []
// Execute searches
for (const query of sortedQueries) {
try {
console.log(` Searching: ${query.query.substring(0, 60)}...`)
const results = await searchGoogle(query.query, 5)
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)}`)
}
}
console.log(` Found ${allResults.length} raw results`)
// Analyze and score opportunities
const opportunities = await analyzeOpportunities(allResults, analysis as EnhancedProductAnalysis)
console.log(` ✓ Analyzed ${opportunities.length} opportunities`)
return NextResponse.json({
success: true,
data: {
totalFound: opportunities.length,
opportunities: opportunities.slice(0, 20),
searchStats: {
queriesUsed: sortedQueries.length,
platformsSearched: [...new Set(sortedQueries.map(q => q.platform))],
averageRelevance: opportunities.reduce((a, o) => a + o.relevanceScore, 0) / opportunities.length || 0
}
}
})
} catch (error: any) {
console.error('❌ Search error:', error)
return NextResponse.json(
{ error: error.message || 'Failed to find opportunities' },
{ status: 500 }
)
}
}
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 searchSerper(query: string, num: number): Promise<SearchResult[]> {
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 })
})
if (!response.ok) throw new Error('Serper API error')
const data = await response.json()
return (data.organic || []).map((r: any) => ({
title: r.title,
url: r.link,
snippet: r.snippet,
source: getSource(r.link)
}))
}
async function searchDirect(query: string, num: number): Promise<SearchResult[]> {
const encodedQuery = encodeURIComponent(query)
const url = `https://www.google.com/search?q=${encodedQuery}&num=${num}`
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[] = []
// Simple regex parsing
const resultBlocks = html.match(/<div class="g"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/g) || []
for (const block of resultBlocks.slice(0, num)) {
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, '') : '',
source: getSource(linkMatch[1])
})
}
}
return results
}
function getSource(url: string): string {
if (url.includes('reddit.com')) return 'Reddit'
if (url.includes('news.ycombinator.com')) return 'Hacker News'
if (url.includes('indiehackers.com')) return 'Indie Hackers'
if (url.includes('quora.com')) return 'Quora'
if (url.includes('twitter.com') || url.includes('x.com')) return 'Twitter/X'
if (url.includes('stackexchange.com') || url.includes('stackoverflow.com')) return 'Stack Exchange'
return 'Other'
}
async function analyzeOpportunities(
results: SearchResult[],
analysis: EnhancedProductAnalysis
): Promise<Opportunity[]> {
const opportunities: Opportunity[] = []
const seen = new Set<string>()
for (const result of results) {
if (seen.has(result.url)) continue
seen.add(result.url)
// Calculate relevance score
const content = (result.title + ' ' + result.snippet).toLowerCase()
// Match keywords
const matchedKeywords = analysis.keywords
.filter(k => content.includes(k.term.toLowerCase()))
.map(k => k.term)
// Match problems
const matchedProblems = analysis.problemsSolved
.filter(p => content.includes(p.problem.toLowerCase()))
.map(p => p.problem)
// Calculate score
const keywordScore = Math.min(matchedKeywords.length * 0.15, 0.6)
const problemScore = Math.min(matchedProblems.length * 0.2, 0.4)
const relevanceScore = Math.min(keywordScore + problemScore, 1)
// Determine intent
let intent: Opportunity['intent'] = 'looking-for'
if (content.includes('frustrated') || content.includes('hate') || content.includes('sucks')) {
intent = 'frustrated'
} else if (content.includes('alternative') || content.includes('switching')) {
intent = 'alternative'
} else if (content.includes('vs') || content.includes('comparison') || content.includes('better')) {
intent = 'comparison'
} else if (content.includes('how to') || content.includes('fix') || content.includes('solution')) {
intent = 'problem-solving'
}
// Find matching persona
const matchedPersona = analysis.personas.find(p =>
p.searchBehavior.some(b => content.includes(b.toLowerCase()))
)?.name
if (relevanceScore >= 0.3) {
opportunities.push({
title: result.title,
url: result.url,
source: result.source,
snippet: result.snippet.slice(0, 300),
relevanceScore,
painPoints: matchedProblems.slice(0, 3),
suggestedApproach: generateApproach(intent, analysis.productName),
matchedKeywords: matchedKeywords.slice(0, 5),
matchedPersona,
intent
})
}
}
return opportunities.sort((a, b) => b.relevanceScore - a.relevanceScore)
}
function generateApproach(intent: string, productName: string): string {
const approaches: Record<string, string> = {
'frustrated': `Empathize with their frustration. Share how ${productName} solves this specific pain point without being pushy.`,
'alternative': `Highlight key differentiators. Focus on why teams switch to ${productName} from their current solution.`,
'comparison': `Provide an honest comparison. Be helpful and mention specific features that address their needs.`,
'problem-solving': `Offer a clear solution. Share a specific example of how ${productName} solves this exact problem.`,
'looking-for': `Introduce ${productName} as a relevant option. Focus on the specific features they're looking for.`
}
return approaches[intent] || approaches['looking-for']
}

36
app/globals.css Normal file
View File

@@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--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;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}

24
app/layout.tsx Normal file
View File

@@ -0,0 +1,24 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'AutoDork - Find Product Opportunities',
description: 'AI-powered product research and opportunity finding',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
{children}
</body>
</html>
)
}

121
app/page.tsx Normal file
View File

@@ -0,0 +1,121 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ArrowRight, Search, Zap, Target, Sparkles } from 'lucide-react'
export default function LandingPage() {
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border">
<div className="container mx-auto max-w-6xl px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Search className="h-4 w-4" />
</div>
<span className="font-semibold text-foreground">AutoDork</span>
</div>
<nav className="flex items-center gap-4">
<Link href="/dashboard" className="text-sm text-muted-foreground hover:text-foreground">
Dashboard
</Link>
<Link href="/onboarding">
<Button size="sm">Get Started</Button>
</Link>
</nav>
</div>
</header>
{/* Hero */}
<section className="container mx-auto max-w-6xl px-4 py-24 lg:py-32">
<div className="flex flex-col items-center text-center space-y-8">
<Badge variant="secondary" className="px-4 py-1.5">
<Sparkles className="mr-1 h-3 w-3" />
AI-Powered Research
</Badge>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl text-foreground">
Find Your Next Customers
<br />
<span className="text-muted-foreground">Before They Know They Need You</span>
</h1>
<p className="max-w-2xl text-lg text-muted-foreground">
AutoDork analyzes your product and finds people on Reddit, Hacker News, and forums
who are actively expressing needs that your solution solves.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/onboarding">
<Button size="lg" className="gap-2">
Start Finding Opportunities
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
</section>
{/* Features */}
<section className="border-t border-border bg-muted/50">
<div className="container mx-auto max-w-6xl px-4 py-24">
<div className="grid md:grid-cols-3 gap-8">
<div className="space-y-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Zap className="h-5 w-5 text-primary" />
</div>
<h3 className="text-lg font-semibold text-foreground">AI Analysis</h3>
<p className="text-muted-foreground">
We scrape your website and use GPT-4 to extract features, pain points, and keywords automatically.
</p>
</div>
<div className="space-y-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
</div>
<h3 className="text-lg font-semibold text-foreground">Smart Dorking</h3>
<p className="text-muted-foreground">
Our system generates targeted Google dork queries to find high-intent posts across Reddit, HN, and more.
</p>
</div>
<div className="space-y-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Target className="h-5 w-5 text-primary" />
</div>
<h3 className="text-lg font-semibold text-foreground">Scored Leads</h3>
<p className="text-muted-foreground">
Each opportunity is scored by relevance and comes with suggested engagement approaches.
</p>
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="container mx-auto max-w-6xl px-4 py-24">
<div className="rounded-2xl border border-border bg-muted/50 p-12 text-center">
<h2 className="text-3xl font-bold text-foreground mb-4">
Ready to find your customers?
</h2>
<p className="text-muted-foreground mb-8 max-w-lg mx-auto">
Stop guessing. Start finding people who are already looking for solutions like yours.
</p>
<Link href="/onboarding">
<Button size="lg">Get Started Free</Button>
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t border-border">
<div className="container mx-auto max-w-6xl px-4 py-8">
<p className="text-center text-sm text-muted-foreground">
AutoDork Built for indie hackers and founders
</p>
</div>
</footer>
</div>
)
}

554
bun.lock Normal file
View File

@@ -0,0 +1,554 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "autodork",
"dependencies": {
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-slot": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.312.0",
"next": "14.1.0",
"openai": "^4.28.0",
"puppeteer": "^22.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4",
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@next/env": ["@next/env@14.1.0", "", {}, "sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@14.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@14.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@14.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@14.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@14.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@14.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@14.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ=="],
"@next/swc-win32-ia32-msvc": ["@next/swc-win32-ia32-msvc@14.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@14.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@puppeteer/browsers": ["@puppeteer/browsers@2.3.0", "", { "dependencies": { "debug": "^4.3.5", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.4.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@swc/helpers": ["@swc/helpers@0.5.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw=="],
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
"@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
"b4a": ["b4a@1.7.3", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q=="],
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
"bare-fs": ["bare-fs@4.5.3", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ=="],
"bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="],
"bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="],
"bare-stream": ["bare-stream@2.7.0", "", { "dependencies": { "streamx": "^2.21.0" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-buffer", "bare-events"] }, "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A=="],
"bare-url": ["bare-url@2.3.2", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"basic-ftp": ["basic-ftp@5.1.0", "", {}, "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chromium-bidi": ["chromium-bidi@0.6.3", "", { "dependencies": { "mitt": "3.0.1", "urlpattern-polyfill": "10.0.0", "zod": "3.23.8" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"devtools-protocol": ["devtools-protocol@0.0.1312386", "", {}, "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"lucide-react": ["lucide-react@0.312.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-3UZsqyswRXjW4t+nw+InICewSimjPKHuSxiFYqTshv9xkK3tPPntXk/lvXc9pKlXIxm3v9WKyoxcrB6YHhP+dg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"next": ["next@14.1.0", "", { "dependencies": { "@next/env": "14.1.0", "@swc/helpers": "0.5.2", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", "postcss": "8.4.31", "styled-jsx": "5.1.1" }, "optionalDependencies": { "@next/swc-darwin-arm64": "14.1.0", "@next/swc-darwin-x64": "14.1.0", "@next/swc-linux-arm64-gnu": "14.1.0", "@next/swc-linux-arm64-musl": "14.1.0", "@next/swc-linux-x64-gnu": "14.1.0", "@next/swc-linux-x64-musl": "14.1.0", "@next/swc-win32-arm64-msvc": "14.1.0", "@next/swc-win32-ia32-msvc": "14.1.0", "@next/swc-win32-x64-msvc": "14.1.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"puppeteer": ["puppeteer@22.15.0", "", { "dependencies": { "@puppeteer/browsers": "2.3.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1312386", "puppeteer-core": "22.15.0" }, "bin": { "puppeteer": "lib/esm/puppeteer/node/cli.js" } }, "sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q=="],
"puppeteer-core": ["puppeteer-core@22.15.0", "", { "dependencies": { "@puppeteer/browsers": "2.3.0", "chromium-bidi": "0.6.3", "debug": "^4.3.6", "devtools-protocol": "0.0.1312386", "ws": "^8.18.0" } }, "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
"streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"styled-jsx": ["styled-jsx@5.1.1", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" } }, "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
"tar-fs": ["tar-fs@3.1.1", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
"text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"unbzip2-stream": ["unbzip2-stream@1.4.3", "", { "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" } }, "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"urlpattern-polyfill": ["urlpattern-polyfill@10.0.0", "", {}, "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"chromium-bidi/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
}
}

17
components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

121
components/sidebar.tsx Normal file
View File

@@ -0,0 +1,121 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
LayoutDashboard,
Search,
Settings,
HelpCircle,
LogOut,
Sparkles,
Target
} from 'lucide-react'
interface SidebarProps {
productName?: string
}
export function Sidebar({ productName }: SidebarProps) {
const pathname = usePathname()
const routes = [
{
label: 'Dashboard',
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',
},
]
return (
<div className="flex h-full w-64 flex-col border-r border-border bg-card">
{/* Logo */}
<div className="flex h-14 items-center border-b border-border px-4">
<Link href="/" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Sparkles className="h-4 w-4" />
</div>
<span className="font-semibold">AutoDork</span>
</Link>
</div>
{/* Product Name */}
{productName && (
<div className="px-4 py-3 border-b border-border">
<p className="text-xs text-muted-foreground uppercase tracking-wide">Analyzing</p>
<p className="text-sm font-medium truncate">{productName}</p>
</div>
)}
{/* Main Nav */}
<ScrollArea className="flex-1 py-4">
<nav className="space-y-1 px-2">
{routes.map((route) => (
<Link
key={route.href}
href={route.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
route.active
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<route.icon className="h-4 w-4" />
{route.label}
</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 */}
<div className="border-t border-border p-4">
<Link href="/">
<Button variant="ghost" className="w-full justify-start gap-2 text-muted-foreground">
<LogOut className="h-4 w-4" />
Exit
</Button>
</Link>
</div>
</div>
)
}

59
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

36
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

56
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

79
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

122
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

25
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
HTMLLabelElement,
React.ComponentPropsWithoutRef<"label"> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = "Label"
export { Label }

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical"
decorative?: boolean
}
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<div
ref={ref}
role={decorative ? "none" : "separator"}
aria-orientation={decorative ? undefined : orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = "Separator"
export { Separator }

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

28
components/ui/slider.tsx Normal file
View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

114
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

55
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

261
lib/analysis-pipeline.ts Normal file
View File

@@ -0,0 +1,261 @@
import OpenAI from 'openai'
import type {
ScrapedContent,
EnhancedProductAnalysis,
Feature,
Problem,
Persona,
Keyword,
UseCase,
Competitor,
DorkQuery
} from './types'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
})
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',
messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: prompt }],
temperature,
max_tokens: 4000
})
const content = response.choices[0].message.content || '{}'
const codeBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/)
const jsonMatch = content.match(/(\{[\s\S]*\})/)
let jsonStr: string
if (codeBlockMatch && codeBlockMatch[1]) jsonStr = codeBlockMatch[1].trim()
else if (jsonMatch && jsonMatch[1]) jsonStr = jsonMatch[1].trim()
else jsonStr = content.trim()
try { return JSON.parse(jsonStr) as T }
catch (e) {
console.error('Failed to parse JSON:', jsonStr.substring(0, 200))
throw new Error('Invalid JSON response from AI')
}
}
async function extractFeatures(content: ScrapedContent): Promise<Feature[]> {
const systemPrompt = `Extract EVERY feature from website content. Be exhaustive.`
const prompt = `Extract features from:
Title: ${content.title}
Description: ${content.metaDescription}
Headings: ${content.headings.slice(0, 15).join('\n')}
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.`
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".`
const prompt = `Identify 5-6 real competitors for: ${content.title}
Description: ${content.metaDescription}
Return EXACT JSON format:
{
"competitors": [
{
"name": "Asana",
"differentiator": "Why this product is better",
"theirStrength": "What they do well",
"switchTrigger": "Why users switch",
"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 => ({
...c,
name: c.name.replace(/^Competitor\s+[A-Z]$/i, 'Alternative Solution').replace(/^Generic\s+/i, '')
})).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.`
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 prompt = `Generate 60-80 keywords for: ${content.title}
Features: ${featuresText}
Competitors: ${competitorNames}
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
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"}]}
Generate 20+ differentiator keywords comparing to: ${competitorNames}`
const result = await aiGenerate<{ keywords: Keyword[] }>(prompt, systemPrompt, 0.5)
// Sort: differentiators first, then by word count
return result.keywords.sort((a, b) => {
const aDiff = a.type === 'differentiator' ? 0 : 1
const bDiff = b.type === 'differentiator' ? 0 : 1
if (aDiff !== bDiff) return aDiff - bDiff
const aWords = a.term.split(/\s+/).length
const bWords = b.term.split(/\s+/).length
if (aWords !== bWords) return aWords - bWords
return a.term.length - b.term.length
}).slice(0, 80)
}
async function identifyProblems(features: Feature[], content: ScrapedContent): 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": ["..."]}]}`
const result = await aiGenerate<{ problems: Problem[] }>(prompt, systemPrompt, 0.4)
return result.problems
}
async function generatePersonas(content: ScrapedContent, problems: Problem[]): 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": ["..."]}]}`
const result = await aiGenerate<{ personas: Persona[] }>(prompt, systemPrompt, 0.5)
return result.personas
}
async function generateUseCases(features: Feature[], personas: Persona[], problems: Problem[]): 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": ["..."]}]}`
const result = await aiGenerate<{ useCases: UseCase[] }>(prompt, systemPrompt, 0.5)
return result.useCases
}
function generateDorkQueries(keywords: Keyword[], problems: Problem[], useCases: UseCase[], competitors: Competitor[]): DorkQuery[] {
const queries: DorkQuery[] = []
const topKeywords = keywords.slice(0, 20).map(k => k.term)
const topProblems = problems.slice(0, 5).map(p => p.problem)
const competitorNames = competitors.map(c => c.name).filter(n => n.length > 1)
// Differentiation queries (HIGH PRIORITY)
competitorNames.forEach(comp => {
queries.push({
query: `site:reddit.com "${comp}" ("alternative" OR "switching from" OR "moving away from")`,
platform: 'reddit',
intent: 'alternative',
priority: 'high'
})
queries.push({
query: `site:reddit.com "${comp}" ("better than" OR "vs" OR "versus" OR "compared to")`,
platform: 'reddit',
intent: 'comparison',
priority: 'high'
})
})
// Keyword-based queries
const redditIntents = [
{ template: 'site:reddit.com "{term}" ("looking for" OR "recommendation")', intent: 'looking-for' as const },
{ template: 'site:reddit.com "{term}" ("frustrated" OR "hate" OR "sucks")', intent: 'frustrated' as const },
{ template: 'site:reddit.com "{term}" ("tired of" OR "fed up")', intent: 'frustrated' as const },
]
topKeywords.slice(0, 10).forEach(term => {
redditIntents.forEach(({ template, intent }) => {
queries.push({ query: template.replace('{term}', term), platform: 'reddit', intent, priority: intent === 'frustrated' ? 'high' : 'medium' })
})
})
// Problem-based queries
topProblems.forEach(problem => {
queries.push({ query: `site:reddit.com "${problem}" ("how to" OR "solution")`, platform: 'reddit', intent: 'problem-solving', priority: 'high' })
})
// Hacker News
topKeywords.slice(0, 8).forEach(term => {
queries.push({ query: `site:news.ycombinator.com "Ask HN" "${term}"`, platform: 'hackernews', intent: 'looking-for', priority: 'high' })
})
// Indie Hackers
topKeywords.slice(0, 6).forEach(term => {
queries.push({ query: `site:indiehackers.com "${term}" ("looking for" OR "need")`, platform: 'indiehackers', intent: 'looking-for', priority: 'medium' })
})
return queries
}
export async function performDeepAnalysis(content: ScrapedContent): Promise<EnhancedProductAnalysis> {
console.log('🔍 Starting deep analysis...')
console.log(' 📦 Pass 1: Features...')
const features = await extractFeatures(content)
console.log(`${features.length} features`)
console.log(' 🏆 Pass 2: Competitors...')
const competitors = await identifyCompetitors(content)
console.log(`${competitors.length} competitors: ${competitors.map(c => c.name).join(', ')}`)
console.log(' 🔑 Pass 3: Keywords...')
const keywords = await generateKeywords(features, content, competitors)
console.log(`${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`)
console.log(' 💡 Pass 5: Use cases...')
const useCases = await generateUseCases(features, personas, problems)
console.log(`${useCases.length} use cases`)
console.log(' 🔎 Pass 6: Dork queries...')
const dorkQueries = generateDorkQueries(keywords, problems, useCases, competitors)
console.log(`${dorkQueries.length} queries`)
const productName = content.title.split(/[\|\-–—:]/)[0].trim()
const tagline = content.metaDescription.split('.')[0]
return {
productName,
tagline,
description: content.metaDescription,
category: '',
positioning: '',
features,
problemsSolved: problems,
personas,
keywords,
useCases,
competitors,
dorkQueries,
scrapedAt: new Date().toISOString(),
analysisVersion: '2.1-optimized'
}
}

293
lib/openai.ts Normal file
View File

@@ -0,0 +1,293 @@
import OpenAI from 'openai'
import type { ProductAnalysis, ScrapedContent, Opportunity } from './types'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
})
export async function analyzeProduct(content: ScrapedContent): Promise<ProductAnalysis> {
const prompt = `Analyze this website content and extract structured product information.
Website URL: ${content.url}
Page Title: ${content.title}
Meta Description: ${content.metaDescription}
Headings Found:
${content.headings.slice(0, 10).join('\n')}
Key Paragraphs:
${content.paragraphs.slice(0, 8).join('\n\n')}
Feature List Items:
${content.featureList.slice(0, 10).join('\n')}
Navigation/Links:
${content.links.slice(0, 8).join(', ')}
Based on this content, provide a comprehensive product analysis in JSON format:
{
"productName": "The exact product name",
"tagline": "A compelling one-sentence tagline",
"description": "2-3 sentence description of what the product does",
"features": ["Feature 1", "Feature 2", "Feature 3"],
"problemsSolved": ["Problem 1", "Problem 2", "Problem 3"],
"targetAudience": ["Audience segment 1", "Audience segment 2"],
"valuePropositions": ["Value prop 1", "Value prop 2", "Value prop 3"],
"keywords": ["keyword1", "keyword2", "keyword3"]
}
Guidelines:
- Product name should be the actual brand/product name, not generic
- Features should be specific capabilities the product offers
- Problems solved should be pain points customers have before using this
- Target audience should be specific personas
- Value propositions should explain WHY someone should care
- Keywords should be terms people search for when looking for this solution
Return ONLY the JSON object, no markdown formatting.`
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: 'You are a product marketing expert who analyzes websites and extracts structured product information. You are thorough and accurate.'
},
{
role: 'user',
content: prompt
}
],
temperature: 0.3,
max_tokens: 1500
})
const content_text = response.choices[0].message.content || '{}'
// Extract JSON from potential markdown
const jsonMatch = content_text.match(/\{[\s\S]*\}/)
const jsonStr = jsonMatch ? jsonMatch[0] : content_text
const analysis = JSON.parse(jsonStr)
return {
productName: analysis.productName || content.title,
tagline: analysis.tagline || '',
description: analysis.description || content.metaDescription,
features: analysis.features || [],
problemsSolved: analysis.problemsSolved || [],
targetAudience: analysis.targetAudience || [],
valuePropositions: analysis.valuePropositions || [],
keywords: analysis.keywords || [],
scrapedAt: new Date().toISOString()
}
}
export async function findOpportunities(analysis: ProductAnalysis): Promise<Opportunity[]> {
// Generate dork queries based on the analysis
const keywords = analysis.keywords.slice(0, 5)
const problems = analysis.problemsSolved.slice(0, 3)
const dorkQueries = [
...keywords.map(k => `site:reddit.com "${k}" ("looking for" OR "need" OR "frustrated" OR "problem")`),
...keywords.map(k => `site:reddit.com "${k}" ("alternative to" OR "tired of" OR "sucks")`),
...keywords.map(k => `site:reddit.com "${k}" ("recommendation" OR "what do you use" OR "suggestions")`),
...problems.map(p => `site:reddit.com "${p}" ("how to" OR "help" OR "solution")`),
...keywords.map(k => `site:news.ycombinator.com "${k}" ("Ask HN" OR "Show HN")`),
...keywords.map(k => `site:indiehackers.com "${k}" ("looking for" OR "need")`),
]
const opportunities: Opportunity[] = []
// Limit to top queries to avoid rate limits
for (const query of dorkQueries.slice(0, 6)) {
try {
const results = await searchGoogle(query, 8)
for (const result of results) {
const opportunity = await analyzeOpportunity(result, analysis)
if (opportunity.relevanceScore >= 0.5) {
opportunities.push(opportunity)
}
}
} catch (e) {
console.error('Search failed:', e)
}
}
// Sort by relevance and dedupe
const seen = new Set<string>()
return opportunities
.filter(o => {
if (seen.has(o.url)) return false
seen.add(o.url)
return true
})
.sort((a, b) => b.relevanceScore - a.relevanceScore)
.slice(0, 15)
}
async function analyzeOpportunity(result: SearchResult, product: ProductAnalysis): Promise<Opportunity> {
const prompt = `Rate how relevant this forum post is for the following product.
Product: ${product.productName}
Product Description: ${product.description}
Product Features: ${product.features.join(', ')}
Problems Solved: ${product.problemsSolved.join(', ')}
Forum Post Title: ${result.title}
Forum Post Snippet: ${result.snippet}
Source: ${result.source}
Rate on a scale of 0-1 how much this post indicates someone who could benefit from the product.
Identify specific pain points mentioned.
Suggest a helpful, non-spammy way to engage.
Return JSON:
{
"relevanceScore": 0.0-1.0,
"painPoints": ["pain point 1", "pain point 2"],
"suggestedApproach": "Suggested message or approach"
}`
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: 'You are a sales researcher. Analyze forum posts for product fit. Be honest about relevance.'
},
{
role: 'user',
content: prompt
}
],
temperature: 0.3,
max_tokens: 400
})
const content = response.choices[0].message.content || '{}'
const jsonMatch = content.match(/\{[\s\S]*\}/)
const jsonStr = jsonMatch ? jsonMatch[0] : content
const analysis = JSON.parse(jsonStr)
return {
title: result.title,
url: result.url,
source: result.source,
snippet: result.snippet.slice(0, 300),
relevanceScore: analysis.relevanceScore || 0,
painPoints: analysis.painPoints || [],
suggestedApproach: analysis.suggestedApproach || ''
}
} catch (e) {
// Fallback simple analysis
const content = (result.title + ' ' + result.snippet).toLowerCase()
const overlap = product.keywords.filter(k => content.includes(k.toLowerCase())).length
const relevance = Math.min(overlap / Math.max(product.keywords.length * 0.5, 1), 1)
return {
title: result.title,
url: result.url,
source: result.source,
snippet: result.snippet.slice(0, 300),
relevanceScore: relevance,
painPoints: ['Related to product domain'],
suggestedApproach: 'Share relevant insights about their problem'
}
}
}
interface SearchResult {
title: string
url: string
snippet: string
source: string
}
async function searchGoogle(query: string, num: number): Promise<SearchResult[]> {
// Try Serper first
if (process.env.SERPER_API_KEY) {
try {
const results = await searchSerper(query, num)
if (results.length > 0) return results
} catch (e) {
console.error('Serper search failed:', e)
}
}
// Fallback to direct fetch
return searchDirect(query, num)
}
async function searchSerper(query: string, num: number): Promise<SearchResult[]> {
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 })
})
if (!response.ok) throw new Error('Serper API error')
const data = await response.json()
return (data.organic || []).map((r: any) => ({
title: r.title,
url: r.link,
snippet: r.snippet,
source: getSource(r.link)
}))
}
async function searchDirect(query: string, num: number): Promise<SearchResult[]> {
const encodedQuery = encodeURIComponent(query)
const url = `https://www.google.com/search?q=${encodedQuery}&num=${num}`
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()
// Simple regex-based parsing to avoid cheerio issues
const results: SearchResult[] = []
// Extract search results using regex
const resultBlocks = html.match(/<div class="g"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/g) || []
for (const block of resultBlocks.slice(0, num)) {
const titleMatch = block.match(/<h3[^>]*>(.*?)<\/h3>/)
const linkMatch = block.match(/<a href="([^"]+)"/)
const snippetMatch = block.match(/<div class="VwiC3b[^"]*"[^>]*>(.*?)<\/div>/)
if (titleMatch && linkMatch) {
const title = titleMatch[1].replace(/<[^>]+>/g, '')
const link = linkMatch[1]
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '') : ''
results.push({
title,
url: link,
snippet,
source: getSource(link)
})
}
}
return results
}
function getSource(url: string): string {
if (url.includes('reddit.com')) return 'Reddit'
if (url.includes('news.ycombinator.com')) return 'Hacker News'
if (url.includes('indiehackers.com')) return 'Indie Hackers'
if (url.includes('quora.com')) return 'Quora'
if (url.includes('twitter.com') || url.includes('x.com')) return 'Twitter/X'
if (url.includes('stackexchange.com') || url.includes('stackoverflow.com')) return 'Stack Exchange'
return 'Other'
}

250
lib/query-generator.ts Normal file
View File

@@ -0,0 +1,250 @@
import type {
EnhancedProductAnalysis,
SearchConfig,
GeneratedQuery,
SearchStrategy,
PlatformId
} from './types'
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 }> {
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 }
}
}
export function generateSearchQueries(
analysis: EnhancedProductAnalysis,
config: SearchConfig
): GeneratedQuery[] {
const queries: GeneratedQuery[] = []
const enabledPlatforms = config.platforms.filter(p => p.enabled)
enabledPlatforms.forEach(platform => {
config.strategies.forEach(strategy => {
const strategyQueries = buildStrategyQueries(strategy, analysis, platform.id)
queries.push(...strategyQueries)
})
})
return sortAndDedupeQueries(queries).slice(0, config.maxResults || 50)
}
function buildStrategyQueries(
strategy: SearchStrategy,
analysis: EnhancedProductAnalysis,
platform: PlatformId
): GeneratedQuery[] {
switch (strategy) {
case 'direct-keywords':
return buildDirectKeywordQueries(analysis, platform)
case 'problem-pain':
return buildProblemQueries(analysis, platform)
case 'competitor-alternative':
return buildCompetitorQueries(analysis, platform)
case 'how-to':
return buildHowToQueries(analysis, platform)
case 'emotional-frustrated':
return buildEmotionalQueries(analysis, platform)
case 'comparison':
return buildComparisonQueries(analysis, platform)
case 'recommendation':
return buildRecommendationQueries(analysis, platform)
default:
return []
}
}
function buildDirectKeywordQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
const keywords = analysis.keywords
.filter(k => k.type === 'product' || k.type === 'feature')
.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")']
}
return keywords.flatMap(kw =>
(templates[platform] || templates.reddit).map(template => ({
query: buildPlatformQuery(platform, `"${kw.term}" ${template}`),
platform,
strategy: 'direct-keywords' as SearchStrategy,
priority: 3,
expectedIntent: 'looking'
}))
)
}
function buildProblemQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
const highSeverityProblems = analysis.problemsSolved
.filter(p => p.severity === 'high')
.slice(0, 5)
return highSeverityProblems.flatMap(problem => [
{
query: buildPlatformQuery(platform, `"${problem.problem}" ("how to" OR "fix" OR "solve")`),
platform,
strategy: 'problem-pain',
priority: 5,
expectedIntent: 'frustrated'
},
...problem.searchTerms.slice(0, 2).map(term => ({
query: buildPlatformQuery(platform, `"${term}"`),
platform,
strategy: 'problem-pain',
priority: 4,
expectedIntent: 'frustrated'
}))
])
}
function buildCompetitorQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
const competitors = analysis.competitors.slice(0, 5)
return competitors.flatMap(comp => [
{
query: buildPlatformQuery(platform, `"${comp.name}" ("alternative" OR "switching from" OR "moving away")`),
platform,
strategy: 'competitor-alternative',
priority: 5,
expectedIntent: 'comparing'
},
{
query: buildPlatformQuery(platform, `"${comp.name}" ("vs" OR "versus" OR "compared to" OR "better than")`),
platform,
strategy: 'competitor-alternative',
priority: 4,
expectedIntent: 'comparing'
},
{
query: buildPlatformQuery(platform, `"${comp.name}" ("frustrated" OR "disappointed" OR "problems with")`),
platform,
strategy: 'competitor-alternative',
priority: 5,
expectedIntent: 'frustrated'
}
])
}
function buildHowToQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): 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")`),
platform,
strategy: 'how-to',
priority: 2,
expectedIntent: 'learning'
}))
}
function buildEmotionalQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): GeneratedQuery[] {
const keywords = analysis.keywords
.filter(k => k.type === 'product' || k.type === 'problem')
.slice(0, 5)
const emotionalTerms = ['frustrated', 'hate', 'sucks', 'terrible', 'annoying', 'fed up', 'tired of']
return keywords.flatMap(kw =>
emotionalTerms.slice(0, 3).map(term => ({
query: buildPlatformQuery(platform, `"${kw.term}" ${term}`),
platform,
strategy: 'emotional-frustrated',
priority: 4,
expectedIntent: 'frustrated'
}))
)
}
function buildComparisonQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): 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,
strategy: 'comparison',
priority: 3,
expectedIntent: 'comparing'
}))
}
function buildRecommendationQueries(analysis: EnhancedProductAnalysis, platform: PlatformId): 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,
strategy: 'recommendation',
priority: 3,
expectedIntent: 'recommending'
}))
}
function buildPlatformQuery(platform: PlatformId, query: string): string {
const siteOperators: Record<PlatformId, 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'
}
return `${siteOperators[platform]} ${query}`
}
function sortAndDedupeQueries(queries: GeneratedQuery[]): GeneratedQuery[] {
// Sort by priority (high first)
const sorted = queries.sort((a, b) => b.priority - a.priority)
// Deduplicate by query string
const seen = new Set<string>()
return sorted.filter(q => {
const normalized = q.query.toLowerCase().replace(/\s+/g, ' ')
if (seen.has(normalized)) return false
seen.add(normalized)
return true
})
}
export function estimateSearchTime(queryCount: number, platforms: PlatformId[]): number {
const avgRateLimit = 25 // requests per minute
return Math.ceil(queryCount / avgRateLimit)
}

137
lib/scraper.ts Normal file
View File

@@ -0,0 +1,137 @@
import puppeteer from 'puppeteer'
import type { ScrapedContent } from './types'
export class ScrapingError extends Error {
constructor(message: string, public code: string) {
super(message)
this.name = 'ScrapingError'
}
}
export async function scrapeWebsite(url: string): Promise<ScrapedContent> {
let validatedUrl = url
if (!url.startsWith('http')) {
validatedUrl = `https://${url}`
}
try {
new URL(validatedUrl)
} catch {
throw new ScrapingError('Invalid URL format. Please enter a valid website URL.', 'INVALID_URL')
}
let browser
try {
browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
})
const page = await browser.newPage()
await page.setViewport({ width: 1920, height: 1080 })
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')
await page.goto(validatedUrl, {
waitUntil: 'networkidle2',
timeout: 30000
})
const extractedContent = await page.evaluate(() => {
const title = document.title || document.querySelector('h1')?.textContent || ''
const metaDesc = document.querySelector('meta[name="description"]')?.getAttribute('content') ||
document.querySelector('meta[property="og:description"]')?.getAttribute('content') || ''
const headings: string[] = []
document.querySelectorAll('h1, h2, h3').forEach(el => {
const text = el.textContent?.trim()
if (text && text.length > 5 && text.length < 200) headings.push(text)
})
const paragraphs: string[] = []
document.querySelectorAll('p').forEach(el => {
const text = el.textContent?.trim()
if (text && text.length > 30 && text.length < 500 && !text.includes('{')) {
paragraphs.push(text)
}
})
const featureList: string[] = []
document.querySelectorAll('ul li, ol li').forEach(el => {
const text = el.textContent?.trim()
if (text && text.length > 10 && text.length < 200) featureList.push(text)
})
const links: string[] = []
document.querySelectorAll('a[href^="/"], a[href^="./"]').forEach(el => {
const text = el.textContent?.trim()
if (text && text.length > 3 && text.length < 50) links.push(text)
})
// Get all visible text for raw analysis
const bodyText = document.body.innerText || ''
return {
title,
metaDescription: metaDesc,
headings,
paragraphs,
featureList,
links,
rawText: bodyText
}
})
return {
url: validatedUrl,
title: extractedContent.title,
metaDescription: extractedContent.metaDescription,
headings: [...new Set(extractedContent.headings)].slice(0, 20),
paragraphs: [...new Set(extractedContent.paragraphs)].slice(0, 30),
featureList: [...new Set(extractedContent.featureList)].slice(0, 20),
links: [...new Set(extractedContent.links)].slice(0, 15),
rawText: extractedContent.rawText.slice(0, 10000) // Limit raw text
}
} catch (error: any) {
console.error('Scraping error:', error)
if (error.message?.includes('ERR_NAME_NOT_RESOLVED') || error.message?.includes('net::ERR')) {
throw new ScrapingError(
`Could not reach ${validatedUrl}. Please check the URL or try entering your product description manually.`,
'DNS_ERROR'
)
}
if (error.message?.includes('timeout')) {
throw new ScrapingError(
'The website took too long to respond. Please try again or enter your product description manually.',
'TIMEOUT'
)
}
throw new ScrapingError(
'Failed to scrape the website. Please try again or enter your product description manually.',
'UNKNOWN'
)
} finally {
if (browser) await browser.close()
}
}
export async function analyzeFromText(
productName: string,
description: string,
features: string
): Promise<ScrapedContent> {
return {
url: 'manual-input',
title: productName,
metaDescription: description,
headings: [productName, 'Features', 'Benefits'],
paragraphs: [description, features],
featureList: features.split('\n').filter(f => f.trim()),
links: [],
rawText: `${productName}\n\n${description}\n\n${features}`
}
}

256
lib/search-executor.ts Normal file
View File

@@ -0,0 +1,256 @@
import type { GeneratedQuery, Opportunity, EnhancedProductAnalysis } from './types'
interface SearchResult {
title: string
url: string
snippet: string
platform: string
raw?: any
}
export async function executeSearches(
queries: GeneratedQuery[],
onProgress?: (progress: { current: number; total: number; platform: string }) => void
): Promise<SearchResult[]> {
const results: SearchResult[] = []
// Group by platform
const byPlatform = new Map<string, GeneratedQuery[]>()
queries.forEach(q => {
if (!byPlatform.has(q.platform)) byPlatform.set(q.platform, [])
byPlatform.get(q.platform)!.push(q)
})
let completed = 0
for (const [platform, platformQueries] of byPlatform) {
console.log(`Searching ${platform}: ${platformQueries.length} queries`)
for (const query of platformQueries) {
try {
const searchResults = await executeSingleSearch(query)
results.push(...searchResults)
completed++
onProgress?.({ current: completed, total: queries.length, platform })
// Rate limiting - 1 second between requests
await delay(1000)
} catch (err) {
console.error(`Search failed for ${platform}:`, err)
}
}
}
return results
}
async function executeSingleSearch(query: GeneratedQuery): Promise<SearchResult[]> {
// Use Serper API if available
if (process.env.SERPER_API_KEY) {
return searchWithSerper(query)
}
// Fallback to direct scraping (less reliable)
return searchDirect(query)
}
async function searchWithSerper(query: GeneratedQuery): Promise<SearchResult[]> {
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.query,
num: 5,
gl: 'us',
hl: 'en'
})
})
if (!response.ok) {
throw new Error(`Serper API error: ${response.status}`)
}
const data = await response.json()
return (data.organic || []).map((r: any) => ({
title: r.title,
url: r.link,
snippet: r.snippet,
platform: query.platform,
raw: r
}))
}
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
): Opportunity[] {
const opportunities: Opportunity[] = []
const seen = new Set<string>()
for (const result of results) {
if (seen.has(result.url)) continue
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)
}
function scoreSingleOpportunity(
result: SearchResult,
analysis: EnhancedProductAnalysis
): Opportunity {
const content = (result.title + ' ' + result.snippet).toLowerCase()
// 1. Keyword matching (max 30 points)
const matchedKeywords = analysis.keywords.filter(k =>
content.includes(k.term.toLowerCase())
)
const keywordScore = Math.min(matchedKeywords.length * 5, 30)
// 2. Problem matching (max 25 points)
const matchedProblems = analysis.problemsSolved.filter(p =>
content.includes(p.problem.toLowerCase()) ||
p.searchTerms.some(t => content.includes(t.toLowerCase()))
)
const problemScore = matchedProblems.reduce((sum, p) =>
sum + (p.severity === 'high' ? 10 : p.severity === 'medium' ? 5 : 2), 0
)
const cappedProblemScore = Math.min(problemScore, 25)
// 3. Emotional intensity (max 20 points)
const emotionalTerms = [
'frustrated', 'hate', 'terrible', 'sucks', 'awful', 'nightmare',
'desperate', 'urgent', 'please help', 'dying', 'killing me',
'fed up', 'tired of', 'annoying', 'painful'
]
const emotionalMatches = emotionalTerms.filter(t => content.includes(t))
const emotionalScore = Math.min(emotionalMatches.length * 5, 20)
// 4. Competitor mention (max 15 points)
const competitorMentioned = analysis.competitors.some(c =>
content.includes(c.name.toLowerCase())
)
const competitorScore = competitorMentioned ? 15 : 0
// Detect intent
let intent: Opportunity['intent'] = 'looking'
if (content.includes('frustrated') || content.includes('hate') || emotionalMatches.length > 0) {
intent = 'frustrated'
} else if (content.includes('vs') || content.includes('compare') || content.includes('alternative')) {
intent = 'comparing'
} else if (content.includes('how to') || content.includes('tutorial')) {
intent = 'learning'
} else if (content.includes('recommend') || content.includes('suggest')) {
intent = 'recommending'
}
// Generate approach
const problemContext = matchedProblems[0]?.problem || 'their current challenges'
const suggestedApproach = generateApproach(intent, analysis.productName, problemContext)
const softPitch = intent === 'frustrated' || intent === 'learning'
const totalScore = (keywordScore + cappedProblemScore + emotionalScore + competitorScore) / 90
return {
id: Math.random().toString(36).substring(2, 15),
title: result.title,
url: result.url,
snippet: result.snippet.slice(0, 300),
platform: result.platform,
source: result.platform,
relevanceScore: Math.min(totalScore, 1),
emotionalIntensity: emotionalScore > 10 ? 'high' : emotionalScore > 5 ? 'medium' : 'low',
intent,
matchedKeywords: matchedKeywords.map(k => k.term),
matchedProblems: matchedProblems.map(p => p.problem),
suggestedApproach,
softPitch,
status: 'new',
scoringBreakdown: {
keywordMatches: keywordScore,
problemMatches: cappedProblemScore,
emotionalIntensity: emotionalScore,
competitorMention: competitorScore,
recency: 0,
engagement: 0
}
}
}
function generateApproach(
intent: Opportunity['intent'],
productName: string,
problemContext: string
): string {
switch (intent) {
case 'frustrated':
return `Empathize with their frustration about ${problemContext}. Share how ${productName} helps teams overcome this specific pain point.`
case 'comparing':
return `They're evaluating options. Highlight ${productName}'s unique approach to ${problemContext}. Be honest about trade-offs.`
case 'looking':
return `They're actively searching. Introduce ${productName} as purpose-built for ${problemContext}. Mention 2-3 specific features.`
case 'learning':
return `Provide genuine help with ${problemContext}. Mention ${productName} as the solution that worked for your team.`
case 'recommending':
return `Share a genuine recommendation for ${productName}. Focus on how it transformed ${problemContext} for your team.`
}
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

194
lib/types.ts Normal file
View File

@@ -0,0 +1,194 @@
// Enhanced Types for Deep Analysis
export type PlatformId = 'reddit' | 'twitter' | 'hackernews' | 'indiehackers' | 'quora' | 'stackoverflow' | 'linkedin'
export type SearchStrategy =
| 'direct-keywords'
| 'problem-pain'
| 'competitor-alternative'
| 'how-to'
| 'emotional-frustrated'
| 'comparison'
| 'recommendation'
export interface PlatformConfig {
id: PlatformId
name: string
icon: string
enabled: boolean
searchTemplate: string
rateLimit: number
}
export interface SearchConfig {
platforms: PlatformConfig[]
strategies: SearchStrategy[]
intensity: 'broad' | 'balanced' | 'targeted'
maxResults: number
timeFilter?: 'past-day' | 'past-week' | 'past-month' | 'past-year' | 'all'
}
export interface GeneratedQuery {
query: string
platform: PlatformId
strategy: SearchStrategy
priority: number
expectedIntent: string
}
export interface Opportunity {
id: string
title: string
url: string
snippet: string
platform: string
source: string
relevanceScore: number
emotionalIntensity: 'low' | 'medium' | 'high'
intent: 'frustrated' | 'looking' | 'comparing' | 'learning' | 'recommending'
matchedKeywords: string[]
matchedProblems: string[]
matchedPersona?: string
engagement?: {
upvotes?: number
comments?: number
views?: number
}
postedAt?: string
status: 'new' | 'viewed' | 'contacted' | 'responded' | 'converted' | 'ignored'
notes?: string
tags?: string[]
suggestedApproach: string
replyTemplate?: string
softPitch: boolean
scoringBreakdown?: {
keywordMatches: number
problemMatches: number
emotionalIntensity: number
competitorMention: number
recency: number
engagement: number
}
}
export interface Feature {
name: string
description: string
benefits: string[]
useCases: string[]
}
export interface Problem {
problem: string
severity: 'high' | 'medium' | 'low'
currentWorkarounds: string[]
emotionalImpact: string
searchTerms: string[]
}
export interface Persona {
name: string
role: string
companySize: string
industry: string
painPoints: string[]
goals: string[]
techSavvy: 'low' | 'medium' | 'high'
objections: string[]
searchBehavior: string[]
}
export interface Keyword {
term: string
type: 'product' | 'problem' | 'solution' | 'competitor' | 'feature' | 'longtail' | 'differentiator'
searchVolume: 'high' | 'medium' | 'low'
intent: 'informational' | 'navigational' | 'transactional'
funnel: 'awareness' | 'consideration' | 'decision'
emotionalIntensity: 'frustrated' | 'curious' | 'ready'
}
export interface UseCase {
scenario: string
trigger: string
emotionalState: string
currentWorkflow: string[]
desiredOutcome: string
alternativeProducts: string[]
whyThisProduct: string
churnRisk: string[]
}
export interface Competitor {
name: string
differentiator: string
theirStrength: string
switchTrigger: string
theirWeakness: string
}
export interface DorkQuery {
query: string
platform: 'reddit' | 'hackernews' | 'indiehackers' | 'twitter' | 'quora' | 'stackoverflow'
intent: 'looking-for' | 'frustrated' | 'alternative' | 'comparison' | 'problem-solving' | 'tutorial'
priority: 'high' | 'medium' | 'low'
}
export interface EnhancedProductAnalysis {
productName: string
tagline: string
description: string
category: string
positioning: string
features: Feature[]
problemsSolved: Problem[]
personas: Persona[]
keywords: Keyword[]
useCases: UseCase[]
competitors: Competitor[]
dorkQueries: DorkQuery[]
scrapedAt: string
analysisVersion: string
}
// Legacy types for backwards compatibility
export interface ProductAnalysis {
productName: string
tagline: string
description: string
features: string[]
problemsSolved: string[]
targetAudience: string[]
valuePropositions: string[]
keywords: string[]
scrapedAt: string
}
export interface ScrapedContent {
url: string
title: string
metaDescription: string
headings: string[]
paragraphs: string[]
featureList: string[]
links: string[]
rawText: string
}
export interface SearchResults {
productAnalysis: EnhancedProductAnalysis
totalFound: number
opportunities: Opportunity[]
searchStats: {
queriesUsed: number
platformsSearched: string[]
averageRelevance: number
}
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

8
next.config.js Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverComponentsExternalPackages: ['puppeteer']
}
}
module.exports = nextConfig

3459
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "autodork",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.312.0",
"next": "14.1.0",
"openai": "^4.28.0",
"puppeteer": "^22.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

79
tailwind.config.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { Config } from "tailwindcss"
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}
export default config

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}