diff --git a/.github/workflows/pr-quick-feedback.yml b/.github/workflows/pr-quick-feedback.yml index 2864484..7302692 100644 --- a/.github/workflows/pr-quick-feedback.yml +++ b/.github/workflows/pr-quick-feedback.yml @@ -49,8 +49,8 @@ jobs: - name: 🔍 Lint changed files only run: | - # Get changed files - CHANGED_FILES=$(git diff --name-only origin/main...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' | xargs) + # Get changed files (exclude deleted files) + CHANGED_FILES=$(git diff --name-only --diff-filter=d origin/main...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' | xargs) if [ ! -z "$CHANGED_FILES" ]; then echo "Linting changed files: $CHANGED_FILES" npx eslint $CHANGED_FILES diff --git a/.gitignore b/.gitignore index 8c7eb86..bfbd853 100644 --- a/.gitignore +++ b/.gitignore @@ -106,8 +106,13 @@ lighthouse-report/ .env.test.local .env.production.local settings.local.json + +# Claude AI assistant files (keep local only) .claude/ claude/ +*.claude.md +CLAUDE*.md +claude.*.md # ESLint cache .eslintcache diff --git a/CLAUDE.md b/CLAUDE.md index d331d7e..211a3c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,11 +5,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Development Commands ### Core Development -- `npm run dev` - Start development server +- `claude run tmux-dev` - Start development server in tmux with Stripe webhooks (recommended) +- `npm run dev:with-stripe` - Start development server with Stripe webhooks +- `npm run dev` - Start development server (basic, no Stripe) - `npm run build` - Production build with type checking - `npm run lint` - ESLint checking - `npm run type-check` - TypeScript validation +**🔄 After Code Changes:** +1. **Always restart dev server** before testing: `claude run tmux-dev` (kills old session, starts fresh with Stripe) +2. Verify changes are reflected on localhost:3000 +3. Check tmux logs for any startup errors +4. Ensure Stripe webhooks are working (important for payments) + ### 🔍 Local CI Verification (CRITICAL for avoiding CI failures) - `npm run ci:local` - **Run this before every commit/push** - Comprehensive local CI check that matches GitHub Actions exactly - `npm run ci:lint` - Quick lint + type check only @@ -29,6 +37,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Same ESLint rules and TypeScript config as CI - Tests run in CI mode (`--ci --coverage --watchAll=false`) +### Browser Tools (AgentDeskAI MCP) +**Before using browser automation tools, run the global command:** +```bash +# Start browser tools server (global command) +claude run browser-tools-start +``` + ### Testing Suite - `npm test` - Unit tests with Jest - `npm run test:ci` - CI testing with coverage @@ -198,6 +213,6 @@ Required environment variables: ### Deployment - **Platform**: Vercel with auto-deployment on main branch -- **Live URL**: https://local-loop-qa.vercel.app +- **Live URL**: https://localloopevents.xyz/ - **CI/CD**: 6 active GitHub workflows including performance testing and monitoring - **Database backups**: Automated daily backups configured \ No newline at end of file diff --git a/app/about/page.tsx b/app/about/page.tsx index 9d22d24..98be8be 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -98,10 +98,10 @@ export default function AboutPage() {

- Create Your First Event + Create Account { const secretKey = process.env.STRIPE_SECRET_KEY @@ -108,14 +110,15 @@ export async function POST(request: NextRequest) { console.log('[DEBUG] Final actualEventId:', actualEventId) console.log('[DEBUG] Requested ticket IDs:', ticketItems.map(item => item.ticket_type_id)) - // Validate and fetch ticket types from database + // Fetch ticket types from Supabase database + // NOTE: Sample ticket data functionality has been DISABLED and migrated to database const { data: ticketTypes, error: ticketTypesError } = await supabase .from('ticket_types') .select('*') .in('id', ticketItems.map(item => item.ticket_type_id)) .eq('event_id', actualEventId) - console.log('[DEBUG] Query filters:', { + console.log('[DEBUG] Database query filters:', { ticket_ids: ticketItems.map(item => item.ticket_type_id), event_id: actualEventId }) @@ -158,19 +161,19 @@ export async function POST(request: NextRequest) { console.log('[DEBUG] ✅ Event timing is valid, calculating totals...') - // Calculate total amount - let total = 0 + // Calculate subtotal (ticket prices only) + let subtotal = 0 for (const item of ticketItems) { const ticketType = (ticketTypes as TicketType[]).find((tt: TicketType) => tt.id === item.ticket_type_id) if (!ticketType) { return NextResponse.json({ error: `Invalid ticket type: ${item.ticket_type_id}` }, { status: 400 }) } - total += ticketType.price * item.quantity + subtotal += ticketType.price * item.quantity } - // Add processing fee (3% + $0.30) - const processingFee = Math.round(total * 0.03 + 30) - total += processingFee + // Calculate processing fee (2.9% + $0.30 - Stripe's standard rate) + const processingFee = Math.round(subtotal * 0.029 + 30) + const total = subtotal + processingFee // Initialize Stripe const stripe = getStripeInstance() @@ -261,6 +264,8 @@ export async function POST(request: NextRequest) { client_secret: paymentIntent.client_secret, payment_intent_id: paymentIntent.id, amount: total, + subtotal: subtotal, + fees: processingFee, currency: 'usd', event: { id: eventData.id, @@ -274,14 +279,13 @@ export async function POST(request: NextRequest) { const ticketType = (ticketTypes as TicketType[]).find((tt: TicketType) => tt.id === item.ticket_type_id) return { ticket_type_id: item.ticket_type_id, + ticket_type_name: ticketType?.name || 'Ticket', quantity: item.quantity, unit_price: ticketType?.price || 0, - total_price: (ticketType?.price || 0) * item.quantity, - name: ticketType?.name || 'Ticket' + total_price: (ticketType?.price || 0) * item.quantity } }), - customer_info, - total_amount: total + customer_info }) } catch (error) { diff --git a/app/api/debug-oauth/route.ts b/app/api/debug-oauth/route.ts index 328cf5d..1a5142f 100644 --- a/app/api/debug-oauth/route.ts +++ b/app/api/debug-oauth/route.ts @@ -32,8 +32,10 @@ export async function GET() { } if (authError) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any supabaseAuthError = authError as any // Type assertion to handle the error properly } + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { supabaseAuthError = err } diff --git a/app/api/manifest/route.ts b/app/api/manifest/route.ts new file mode 100644 index 0000000..ffa9fc8 --- /dev/null +++ b/app/api/manifest/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + const manifest = { + name: "LocalLoop - Community Events Platform", + short_name: "LocalLoop", + description: "Discover and join local community events with seamless calendar integration", + start_url: "/", + display: "standalone", + background_color: "#ffffff", + theme_color: "#2563eb", + orientation: "portrait-primary", + scope: "/", + lang: "en", + categories: [ + "social", + "lifestyle", + "productivity" + ], + icons: [ + { + src: "/favicon-16x16.svg", + sizes: "16x16", + type: "image/svg+xml" + }, + { + src: "/favicon-32x32.svg", + sizes: "32x32", + type: "image/svg+xml" + }, + { + src: "/icon.svg", + sizes: "any", + type: "image/svg+xml" + } + ] + } + + return NextResponse.json(manifest, { + headers: { + 'Content-Type': 'application/manifest+json', + 'Cache-Control': 'public, max-age=86400', // Cache for 24 hours + }, + }) +} \ No newline at end of file diff --git a/app/api/refunds/route.ts b/app/api/refunds/route.ts index 42b2cdb..3231b0a 100644 --- a/app/api/refunds/route.ts +++ b/app/api/refunds/route.ts @@ -5,9 +5,44 @@ import { calculateRefundAmount } from '@/lib/utils/ticket-utils' import { sendRefundConfirmationEmail } from '@/lib/email-service' import { z } from 'zod' +// Database types for order with relations +interface OrderData { + id: string; + created_at: string; + updated_at: string; + user_id: string | null; + event_id: string; + status: string; + total_amount: number; + currency: string; + refunded_at: string | null; + refund_amount: number; + stripe_payment_intent_id: string | null; + guest_email: string | null; + guest_name: string | null; + tickets: Array<{ + id: string; + quantity: number; + unit_price: number; + ticket_type_id: string; + ticket_types: { + name: string; + }; + }>; + events: { + id: string; + title: string; + start_time: string; + end_time: string; + location: string | null; + cancelled: boolean; + slug: string; + }; +} + // Request validation schema const refundRequestSchema = z.object({ - order_id: z.string().uuid(), + order_id: z.string().min(1), // Accept any string, we'll handle UUID conversion refund_type: z.enum(['full_cancellation', 'customer_request']), reason: z.string().min(1).max(500) }) @@ -16,9 +51,12 @@ export async function POST(request: NextRequest) { try { // Parse and validate request body const body = await request.json() + console.log('Refund request received:', { order_id: body.order_id, refund_type: body.refund_type }) + const validationResult = refundRequestSchema.safeParse(body) if (!validationResult.success) { + console.error('Validation failed:', validationResult.error.issues) return NextResponse.json( { error: 'Invalid request data', details: validationResult.error.issues }, { status: 400 } @@ -30,11 +68,34 @@ export async function POST(request: NextRequest) { // Create Supabase client const supabase = await createServerSupabaseClient() - // Get order details with tickets, customer info, and event details - const { data: orderData, error: orderError } = await supabase + // Get current user for authorization (moved up to avoid reference error) + const { data: { user }, error: userError } = await supabase.auth.getUser() + + if (userError) { + console.error('User authentication error:', userError) + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ) + } + + // Handle both full UUIDs and display IDs + const orderQuery = supabase .from('orders') .select(` - *, + id, + created_at, + updated_at, + user_id, + event_id, + status, + total_amount, + currency, + refunded_at, + refund_amount, + stripe_payment_intent_id, + guest_email, + guest_name, tickets ( id, quantity, @@ -52,16 +113,77 @@ export async function POST(request: NextRequest) { slug ) `) - .eq('id', order_id) - .single() + + // Check if order_id is a UUID (36 characters with hyphens) or a display ID (8 characters) + let orderData: OrderData | null = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let orderError: any = null; + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(order_id)) { + // It's a full UUID + console.log('Looking up order by full UUID:', order_id) + const result = await orderQuery.eq('id', order_id).single() + orderData = result.data as unknown as OrderData | null + orderError = result.error + } else { + // It's likely a display ID - try to find by the last 8 characters of the UUID + console.log('Looking up order by display ID:', order_id) + const result = await orderQuery.like('id', `%${order_id}`) + + if (result.data && Array.isArray(result.data) && result.data.length === 1) { + orderData = result.data[0] as unknown as OrderData + orderError = null + } else if (result.data && Array.isArray(result.data) && result.data.length > 1) { + console.error('Multiple orders found with same display ID:', order_id) + orderError = { message: 'Multiple orders found with same display ID' } + orderData = null + } else { + orderData = null + orderError = result.error || { message: 'Order not found' } + } + } if (orderError || !orderData) { + console.error('Order fetch error:', { + error: orderError, + order_id: order_id, + errorCode: orderError?.code, + errorMessage: orderError?.message + }) + + // Let's also try to see if there are any orders at all for this user + const { data: allUserOrders, error: allOrdersError } = await supabase + .from('orders') + .select('id, stripe_payment_intent_id, status, user_id, guest_email') + .limit(10) + + console.log('Available orders for debugging:', { + searchedOrderId: order_id, + currentUserId: user?.id, + allUserOrders: allUserOrders?.map(o => ({ + id: o.id, + stripe_id: o.stripe_payment_intent_id, + user_id: o.user_id, + guest_email: o.guest_email, + status: o.status + })), + allOrdersError + }) + return NextResponse.json( { error: 'Order not found' }, { status: 404 } ) } + console.log('Order data fetched:', { + order_id: orderData.id, + status: orderData.status, + stripe_payment_intent_id: orderData.stripe_payment_intent_id, + total_amount: orderData.total_amount, + refund_amount: orderData.refund_amount, + user_id: orderData.user_id + }) + // Comprehensive refund eligibility validation const now = new Date() const eventStartTime = new Date(orderData.events.start_time) @@ -106,27 +228,41 @@ export async function POST(request: NextRequest) { } } - // Get current user for authorization - const { data: { user }, error: userError } = await supabase.auth.getUser() - if (userError) { - return NextResponse.json( - { error: 'Authentication required' }, - { status: 401 } - ) - } + console.log('Refund request debug info:', { + order_id, + refund_type, + user_id: user?.id, + order_user_id: orderData.user_id, + order_guest_email: orderData.guest_email, + order_status: orderData.status, + event_cancelled: orderData.events.cancelled + }) - // Authorization check: user must own the order OR be a guest with matching email + // Authorization check: user must own the order const isOwner = user && orderData.user_id === user.id - const isGuest = !user && orderData.guest_email && orderData.guest_email === orderData.guest_email + const isGuestOrder = !orderData.user_id && orderData.guest_email + + console.log('Authorization debug:', { + isOwner, + isGuestOrder, + userAuthenticated: !!user, + orderHasUser: !!orderData.user_id, + orderHasGuestEmail: !!orderData.guest_email + }) - if (!isOwner && !isGuest) { + if (!isOwner && !isGuestOrder) { return NextResponse.json( { error: 'Unauthorized to refund this order' }, { status: 403 } ) } + // For guest orders, we should allow refunds for now (in production, add email verification) + if (isGuestOrder && !user) { + console.log('⚠️ Allowing guest order refund - in production, implement email verification') + } + // Calculate refund amount const remainingAmount = orderData.total_amount - orderData.refund_amount let refundAmount: number @@ -148,6 +284,15 @@ export async function POST(request: NextRequest) { ) } + // Check if we have a Stripe payment intent ID + if (!orderData.stripe_payment_intent_id) { + console.error('Order missing Stripe payment intent ID:', order_id) + return NextResponse.json( + { error: 'This order cannot be refunded online. Please contact support.' }, + { status: 400 } + ) + } + // Process Stripe refund let stripeRefund try { @@ -186,10 +331,7 @@ export async function POST(request: NextRequest) { .from('orders') .update({ refund_amount: newRefundAmount, - refunded_at: new Date().toISOString(), - notes: orderData.notes ? - `${orderData.notes}\n[${new Date().toISOString()}] Refund processed: $${(refundAmount / 100).toFixed(2)} (${refund_type}) - ${reason}` : - `[${new Date().toISOString()}] Refund processed: $${(refundAmount / 100).toFixed(2)} (${refund_type}) - ${reason}` + refunded_at: new Date().toISOString() }) .eq('id', order_id) @@ -218,13 +360,7 @@ export async function POST(request: NextRequest) { }) // Prepare refunded tickets data for email - interface TicketData { - ticket_types: { name: string } - quantity: number - unit_price: number - } - - const refundedTickets = orderData.tickets.map((ticket: TicketData) => { + const refundedTickets = orderData.tickets.map((ticket) => { const ticketRefundAmount = refund_type === 'full_cancellation' ? ticket.quantity * ticket.unit_price : Math.round((ticket.quantity * ticket.unit_price) * (refundAmount / remainingAmount)) @@ -239,8 +375,8 @@ export async function POST(request: NextRequest) { // Send confirmation email try { - const customerName = orderData.customer_name || 'Customer' - const customerEmail = orderData.customer_email || orderData.guest_email + const customerName = orderData.guest_name || 'Customer' + const customerEmail = orderData.guest_email if (customerEmail) { await sendRefundConfirmationEmail({ @@ -249,7 +385,7 @@ export async function POST(request: NextRequest) { eventTitle: orderData.events.title, eventDate: eventDate, eventTime: eventTime, - eventLocation: orderData.events.location, + eventLocation: orderData.events.location || '', refundedTickets: refundedTickets, totalRefundAmount: refundAmount, originalOrderAmount: orderData.total_amount, diff --git a/app/api/rsvps/[id]/route.ts b/app/api/rsvps/[id]/route.ts index 0df75aa..4be4b6d 100644 --- a/app/api/rsvps/[id]/route.ts +++ b/app/api/rsvps/[id]/route.ts @@ -31,15 +31,14 @@ export async function GET( start_time, end_time, location, - image_url, - is_cancellable + image_url ) `) .eq('id', id) // Apply RLS - users can only see their own RSVPs if (user) { - query = query.or(`user_id.eq.${user.id},guest_email.eq.${user.email}`) + query = query.eq('user_id', user.id) } else { // For unauthenticated guests, they need to provide email verification // This will be handled by a separate endpoint for security @@ -49,6 +48,8 @@ export async function GET( ) } + console.log('GET RSVP - User ID:', user.id, 'RSVP ID:', id); + const { data: rsvp, error } = await query.single() if (error || !rsvp) { @@ -100,14 +101,13 @@ export async function PATCH( events:event_id ( id, title, - start_time, - is_cancellable + start_time ) `) .eq('id', id) if (user) { - existingRsvpQuery = existingRsvpQuery.or(`user_id.eq.${user.id},guest_email.eq.${user.email}`) + existingRsvpQuery = existingRsvpQuery.eq('user_id', user.id) } else { return NextResponse.json( { error: 'Authentication required' }, @@ -115,21 +115,30 @@ export async function PATCH( ) } + console.log('PATCH RSVP - User ID:', user.id, 'RSVP ID:', id); + const { data: existingRsvp, error: rsvpError } = await existingRsvpQuery.single() if (rsvpError || !existingRsvp) { + console.error('RSVP lookup error (PATCH):', rsvpError); return NextResponse.json( { error: 'RSVP not found or access denied' }, { status: 404 } ) } - // Check if cancellation is allowed (business rule: 2 hours before event) - if (updateData.status === 'cancelled' && !existingRsvp.events.is_cancellable) { - return NextResponse.json( - { error: 'RSVP cancellation is not allowed within 2 hours of the event' }, - { status: 400 } - ) + // Check if cancellation is allowed (calculate the 2-hour rule directly) + if (updateData.status === 'cancelled') { + const eventStartTime = new Date(existingRsvp.events.start_time); + const now = new Date(); + const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000); + + if (existingRsvp.status !== 'confirmed' || eventStartTime <= twoHoursFromNow) { + return NextResponse.json( + { error: 'RSVP cancellation is not allowed within 2 hours of the event' }, + { status: 400 } + ) + } } // Update the RSVP @@ -259,6 +268,8 @@ export async function DELETE( ) } + console.log('DELETE RSVP - User ID:', user.id, 'RSVP ID:', id); + // Verify RSVP exists and user has permission const { data: existingRsvp, error: rsvpError } = await supabase .from('rsvps') @@ -267,23 +278,27 @@ export async function DELETE( events:event_id ( id, title, - start_time, - is_cancellable + start_time ) `) .eq('id', id) - .or(`user_id.eq.${user.id},guest_email.eq.${user.email}`) + .eq('user_id', user.id) .single() if (rsvpError || !existingRsvp) { + console.error('RSVP lookup error:', rsvpError); return NextResponse.json( { error: 'RSVP not found or access denied' }, { status: 404 } ) } - // Check if deletion is allowed (same business rules as cancellation) - if (!existingRsvp.events.is_cancellable) { + // Check if deletion is allowed (calculate the 2-hour rule directly) + const eventStartTime = new Date(existingRsvp.events.start_time); + const now = new Date(); + const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000); + + if (existingRsvp.status !== 'confirmed' || eventStartTime <= twoHoursFromNow) { return NextResponse.json( { error: 'RSVP deletion is not allowed within 2 hours of the event' }, { status: 400 } diff --git a/app/api/ticket-types/route.ts b/app/api/ticket-types/route.ts index a760c27..4542184 100644 --- a/app/api/ticket-types/route.ts +++ b/app/api/ticket-types/route.ts @@ -2,24 +2,30 @@ import { NextRequest, NextResponse } from 'next/server' import { createServerSupabaseClient } from '@/lib/supabase-server' import { z } from 'zod' -// Map event slugs to their corresponding numeric IDs for sample events +// Map event slugs to their corresponding UUIDs function getEventIdFromSlugOrId(eventIdOrSlug: string): string | null { - const slugToIdMap: { [key: string]: string } = { - 'local-business-networking': '2', - 'kids-art-workshop': '3', - 'startup-pitch-night': '7', - 'food-truck-festival': '9', + const slugToUuidMap: { [key: string]: string } = { + 'local-business-networking': '00000000-0000-0000-0000-000000000002', + 'kids-art-workshop': '00000000-0000-0000-0000-000000000003', + 'startup-pitch-night': '00000000-0000-0000-0000-000000000007', + 'food-truck-festival': '00000000-0000-0000-0000-000000000009', // Add more mappings as needed }; - // If it's already a numeric ID or UUID, return as is - if (/^\d+$/.test(eventIdOrSlug) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(eventIdOrSlug)) { + // If it's already a UUID, return as is + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(eventIdOrSlug)) { return eventIdOrSlug; } - // If it's a sample event slug, map it to the numeric ID - // Otherwise, return the original slug to allow database lookup - return slugToIdMap[eventIdOrSlug] || eventIdOrSlug; + // If it's a numeric ID, convert to UUID format + if (/^\d+$/.test(eventIdOrSlug)) { + const num = parseInt(eventIdOrSlug); + return `00000000-0000-0000-0000-${num.toString().padStart(12, '0')}`; + } + + // If it's a known slug, map it to UUID + // Otherwise, return the original slug to allow database lookup by slug + return slugToUuidMap[eventIdOrSlug] || eventIdOrSlug; } // Custom datetime validation for datetime-local inputs @@ -201,8 +207,10 @@ export async function GET(request: NextRequest) { // Map slug to ID if needed, or keep as is if already valid const eventId = getEventIdFromSlugOrId(eventIdOrSlug); + // DISABLED: Sample tickets functionality - use database instead // For development/demo: Check for sample events first - if (eventId) { + const ENABLE_SAMPLE_TICKETS = false; // Set to true only for testing + if (ENABLE_SAMPLE_TICKETS && eventId) { const sampleTickets = getSampleTicketTypes(eventId); if (sampleTickets) { return NextResponse.json({ ticket_types: sampleTickets }); @@ -242,10 +250,20 @@ export async function GET(request: NextRequest) { actualEventId = event.id; } - // Fetch ticket types from database using the actual UUID + // Fetch ticket types and calculate sold counts from orders const { data: tickets, error } = await supabase .from('ticket_types') - .select('*') + .select(` + *, + tickets!tickets_ticket_type_id_fkey( + quantity, + orders!tickets_order_id_fkey( + status, + refund_amount, + total_amount + ) + ) + `) .eq('event_id', actualEventId) .order('sort_order', { ascending: true }); @@ -257,11 +275,35 @@ export async function GET(request: NextRequest) { ); } - // Add sold_count field for frontend compatibility (set to 0 for now) - const ticketsWithSoldCount = (tickets || []).map(ticket => ({ - ...ticket, - sold_count: 0 // TODO: Calculate actual sold count from orders - })); + // Calculate sold counts from completed, non-refunded orders + interface TicketRecord { + quantity?: number; + orders?: { + status: string; + refund_amount: number; + total_amount: number; + }; + } + + const ticketsWithSoldCount = (tickets || []).map(ticket => { + const soldCount = (ticket.tickets || []).reduce((total: number, ticketRecord: TicketRecord) => { + const order = ticketRecord.orders; + // Only count tickets from completed orders that haven't been fully refunded + if (order && + order.status === 'completed' && + (order.refund_amount === 0 || order.refund_amount < order.total_amount)) { + return total + (ticketRecord.quantity || 0); + } + return total; + }, 0); + + return { + ...ticket, + sold_count: soldCount, + // Remove the nested relationship data from response + tickets: undefined + }; + }); return NextResponse.json({ ticket_types: ticketsWithSoldCount }); diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 542ec89..d4e0938 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -4,6 +4,41 @@ import { createServerSupabaseClient } from '@/lib/supabase-server' import { verifyWebhookSignature } from '@/lib/stripe' import { sendTicketConfirmationEmail } from '@/lib/emails/send-ticket-confirmation' +// Database types for order with relations +interface OrderData { + id: string; + created_at: string; + updated_at: string; + user_id: string | null; + event_id: string; + status: string; + total_amount: number; + currency: string; + refunded_at: string | null; + refund_amount: number; + stripe_payment_intent_id: string | null; + guest_email: string | null; + guest_name: string | null; + tickets: Array<{ + id: string; + quantity: number; + unit_price: number; + ticket_type_id: string; + ticket_types: { + name: string; + }; + }>; + events: { + id: string; + title: string; + start_time: string; + end_time?: string; + location: string | null; + cancelled?: boolean; + slug: string; + }; +} + // Helper function to resolve event slug/ID to UUID (same as checkout API) function getEventIdFromSlugOrId(eventIdOrSlug: string): string { const slugToIdMap: { [key: string]: string } = { @@ -112,8 +147,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing metadata in payment intent' }, { status: 400 }) } - // Validate all required metadata fields exist - const requiredFields = ['event_id', 'user_id', 'ticket_items', 'customer_email', 'customer_name'] + // Validate all required metadata fields exist (customer_name is optional) + const requiredFields = ['event_id', 'user_id', 'ticket_items', 'customer_email'] const missingFields = requiredFields.filter(field => !paymentIntent.metadata[field]) if (missingFields.length > 0) { @@ -124,8 +159,9 @@ export async function POST(request: NextRequest) { }, { status: 400 }) } - // Extract metadata values - const { event_id: rawEventId, user_id, ticket_items, customer_email, customer_name } = paymentIntent.metadata + // Extract metadata values (customer_name is optional) + const { event_id: rawEventId, user_id, ticket_items, customer_email } = paymentIntent.metadata + const customer_name = paymentIntent.metadata.customer_name || 'Customer' // Convert slug/ID to UUID format (fixes UUID constraint errors) let event_id = getEventIdFromSlugOrId(rawEventId) @@ -211,7 +247,8 @@ export async function POST(request: NextRequest) { } // Create order record first - const { data: orderData, error: orderError } = await supabase + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: orderData, error: orderError }: { data: OrderData | null, error: any } = await supabase .from('orders') .insert({ event_id, @@ -259,7 +296,7 @@ export async function POST(request: NextRequest) { ) } - console.log('✅ Created order:', orderData.id) + console.log('✅ Created order:', orderData?.id) console.log('🔄 Creating tickets...') // Create tickets in database @@ -267,7 +304,7 @@ export async function POST(request: NextRequest) { for (const item of parsedTicketItems) { for (let i = 0; i < item.quantity; i++) { ticketsToCreate.push({ - order_id: orderData.id, + order_id: orderData?.id || '', ticket_type_id: item.ticket_type_id, event_id, user_id: user_id && user_id !== 'guest' ? user_id : null, @@ -312,7 +349,7 @@ export async function POST(request: NextRequest) { ) } - console.log(`✅ Created ${createdTickets?.length || 0} tickets for order ${orderData.id}`) + console.log(`✅ Created ${createdTickets?.length || 0} tickets for order ${orderData?.id}`) // Send confirmation email if (customer_email && createdTickets && createdTickets.length > 0) { @@ -367,7 +404,7 @@ export async function POST(request: NextRequest) { } } - console.log(`✅ [${webhookId}] Successfully processed payment ${paymentIntent.id}: created order ${orderData.id} with ${createdTickets?.length || 0} tickets`) + console.log(`✅ [${webhookId}] Successfully processed payment ${paymentIntent.id}: created order ${orderData?.id} with ${createdTickets?.length || 0} tickets`) const processingTime = Date.now() - startTime console.log(`⏱️ [${webhookId}] Processing completed in ${processingTime}ms`) @@ -376,7 +413,7 @@ export async function POST(request: NextRequest) { received: true, webhookId, processingTime, - orderId: orderData.id, + orderId: orderData?.id || '', ticketCount: createdTickets?.length || 0 }) } catch (error) { @@ -414,8 +451,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing metadata in payment intent' }, { status: 400 }) } - // Validate all required metadata fields exist - const requiredFields = ['event_id', 'user_id', 'ticket_items', 'customer_email', 'customer_name'] + // Validate all required metadata fields exist (customer_name is optional) + const requiredFields = ['event_id', 'user_id', 'ticket_items', 'customer_email'] const missingFields = requiredFields.filter(field => !paymentIntent.metadata[field]) if (missingFields.length > 0) { @@ -426,8 +463,9 @@ export async function POST(request: NextRequest) { }, { status: 400 }) } - // Extract metadata values - const { event_id: rawEventId, user_id, ticket_items, customer_email, customer_name } = paymentIntent.metadata + // Extract metadata values (customer_name is optional) + const { event_id: rawEventId, user_id, ticket_items, customer_email } = paymentIntent.metadata + const customer_name = paymentIntent.metadata.customer_name || 'Customer' // Convert slug/ID to UUID format (fixes UUID constraint errors) let event_id = getEventIdFromSlugOrId(rawEventId) @@ -513,7 +551,8 @@ export async function POST(request: NextRequest) { } // Create order record first - const { data: orderData, error: orderError } = await supabase + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: orderData, error: orderError }: { data: OrderData | null, error: any } = await supabase .from('orders') .insert({ event_id, @@ -561,7 +600,7 @@ export async function POST(request: NextRequest) { ) } - console.log('✅ Created order:', orderData.id) + console.log('✅ Created order:', orderData?.id) console.log('🔄 Creating tickets...') // Create tickets in database @@ -569,7 +608,7 @@ export async function POST(request: NextRequest) { for (const item of parsedTicketItems) { for (let i = 0; i < item.quantity; i++) { ticketsToCreate.push({ - order_id: orderData.id, + order_id: orderData?.id || '', ticket_type_id: item.ticket_type_id, event_id, user_id: user_id && user_id !== 'guest' ? user_id : null, @@ -614,9 +653,9 @@ export async function POST(request: NextRequest) { ) } - console.log(`✅ Created ${createdTickets?.length || 0} tickets for order ${orderData.id}`) + console.log(`✅ Created ${createdTickets?.length || 0} tickets for order ${orderData?.id}`) - console.log(`✅ [${webhookId}] Successfully processed charge ${charge.id} for payment ${paymentIntent.id}: created order ${orderData.id} with ${createdTickets?.length || 0} tickets`) + console.log(`✅ [${webhookId}] Successfully processed charge ${charge.id} for payment ${paymentIntent.id}: created order ${orderData?.id} with ${createdTickets?.length || 0} tickets`) const processingTime = Date.now() - startTime console.log(`⏱️ [${webhookId}] Processing completed in ${processingTime}ms`) @@ -625,7 +664,7 @@ export async function POST(request: NextRequest) { received: true, webhookId, processingTime, - orderId: orderData.id, + orderId: orderData?.id || '', ticketCount: createdTickets?.length || 0 }) } catch (error) { @@ -654,6 +693,232 @@ export async function POST(request: NextRequest) { break } + case 'charge.refunded': { + try { + const charge = event.data.object + console.log(`💸 [${webhookId}] Charge refunded: ${charge.id} for PaymentIntent: ${charge.payment_intent}`) + + if (!charge.payment_intent) { + console.error('❌ No payment_intent found in refunded charge') + return NextResponse.json({ error: 'Missing payment_intent in refunded charge' }, { status: 400 }) + } + + // Find the order by payment intent ID + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: orderData, error: orderError }: { data: OrderData | null, error: any } = await supabase + .from('orders') + .select(` + id, + status, + total_amount, + refund_amount, + guest_email, + guest_name, + events ( + id, + title, + start_time, + location, + slug + ) + `) + .eq('stripe_payment_intent_id', charge.payment_intent) + .single() + + if (orderError || !orderData) { + console.error('❌ Order not found for refunded charge:', charge.payment_intent) + return NextResponse.json({ error: 'Order not found for refunded charge' }, { status: 404 }) + } + + // Calculate total refunded amount from Stripe charge object + const totalRefundedAmount = charge.amount_refunded || 0 + console.log(`💰 [${webhookId}] Total refunded amount: ${totalRefundedAmount} cents`) + + // Update order with refund information + const { error: updateError } = await supabase + .from('orders') + .update({ + refund_amount: totalRefundedAmount, + refunded_at: new Date().toISOString(), + status: totalRefundedAmount >= orderData.total_amount ? 'refunded' : 'partially_refunded', + updated_at: new Date().toISOString() + }) + .eq('id', orderData?.id || '') + + if (updateError) { + console.error('❌ Failed to update order with refund info:', updateError) + return NextResponse.json( + { error: 'Failed to update order with refund information' }, + { status: 500 } + ) + } + + console.log(`✅ [${webhookId}] Updated order ${orderData?.id} with refund amount: ${totalRefundedAmount}`) + + // Send refund confirmation email if we have customer email + const customerEmail = orderData?.guest_email + const customerName = orderData?.guest_name || 'Customer' + + if (customerEmail) { + try { + const { sendRefundConfirmationEmail } = await import('@/lib/email-service') + + // Format event details for email + const eventDate = new Date(orderData?.events?.start_time || '').toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }) + + const eventTime = new Date(orderData?.events?.start_time || '').toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }) + + await sendRefundConfirmationEmail({ + to: customerEmail, + customerName: customerName, + eventTitle: orderData?.events?.title || '', + eventDate: eventDate, + eventTime: eventTime, + eventLocation: orderData?.events?.location || '', + refundedTickets: [], // We'll populate this with actual ticket data if needed + totalRefundAmount: totalRefundedAmount, + originalOrderAmount: orderData?.total_amount || 0, + refundType: totalRefundedAmount >= (orderData?.total_amount || 0) ? 'full_cancellation' : 'customer_request', + stripeRefundId: charge.id, + orderId: orderData?.id || '', + processingTimeframe: '5-10 business days', + refundReason: 'Processed via Stripe webhook', + remainingAmount: (orderData?.total_amount || 0) - totalRefundedAmount, + eventSlug: orderData?.events?.slug || '' + }) + + console.log(`✅ [${webhookId}] Refund confirmation email sent to ${customerEmail}`) + } catch (emailError) { + console.error('❌ Failed to send refund confirmation email:', emailError) + // Don't fail the webhook for email errors + } + } + + return NextResponse.json({ + received: true, + webhookId, + orderId: orderData?.id || '', + refundAmount: totalRefundedAmount, + message: 'Refund processed successfully' + }) + } catch (error) { + console.error('❌ Refund webhook processing error:', error) + return NextResponse.json( + { error: 'Refund webhook handler failed', details: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } + } + + case 'refund.created': { + try { + const refund = event.data.object + console.log(`💸 [${webhookId}] Refund created: ${refund.id} for charge: ${refund.charge}`) + + // Log refund details for debugging + console.log(`💰 [${webhookId}] Refund details:`, { + refund_id: refund.id, + amount: refund.amount, + status: refund.status, + reason: refund.reason, + charge: refund.charge + }) + + // The charge.refunded webhook will handle the database updates + // This webhook is mainly for logging and monitoring + return NextResponse.json({ + received: true, + webhookId, + refundId: refund.id, + message: 'Refund creation logged' + }) + } catch (error) { + console.error('❌ Refund created webhook error:', error) + return NextResponse.json( + { error: 'Refund created webhook failed' }, + { status: 500 } + ) + } + } + + case 'refund.failed': { + try { + const refund = event.data.object + console.error(`❌ [${webhookId}] Refund failed: ${refund.id} for charge: ${refund.charge}`) + console.error(`❌ [${webhookId}] Refund failure reason:`, refund.failure_reason) + + // Log the failure for staff to investigate + console.error('💡 STAFF ACTION REQUIRED: Refund failed and may need manual processing', { + refund_id: refund.id, + charge_id: refund.charge, + amount: refund.amount, + failure_reason: refund.failure_reason, + failure_balance_transaction: refund.failure_balance_transaction + }) + + // For failed refunds, we might want to: + // 1. Send an alert to staff + // 2. Update order status to indicate refund failure + // 3. Log the failure for manual intervention + + return NextResponse.json({ + received: true, + webhookId, + refundId: refund.id, + message: 'Refund failure logged', + action_required: 'Staff review needed' + }) + } catch (error) { + console.error('❌ Refund failed webhook error:', error) + return NextResponse.json( + { error: 'Refund failed webhook handler failed' }, + { status: 500 } + ) + } + } + + case 'charge.updated': { + try { + const charge = event.data.object + console.log(`🔄 [${webhookId}] Charge updated: ${charge.id} - Status: ${charge.status}`) + + // Log charge update details for monitoring + console.log(`📊 [${webhookId}] Charge update details:`, { + charge_id: charge.id, + status: charge.status, + payment_intent: charge.payment_intent, + amount: charge.amount, + currency: charge.currency, + paid: charge.paid, + refunded: charge.refunded + }) + + // For charge updates, we mainly log for monitoring + // The important events (succeeded, failed, refunded) are handled separately + return NextResponse.json({ + received: true, + webhookId, + chargeId: charge.id, + message: 'Charge update logged' + }) + } catch (error) { + console.error('❌ Charge updated webhook error:', error) + return NextResponse.json( + { error: 'Charge updated webhook failed' }, + { status: 500 } + ) + } + } + default: console.log(`Unhandled event type: ${event.type}`) } diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index 99b0636..13f4ac3 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -11,6 +11,8 @@ export default function LoginPage() { const [password, setPassword] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState('') + const [showAppleMessage, setShowAppleMessage] = useState(false) + const [messageTimerRef, setMessageTimerRef] = useState(null) const { user, @@ -88,7 +90,7 @@ export default function LoginPage() { const handleAppleLogin = async () => { if (!isAppleAuthEnabled) { - setError('Apple authentication is coming soon! We need an Apple Developer account to enable this feature.') + showAppleMessageTemporarily() return } @@ -110,11 +112,30 @@ export default function LoginPage() { } } + const showAppleMessageTemporarily = () => { + // Clear existing timer if any + if (messageTimerRef) { + clearTimeout(messageTimerRef) + } + + setShowAppleMessage(true) + // Set timer to revert after 10 seconds + const newTimer = setTimeout(() => { + setShowAppleMessage(false) + }, 10000) + setMessageTimerRef(newTimer) + } + + const handleDebugButtonAction = () => { + // Always clear auth data when clicked, regardless of message state + handleClearAuthData() + } + return (
-

+

Sign in to LocalLoop

@@ -134,7 +155,12 @@ export default function LoginPage() {

+
+
@@ -190,11 +223,17 @@ export default function LoginPage() { onClick={handleGoogleLogin} type="button" disabled={!isGoogleAuthEnabled} - className={`w-full inline-flex justify-center py-3 px-4 border rounded-md shadow-sm text-base font-medium transition-colors min-h-[44px] ${isGoogleAuthEnabled + className={`w-full inline-flex justify-center items-center py-3 px-4 border rounded-md shadow-sm text-base font-medium transition-colors min-h-[44px] ${isGoogleAuthEnabled ? 'border-border bg-background text-muted-foreground hover:bg-accent' : 'border-border bg-muted text-muted-foreground cursor-not-allowed' }`} > + + + + + + Google @@ -202,41 +241,86 @@ export default function LoginPage() {
- {!isAppleAuthEnabled && ( -

- Apple Sign-in coming soon! We're working on getting an Apple Developer account. -

- )}
- {/* Debug: Clear stale auth data button */} + {/* Debug: Clear stale auth data button / Apple message */}
+ + {/* Custom animation keyframes */} +
) diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx index dbc6b5a..0b66f46 100644 --- a/app/auth/signup/page.tsx +++ b/app/auth/signup/page.tsx @@ -86,23 +86,35 @@ export default function SignupPage() {
+ setEmail(e.target.value)} className="block w-full px-4 py-3 border border-border placeholder-muted-foreground text-foreground bg-background rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary text-base" placeholder="Email address" + autoComplete="email" />
+ setPassword(e.target.value)} className="block w-full px-4 py-3 border border-border placeholder-muted-foreground text-foreground bg-background rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary text-base" placeholder="Password" + autoComplete="new-password" />
diff --git a/app/contact/page.tsx b/app/contact/page.tsx index c3a8af6..f8ecf07 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -27,7 +27,12 @@ export default function ContactPage() {

Email

-

{EMAIL_ADDRESSES.CONTACT}

+ + {EMAIL_ADDRESSES.CONTACT} +
@@ -35,8 +40,8 @@ export default function ContactPage() {
-

Live Chat

-

Available Monday-Friday, 9AM-5PM

+

Coming Soon

+

Live chat support will be available soon

@@ -45,7 +50,12 @@ export default function ContactPage() {

Phone

-

+1 (555) 123-4567

+ + +1 (555) 123-4567 +
@@ -58,56 +68,64 @@ export default function ContactPage() {
-
-
-
-
-