From 4c1d665f486c9ea45d44065b697c7a594a6ce3ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:24:41 +0000 Subject: [PATCH 01/25] =?UTF-8?q?fix:=20address=205=20UX=20issues=20?= =?UTF-8?q?=E2=80=94=20billing,=20chat,=20templates,=20idea=20board,=20err?= =?UTF-8?q?or=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tighten free tier: 1 repo, 1 analysis/mo, 1 blueprint view, 200 credits (was 2/2/3/500) - Billing page: show all 4 plans (Free, Pro, Scale, BYOK) side-by-side for easy comparison - Pattern Analyzer → App Idea Chat: conversational UI with chat bubbles, starter prompts, follow-up questions, and optional codebase grounding; new /api/app-idea-chat route - Templates page: add Create Template button (multi-step modal to combine blueprints) - Idea Board: restyle with status-colored cards, icon stats strip, New Analysis CTA - Analysis detail: fix overly broad catch that called notFound() on subscription/views table errors, keeping free-plan defaults instead --- app/api/app-idea-chat/route.ts | 166 +++++++++++ app/dashboard/analyses/[id]/page.tsx | 11 +- app/dashboard/templates/page.tsx | 62 +++-- app/pricing/page.tsx | 6 +- components/billing-client.tsx | 193 ++++++++++--- components/create-template-modal.tsx | 335 ++++++++++++++++++++++ components/dashboard-header.tsx | 2 +- components/idea-board.tsx | 175 ++++++++---- components/pattern-analyzer.tsx | 400 +++++++++++++++------------ lib/stripe.ts | 8 +- 10 files changed, 1036 insertions(+), 322 deletions(-) create mode 100644 app/api/app-idea-chat/route.ts create mode 100644 components/create-template-modal.tsx diff --git a/app/api/app-idea-chat/route.ts b/app/api/app-idea-chat/route.ts new file mode 100644 index 0000000..5e2c64d --- /dev/null +++ b/app/api/app-idea-chat/route.ts @@ -0,0 +1,166 @@ +import { NextRequest, NextResponse } from 'next/server' +import { Anthropic } from '@anthropic-ai/sdk' +import { + getAnalysisById, + getRepositoriesForAnalysis, + getBlueprintsByAnalysis, + getFilesByRepository, +} from '@/lib/queries' +import { getAnthropicModel } from '@/lib/anthropic-model' +import { getCurrentUser } from '@/lib/auth' +import { deductCredits, CREDITS } from '@/lib/credits' + +const anthropic = new Anthropic() + +export interface AppIdeaSuggestion { + name: string + tagline: string + description: string + type: string + difficulty: 'easy' | 'medium' | 'hard' + estimatedEffort: string + suggestedStack: string[] + monetizationAngle: string + whyNow: string +} + +export interface AppIdeaChatResponse { + reply: string + suggestions: AppIdeaSuggestion[] + followUpQuestions: string[] +} + +export interface ChatMessage { + role: 'user' | 'assistant' + content: string +} + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const { message, analysisId, history = [] } = (await request.json()) as { + message: string + analysisId?: string + history?: ChatMessage[] + } + + if (!message?.trim()) { + return NextResponse.json({ error: 'Message is required' }, { status: 400 }) + } + + const creditResult = await deductCredits( + user.id, + CREDITS.PATTERN_ANALYZER_COST, + 'pattern_analyzer', + { analysisId }, + ) + if (!creditResult.success) { + return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) + } + + // Optionally load codebase context + let codebaseContext = '' + if (analysisId) { + try { + const analysis = await getAnalysisById(analysisId) + if (analysis && analysis.status === 'complete') { + const [repositories, blueprints] = await Promise.all([ + getRepositoriesForAnalysis(analysisId), + getBlueprintsByAnalysis(analysisId), + ]) + + const allFiles = ( + await Promise.all(repositories.map((r) => getFilesByRepository(r.id))) + ).flat() + + const techCount: Record = {} + for (const file of allFiles) { + for (const tech of file.technologies) { + techCount[tech] = (techCount[tech] || 0) + 1 + } + } + const topTech = Object.entries(techCount) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([t]) => t) + + codebaseContext = ` +## Developer's codebase context +Repositories: ${repositories.map((r) => r.name).join(', ')} +Top technologies: ${topTech.join(', ')} +Total files: ${allFiles.length} +Existing blueprints: ${blueprints.slice(0, 5).map((b) => b.name).join(', ') || 'none yet'} +` + } + } catch { + // Codebase context optional — continue without it + } + } + + // Build conversation history for context + const conversationHistory = history.slice(-6).map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })) + + const systemPrompt = `You are an expert product strategist and startup advisor helping developers discover what apps to build. You're having a friendly, concise conversation to help them find the perfect project idea. + +${codebaseContext} + +When responding: +- Keep your reply conversational and under 100 words +- Suggest 2-4 concrete project ideas tailored to their request${codebaseContext ? ' and their codebase' : ''} +- Ask a relevant follow-up question to refine suggestions +- Be enthusiastic and actionable + +Always respond with valid JSON only (no markdown fences): +{ + "reply": "conversational response under 100 words", + "suggestions": [ + { + "name": "Project Name", + "tagline": "One punchy sentence", + "description": "2-3 sentences", + "type": "SaaS | CLI Tool | API | Dashboard | etc", + "difficulty": "easy | medium | hard", + "estimatedEffort": "e.g. 1–2 weeks", + "suggestedStack": ["tech1", "tech2"], + "monetizationAngle": "How to charge", + "whyNow": "Why this is timely" + } + ], + "followUpQuestions": ["Question 1?", "Question 2?"] +}` + + const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [ + ...conversationHistory, + { role: 'user', content: message }, + ] + + const response = await anthropic.messages.create({ + model: getAnthropicModel(), + max_tokens: 2048, + system: systemPrompt, + messages, + }) + + const raw = response.content[0].type === 'text' ? response.content[0].text.trim() : '' + const jsonText = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim() + + let parsed: AppIdeaChatResponse + try { + parsed = JSON.parse(jsonText) + } catch { + return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) + } + + return NextResponse.json(parsed) + } catch (error) { + console.error('[app-idea-chat] error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/dashboard/analyses/[id]/page.tsx b/app/dashboard/analyses/[id]/page.tsx index c862830..abce427 100644 --- a/app/dashboard/analyses/[id]/page.tsx +++ b/app/dashboard/analyses/[id]/page.tsx @@ -33,19 +33,22 @@ export default async function AnalysisDetailPage({ getRepositoriesForAnalysis(id), getBlueprintsByAnalysis(id), ]) + } catch { + notFound() + } - if (user) { + if (user) { + try { const [subscription, viewedIds] = await Promise.all([ getSubscriptionByGithubId(user.github_id), getUserViewedBlueprintIds(user.id), ]) userPlan = subscription?.plan || 'free' viewedBlueprintIds = viewedIds - // Check if in trial via Stripe (subscription status = 'trialing') isTrialing = subscription?.status === 'trialing' + } catch { + // Subscription/views table not available yet — use free defaults } - } catch { - notFound() } if (!analysis) { diff --git a/app/dashboard/templates/page.tsx b/app/dashboard/templates/page.tsx index 38d764b..c3949ba 100644 --- a/app/dashboard/templates/page.tsx +++ b/app/dashboard/templates/page.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { TemplateAssemblyCard } from '@/components/template-assembly-card' +import { CreateTemplateModal } from '@/components/create-template-modal' import { getAllTemplates, getFeaturedTemplates, type Template } from '@/lib/queries' export const dynamic = 'force-dynamic' @@ -42,29 +43,33 @@ async function TemplateHubContent() { if (setupRequired || !all.length) { return (
-
- - - -
-

Template Assembly Hub

-

Pre-built project combinations ready to assemble

+
+
+ + + +
+

Template Assembly Hub

+

Pre-built project combinations ready to assemble

+
+
-

No templates available yet

+

No templates yet

- Templates will be generated once you run an analysis on your repositories. + Create a template by combining blueprints from your analyses, or run an analysis to discover new blueprints.

- +
+ + +
) @@ -85,18 +90,21 @@ async function TemplateHubContent() {
{/* Header */}
-
- - - -
-

Template Assembly Hub

-

- Start building today from code you already have. Pre-configured templates combine your best pieces. -

+
+
+ + + +
+

Template Assembly Hub

+

+ Start building today from code you already have. Pre-configured templates combine your best pieces. +

+
+
{/* Feature Cards */} diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 8f26172..e0c8e70 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -15,9 +15,9 @@ const plans = [ description: 'Explore your codebase, no card needed', credits: PLANS.free.credits_per_month, features: [ - `Up to ${PLANS.free.repos_limit} repositories`, - `${PLANS.free.analyses_per_month} analyses per month`, - `${PLANS.free.blueprints_viewable} blueprint views`, + `${PLANS.free.repos_limit} repository`, + `${PLANS.free.analyses_per_month} analysis per month`, + `${PLANS.free.blueprints_viewable} blueprint view`, `${PLANS.free.credits_per_month} credits to start`, 'AI-powered app blueprints', 'Gap discovery & analysis', diff --git a/components/billing-client.tsx b/components/billing-client.tsx index 743ebc3..f8b6377 100644 --- a/components/billing-client.tsx +++ b/components/billing-client.tsx @@ -15,6 +15,8 @@ import { ExternalLink, Zap, Crown, + Rocket, + Key, } from 'lucide-react' interface BillingClientProps { @@ -32,13 +34,61 @@ interface BillingClientProps { isTrialing?: boolean } +const PLAN_CONFIGS = [ + { + id: 'free', + name: 'Free', + price: '$0', + period: 'forever', + description: 'Try it out', + icon: Zap, + features: ['1 repository', '1 analysis/month', '1 blueprint view', '200 credits'], + cta: null, + ctaHref: null, + }, + { + id: 'pro', + name: 'Pro', + price: '$19', + period: '/mo', + description: '7-day free trial', + icon: Crown, + features: ['Unlimited repos', 'Unlimited analyses', '3,000 credits/month', 'Scaffold generation', 'App Idea Chat', 'Build This App'], + cta: 'Start Free Trial', + ctaHref: null, // handled by handleUpgrade + highlighted: true, + }, + { + id: 'scale', + name: 'Scale', + price: '$49', + period: '/mo', + description: 'Power users & teams', + icon: Rocket, + features: ['Everything in Pro', '12,000 credits/month', 'Highest priority AI', 'Early access', 'Dedicated support'], + cta: 'Get Scale', + ctaHref: 'https://buy.stripe.com/3cIcN65VJ55g6nC9gkbjW00', + }, + { + id: 'byok', + name: 'BYOK', + price: '$9', + period: '/mo', + description: 'Your own API key', + icon: Key, + features: ['Unlimited repos', 'Unlimited analyses', 'Use own Anthropic/OpenAI key', 'No per-credit billing', 'Up to 90% cheaper'], + cta: 'Set Up BYOK', + ctaHref: '/dashboard/settings', + }, +] as const + export function BillingClient({ plan, planName, analysesUsed, analysesLimit, blueprintsUsed = 0, - blueprintsLimit = 2, + blueprintsLimit = 1, reposLimit, status, currentPeriodEnd, @@ -56,18 +106,14 @@ export function BillingClient({ const handleUpgrade = async () => { setCheckoutLoading(true) try { - console.log('[v0] Starting checkout request') const res = await fetch('/api/stripe/checkout', { method: 'POST' }) - console.log('[v0] Checkout response status:', res.status) const data = await res.json().catch(() => ({ error: 'Unexpected server error' })) - console.log('[v0] Checkout response data:', data) if (res.ok && data.url) { window.location.href = data.url } else { alert(data.error || 'Billing is not available right now. Please try again later.') } - } catch (error) { - console.error('[v0] Checkout error:', error) + } catch { alert('Could not connect to billing. Please check your connection and try again.') } finally { setCheckoutLoading(false) @@ -82,7 +128,7 @@ export function BillingClient({ if (res.ok && data.url) { window.location.href = data.url } else { - alert(data.error || 'Billing portal is not available right now. Please try again later.') + alert(data.error || 'Billing portal is not available right now.') } } catch { alert('Could not connect to billing. Please check your connection and try again.') @@ -98,7 +144,7 @@ export function BillingClient({

Manage your subscription and usage

- {/* Current plan */} + {/* Current plan + usage */}
@@ -136,13 +182,13 @@ export function BillingClient({
- {isPaid ? 'Unlimited repositories' : `Up to ${reposLimit} repositories`} + {isPaid ? 'Unlimited repositories' : `Up to ${reposLimit} repositor${reposLimit === 1 ? 'y' : 'ies'}`}
- {isPaid ? 'Unlimited analyses' : `${analysesLimit} analyses per month`} + {isPaid ? 'Unlimited analyses' : `${analysesLimit} analysis per month`}
{isPaid && ( @@ -162,20 +208,12 @@ export function BillingClient({
{isPaid && hasStripeCustomer ? ( ) : !isPaid ? ( ) : null} @@ -200,15 +238,11 @@ export function BillingClient({ {analysesLimit > 0 ? ( ) : ( -
-
-
+
)} {!isPaid && analysesLimit > 0 && usagePercent >= 80 && (

- {usagePercent >= 100 - ? 'You\'ve reached your monthly limit.' - : 'Approaching your monthly limit.'} + {usagePercent >= 100 ? "You've reached your monthly limit." : 'Approaching your monthly limit.'} {' '}

- {/* Blueprint views for free users */} {!isPaid && blueprintsLimit > 0 && (
@@ -245,11 +278,10 @@ export function BillingClient({

Unlock unlimited analyses

- Pro gives you unlimited analyses, unlimited repos, scaffold generation, and priority AI. + Pro gives you unlimited analyses, repos, scaffold generation, and priority AI.

@@ -259,28 +291,105 @@ export function BillingClient({
- {/* Credits Section for paid users */} + {/* Credits for paid users */} {isPaid && userId && (

Credits & Usage

-

Track your credits and see how they're used

+

Track your credits and see how they're used

)} - {/* Compare plans */} - {!isPaid && ( -
- + {/* All plans comparison */} +
+
+

All Plans

+

Compare plans and upgrade when you're ready

- )} +
+ {PLAN_CONFIGS.map((p) => { + const PlanIcon = p.icon + const isCurrent = plan === p.id + const highlighted = 'highlighted' in p && p.highlighted + + return ( + + {isCurrent && ( +
+ + Current Plan + +
+ )} + {highlighted && !isCurrent && ( +
+ + Most Popular + +
+ )} + +
+
+ +
+

{p.name}

+

{p.description}

+
+ +
+ {p.price} + {p.period} +
+ +
    + {p.features.map((feature) => ( +
  • + + + + {feature} +
  • + ))} +
+ + {!isCurrent && p.cta && ( + p.ctaHref ? ( + + ) : ( + + ) + )} + {isCurrent && ( +
+ ✓ Your current plan +
+ )} +
+ ) + })} +
+
) } diff --git a/components/create-template-modal.tsx b/components/create-template-modal.tsx new file mode 100644 index 0000000..05166bf --- /dev/null +++ b/components/create-template-modal.tsx @@ -0,0 +1,335 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Plus, Loader2, Sparkles, Check } from 'lucide-react' + +interface BlueprintOption { + id: string + name: string + description?: string + technologies: string[] +} + +interface AnalysisOption { + id: string + name: string + status: string +} + +export function CreateTemplateModal({ onCreated }: { onCreated?: () => void }) { + const [open, setOpen] = useState(false) + const [step, setStep] = useState<'select-analysis' | 'select-blueprints' | 'name'>('select-analysis') + const [analyses, setAnalyses] = useState([]) + const [blueprints, setBlueprints] = useState([]) + const [selectedAnalysisId, setSelectedAnalysisId] = useState('') + const [selectedBlueprintIds, setSelectedBlueprintIds] = useState([]) + const [templateName, setTemplateName] = useState('') + const [templateDescription, setTemplateDescription] = useState('') + const [loadingAnalyses, setLoadingAnalyses] = useState(false) + const [loadingBlueprints, setLoadingBlueprints] = useState(false) + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (open) { + fetchAnalyses() + } + }, [open]) + + const fetchAnalyses = async () => { + setLoadingAnalyses(true) + try { + const res = await fetch('/api/analyses') + if (!res.ok) throw new Error('Failed to load analyses') + const data: AnalysisOption[] = await res.json() + setAnalyses(data.filter((a) => a.status === 'complete')) + } catch { + setError('Could not load analyses') + } finally { + setLoadingAnalyses(false) + } + } + + const fetchBlueprints = async (analysisId: string) => { + setLoadingBlueprints(true) + setBlueprints([]) + setSelectedBlueprintIds([]) + try { + const res = await fetch(`/api/analyses/${analysisId}/blueprints`) + if (res.ok) { + const data: BlueprintOption[] = await res.json() + setBlueprints(data) + } else { + // Fallback: use analysis detail endpoint + const res2 = await fetch(`/api/analyses/${analysisId}`) + if (res2.ok) { + const analysis = await res2.json() + setBlueprints(analysis.blueprints || []) + } + } + } catch { + setError('Could not load blueprints for this analysis') + } finally { + setLoadingBlueprints(false) + } + } + + const handleAnalysisSelect = async (id: string) => { + setSelectedAnalysisId(id) + setError(null) + await fetchBlueprints(id) + setStep('select-blueprints') + } + + const toggleBlueprint = (id: string) => { + setSelectedBlueprintIds((prev) => + prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id], + ) + } + + const handleCreate = async () => { + if (!templateName.trim()) { + setError('Please enter a template name') + return + } + if (selectedBlueprintIds.length < 2) { + setError('Select at least 2 blueprints to combine') + return + } + + setCreating(true) + setError(null) + try { + const res = await fetch('/api/templates/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: templateName.trim(), + description: templateDescription.trim() || undefined, + blueprintIds: selectedBlueprintIds, + tier: 'standard', + }), + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || 'Failed to create template') + } + setOpen(false) + reset() + onCreated?.() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create template') + } finally { + setCreating(false) + } + } + + const reset = () => { + setStep('select-analysis') + setSelectedAnalysisId('') + setSelectedBlueprintIds([]) + setTemplateName('') + setTemplateDescription('') + setBlueprints([]) + setError(null) + } + + const handleClose = () => { + setOpen(false) + reset() + } + + return ( + <> + + + (v ? setOpen(true) : handleClose())}> + + + + + Create Template + + + Combine blueprints from a completed analysis into a reusable template. + + + +
+ {/* Step 1: Select analysis */} + {step === 'select-analysis' && ( +
+

Pick a completed analysis

+ {loadingAnalyses ? ( +
+ + Loading analyses... +
+ ) : analyses.length === 0 ? ( + +

+ No completed analyses found. Run an analysis first to generate blueprints. +

+
+ ) : ( +
+ {analyses.map((a) => ( + + ))} +
+ )} +
+ )} + + {/* Step 2: Select blueprints */} + {step === 'select-blueprints' && ( +
+
+

Select blueprints to combine

+ + {selectedBlueprintIds.length} selected (min 2) + +
+ + {loadingBlueprints ? ( +
+ + Loading blueprints... +
+ ) : blueprints.length === 0 ? ( + +

+ No blueprints found for this analysis. Run the analysis again or pick a different one. +

+
+ ) : ( +
+ {blueprints.map((bp) => { + const selected = selectedBlueprintIds.includes(bp.id) + return ( + + ) + })} +
+ )} + + {selectedBlueprintIds.length >= 2 && ( + + )} + + +
+ )} + + {/* Step 3: Name the template */} + {step === 'name' && ( +
+
+ + setTemplateName(e.target.value)} + /> +
+
+ + setTemplateDescription(e.target.value)} + /> +
+
+ Combining {selectedBlueprintIds.length} blueprints +
+ +
+ )} + + {error && ( +

{error}

+ )} +
+ + {step === 'name' && ( + + + + + )} +
+
+ + ) +} diff --git a/components/dashboard-header.tsx b/components/dashboard-header.tsx index a7f07e3..3b0b18e 100644 --- a/components/dashboard-header.tsx +++ b/components/dashboard-header.tsx @@ -16,7 +16,7 @@ const navItems = [ { href: '/dashboard/repositories', label: 'Repositories', icon: FolderGit2 }, { href: '/dashboard/analyses', label: 'Analyses', icon: Sparkles }, { href: '/dashboard/idea-board', label: 'Idea Board', icon: LayoutGrid }, - { href: '/dashboard/pattern-analyzer', label: 'Pattern Analyzer', icon: Cpu }, + { href: '/dashboard/pattern-analyzer', label: 'App Idea Chat', icon: Cpu }, { href: '/dashboard/billing', label: 'Billing', icon: CreditCard }, ] diff --git a/components/idea-board.tsx b/components/idea-board.tsx index 6f73b12..18a9555 100644 --- a/components/idea-board.tsx +++ b/components/idea-board.tsx @@ -16,6 +16,7 @@ import { Search, RefreshCw, LayoutGrid, + Plus, type LucideIcon, } from 'lucide-react' import type { Analysis } from '@/lib/queries' @@ -24,36 +25,41 @@ type StatusFilter = 'all' | Analysis['status'] const STATUS_META: Record< Analysis['status'], - { label: string; color: string; badgeClass: string; icon: LucideIcon } + { label: string; color: string; badgeClass: string; cardBorder: string; icon: LucideIcon } > = { pending: { label: 'Pending', color: 'text-muted-foreground', - badgeClass: 'bg-muted text-muted-foreground', + badgeClass: 'bg-muted text-muted-foreground border-0', + cardBorder: 'border-border/60', icon: Clock, }, scanning: { label: 'Scanning', - color: 'text-chart-1', - badgeClass: 'bg-chart-1/10 text-chart-1', + color: 'text-blue-500', + badgeClass: 'bg-blue-500/10 text-blue-500 border-0', + cardBorder: 'border-blue-500/30', icon: Loader2, }, analyzing: { label: 'Analyzing', color: 'text-chart-2', - badgeClass: 'bg-chart-2/10 text-chart-2', + badgeClass: 'bg-chart-2/10 text-chart-2 border-0', + cardBorder: 'border-chart-2/30', icon: Sparkles, }, complete: { label: 'Complete', color: 'text-chart-1', - badgeClass: 'bg-chart-1/10 text-chart-1', + badgeClass: 'bg-chart-1/10 text-chart-1 border-0', + cardBorder: 'border-chart-1/30', icon: CheckCircle2, }, failed: { label: 'Failed', color: 'text-destructive', - badgeClass: 'bg-destructive/10 text-destructive', + badgeClass: 'bg-destructive/10 text-destructive border-0', + cardBorder: 'border-destructive/30', icon: XCircle, }, } @@ -76,11 +82,17 @@ function AnalysisCard({ analysis }: { analysis: Analysis }) { : 0 return ( - +
-
- +
+

{analysis.name}

@@ -94,9 +106,6 @@ function AnalysisCard({ analysis }: { analysis: Analysis }) {
- {meta.label}
@@ -105,11 +114,13 @@ function AnalysisCard({ analysis }: { analysis: Analysis }) {
{analysis.analyzed_files} / {analysis.total_files} files - {progress}% + {progress}%
@@ -117,12 +128,14 @@ function AnalysisCard({ analysis }: { analysis: Analysis }) { )} {analysis.error_message && ( -

{analysis.error_message}

+

+ {analysis.error_message} +

)} - @@ -173,50 +186,87 @@ export function IdeaBoard() { {} as Record, ) + const completeCount = counts['complete'] || 0 + const failedCount = counts['failed'] || 0 + const inProgressCount = (counts['scanning'] || 0) + (counts['analyzing'] || 0) + return (
{/* Header */}
-
- +
+
+ +

Idea Board

-

All your analyses at a glance — track progress and review results.

+

All your analyses at a glance — track progress and review results.

+
+
+ +
-
{/* Stats strip */} {!loading && analyses.length > 0 && ( -
- {(Object.keys(STATUS_META) as Analysis['status'][]).map((status) => { - const meta = STATUS_META[status] - const count = counts[status] || 0 - if (count === 0) return null - return ( - - ) - })} +
+ setStatusFilter(statusFilter === 'complete' ? 'all' : 'complete')} + > +
+
+ +
+
+

{completeCount}

+

Complete

+
+
+
+ 0 ? 'hover:shadow-sm' : 'opacity-60'}`} + onClick={() => inProgressCount > 0 && setStatusFilter('scanning')} + > +
+
+ +
+
+

{inProgressCount}

+

In Progress

+
+
+
+ 0 ? 'hover:shadow-sm' : 'opacity-60'}`} + onClick={() => failedCount > 0 && setStatusFilter(statusFilter === 'failed' ? 'all' : 'failed')} + > +
+
+ +
+
+

{failedCount}

+

Failed

+
+
+
)} @@ -239,11 +289,11 @@ export function IdeaBoard() { className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${ statusFilter === f.value ? 'bg-foreground text-background' - : 'bg-muted text-muted-foreground hover:text-foreground' + : 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80' }`} > {f.label} - {f.value !== 'all' && counts[f.value] != null && ( + {f.value !== 'all' && counts[f.value] != null && counts[f.value] > 0 && ( {counts[f.value]} )} @@ -254,37 +304,40 @@ export function IdeaBoard() { {/* Content */} {loading && (
- +
+ +

Loading analyses...

+
)} {error && ( - +

Failed to load analyses

{error}

- +
)} {!loading && !error && filtered.length === 0 && ( - +
+ +

{analyses.length === 0 ? 'No analyses yet' : 'No matches'}

-

+

{analyses.length === 0 - ? 'Run your first analysis to see it appear here.' + ? 'Run your first analysis to discover apps you can build from your existing code.' : 'Try adjusting your search or filter.'}

{analyses.length === 0 && ( )} diff --git a/components/pattern-analyzer.tsx b/components/pattern-analyzer.tsx index 3eae444..0c09371 100644 --- a/components/pattern-analyzer.tsx +++ b/components/pattern-analyzer.tsx @@ -1,9 +1,10 @@ 'use client' -import { useState } from 'react' +import { useState, useRef, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' import { Select, SelectContent, @@ -12,20 +13,20 @@ import { SelectValue, } from '@/components/ui/select' import { - Cpu, + MessageSquare, Sparkles, Loader2, + Send, Lightbulb, Clock, TrendingUp, - Zap, - Tag, ChevronDown, ChevronUp, - AlertCircle, + Bot, + User, } from 'lucide-react' import type { Analysis } from '@/lib/queries' -import type { PatternAnalyzerResult, ProjectSuggestion } from '@/app/api/pattern-analyzer/route' +import type { AppIdeaSuggestion, AppIdeaChatResponse, ChatMessage } from '@/app/api/app-idea-chat/route' const DIFFICULTY_META = { easy: { label: 'Easy', class: 'bg-chart-1/10 text-chart-1' }, @@ -33,44 +34,47 @@ const DIFFICULTY_META = { hard: { label: 'Hard', class: 'bg-destructive/10 text-destructive' }, } -function SuggestionCard({ suggestion }: { suggestion: ProjectSuggestion }) { +const STARTER_PROMPTS = [ + 'I want to build a developer tool', + 'I want to create a SaaS business', + 'I want to build something with AI', + 'I need a quick side project to ship', +] + +function SuggestionCard({ suggestion }: { suggestion: AppIdeaSuggestion }) { const [expanded, setExpanded] = useState(false) const diff = DIFFICULTY_META[suggestion.difficulty] ?? DIFFICULTY_META.medium return ( - -
+ +
-

{suggestion.name}

- - {suggestion.type} - +

{suggestion.name}

+ {suggestion.type}
-

{suggestion.tagline}

+

{suggestion.tagline}

{diff.label}
-

{suggestion.description}

+

{suggestion.description}

-
-
- +
+
+ {suggestion.estimatedEffort}
-
- +
+ {suggestion.monetizationAngle}
{suggestion.suggestedStack.length > 0 && ( -
+
{suggestion.suggestedStack.map((tech) => ( - - {tech} - + {tech} ))}
)} @@ -79,113 +83,184 @@ function SuggestionCard({ suggestion }: { suggestion: ProjectSuggestion }) { onClick={() => setExpanded((v) => !v)} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" > - {expanded ? : } - {expanded ? 'Less detail' : 'More detail'} + {expanded ? : } + {expanded ? 'Less detail' : 'Why now'} {expanded && ( -
- {suggestion.detectedPatterns.length > 0 && ( -
-

- - Inspired by -

-
    - {suggestion.detectedPatterns.map((p) => ( -
  • - - {p} -
  • - ))} -
-
- )} -
-

- - Why now -

-

{suggestion.whyNow}

-
+
+ {suggestion.whyNow}
)} ) } +interface ChatBubbleProps { + message: ChatMessage & { suggestions?: AppIdeaSuggestion[]; followUpQuestions?: string[] } + onFollowUp?: (q: string) => void +} + +function ChatBubble({ message, onFollowUp }: ChatBubbleProps) { + const isUser = message.role === 'user' + + return ( +
+
+ {isUser ? : } +
+
+
+ {message.content} +
+ + {!isUser && message.suggestions && message.suggestions.length > 0 && ( +
+ {message.suggestions.map((s) => ( + + ))} +
+ )} + + {!isUser && message.followUpQuestions && message.followUpQuestions.length > 0 && ( +
+ {message.followUpQuestions.map((q) => ( + + ))} +
+ )} +
+
+ ) +} + interface PatternAnalyzerProps { completedAnalyses: Analysis[] } +type FullChatMessage = ChatMessage & { + suggestions?: AppIdeaSuggestion[] + followUpQuestions?: string[] +} + export function PatternAnalyzer({ completedAnalyses }: PatternAnalyzerProps) { - const [selectedAnalysisId, setSelectedAnalysisId] = useState( - completedAnalyses[0]?.id ?? '', - ) - const [result, setResult] = useState(null) + const [messages, setMessages] = useState([ + { + role: 'assistant', + content: + "Hi! I'm your App Idea advisor. Tell me what kind of app you want to build — your tech stack preferences, target audience, or problem you want to solve — and I'll suggest the best project ideas for you.", + suggestions: [], + followUpQuestions: STARTER_PROMPTS, + }, + ]) + const [input, setInput] = useState('') const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) + const [selectedAnalysisId, setSelectedAnalysisId] = useState('') + const bottomRef = useRef(null) - const handleScan = async () => { - if (!selectedAnalysisId) return + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + const sendMessage = async (text: string) => { + if (!text.trim() || loading) return + + const userMessage: FullChatMessage = { role: 'user', content: text } + const updatedMessages = [...messages, userMessage] + setMessages(updatedMessages) + setInput('') setLoading(true) - setError(null) - setResult(null) try { - const res = await fetch('/api/pattern-analyzer', { + const history = updatedMessages + .filter((m) => !m.suggestions?.length && !m.followUpQuestions?.length) + .slice(-8) + .map(({ role, content }) => ({ role, content })) + + const res = await fetch('/api/app-idea-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ analysisId: selectedAnalysisId }), + body: JSON.stringify({ + message: text, + analysisId: selectedAnalysisId || undefined, + history, + }), }) if (!res.ok) { const data = await res.json() - throw new Error(data.error || 'Scan failed') + throw new Error(data.error || 'Request failed') } - const data: PatternAnalyzerResult = await res.json() - setResult(data) + const data: AppIdeaChatResponse = await res.json() + + setMessages((prev) => [ + ...prev, + { + role: 'assistant', + content: data.reply, + suggestions: data.suggestions, + followUpQuestions: data.followUpQuestions, + }, + ]) } catch (err) { - setError(err instanceof Error ? err.message : 'Scan failed') + setMessages((prev) => [ + ...prev, + { + role: 'assistant', + content: + err instanceof Error ? err.message : 'Something went wrong. Please try again.', + }, + ]) } finally { setLoading(false) } } - const noAnalyses = completedAnalyses.length === 0 - return ( -
+
{/* Header */} -
+
- -

Pattern Analyzer

+ +

App Idea Chat

-

- Scan your analyzed codebase to surface hidden patterns and get AI-generated project suggestions - tailored to your tech stack. +

+ Describe what you want to build and get tailored project ideas — optionally grounded in your codebase.

- {/* Control panel */} - -

Select a completed analysis to scan

- - {noAnalyses ? ( -
- - No completed analyses yet. Run an analysis first, then come back here. -
- ) : ( -
-
+ {/* Codebase selector */} + {completedAnalyses.length > 0 && ( +
+ +
+
+ + Ground ideas in your codebase +
- -
- )} - - - {/* Error */} - {error && ( - -
- - Scan failed -
-

{error}

-
- )} - - {/* Loading skeleton */} - {loading && ( -
- {[1, 2, 3].map((i) => ( - -
-
-
- - ))} +
)} - {/* Results */} - {result && !loading && ( -
- {/* Detected patterns */} - {result.patterns.length > 0 && ( -
-

- - Detected Patterns -

-
- {result.patterns.map((p) => ( - - {p} - - ))} -
-
- )} - - {/* Top technologies */} - {result.topTechnologies.length > 0 && ( -
-

- - Top Technologies -

-
- {result.topTechnologies.map((t) => ( - - {t} - - ))} -
-
- )} + {/* Chat messages */} +
+ {messages.map((msg, i) => ( + { + setInput(q) + sendMessage(q) + }} + /> + ))} + {loading && ( +
+
+ +
+
+ +
+
+ )} +
+
- {/* Project suggestions */} - {result.suggestions.length > 0 && ( -
-
- -

Project Suggestions

- - {result.suggestions.length} ideas - -
-
- {result.suggestions.map((s) => ( - - ))} -
-
- )} + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + sendMessage(input) + } + }} + placeholder="Describe the kind of app you want to build..." + disabled={loading} + className="flex-1" + /> +
- )} +

+ + Each message costs credits. Be specific for better results. +

+
) } diff --git a/lib/stripe.ts b/lib/stripe.ts index 5d0ef55..f7abb4b 100644 --- a/lib/stripe.ts +++ b/lib/stripe.ts @@ -26,11 +26,11 @@ export function getStripe(): Stripe { export const PLANS = { free: { name: 'Free', - analyses_per_month: 2, - blueprints_viewable: 3, - repos_limit: 2, + analyses_per_month: 1, + blueprints_viewable: 1, + repos_limit: 1, price_monthly: 0, - credits_per_month: 500, + credits_per_month: 200, ai_provider: 'builtin' as const, description: 'Explore the basics, no card needed', }, From 5294d56110762f430156f320d26b7be2c77b4e73 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 22:51:19 +0000 Subject: [PATCH 02/25] feat: unify header style across all pages to match homepage - DashboardHeader: switch to black/cyan cyberpunk theme (bg-black/95, border-cyan-500/20, RepoFuseLogo3D, mono tracking-wider nav links, cyan ring on avatar, cyan-styled auth buttons) - Dashboard layout: bg-black text-white to match homepage - Pricing page: same black/cyan header with RepoFuseLogo3D; plan cards and credit table restyled for dark background (gray-900 cards, cyan accents, white text) --- app/dashboard/layout.tsx | 2 +- app/pricing/page.tsx | 97 ++++++++++++++++----------------- components/dashboard-header.tsx | 57 +++++++++---------- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 4d400cf..c5c8818 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -14,7 +14,7 @@ export default async function DashboardLayout({ } return ( -
+
{children} diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index e0c8e70..ac13d91 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Check, ArrowRight, Sparkles, Github, Crown, Zap, Rocket, Key } from 'lucide-react' -import { RepoFuseLogo } from '@/components/repofuse-logo' +import { RepoFuseLogo3D } from '@/components/repofuse-logo-3d' import { PLANS } from '@/lib/stripe' import { CREDITS } from '@/lib/credits' @@ -108,21 +108,21 @@ const creditCosts = [ export default function PricingPage() { return ( -
+
{/* Header */} -
-
- - +
+
+ + @@ -132,14 +132,14 @@ export default function PricingPage() {
{/* Hero */}
-
- +
+ Simple, transparent pricing
-

+

Pick your plan

-

+

Start free and upgrade when you need more power — unlimited repos, more credits, and the ability to build and push real apps.

@@ -149,35 +149,35 @@ export default function PricingPage() { {plans.map((plan) => { const PlanIcon = plan.icon return ( - {plan.highlighted && (
- + Most Popular
)}
-
- +
+
-

{plan.name}

-

{plan.description}

+

{plan.name}

+

{plan.description}

- {plan.price} - {plan.period} + {plan.price} + {plan.period} {plan.credits != null && ( -

+

{plan.credits.toLocaleString()} credits/mo

)} @@ -186,26 +186,25 @@ export default function PricingPage() {
    {plan.features.map((feature) => (
  • - - + + - {feature} + {feature}
  • ))}
- - + {plan.cta} + +
) })}
@@ -213,24 +212,24 @@ export default function PricingPage() { {/* Credit costs table */}
-

How credits work

-

- Each action costs credits. Pro gets 3,000/mo, Scale gets 12,000/mo — unused credits don't roll over. +

How credits work

+

+ Each action costs credits. Pro gets 3,000/mo, Scale gets 12,000/mo — unused credits don't roll over.

{creditCosts.map((item) => ( - +
{item.icon}
-

{item.action}

-

{item.cost}

-

credits

- +

{item.action}

+

{item.cost}

+

credits

+
))}
-

+

All plans include read-only repository access. Your code is never stored — we only analyze file structures and patterns.

diff --git a/components/dashboard-header.tsx b/components/dashboard-header.tsx index 3b0b18e..c4d769a 100644 --- a/components/dashboard-header.tsx +++ b/components/dashboard-header.tsx @@ -2,9 +2,9 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' -import { Github, BarChart3, FolderGit2, Sparkles, CreditCard, LayoutGrid, Cpu } from 'lucide-react' +import { Github, BarChart3, FolderGit2, Sparkles, CreditCard, LayoutGrid, MessageSquare } from 'lucide-react' import { cn } from '@/lib/utils' -import { RepoFuseLogo } from '@/components/repofuse-logo' +import { RepoFuseLogo3D } from '@/components/repofuse-logo-3d' import type { AuthUser } from '@/lib/auth' interface DashboardHeaderProps { @@ -13,10 +13,10 @@ interface DashboardHeaderProps { const navItems = [ { href: '/dashboard', label: 'Overview', icon: BarChart3 }, - { href: '/dashboard/repositories', label: 'Repositories', icon: FolderGit2 }, + { href: '/dashboard/repositories', label: 'Repos', icon: FolderGit2 }, { href: '/dashboard/analyses', label: 'Analyses', icon: Sparkles }, { href: '/dashboard/idea-board', label: 'Idea Board', icon: LayoutGrid }, - { href: '/dashboard/pattern-analyzer', label: 'App Idea Chat', icon: Cpu }, + { href: '/dashboard/pattern-analyzer', label: 'App Idea Chat', icon: MessageSquare }, { href: '/dashboard/billing', label: 'Billing', icon: CreditCard }, ] @@ -25,11 +25,11 @@ export function DashboardHeader({ user }: DashboardHeaderProps) { return ( <> -
-
-
- - +
+
+
+ +
-
+
{user ? (
{user.github_avatar_url && ( {user.github_username} )} -
-

@{user.github_username}

+
+

@{user.github_username}

Sign out @@ -80,19 +80,19 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
- Connect GitHub + GitHub - Connect GitLab + GitLab
)} @@ -100,8 +100,9 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
-
diff --git a/components/dashboard-header.tsx b/components/dashboard-header.tsx index c4d769a..7e22db4 100644 --- a/components/dashboard-header.tsx +++ b/components/dashboard-header.tsx @@ -78,14 +78,14 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
) : ( )}
From 0ebde0c09e37389f9cb4ef1f8d158b1b1a812201 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 03:41:03 +0000 Subject: [PATCH 05/25] Fix hero section blank space on mobile by reordering columns On mobile, the left column (text) was pushed down by 96px of padding before showing any content. Fix: reduce section/container top padding and use CSS order-* classes so the terminal window appears first above the text on small screens, matching the visual hierarchy on desktop (left-first layout preserved via lg:order-1 / lg:order-2). https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/page.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 240d19b..bc3e611 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -86,7 +86,7 @@ export default async function HomePage({ searchParams }: { searchParams: Promise {/* Hero Section */}
-
+
{/* Animated grid background */}
-
-
- {/* Left content */} -
+
+
+ {/* Left content — appears second on mobile, first on desktop */} +
{/* Badge */}
@@ -152,8 +152,8 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
- {/* Right: Terminal window */} -
+ {/* Right: Terminal window — appears first on mobile */} +
{/* Terminal header */}
From 58157fa33d042baba4aa1d1343b228f898727114 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 04:07:04 +0000 Subject: [PATCH 06/25] Redesign homepage to centered single-column MyClaw-style layout - Simplified header: logo + name, desktop nav, single cyan "Get Started" button; NavDropdown shown in mobile header - Hero: centered single column instead of 2-col grid; trust badge with Zap icon + divider; large two-line headline (white/cyan); single full-width-on-mobile CTA button; avatar stack social proof - Terminal preview moved below CTA, full-width card with window chrome and Online badge - Metrics, How It Works, Features, bottom CTA, footer all updated to match clean dark style (white/5 borders, rounded-2xl cards) - Background updated to #0a0a0f for warmer deep-dark feel https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/page.tsx | 399 ++++++++++++++++++++++----------------------------- 1 file changed, 169 insertions(+), 230 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index bc3e611..7a571e9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,8 +2,7 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' import { RepoFuseLogo3D } from '@/components/repofuse-logo-3d' import { NavDropdown } from '@/components/nav-dropdown' -import { Github, ArrowRight, AlertCircle, Shield, Zap, GitBranch, Rocket, Code2, Sparkles } from 'lucide-react' -import { LaunchSignupModal } from '@/components/launch-signup-modal' +import { Github, ArrowRight, AlertCircle, Zap } from 'lucide-react' const ERROR_MESSAGES: Record = { auth_required: 'You must sign in to access the dashboard.', @@ -22,7 +21,7 @@ export default async function HomePage({ searchParams }: { searchParams: Promise const errorMessage = error ? ERROR_MESSAGES[error] ?? 'An unexpected error occurred.' : null return ( -
+
{errorMessage && (
@@ -30,165 +29,132 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
)} - {/* Animated scanlines overlay */} -
- {/* Noise overlay */} -
{/* Header */} -
+
- - + + + RepoFuse - -
{/* Hero Section */}
-
- {/* Animated grid background */} -
+
+ {/* Subtle radial glow */} +
+
- {/* Glowing orbs */} -
-
-
+
+ {/* Trust badge */} +
+ + + AI-POWERED + + + The #1 Repo Intelligence Platform +
-
-
- {/* Left content — appears second on mobile, first on desktop */} -
- {/* Badge */} -
- - Now in Public Beta -
- - {/* Heading */} -
-

- Everything your - - repos - have been - - - waiting - for - -

-
- - {/* Subheading */} -

- RepoFuse scans your connected GitHub or GitLab repos and surfaces buildable project ideas, detects hidden potential, and turns scattered code into your next big launch — automatically. -

+ {/* Headline */} +

+ Your repos are hiding + buildable apps +

- {/* CTA Buttons */} -
- - - - -
+ {/* Subheading */} +

+ RepoFuse scans your GitHub and GitLab repos, surfaces project ideas, and turns scattered code into your next launch —{' '} + automatically. +

- {/* Social proof */} -
- 2,400+ developers already on the waitlist -
+ {/* Primary CTA */} + + + Scan My Repos Free + + + {/* Sub-text */} +

+ Connect in seconds. No credit card required. +

+ + {/* Social proof */} +
+
+ {['#22d3ee','#a78bfa','#fb923c','#4ade80'].map((c, i) => ( +
+ ))}
+ 2,400+ developers already scanning +
- {/* Right: Terminal window — appears first on mobile */} -
-
- {/* Terminal header */} -
-
-
-
- repofuse — repo-scanner -
+ {/* Terminal preview */} +
+ {/* Window chrome */} +
+
+
+
+ RepoFuse Dashboard +
+ + Online +
+
- {/* Terminal body */} -
-
- $ - repofuse scan --org DealPatrol -
-
-
▸ Connecting to GitHub API...
-
✓ Found 14 repositories
-
▸ Analyzing code patterns...
-
-
-
📦 repo-app-architect
-
⚡ 3 buildable ideas detected
-
→ SaaS: AI Code Review Tool
-
→ Tool: Repo Health Dashboard
-
→ API: Webhook Automation Kit
-
-
-
▸ Generating project briefs...
-
-
- $ - -
-
+ {/* Content */} +
+
+ $ + repofuse scan --org DealPatrol --all-repos +
+
+
▸ Connecting to GitHub API...
+
✓ Found 14 repositories
+
▸ Analyzing code patterns & dependencies...
+
+
+
📦 repo-app-architect
+
⚡ 3 buildable ideas detected
+
→ SaaS: AI Code Review Tool
+
→ Tool: Repo Health Dashboard
+
→ API: Webhook Automation Kit
+
+
▸ Generating full project briefs...
+
+ $ +
@@ -196,69 +162,49 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
{/* Metrics Strip */} -
+
-
-
-

12k+

-

Repos Scanned

-
-
-

4.1k

-

Ideas Found

-
-
-

89%

-

Code Reuse

-
-
-

<30s

-

Analysis Time

-
+
+ {[ + { val: '12k+', label: 'Repos Scanned', color: 'text-cyan-400' }, + { val: '4.1k', label: 'Ideas Found', color: 'text-orange-400' }, + { val: '89%', label: 'Code Reuse', color: 'text-purple-400' }, + { val: '<30s', label: 'Analysis Time', color: 'text-cyan-400' }, + ].map((m) => ( +
+

{m.val}

+

{m.label}

+
+ ))}
{/* How It Works */} -
-
-
-
- +
+
+
+
HOW IT WORKS
-

Four steps to
- - buildable blueprints - +

+ Four steps to
+ buildable blueprints

-
- {/* Connecting line */} -
- +
{[ { num: '01', title: 'Connect', icon: '🔗', desc: 'OAuth in one click. Read-only access to your repos.' }, { num: '02', title: 'Scan', icon: '⚡', desc: 'AI analyzes structure, patterns, and dependencies.' }, { num: '03', title: 'Discover', icon: '💡', desc: 'Ideas surface instantly, ranked by viability.' }, - { num: '04', title: 'Build', icon: '🚀', desc: 'Get full briefs, stack recs, and MVP roadmaps.' } + { num: '04', title: 'Build', icon: '🚀', desc: 'Get full briefs, stack recs, and MVP roadmaps.' }, ].map((step, i) => ( -
-
- {/* Step number background */} -
-
- {step.num} -
-
-

{step.title}

-

{step.desc}

-
+
+
{step.icon}
+
{step.num}
+

{step.title}

+

{step.desc}

))}
@@ -267,78 +213,71 @@ export default async function HomePage({ searchParams }: { searchParams: Promise {/* Feature Grid */}
-
-
-
- +
+
+
FEATURES
-

Everything your repos
- - have been waiting for - +

+ Everything your repos
+ have been waiting for

-
+
{[ { icon: '⚡', title: 'AI Repo Scanner', desc: 'Deep analysis of your codebase structure and patterns in seconds.' }, { icon: '💡', title: 'Idea Surfacer', desc: 'Turns existing code into ranked, buildable project ideas.' }, { icon: '🔗', title: 'Multi-Repo Fusion', desc: 'Cross-reference patterns across all your repos simultaneously.' }, { icon: '📊', title: 'Health Dashboard', desc: 'Live metrics on code quality and technical debt.' }, { icon: '📋', title: 'Launch Briefs', desc: 'AI-generated product briefs for every detected idea.' }, - { icon: '🔒', title: 'Private by Default', desc: 'Your code never leaves your control.' } - ].map((feature, i) => ( -
-
{feature.icon}
-

{feature.title}

-

{feature.desc}

-
+ { icon: '🔒', title: 'Private by Default', desc: 'Your code never leaves your control.' }, + ].map((f, i) => ( +
+
{f.icon}
+

{f.title}

+

{f.desc}

))}
- {/* CTA Section */} -
-
-

+ {/* Bottom CTA */} +
+
+

Your next product is
- - already in your repos - + already in your repos

-

+

Join 2,400+ developers who've stopped guessing and started shipping.

- -

- no credit card required · read-only access + + + Start Scanning Now + + +

+ no credit card · read-only access · cancel anytime

{/* Footer */} -
-
- © 2025 RepoFuse. Built by developers, for developers. +
+
+ © 2025 RepoFuse. Built by developers, for developers. +
+ Pricing + Dashboard +
- -
) } From 1bfea48f1d26e657412c1cd45dd9b1e92d4cfdc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 04:52:29 +0000 Subject: [PATCH 07/25] Add Without/With RepoFuse comparison section to homepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors MyClaw's before/after layout: broken terminal showing repos collecting dust + four ✕ pain-point lines, red arrow transition, then a success terminal showing 7 detected app ideas + three ✓ benefit lines and headline 'turns your graveyard into a goldmine'. Inserted between Metrics Strip and How It Works. https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/page.tsx | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/app/page.tsx b/app/page.tsx index 7a571e9..459a778 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -180,6 +180,96 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
+ {/* Before / After comparison */} +
+
+ + {/* WITHOUT */} +

Without RepoFuse

+ +
+
+
+
+
+
+
+
$ git log --oneline
+
a3f2c11 WIP: dashboard v2
+
b7d8e09 fix: auth bug (again)
+
c1a4d67 TODO: finish this someday
+
$ ls graveyard/
+
half-built-saas/ abandoned-api/ ideas.txt
+
ERROR: another repo collecting dust
+
Build failed. No launch. Again.
+
+
+ +
+ {[ + { label: 'Repos sit unused for months', red: false }, + { label: "Can't see the ideas hiding in your code", red: false }, + { label: 'Start from scratch every single time', red: false }, + { label: 'Give up on half-built projects', red: true }, + ].map((item) => ( +
+ + {item.label} +
+ ))} +
+ + {/* Arrow */} +
+ + + + +
+ + {/* WITH */} +
+
+

With RepoFuse

+
+ +
+
+
+
+
+
+
+
+ + RepoFuse scan complete +
+
⚡ 7 buildable apps detected
+
→ SaaS: AI Code Review Tool
+
→ Tool: Repo Health Dashboard
+
→ API: Webhook Automation Kit
+
+ 4 more ideas →
+
+
+ +
+

RepoFuse turns your graveyard into a goldmine.

+ {[ + 'Your old code becomes new launchable ideas.', + 'Full blueprints, stack recs, and MVP roadmaps.', + 'Push a scaffold to GitHub in one click.', + ].map((text) => ( +
+ + {text} +
+ ))} +
+
+ +
+
+ {/* How It Works */}
From 817d0f559a97714140e7d486833d909c6e61c8b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 05:38:32 +0000 Subject: [PATCH 08/25] Fix Build This App timeout causing 'Load failed' on Safari/iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add maxDuration=60 and force-dynamic to build-app route so Vercel allows up to 60s for Claude file generation (was 10s default) - Reduce max_tokens 8192→4096 to finish faster and stay within budget - Client: detect stream-ended-without-done (silent timeout) and show actionable retry message instead of freezing on the progress screen - Client: map Safari's 'Load failed' / 'Failed to fetch' network errors to a clear 'Connection timed out — please try again' message https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/api/build-app/route.ts | 5 ++++- components/build-app-modal.tsx | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 6757ef0..1d0ae3d 100644 --- a/app/api/build-app/route.ts +++ b/app/api/build-app/route.ts @@ -4,6 +4,9 @@ import { getCurrentUser } from '@/lib/auth' import { getAnthropicModel } from '@/lib/anthropic-model' import type { AppBlueprint } from '@/lib/queries' +export const maxDuration = 60 +export const dynamic = 'force-dynamic' + const anthropic = new Anthropic() type Platform = 'github' | 'gitlab' @@ -61,7 +64,7 @@ Return format: {"path/to/file.ts": "...full content...", "README.md": "..."} const response = await anthropic.messages.create({ model: getAnthropicModel(), - max_tokens: 8192, + max_tokens: 4096, messages: [{ role: 'user', content: prompt }], }) diff --git a/components/build-app-modal.tsx b/components/build-app-modal.tsx index dead148..ca70a92 100644 --- a/components/build-app-modal.tsx +++ b/components/build-app-modal.tsx @@ -140,8 +140,22 @@ export function BuildAppModal({ blueprint, open, onOpenChange }: BuildAppModalPr if (done) break } + + // If stream ended without reaching 'done', the function timed out + setStep((prev) => + prev.id !== 'done' && prev.id !== 'error' + ? { id: 'error', message: 'Build timed out — please try again. File generation can take up to 60 seconds.' } + : prev + ) } catch (e) { - setStep({ id: 'error', message: e instanceof Error ? e.message : 'Build failed' }) + const msg = e instanceof Error ? e.message : 'Build failed' + // Safari reports network/timeout errors as "Load failed" + setStep({ + id: 'error', + message: msg === 'Load failed' || msg === 'Failed to fetch' + ? 'Connection timed out — please try again. Large projects can take up to 60 seconds.' + : msg, + }) } } From 5a9cbb8e5d291fd523dec1ba68aec3195bd3498c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 10:41:52 +0000 Subject: [PATCH 09/25] Fix Stripe payment flow: plan detection, Scale routing, invoice logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook fixes: - checkout.session.completed: read plan from sub.metadata.plan instead of hardcoding 'pro' — Scale subscribers now get plan='scale' in DB - Also correctly set status='trialing' on trial subscriptions at signup - customer.subscription.updated: preserve 'scale' plan when subscription is still active; read from metadata.plan or existing DB plan - invoice.payment_succeeded: replace fragile invoice.number!='0001' check with invoice.billing_reason==='subscription_cycle' for reliable renewal credit granting Checkout flow fixes: - billing-client: Scale plan now calls /api/stripe/checkout with {plan:'scale'} so github_id metadata is attached to the Stripe subscription; falls back to payment link only if STRIPE_SCALE_PRICE_ID is not configured (503 response) - billing-client: plan comparison buttons pass correct plan id to handleUpgrade() instead of always defaulting to 'pro' - pricing page: Scale CTA links to /dashboard/billing instead of bare Stripe payment link, routing authenticated users through checkout API https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/api/stripe/webhook/route.ts | 33 ++++++++++++++++++++------------- app/pricing/page.tsx | 2 +- components/billing-client.tsx | 15 +++++++++++---- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 0d277cd..1b47ead 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -36,16 +36,18 @@ export async function POST(request: NextRequest) { const sub = await stripe.subscriptions.retrieve(session.subscription as string) const githubId = Number(sub.metadata.github_id || session.metadata?.github_id) const periodEnd = (sub as unknown as { current_period_end?: number }).current_period_end + // Use the plan stored in metadata; default to 'pro' if missing + const planName = (sub.metadata.plan === 'scale' ? 'scale' : 'pro') as 'pro' | 'scale' if (githubId) { await upsertSubscription({ github_id: githubId, stripe_customer_id: session.customer as string, stripe_subscription_id: sub.id, - plan: 'pro', - status: 'active', + plan: planName, + status: sub.status === 'trialing' ? 'trialing' : 'active', current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null, }) - + // Grant initial credits on signup try { const user = await getUserByGithubId(githubId) @@ -53,13 +55,13 @@ export async function POST(request: NextRequest) { await grantCredits( user.id, CREDITS.INITIAL_GRANT, - 'Pro plan signup bonus', + `${planName} plan signup bonus`, { stripe_customer_id: session.customer as string } ) - console.log(`[v0] Granted ${CREDITS.INITIAL_GRANT} credits to user ${user.id}`) + console.log(`[webhook] Granted ${CREDITS.INITIAL_GRANT} credits to user ${user.id} (${planName})`) } } catch (err) { - console.error('[v0] Failed to grant signup credits:', err) + console.error('[webhook] Failed to grant signup credits:', err) } } } @@ -71,10 +73,15 @@ export async function POST(request: NextRequest) { const existing = await getSubscriptionByStripeCustomerId(sub.customer as string) const periodEnd = (sub as unknown as { current_period_end?: number }).current_period_end if (existing) { - const isPro = sub.status === 'active' || sub.status === 'trialing' + const isActive = sub.status === 'active' || sub.status === 'trialing' + // Preserve existing plan name (pro/scale) when still active; downgrade to free on cancel + const metaPlan = sub.metadata?.plan as string | undefined + const planToSet = isActive + ? ((metaPlan === 'scale' ? 'scale' : existing.plan === 'scale' ? 'scale' : 'pro') as 'pro' | 'scale') + : 'free' as const await upsertSubscription({ github_id: existing.github_id, - plan: isPro ? 'pro' : 'free', + plan: planToSet, status: sub.status === 'active' ? 'active' : sub.status === 'past_due' ? 'past_due' : sub.status === 'trialing' ? 'trialing' @@ -117,10 +124,10 @@ export async function POST(request: NextRequest) { if (invoice.customer) { const existing = await getSubscriptionByStripeCustomerId(invoice.customer as string) if (existing) { - // Grant monthly renewal credits on successful payment (but only if not the initial invoice) + // Only grant renewal credits on recurring payments, not the initial subscription creation try { - // Check if this is a renewal (not the initial invoice by checking if invoice number > 1) - if (invoice.number && invoice.number !== '0001') { + const isRenewal = invoice.billing_reason === 'subscription_cycle' + if (isRenewal) { const user = await getUserByGithubId(existing.github_id) if (user) { await grantCredits( @@ -129,11 +136,11 @@ export async function POST(request: NextRequest) { 'Monthly subscription renewal', { invoice_id: invoice.id, stripe_customer_id: invoice.customer as string } ) - console.log(`[v0] Granted ${CREDITS.MONTHLY_GRANT} monthly renewal credits to user ${user.id}`) + console.log(`[webhook] Granted ${CREDITS.MONTHLY_GRANT} renewal credits to user ${user.id}`) } } } catch (err) { - console.error('[v0] Failed to grant renewal credits:', err) + console.error('[webhook] Failed to grant renewal credits:', err) } } } diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 0c7ba66..a69f6ee 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -71,7 +71,7 @@ const plans = [ ], icon: Rocket, cta: 'Get Scale', - ctaHref: 'https://buy.stripe.com/3cIcN65VJ55g6nC9gkbjW00', + ctaHref: '/dashboard/billing', highlighted: false, checkoutPlan: 'scale', }, diff --git a/components/billing-client.tsx b/components/billing-client.tsx index f8b6377..5b5b285 100644 --- a/components/billing-client.tsx +++ b/components/billing-client.tsx @@ -67,7 +67,7 @@ const PLAN_CONFIGS = [ icon: Rocket, features: ['Everything in Pro', '12,000 credits/month', 'Highest priority AI', 'Early access', 'Dedicated support'], cta: 'Get Scale', - ctaHref: 'https://buy.stripe.com/3cIcN65VJ55g6nC9gkbjW00', + ctaHref: null, // goes through checkout API so github_id is attached to subscription }, { id: 'byok', @@ -103,13 +103,20 @@ export function BillingClient({ const isPro = plan === 'pro' const usagePercent = analysesLimit > 0 ? Math.min(100, Math.round((analysesUsed / analysesLimit) * 100)) : 0 - const handleUpgrade = async () => { + const handleUpgrade = async (targetPlan: 'pro' | 'scale' = 'pro') => { setCheckoutLoading(true) try { - const res = await fetch('/api/stripe/checkout', { method: 'POST' }) + const res = await fetch('/api/stripe/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plan: targetPlan }), + }) const data = await res.json().catch(() => ({ error: 'Unexpected server error' })) if (res.ok && data.url) { window.location.href = data.url + } else if (targetPlan === 'scale' && res.status === 503) { + // Scale price ID not configured — fall back to Stripe payment link + window.location.href = 'https://buy.stripe.com/3cIcN65VJ55g6nC9gkbjW00' } else { alert(data.error || 'Billing is not available right now. Please try again later.') } @@ -372,7 +379,7 @@ export function BillingClient({ size="sm" variant={highlighted ? 'default' : 'outline'} className="w-full" - onClick={handleUpgrade} + onClick={() => handleUpgrade(p.id === 'scale' ? 'scale' : 'pro')} disabled={checkoutLoading} > {checkoutLoading ? : null} From 352f245a5ce13b5a413dee5c6ee06d81aeec9d8c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 09:07:49 +0000 Subject: [PATCH 10/25] feat: add AI Code Cleanup + Technical Debt Scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New feature that scans connected repositories for technical debt across 6 categories (duplicates, unused files, security risks, slow queries, outdated dependencies, quick wins) and surfaces actionable issues with severity badges. Auto-fixable issues support one-click GitHub PR generation via Claude. - lib/credits.ts: add DEBT_SCAN_COST (150), extend transaction_type union and deductCredits type parameter with 'debt_scan' - app/api/debt-scan/route.ts: POST endpoint — auth, deduct credits, load repo file metadata, build Claude prompt, parse and return DebtScanResult - app/api/debt-fix/route.ts: SSE endpoint — fetch file from GitHub, Claude generates fix, creates branch + PR on the user's repo - components/debt-scanner-client.tsx: full client UI with idle/scanning/results states, severity summary bar, category tabs, issue cards with before/after snippets, auto-fix and copy-suggestion actions - components/debt-fix-modal.tsx: SSE streaming modal (fetch → generating → PR) following the same pattern as build-app-modal - app/dashboard/debt-scanner/page.tsx: server wrapper that loads completed analyses and passes them to the client component - components/dashboard-header.tsx: add Debt Scanner nav item (ShieldAlert icon) https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/api/debt-fix/route.ts | 255 +++++++++++++++ app/api/debt-scan/route.ts | 147 +++++++++ app/dashboard/debt-scanner/page.tsx | 16 + components/dashboard-header.tsx | 3 +- components/debt-fix-modal.tsx | 266 ++++++++++++++++ components/debt-scanner-client.tsx | 470 ++++++++++++++++++++++++++++ lib/credits.ts | 5 +- 7 files changed, 1159 insertions(+), 3 deletions(-) create mode 100644 app/api/debt-fix/route.ts create mode 100644 app/api/debt-scan/route.ts create mode 100644 app/dashboard/debt-scanner/page.tsx create mode 100644 components/debt-fix-modal.tsx create mode 100644 components/debt-scanner-client.tsx diff --git a/app/api/debt-fix/route.ts b/app/api/debt-fix/route.ts new file mode 100644 index 0000000..c8efac9 --- /dev/null +++ b/app/api/debt-fix/route.ts @@ -0,0 +1,255 @@ +import { NextRequest } from 'next/server' +import Anthropic from '@anthropic-ai/sdk' +import { getCurrentUser } from '@/lib/auth' +import { getAnthropicModel } from '@/lib/anthropic-model' +import type { DebtIssue } from '@/app/api/debt-scan/route' + +export const maxDuration = 60 +export const dynamic = 'force-dynamic' + +const anthropic = new Anthropic() + +interface DebtFixRequest { + issue: DebtIssue + analysisId: string + repoOwner: string + repoName: string +} + +async function getFileFromGitHub( + accessToken: string, + owner: string, + repo: string, + path: string, +): Promise<{ content: string; sha: string } | null> { + const res = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }, + }, + ) + if (!res.ok) return null + const data = (await res.json()) as { content?: string; sha?: string } + if (!data.content || !data.sha) return null + return { + content: Buffer.from(data.content, 'base64').toString('utf-8'), + sha: data.sha, + } +} + +async function generateFix( + issue: DebtIssue, + currentContent: string, +): Promise { + const prompt = `You are a senior engineer fixing technical debt. Apply the following fix to this file. + +Issue: ${issue.title} +Category: ${issue.category} +Severity: ${issue.severity} +Description: ${issue.description} +Suggestion: ${issue.suggestion} +${issue.fix ? `\nExpected change:\nBefore: ${issue.fix.before}\nAfter: ${issue.fix.after}` : ''} + +Current file content: +\`\`\` +${currentContent.slice(0, 4000)} +\`\`\` + +Return ONLY the complete fixed file content, no explanations, no markdown fences.` + + const response = await anthropic.messages.create({ + model: getAnthropicModel(), + max_tokens: 4096, + messages: [{ role: 'user', content: prompt }], + }) + + const raw = response.content[0].type === 'text' ? response.content[0].text.trim() : '' + return raw.replace(/^```(?:\w+)?\s*/i, '').replace(/\s*```\s*$/, '').trim() +} + +async function createGitHubPR( + accessToken: string, + owner: string, + repo: string, + filePath: string, + fixedContent: string, + sha: string, + issue: DebtIssue, +): Promise { + const branchName = `debt-fix/${issue.id}-${Date.now()}` + const encoded = Buffer.from(fixedContent).toString('base64') + + // Get default branch SHA + const repoRes = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' }, + }) + if (!repoRes.ok) throw new Error('Failed to fetch repo info') + const repoData = (await repoRes.json()) as { default_branch: string } + const defaultBranch = repoData.default_branch || 'main' + + const refRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/ref/heads/${defaultBranch}`, + { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' } }, + ) + if (!refRes.ok) throw new Error('Failed to get ref') + const refData = (await refRes.json()) as { object: { sha: string } } + const baseSha = refData.object.sha + + // Create branch + const branchRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ref: `refs/heads/${branchName}`, sha: baseSha }), + }) + if (!branchRes.ok) throw new Error('Failed to create branch') + + // Update file on new branch + const updateRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath)}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: `fix: ${issue.title}`, + content: encoded, + sha, + branch: branchName, + }), + }, + ) + if (!updateRes.ok) { + const err = (await updateRes.json()) as { message?: string } + throw new Error(err.message ?? 'Failed to update file') + } + + // Create PR + const prRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: `[RepoFuse] fix: ${issue.title}`, + body: `## Technical Debt Fix\n\n**Category:** ${issue.category}\n**Severity:** ${issue.severity}\n**File:** \`${filePath}\`\n\n### Problem\n${issue.description}\n\n### Fix Applied\n${issue.suggestion}\n\n---\n*Generated by RepoFuse Debt Scanner*`, + head: branchName, + base: defaultBranch, + }), + }) + if (!prRes.ok) { + const err = (await prRes.json()) as { message?: string } + throw new Error(err.message ?? 'Failed to create PR') + } + const prData = (await prRes.json()) as { html_url: string } + return prData.html_url +} + +export async function POST(request: NextRequest) { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + const send = (data: object) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) + } + + try { + const user = await getCurrentUser() + if (!user) { + send({ step: 'error', message: 'Sign in to apply fixes.' }) + controller.close() + return + } + + const body = (await request.json()) as DebtFixRequest + const { issue, repoOwner, repoName } = body + + if (!issue || !repoOwner || !repoName) { + send({ step: 'error', message: 'Missing required fields.' }) + controller.close() + return + } + + // Step 1 — fetch current file + send({ step: 'fetching', message: 'Fetching current file from GitHub…' }) + + const fileData = await getFileFromGitHub( + user.access_token, + repoOwner, + repoName, + issue.file, + ) + + if (!fileData) { + send({ step: 'error', message: `Could not fetch file: ${issue.file}. Make sure the repository is connected.` }) + controller.close() + return + } + + // Step 2 — generate fix with Claude + send({ step: 'generating', message: 'Generating fix with Claude…' }) + + let fixedContent: string + try { + fixedContent = await generateFix(issue, fileData.content) + } catch (e) { + send({ + step: 'error', + message: `Fix generation failed: ${e instanceof Error ? e.message : String(e)}`, + }) + controller.close() + return + } + + // Step 3 — create PR + send({ step: 'pr_creating', message: 'Creating pull request on GitHub…' }) + + let prUrl: string + try { + prUrl = await createGitHubPR( + user.access_token, + repoOwner, + repoName, + issue.file, + fixedContent, + fileData.sha, + issue, + ) + } catch (e) { + send({ + step: 'error', + message: `PR creation failed: ${e instanceof Error ? e.message : String(e)}`, + }) + controller.close() + return + } + + send({ step: 'done', message: 'Pull request created!', prUrl }) + } catch (e) { + console.error('[debt-fix] unhandled error:', e) + send({ step: 'error', message: 'An unexpected error occurred.' }) + } finally { + controller.close() + } + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +} diff --git a/app/api/debt-scan/route.ts b/app/api/debt-scan/route.ts new file mode 100644 index 0000000..4568c26 --- /dev/null +++ b/app/api/debt-scan/route.ts @@ -0,0 +1,147 @@ +import { NextRequest, NextResponse } from 'next/server' +import { Anthropic } from '@anthropic-ai/sdk' +import { + getAnalysisById, + getRepositoriesForAnalysis, + getBlueprintsByAnalysis, + getFilesByRepository, +} from '@/lib/queries' +import { getAnthropicModel } from '@/lib/anthropic-model' +import { getCurrentUser } from '@/lib/auth' +import { deductCredits, CREDITS } from '@/lib/credits' + +export const dynamic = 'force-dynamic' + +const anthropic = new Anthropic() + +export interface DebtIssue { + id: string + category: 'duplicate' | 'unused' | 'security' | 'slow_query' | 'outdated_dep' | 'quick_win' + severity: 'critical' | 'high' | 'medium' | 'low' + title: string + description: string + file: string + suggestion: string + autoFixable: boolean + fix?: { before: string; after: string } +} + +export interface DebtScanResult { + issues: DebtIssue[] +} + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const { analysisId } = (await request.json()) as { analysisId: string } + + if (!analysisId) { + return NextResponse.json({ error: 'analysisId is required' }, { status: 400 }) + } + + const creditResult = await deductCredits( + user.id, + CREDITS.DEBT_SCAN_COST, + 'debt_scan', + { analysisId }, + ) + if (!creditResult.success) { + return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) + } + + const analysis = await getAnalysisById(analysisId) + if (!analysis || analysis.status !== 'complete') { + return NextResponse.json({ error: 'Analysis not found or not complete' }, { status: 404 }) + } + + const [repositories, blueprints] = await Promise.all([ + getRepositoriesForAnalysis(analysisId), + getBlueprintsByAnalysis(analysisId), + ]) + + const allFiles = ( + await Promise.all(repositories.map((r) => getFilesByRepository(r.id))) + ).flat() + + const techCount: Record = {} + for (const file of allFiles) { + for (const tech of file.technologies) { + techCount[tech] = (techCount[tech] || 0) + 1 + } + } + const topTech = Object.entries(techCount) + .sort((a, b) => b[1] - a[1]) + .slice(0, 15) + .map(([t]) => t) + + const fileList = allFiles + .slice(0, 150) + .map((f) => ` ${f.path}${f.purpose ? ` — ${f.purpose}` : ''}`) + .join('\n') + + const systemPrompt = `You are an expert code reviewer and technical debt analyst. Analyze this repository's file structure and metadata to identify technical debt and code quality issues. + +Repositories: ${repositories.map((r) => r.name).join(', ')} +Technologies: ${topTech.join(', ')} +Total files: ${allFiles.length} +Existing blueprints: ${blueprints.slice(0, 5).map((b) => b.name).join(', ') || 'none'} + +Files (path — purpose): +${fileList} +${allFiles.length > 150 ? `\n ... and ${allFiles.length - 150} more files` : ''} + +Return ONLY valid JSON (no markdown fences): +{ + "issues": [ + { + "id": "unique-kebab-slug", + "category": "duplicate|unused|security|slow_query|outdated_dep|quick_win", + "severity": "critical|high|medium|low", + "title": "Short descriptive title", + "description": "What the problem is and why it matters", + "file": "path/to/file.ts", + "suggestion": "Specific actionable recommendation", + "autoFixable": true, + "fix": { "before": "problematic code snippet", "after": "fixed code snippet" } + } + ] +} + +Rules: +- Limit to 20 most impactful issues +- Only include "fix" when autoFixable is true and you can provide a concrete before/after snippet +- For security: flag .env files committed, plaintext tokens/passwords, missing auth checks, SQL injection risks +- For duplicates: flag files with very similar names or purposes across repos that should be merged +- For unused: flag files that appear orphaned based on naming patterns (e.g. old/deprecated/backup files) +- For slow_query: flag files with database access patterns that suggest N+1 queries or missing indexes +- For outdated_dep: flag very old technology patterns or deprecated APIs visible from file names/purposes +- For quick_win: flag simple high-impact improvements (missing error handling, missing .gitignore entries, etc.) +- Infer issues from file names and purposes — you don't have actual file content, so focus on structural and naming patterns` + + const response = await anthropic.messages.create({ + model: getAnthropicModel(), + max_tokens: 3000, + system: systemPrompt, + messages: [{ role: 'user', content: 'Scan this codebase for technical debt and return the JSON result.' }], + }) + + const raw = response.content[0].type === 'text' ? response.content[0].text.trim() : '' + const jsonText = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim() + + let parsed: DebtScanResult + try { + parsed = JSON.parse(jsonText) + } catch { + return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) + } + + return NextResponse.json(parsed) + } catch (error) { + console.error('[debt-scan] error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/dashboard/debt-scanner/page.tsx b/app/dashboard/debt-scanner/page.tsx new file mode 100644 index 0000000..7e89ef1 --- /dev/null +++ b/app/dashboard/debt-scanner/page.tsx @@ -0,0 +1,16 @@ +import { getAllAnalyses, type Analysis } from '@/lib/queries' +import { DebtScannerClient } from '@/components/debt-scanner-client' + +export const dynamic = 'force-dynamic' + +export default async function DebtScannerPage() { + let analyses: Analysis[] = [] + try { + const all = await getAllAnalyses() + analyses = all.filter((a) => a.status === 'complete') + } catch { + // Return empty list if queries fail — client handles it gracefully + } + + return +} diff --git a/components/dashboard-header.tsx b/components/dashboard-header.tsx index 7e22db4..26f19cf 100644 --- a/components/dashboard-header.tsx +++ b/components/dashboard-header.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' -import { Github, BarChart3, FolderGit2, Sparkles, CreditCard, LayoutGrid, MessageSquare } from 'lucide-react' +import { Github, BarChart3, FolderGit2, Sparkles, CreditCard, LayoutGrid, MessageSquare, ShieldAlert } from 'lucide-react' import { cn } from '@/lib/utils' import { RepoFuseLogo3D } from '@/components/repofuse-logo-3d' import type { AuthUser } from '@/lib/auth' @@ -17,6 +17,7 @@ const navItems = [ { href: '/dashboard/analyses', label: 'Analyses', icon: Sparkles }, { href: '/dashboard/idea-board', label: 'Idea Board', icon: LayoutGrid }, { href: '/dashboard/pattern-analyzer', label: 'App Idea Chat', icon: MessageSquare }, + { href: '/dashboard/debt-scanner', label: 'Debt Scanner', icon: ShieldAlert }, { href: '/dashboard/billing', label: 'Billing', icon: CreditCard }, ] diff --git a/components/debt-fix-modal.tsx b/components/debt-fix-modal.tsx new file mode 100644 index 0000000..38e13f1 --- /dev/null +++ b/components/debt-fix-modal.tsx @@ -0,0 +1,266 @@ +'use client' + +import { useState, useRef } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { + Loader2, + CheckCircle2, + XCircle, + ExternalLink, + Download, + Wrench, + GitPullRequest, +} from 'lucide-react' +import type { DebtIssue } from '@/app/api/debt-scan/route' + +type FixStep = + | { id: 'idle' } + | { id: 'fetching' } + | { id: 'generating' } + | { id: 'pr_creating' } + | { id: 'done'; prUrl: string } + | { id: 'error'; message: string } + +interface DebtFixModalProps { + issue: DebtIssue + analysisId: string + repoOwner: string + repoName: string + open: boolean + onOpenChange: (open: boolean) => void +} + +const STEPS = [ + { key: 'fetching', label: 'Fetching file from GitHub', icon: Download }, + { key: 'generating', label: 'Generating fix with Claude', icon: Wrench }, + { key: 'pr_creating', label: 'Creating pull request', icon: GitPullRequest }, + { key: 'done', label: 'Complete', icon: CheckCircle2 }, +] + +function stepIndex(step: FixStep): number { + if (step.id === 'idle') return -1 + if (step.id === 'fetching') return 0 + if (step.id === 'generating') return 1 + if (step.id === 'pr_creating') return 2 + if (step.id === 'done') return 3 + return -1 +} + +export function DebtFixModal({ + issue, + analysisId, + repoOwner, + repoName, + open, + onOpenChange, +}: DebtFixModalProps) { + const [step, setStep] = useState({ id: 'idle' }) + const readerRef = useRef | null>(null) + + const isFixing = + step.id !== 'idle' && step.id !== 'done' && step.id !== 'error' + + const handleFix = async () => { + setStep({ id: 'fetching' }) + + try { + const res = await fetch('/api/debt-fix', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ issue, analysisId, repoOwner, repoName }), + }) + + if (!res.ok || !res.body) { + const data = await res.json().catch(() => ({})) + setStep({ id: 'error', message: (data as { error?: string }).error ?? 'Request failed' }) + return + } + + const reader = res.body.getReader() + readerRef.current = reader + const decoder = new TextDecoder() + let buf = '' + + while (true) { + const { done, value } = await reader.read() + if (value) buf += decoder.decode(value, { stream: true }) + if (done) { buf += decoder.decode(undefined, { stream: false }) } + + const lines = buf.split('\n') + buf = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('data: ')) continue + try { + const data = JSON.parse(trimmed.slice(6)) as { + step: string + message?: string + prUrl?: string + } + + if (data.step === 'fetching') { + setStep({ id: 'fetching' }) + } else if (data.step === 'generating') { + setStep({ id: 'generating' }) + } else if (data.step === 'pr_creating') { + setStep({ id: 'pr_creating' }) + } else if (data.step === 'done') { + setStep({ id: 'done', prUrl: data.prUrl! }) + } else if (data.step === 'error') { + setStep({ id: 'error', message: data.message ?? 'Fix failed' }) + } + } catch { + // incomplete chunk + } + } + + if (done) break + } + + setStep((prev: FixStep) => + prev.id !== 'done' && prev.id !== 'error' + ? { id: 'error', message: 'Operation timed out — please try again.' } + : prev, + ) + } catch (e) { + const msg = e instanceof Error ? e.message : 'Fix failed' + setStep({ + id: 'error', + message: msg === 'Load failed' || msg === 'Failed to fetch' + ? 'Connection timed out — please try again.' + : msg, + }) + } + } + + const handleClose = (open: boolean) => { + if (isFixing) return + setStep({ id: 'idle' }) + onOpenChange(open) + } + + const currentStepIdx = stepIndex(step) + + return ( + + + +
+ + Auto-fix: {issue.title} +
+ + Claude will generate a fix for {issue.file} and open a GitHub PR. + +
+ + {step.id === 'idle' || step.id === 'error' ? ( +
+
+
+ File + {issue.file} +
+
+ Problem + {issue.description} +
+
+ Fix + {issue.suggestion} +
+
+ + {step.id === 'error' && ( +
+ +

{step.message}

+
+ )} + + +
+ ) : step.id === 'done' ? ( +
+
+
+ +
+
+

Pull request created!

+

+ Review and merge the PR to apply the fix. +

+
+
+ + +
+ ) : ( +
+
+ {STEPS.map((s, idx) => { + const isDone = currentStepIdx > idx + const isActive = currentStepIdx === idx + const Icon = s.icon + return ( +
+
+ {isDone ? ( + + ) : isActive ? ( + + ) : ( + + )} +
+

+ {s.label} +

+
+ ) + })} +
+

+ This may take up to 30 seconds — please keep this window open. +

+
+ )} +
+
+ ) +} diff --git a/components/debt-scanner-client.tsx b/components/debt-scanner-client.tsx new file mode 100644 index 0000000..258f837 --- /dev/null +++ b/components/debt-scanner-client.tsx @@ -0,0 +1,470 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + ShieldAlert, + Loader2, + AlertTriangle, + ChevronDown, + ChevronUp, + Copy, + Wrench, + Files, + Trash2, + Lock, + Zap, + Package, + Lightbulb, + CheckCircle2, +} from 'lucide-react' +import type { Analysis } from '@/lib/queries' +import type { DebtIssue, DebtScanResult } from '@/app/api/debt-scan/route' +import { DebtFixModal } from '@/components/debt-fix-modal' +import { CREDITS } from '@/lib/credits' + +type ScanState = 'idle' | 'scanning' | 'results' | 'error' + +type Category = 'all' | DebtIssue['category'] + +const CATEGORY_META: Record< + DebtIssue['category'], + { label: string; icon: React.ComponentType<{ className?: string }> } +> = { + duplicate: { label: 'Duplicates', icon: Files }, + unused: { label: 'Unused', icon: Trash2 }, + security: { label: 'Security', icon: Lock }, + slow_query: { label: 'Slow Queries', icon: Zap }, + outdated_dep: { label: 'Outdated Deps', icon: Package }, + quick_win: { label: 'Quick Wins', icon: Lightbulb }, +} + +const SEVERITY_META: Record< + DebtIssue['severity'], + { label: string; badgeClass: string; dotClass: string } +> = { + critical: { + label: 'Critical', + badgeClass: 'bg-red-500/10 text-red-400 border-red-500/20', + dotClass: 'bg-red-500', + }, + high: { + label: 'High', + badgeClass: 'bg-orange-500/10 text-orange-400 border-orange-500/20', + dotClass: 'bg-orange-500', + }, + medium: { + label: 'Medium', + badgeClass: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + dotClass: 'bg-yellow-500', + }, + low: { + label: 'Low', + badgeClass: 'bg-muted text-muted-foreground border-border', + dotClass: 'bg-muted-foreground', + }, +} + +const TABS: { key: Category; label: string }[] = [ + { key: 'all', label: 'All' }, + { key: 'security', label: 'Security' }, + { key: 'duplicate', label: 'Duplicates' }, + { key: 'unused', label: 'Unused' }, + { key: 'slow_query', label: 'Slow Queries' }, + { key: 'outdated_dep', label: 'Outdated Deps' }, + { key: 'quick_win', label: 'Quick Wins' }, +] + +interface IssueCardProps { + issue: DebtIssue + analysisId: string + repoOwner: string + repoName: string +} + +function IssueCard({ issue, analysisId, repoOwner, repoName }: IssueCardProps) { + const [expanded, setExpanded] = useState(false) + const [fixOpen, setFixOpen] = useState(false) + const [copied, setCopied] = useState(false) + + const severity = SEVERITY_META[issue.severity] ?? SEVERITY_META.low + const category = CATEGORY_META[issue.category] + const CategoryIcon = category?.icon ?? ShieldAlert + + const handleCopy = async () => { + await navigator.clipboard.writeText(issue.suggestion) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + <> + +
+
+ +
+
+
+ + + {severity.label} + + {category?.label} +
+

{issue.title}

+

{issue.file}

+
+
+ +

{issue.description}

+ + {issue.fix && ( + + )} + + {expanded && issue.fix && ( +
+
+

Before

+
+                {issue.fix.before}
+              
+
+
+

After

+
+                {issue.fix.after}
+              
+
+
+ )} + +
+

{issue.suggestion}

+
+ {issue.autoFixable ? ( + + ) : ( + + )} +
+
+
+ + {issue.autoFixable && ( + + )} + + ) +} + +interface SummaryBarProps { + issues: DebtIssue[] +} + +function SummaryBar({ issues }: SummaryBarProps) { + const counts = { + critical: issues.filter((i) => i.severity === 'critical').length, + high: issues.filter((i) => i.severity === 'high').length, + medium: issues.filter((i) => i.severity === 'medium').length, + low: issues.filter((i) => i.severity === 'low').length, + } + + return ( +
+ {(Object.entries(counts) as [DebtIssue['severity'], number][]).map(([severity, count]) => { + const meta = SEVERITY_META[severity] + return ( +
+

{count}

+

c.startsWith('text-'))}`}> + {meta.label} +

+
+ ) + })} +
+ ) +} + +interface DebtScannerClientProps { + completedAnalyses: Analysis[] +} + +export function DebtScannerClient({ completedAnalyses }: DebtScannerClientProps) { + const [selectedAnalysisId, setSelectedAnalysisId] = useState('') + const [repoOwner, setRepoOwner] = useState('') + const [repoName, setRepoName] = useState('') + const [scanState, setScanState] = useState('idle') + const [issues, setIssues] = useState([]) + const [errorMessage, setErrorMessage] = useState('') + const [activeTab, setActiveTab] = useState('all') + + const selectedAnalysis = completedAnalyses.find((a) => a.id === selectedAnalysisId) + + const handleAnalysisChange = (id: string) => { + setSelectedAnalysisId(id) + // Pre-fill repo owner/name from first repo if available + setRepoOwner('') + setRepoName('') + setIssues([]) + setScanState('idle') + setActiveTab('all') + } + + const handleScan = async () => { + if (!selectedAnalysisId) return + setScanState('scanning') + setIssues([]) + setErrorMessage('') + + try { + const res = await fetch('/api/debt-scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ analysisId: selectedAnalysisId }), + }) + + const data = (await res.json()) as DebtScanResult & { error?: string } + + if (!res.ok || data.error) { + setErrorMessage(data.error ?? 'Scan failed') + setScanState('error') + return + } + + setIssues(data.issues ?? []) + setScanState('results') + setActiveTab('all') + } catch { + setErrorMessage('Network error — please try again.') + setScanState('error') + } + } + + const filteredIssues = + activeTab === 'all' ? issues : issues.filter((i) => i.category === activeTab) + + const tabCounts = TABS.reduce>( + (acc, t) => { + acc[t.key] = + t.key === 'all' ? issues.length : issues.filter((i) => i.category === t.key).length + return acc + }, + {} as Record, + ) + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Debt Scanner

+

+ AI-powered technical debt analysis with one-click fixes +

+
+
+
+ + {/* Config panel */} + {(scanState === 'idle' || scanState === 'error') && ( + +
+ + +
+ +
+
+ + setRepoOwner(e.target.value)} + /> +
+
+ + setRepoName(e.target.value)} + /> +
+
+ + {scanState === 'error' && ( +
+ +

{errorMessage}

+
+ )} + +
+

+ Costs {CREDITS.DEBT_SCAN_COST} credits per scan +

+ +
+
+ )} + + {/* Scanning state */} + {scanState === 'scanning' && ( + +
+ +
+
+

Analyzing your codebase…

+

+ Claude is scanning for technical debt across all your repositories. +

+
+
+ )} + + {/* Results */} + {scanState === 'results' && ( +
+
+
+

+ {issues.length} issue{issues.length !== 1 ? 's' : ''} found + {selectedAnalysis ? ` in ${selectedAnalysis.name ?? 'analysis'}` : ''} +

+

+ Click Auto-fix on any fixable issue to generate a GitHub PR +

+
+ +
+ + + + {/* Category tabs */} +
+ {TABS.filter((t) => t.key === 'all' || tabCounts[t.key] > 0).map((tab) => ( + + ))} +
+ + {/* Issue cards */} + {filteredIssues.length === 0 ? ( +
+ No {activeTab === 'all' ? '' : activeTab.replace('_', ' ')} issues found. +
+ ) : ( +
+ {filteredIssues.map((issue) => ( + + ))} +
+ )} +
+ )} +
+ ) +} diff --git a/lib/credits.ts b/lib/credits.ts index 8dce446..8f538a9 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -10,6 +10,7 @@ export const CREDITS = { SCAFFOLD_COST: 150, // Credits per scaffold generation BUILD_APP_COST: 500, // Credits per Build This App PATTERN_ANALYZER_COST: 100, // Credits per Pattern Analyzer scan + DEBT_SCAN_COST: 150, // Credits per Debt Scanner scan } // Types @@ -28,7 +29,7 @@ export interface CreditTransaction { id: string user_id: string amount: number - transaction_type: 'grant' | 'analysis' | 'scaffold' | 'build_app' | 'pattern_analyzer' | 'refund' | 'renewal' + transaction_type: 'grant' | 'analysis' | 'scaffold' | 'build_app' | 'pattern_analyzer' | 'debt_scan' | 'refund' | 'renewal' reason: string | null metadata: Record balance_after: number @@ -113,7 +114,7 @@ export async function grantCredits( export async function deductCredits( userId: string, amount: number, - type: 'analysis' | 'scaffold' | 'build_app' | 'pattern_analyzer', + type: 'analysis' | 'scaffold' | 'build_app' | 'pattern_analyzer' | 'debt_scan', metadata: Record = {} ): Promise<{ success: boolean; transaction?: CreditTransaction; error?: string }> { const sql = getDb() From 2ad6135297a5c40a21e49e1595f38e8296410501 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 09:11:04 +0000 Subject: [PATCH 11/25] fix: add 'scale' to Subscription plan type union upsertSubscription and the Subscription interface only allowed 'free' | 'byok' | 'pro', causing a build-breaking type error in the Stripe webhook handler which correctly writes 'scale' for Scale plan subscribers. https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- lib/queries.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/queries.ts b/lib/queries.ts index 47fc9ac..6f2843f 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -69,7 +69,7 @@ export interface Subscription { github_id: number stripe_customer_id: string | null stripe_subscription_id: string | null - plan: 'free' | 'byok' | 'pro' + plan: 'free' | 'byok' | 'pro' | 'scale' status: 'active' | 'past_due' | 'canceled' | 'trialing' current_period_end: string | null analyses_used_this_month: number @@ -105,7 +105,7 @@ export async function upsertSubscription(data: { github_id: number stripe_customer_id?: string | null stripe_subscription_id?: string | null - plan?: 'free' | 'byok' | 'pro' + plan?: 'free' | 'byok' | 'pro' | 'scale' status?: 'active' | 'past_due' | 'canceled' | 'trialing' current_period_end?: string | null }): Promise { From 619421dd4e52d91358474833a24991e5d8305f1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 09:12:38 +0000 Subject: [PATCH 12/25] fix: wrap handleUpgrade in arrow function for onClick handlers handleUpgrade takes (targetPlan?: 'pro' | 'scale') but was passed directly as onClick, which expects a MouseEventHandler. Wrapping with () => handleUpgrade() fixes the type mismatch on all four call sites. https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- components/billing-client.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/billing-client.tsx b/components/billing-client.tsx index 5b5b285..0c7f344 100644 --- a/components/billing-client.tsx +++ b/components/billing-client.tsx @@ -219,7 +219,7 @@ export function BillingClient({ Manage Subscription ) : !isPaid ? ( - @@ -251,7 +251,7 @@ export function BillingClient({

{usagePercent >= 100 ? "You've reached your monthly limit." : 'Approaching your monthly limit.'} {' '} -

@@ -270,7 +270,7 @@ export function BillingClient({ {blueprintsUsed >= blueprintsLimit && (

You've viewed all your free blueprints.{' '} -

@@ -287,7 +287,7 @@ export function BillingClient({

Pro gives you unlimited analyses, repos, scaffold generation, and priority AI.

-
From e014800c1e807a1c9221251b051f3dda46dcc38e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 09:13:59 +0000 Subject: [PATCH 13/25] fix: remove unreachable scanState==='scanning' comparison in debt-scanner The button sits inside a block guarded by scanState==='idle'||'error', so TypeScript correctly flags the inner === 'scanning' check as having no overlap. Removed the redundant condition. https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- components/debt-scanner-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/debt-scanner-client.tsx b/components/debt-scanner-client.tsx index 258f837..4da4008 100644 --- a/components/debt-scanner-client.tsx +++ b/components/debt-scanner-client.tsx @@ -373,7 +373,7 @@ export function DebtScannerClient({ completedAnalyses }: DebtScannerClientProps)

) : step.id === 'done' ? ( -
-
-
- -
-
-

App built successfully!

-

- {step.filesCreated} files pushed to your new repository. -

+ <> +
+
+
+ +
+
+

App built successfully!

+

+ {step.filesCreated} files pushed to your new repository. +

+
+ + {platform === 'github' && ( + + )} + + +
- - -
+ {(() => { + const parts = step.repoUrl.replace('https://github.com/', '').split('/') + const owner = parts[0] ?? '' + const name = parts[1] ?? repoName + return ( + + ) + })()} + ) : ( /* Building state — step tracker */
diff --git a/components/launch-preview-modal.tsx b/components/launch-preview-modal.tsx new file mode 100644 index 0000000..8fa426f --- /dev/null +++ b/components/launch-preview-modal.tsx @@ -0,0 +1,315 @@ +'use client' + +import { useState, useRef } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { + Loader2, + CheckCircle2, + XCircle, + ExternalLink, + Download, + Wrench, + Upload, + FolderGit2, + Rocket, + Eye, + EyeOff, +} from 'lucide-react' + +type PreviewStep = + | { id: 'idle' } + | { id: 'fetching' } + | { id: 'fixing' } + | { id: 'pushing' } + | { id: 'creating' } + | { id: 'deploying'; message?: string } + | { id: 'done'; previewUrl: string } + | { id: 'error'; message: string } + +interface LaunchPreviewModalProps { + repoOwner: string + repoName: string + open: boolean + onOpenChange: (open: boolean) => void +} + +const STEPS = [ + { key: 'fetching', label: 'Fetching package.json', icon: Download }, + { key: 'fixing', label: 'Updating dependencies', icon: Wrench }, + { key: 'pushing', label: 'Pushing changes', icon: Upload }, + { key: 'creating', label: 'Creating Vercel project', icon: FolderGit2 }, + { key: 'deploying', label: 'Building & deploying', icon: Rocket }, + { key: 'done', label: 'Live!', icon: CheckCircle2 }, +] + +function stepIndex(step: PreviewStep): number { + if (step.id === 'idle') return -1 + if (step.id === 'fetching') return 0 + if (step.id === 'fixing') return 1 + if (step.id === 'pushing') return 2 + if (step.id === 'creating') return 3 + if (step.id === 'deploying') return 4 + if (step.id === 'done') return 5 + return -1 +} + +export function LaunchPreviewModal({ + repoOwner, + repoName, + open, + onOpenChange, +}: LaunchPreviewModalProps) { + const [vercelToken, setVercelToken] = useState('') + const [showToken, setShowToken] = useState(false) + const [step, setStep] = useState({ id: 'idle' }) + const readerRef = useRef | null>(null) + + const isDeploying = + step.id !== 'idle' && step.id !== 'done' && step.id !== 'error' + + const handleLaunch = async () => { + if (!vercelToken.trim()) return + setStep({ id: 'fetching' }) + + try { + const res = await fetch('/api/launch-preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repoOwner, repoName, vercelToken }), + }) + + if (!res.ok || !res.body) { + const data = await res.json().catch(() => ({})) + setStep({ id: 'error', message: (data as { error?: string }).error ?? 'Request failed' }) + return + } + + const reader = res.body.getReader() + readerRef.current = reader + const decoder = new TextDecoder() + let buf = '' + + while (true) { + const { done, value } = await reader.read() + if (value) buf += decoder.decode(value, { stream: true }) + if (done) { buf += decoder.decode(undefined, { stream: false }) } + + const lines = buf.split('\n') + buf = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('data: ')) continue + try { + const data = JSON.parse(trimmed.slice(6)) as { + step: string + message?: string + previewUrl?: string + } + + if (data.step === 'fetching') { + setStep({ id: 'fetching' }) + } else if (data.step === 'fixing') { + setStep({ id: 'fixing' }) + } else if (data.step === 'pushing') { + setStep({ id: 'pushing' }) + } else if (data.step === 'creating') { + setStep({ id: 'creating' }) + } else if (data.step === 'deploying') { + setStep({ id: 'deploying', message: data.message }) + } else if (data.step === 'done') { + setStep({ id: 'done', previewUrl: data.previewUrl! }) + } else if (data.step === 'error') { + setStep({ id: 'error', message: data.message ?? 'Preview launch failed' }) + } + } catch { + // incomplete chunk + } + } + + if (done) break + } + + setStep((prev: PreviewStep) => + prev.id !== 'done' && prev.id !== 'error' + ? { id: 'error', message: 'Operation timed out — please try again.' } + : prev, + ) + } catch (e) { + const msg = e instanceof Error ? e.message : 'Launch failed' + setStep({ + id: 'error', + message: + msg === 'Load failed' || msg === 'Failed to fetch' + ? 'Connection timed out — please try again.' + : msg, + }) + } + } + + const handleClose = (open: boolean) => { + if (isDeploying) return + setStep({ id: 'idle' }) + onOpenChange(open) + } + + const currentStepIdx = stepIndex(step) + + return ( + + + +
+ + Launch Preview +
+ + Deploy{' '} + + {repoOwner}/{repoName} + {' '} + to Vercel and get a live preview URL. + +
+ + {step.id === 'idle' || step.id === 'error' ? ( +
+
+ +
+ setVercelToken(e.target.value)} + placeholder="paste your Vercel token here" + className="pr-10" + /> + +
+

+ Create a token at{' '} + vercel.com/account/tokens. Your token is sent + directly to Vercel and is never stored. +

+
+ +
+

What happens:

+
    +
  • • Claude updates your package.json to latest stable deps
  • +
  • • A Vercel project is created and linked to your GitHub repo
  • +
  • • Your app is built and deployed (~60–90 seconds)
  • +
  • • You receive a live preview URL
  • +
+
+ + {step.id === 'error' && ( +
+ +

{step.message}

+
+ )} + + +
+ ) : step.id === 'done' ? ( +
+
+
+ +
+
+

Your app is live!

+

+ {step.previewUrl} +

+
+
+ + +
+ ) : ( +
+
+ {STEPS.map((s, idx) => { + const isDone = currentStepIdx > idx + const isActive = currentStepIdx === idx + const Icon = s.icon + return ( +
+
+ {isDone ? ( + + ) : isActive ? ( + + ) : ( + + )} +
+
+

+ {s.label} +

+ {isActive && step.id === 'deploying' && step.message && ( +

{step.message}

+ )} +
+
+ ) + })} +
+

+ Vercel builds may take up to 90 seconds — keep this window open. +

+
+ )} +
+
+ ) +} From 3335b93f02c76ffedb10f7d403141d5b58b0c1ca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 10:18:49 +0000 Subject: [PATCH 15/25] fix: prevent Build This App timeout on Vercel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes of the consistent 'Generating file contents' hang: 1. maxDuration=60 — Vercel killed the function before Claude finished generating complete files (can take 60-90s for 5+ files) 2. max_tokens=4096 — too low; large apps hit the limit and the JSON response was truncated, causing JSON.parse to throw Fixes: - maxDuration 60 → 120 to give Claude enough headroom - Switch anthropic.messages.create to anthropic.messages.stream so tokens stream in continuously rather than Vercel waiting on one blocked response (keeps the connection warm and avoids idle timeout) - max_tokens 4096 → 8192 to handle full multi-file generations - Update UI copy from '30 seconds' to '2 minutes' https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/api/build-app/route.ts | 22 ++++++++++++++++------ components/build-app-modal.tsx | 4 ++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 1d0ae3d..117c4a1 100644 --- a/app/api/build-app/route.ts +++ b/app/api/build-app/route.ts @@ -4,7 +4,7 @@ import { getCurrentUser } from '@/lib/auth' import { getAnthropicModel } from '@/lib/anthropic-model' import type { AppBlueprint } from '@/lib/queries' -export const maxDuration = 60 +export const maxDuration = 120 export const dynamic = 'force-dynamic' const anthropic = new Anthropic() @@ -62,16 +62,26 @@ Rules: Return format: {"path/to/file.ts": "...full content...", "README.md": "..."} ` - const response = await anthropic.messages.create({ + // Use streaming so Vercel keeps the function alive during generation + // and we aren't blocked waiting for a single large response. + let raw = '' + const stream = anthropic.messages.stream({ model: getAnthropicModel(), - max_tokens: 4096, + max_tokens: 8192, messages: [{ role: 'user', content: prompt }], }) + for await (const chunk of stream) { + if ( + chunk.type === 'content_block_delta' && + chunk.delta.type === 'text_delta' + ) { + raw += chunk.delta.text + } + } - const raw = response.content[0].type === 'text' ? response.content[0].text.trim() : '' - const jsonText = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim() + raw = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim() - const obj = JSON.parse(jsonText) as Record + const obj = JSON.parse(raw) as Record const files: Record = {} for (const [k, v] of Object.entries(obj)) { files[k] = typeof v === 'string' ? v : JSON.stringify(v, null, 2) diff --git a/components/build-app-modal.tsx b/components/build-app-modal.tsx index b5c6c71..7ff8e6e 100644 --- a/components/build-app-modal.tsx +++ b/components/build-app-modal.tsx @@ -147,7 +147,7 @@ export function BuildAppModal({ blueprint, open, onOpenChange }: BuildAppModalPr // If stream ended without reaching 'done', the function timed out setStep((prev) => prev.id !== 'done' && prev.id !== 'error' - ? { id: 'error', message: 'Build timed out — please try again. File generation can take up to 60 seconds.' } + ? { id: 'error', message: 'Build timed out — please try again. File generation can take up to 2 minutes for larger projects.' } : prev ) } catch (e) { @@ -384,7 +384,7 @@ export function BuildAppModal({ blueprint, open, onOpenChange }: BuildAppModalPr

- This may take up to 30 seconds — please keep this window open. + This may take up to 2 minutes — please keep this window open.

)} From 5a8a70f2b895d0e3791e9bc4d92a858260e65c2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 10:22:49 +0000 Subject: [PATCH 16/25] fix: App Idea Chat blank page + add starter code template feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blank page root causes: - h-[calc(100vh-12rem)] collapsed when parent had no fixed height - bg-muted / text-foreground CSS vars don't match the black dashboard theme Fix: use height:calc(100dvh-10rem) with flex + min-h-0 on the scroll container, and explicit dark colors matching the rest of the dashboard. New feature — Starter Code from Your Repos: - When a codebase is selected, the API asks Claude to identify up to 6 files from the user's existing repos that are relevant to what they want to build, each with a relevance explanation - Returned starterFiles are validated against the actual DB file list so Claude can't hallucinate paths that don't exist - UI shows a 'Starter Code from Your Repos' section with path, purpose, relevance, tech stack tags, and a copy-path button - Empty state shows starter prompt chips and explains the codebase feature https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/api/app-idea-chat/route.ts | 84 +++++++-- components/pattern-analyzer.tsx | 323 ++++++++++++++++++++++---------- 2 files changed, 291 insertions(+), 116 deletions(-) diff --git a/app/api/app-idea-chat/route.ts b/app/api/app-idea-chat/route.ts index 5e2c64d..375efa3 100644 --- a/app/api/app-idea-chat/route.ts +++ b/app/api/app-idea-chat/route.ts @@ -24,10 +24,20 @@ export interface AppIdeaSuggestion { whyNow: string } +export interface StarterFile { + path: string + purpose: string + technologies: string[] + repoName: string + relevance: string +} + export interface AppIdeaChatResponse { reply: string suggestions: AppIdeaSuggestion[] followUpQuestions: string[] + starterFiles?: StarterFile[] + templateSummary?: string } export interface ChatMessage { @@ -62,8 +72,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) } - // Optionally load codebase context + // Load codebase context and raw file list when an analysis is selected let codebaseContext = '' + let repoFiles: Array<{ path: string; purpose: string; technologies: string[]; repoName: string }> = [] + if (analysisId) { try { const analysis = await getAnalysisById(analysisId) @@ -74,9 +86,21 @@ export async function POST(request: NextRequest) { ]) const allFiles = ( - await Promise.all(repositories.map((r) => getFilesByRepository(r.id))) + await Promise.all( + repositories.map(async (r) => { + const files = await getFilesByRepository(r.id) + return files.map((f) => ({ ...f, repoName: r.name })) + }), + ) ).flat() + repoFiles = allFiles.map((f) => ({ + path: f.path, + purpose: f.purpose ?? '', + technologies: f.technologies ?? [], + repoName: f.repoName, + })) + const techCount: Record = {} for (const file of allFiles) { for (const tech of file.technologies) { @@ -85,15 +109,21 @@ export async function POST(request: NextRequest) { } const topTech = Object.entries(techCount) .sort((a, b) => b[1] - a[1]) - .slice(0, 10) + .slice(0, 12) .map(([t]) => t) codebaseContext = ` -## Developer's codebase context +## Developer's existing codebase Repositories: ${repositories.map((r) => r.name).join(', ')} Top technologies: ${topTech.join(', ')} Total files: ${allFiles.length} Existing blueprints: ${blueprints.slice(0, 5).map((b) => b.name).join(', ') || 'none yet'} + +Files sample (first 60): +${allFiles + .slice(0, 60) + .map((f) => ` [${f.repoName}] ${f.path} — ${f.purpose}`) + .join('\n')} ` } } catch { @@ -101,39 +131,48 @@ Existing blueprints: ${blueprints.slice(0, 5).map((b) => b.name).join(', ') || ' } } - // Build conversation history for context + const hasCodebase = repoFiles.length > 0 const conversationHistory = history.slice(-6).map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, })) - const systemPrompt = `You are an expert product strategist and startup advisor helping developers discover what apps to build. You're having a friendly, concise conversation to help them find the perfect project idea. + const systemPrompt = `You are an expert product strategist helping developers figure out what to build next. You give concise, actionable advice. ${codebaseContext} -When responding: -- Keep your reply conversational and under 100 words -- Suggest 2-4 concrete project ideas tailored to their request${codebaseContext ? ' and their codebase' : ''} -- Ask a relevant follow-up question to refine suggestions -- Be enthusiastic and actionable +When the user describes something they want to build: +${hasCodebase ? `- Identify up to 6 files from their EXISTING codebase (listed above) that are most relevant as starter code for this project. Pick files that could be reused or adapted directly.` : '- They have no connected codebase yet; suggest ideas and tech stacks only.'} +- Suggest 2-3 concrete project ideas aligned with their request +- Ask one follow-up question to refine suggestions -Always respond with valid JSON only (no markdown fences): +Respond with valid JSON only (no markdown fences): { - "reply": "conversational response under 100 words", + "reply": "conversational response, max 80 words", "suggestions": [ { "name": "Project Name", "tagline": "One punchy sentence", "description": "2-3 sentences", - "type": "SaaS | CLI Tool | API | Dashboard | etc", + "type": "SaaS | CLI | API | Dashboard | Mobile | etc", "difficulty": "easy | medium | hard", "estimatedEffort": "e.g. 1–2 weeks", "suggestedStack": ["tech1", "tech2"], - "monetizationAngle": "How to charge", + "monetizationAngle": "How to monetize", "whyNow": "Why this is timely" } ], - "followUpQuestions": ["Question 1?", "Question 2?"] + "followUpQuestions": ["Question 1?", "Question 2?"], + ${hasCodebase ? `"starterFiles": [ + { + "path": "exact/path/from/codebase", + "purpose": "what this file does", + "technologies": ["tech"], + "repoName": "repo-name", + "relevance": "one sentence: why this file helps for what they want to build" + } + ], + "templateSummary": "One sentence describing what these files collectively give them as a starting point"` : `"starterFiles": [], "templateSummary": null`} }` const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [ @@ -158,6 +197,19 @@ Always respond with valid JSON only (no markdown fences): return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) } + // Validate starterFiles against actual repo files to avoid hallucinations + if (parsed.starterFiles?.length && repoFiles.length > 0) { + const pathSet = new Set(repoFiles.map((f) => f.path)) + const validFiles = parsed.starterFiles.filter((sf) => pathSet.has(sf.path)) + // Enrich with actual data from DB + parsed.starterFiles = validFiles.map((sf) => { + const actual = repoFiles.find((f) => f.path === sf.path) + return actual + ? { ...sf, purpose: actual.purpose || sf.purpose, technologies: actual.technologies } + : sf + }) + } + return NextResponse.json(parsed) } catch (error) { console.error('[app-idea-chat] error:', error) diff --git a/components/pattern-analyzer.tsx b/components/pattern-analyzer.tsx index 0c09371..15846f4 100644 --- a/components/pattern-analyzer.tsx +++ b/components/pattern-analyzer.tsx @@ -2,7 +2,6 @@ import { useState, useRef, useEffect } from 'react' import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { @@ -17,28 +16,37 @@ import { Sparkles, Loader2, Send, - Lightbulb, Clock, TrendingUp, ChevronDown, ChevronUp, Bot, User, + FileCode, + Package, + Copy, + Check, + Database, } from 'lucide-react' import type { Analysis } from '@/lib/queries' -import type { AppIdeaSuggestion, AppIdeaChatResponse, ChatMessage } from '@/app/api/app-idea-chat/route' +import type { + AppIdeaSuggestion, + AppIdeaChatResponse, + ChatMessage, + StarterFile, +} from '@/app/api/app-idea-chat/route' const DIFFICULTY_META = { - easy: { label: 'Easy', class: 'bg-chart-1/10 text-chart-1' }, - medium: { label: 'Medium', class: 'bg-chart-2/10 text-chart-2' }, - hard: { label: 'Hard', class: 'bg-destructive/10 text-destructive' }, + easy: { label: 'Easy', class: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' }, + medium: { label: 'Medium', class: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20' }, + hard: { label: 'Hard', class: 'bg-red-500/10 text-red-400 border-red-500/20' }, } const STARTER_PROMPTS = [ - 'I want to build a developer tool', - 'I want to create a SaaS business', + 'I want to build a SaaS dashboard', 'I want to build something with AI', - 'I need a quick side project to ship', + 'I need an e-commerce store', + 'I want to build a developer tool', ] function SuggestionCard({ suggestion }: { suggestion: AppIdeaSuggestion }) { @@ -46,26 +54,30 @@ function SuggestionCard({ suggestion }: { suggestion: AppIdeaSuggestion }) { const diff = DIFFICULTY_META[suggestion.difficulty] ?? DIFFICULTY_META.medium return ( - +
-

{suggestion.name}

- {suggestion.type} +

{suggestion.name}

+ + {suggestion.type} +
-

{suggestion.tagline}

+

{suggestion.tagline}

- {diff.label} + + {diff.label} +
-

{suggestion.description}

+

{suggestion.description}

-
+
{suggestion.estimatedEffort}
-
+
{suggestion.monetizationAngle}
@@ -74,30 +86,82 @@ function SuggestionCard({ suggestion }: { suggestion: AppIdeaSuggestion }) { {suggestion.suggestedStack.length > 0 && (
{suggestion.suggestedStack.map((tech) => ( - {tech} + + {tech} + ))}
)} {expanded && ( -
+
{suggestion.whyNow}
)} - +
+ ) +} + +function StarterFileCard({ file }: { file: StarterFile }) { + const [copied, setCopied] = useState(false) + + const handleCopy = () => { + navigator.clipboard.writeText(file.path) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + return ( +
+ +
+
+ {file.path} + + {file.repoName} + +
+

{file.purpose}

+

{file.relevance}

+ {file.technologies.length > 0 && ( +
+ {file.technologies.slice(0, 4).map((t) => ( + + {t} + + ))} +
+ )} +
+ +
) } interface ChatBubbleProps { - message: ChatMessage & { suggestions?: AppIdeaSuggestion[]; followUpQuestions?: string[] } + message: ChatMessage & { + suggestions?: AppIdeaSuggestion[] + followUpQuestions?: string[] + starterFiles?: StarterFile[] + templateSummary?: string + } onFollowUp?: (q: string) => void } @@ -108,22 +172,48 @@ function ChatBubble({ message, onFollowUp }: ChatBubbleProps) {
- {isUser ? : } + {isUser ? ( + + ) : ( + + )}
-
+ +
{message.content}
+ {/* Starter kit from existing repos */} + {!isUser && message.starterFiles && message.starterFiles.length > 0 && ( +
+
+ +

+ Starter Code from Your Repos +

+
+ {message.templateSummary && ( +

{message.templateSummary}

+ )} +
+ {message.starterFiles.map((f) => ( + + ))} +
+
+ )} + + {/* App idea suggestions */} {!isUser && message.suggestions && message.suggestions.length > 0 && (
{message.suggestions.map((s) => ( @@ -132,13 +222,14 @@ function ChatBubble({ message, onFollowUp }: ChatBubbleProps) {
)} + {/* Follow-up question chips */} {!isUser && message.followUpQuestions && message.followUpQuestions.length > 0 && (
{message.followUpQuestions.map((q) => ( @@ -157,22 +248,17 @@ interface PatternAnalyzerProps { type FullChatMessage = ChatMessage & { suggestions?: AppIdeaSuggestion[] followUpQuestions?: string[] + starterFiles?: StarterFile[] + templateSummary?: string } export function PatternAnalyzer({ completedAnalyses }: PatternAnalyzerProps) { - const [messages, setMessages] = useState([ - { - role: 'assistant', - content: - "Hi! I'm your App Idea advisor. Tell me what kind of app you want to build — your tech stack preferences, target audience, or problem you want to solve — and I'll suggest the best project ideas for you.", - suggestions: [], - followUpQuestions: STARTER_PROMPTS, - }, - ]) + const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [selectedAnalysisId, setSelectedAnalysisId] = useState('') const bottomRef = useRef(null) + const inputRef = useRef(null) useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -189,7 +275,7 @@ export function PatternAnalyzer({ completedAnalyses }: PatternAnalyzerProps) { try { const history = updatedMessages - .filter((m) => !m.suggestions?.length && !m.followUpQuestions?.length) + .filter((m) => !m.suggestions?.length) .slice(-8) .map(({ role, content }) => ({ role, content })) @@ -204,8 +290,8 @@ export function PatternAnalyzer({ completedAnalyses }: PatternAnalyzerProps) { }) if (!res.ok) { - const data = await res.json() - throw new Error(data.error || 'Request failed') + const data = await res.json().catch(() => ({})) + throw new Error((data as { error?: string }).error || 'Request failed') } const data: AppIdeaChatResponse = await res.json() @@ -217,6 +303,8 @@ export function PatternAnalyzer({ completedAnalyses }: PatternAnalyzerProps) { content: data.reply, suggestions: data.suggestions, followUpQuestions: data.followUpQuestions, + starterFiles: data.starterFiles, + templateSummary: data.templateSummary, }, ]) } catch (err) { @@ -230,78 +318,108 @@ export function PatternAnalyzer({ completedAnalyses }: PatternAnalyzerProps) { ]) } finally { setLoading(false) + inputRef.current?.focus() } } + const isEmpty = messages.length === 0 + return ( -
+
{/* Header */} -
-
- -

App Idea Chat

+
+
+
+ +

App Idea Chat

+
+

+ Describe what you want to build — RepoFuse surfaces relevant code from your repos as a starter template. +

-

- Describe what you want to build and get tailored project ideas — optionally grounded in your codebase. -

+ + {completedAnalyses.length > 0 && ( +
+ + +
+ )}
- {/* Codebase selector */} - {completedAnalyses.length > 0 && ( -
- -
-
- - Ground ideas in your codebase -
- + {/* Messages area */} +
+ {isEmpty ? ( + /* Empty state */ +
+
+
- -
- )} - - {/* Chat messages */} -
- {messages.map((msg, i) => ( - { - setInput(q) - sendMessage(q) - }} - /> - ))} - {loading && ( -
-
- +
+

What do you want to build?

+

+ {completedAnalyses.length > 0 + ? 'I\'ll search your connected repos for code you can use as a starting point.' + : 'Tell me your idea and I\'ll suggest the best project approach and tech stack.'} +

-
- +
+ {STARTER_PROMPTS.map((p) => ( + + ))}
+ {completedAnalyses.length === 0 && ( +

+ Connect a repo via Analyses to get personalized starter code from your existing projects. +

+ )} +
+ ) : ( +
+ {messages.map((msg, i) => ( + sendMessage(q)} + /> + ))} + {loading && ( +
+
+ +
+
+ +
+
+ )} +
)} -
{/* Input */} -
+
setInput(e.target.value)} onKeyDown={(e) => { @@ -310,14 +428,15 @@ export function PatternAnalyzer({ completedAnalyses }: PatternAnalyzerProps) { sendMessage(input) } }} - placeholder="Describe the kind of app you want to build..." + placeholder="Describe what you want to build…" disabled={loading} - className="flex-1" + className="flex-1 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-cyan-500/50" />
-

- - Each message costs credits. Be specific for better results. +

+ Each message costs {100} credits. + {completedAnalyses.length > 0 && selectedAnalysisId + ? ' Searching your codebase for relevant starter files.' + : completedAnalyses.length > 0 + ? ' Select a codebase above to surface starter code from your repos.' + : ''}

From cba8fcc1254f336f7cf98948f57cd0be077dd3ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 10:40:52 +0000 Subject: [PATCH 17/25] fix: add missing credit deductions + null-purpose render bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Financial bugs (credits were never deducted for these operations): - debt-fix: deduct 50 credits before calling Claude + creating GitHub PR - launch-preview: deduct 100 credits before running Claude + Vercel deploy - build-app: deduct 500 credits (CREDITS.BUILD_APP_COST was defined but never used — the most expensive operation had no guard at all) Credit system updates (lib/credits.ts): - Add DEBT_FIX_COST: 50 and LAUNCH_PREVIEW_COST: 100 constants - Extend CreditTransaction.transaction_type union with 'debt_fix' | 'launch_preview' - Extend deductCredits type parameter with the same two new types UI: Build This App button now shows '— 500 credits' so users aren't surprised when the balance drops Runtime bug (app-idea-chat): - ${f.purpose} in template string rendered the string "null" when purpose was null; fixed with ?? '' https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/api/app-idea-chat/route.ts | 2 +- app/api/build-app/route.ts | 14 ++++++++++++++ app/api/debt-fix/route.ts | 14 ++++++++++++++ app/api/launch-preview/route.ts | 14 ++++++++++++++ components/build-app-modal.tsx | 2 +- lib/credits.ts | 8 +++++--- 6 files changed, 49 insertions(+), 5 deletions(-) diff --git a/app/api/app-idea-chat/route.ts b/app/api/app-idea-chat/route.ts index 375efa3..ff32a6c 100644 --- a/app/api/app-idea-chat/route.ts +++ b/app/api/app-idea-chat/route.ts @@ -122,7 +122,7 @@ Existing blueprints: ${blueprints.slice(0, 5).map((b) => b.name).join(', ') || ' Files sample (first 60): ${allFiles .slice(0, 60) - .map((f) => ` [${f.repoName}] ${f.path} — ${f.purpose}`) + .map((f) => ` [${f.repoName}] ${f.path} — ${f.purpose ?? ''}`) .join('\n')} ` } diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 117c4a1..7830f2b 100644 --- a/app/api/build-app/route.ts +++ b/app/api/build-app/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server' import Anthropic from '@anthropic-ai/sdk' import { getCurrentUser } from '@/lib/auth' import { getAnthropicModel } from '@/lib/anthropic-model' +import { deductCredits, CREDITS } from '@/lib/credits' import type { AppBlueprint } from '@/lib/queries' export const maxDuration = 120 @@ -238,6 +239,19 @@ export async function POST(request: NextRequest) { const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() + // Deduct credits before any AI work + const creditResult = await deductCredits( + user.id, + CREDITS.BUILD_APP_COST, + 'build_app', + { repoName: cleanRepoName, platform }, + ) + if (!creditResult.success) { + send({ step: 'error', message: creditResult.error ?? 'Insufficient credits to build this app.' }) + controller.close() + return + } + // Step 1 — generate files with Claude send({ step: 'generating', message: 'Generating file contents with Claude…' }) diff --git a/app/api/debt-fix/route.ts b/app/api/debt-fix/route.ts index c8efac9..47d1c88 100644 --- a/app/api/debt-fix/route.ts +++ b/app/api/debt-fix/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server' import Anthropic from '@anthropic-ai/sdk' import { getCurrentUser } from '@/lib/auth' import { getAnthropicModel } from '@/lib/anthropic-model' +import { deductCredits, CREDITS } from '@/lib/credits' import type { DebtIssue } from '@/app/api/debt-scan/route' export const maxDuration = 60 @@ -181,6 +182,19 @@ export async function POST(request: NextRequest) { return } + // Deduct credits before any AI work + const creditResult = await deductCredits( + user.id, + CREDITS.DEBT_FIX_COST, + 'debt_fix', + { issueId: issue.id, repoOwner, repoName }, + ) + if (!creditResult.success) { + send({ step: 'error', message: creditResult.error ?? 'Insufficient credits' }) + controller.close() + return + } + // Step 1 — fetch current file send({ step: 'fetching', message: 'Fetching current file from GitHub…' }) diff --git a/app/api/launch-preview/route.ts b/app/api/launch-preview/route.ts index 3275491..50b28ea 100644 --- a/app/api/launch-preview/route.ts +++ b/app/api/launch-preview/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server' import Anthropic from '@anthropic-ai/sdk' import { getCurrentUser } from '@/lib/auth' import { getAnthropicModel } from '@/lib/anthropic-model' +import { deductCredits, CREDITS } from '@/lib/credits' export const maxDuration = 120 export const dynamic = 'force-dynamic' @@ -185,6 +186,19 @@ export async function POST(request: NextRequest) { return } + // Deduct credits before any AI/cloud work + const creditResult = await deductCredits( + user.id, + CREDITS.LAUNCH_PREVIEW_COST, + 'launch_preview', + { repoOwner, repoName }, + ) + if (!creditResult.success) { + send({ step: 'error', message: creditResult.error ?? 'Insufficient credits' }) + controller.close() + return + } + // Step 1 — fetch package.json send({ step: 'fetching', message: 'Fetching package.json from GitHub…' }) const pkgFile = await getGitHubFile(user.access_token, repoOwner, repoName, 'package.json') diff --git a/components/build-app-modal.tsx b/components/build-app-modal.tsx index 7ff8e6e..d7b9f02 100644 --- a/components/build-app-modal.tsx +++ b/components/build-app-modal.tsx @@ -260,7 +260,7 @@ export function BuildAppModal({ blueprint, open, onOpenChange }: BuildAppModalPr
) : step.id === 'done' ? ( diff --git a/lib/credits.ts b/lib/credits.ts index 8f538a9..f5a046f 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -9,8 +9,10 @@ export const CREDITS = { ANALYSIS_COST: 100, // Credits per analysis run SCAFFOLD_COST: 150, // Credits per scaffold generation BUILD_APP_COST: 500, // Credits per Build This App - PATTERN_ANALYZER_COST: 100, // Credits per Pattern Analyzer scan + PATTERN_ANALYZER_COST: 100, // Credits per App Idea Chat message DEBT_SCAN_COST: 150, // Credits per Debt Scanner scan + DEBT_FIX_COST: 50, // Credits per auto-fix PR + LAUNCH_PREVIEW_COST: 100, // Credits per Launch Preview deployment } // Types @@ -29,7 +31,7 @@ export interface CreditTransaction { id: string user_id: string amount: number - transaction_type: 'grant' | 'analysis' | 'scaffold' | 'build_app' | 'pattern_analyzer' | 'debt_scan' | 'refund' | 'renewal' + transaction_type: 'grant' | 'analysis' | 'scaffold' | 'build_app' | 'pattern_analyzer' | 'debt_scan' | 'debt_fix' | 'launch_preview' | 'refund' | 'renewal' reason: string | null metadata: Record balance_after: number @@ -114,7 +116,7 @@ export async function grantCredits( export async function deductCredits( userId: string, amount: number, - type: 'analysis' | 'scaffold' | 'build_app' | 'pattern_analyzer' | 'debt_scan', + type: 'analysis' | 'scaffold' | 'build_app' | 'pattern_analyzer' | 'debt_scan' | 'debt_fix' | 'launch_preview', metadata: Record = {} ): Promise<{ success: boolean; transaction?: CreditTransaction; error?: string }> { const sql = getDb() From 123867164631134ec1cee62dcb74dfbdf4e07b58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 10:44:31 +0000 Subject: [PATCH 18/25] chore: add comparison section SVG mockup for review Before/after comparison graphic matching the site's actual style (#0a0a0f bg, cyan-400 accent, monospace terminals). Awaiting user approval before wiring into the homepage. https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- public/comparison-preview.svg | 121 ++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 public/comparison-preview.svg diff --git a/public/comparison-preview.svg b/public/comparison-preview.svg new file mode 100644 index 0000000..f77d6f3 --- /dev/null +++ b/public/comparison-preview.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WITHOUT REPOFUSE + + + + + + + + + + + + $ + git log --oneline + a3f2c11 WIP: dashboard v2 (abandoned) + b7d8e09 TODO: finish this someday + c1a4d67 fix: auth bug (again) + $ + ls graveyard/ + half-built-saas/ abandoned-api/ ideas.txt + ERROR: 3 repos collecting dust. No launches. + + + + Code sits forgotten in your repos + + + App ideas are invisible without scanning + + + Start from scratch on every new project + + + Good code dies. Nothing ships. + + + + + + + + + + + + WITH REPOFUSE + + + + + + + + + + + Online + + + + RepoFuse scan complete — 14 repos + ⚡ 7 buildable apps detected + → SaaS: AI Code Review Tool + → Tool: Repo Health Dashboard + → API: Webhook Automation Kit + + 4 more → full blueprints ready + ▸ Generating MVP roadmaps... + + + Your graveyard becomes a + goldmine. + + + + AI scans all repos, surfaces hidden ideas + + + Full blueprints, stack recs & MVP roadmaps + + + Push a working scaffold to GitHub in 1 click + + + Auto-fix technical debt, launch previews + + + + Scan My Repos Free → + From 3599ce6ebe8edf64dad57756deb31826790d1c25 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 10:47:38 +0000 Subject: [PATCH 19/25] feat: replace before/after section with SVG comparison graphic Swaps the ~90-line inline JSX terminal mockup with a single tag pointing to the approved /comparison-preview.svg, which matches the site's dark style and actual product messaging. https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/page.tsx | 91 ++++------------------------------------------------ 1 file changed, 6 insertions(+), 85 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 459a778..190b81d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -182,91 +182,12 @@ export default async function HomePage({ searchParams }: { searchParams: Promise {/* Before / After comparison */}
-
- - {/* WITHOUT */} -

Without RepoFuse

- -
-
-
-
-
-
-
-
$ git log --oneline
-
a3f2c11 WIP: dashboard v2
-
b7d8e09 fix: auth bug (again)
-
c1a4d67 TODO: finish this someday
-
$ ls graveyard/
-
half-built-saas/ abandoned-api/ ideas.txt
-
ERROR: another repo collecting dust
-
Build failed. No launch. Again.
-
-
- -
- {[ - { label: 'Repos sit unused for months', red: false }, - { label: "Can't see the ideas hiding in your code", red: false }, - { label: 'Start from scratch every single time', red: false }, - { label: 'Give up on half-built projects', red: true }, - ].map((item) => ( -
- - {item.label} -
- ))} -
- - {/* Arrow */} -
- - - - -
- - {/* WITH */} -
-
-

With RepoFuse

-
- -
-
-
-
-
-
-
-
- - RepoFuse scan complete -
-
⚡ 7 buildable apps detected
-
→ SaaS: AI Code Review Tool
-
→ Tool: Repo Health Dashboard
-
→ API: Webhook Automation Kit
-
+ 4 more ideas →
-
-
- -
-

RepoFuse turns your graveyard into a goldmine.

- {[ - 'Your old code becomes new launchable ideas.', - 'Full blueprints, stack recs, and MVP roadmaps.', - 'Push a scaffold to GitHub in one click.', - ].map((text) => ( -
- - {text} -
- ))} -
-
- +
+ Without RepoFuse: repos collecting dust, no launches. With RepoFuse: 7 buildable apps detected, full blueprints, push to GitHub in one click.
From b23683df06467b489a22fb39c11195949826f890 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 22 May 2026 20:37:06 +0000 Subject: [PATCH 20/25] fix: create template failure + dashboard error boundary - app/dashboard/error.tsx: add Next.js error boundary so any dashboard page crash shows a recoverable UI instead of a raw 500 screen - migrations/002 used MySQL-style inline INDEX in CREATE TABLE which is invalid PostgreSQL, so missing_file_gaps and templates tables were never created; added migration 007 with correct syntax - app/api/setup/init-db: added missing_file_gaps, completed_gaps, and templates table creation with correct PostgreSQL syntax - app/api/templates/generate: wrap getMissingGapsByBlueprint in try/catch so missing tables don't block template creation; add auth check; surface "run /api/setup/init-db" hint when tables are missing https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/api/setup/init-db/route.ts | 60 +++++++++++++++++++++++++++++ app/api/templates/generate/route.ts | 59 ++++++++++++++++++++-------- app/dashboard/error.tsx | 42 ++++++++++++++++++++ migrations/007_fix_gap_tables.sql | 56 +++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 16 deletions(-) create mode 100644 app/dashboard/error.tsx create mode 100644 migrations/007_fix_gap_tables.sql diff --git a/app/api/setup/init-db/route.ts b/app/api/setup/init-db/route.ts index 76b4f3b..c66a7d7 100644 --- a/app/api/setup/init-db/route.ts +++ b/app/api/setup/init-db/route.ts @@ -133,6 +133,66 @@ async function run() { await sql`CREATE INDEX IF NOT EXISTS idx_analyses_status ON analyses(status)` await sql`CREATE INDEX IF NOT EXISTS idx_app_blueprints_analysis_id ON app_blueprints(analysis_id)` + // Gap tracking tables (migration 002 had invalid PostgreSQL syntax — created here instead) + await sql` + CREATE TABLE IF NOT EXISTS missing_file_gaps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + blueprint_id UUID NOT NULL REFERENCES app_blueprints(id) ON DELETE CASCADE, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(512) NOT NULL, + purpose TEXT, + complexity VARCHAR(20) NOT NULL CHECK (complexity IN ('low', 'medium', 'high')), + estimated_hours NUMERIC(10, 2) NOT NULL DEFAULT 1.0, + category VARCHAR(50) NOT NULL CHECK (category IN ( + 'auth', 'api', 'ui', 'database', 'utils', 'config', 'other' + )), + dependencies JSONB DEFAULT '[]'::jsonb, + is_blocking BOOLEAN DEFAULT FALSE, + suggested_stub TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(blueprint_id, file_path) + ) + ` + + await sql` + CREATE TABLE IF NOT EXISTS completed_gaps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + gap_id UUID NOT NULL REFERENCES missing_file_gaps(id) ON DELETE CASCADE, + blueprint_id UUID NOT NULL REFERENCES app_blueprints(id) ON DELETE CASCADE, + completed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(gap_id, blueprint_id) + ) + ` + + await sql` + CREATE TABLE IF NOT EXISTS templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + blueprint_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + tech_stack JSONB NOT NULL DEFAULT '[]'::jsonb, + estimated_hours NUMERIC(10, 2) NOT NULL DEFAULT 4.0, + reuse_percentage NUMERIC(5, 2) NOT NULL DEFAULT 50, + total_files INTEGER NOT NULL DEFAULT 0, + missing_files INTEGER NOT NULL DEFAULT 0, + tier VARCHAR(50) NOT NULL CHECK (tier IN ('quick_start', 'standard', 'comprehensive')), + featured BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + ` + + await sql`CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_blueprint_id ON missing_file_gaps(blueprint_id)` + await sql`CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_category ON missing_file_gaps(category)` + await sql`CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_is_blocking ON missing_file_gaps(is_blocking)` + await sql`CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_blueprint_complexity ON missing_file_gaps(blueprint_id, complexity)` + await sql`CREATE INDEX IF NOT EXISTS idx_completed_gaps_gap_id ON completed_gaps(gap_id)` + await sql`CREATE INDEX IF NOT EXISTS idx_completed_gaps_blueprint_id ON completed_gaps(blueprint_id)` + await sql`CREATE INDEX IF NOT EXISTS idx_templates_tier ON templates(tier)` + await sql`CREATE INDEX IF NOT EXISTS idx_templates_featured ON templates(featured)` + return NextResponse.json({ success: true, message: 'Database schema initialized successfully.' }) } catch (err) { console.error('[setup] DB init failed:', err) diff --git a/app/api/templates/generate/route.ts b/app/api/templates/generate/route.ts index e9262fa..7668924 100644 --- a/app/api/templates/generate/route.ts +++ b/app/api/templates/generate/route.ts @@ -1,8 +1,15 @@ import { NextRequest, NextResponse } from 'next/server' -import { createTemplate, getBlueprintsByAnalysis, getMissingGapsByBlueprint } from '@/lib/queries' +import { createTemplate, getMissingGapsByBlueprint } from '@/lib/queries' +import { getCurrentUser } from '@/lib/auth' +import { deductCredits, CREDITS } from '@/lib/credits' export async function POST(request: NextRequest) { try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Sign in to create templates.' }, { status: 401 }) + } + const body = await request.json() const { name, @@ -19,23 +26,35 @@ export async function POST(request: NextRequest) { ) } - // Calculate aggregate metrics from blueprints - let totalFiles = 0 + const creditResult = await deductCredits( + user.id, + CREDITS.PATTERN_ANALYZER_COST, + 'pattern_analyzer', + { action: 'create_template' }, + ) + if (!creditResult.success) { + return NextResponse.json( + { error: creditResult.error ?? 'Insufficient credits.' }, + { status: 402 } + ) + } + + // Calculate aggregate metrics — best-effort, missing_file_gaps table may not exist yet let totalMissingFiles = 0 let totalEstimatedHours = 0 - let totalReuse = 0 - - for (const blueprintId of blueprintIds) { - const gaps = await getMissingGapsByBlueprint(blueprintId) - totalMissingFiles += gaps.length - totalEstimatedHours += gaps.reduce((sum, g) => sum + g.estimated_hours, 0) - - // Estimate reuse (would be pulled from blueprint in real system) - totalReuse += 60 // placeholder + const totalReuse = blueprintIds.length * 60 + + try { + for (const blueprintId of blueprintIds) { + const gaps = await getMissingGapsByBlueprint(blueprintId) + totalMissingFiles += gaps.length + totalEstimatedHours += gaps.reduce((sum, g) => sum + g.estimated_hours, 0) + } + } catch { + // missing_file_gaps table not yet created — metrics default to 0 } - const reusePercentage = (totalReuse / blueprintIds.length) - const totalAllFiles = totalFiles + totalMissingFiles + const reusePercentage = totalReuse / blueprintIds.length const template = await createTemplate({ name, @@ -44,7 +63,7 @@ export async function POST(request: NextRequest) { tech_stack: techStack || [], estimated_hours: Math.round(totalEstimatedHours), reuse_percentage: Math.round(reusePercentage), - total_files: totalAllFiles, + total_files: totalMissingFiles, missing_files: totalMissingFiles, tier: tier as 'quick_start' | 'standard' | 'comprehensive', featured: false, @@ -52,7 +71,15 @@ export async function POST(request: NextRequest) { return NextResponse.json({ template }, { status: 201 }) } catch (error) { - console.error('[v0] Error generating template:', error) + console.error('[templates/generate] error:', error) + const msg = error instanceof Error ? error.message : String(error) + // If templates table doesn't exist, prompt the user to run setup + if (msg.includes('does not exist') || msg.includes('relation')) { + return NextResponse.json( + { error: 'Database tables missing — visit /api/setup/init-db once to initialize, then retry.' }, + { status: 500 } + ) + } return NextResponse.json( { error: 'Failed to generate template' }, { status: 500 } diff --git a/app/dashboard/error.tsx b/app/dashboard/error.tsx new file mode 100644 index 0000000..29992aa --- /dev/null +++ b/app/dashboard/error.tsx @@ -0,0 +1,42 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { AlertCircle } from 'lucide-react' +import Link from 'next/link' +import { useEffect } from 'react' + +export default function DashboardError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('[dashboard] error boundary caught:', error) + }, [error]) + + return ( +
+

+ Dashboard +

+
+ +
+
+

This panel could not load.

+

+ Try refreshing the panel. If it keeps happening, check your environment + variables on Vercel. +

+
+
+ + +
+
+ ) +} diff --git a/migrations/007_fix_gap_tables.sql b/migrations/007_fix_gap_tables.sql new file mode 100644 index 0000000..c072dce --- /dev/null +++ b/migrations/007_fix_gap_tables.sql @@ -0,0 +1,56 @@ +-- Fix: migration 002 used MySQL-style inline INDEX declarations which are invalid +-- in PostgreSQL. This migration creates the tables with correct syntax. + +CREATE TABLE IF NOT EXISTS missing_file_gaps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + blueprint_id UUID NOT NULL REFERENCES app_blueprints(id) ON DELETE CASCADE, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(512) NOT NULL, + purpose TEXT, + complexity VARCHAR(20) NOT NULL CHECK (complexity IN ('low', 'medium', 'high')), + estimated_hours NUMERIC(10, 2) NOT NULL DEFAULT 1.0, + category VARCHAR(50) NOT NULL CHECK (category IN ( + 'auth', 'api', 'ui', 'database', 'utils', 'config', 'other' + )), + dependencies JSONB DEFAULT '[]'::jsonb, + is_blocking BOOLEAN DEFAULT FALSE, + suggested_stub TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(blueprint_id, file_path) +); + +CREATE TABLE IF NOT EXISTS completed_gaps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + gap_id UUID NOT NULL REFERENCES missing_file_gaps(id) ON DELETE CASCADE, + blueprint_id UUID NOT NULL REFERENCES app_blueprints(id) ON DELETE CASCADE, + completed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(gap_id, blueprint_id) +); + +CREATE TABLE IF NOT EXISTS templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + blueprint_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + tech_stack JSONB NOT NULL DEFAULT '[]'::jsonb, + estimated_hours NUMERIC(10, 2) NOT NULL DEFAULT 4.0, + reuse_percentage NUMERIC(5, 2) NOT NULL DEFAULT 50, + total_files INTEGER NOT NULL DEFAULT 0, + missing_files INTEGER NOT NULL DEFAULT 0, + tier VARCHAR(50) NOT NULL CHECK (tier IN ('quick_start', 'standard', 'comprehensive')), + featured BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_blueprint_id ON missing_file_gaps(blueprint_id); +CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_category ON missing_file_gaps(category); +CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_is_blocking ON missing_file_gaps(is_blocking); +CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_blueprint_complexity ON missing_file_gaps(blueprint_id, complexity); +CREATE INDEX IF NOT EXISTS idx_completed_gaps_gap_id ON completed_gaps(gap_id); +CREATE INDEX IF NOT EXISTS idx_completed_gaps_blueprint_id ON completed_gaps(blueprint_id); +CREATE INDEX IF NOT EXISTS idx_templates_tier ON templates(tier); +CREATE INDEX IF NOT EXISTS idx_templates_featured ON templates(featured); +CREATE INDEX IF NOT EXISTS idx_templates_tier_featured ON templates(tier, featured); From c9c1a254af0746ddf565c0a6ed257eb43cb7319f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 01:55:06 +0000 Subject: [PATCH 21/25] feat: add phone slideshow to landing page Adds a MyClaw-style phone mockup carousel to the landing page (between the metrics strip and the before/after comparison), showing RepoFuse in action across three slides: Discover, Build, and Ship. - components/phone-slideshow.tsx: client component with iOS phone frame, auto-advancing every 4s, click-to-navigate dot indicators, and a sidebar slide-list nav with cyan accent for the active item - app/page.tsx: new "SEE IT IN ACTION" section wrapping the slideshow - public/screenshots/: directory for blueprints.png, build-modal.png, build-progress.png (user must add these three images) https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/page.tsx | 19 +++ components/phone-slideshow.tsx | 203 +++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 components/phone-slideshow.tsx diff --git a/app/page.tsx b/app/page.tsx index 190b81d..df5d38d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' import { RepoFuseLogo3D } from '@/components/repofuse-logo-3d' import { NavDropdown } from '@/components/nav-dropdown' +import { PhoneSlideshow } from '@/components/phone-slideshow' import { Github, ArrowRight, AlertCircle, Zap } from 'lucide-react' const ERROR_MESSAGES: Record = { @@ -180,6 +181,24 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
+ {/* Phone Slideshow — See it in action */} +
+
+
+
+ SEE IT IN ACTION +
+

+ From idea to GitHub
+ in under a minute +

+
+
+ +
+
+
+ {/* Before / After comparison */}
diff --git a/components/phone-slideshow.tsx b/components/phone-slideshow.tsx new file mode 100644 index 0000000..f3a0839 --- /dev/null +++ b/components/phone-slideshow.tsx @@ -0,0 +1,203 @@ +'use client' + +import { useState, useEffect, useCallback, useRef } from 'react' +import Image from 'next/image' + +const SLIDES = [ + { + src: '/screenshots/blueprints.png', + label: 'DISCOVER', + title: 'Ideas ranked by viability', + desc: 'AI surfaces the best apps hiding in your code, scored and ready to build.', + }, + { + src: '/screenshots/build-modal.png', + label: 'BUILD', + title: 'Generate all missing files', + desc: 'Select an idea, pick GitHub or GitLab, and Claude writes every missing file.', + }, + { + src: '/screenshots/build-progress.png', + label: 'SHIP', + title: 'Watch it deploy in 30 seconds', + desc: 'File generation, repo creation, and push — all automated end-to-end.', + }, +] + +const INTERVAL_MS = 4000 + +export function PhoneSlideshow() { + const [current, setCurrent] = useState(0) + const [animating, setAnimating] = useState(false) + const timerRef = useRef | null>(null) + + const go = useCallback( + (idx: number) => { + if (animating || idx === current) return + setAnimating(true) + setCurrent(idx) + setTimeout(() => setAnimating(false), 450) + }, + [animating, current], + ) + + const next = useCallback(() => go((current + 1) % SLIDES.length), [current, go]) + + const resetTimer = useCallback(() => { + if (timerRef.current) clearInterval(timerRef.current) + timerRef.current = setInterval(next, INTERVAL_MS) + }, [next]) + + useEffect(() => { + resetTimer() + return () => { + if (timerRef.current) clearInterval(timerRef.current) + } + }, [resetTimer]) + + const handleDotClick = (idx: number) => { + go(idx) + resetTimer() + } + + return ( +
+ {/* Phone mockup */} +
+ {/* Ambient glow */} +
+
+ + {/* Phone shell */} +
+ {/* Side buttons */} +
+
+
+
+ + {/* Screen area */} +
+ {/* Dynamic Island */} +
+
+
+ + {/* Status bar */} +
+ 5:10 +
+ + + + + + + + + + +
+
+
+
+
+
+
+
+ + {/* Slides */} + {SLIDES.map((slide, i) => ( +
+ {slide.label} +
+ ))} + + {/* Home indicator */} +
+
+
+
+ + {/* Caption + nav */} +
+
+

+ {SLIDES[current].label} +

+

+ {SLIDES[current].title} +

+

{SLIDES[current].desc}

+
+ + {/* Slide list nav */} +
+ {SLIDES.map((slide, i) => ( + + ))} +
+ + {/* Dot indicators */} +
+ {SLIDES.map((_, i) => ( +
+
+
+ ) +} From be7a624fa2d3c0b5ec2aa2b82f9cabeda114cd11 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 02:48:06 +0000 Subject: [PATCH 22/25] feat: add 'LAUNCHED' result slide to phone slideshow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 4th slide showing the finished product (Memorial QR screenshot) inside a mini browser-chrome frame with a green LIVE badge, completing the narrative arc: Discover → Build → Ship → Launched. Desktop screenshots render with a browser-chrome header instead of the plain full-bleed style used for mobile slides. Green accent colors signal the live/launched state throughout (dot indicator, label, sidebar accent). User must add: public/screenshots/result.png https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- components/phone-slideshow.tsx | 65 +++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/components/phone-slideshow.tsx b/components/phone-slideshow.tsx index f3a0839..77f50e2 100644 --- a/components/phone-slideshow.tsx +++ b/components/phone-slideshow.tsx @@ -9,18 +9,28 @@ const SLIDES = [ label: 'DISCOVER', title: 'Ideas ranked by viability', desc: 'AI surfaces the best apps hiding in your code, scored and ready to build.', + desktop: false, }, { src: '/screenshots/build-modal.png', label: 'BUILD', title: 'Generate all missing files', desc: 'Select an idea, pick GitHub or GitLab, and Claude writes every missing file.', + desktop: false, }, { src: '/screenshots/build-progress.png', label: 'SHIP', title: 'Watch it deploy in 30 seconds', desc: 'File generation, repo creation, and push — all automated end-to-end.', + desktop: false, + }, + { + src: '/screenshots/result.png', + label: 'LAUNCHED', + title: 'Your app, live', + desc: 'A real, working product — built from your existing code in one session.', + desktop: true, }, ] @@ -121,14 +131,45 @@ export function PhoneSlideshow() { zIndex: i === current ? 10 : 1, }} > - {slide.label} + {slide.desktop ? ( + /* Desktop screenshot — shown scaled inside a mini browser chrome */ +
+ {/* Mini browser chrome */} +
+
+
+
+
+ memorialqr.com +
+
+ {/* Screenshot scaled to fit */} +
+ {slide.label} + {/* "LIVE" badge overlay */} +
+ + LIVE +
+
+
+ ) : ( + {slide.label} + )}
))} @@ -141,7 +182,7 @@ export function PhoneSlideshow() { {/* Caption + nav */}
-

+

{SLIDES[current].label}

-

+

{slide.label}

@@ -191,7 +232,7 @@ export function PhoneSlideshow() { aria-label={`Go to slide ${i + 1}`} className={`rounded-full transition-all duration-300 ${ i === current - ? 'w-6 h-2 bg-cyan-400' + ? `w-6 h-2 ${SLIDES[i].desktop ? 'bg-green-400' : 'bg-cyan-400'}` : 'w-2 h-2 bg-white/20 hover:bg-white/40' }`} /> From da97a9683a3de3ca12052cf5774f7c4a95f9bda2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 17:47:39 +0000 Subject: [PATCH 23/25] fix: prevent credit loss on failed builds + remove dead analyze routes - build-app: deduct credits only after a successful build instead of up front, so failed generation/repo-creation/push no longer burns 500 credits. Balance is still checked before work begins. - Delete the unused OpenAI gpt-4-turbo analysis route and the unused cross-platform analyze route (plus their orphaned libs); the live /run flow already uses Claude. - build-app-modal: align timeout copy on 2 minutes, surface HTTP status on request failures, and explain Vercel preview is GitHub-only. - queries: log blueprint_views catch blocks instead of swallowing errors silently. https://claude.ai/code/session_01JcAQpzFeBtwmK6ZrXKZ8mx --- app/api/analyses/[id]/analyze/route.ts | 156 ------------------- app/api/analyze/route.ts | 35 ----- app/api/build-app/route.ts | 34 +++-- components/build-app-modal.tsx | 12 +- lib/app-discovery.ts | 109 ------------- lib/cross-platform-scanner.ts | 202 ------------------------- lib/queries.ts | 16 +- 7 files changed, 41 insertions(+), 523 deletions(-) delete mode 100644 app/api/analyses/[id]/analyze/route.ts delete mode 100644 app/api/analyze/route.ts delete mode 100644 lib/app-discovery.ts delete mode 100644 lib/cross-platform-scanner.ts diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts deleted file mode 100644 index f48138d..0000000 --- a/app/api/analyses/[id]/analyze/route.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { generateText } from 'ai' -import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits' -import { getAnalysisById } from '@/lib/queries' - -const model = 'openai/gpt-4-turbo' - -interface SelectedRepository { - name: string - full_name: string - default_branch: string -} - -interface RepositoryTreeFile { - path: string - size?: number -} - -interface AppSuggestion { - is_complete?: boolean -} - -export async function POST(request: NextRequest) { - try { - const { analysisId, selectedRepos, userId } = (await request.json()) as { - analysisId: string - selectedRepos: SelectedRepository[] - userId: string - } - - // Check credit balance before proceeding - if (!userId) { - return NextResponse.json({ error: 'User ID required' }, { status: 401 }) - } - - const currentBalance = await getCreditBalance(userId) - if (currentBalance < CREDITS.ANALYSIS_COST) { - return NextResponse.json( - { - error: 'Insufficient credits', - required: CREDITS.ANALYSIS_COST, - available: currentBalance, - message: 'Upgrade to Pro to get unlimited analyses with 5,000 monthly credits.', - }, - { status: 402 } - ) - } - - // Get all repo files from database - const filesByRepo: Record = {} - - for (const repo of selectedRepos) { - // Fetch repo structure from GitHub API - const files = await fetchRepoStructure(repo) - filesByRepo[repo.name] = files - } - - // Use AI to analyze cross-repo patterns - const prompt = `You are an expert software architect analyzing code across multiple repositories. - -Given these repositories with their file structures: -${Object.entries(filesByRepo).map(([name, files]) => - `${name}: ${files.map(f => f.path).join(', ')}` -).join('\n')} - -Your task is to discover what applications could be built by combining files from these repositories. For each possible app, determine: -1. App Name -2. App Type (Web App, Mobile App, API, Library, etc.) -3. Description -4. Complete? (true/false - does it have 80%+ of needed files?) -5. Reuse Percentage (how much code can be reused) -6. Missing Files (if not complete) -7. Technologies Used -8. Difficulty Level (easy/medium/hard) -9. Fast Cash Label (if missing only 2-3 files) -10. Explanation - -Return as JSON array of app suggestions. Focus on practical, buildable applications.` - - const result = await generateText({ - model, - prompt, - temperature: 0.7, - maxOutputTokens: 2000, - }) - - // Parse AI response and save suggestions - let suggestions: AppSuggestion[] = [] - try { - const jsonMatch = result.text.match(/\[[\s\S]*\]/) - if (jsonMatch) { - suggestions = JSON.parse(jsonMatch[0]) - } - } catch (e) { - console.error('Failed to parse AI response:', e) - } - - // Deduct credits for successful analysis - const deductResult = await deductCredits(userId, CREDITS.ANALYSIS_COST, 'analysis', { - analysisId, - selectedRepos: selectedRepos.map((r) => r.name), - }) - - if (!deductResult.success) { - console.error('[v0] Failed to deduct credits:', deductResult.error) - return NextResponse.json( - { error: 'Failed to process analysis' }, - { status: 500 } - ) - } - - const newBalance = deductResult.transaction?.balance_after || 0 - - return NextResponse.json({ - analysisId, - suggestions, - totalSuggestions: suggestions.length, - completeSuggestions: suggestions.filter((suggestion) => suggestion.is_complete).length, - creditsUsed: CREDITS.ANALYSIS_COST, - creditsRemaining: newBalance, - }) - } catch (error) { - console.error('Analysis error:', error) - return NextResponse.json({ error: 'Failed to analyze repositories' }, { status: 500 }) - } -} - -async function fetchRepoStructure(repo: SelectedRepository): Promise { - try { - const response = await fetch( - `https://api.github.com/repos/${repo.full_name}/git/trees/${repo.default_branch}?recursive=1`, - { - headers: { - 'Accept': 'application/vnd.github+json', - }, - } - ) - - if (!response.ok) return [] - - const data = (await response.json()) as { - tree?: Array<{ path: string; size?: number; type: string }> - } - - return (data.tree ?? []) - .filter((item) => item.type === 'blob') - .slice(0, 100) // Limit to first 100 files - .map((item) => ({ - path: item.path, - size: item.size, - })) - } catch (error) { - console.error('Error fetching repo structure:', error) - return [] - } -} diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts deleted file mode 100644 index a0f155c..0000000 --- a/app/api/analyze/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { scanCrossPlatformCode } from '@/lib/cross-platform-scanner' -import { discoverApps } from '@/lib/app-discovery' - -export async function POST(request: NextRequest) { - try { - console.log('[v0] Starting cross-platform analysis...') - - // Scan code from all connected platforms - const scannedFiles = await scanCrossPlatformCode() - console.log(`[v0] Scanned ${scannedFiles.length} files`) - - if (scannedFiles.length === 0) { - return NextResponse.json({ error: 'No code files found to analyze' }, { status: 400 }) - } - - // Use AI to discover buildable apps - const discoveredApps = await discoverApps(scannedFiles) - console.log(`[v0] Discovered ${discoveredApps.length} apps`) - - return NextResponse.json({ - success: true, - filesScanned: scannedFiles.length, - appsDiscovered: discoveredApps.length, - apps: discoveredApps, - files: scannedFiles, - }) - } catch (error) { - console.error('[v0] Analysis error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Analysis failed' }, - { status: 500 } - ) - } -} diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 7830f2b..836a90b 100644 --- a/app/api/build-app/route.ts +++ b/app/api/build-app/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from 'next/server' import Anthropic from '@anthropic-ai/sdk' import { getCurrentUser } from '@/lib/auth' import { getAnthropicModel } from '@/lib/anthropic-model' -import { deductCredits, CREDITS } from '@/lib/credits' +import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits' import type { AppBlueprint } from '@/lib/queries' export const maxDuration = 120 @@ -239,15 +239,15 @@ export async function POST(request: NextRequest) { const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() - // Deduct credits before any AI work - const creditResult = await deductCredits( - user.id, - CREDITS.BUILD_APP_COST, - 'build_app', - { repoName: cleanRepoName, platform }, - ) - if (!creditResult.success) { - send({ step: 'error', message: creditResult.error ?? 'Insufficient credits to build this app.' }) + // Verify the user can afford the build up front, but only charge them + // once the app is actually built (see the deduction after 'done' below). + // This prevents losing credits when generation, repo creation, or pushing fails. + const balance = await getCreditBalance(user.id) + if (balance < CREDITS.BUILD_APP_COST) { + send({ + step: 'error', + message: `Insufficient credits. Required: ${CREDITS.BUILD_APP_COST}, Available: ${balance}`, + }) controller.close() return } @@ -326,6 +326,20 @@ export async function POST(request: NextRequest) { }) } + // The build succeeded — charge the user now. Deducting here (rather than + // up front) guarantees failed builds never cost credits. A deduction + // failure must not turn a successful build into an error, so it's logged + // rather than surfaced. + try { + await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', { + repoName: cleanRepoName, + platform, + filesCreated: pushed, + }) + } catch (e) { + console.error('[build-app] credit deduction failed after successful build:', e) + } + send({ step: 'done', message: `${pushed} files pushed successfully.`, diff --git a/components/build-app-modal.tsx b/components/build-app-modal.tsx index d7b9f02..b7d6f29 100644 --- a/components/build-app-modal.tsx +++ b/components/build-app-modal.tsx @@ -87,7 +87,8 @@ export function BuildAppModal({ blueprint, open, onOpenChange }: BuildAppModalPr if (!res.ok || !res.body) { const data = await res.json().catch(() => ({})) - setStep({ id: 'error', message: (data as { error?: string }).error ?? 'Request failed' }) + const fallback = `Request failed (HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''})` + setStep({ id: 'error', message: (data as { error?: string }).error ?? fallback }) return } @@ -156,7 +157,7 @@ export function BuildAppModal({ blueprint, open, onOpenChange }: BuildAppModalPr setStep({ id: 'error', message: msg === 'Load failed' || msg === 'Failed to fetch' - ? 'Connection timed out — please try again. Large projects can take up to 60 seconds.' + ? 'Connection timed out — please try again. Large projects can take up to 2 minutes.' : msg, }) } @@ -278,7 +279,7 @@ export function BuildAppModal({ blueprint, open, onOpenChange }: BuildAppModalPr

- {platform === 'github' && ( + {platform === 'github' ? ( + ) : ( +

+ One-click Vercel preview is available for GitHub repositories. Deploy your + GitLab repository from the Vercel dashboard. +

)}