From b060e7f008fb5480118eee79cdfc0cdb37a0703a Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Mon, 2 Feb 2026 15:58:45 +0000 Subject: [PATCH] initialised repo --- .env.example | 5 + .gitignore | 34 + README.md | 51 + app/(app)/dashboard/page.tsx | 516 +++++ app/(app)/layout.tsx | 22 + app/(app)/onboarding/page.tsx | 365 ++++ app/(app)/opportunities/page.tsx | 423 ++++ app/api/analyze-manual/route.ts | 59 + app/api/analyze/route.ts | 69 + app/api/opportunities/route.ts | 139 ++ app/api/search/route.ts | 245 +++ app/globals.css | 36 + app/layout.tsx | 24 + app/page.tsx | 121 ++ bun.lock | 554 +++++ components.json | 17 + components/sidebar.tsx | 121 ++ components/ui/alert.tsx | 59 + components/ui/badge.tsx | 36 + components/ui/button.tsx | 56 + components/ui/card.tsx | 79 + components/ui/checkbox.tsx | 30 + components/ui/collapsible.tsx | 11 + components/ui/dialog.tsx | 122 ++ components/ui/input.tsx | 25 + components/ui/label.tsx | 24 + components/ui/scroll-area.tsx | 48 + components/ui/separator.tsx | 32 + components/ui/skeleton.tsx | 15 + components/ui/slider.tsx | 28 + components/ui/table.tsx | 114 + components/ui/tabs.tsx | 55 + components/ui/textarea.tsx | 24 + lib/analysis-pipeline.ts | 261 +++ lib/openai.ts | 293 +++ lib/query-generator.ts | 250 +++ lib/scraper.ts | 137 ++ lib/search-executor.ts | 256 +++ lib/types.ts | 194 ++ lib/utils.ts | 6 + next.config.js | 8 + package-lock.json | 3459 ++++++++++++++++++++++++++++++ package.json | 40 + postcss.config.js | 6 + tailwind.config.ts | 79 + tsconfig.json | 26 + 46 files changed, 8574 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/(app)/dashboard/page.tsx create mode 100644 app/(app)/layout.tsx create mode 100644 app/(app)/onboarding/page.tsx create mode 100644 app/(app)/opportunities/page.tsx create mode 100644 app/api/analyze-manual/route.ts create mode 100644 app/api/analyze/route.ts create mode 100644 app/api/opportunities/route.ts create mode 100644 app/api/search/route.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 bun.lock create mode 100644 components.json create mode 100644 components/sidebar.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 lib/analysis-pipeline.ts create mode 100644 lib/openai.ts create mode 100644 lib/query-generator.ts create mode 100644 lib/scraper.ts create mode 100644 lib/search-executor.ts create mode 100644 lib/types.ts create mode 100644 lib/utils.ts create mode 100644 next.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0c1828 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Required: OpenAI API key +OPENAI_API_KEY=sk-... + +# Optional: Serper.dev API key for reliable Google search +SERPER_API_KEY=... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5eee52b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9e5192 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..c9994de --- /dev/null +++ b/app/(app)/dashboard/page.tsx @@ -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(null) + const [opportunities, setOpportunities] = useState([]) + const [loadingOpps, setLoadingOpps] = useState(false) + const [stats, setStats] = useState(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 + case 'alternative': return + case 'comparison': return + case 'problem-solving': return + default: return + } + } + + // 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 ( +
+
+

Dashboard

+

No product analyzed yet.

+ +
+
+ ) + } + + return ( +
+ {/* Centered container */} +
+ {/* Header */} +
+
+

{analysis.productName}

+

{analysis.tagline}

+
+
+ + +
+
+ + {/* Stats Cards */} + {stats && ( +
+ + +
{stats.features}
+
Features
+
+ + +
{stats.keywords}
+
Keywords
+
+ + +
{stats.personas}
+
Personas
+
+ + +
{stats.useCases}
+
Use Cases
+
+ + +
{stats.competitors}
+
Competitors
+
+ + +
{stats.dorkQueries}
+
Queries
+
+
+ )} + + {/* Main Tabs */} + + + Overview + Features + Personas + Keywords + + Opportunities {opportunities.length > 0 && `(${opportunities.length})`} + + + + {/* Overview Tab */} + + + + Product Description + + +

{analysis.description}

+
+
+ +
+ {/* Problems Solved */} + + + + + Problems Solved + + + + {analysis.problemsSolved.slice(0, 5).map((problem, i) => ( +
+
+ {problem.problem} + + {problem.severity} + +
+

+ Feeling: {problem.emotionalImpact} +

+
+ ))} +
+
+ + {/* Competitors */} + + + + + Competitive Landscape + + + + {analysis.competitors.slice(0, 4).map((comp, i) => ( +
+
{comp.name}
+

+ Your edge: {comp.differentiator} +

+

+ Their strength: {comp.theirStrength} +

+
+ ))} +
+
+
+
+ + {/* Features Tab */} + + {analysis.features.map((feature, i) => ( + + + + + {feature.name} + + {feature.description} + + +
+
+

Benefits

+
    + {feature.benefits.map((b, j) => ( +
  • + + {b} +
  • + ))} +
+
+
+

Use Cases

+
    + {feature.useCases.map((u, j) => ( +
  • + • {u} +
  • + ))} +
+
+
+
+
+ ))} +
+ + {/* Personas Tab */} + + {analysis.personas.map((persona, i) => ( + + +
+
+ + + {persona.name} + + {persona.role} • {persona.companySize} +
+ {persona.techSavvy} tech +
+
+ +
+

Pain Points

+
+ {persona.painPoints.map((p, j) => ( + + {p} + + ))} +
+
+
+

Goals

+
    + {persona.goals.map((g, j) => ( +
  • • {g}
  • + ))} +
+
+
+

Search Behavior

+
+ {persona.searchBehavior.map((s, j) => ( +
+ "{s}" +
+ ))} +
+
+
+
+ ))} +
+ + {/* Keywords Tab */} + + {/* Differentiation Keywords First */} + {differentiatorKeywords.length > 0 && ( + + + + + Differentiation Keywords + + + Keywords that highlight your competitive advantage + + + +
+ {differentiatorKeywords.map((kw, i) => ( + + {kw.term} + + ))} +
+
+
+ )} + + {/* All Keywords */} + + + All Keywords ({sortedKeywords.length}) + + Sorted by word count - single words first + + + +
+ {sortedKeywords.map((kw, i) => ( + + {kw.term} + + ))} +
+
+
+ + {/* Keywords by Type */} +
+ {['product', 'problem', 'solution', 'feature', 'competitor'].map(type => { + const typeKeywords = analysis.keywords.filter(k => k.type === type) + if (typeKeywords.length === 0) return null + return ( + + + {type} + + +
+ {typeKeywords.slice(0, 20).map((kw, i) => ( + + {kw.term} + + ))} +
+
+
+ ) + })} +
+
+ + {/* Opportunities Tab */} + + {opportunities.length > 0 ? ( + <> + {opportunities.map((opp) => ( + + +
+
+

{opp.title}

+
+ + {Math.round(opp.relevanceScore * 100)}% + +
+ +
+ {opp.source} + + {getIntentIcon(opp.intent)} + {opp.intent} + + {opp.matchedPersona && ( + + {opp.matchedPersona} + + )} +
+ +

+ {opp.snippet} +

+ + {opp.matchedKeywords.length > 0 && ( +
+ {opp.matchedKeywords.map((k, i) => ( + + {k} + + ))} +
+ )} + +
+

+ Suggested Approach +

+

{opp.suggestedApproach}

+
+ + + View Post + + +
+
+ ))} + + ) : ( + + + +

No opportunities yet

+

+ Click "Find Opportunities" to search for potential customers +

+ +
+
+ )} +
+
+
+
+ ) +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..bca8e5c --- /dev/null +++ b/app/(app)/layout.tsx @@ -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 ( +
+ +
+ {children} +
+
+ ) +} diff --git a/app/(app)/onboarding/page.tsx b/app/(app)/onboarding/page.tsx new file mode 100644 index 0000000..0ab941d --- /dev/null +++ b/app/(app)/onboarding/page.tsx @@ -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 ( +
+
+
+
+
+ +
+
+

Couldn't Reach Website

+

+ No problem! Tell us about your product and we'll analyze it manually. +

+
+ + {error && ( + + + {error} + + )} + + + + Describe Your Product + + Enter your product details and we'll extract the key information. + + + +
+ + setManualProductName(e.target.value)} + /> +
+ +
+ +