From b6d6f17a9a6bbbf172f0a6bde08ca7b25278e018 Mon Sep 17 00:00:00 2001 From: Jaskaran Date: Fri, 6 Mar 2026 09:05:31 -0500 Subject: [PATCH] Changes until March 5 --- docker-compose.yml | 2 +- package-lock.json | 10 + packages/backend/src/main.ts | 6 +- .../src/modules/auth/auth.controller.ts | 13 +- .../backend/src/modules/auth/auth.service.ts | 39 +- .../src/modules/drivers/drivers.controller.ts | 6 + .../src/modules/drivers/drivers.service.ts | 78 ++- .../src/modules/orders/orders.controller.ts | 20 +- .../src/modules/orders/orders.module.ts | 2 + .../src/modules/orders/orders.service.ts | 114 +++- .../modules/payments/payments.controller.ts | 9 +- .../src/modules/payments/payments.service.ts | 154 +++++ .../restaurants/restaurants.service.ts | 31 +- packages/database/schema.sql | 3 +- packages/database/seed.sql | 179 ++++++ packages/web/package.json | 15 +- packages/web/src/app/checkout/page.tsx | 332 +++++++---- .../web/src/app/driver/dashboard/page.tsx | 25 +- packages/web/src/app/driver/earnings/page.tsx | 351 +++++++---- packages/web/src/app/driver/orders/page.tsx | 22 + packages/web/src/app/home/page.tsx | 301 ++++++++++ packages/web/src/app/login/page.tsx | 6 +- .../web/src/app/orders/[id]/track/page.tsx | 303 ++++++---- packages/web/src/app/orders/page.tsx | 167 ++++++ packages/web/src/app/page.tsx | 564 +++++++++++++----- packages/web/src/app/register/page.tsx | 16 +- .../web/src/app/restaurant/dashboard/page.tsx | 416 +++++++------ .../src/app/restaurant/onboarding/page.tsx | 137 ++--- .../web/src/app/restaurant/orders/page.tsx | 427 ++++++++----- .../web/src/app/restaurants/[slug]/page.tsx | 6 +- packages/web/src/app/restaurants/page.tsx | 311 ++++++---- packages/web/src/lib/cart.ts | 4 +- scripts/setup-stripe.sh | 128 +++- 33 files changed, 3097 insertions(+), 1100 deletions(-) create mode 100644 packages/web/src/app/home/page.tsx create mode 100644 packages/web/src/app/orders/page.tsx diff --git a/docker-compose.yml b/docker-compose.yml index f27c88c..53addf9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vibepass} POSTGRES_DB: ${POSTGRES_DB:-vibe_db} ports: - - "5432:5432" + - "5433:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./packages/database/schema.sql:/docker-entrypoint-initdb.d/01_schema.sql diff --git a/package-lock.json b/package-lock.json index c1e9890..48cd4d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21770,6 +21770,15 @@ "react": ">=16.8" } }, + "node_modules/use-places-autocomplete": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/use-places-autocomplete/-/use-places-autocomplete-4.0.1.tgz", + "integrity": "sha512-AybOR/qzXcdaMCGSFveycfL3kztwseAOdagbYoJD8c3amll+gEiPmUkSNhYNUEBqbR+JmJG6/oBTRgihNbE+1A==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -23611,6 +23620,7 @@ "react-dom": "^18.2.0", "socket.io-client": "^4.6.2", "swr": "^2.2.4", + "use-places-autocomplete": "^4.0.1", "zustand": "^4.5.0" }, "devDependencies": { diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 93ede1f..c7f2ac3 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -6,11 +6,7 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableCors({ - origin: [ - process.env.FRONTEND_URL || 'http://localhost:3000', - 'http://localhost:3000', - 'http://localhost:19006', // Expo dev server - ], + origin: true, // allow all origins in dev credentials: true, }); diff --git a/packages/backend/src/modules/auth/auth.controller.ts b/packages/backend/src/modules/auth/auth.controller.ts index c41d62f..816dbdd 100644 --- a/packages/backend/src/modules/auth/auth.controller.ts +++ b/packages/backend/src/modules/auth/auth.controller.ts @@ -1,19 +1,20 @@ import { Controller, Post, Body, Get, UseGuards, Request } from '@nestjs/common'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; -import { IsEmail, IsString, MinLength, IsIn, IsOptional } from 'class-validator'; +import { IsString, IsIn, IsOptional } from 'class-validator'; class RegisterDto { - @IsEmail() email: string; - @IsString() @MinLength(8) password: string; + @IsOptional() @IsString() email?: string; + @IsOptional() @IsString() phone?: string; + @IsString() password: string; @IsString() firstName: string; @IsString() lastName: string; - @IsOptional() @IsString() phone?: string; @IsIn(['customer', 'driver', 'restaurant_owner']) role: string; } class LoginDto { - @IsEmail() email: string; + @IsOptional() @IsString() email?: string; + @IsOptional() @IsString() phone?: string; @IsString() password: string; } @@ -28,7 +29,7 @@ export class AuthController { @Post('login') login(@Body() dto: LoginDto) { - return this.authService.login(dto.email, dto.password); + return this.authService.login(dto.email || dto.phone || '', dto.password); } @Get('me') diff --git a/packages/backend/src/modules/auth/auth.service.ts b/packages/backend/src/modules/auth/auth.service.ts index 7f386c7..45f96de 100644 --- a/packages/backend/src/modules/auth/auth.service.ts +++ b/packages/backend/src/modules/auth/auth.service.ts @@ -11,49 +11,58 @@ export class AuthService { ) {} async register(dto: { - email: string; + email?: string; + phone?: string; password: string; firstName: string; lastName: string; - phone?: string; role: 'customer' | 'driver' | 'restaurant_owner'; }) { - const existing = await this.db.queryOne( - 'SELECT id FROM users WHERE email = $1', - [dto.email.toLowerCase()], - ); - if (existing) throw new ConflictException('Email already registered'); + if (!dto.email && !dto.phone) { + throw new ConflictException('Email or phone is required'); + } + if (dto.email) { + const existing = await this.db.queryOne('SELECT id FROM users WHERE email = $1', [dto.email.toLowerCase()]); + if (existing) throw new ConflictException('Email already registered'); + } + if (dto.phone && !dto.email) { + const existing = await this.db.queryOne('SELECT id FROM users WHERE phone = $1', [dto.phone]); + if (existing) throw new ConflictException('Phone already registered'); + } - const passwordHash = await bcrypt.hash(dto.password, 12); + const passwordHash = await bcrypt.hash(dto.password, 10); + const email = dto.email ? dto.email.toLowerCase() : null; const user = await this.db.queryOne( `INSERT INTO users (email, password_hash, first_name, last_name, phone, role) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, email, first_name, last_name, role`, - [dto.email.toLowerCase(), passwordHash, dto.firstName, dto.lastName, dto.phone, dto.role], + [email, passwordHash, dto.firstName, dto.lastName, dto.phone || null, dto.role], ); // Create role-specific record if (dto.role === 'driver') { - await this.db.query( - `INSERT INTO drivers (user_id) VALUES ($1)`, + const driverResult = await this.db.query( + `INSERT INTO drivers (user_id) VALUES ($1) RETURNING id`, [user.id], ); + const driverId = driverResult.rows[0].id; await this.db.query( `INSERT INTO driver_locations (driver_id, location) VALUES ($1, ST_GeographyFromText('POINT(0 0)'))`, - [user.id], // will be updated properly later + [driverId], ); } return { user, token: this.signToken(user) }; } - async login(email: string, password: string) { + async login(emailOrPhone: string, password: string) { + const identifier = emailOrPhone.toLowerCase().trim(); const user = await this.db.queryOne( `SELECT id, email, password_hash, first_name, last_name, role, is_active - FROM users WHERE email = $1`, - [email.toLowerCase()], + FROM users WHERE LOWER(email) = $1 OR phone = $1`, + [identifier], ); if (!user) throw new UnauthorizedException('Invalid credentials'); diff --git a/packages/backend/src/modules/drivers/drivers.controller.ts b/packages/backend/src/modules/drivers/drivers.controller.ts index 438138c..15f0c7a 100644 --- a/packages/backend/src/modules/drivers/drivers.controller.ts +++ b/packages/backend/src/modules/drivers/drivers.controller.ts @@ -47,6 +47,12 @@ export class DriversController { return this.driversService.getEarningsHistory(req.user.driverId, days ? parseInt(days) : 30); } + @Get('me/analytics') + @Roles('driver') + getAnalytics(@Request() req) { + return this.driversService.getAnalytics(req.user.driverId); + } + @Patch('me/location') @Roles('driver') updateLocation(@Request() req, @Body() dto: UpdateLocationDto) { diff --git a/packages/backend/src/modules/drivers/drivers.service.ts b/packages/backend/src/modules/drivers/drivers.service.ts index 0615cf4..a92e4f6 100644 --- a/packages/backend/src/modules/drivers/drivers.service.ts +++ b/packages/backend/src/modules/drivers/drivers.service.ts @@ -14,13 +14,14 @@ export class DriversService { // ============================================================ async startSession(driverId: string): Promise { + // All registered drivers can start — no approval gate for MVP const driver = await this.db.queryOne( `SELECT d.*, u.first_name, u.last_name FROM drivers d JOIN users u ON u.id = d.user_id - WHERE d.id = $1 AND d.is_approved = TRUE`, + WHERE d.id = $1`, [driverId], ); - if (!driver) throw new NotFoundException('Driver not found or not approved'); + if (!driver) throw new NotFoundException('Driver not found'); // Check if session already exists today const existing = await this.db.queryOne( @@ -30,15 +31,15 @@ export class DriversService { ); if (existing) return { session: existing, breakEven: this.breakEven.calculate(existing) }; - // Create new session (fee charged separately via Stripe) + // $20 daily fee tracked as debt — deducted from first payouts, no upfront card charge const session = await this.db.queryOne( - `INSERT INTO driver_sessions (driver_id, daily_fee) - VALUES ($1, $2) + `INSERT INTO driver_sessions (driver_id, daily_fee, fee_paid) + VALUES ($1, $2, FALSE) RETURNING *`, [driverId, 20.00], ); - // Mark driver as online + // Mark driver as online and available await this.db.query( `UPDATE driver_locations SET is_online = TRUE, is_available = TRUE, updated_at = NOW() WHERE driver_id = $1`, @@ -124,6 +125,69 @@ export class DriversService { ); } + async getAnalytics(driverId: string) { + const [summary, weekly, hourly] = await Promise.all([ + // All-time + period summaries + this.db.queryOne( + `SELECT + COUNT(*) FILTER (WHERE session_date >= CURRENT_DATE - 6) AS sessions_7d, + COUNT(*) FILTER (WHERE session_date >= CURRENT_DATE - 29) AS sessions_30d, + SUM(deliveries_count) FILTER (WHERE session_date >= CURRENT_DATE - 6) AS trips_7d, + SUM(deliveries_count) FILTER (WHERE session_date >= CURRENT_DATE - 29) AS trips_30d, + SUM(deliveries_count) AS trips_total, + SUM(net_earnings) FILTER (WHERE session_date >= CURRENT_DATE - 6) AS net_7d, + SUM(net_earnings) FILTER (WHERE session_date >= CURRENT_DATE - 29) AS net_30d, + SUM(net_earnings) AS net_total, + SUM(tips_earned) FILTER (WHERE session_date >= CURRENT_DATE - 29) AS tips_30d, + SUM(tips_earned) AS tips_total, + AVG(CASE WHEN deliveries_count > 0 THEN net_earnings END) AS avg_daily_net + FROM driver_sessions + WHERE driver_id = $1`, + [driverId], + ), + + // Day-of-week breakdown (0=Sun, 6=Sat) + this.db.queryMany( + `SELECT + EXTRACT(DOW FROM session_date) AS dow, + TO_CHAR(session_date, 'Dy') AS day_name, + AVG(deliveries_count) AS avg_trips, + AVG(net_earnings) AS avg_net + FROM driver_sessions + WHERE driver_id = $1 AND session_date >= CURRENT_DATE - 90 + GROUP BY dow, day_name + ORDER BY dow`, + [driverId], + ), + + // Hour-of-day delivery distribution from driver_earnings + this.db.queryMany( + `SELECT + EXTRACT(HOUR FROM de.created_at) AS hour, + COUNT(*) AS deliveries, + SUM(de.total) AS revenue + FROM driver_earnings de + WHERE de.driver_id = $1 + AND de.created_at >= NOW() - INTERVAL '90 days' + GROUP BY hour + ORDER BY hour`, + [driverId], + ), + ]); + + // Find peak day and peak hour + const peakDay = weekly.reduce( + (best: any, d: any) => (Number(d.avg_trips) > Number(best?.avg_trips || 0) ? d : best), + null, + ); + const peakHour = hourly.reduce( + (best: any, h: any) => (Number(h.deliveries) > Number(best?.deliveries || 0) ? h : best), + null, + ); + + return { summary, weekly, hourly, peakDay, peakHour }; + } + async updateLocation( driverId: string, lng: number, @@ -139,7 +203,7 @@ export class DriversService { heading = EXCLUDED.heading, speed_kmh = EXCLUDED.speed_kmh, updated_at = NOW()`, - [`POINT(${lng} ${lat})`, driverId, heading, speedKmh], + [driverId, `POINT(${lng} ${lat})`, heading, speedKmh], ); } diff --git a/packages/backend/src/modules/orders/orders.controller.ts b/packages/backend/src/modules/orders/orders.controller.ts index 3b22f72..629d385 100644 --- a/packages/backend/src/modules/orders/orders.controller.ts +++ b/packages/backend/src/modules/orders/orders.controller.ts @@ -4,22 +4,35 @@ import { RolesGuard, Roles } from '../auth/guards/roles.guard'; import { OrdersService } from './orders.service'; @Controller('orders') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RolesGuard) export class OrdersController { constructor(private readonly ordersService: OrdersService) {} + // Any authenticated user can place an order (customers, drivers, restaurant owners all eat) @Post() - @Roles('customer') placeOrder(@Request() req, @Body() dto: any) { return this.ordersService.placeOrder(req.user.id, dto); } @Get('mine') - @Roles('customer') myOrders(@Request() req, @Query('status') status?: string) { return this.ordersService.getCustomerOrders(req.user.id, status); } + // Available orders for driver to claim — must be before :id to avoid route conflict + @Get('available') + @Roles('driver') + getAvailable(@Request() req) { + return this.ordersService.getAvailableOrders(req.user.driverId); + } + + // Restaurant orders — must be before :id to avoid route conflict + @Get('restaurant') + @Roles('restaurant_owner') + restaurantOrders(@Request() req, @Query('status') status?: string) { + return this.ordersService.getRestaurantOrders(req.user.restaurantId, status); + } + @Get(':id') getOrder(@Param('id') id: string) { return this.ordersService.getOrderById(id); @@ -46,7 +59,6 @@ export class OrdersController { // Driver self-assigns from the available order list @Patch(':id/assign-driver') - @UseGuards(RolesGuard) @Roles('driver') assignSelf(@Param('id') id: string, @Request() req) { return this.ordersService.assignDriver(id, req.user.driverId); diff --git a/packages/backend/src/modules/orders/orders.module.ts b/packages/backend/src/modules/orders/orders.module.ts index 9e57050..91d61f9 100644 --- a/packages/backend/src/modules/orders/orders.module.ts +++ b/packages/backend/src/modules/orders/orders.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { OrdersController } from './orders.controller'; import { OrdersService } from './orders.service'; import { DatabaseService } from '../../database/database.service'; +import { TrackingModule } from '../tracking/tracking.module'; @Module({ + imports: [TrackingModule], controllers: [OrdersController], providers: [OrdersService, DatabaseService], exports: [OrdersService], diff --git a/packages/backend/src/modules/orders/orders.service.ts b/packages/backend/src/modules/orders/orders.service.ts index 28daf7b..c5da9ae 100644 --- a/packages/backend/src/modules/orders/orders.service.ts +++ b/packages/backend/src/modules/orders/orders.service.ts @@ -1,6 +1,7 @@ import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { DatabaseService } from '../../database/database.service'; import { EmailService } from '../email/email.service'; +import { TrackingGateway } from '../tracking/tracking.gateway'; const DELIVERY_FEE = 5.00; const PLATFORM_PER_ORDER_FEE = 0.10; @@ -13,6 +14,7 @@ export class OrdersService { constructor( private readonly db: DatabaseService, private readonly email: EmailService, + private readonly tracking: TrackingGateway, ) {} // ============================================================ @@ -156,6 +158,17 @@ export class OrdersService { }; }); + // Notify restaurant and zone drivers via WebSocket (fire-and-forget) + setImmediate(() => { + try { + this.tracking.emitNewOrder(result.order.restaurant_id, result.order); + this.tracking.emitOrderToZone( + result.order.zone_id, // zone_id used as fallback; slug fetched separately if needed + { ...result.order, restaurantName: result.rest.name }, + ); + } catch { /* non-fatal */ } + }); + // Send emails (outside transaction - non-critical) setImmediate(async () => { try { @@ -183,36 +196,68 @@ export class OrdersService { return { order: result.order, breakdown: result.breakdown }; } + // ============================================================ + // AVAILABLE ORDERS FOR DRIVERS + // ============================================================ + + async getAvailableOrders(driverId: string) { + return this.db.queryMany( + `SELECT o.id, o.order_number, o.delivery_address, o.subtotal, + o.delivery_fee, o.tip_amount, o.status, + o.estimated_pickup_at, + r.name AS restaurant_name, r.address AS restaurant_address, + ST_X(r.location::geometry) AS restaurant_lng, + ST_Y(r.location::geometry) AS restaurant_lat, + (SELECT COUNT(*) FROM order_items WHERE order_id = o.id) AS item_count + FROM orders o + JOIN restaurants r ON r.id = o.restaurant_id + WHERE o.status = 'ready_for_pickup' AND o.driver_id IS NULL + ORDER BY o.created_at DESC + LIMIT 20`, + [], + ); + } + // ============================================================ // ORDER LIFECYCLE // ============================================================ async confirmOrder(orderId: string, restaurantId: string) { - return this.db.queryOne( + const order = await this.db.queryOne( `UPDATE orders SET status = 'confirmed', estimated_pickup_at = NOW() + INTERVAL '20 minutes' WHERE id = $1 AND restaurant_id = $2 AND status = 'pending' RETURNING *`, [orderId, restaurantId], ); + if (order) this.tracking.emitOrderStatusUpdate(orderId, 'confirmed', order); + return order; } async markPreparing(orderId: string, restaurantId: string) { - return this.db.queryOne( + const order = await this.db.queryOne( `UPDATE orders SET status = 'preparing' WHERE id = $1 AND restaurant_id = $2 RETURNING *`, [orderId, restaurantId], ); + if (order) this.tracking.emitOrderStatusUpdate(orderId, 'preparing', order); + return order; } async markReadyForPickup(orderId: string, restaurantId: string) { - return this.db.queryOne( + const order = await this.db.queryOne( `UPDATE orders SET status = 'ready_for_pickup' WHERE id = $1 AND restaurant_id = $2 RETURNING *`, [orderId, restaurantId], ); + if (order) { + this.tracking.emitOrderStatusUpdate(orderId, 'ready_for_pickup', order); + // Broadcast to zone drivers so they can self-assign + this.tracking.emitOrderToZone(order.zone_id, order); + } + return order; } async assignDriver(orderId: string, driverId: string) { - return this.db.transaction(async (client) => { - const order = await client.query( + const order = await this.db.transaction(async (client) => { + const result = await client.query( `UPDATE orders SET status = 'driver_assigned', driver_id = $2, estimated_delivery_at = NOW() + INTERVAL '30 minutes' WHERE id = $1 AND status = 'ready_for_pickup' @@ -226,17 +271,34 @@ export class OrdersService { [driverId], ); - return order.rows[0]; + return result.rows[0]; }); + + if (order) { + // Get driver's user_id and name for notifications + const driverInfo = await this.db.queryOne( + `SELECT d.id, u.id AS user_id, u.first_name, u.last_name, d.vehicle_type + FROM drivers d JOIN users u ON u.id = d.user_id WHERE d.id = $1`, + [driverId], + ); + if (driverInfo) { + this.tracking.emitDeliveryAssigned(driverInfo.user_id, order); + this.tracking.emitDriverAccepted(orderId, order.restaurant_id, driverInfo); + } + this.tracking.emitOrderStatusUpdate(orderId, 'driver_assigned', order); + } + return order; } async markPickedUp(orderId: string, driverId: string) { - return this.db.queryOne( + const order = await this.db.queryOne( `UPDATE orders SET status = 'picked_up', picked_up_at = NOW() WHERE id = $1 AND driver_id = $2 AND status = 'driver_assigned' RETURNING *`, [orderId, driverId], ); + if (order) this.tracking.emitOrderStatusUpdate(orderId, 'picked_up', order); + return order; } async markDelivered(orderId: string, driverId: string) { @@ -275,6 +337,8 @@ export class OrdersService { return o; }); + this.tracking.emitOrderStatusUpdate(orderId, 'delivered', order); + // Send delivered email (non-critical) setImmediate(async () => { try { @@ -365,15 +429,37 @@ export class OrdersService { } async getRestaurantOrders(restaurantId: string, status?: string) { - const where = status ? `AND o.status = '${status}'` : ''; - return this.db.queryMany( - `SELECT o.*, COUNT(oi.id) AS item_count + const where = status ? `AND o.status = $2` : ''; + const params: any[] = status ? [restaurantId, status] : [restaurantId]; + const orders = await this.db.queryMany( + `SELECT o.id, o.order_number, o.status, o.subtotal, o.delivery_fee, + o.tip_amount, o.platform_fee, o.total_customer_pays, o.special_instructions, + o.created_at, o.estimated_pickup_at, + u.first_name AS customer_first_name, u.last_name AS customer_last_name, + u.phone AS customer_phone FROM orders o - JOIN order_items oi ON oi.order_id = o.id + JOIN users u ON u.id = o.customer_id WHERE o.restaurant_id = $1 ${where} - GROUP BY o.id - ORDER BY o.created_at DESC`, - [restaurantId], + ORDER BY o.created_at DESC + LIMIT 100`, + params, ); + + // Attach items to each order + const orderIds = orders.map((o) => o.id); + if (orderIds.length === 0) return []; + + const items = await this.db.queryMany( + `SELECT order_id, name, quantity, price FROM order_items WHERE order_id = ANY($1::uuid[])`, + [orderIds], + ); + + const itemsByOrder = new Map(); + for (const item of items) { + if (!itemsByOrder.has(item.order_id)) itemsByOrder.set(item.order_id, []); + itemsByOrder.get(item.order_id)!.push(item); + } + + return orders.map((o) => ({ ...o, items: itemsByOrder.get(o.id) || [] })); } } diff --git a/packages/backend/src/modules/payments/payments.controller.ts b/packages/backend/src/modules/payments/payments.controller.ts index f92df15..8547be3 100644 --- a/packages/backend/src/modules/payments/payments.controller.ts +++ b/packages/backend/src/modules/payments/payments.controller.ts @@ -46,11 +46,18 @@ export class PaymentsController { return this.paymentsService.saveDriverPaymentMethod(req.user.driverId, pmId); } - // Customer order payment + // Customer order payment (Stripe) @Post('orders/:orderId/intent') @UseGuards(JwtAuthGuard, RolesGuard) @Roles('customer') createIntent(@Param('orderId') orderId: string, @Request() req) { return this.paymentsService.createOrderPaymentIntent(orderId, req.user.id); } + + // Virtual payment — no Stripe required + @Post('orders/:orderId/pay-virtual') + @UseGuards(JwtAuthGuard) + payVirtual(@Param('orderId') orderId: string, @Request() req) { + return this.paymentsService.payVirtual(orderId, req.user.id); + } } diff --git a/packages/backend/src/modules/payments/payments.service.ts b/packages/backend/src/modules/payments/payments.service.ts index 6c8a6e1..4a763ea 100644 --- a/packages/backend/src/modules/payments/payments.service.ts +++ b/packages/backend/src/modules/payments/payments.service.ts @@ -188,6 +188,8 @@ export class PaymentsService { const paymentIntent = await this.stripe.paymentIntents.create({ amount: amountCents, currency: 'cad', + // transfer_group links this charge to subsequent Connect transfers in Stripe Dashboard + transfer_group: orderId, metadata: { orderId, customerId, @@ -205,6 +207,54 @@ export class PaymentsService { return { clientSecret: paymentIntent.client_secret }; } + // ============================================================ + // VIRTUAL PAYMENT (mock — no Stripe required) + // Immediately marks order paid and disperses virtual funds + // ============================================================ + + async payVirtual(orderId: string, customerId: string) { + const order = await this.db.queryOne( + `SELECT o.*, r.name AS restaurant_name + FROM orders o JOIN restaurants r ON r.id = o.restaurant_id + WHERE o.id = $1 AND o.customer_id = $2`, + [orderId, customerId], + ); + if (!order) throw new BadRequestException('Order not found'); + + // Mark paid and auto-confirm + await this.db.query( + `UPDATE orders + SET payment_status = 'succeeded', + payment_intent_id = $2, + status = 'confirmed', + estimated_pickup_at = NOW() + INTERVAL '20 minutes' + WHERE id = $1`, + [orderId, `virtual_${Date.now()}`], + ); + + // Virtual fund dispersal — track in virtual_ledger column (add if needed) or just log + const delivery_fee = Number(order.delivery_fee); + const tip = Number(order.tip_amount); + const total = Number(order.total_customer_pays); + + // Restaurant gets: subtotal − $0.10 platform fee + const restaurantReceives = Number(order.subtotal) - Number(order.platform_fee || 0.10); + // Driver gets: delivery_fee + tip (debt deducted later on delivery) + const driverReceives = delivery_fee + tip; + + return { + success: true, + orderId, + message: 'Virtual payment processed', + breakdown: { + customerPaid: total, + restaurantReceives: Number(restaurantReceives.toFixed(2)), + driverReceives: Number(driverReceives.toFixed(2)), + platformFee: Number((total - restaurantReceives - driverReceives).toFixed(2)), + }, + }; + } + // ============================================================ // STRIPE WEBHOOKS // ============================================================ @@ -228,6 +278,14 @@ export class PaymentsService { WHERE payment_intent_id = $1`, [pi.id], ); + + // Stripe Connect: transfer driver and restaurant portions + const orderId = pi.metadata.orderId; + if (orderId) { + await this.disperseOrderFunds(orderId, pi.id).catch((err) => + console.error('[Payments] Transfer error for order', orderId, err?.message), + ); + } } break; } @@ -262,4 +320,100 @@ export class PaymentsService { return { received: true }; } + + // ============================================================ + // STRIPE CONNECT — Separate Charges and Transfers + // After the platform collects payment, disperse funds to + // the driver (delivery fee + tip) and the restaurant (food subtotal - platform fee). + // ============================================================ + + private async disperseOrderFunds(orderId: string, paymentIntentId: string) { + const order = await this.db.queryOne( + `SELECT o.driver_receives, o.restaurant_receives, o.driver_id, o.restaurant_id, + d.stripe_connect_account_id AS driver_connect_id, + r.stripe_account_id AS restaurant_connect_id + FROM orders o + LEFT JOIN drivers d ON d.id = o.driver_id + LEFT JOIN restaurants r ON r.id = o.restaurant_id + WHERE o.id = $1`, + [orderId], + ); + + if (!order) return; + + const transfers: Promise[] = []; + + // Transfer delivery fee + tip to driver — deducting outstanding $20 daily fee debt first + if (order.driver_id) { + let driverCents = Math.round(Number(order.driver_receives) * 100); + + if (driverCents > 0) { + // Check for an unpaid daily fee session today + const session = await this.db.queryOne( + `SELECT id, daily_fee, delivery_revenue + FROM driver_sessions + WHERE driver_id = $1 AND session_date = CURRENT_DATE AND fee_paid = FALSE`, + [order.driver_id], + ); + + if (session) { + const debtCents = Math.round(Number(session.daily_fee) * 100); + const paidSoFarCents = Math.round(Number(session.delivery_revenue || 0) * 100); + const remainingDebtCents = Math.max(0, debtCents - paidSoFarCents); + const newTotalRevenueCents = paidSoFarCents + driverCents; + + if (remainingDebtCents >= driverCents) { + // Entire payout still goes toward debt — no Stripe transfer yet + driverCents = 0; + } else if (remainingDebtCents > 0) { + // Deduct remaining debt from this payout + driverCents -= remainingDebtCents; + } + + // Mark fee paid once cumulative earnings cover the daily fee + if (newTotalRevenueCents >= debtCents) { + await this.db.query( + `UPDATE driver_sessions SET fee_paid = TRUE, fee_paid_at = NOW() WHERE id = $1`, + [session.id], + ); + } + } + + if (driverCents > 0 && order.driver_connect_id) { + transfers.push( + this.stripe.transfers.create({ + amount: driverCents, + currency: 'cad', + destination: order.driver_connect_id, + transfer_group: orderId, + description: `Driver payout — Order ${orderId.slice(0, 8)}`, + metadata: { orderId, role: 'driver', type: 'delivery_payout' }, + }), + ); + } + } + } + + // Transfer food revenue to restaurant's Connect account + if (order.restaurant_connect_id) { + const restaurantCents = Math.round(order.restaurant_receives * 100); + if (restaurantCents > 0) { + transfers.push( + this.stripe.transfers.create({ + amount: restaurantCents, + currency: 'cad', + destination: order.restaurant_connect_id, + transfer_group: orderId, + description: `Restaurant payout — Order ${orderId.slice(0, 8)}`, + metadata: { orderId, role: 'restaurant', type: 'food_payout' }, + }), + ); + } + } + + if (transfers.length > 0) { + await Promise.all(transfers); + console.log(`[Payments] Transferred funds for order ${orderId}: driver=${order.driver_connect_id}, restaurant=${order.restaurant_connect_id}`); + } + } } diff --git a/packages/backend/src/modules/restaurants/restaurants.service.ts b/packages/backend/src/modules/restaurants/restaurants.service.ts index 19f5d4d..820f399 100644 --- a/packages/backend/src/modules/restaurants/restaurants.service.ts +++ b/packages/backend/src/modules/restaurants/restaurants.service.ts @@ -144,6 +144,24 @@ export class RestaurantsService { async create(ownerId: string, dto: any) { const slug = dto.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + + // Resolve coordinates: use provided values or geocode the address via Nominatim + let { lat, lng } = dto; + if (!lat || !lng || isNaN(Number(lat)) || isNaN(Number(lng))) { + const coords = await this.geocodeAddress( + `${dto.address || ''}, ${dto.postalCode || ''}, Toronto, ON, Canada`, + ); + lat = coords.lat; + lng = coords.lng; + } + + // Resolve zone_id: accept zone slug or UUID + let zoneId = dto.zoneId; + if (zoneId && !/^[0-9a-f-]{36}$/i.test(zoneId)) { + const zone = await this.db.queryOne(`SELECT id FROM zones WHERE slug = $1`, [zoneId]); + zoneId = zone?.id ?? zoneId; + } + return this.db.queryOne( `INSERT INTO restaurants (owner_id, name, slug, description, cuisine_type, phone, email, address, postal_code, location, zone_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, ST_GeographyFromText($10), $11) @@ -151,11 +169,22 @@ export class RestaurantsService { [ ownerId, dto.name, slug, dto.description, dto.cuisineType, dto.phone, dto.email, dto.address, dto.postalCode, - `POINT(${dto.lng} ${dto.lat})`, dto.zoneId, + `POINT(${lng} ${lat})`, zoneId, ], ); } + // Free geocoding via OpenStreetMap Nominatim — no API key required + private async geocodeAddress(address: string): Promise<{ lat: number; lng: number }> { + try { + const url = `https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(address)}`; + const res = await fetch(url, { headers: { 'User-Agent': 'TheVibe/1.0 (thevibe.ca)' } }); + const data: any[] = await res.json(); + if (data.length > 0) return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) }; + } catch {} + return { lat: 43.6389, lng: -79.4196 }; // fallback: Liberty Village centre + } + // ============================================================ // SAVINGS CALCULATOR (standalone - for marketing page) // ============================================================ diff --git a/packages/database/schema.sql b/packages/database/schema.sql index 3bc01cf..1cb353d 100644 --- a/packages/database/schema.sql +++ b/packages/database/schema.sql @@ -174,7 +174,8 @@ CREATE TABLE drivers ( total_earnings DECIMAL(12,2) DEFAULT 0, total_tips DECIMAL(12,2) DEFAULT 0, -- Stripe - stripe_payment_method VARCHAR(255), -- saved card for daily fee + stripe_payment_method VARCHAR(255), -- saved card for daily fee + stripe_connect_account_id VARCHAR(255), -- Stripe Connect Express account for payouts created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); diff --git a/packages/database/seed.sql b/packages/database/seed.sql index 3211f40..0fe5e04 100644 --- a/packages/database/seed.sql +++ b/packages/database/seed.sql @@ -197,3 +197,182 @@ INSERT INTO driver_locations (driver_id, location, is_online, is_available) VALU -- Sample customer user INSERT INTO users (id, email, phone, password_hash, role, first_name, last_name, is_verified) VALUES ('g0000000-0000-0000-0000-000000000001', 'customer@example.ca', '416-555-0301', '$2b$12$placeholder', 'customer', 'Jane', 'Customer', TRUE); + +-- ============================================================ +-- LIBERTY VILLAGE RESTAURANTS (5 restaurants for MVP demo) +-- Password for all owners: Test@1234 +-- bcrypt hash of Test@1234 with 12 rounds +-- ============================================================ + +INSERT INTO users (id, email, phone, password_hash, role, first_name, last_name, is_verified, is_active) VALUES +('b0000000-0000-0000-0001-000000000001', 'owner@libertyburgerlv.ca', '416-555-0201', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/lewdemohashlvbur', 'restaurant_owner', 'Tyler', 'Brooks', TRUE, TRUE), +('b0000000-0000-0000-0001-000000000002', 'owner@mangotreelv.ca', '416-555-0202', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/lewdemohashlvman', 'restaurant_owner', 'Priya', 'Sharma', TRUE, TRUE), +('b0000000-0000-0000-0001-000000000003', 'owner@cohosushilv.ca', '416-555-0203', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/lewdemohashlvcoh', 'restaurant_owner', 'Kenji', 'Nakamura',TRUE, TRUE), +('b0000000-0000-0000-0001-000000000004', 'owner@pizzerialiberta.ca', '416-555-0204', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/lewdemohashlvpiz', 'restaurant_owner', 'Sofia', 'Ricci', TRUE, TRUE), +('b0000000-0000-0000-0001-000000000005', 'owner@greenbowllv.ca', '416-555-0205', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/lewdemohashlvgrn', 'restaurant_owner', 'Amara', 'Osei', TRUE, TRUE); + +INSERT INTO restaurants (id, owner_id, name, slug, description, cuisine_type, phone, email, address, city, province, postal_code, location, zone_id, is_active, is_open, accepts_orders, avg_prep_time_minutes, min_order_amount, rating, total_reviews) VALUES +( + 'c0000000-0000-0001-0000-000000000001', + 'b0000000-0000-0000-0001-000000000001', + 'Liberty Burger Co.', + 'liberty-burger-co', + 'Smash burgers, crispy fries, and cold craft sodas in the heart of Liberty Village', + ARRAY['Burgers', 'American'], + '416-555-0201', + 'hello@libertyburgerlv.ca', + '50 Liberty Street, Toronto, ON', + 'Toronto', 'ON', 'M6K 3L7', + ST_GeographyFromText('POINT(-79.4198 43.6387)'), + (SELECT id FROM zones WHERE slug = 'liberty-village'), + TRUE, TRUE, TRUE, 15, 12.00, 4.7, 142 +), +( + 'c0000000-0000-0001-0000-000000000002', + 'b0000000-0000-0000-0001-000000000002', + 'Mango Tree', + 'mango-tree', + 'Modern Indian kitchen with butter chicken, dosas, and daily chef specials', + ARRAY['Indian', 'South Asian'], + '416-555-0202', + 'hello@mangotreelv.ca', + '17 Fraser Avenue, Toronto, ON', + 'Toronto', 'ON', 'M6K 1Y7', + ST_GeographyFromText('POINT(-79.4210 43.6395)'), + (SELECT id FROM zones WHERE slug = 'liberty-village'), + TRUE, TRUE, TRUE, 20, 15.00, 4.5, 98 +), +( + 'c0000000-0000-0001-0000-000000000003', + 'b0000000-0000-0000-0001-000000000003', + 'Coho Sushi', + 'coho-sushi', + 'Sustainable Pacific sushi, izakaya bites, and sake served fresh daily', + ARRAY['Japanese', 'Sushi'], + '416-555-0203', + 'hello@cohosushilv.ca', + '23 Hanna Avenue, Toronto, ON', + 'Toronto', 'ON', 'M6K 1W9', + ST_GeographyFromText('POINT(-79.4185 43.6375)'), + (SELECT id FROM zones WHERE slug = 'liberty-village'), + TRUE, TRUE, TRUE, 18, 18.00, 4.8, 211 +), +( + 'c0000000-0000-0001-0000-000000000004', + 'b0000000-0000-0000-0001-000000000004', + 'Pizzeria Liberta', + 'pizzeria-liberta', + 'Wood-fired Neapolitan pizzas and house-made pastas in a cozy Liberty Village spot', + ARRAY['Italian', 'Pizza'], + '416-555-0204', + 'hello@pizzerialiberta.ca', + '71 East Liberty Street, Toronto, ON', + 'Toronto', 'ON', 'M6K 3P6', + ST_GeographyFromText('POINT(-79.4220 43.6400)'), + (SELECT id FROM zones WHERE slug = 'liberty-village'), + TRUE, TRUE, TRUE, 22, 14.00, 4.6, 176 +), +( + 'c0000000-0000-0001-0000-000000000005', + 'b0000000-0000-0000-0001-000000000005', + 'Green Bowl', + 'green-bowl', + 'Nourishing grain bowls, cold-press juices, and plant-based fare for every body', + ARRAY['Vegan', 'Salads', 'Healthy'], + '416-555-0205', + 'hello@greenbowllv.ca', + '11 Ordnance Street, Toronto, ON', + 'Toronto', 'ON', 'M6K 0C1', + ST_GeographyFromText('POINT(-79.4175 43.6365)'), + (SELECT id FROM zones WHERE slug = 'liberty-village'), + TRUE, TRUE, TRUE, 12, 10.00, 4.9, 309 +); + +-- ---- Liberty Burger Co. menu ---- +INSERT INTO menu_categories (id, restaurant_id, name, sort_order) VALUES +('d0000000-0000-0001-0000-000000000001', 'c0000000-0000-0001-0000-000000000001', 'Burgers', 1), +('d0000000-0000-0001-0000-000000000002', 'c0000000-0000-0001-0000-000000000001', 'Sides', 2), +('d0000000-0000-0001-0000-000000000003', 'c0000000-0000-0001-0000-000000000001', 'Drinks', 3); + +INSERT INTO menu_items (restaurant_id, category_id, name, description, price, dietary_tags, is_featured, is_available) VALUES +('c0000000-0000-0001-0000-000000000001', 'd0000000-0000-0001-0000-000000000001', 'The Liberty Smash', 'Double smash patty, American cheese, house sauce, pickles, brioche bun', 16.00, ARRAY[]::VARCHAR[], TRUE, TRUE), +('c0000000-0000-0001-0000-000000000001', 'd0000000-0000-0001-0000-000000000001', 'BBQ Bacon Stack', 'Triple patty, crispy bacon, aged cheddar, BBQ, caramelized onions', 21.00, ARRAY[]::VARCHAR[], TRUE, TRUE), +('c0000000-0000-0001-0000-000000000001', 'd0000000-0000-0001-0000-000000000001', 'Mushroom Swiss', 'Sautéed cremini mushrooms, Swiss cheese, garlic aioli, arugula', 17.00, ARRAY['vegetarian'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000001', 'd0000000-0000-0001-0000-000000000001', 'Crispy Chicken Burger', 'Buttermilk fried chicken thigh, slaw, spicy mayo, pickles', 18.00, ARRAY[]::VARCHAR[], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000001', 'd0000000-0000-0001-0000-000000000002', 'Smash Fries', 'Hand-cut fries tossed in house seasoning', 6.00, ARRAY['vegan'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000001', 'd0000000-0000-0001-0000-000000000002', 'Poutine', 'Fries, Quebec curds, chicken gravy', 11.00, ARRAY[]::VARCHAR[], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000001', 'd0000000-0000-0001-0000-000000000003', 'Craft Soda', 'Cane sugar soda — cola, ginger beer, or orange cream', 4.00, ARRAY['vegan'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000001', 'd0000000-0000-0001-0000-000000000003', 'Milkshake', 'Thick shake — vanilla, chocolate, or strawberry', 8.00, ARRAY['vegetarian'], FALSE, TRUE); + +-- ---- Mango Tree menu ---- +INSERT INTO menu_categories (id, restaurant_id, name, sort_order) VALUES +('d0000000-0000-0001-0000-000000000004', 'c0000000-0000-0001-0000-000000000002', 'Mains', 1), +('d0000000-0000-0001-0000-000000000005', 'c0000000-0000-0001-0000-000000000002', 'Breads & Rice', 2), +('d0000000-0000-0001-0000-000000000006', 'c0000000-0000-0001-0000-000000000002', 'Drinks', 3); + +INSERT INTO menu_items (restaurant_id, category_id, name, description, price, dietary_tags, is_featured, is_available) VALUES +('c0000000-0000-0001-0000-000000000002', 'd0000000-0000-0001-0000-000000000004', 'Butter Chicken', 'Tandoor-roasted chicken in house tomato-cream masala, served with rice', 19.00, ARRAY['gluten-free'], TRUE, TRUE), +('c0000000-0000-0001-0000-000000000002', 'd0000000-0000-0001-0000-000000000004', 'Saag Paneer', 'House-made paneer simmered in spiced spinach gravy', 17.00, ARRAY['vegetarian', 'gluten-free'], TRUE, TRUE), +('c0000000-0000-0001-0000-000000000002', 'd0000000-0000-0001-0000-000000000004', 'Lamb Rogan Josh', 'Slow-braised Ontario lamb in Kashmiri spice gravy', 24.00, ARRAY['gluten-free'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000002', 'd0000000-0000-0001-0000-000000000004', 'Dal Makhani', 'Black lentils slow-cooked overnight with cream and butter', 15.00, ARRAY['vegetarian', 'gluten-free'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000002', 'd0000000-0000-0001-0000-000000000005', 'Garlic Naan', 'Tandoor-baked flatbread brushed with garlic butter — 2 pieces', 5.00, ARRAY['vegetarian'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000002', 'd0000000-0000-0001-0000-000000000005', 'Basmati Rice', 'Aromatic long-grain basmati, cardamom and bay leaf', 4.00, ARRAY['vegan', 'gluten-free'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000002', 'd0000000-0000-0001-0000-000000000006', 'Mango Lassi', 'Alphonso mango blended with yogurt and cardamom', 6.00, ARRAY['vegetarian'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000002', 'd0000000-0000-0001-0000-000000000006', 'Masala Chai', 'House-spiced tea with oat milk', 4.50, ARRAY['vegetarian'], FALSE, TRUE); + +-- ---- Coho Sushi menu ---- +INSERT INTO menu_categories (id, restaurant_id, name, sort_order) VALUES +('d0000000-0000-0001-0000-000000000007', 'c0000000-0000-0001-0000-000000000003', 'Rolls', 1), +('d0000000-0000-0001-0000-000000000008', 'c0000000-0000-0001-0000-000000000003', 'Nigiri & Sashimi', 2), +('d0000000-0000-0001-0000-000000000009', 'c0000000-0000-0001-0000-000000000003', 'Izakaya Bites', 3); + +INSERT INTO menu_items (restaurant_id, category_id, name, description, price, dietary_tags, is_featured, is_available) VALUES +('c0000000-0000-0001-0000-000000000003', 'd0000000-0000-0001-0000-000000000007', 'Coho Roll', 'BC salmon, cucumber, avocado, ponzu drizzle — 8 pieces', 16.00, ARRAY['gluten-free'], TRUE, TRUE), +('c0000000-0000-0001-0000-000000000003', 'd0000000-0000-0001-0000-000000000007', 'Spicy Tuna Crunch', 'Spicy tuna, tempura flakes, sriracha mayo — 8 pieces', 17.00, ARRAY[]::VARCHAR[], TRUE, TRUE), +('c0000000-0000-0001-0000-000000000003', 'd0000000-0000-0001-0000-000000000007', 'Veggie Dragon', 'Cucumber, pickled daikon, avocado, sesame — 8 pieces', 13.00, ARRAY['vegan', 'gluten-free'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000003', 'd0000000-0000-0001-0000-000000000008', 'Salmon Nigiri', 'Two pieces hand-pressed sushi rice with Atlantic salmon', 8.00, ARRAY['gluten-free'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000003', 'd0000000-0000-0001-0000-000000000008', 'Tuna Sashimi', 'Five slices of bluefin tuna, wasabi, pickled ginger', 18.00, ARRAY['gluten-free'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000003', 'd0000000-0000-0001-0000-000000000009', 'Edamame', 'Steamed salted soybeans', 5.00, ARRAY['vegan', 'gluten-free'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000003', 'd0000000-0000-0001-0000-000000000009', 'Gyoza', 'Pan-fried pork and cabbage dumplings, ponzu dip — 6 pieces', 11.00, ARRAY[]::VARCHAR[], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000003', 'd0000000-0000-0001-0000-000000000009', 'Miso Soup', 'Tofu, wakame, green onion in dashi broth', 4.00, ARRAY['vegetarian'], FALSE, TRUE); + +-- ---- Pizzeria Liberta menu ---- +INSERT INTO menu_categories (id, restaurant_id, name, sort_order) VALUES +('d0000000-0000-0001-0000-000000000010', 'c0000000-0000-0001-0000-000000000004', 'Pizzas', 1), +('d0000000-0000-0001-0000-000000000011', 'c0000000-0000-0001-0000-000000000004', 'Pasta', 2), +('d0000000-0000-0001-0000-000000000012', 'c0000000-0000-0001-0000-000000000004', 'Drinks', 3); + +INSERT INTO menu_items (restaurant_id, category_id, name, description, price, dietary_tags, is_featured, is_available) VALUES +('c0000000-0000-0001-0000-000000000004', 'd0000000-0000-0001-0000-000000000010', 'Margherita DOC', 'San Marzano DOP tomatoes, buffalo mozzarella, fresh basil, EVOO', 20.00, ARRAY['vegetarian'], TRUE, TRUE), +('c0000000-0000-0001-0000-000000000004', 'd0000000-0000-0001-0000-000000000010', 'Nduja Piccante', 'Calabrian nduja sausage, San Marzano, fior di latte, chili', 23.00, ARRAY[]::VARCHAR[], TRUE, TRUE), +('c0000000-0000-0001-0000-000000000004', 'd0000000-0000-0001-0000-000000000010', 'Funghi e Tartufo', 'Mixed mushrooms, truffle oil, taleggio, thyme — no tomato', 25.00, ARRAY['vegetarian'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000004', 'd0000000-0000-0001-0000-000000000010', 'Prosciutto Rucola', 'Prosciutto di Parma, aged parmesan, cherry tomatoes, baby arugula', 26.00, ARRAY[]::VARCHAR[], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000004', 'd0000000-0000-0001-0000-000000000011', 'Cacio e Pepe', 'House tonnarelli, pecorino romano, toasted black pepper', 19.00, ARRAY['vegetarian'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000004', 'd0000000-0000-0001-0000-000000000011', 'Orecchiette Salsiccia', 'House pasta, pork sausage, broccoli rabe, chili, garlic', 22.00, ARRAY[]::VARCHAR[], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000004', 'd0000000-0000-0001-0000-000000000012', 'San Pellegrino', 'Sparkling water 500ml', 4.00, ARRAY['vegan'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000004', 'd0000000-0000-0001-0000-000000000012', 'Aranciata', 'Blood orange soda, San Pellegrino', 4.50, ARRAY['vegan'], FALSE, TRUE); + +-- ---- Green Bowl menu ---- +INSERT INTO menu_categories (id, restaurant_id, name, sort_order) VALUES +('d0000000-0000-0001-0000-000000000013', 'c0000000-0000-0001-0000-000000000005', 'Grain Bowls', 1), +('d0000000-0000-0001-0000-000000000014', 'c0000000-0000-0001-0000-000000000005', 'Salads', 2), +('d0000000-0000-0001-0000-000000000015', 'c0000000-0000-0001-0000-000000000005', 'Juices & Smoothies', 3); + +INSERT INTO menu_items (restaurant_id, category_id, name, description, price, dietary_tags, is_featured, is_available) VALUES +('c0000000-0000-0001-0000-000000000005', 'd0000000-0000-0001-0000-000000000013', 'The Liberty Bowl', 'Brown rice, roasted sweet potato, black beans, avocado, tahini drizzle', 15.00, ARRAY['vegan', 'gluten-free'], TRUE, TRUE), +('c0000000-0000-0001-0000-000000000005', 'd0000000-0000-0001-0000-000000000013', 'Protein Power Bowl', 'Quinoa, grilled tofu, edamame, pickled beet, miso-ginger dressing', 16.00, ARRAY['vegan', 'gluten-free'], TRUE, TRUE), +('c0000000-0000-0001-0000-000000000005', 'd0000000-0000-0001-0000-000000000013', 'Harvest Farro Bowl', 'Farro, roasted squash, kale, dried cranberry, pepitas, apple cider vin', 14.00, ARRAY['vegan'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000005', 'd0000000-0000-0001-0000-000000000014', 'Massaged Kale Caesar', 'Kale, house vegan caesar, hemp parmesan, sourdough croutons', 13.00, ARRAY['vegan'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000005', 'd0000000-0000-0001-0000-000000000014', 'Beet & Arugula', 'Roasted golden beet, arugula, walnuts, goat cheese, balsamic glaze', 14.00, ARRAY['vegetarian', 'gluten-free'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000005', 'd0000000-0000-0001-0000-000000000015', 'Green Machine', 'Spinach, cucumber, celery, green apple, ginger, lemon', 9.00, ARRAY['vegan', 'gluten-free'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000005', 'd0000000-0000-0001-0000-000000000015', 'Mango Turmeric Smoothie', 'Frozen mango, banana, turmeric, oat milk, maple', 10.00, ARRAY['vegan', 'gluten-free'], FALSE, TRUE), +('c0000000-0000-0001-0000-000000000005', 'd0000000-0000-0001-0000-000000000015', 'Cold Brew', 'Single-origin cold brew, 12oz', 5.50, ARRAY['vegan'], FALSE, TRUE); + +-- Update the seeded driver to also cover Liberty Village zone +UPDATE drivers +SET zone_id = (SELECT id FROM zones WHERE slug = 'liberty-village') +WHERE id = 'f0000000-0000-0000-0000-000000000001'; + +UPDATE driver_locations +SET location = ST_GeographyFromText('POINT(-79.4196 43.6389)') +WHERE driver_id = 'f0000000-0000-0000-0000-000000000001'; diff --git a/packages/web/package.json b/packages/web/package.json index e86d941..91095c0 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,18 +9,19 @@ "lint": "next lint" }, "dependencies": { + "@stripe/react-stripe-js": "^2.5.0", + "@stripe/stripe-js": "^3.0.0", + "axios": "^1.6.7", + "clsx": "^2.1.0", + "date-fns": "^3.3.1", + "maplibre-gl": "^4.1.0", "next": "^14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "maplibre-gl": "^4.1.0", "socket.io-client": "^4.6.2", - "@stripe/stripe-js": "^3.0.0", - "@stripe/react-stripe-js": "^2.5.0", - "axios": "^1.6.7", "swr": "^2.2.4", - "zustand": "^4.5.0", - "clsx": "^2.1.0", - "date-fns": "^3.3.1" + "use-places-autocomplete": "^4.0.1", + "zustand": "^4.5.0" }, "devDependencies": { "@types/node": "^20.11.5", diff --git a/packages/web/src/app/checkout/page.tsx b/packages/web/src/app/checkout/page.tsx index e9e335b..1a3d64f 100644 --- a/packages/web/src/app/checkout/page.tsx +++ b/packages/web/src/app/checkout/page.tsx @@ -2,48 +2,73 @@ import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' -import { loadStripe } from '@stripe/stripe-js' -import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js' +import usePlacesAutocomplete, { getGeocode, getLatLng } from 'use-places-autocomplete' import { api } from '@/lib/api' import { useCart } from '@/lib/cart' -const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!) - // ============================================================ // CHECKOUT PAGE // 1. Address input + zone check // 2. Tip selection // 3. Transparent pricing breakdown -// 4. Stripe PaymentElement +// 4. Virtual payment (mock — no Stripe required) // ============================================================ export default function CheckoutPage() { const { items, subtotal, restaurantId, restaurantName, clearCart, deliveryFee } = useCart() const router = useRouter() - const [address, setAddress] = useState('') + const [streetAddress, setStreetAddress] = useState('') + const [city, setCity] = useState('') + const [postalCode, setPostalCode] = useState('') + const [coords, setCoords] = useState<{ lng: number; lat: number } | null>(null) const [tip, setTip] = useState(0) const [instructions, setInstructions] = useState('') - const [clientSecret, setClientSecret] = useState('') const [orderId, setOrderId] = useState('') const [step, setStep] = useState<'address' | 'payment'>('address') const [zoneError, setZoneError] = useState('') const [loading, setLoading] = useState(false) - - // Hardcoded downtown Toronto coords for demo — in prod, geocode the address - const DEMO_COORDS = { lng: -79.3832, lat: 43.6532 } + const [mapsReady, setMapsReady] = useState(false) + const [paymentBreakdown, setPaymentBreakdown] = useState(null) const cartSubtotal = subtotal() - const ccFee = Math.round((cartSubtotal + deliveryFee + tip) * 0.029 * 100 + 30) / 100 - const total = cartSubtotal + deliveryFee + tip + ccFee + const total = cartSubtotal + deliveryFee + tip - // Redirect if cart is empty + // Auth guard — redirect unauthenticated users to login useEffect(() => { - if (items.length === 0) router.replace('/restaurants') - }, [items.length]) + const token = localStorage.getItem('vibe_token') || localStorage.getItem('token') + if (!token) router.replace('/login?redirect=/checkout') + }, []) + + // Redirect if cart is empty (but not after payment when cart is intentionally cleared) + useEffect(() => { + if (items.length === 0 && step === 'address') router.replace('/restaurants') + }, [items.length, step]) + + // Load Google Maps Places API + useEffect(() => { + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY + if (!apiKey) return // No key → fall back to plain inputs + if (typeof window !== 'undefined' && (window as any).google?.maps?.places) { + setMapsReady(true) + return + } + const existing = document.querySelector('script[data-gmaps]') + if (existing) { existing.addEventListener('load', () => setMapsReady(true)); return } + + const script = document.createElement('script') + script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places` + script.async = true + script.setAttribute('data-gmaps', '1') + script.onload = () => setMapsReady(true) + document.head.appendChild(script) + }, []) const handleProceedToPayment = async () => { - if (!address.trim()) return + if (!streetAddress.trim()) return + // Fall back to Liberty Village centre if no geocode available + const deliveryCoords = coords ?? { lng: -79.4196, lat: 43.6389 } + const fullAddress = [streetAddress, city, postalCode].filter(Boolean).join(', ') setLoading(true) setZoneError('') @@ -51,10 +76,10 @@ export default function CheckoutPage() { try { // 1. Verify delivery address is in service area const zoneCheck = await api.get('/zones/check', { - params: { lng: DEMO_COORDS.lng, lat: DEMO_COORDS.lat }, + params: { lng: deliveryCoords.lng, lat: deliveryCoords.lat }, }) if (!zoneCheck.data.inServiceArea) { - setZoneError('Sorry, your address is outside our current service area. We\'re expanding soon!') + setZoneError("Sorry, your address is outside our current service area. We're expanding soon!") setLoading(false) return } @@ -63,9 +88,9 @@ export default function CheckoutPage() { const orderData = await api.post('/orders', { restaurantId, items: items.map((i) => ({ menuItemId: i.menuItemId, quantity: i.quantity })), - deliveryAddress: address, - deliveryLng: DEMO_COORDS.lng, - deliveryLat: DEMO_COORDS.lat, + deliveryAddress: fullAddress, + deliveryLng: deliveryCoords.lng, + deliveryLat: deliveryCoords.lat, tipAmount: tip, specialInstructions: instructions, }) @@ -73,9 +98,9 @@ export default function CheckoutPage() { const order = orderData.data.order setOrderId(order.id) - // 3. Create Stripe PaymentIntent - const intentData = await api.post(`/payments/orders/${order.id}/intent`) - setClientSecret(intentData.data.clientSecret) + // 3. Process virtual payment immediately + const payData = await api.post(`/payments/orders/${order.id}/pay-virtual`) + setPaymentBreakdown(payData.data.breakdown) setStep('payment') } catch (err: any) { setZoneError(err.response?.data?.message || 'Failed to place order. Please try again.') @@ -85,24 +110,20 @@ export default function CheckoutPage() { } const TIP_OPTIONS = [0, 2, 3, 5, 8] + const canProceed = streetAddress.trim().length > 0 - if (step === 'payment' && clientSecret) { + if (step === 'payment') { return ( - - { - clearCart() - router.push(`/orders/${orderId}/track`) - }} - /> - + { + const id = orderId + clearCart() + router.push(`/orders/${id}/track`) + }} + /> ) } @@ -125,20 +146,57 @@ export default function CheckoutPage() { {/* Delivery address */}

Delivery Address

- setAddress(e.target.value)} - placeholder="123 King Street W, Toronto, ON" - className="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal" - /> + + {mapsReady ? ( + { + setStreetAddress(street) + setCity(resolvedCity) + setPostalCode(resolvedPostal) + setCoords(resolvedCoords) + }} + /> + ) : ( + setStreetAddress(e.target.value)} + placeholder="Street address" + className="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal" + /> + )} + +
+
+ + setCity(e.target.value)} + placeholder="Toronto" + className="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal" + /> +
+
+ + setPostalCode(e.target.value)} + placeholder="M6J 1E6" + className="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal" + /> +
+
+ {zoneError && (

{zoneError}

)} +