diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index cb8d6b9..aae9279 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -1,268 +1,189 @@ -import { NextRequest, NextResponse } from "next/server"; -import Stripe from "stripe"; -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; - -export const dynamic = "force-dynamic"; -export const runtime = "nodejs"; - -// ─── Lazy clients (avoid build-time evaluation) ────────────────────────────── - -let _stripe: Stripe | null = null; -function getStripeClient(): Stripe { - if (_stripe) return _stripe; - const key = process.env.STRIPE_SECRET_KEY; - if (!key) { - throw new Error("STRIPE_SECRET_KEY is not configured"); +import { NextRequest, NextResponse } from 'next/server' +import { getStripe } from '@/lib/stripe' +import { upsertSubscription, getSubscriptionByStripeCustomerId, getUserByGithubId } from '@/lib/queries' +import { grantCredits, CREDITS } from '@/lib/credits' +import type Stripe from 'stripe' + +/** Stripe SDK requires Node.js (not Edge). */ +export const runtime = 'nodejs' + +const HANDLED_EVENTS = new Set([ + 'checkout.session.completed', + 'customer.subscription.updated', + 'customer.subscription.deleted', + 'invoice.payment_failed', + 'invoice.payment_succeeded', +]) + +export async function POST(request: NextRequest) { + const signature = request.headers.get('stripe-signature') + + if (!process.env.STRIPE_WEBHOOK_SECRET || !process.env.STRIPE_SECRET_KEY) { + console.error('[stripe/webhook] Missing STRIPE_WEBHOOK_SECRET or STRIPE_SECRET_KEY in environment') + return NextResponse.json({ error: 'Webhook not configured' }, { status: 503 }) } - _stripe = new Stripe(key); - return _stripe; -} -let _supabase: SupabaseClient | null = null; -function getSupabaseClient(): SupabaseClient { - if (_supabase) return _supabase; - const url = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!url || !serviceKey) { - throw new Error("Supabase env vars are not configured"); + if (!signature) { + return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 }) } - _supabase = createClient(url, serviceKey); - return _supabase; -} - -// ─── 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"); - - 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 }); + let body: string + try { + body = await request.text() + } catch (err) { + console.error('[stripe/webhook] Failed to read request body:', err) + return NextResponse.json({ error: 'Invalid body' }, { status: 400 }) } - // 2. Verify the event actually came from Stripe - let event: Stripe.Event; + let stripe: ReturnType try { - event = getStripeClient().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 }); + stripe = getStripe() + } catch (err) { + console.error('[stripe/webhook] Stripe client init failed:', err) + return NextResponse.json({ error: 'Stripe not configured' }, { status: 503 }) } - console.log(`✅ Stripe webhook received: ${event.type}`); + let event: Stripe.Event - // 3. Handle each event type try { - switch (event.type) { - - // ── 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; - - if (!customerEmail) break; - - await getSupabaseClient() - .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 getSupabaseClient() - .from("profiles") - .update({ - stripe_customer_id: customerId, - subscription_status: "active", - }) - .eq("email", customerEmail); - - console.log(`✅ Checkout completed for ${customerEmail}`); - break; - } - - // ── 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); - - // Get customer email from Stripe - const customer = await getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; + event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET) + } catch (err) { + console.error('[stripe/webhook] Signature verification failed:', err) + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }) + } - await getSupabaseClient() - .from("subscriptions") - .upsert({ - stripe_customer_id: customerId, - stripe_subscription_id: subscription.id, - email, - status: subscription.status, - plan, - current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }, { onConflict: "stripe_subscription_id" }); + console.log('[stripe/webhook] Received event', { id: event.id, type: event.type }) - await getSupabaseClient() - .from("profiles") - .update({ plan, subscription_status: subscription.status }) - .eq("email", email); + if (!HANDLED_EVENTS.has(event.type)) { + return NextResponse.json({ received: true, ignored: event.type }) + } - console.log(`✅ Subscription created: ${email} → ${plan}`); - break; + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session + if (session.mode === 'subscription' && session.customer && session.subscription) { + 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 + if (githubId) { + await upsertSubscription({ + github_id: githubId, + stripe_customer_id: session.customer as string, + stripe_subscription_id: sub.id, + plan: 'pro', + status: 'active', + current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null, + }) + + try { + const user = await getUserByGithubId(githubId) + if (user) { + await grantCredits( + user.id, + CREDITS.INITIAL_GRANT, + 'Pro plan signup bonus', + { stripe_customer_id: session.customer as string }, + ) + console.log(`[stripe/webhook] Granted ${CREDITS.INITIAL_GRANT} signup credits to ${user.id}`) + } + } catch (err) { + console.error('[stripe/webhook] Failed to grant signup credits (subscription saved):', err) + } + } else { + console.warn('[stripe/webhook] checkout.session.completed missing github_id metadata', { + sessionId: session.id, + }) + } + } + break } - // ── 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 getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await getSupabaseClient() - .from("subscriptions") - .update({ - status: subscription.status, - plan, - current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), + case 'customer.subscription.updated': { + const sub = event.data.object as Stripe.Subscription + 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' + await upsertSubscription({ + github_id: existing.github_id, + plan: isPro ? 'pro' : 'free', + status: + sub.status === 'active' + ? 'active' + : sub.status === 'past_due' + ? 'past_due' + : sub.status === 'trialing' + ? 'trialing' + : 'canceled', + current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null, }) - .eq("stripe_subscription_id", subscription.id); - - await getSupabaseClient() - .from("profiles") - .update({ plan, subscription_status: subscription.status }) - .eq("email", email); - - console.log(`✅ Subscription updated: ${email} → ${plan} (${subscription.status})`); - break; + } + break } - // ── Subscription cancelled/deleted ──────────────────────────────────── - case "customer.subscription.deleted": { - const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - - const customer = await getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await getSupabaseClient() - .from("subscriptions") - .update({ - status: "canceled", - plan: "free", - updated_at: new Date().toISOString(), + case 'customer.subscription.deleted': { + const sub = event.data.object as Stripe.Subscription + const existing = await getSubscriptionByStripeCustomerId(sub.customer as string) + if (existing) { + await upsertSubscription({ + github_id: existing.github_id, + plan: 'free', + status: 'canceled', }) - .eq("stripe_subscription_id", subscription.id); - - await getSupabaseClient() - .from("profiles") - .update({ plan: "free", subscription_status: "canceled" }) - .eq("email", email); - - console.log(`✅ Subscription cancelled: ${email}`); - break; + } + break } - // ── Invoice paid (recurring renewal) ───────────────────────────────── - case "invoice.payment_succeeded": { - const invoice = event.data.object as Stripe.Invoice; - const subscriptionId = (invoice as unknown as { subscription?: string }).subscription as string | undefined; - - if (!subscriptionId) break; - - // Update period end on renewal - const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId); - await getSupabaseClient() - .from("subscriptions") - .update({ - status: "active", - current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }) - .eq("stripe_subscription_id", subscriptionId); - - console.log(`✅ Invoice paid for subscription: ${subscriptionId}`); - break; + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice + if (invoice.customer) { + const existing = await getSubscriptionByStripeCustomerId(invoice.customer as string) + if (existing) { + await upsertSubscription({ + github_id: existing.github_id, + status: 'past_due', + }) + } + } + 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 getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await getSupabaseClient() - .from("subscriptions") - .update({ - status: "past_due", - updated_at: new Date().toISOString(), - }) - .eq("stripe_customer_id", customerId); - - await getSupabaseClient() - .from("profiles") - .update({ subscription_status: "past_due" }) - .eq("email", email); - - console.log(`⚠️ Payment failed for: ${email}`); - break; + case 'invoice.payment_succeeded': { + const invoice = event.data.object as Stripe.Invoice + if (invoice.customer) { + const existing = await getSubscriptionByStripeCustomerId(invoice.customer as string) + if (existing) { + try { + if (invoice.number && invoice.number !== '0001') { + const user = await getUserByGithubId(existing.github_id) + if (user) { + await grantCredits( + user.id, + CREDITS.MONTHLY_GRANT, + 'Monthly subscription renewal', + { invoice_id: invoice.id, stripe_customer_id: invoice.customer as string }, + ) + console.log(`[stripe/webhook] Granted ${CREDITS.MONTHLY_GRANT} renewal credits to ${user.id}`) + } + } + } catch (err) { + console.error('[stripe/webhook] Failed to grant renewal credits:', err) + } + } + } + break } - - default: - console.log(`ℹ️ 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 }); + } catch (err) { + console.error('[stripe/webhook] Handler error:', { eventId: event.id, type: event.type, err }) + return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 }) } - // 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 +/** Stripe probes with GET; explicit 405 avoids ambiguous routing errors. */ 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/layout.tsx b/app/layout.tsx index 41cfdf9..3edd10c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -68,7 +68,6 @@ export const metadata: Metadata = { export const viewport = { width: 'device-width', initialScale: 1, - maximumScale: 1, } export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index 7a571e9..c267264 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,8 @@ 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' +import { ImpactStats } from '@/components/impact-stats' +import { Testimonials } from '@/components/testimonials' const ERROR_MESSAGES: Record = { auth_required: 'You must sign in to access the dashboard.', @@ -35,6 +37,26 @@ export default async function HomePage({ searchParams }: { searchParams: Promise backgroundSize: '256px' }} /> + {/* Post-launch offer */} +
+
+ +
+

+ Now live — start free today +

+

+ 14-day Pro trial + {' · '} + free tier includes repo scans and blueprint previews +

+
+ +
+
+ {/* Header */}
@@ -65,15 +87,12 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
- {/* Hero Section */}
- {/* Subtle radial glow */}
- {/* Trust badge */}
@@ -83,19 +102,16 @@ export default async function HomePage({ searchParams }: { searchParams: Promise The #1 Repo Intelligence Platform
- {/* Headline */}

Your repos are hiding buildable apps

- {/* Subheading */}

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

- {/* Primary CTA */} - {/* Sub-text */}

Connect in seconds. No credit card required.

- {/* Social proof */}
- {['#22d3ee','#a78bfa','#fb923c','#4ade80'].map((c, i) => ( + {['#22d3ee', '#a78bfa', '#fb923c', '#4ade80'].map((c, i) => (
))}
- 2,400+ developers already scanning + Join developers turning repos into shippable products
- {/* Terminal preview */}
- {/* Window chrome */}
@@ -133,7 +145,6 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
- {/* Content */}
$ @@ -161,26 +172,8 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
- {/* Metrics Strip */} -
-
-
- {[ - { 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 */}
@@ -211,7 +204,6 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
- {/* Feature Grid */}
@@ -243,7 +235,8 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
- {/* Bottom CTA */} + +
- {/* Footer */}