diff --git a/.env b/.env index 61af62d7..5daa1eda 100644 --- a/.env +++ b/.env @@ -7,3 +7,9 @@ SERVER_ACTIONS_ALLOWED_ORIGINS="*" # Authentication Configuration # Disable Supabase auth and use mock user for development/preview AUTH_DISABLED_FOR_DEV="false" + +# Standard Tier Configuration +STANDARD_TIER_PRICE_ID="price_standard_41_yearly" +STANDARD_TIER_CREDITS=8000 +STANDARD_TIER_MONTHLY_PRICE=41 +STANDARD_TIER_BILLING_CYCLE="yearly" diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..4eef0359 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Supabase Configuration +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here + +# Stripe Configuration +STANDARD_TIER_PRICE_ID=price_placeholder # must be real Stripe price ID in prod +STANDARD_TIER_CREDITS=8000 +STANDARD_TIER_MONTHLY_PRICE=41 +STANDARD_TIER_BILLING_CYCLE=yearly + +# Other Environment Variables +# Add other existing env vars here with placeholder values diff --git a/.gitignore b/.gitignore index 36a24b98..e6c0c0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,59 +1,39 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build +# Dependency directories +node_modules/ +.bun/ + +# Build outputs +.next/ +dist/ +build/ +out/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.*.local -# misc +# IDE/Editor +.vscode/ +.idea/ +*.swp +*.swo .DS_Store -*.pem -# debug +# Logs npm-debug.log* yarn-debug.log* yarn-error.log* +bun.lockb -# local env files -.env*.local - -# log files -dev_server.log -server.log +# Testing +playwright-report/ +test-results/ +coverage/ -# vercel -.vercel - -# typescript +# Misc +.vercel/ *.tsbuildinfo -next-env.d.ts - -# Playwright -/playwright-report/ -/test-results/ -/dev.log -# AlphaEarth Embeddings - Sensitive Files -# Add these lines to your main .gitignore - -# GCP Service Account Credentials (NEVER commit) -gcp_credentials.json -**/gcp_credentials.json - -# AlphaEarth Index File (large, should be downloaded separately) -aef_index.csv - -# Environment variables with GCP credentials -.env.local -.env.production.local -*.log diff --git a/.vscode/settings.json b/.vscode/settings.json index 89d1965f..39eb4d65 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode", + "IDX.corgiMode": true } \ No newline at end of file diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index fd6a74cf..be843676 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -3,6 +3,7 @@ import { saveChat } from '@/lib/actions/chat'; import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; import { type Chat } from '@/lib/types'; import { v4 as uuidv4 } from 'uuid'; +import { checkAndConsumeCredits } from '@/lib/middleware/check-credits'; export async function POST(request: NextRequest) { try { @@ -11,6 +12,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + // Check and consume credits + const creditCheck = await checkAndConsumeCredits(request, 10); + if (creditCheck.error) { + return NextResponse.json({ error: creditCheck.error }, { status: creditCheck.status }); + } + const body = await request.json(); const { title, initialMessageContent, role = 'user' } = body; diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts new file mode 100644 index 00000000..d4ada5d6 --- /dev/null +++ b/app/api/checkout/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; + +// Ensure Stripe is initialized with the secret key +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_dummy', { + apiVersion: '2025-12-15.clover' as any, // Use the specific version expected by the SDK +}); + +export async function POST(req: NextRequest) { + try { + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json(); + const { priceId, returnUrl } = body; + + if (!priceId) { + return NextResponse.json({ error: 'Price ID is required' }, { status: 400 }); + } + + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', // or 'payment' for one-time + payment_method_types: ['card'], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + metadata: { + userId: userId, // Pass userId to metadata for webhook + }, + success_url: `${returnUrl || req.headers.get('origin')}/?success=true`, + cancel_url: `${returnUrl || req.headers.get('origin')}/?canceled=true`, + }); + + return NextResponse.json({ url: session.url }); + } catch (error: any) { + console.error('Error creating checkout session:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/app/api/user/credits/route.ts b/app/api/user/credits/route.ts new file mode 100644 index 00000000..330048ca --- /dev/null +++ b/app/api/user/credits/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { users } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { getSupabaseServerClient } from '@/lib/supabase/client'; +import { TIERS, parseTier, getTierConfig } from '@/lib/utils/subscription'; + +export async function GET(req: NextRequest) { + try { + const supabase = getSupabaseServerClient(); + const { + data: { user }, + error: userError + } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Get user from database + const dbUser = await db.query.users.findFirst({ + where: eq(users.id, user.id) + }); + + if (!dbUser) { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ); + } + + const tier = parseTier(dbUser.tier); + // If user is not on Standard tier, they might not need credits logic, + // but for now we return the credits regardless. + // If the tier doesn't support credits (e.g. Free or Pro), the UI can handle it. + + return NextResponse.json({ + credits: dbUser.credits, + tier: tier, + features: getTierConfig(tier) + }); + + } catch (error) { + console.error('Error fetching user credits:', error); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 00000000..0dc0515f --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,68 @@ +import { headers } from 'next/headers'; +import { NextResponse } from 'next/server'; +import Stripe from 'stripe'; +import { getSupabaseServiceClient } from '@/lib/supabase/client'; +import { TIER_CONFIGS, TIERS } from '@/lib/utils/subscription'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_dummy', { + apiVersion: '2025-12-15.clover' as any, +}); + +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || 'whsec_dummy'; + +export async function POST(req: Request) { + const body = await req.text(); + const headersList = await headers(); // Await headers() as per Next.js 15+ / 16 + const signature = headersList.get('stripe-signature') as string; + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent(body, signature, webhookSecret); + } catch (err: any) { + console.error(`Webhook signature verification failed: ${err.message}`); + return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 }); + } + + const supabase = getSupabaseServiceClient(); + + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + const userId = session.metadata?.userId; + + if (userId) { + console.log(`Processing checkout for user: ${userId}`); + // Update user credits and tier + // Assuming Standard Tier for this example, or derive from session + const standardCredits = TIER_CONFIGS[TIERS.STANDARD].credits; + + const { error } = await supabase + .from('users') + .update({ + credits: standardCredits, + tier: 'standard', + // updated_at: new Date().toISOString() + }) + .eq('id', userId); + + if (error) { + console.error('Error updating user credits in Supabase:', error); + throw error; + } + console.log(`Updated credits for user ${userId} to ${standardCredits}`); + } + break; + } + // Handle other event types + default: + console.log(`Unhandled event type ${event.type}`); + } + } catch (error) { + console.error('Error handling webhook event:', error); + return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 }); + } + + return NextResponse.json({ received: true }); +} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index d6cd1824..6565e2a2 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -1,6 +1,7 @@ import { createServerClient, type CookieOptions } from '@supabase/ssr' import { cookies } from 'next/headers' import { NextResponse } from 'next/server' +import { TIER_CONFIGS, TIERS } from '@/lib/utils/subscription'; export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url) @@ -31,11 +32,42 @@ export async function GET(request: Request) { } ) const { error } = await supabase.auth.exchangeCodeForSession(code) - if (!error) { + if (error) { + console.error('[Auth Callback] Exchange code error:', { + message: error.message, + status: error.status, + name: error.name, + code: code?.substring(0, 10) + '...' + }) + return NextResponse.redirect(`${origin}/auth/auth-code-error?error=${encodeURIComponent(error.message)}`) + } else { try { const { data: { user }, error: userErr } = await supabase.auth.getUser() if (!userErr && user) { console.log('[Auth Callback] User signed in:', user.email) + + // Check if user exists in the 'users' table + const { data: existingUser, error: fetchError } = await supabase + .from('users') + .select('*') + .eq('id', user.id) + .single() + + if (!existingUser && !fetchError) { + console.log('[Auth Callback] Initializing new user:', user.id); + // Create new user entry + const { error: insertError } = await supabase.from('users').insert({ + id: user.id, + email: user.email, + credits: 0, // Start with 0 or free tier credits + tier: 'free', + // Add other default fields if necessary + }); + + if (insertError) { + console.error('[Auth Callback] Error creating user record:', insertError); + } + } } } catch (e) { console.warn('[Auth Callback] Could not fetch user after exchange', e) diff --git a/app/layout.tsx b/app/layout.tsx index 620d7af2..69d7794a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -17,6 +17,7 @@ import { MapLoadingProvider } from '@/components/map-loading-context'; import ConditionalLottie from '@/components/conditional-lottie'; import { MapProvider } from '@/components/map/map-context' import { getSupabaseUserAndSessionOnServer } from '@/lib/auth/get-current-user' +import { PurchaseCreditsProvider } from '@/components/providers/purchase-credits-provider'; // Force dynamic rendering since we check auth with cookies export const dynamic = 'force-dynamic' @@ -93,6 +94,7 @@ export default async function RootLayout({ {children} +