diff --git a/.env.example b/.env.example index e25292d..85cabb4 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,11 @@ OPENAI_API_KEY=sk-... ANTHROPIC_API_KEY=sk-ant-... # Optional override for analysis + scaffold (default: Claude Sonnet 4.5 snapshot) # ANTHROPIC_ANALYSIS_MODEL=claude-sonnet-4-5-20250929 + +# Stripe live billing +# Prefer a restricted live key (rk_live_...) with Checkout, Customer, Subscription, +# Billing Portal, and webhook read permissions. +STRIPE_LIVE_SECRET_KEY=rk_live_... +STRIPE_LIVE_WEBHOOK_SECRET=whsec_... +STRIPE_LIVE_PRO_PRICE_ID=price_... +STRIPE_LIVE_SCALE_PRICE_ID=price_... diff --git a/app/api/analyses/[id]/run/route.ts b/app/api/analyses/[id]/run/route.ts index 502feb4..b00fbb8 100644 --- a/app/api/analyses/[id]/run/route.ts +++ b/app/api/analyses/[id]/run/route.ts @@ -20,7 +20,7 @@ import { incrementAnalysisUsage, } from '@/lib/queries' import { getAnthropicModel } from '@/lib/anthropic-model' -import { PLANS } from '@/lib/stripe' +import { isPaidPlan, PLANS } from '@/lib/stripe' // Schema for AI-generated app blueprints const complexityEnum = z.preprocess((val) => { @@ -162,7 +162,7 @@ export async function POST( if (!sub) { sub = await upsertSubscription({ github_id: user.github_id }).catch(() => null) } - if (sub && sub.plan !== 'pro') { + if (sub && !isPaidPlan(sub.plan)) { const limit = PLANS.free.analyses_per_month if (sub.analyses_used_this_month >= limit) { send({ error: `You've reached your free plan limit of ${limit} analyses per month. Upgrade to Pro for unlimited analyses.`, status: 'failed' }) diff --git a/app/api/app-idea-chat/route.ts b/app/api/app-idea-chat/route.ts index 5e2c64d..8a81cbd 100644 --- a/app/api/app-idea-chat/route.ts +++ b/app/api/app-idea-chat/route.ts @@ -22,6 +22,9 @@ export interface AppIdeaSuggestion { suggestedStack: string[] monetizationAngle: string whyNow: string + reusePlan?: string + sourceFiles?: string[] + filesToCreate?: string[] } export interface AppIdeaChatResponse { @@ -87,6 +90,13 @@ export async function POST(request: NextRequest) { .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([t]) => t) + const reusableFiles = allFiles + .sort((a, b) => b.reusability_score - a.reusability_score) + .slice(0, 16) + .map((file) => { + const repo = repositories.find((r) => r.id === file.repository_id) + return `${repo?.full_name ?? 'repo'}/${file.path}${file.purpose ? ` - ${file.purpose}` : ''}` + }) codebaseContext = ` ## Developer's codebase context @@ -94,6 +104,8 @@ 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'} +Reusable source files: +${reusableFiles.length > 0 ? reusableFiles.map((file) => `- ${file}`).join('\n') : '- No analyzed file summaries yet'} ` } } catch { @@ -107,13 +119,15 @@ Existing blueprints: ${blueprints.slice(0, 5).map((b) => b.name).join(', ') || ' 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 RepoFuse's VibeCoding app assembler. Help developers describe what they want to build, then turn their connected GitHub/GitLab repository knowledge into buildable app plans that reuse as much existing code, file structure, and patterns as possible. ${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' : ''} +- Suggest 2-4 concrete app builds tailored to their request${codebaseContext ? ' and their codebase' : ''} +- For each suggestion, explain how RepoFuse should stitch together existing files/patterns and which new files are needed +- Prefer specific source file paths from the codebase context when available - Ask a relevant follow-up question to refine suggestions - Be enthusiastic and actionable @@ -127,10 +141,13 @@ Always respond with valid JSON only (no markdown fences): "description": "2-3 sentences", "type": "SaaS | CLI Tool | API | Dashboard | etc", "difficulty": "easy | medium | hard", - "estimatedEffort": "e.g. 1–2 weeks", + "estimatedEffort": "e.g. Small MVP | Medium build | Larger build", "suggestedStack": ["tech1", "tech2"], "monetizationAngle": "How to charge", - "whyNow": "Why this is timely" + "whyNow": "Why this is timely", + "reusePlan": "How RepoFuse should combine existing repo code and patterns", + "sourceFiles": ["repo/path/to/reuse.ts"], + "filesToCreate": ["app/new-feature/page.tsx"] } ], "followUpQuestions": ["Question 1?", "Question 2?"] diff --git a/app/api/repositories/import/route.ts b/app/api/repositories/import/route.ts index eafc62d..690df54 100644 --- a/app/api/repositories/import/route.ts +++ b/app/api/repositories/import/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getCurrentAccessToken, getCurrentUser } from '@/lib/auth' import { listGitHubRepositories } from '@/lib/github' import { createRepository, getAllRepositories, getSubscriptionByGithubId, upsertSubscription } from '@/lib/queries' -import { PLANS } from '@/lib/stripe' +import { isPaidPlan, PLANS } from '@/lib/stripe' export async function POST(request: NextRequest) { try { @@ -25,7 +25,7 @@ export async function POST(request: NextRequest) { if (!sub) { sub = await upsertSubscription({ github_id: user.github_id }).catch(() => null) } - if (sub && sub.plan !== 'pro') { + if (sub && !isPaidPlan(sub.plan)) { repoLimit = PLANS.free.repos_limit } } diff --git a/app/api/repositories/route.ts b/app/api/repositories/route.ts index bd2a03e..9abe588 100644 --- a/app/api/repositories/route.ts +++ b/app/api/repositories/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getAllRepositories, createRepository, getSubscriptionByGithubId, upsertSubscription } from '@/lib/queries' import { getCurrentUser } from '@/lib/auth' -import { PLANS } from '@/lib/stripe' +import { isPaidPlan, PLANS } from '@/lib/stripe' export async function GET() { try { @@ -21,7 +21,7 @@ export async function POST(request: NextRequest) { if (!sub) { sub = await upsertSubscription({ github_id: user.github_id }).catch(() => null) } - if (sub && sub.plan !== 'pro') { + if (sub && !isPaidPlan(sub.plan)) { const repos = await getAllRepositories() if (repos.length >= PLANS.free.repos_limit) { return NextResponse.json( diff --git a/app/api/setup/init-db/route.ts b/app/api/setup/init-db/route.ts index 76b4f3b..69ce84e 100644 --- a/app/api/setup/init-db/route.ts +++ b/app/api/setup/init-db/route.ts @@ -113,7 +113,7 @@ async function run() { github_id BIGINT NOT NULL UNIQUE, stripe_customer_id VARCHAR(255) UNIQUE, stripe_subscription_id VARCHAR(255) UNIQUE, - plan VARCHAR(50) DEFAULT 'free' CHECK (plan IN ('free', 'pro')), + plan VARCHAR(50) DEFAULT 'free' CHECK (plan IN ('free', 'byok', 'pro', 'scale')), status VARCHAR(50) DEFAULT 'active' CHECK (status IN ('active', 'past_due', 'canceled', 'trialing')), current_period_end TIMESTAMP WITH TIME ZONE, analyses_used_this_month INTEGER DEFAULT 0, diff --git a/app/api/stripe/checkout-redirect/route.ts b/app/api/stripe/checkout-redirect/route.ts index 1050526..4e8feaa 100644 --- a/app/api/stripe/checkout-redirect/route.ts +++ b/app/api/stripe/checkout-redirect/route.ts @@ -51,9 +51,10 @@ export async function GET(request: NextRequest) { line_items: [{ price: priceId, quantity: 1 }], success_url: `${appUrl}/dashboard?upgraded=true`, cancel_url: `${appUrl}/pricing?cancelled=true`, + metadata: { github_id: String(user.github_id), plan: 'pro' }, subscription_data: { trial_period_days: 14, // Launch offer: 14 days free - metadata: { github_id: String(user.github_id) }, + metadata: { github_id: String(user.github_id), plan: 'pro' }, }, }) diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts index 7dd44fc..0fb3de5 100644 --- a/app/api/stripe/checkout/route.ts +++ b/app/api/stripe/checkout/route.ts @@ -53,6 +53,7 @@ export async function POST(request: NextRequest) { line_items: [{ price: priceId, quantity: 1 }], success_url: `${appUrl}/dashboard?upgraded=true`, cancel_url: `${appUrl}/pricing`, + metadata: { github_id: String(user.github_id), plan }, subscription_data: { metadata: { github_id: String(user.github_id), plan }, }, diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index eddca3e..4ab3ee3 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -1,252 +1,169 @@ -import { NextRequest, NextResponse } from "next/server"; -import Stripe from "stripe"; -import { createClient } from "@supabase/supabase-js"; - -// ─── Clients ────────────────────────────────────────────────────────────────── - -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2024-11-20.acacia", -}); - -// Use the service role key so we can write to Supabase from the server -const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! -); - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function getPlanFromPriceId(priceId: string): string { - const priceMap: Record = { - [process.env.STRIPE_PRICE_PRO_MONTHLY || ""]: "pro", - [process.env.STRIPE_PRICE_PRO_YEARLY || ""]: "pro", - [process.env.STRIPE_PRICE_STARTER_MONTHLY || ""]: "starter", - }; - return priceMap[priceId] || "free"; -} - -// ─── Webhook Handler ────────────────────────────────────────────────────────── - -export async function POST(req: NextRequest) { - // 1. Get the raw body — required for Stripe signature verification - const body = await req.text(); - const sig = req.headers.get("stripe-signature"); +import { NextRequest, NextResponse } from 'next/server' +import Stripe from 'stripe' +import { getPriceIdForPlan, getStripe, getStripeWebhookSecret } from '@/lib/stripe' +import { upsertSubscription, type Subscription } from '@/lib/queries' - if (!sig) { - console.error("⚠️ Missing stripe-signature header"); - return NextResponse.json({ error: "Missing signature" }, { status: 400 }); - } - - if (!process.env.STRIPE_WEBHOOK_SECRET) { - console.error("⚠️ STRIPE_WEBHOOK_SECRET is not set in environment variables"); - return NextResponse.json({ error: "Webhook secret not configured" }, { status: 500 }); - } +type BillingPlan = Extract +type BillingStatus = Subscription['status'] - // 2. Verify the event actually came from Stripe - let event: Stripe.Event; - try { - event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Unknown error"; - console.error(`⚠️ Webhook signature verification failed: ${message}`); - return NextResponse.json({ error: `Webhook Error: ${message}` }, { status: 400 }); - } - - console.log(`✅ Stripe webhook received: ${event.type}`); - - // 3. Handle each event type - try { - switch (event.type) { +function getPlanFromPriceId(priceId: string | undefined): BillingPlan | null { + if (!priceId) return null - // ── Checkout completed (first subscription purchase) ────────────────── - case "checkout.session.completed": { - const session = event.data.object as Stripe.Checkout.Session; - const customerId = session.customer as string; - const subscriptionId = session.subscription as string; - const customerEmail = session.customer_email || session.customer_details?.email; + const priceMap = new Map() + const proPriceId = getPriceIdForPlan('pro') + const scalePriceId = getPriceIdForPlan('scale') - if (!customerEmail) break; + if (proPriceId) priceMap.set(proPriceId, 'pro') + if (scalePriceId) priceMap.set(scalePriceId, 'scale') - await supabase - .from("subscriptions") - .upsert({ - stripe_customer_id: customerId, - stripe_subscription_id: subscriptionId, - email: customerEmail, - status: "active", - updated_at: new Date().toISOString(), - }, { onConflict: "email" }); - - // Also update the user's plan in the profiles table - await supabase - .from("profiles") - .update({ - stripe_customer_id: customerId, - subscription_status: "active", - }) - .eq("email", customerEmail); + return priceMap.get(priceId) ?? null +} - console.log(`✅ Checkout completed for ${customerEmail}`); - break; - } +function normalizeSubscriptionStatus(status: Stripe.Subscription.Status): BillingStatus { + if (status === 'trialing') return 'trialing' + if (status === 'past_due' || status === 'unpaid' || status === 'incomplete') return 'past_due' + if (status === 'canceled' || status === 'incomplete_expired') return 'canceled' + return 'active' +} - // ── Subscription created ─────────────────────────────────────────────── - case "customer.subscription.created": { - const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - const priceId = subscription.items.data[0]?.price.id; - const plan = getPlanFromPriceId(priceId); +function getCurrentPeriodEnd(subscription: Stripe.Subscription): string | null { + const currentPeriodEnd = (subscription as unknown as { current_period_end?: number }).current_period_end + return currentPeriodEnd ? new Date(currentPeriodEnd * 1000).toISOString() : null +} - // Get customer email from Stripe - const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; +function getGithubIdFromMetadata(...metadataSources: Array): number | null { + for (const metadata of metadataSources) { + const rawGithubId = metadata?.github_id + if (!rawGithubId) continue + const githubId = Number.parseInt(rawGithubId, 10) + if (!Number.isNaN(githubId)) return githubId + } + return null +} - if (!email) break; +function getInvoiceSubscriptionId(invoice: Stripe.Invoice): string | null { + const legacy = (invoice as unknown as { subscription?: string | { id?: string } }).subscription + if (typeof legacy === 'string') return legacy + if (legacy?.id) return legacy.id - await supabase - .from("subscriptions") - .upsert({ - stripe_customer_id: customerId, - stripe_subscription_id: subscription.id, - email, - status: subscription.status, - plan, - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }, { onConflict: "stripe_subscription_id" }); - - await supabase - .from("profiles") - .update({ plan, subscription_status: subscription.status }) - .eq("email", email); - - console.log(`✅ Subscription created: ${email} → ${plan}`); - break; - } + const parentSubscription = (invoice as unknown as { + parent?: { subscription_details?: { subscription?: string } } + }).parent?.subscription_details?.subscription - // ── Subscription updated (plan change, renewal, etc.) ───────────────── - case "customer.subscription.updated": { - const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - const priceId = subscription.items.data[0]?.price.id; - const plan = getPlanFromPriceId(priceId); - - const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await supabase - .from("subscriptions") - .update({ - status: subscription.status, - plan, - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }) - .eq("stripe_subscription_id", subscription.id); + return parentSubscription ?? null +} - await supabase - .from("profiles") - .update({ plan, subscription_status: subscription.status }) - .eq("email", email); +async function upsertFromSubscription(subscription: Stripe.Subscription, fallbackMetadata?: Stripe.Metadata | null) { + const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id + const priceId = subscription.items.data[0]?.price.id + const plan = getPlanFromPriceId(priceId) ?? getMetadataPlan(subscription.metadata, fallbackMetadata) ?? 'pro' + const githubId = getGithubIdFromMetadata(subscription.metadata, fallbackMetadata) - console.log(`✅ Subscription updated: ${email} → ${plan} (${subscription.status})`); - break; - } + if (!githubId) { + console.warn('[stripe-webhook] Missing github_id metadata for subscription:', subscription.id) + return + } - // ── Subscription cancelled/deleted ──────────────────────────────────── - case "customer.subscription.deleted": { - const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; + await upsertSubscription({ + github_id: githubId, + stripe_customer_id: customerId, + stripe_subscription_id: subscription.id, + plan, + status: normalizeSubscriptionStatus(subscription.status), + current_period_end: getCurrentPeriodEnd(subscription), + }) +} - const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; +function getMetadataPlan(...metadataSources: Array): BillingPlan | null { + for (const metadata of metadataSources) { + if (metadata?.plan === 'pro' || metadata?.plan === 'scale') return metadata.plan + } + return null +} - if (!email) break; +export async function POST(req: NextRequest) { + const signature = req.headers.get('stripe-signature') + if (!signature) { + return NextResponse.json({ error: 'Missing signature' }, { status: 400 }) + } - await supabase - .from("subscriptions") - .update({ - status: "canceled", - plan: "free", - updated_at: new Date().toISOString(), - }) - .eq("stripe_subscription_id", subscription.id); + const webhookSecret = getStripeWebhookSecret() + if (!webhookSecret) { + return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 }) + } - await supabase - .from("profiles") - .update({ plan: "free", subscription_status: "canceled" }) - .eq("email", email); + let event: Stripe.Event + try { + const body = await req.text() + event = getStripe().webhooks.constructEvent(body, signature, webhookSecret) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + console.error('[stripe-webhook] Signature verification failed:', message) + return NextResponse.json({ error: `Webhook Error: ${message}` }, { status: 400 }) + } - console.log(`✅ Subscription cancelled: ${email}`); - break; + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session + if (typeof session.subscription === 'string') { + const subscription = await getStripe().subscriptions.retrieve(session.subscription) + await upsertFromSubscription(subscription, session.metadata) + } else { + const githubId = getGithubIdFromMetadata(session.metadata) + const customerId = typeof session.customer === 'string' ? session.customer : null + if (githubId && customerId) { + await upsertSubscription({ github_id: githubId, stripe_customer_id: customerId }) + } + } + break } - // ── Invoice paid (recurring renewal) ───────────────────────────────── - case "invoice.payment_succeeded": { - const invoice = event.data.object as Stripe.Invoice; - const subscriptionId = invoice.subscription as string; - - if (!subscriptionId) break; - - // Update period end on renewal - const subscription = await stripe.subscriptions.retrieve(subscriptionId); - await supabase - .from("subscriptions") - .update({ - status: "active", - current_period_end: new Date(subscription.current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }) - .eq("stripe_subscription_id", subscriptionId); - - console.log(`✅ Invoice paid for subscription: ${subscriptionId}`); - break; + case 'customer.subscription.created': + case 'customer.subscription.updated': { + await upsertFromSubscription(event.data.object as Stripe.Subscription) + break } - // ── Invoice payment failed ──────────────────────────────────────────── - case "invoice.payment_failed": { - const invoice = event.data.object as Stripe.Invoice; - const customerId = invoice.customer as string; - - const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await supabase - .from("subscriptions") - .update({ - status: "past_due", - updated_at: new Date().toISOString(), + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription + const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id + const githubId = getGithubIdFromMetadata(subscription.metadata) + if (githubId) { + await upsertSubscription({ + github_id: githubId, + stripe_customer_id: customerId, + stripe_subscription_id: subscription.id, + plan: 'free', + status: 'canceled', + current_period_end: getCurrentPeriodEnd(subscription), }) - .eq("stripe_customer_id", customerId); - - await supabase - .from("profiles") - .update({ subscription_status: "past_due" }) - .eq("email", email); + } + break + } - console.log(`⚠️ Payment failed for: ${email}`); - break; + case 'invoice.payment_succeeded': + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice + const subscriptionId = getInvoiceSubscriptionId(invoice) + if (subscriptionId) { + const subscription = await getStripe().subscriptions.retrieve(subscriptionId) + await upsertFromSubscription(subscription) + } + break } default: - console.log(`ℹ️ Unhandled event type: ${event.type}`); + console.log('[stripe-webhook] Unhandled event type:', event.type) } } catch (error) { - console.error("❌ Error processing webhook event:", error); - // Still return 200 so Stripe doesn't keep retrying for logic errors - return NextResponse.json({ received: true, warning: "Processing error" }, { status: 200 }); + console.error('[stripe-webhook] Error processing event:', error) + return NextResponse.json({ received: true, warning: 'Processing error' }, { status: 200 }) } - // 4. Always return 200 so Stripe knows we received the event - return NextResponse.json({ received: true }, { status: 200 }); + return NextResponse.json({ received: true }) } -// ─── Block all other HTTP methods ───────────────────────────────────────────── -// This prevents the 405 error Stripe was getting before export async function GET() { - return NextResponse.json({ error: "Method not allowed" }, { status: 405 }); -} \ No newline at end of file + return NextResponse.json({ error: 'Method not allowed' }, { status: 405 }) +} diff --git a/app/page.tsx b/app/page.tsx index 7a571e9..ca2c41f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,4 @@ 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, Zap } from 'lucide-react' @@ -220,7 +219,7 @@ export default async function HomePage({ searchParams }: { searchParams: Promise

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

@@ -251,7 +250,7 @@ export default async function HomePage({ searchParams }: { searchParams: Promise already in your repos

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

= { - pending: { - label: 'Pending', - color: 'text-muted-foreground', - badgeClass: 'bg-muted text-muted-foreground border-0', - cardBorder: 'border-border/60', - icon: Clock, - }, - scanning: { - label: 'Scanning', - 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 border-0', - cardBorder: 'border-chart-2/30', - icon: Sparkles, - }, - complete: { - label: 'Complete', - color: '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 border-0', - cardBorder: 'border-destructive/30', - icon: XCircle, - }, +const DIFFICULTY_META: Record = { + easy: { label: 'Easy', badgeClass: 'bg-chart-1/10 text-chart-1 border-0', icon: Sparkles }, + medium: { label: 'Medium', badgeClass: 'bg-chart-2/10 text-chart-2 border-0', icon: LayoutGrid }, + hard: { label: 'Hard', badgeClass: 'bg-destructive/10 text-destructive border-0', icon: Code2 }, } -const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [ +const DIFFICULTY_FILTERS: { value: DifficultyFilter; label: string }[] = [ { value: 'all', label: 'All' }, - { value: 'complete', label: 'Complete' }, - { value: 'analyzing', label: 'Analyzing' }, - { value: 'scanning', label: 'Scanning' }, - { value: 'pending', label: 'Pending' }, - { value: 'failed', label: 'Failed' }, + { value: 'easy', label: 'Easy' }, + { value: 'medium', label: 'Medium' }, + { value: 'hard', label: 'Hard' }, ] -function AnalysisCard({ analysis }: { analysis: Analysis }) { - const meta = STATUS_META[analysis.status] - const StatusIcon = meta.icon - const progress = - analysis.total_files > 0 - ? Math.round((analysis.analyzed_files / analysis.total_files) * 100) - : 0 +function LikedAppCard({ app, onRemove }: { app: LikedApp; onRemove: (id: string) => void }) { + const meta = DIFFICULTY_META[app.difficulty] ?? DIFFICULTY_META.medium + const DifficultyIcon = meta.icon return ( - +
-
-
- +
+
+
-

{analysis.name}

-

- {new Date(analysis.created_at).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - })} -

+

{app.name}

+

{app.tagline}

- + +
+ +

{app.description}

+ +
+ {app.type} + + {meta.label} + {app.suggestedStack.slice(0, 4).map((tech) => ( + {tech} + ))}
- {analysis.total_files > 0 && ( -
-
- {analysis.analyzed_files} / {analysis.total_files} files - {progress}% -
-
-
+ {app.reusePlan && ( +
+
+ + RepoFuse reuse plan
+ {app.reusePlan}
)} - {analysis.error_message && ( -

- {analysis.error_message} -

- )} +
+ {app.sourceFiles && app.sourceFiles.length > 0 && ( +
+
Reuse
+
+ {app.sourceFiles.slice(0, 3).map((file) => ( +
{file}
+ ))} +
+
+ )} + {app.filesToCreate && app.filesToCreate.length > 0 && ( +
+
+ + Create +
+
+ {app.filesToCreate.slice(0, 3).map((file) => ( +
{file}
+ ))} +
+
+ )} +
- +
+ + Liked {new Date(app.selectedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} + + +
) } export function IdeaBoard() { - const [analyses, setAnalyses] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [likedApps, setLikedApps] = useState([]) const [search, setSearch] = useState('') - const [statusFilter, setStatusFilter] = useState('all') - const [refreshing, setRefreshing] = useState(false) - - const fetchAnalyses = async (isRefresh = false) => { - if (isRefresh) setRefreshing(true) - else setLoading(true) - setError(null) - try { - const res = await fetch('/api/analyses') - if (!res.ok) throw new Error('Failed to load analyses') - const data: Analysis[] = await res.json() - setAnalyses(data) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load analyses') - } finally { - setLoading(false) - setRefreshing(false) - } - } + const [difficultyFilter, setDifficultyFilter] = useState('all') useEffect(() => { - fetchAnalyses() + const refresh = () => setLikedApps(getLikedApps()) + refresh() + return subscribeToLikedApps(refresh) }, []) - const filtered = analyses.filter((a) => { - const matchesStatus = statusFilter === 'all' || a.status === statusFilter - const matchesSearch = a.name.toLowerCase().includes(search.toLowerCase()) - return matchesStatus && matchesSearch - }) + const filtered = useMemo(() => { + const query = search.trim().toLowerCase() + return likedApps.filter((app) => { + const matchesDifficulty = difficultyFilter === 'all' || app.difficulty === difficultyFilter + const matchesSearch = + !query || + app.name.toLowerCase().includes(query) || + app.description.toLowerCase().includes(query) || + app.suggestedStack.some((tech) => tech.toLowerCase().includes(query)) + return matchesDifficulty && matchesSearch + }) + }, [difficultyFilter, likedApps, search]) - const counts = analyses.reduce( - (acc, a) => { - acc[a.status] = (acc[a.status] || 0) + 1 + const counts = likedApps.reduce( + (acc, app) => { + acc[app.difficulty] += 1 return acc }, - {} as Record, + { easy: 0, medium: 0, hard: 0 } as Record, ) - const completeCount = counts['complete'] || 0 - const failedCount = counts['failed'] || 0 - const inProgressCount = (counts['scanning'] || 0) + (counts['analyzing'] || 0) + const handleRemove = (id: string) => { + setLikedApps(removeLikedApp(id)) + } return (
- {/* Header */} -
+
-
-
- +
+
+
-

Idea Board

+

Liked Apps

-

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

-
-
- - +

+ Apps you picked from VibeCoding Chat, ready to refine into RepoFuse builds. +

+
- {/* Stats strip */} - {!loading && analyses.length > 0 && ( -
- 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

-
-
-
+ {likedApps.length > 0 && ( +
+ {(['easy', 'medium', 'hard'] as LikedAppDifficulty[]).map((difficulty) => { + const meta = DIFFICULTY_META[difficulty] + const Icon = meta.icon + return ( + setDifficultyFilter(difficultyFilter === difficulty ? 'all' : difficulty)} + > +
+
+ +
+
+

{counts[difficulty]}

+

{meta.label} builds

+
+
+
+ ) + })}
)} - {/* Filters */} -
-
- +
+
+ setSearch(e.target.value)} className="pl-9" />
- {STATUS_FILTERS.map((f) => ( + {DIFFICULTY_FILTERS.map((filter) => ( ))}
- {/* Content */} - {loading && ( -
-
- -

Loading analyses...

-
-
- )} - - {error && ( - - -

Failed to load analyses

-

{error}

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

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

+ {likedApps.length === 0 ? 'No liked apps yet' : 'No liked apps match'}

-

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

+ {likedApps.length === 0 + ? 'Open VibeCoding Chat, ask what to build, then heart the app cards you want RepoFuse to assemble.' + : 'Try adjusting your search or difficulty filter.'}

- {analyses.length === 0 && ( + {likedApps.length === 0 && ( )} - )} - - {!loading && !error && filtered.length > 0 && ( + ) : (
- {filtered.map((analysis) => ( - + {filtered.map((app) => ( + ))}
)} diff --git a/components/pattern-analyzer.tsx b/components/pattern-analyzer.tsx index 0c09371..4193980 100644 --- a/components/pattern-analyzer.tsx +++ b/components/pattern-analyzer.tsx @@ -4,7 +4,7 @@ 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 { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, @@ -24,9 +24,13 @@ import { ChevronUp, Bot, User, + Heart, + Code2, + FilePlus2, } from 'lucide-react' import type { Analysis } from '@/lib/queries' import type { AppIdeaSuggestion, AppIdeaChatResponse, ChatMessage } from '@/app/api/app-idea-chat/route' +import { createLikedAppId, getLikedApps, toggleLikedApp } from '@/lib/liked-apps' const DIFFICULTY_META = { easy: { label: 'Easy', class: 'bg-chart-1/10 text-chart-1' }, @@ -35,13 +39,21 @@ const DIFFICULTY_META = { } 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', + 'Vibe-code a SaaS from my existing repos', + 'Turn my repo patterns into a developer tool', + 'Use my codebase to assemble an AI product', + 'Find the fastest app I can build from existing files', ] -function SuggestionCard({ suggestion }: { suggestion: AppIdeaSuggestion }) { +function SuggestionCard({ + suggestion, + liked, + onToggleLiked, +}: { + suggestion: AppIdeaSuggestion + liked: boolean + onToggleLiked: (suggestion: AppIdeaSuggestion) => void +}) { const [expanded, setExpanded] = useState(false) const diff = DIFFICULTY_META[suggestion.difficulty] ?? DIFFICULTY_META.medium @@ -55,7 +67,22 @@ function SuggestionCard({ suggestion }: { suggestion: AppIdeaSuggestion }) {

{suggestion.tagline}

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

{suggestion.description}

@@ -79,17 +106,54 @@ function SuggestionCard({ suggestion }: { suggestion: AppIdeaSuggestion }) {
)} + {suggestion.reusePlan && ( +
+
+ + RepoFuse assembly plan +
+ {suggestion.reusePlan} +
+ )} + {expanded && ( -
- {suggestion.whyNow} +
+ {suggestion.sourceFiles && suggestion.sourceFiles.length > 0 && ( +
+
+ + Reuse these files +
+
+ {suggestion.sourceFiles.slice(0, 4).map((file) => ( +
{file}
+ ))} +
+
+ )} + {suggestion.filesToCreate && suggestion.filesToCreate.length > 0 && ( +
+
+ + Create these files +
+
+ {suggestion.filesToCreate.slice(0, 4).map((file) => ( +
{file}
+ ))} +
+
+ )} +
{suggestion.whyNow}
)} @@ -99,9 +163,11 @@ function SuggestionCard({ suggestion }: { suggestion: AppIdeaSuggestion }) { interface ChatBubbleProps { message: ChatMessage & { suggestions?: AppIdeaSuggestion[]; followUpQuestions?: string[] } onFollowUp?: (q: string) => void + likedIds: Set + onToggleLiked: (suggestion: AppIdeaSuggestion) => void } -function ChatBubble({ message, onFollowUp }: ChatBubbleProps) { +function ChatBubble({ message, onFollowUp, likedIds, onToggleLiked }: ChatBubbleProps) { const isUser = message.role === 'user' return ( @@ -115,7 +181,7 @@ function ChatBubble({ message, onFollowUp }: ChatBubbleProps) {
0 && (
{message.suggestions.map((s) => ( - + ))}
)} @@ -136,6 +207,7 @@ function ChatBubble({ message, onFollowUp }: ChatBubbleProps) {
{message.followUpQuestions.map((q) => (

- Each message costs credits. Be specific for better results. + Like any app card to pin it to Idea Board.

diff --git a/components/repositories-list.tsx b/components/repositories-list.tsx index 872912c..5a7cd6e 100644 --- a/components/repositories-list.tsx +++ b/components/repositories-list.tsx @@ -73,6 +73,14 @@ export function RepositoriesList({ repositories }: RepositoriesListProps) { if (selectableGithubIds.length === 0) return false return selectableGithubIds.every((id) => selectedRepos.includes(id)) }, [selectableGithubIds, selectedRepos]) + const trackedLanguages = useMemo( + () => Array.from(new Set(repositories.map((repo) => repo.language).filter(Boolean))), + [repositories], + ) + const totalStars = useMemo( + () => repositories.reduce((sum, repo) => sum + repo.stars, 0), + [repositories], + ) const oauthError = searchParams.get('error') const oauthConnected = searchParams.get('connected') @@ -217,41 +225,92 @@ export function RepositoriesList({ repositories }: RepositoriesListProps) { return (
-
-
-

Repositories

-

Sign in with GitHub, import repositories, and track them in RepoFuse.

+ +
+
+
+ + Repo workspace +
+

Repositories

+

+ Connect your code sources, import the repos RepoFuse can analyze, and turn existing files into VibeCoding build blocks. +

+
+ {loadingAuth ? ( + + ) : auth?.authenticated ? ( +
+
+ Connected as @{auth.user?.github_username} +
+
+ + +
+
+ ) : ( + + )}
- {loadingAuth ? ( - - ) : auth?.authenticated ? ( +
+ +
+ {(oauthConnected || error || auth?.error || oauthError) && ( @@ -262,12 +321,12 @@ export function RepositoriesList({ repositories }: RepositoriesListProps) { )} - +
-

GitHub import

+

Import from GitHub

- Connect GitHub to import public and private repositories directly into the app. + Choose public or private repositories to make available for scans, app ideas, and code assembly.

{auth?.authenticated ? ( @@ -341,7 +400,7 @@ export function RepositoriesList({ repositories }: RepositoriesListProps) { {githubRepos.map((repo) => { const alreadyImported = importedGithubIds.has(repo.id) return ( - +