Compare commits

...

2 Commits

Author SHA1 Message Date
Jaskaran
8749740e6c latest chagnes 2026-03-15 14:54:36 -04:00
Jaskaran
b6d6f17a9a Changes until March 5 2026-03-06 09:05:31 -05:00
35 changed files with 3724 additions and 1103 deletions

View File

@ -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

10
package-lock.json generated
View File

@ -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": {

View File

@ -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,
});

View File

@ -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')

View File

@ -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');
@ -62,8 +71,9 @@ export class AuthService {
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) throw new UnauthorizedException('Invalid credentials');
const { password_hash, ...safeUser } = user;
return { user: safeUser, token: this.signToken(user) };
// Return full profile including restaurantId / driverId so frontend can use them
const fullUser = await this.validateToken({ sub: user.id, role: user.role });
return { user: fullUser, token: this.signToken(user) };
}
async validateToken(payload: { sub: string; role: string }) {

View File

@ -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) {

View File

@ -14,13 +14,14 @@ export class DriversService {
// ============================================================
async startSession(driverId: string): Promise<any> {
// 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],
);
}

View File

@ -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);

View File

@ -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],

View File

@ -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<string, any[]>();
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) || [] }));
}
}

View File

@ -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);
}
}

View File

@ -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<any>[] = [];
// 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}`);
}
}
}

View File

@ -1,4 +1,4 @@
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards, Request, BadRequestException } from '@nestjs/common';
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards, Request, BadRequestException } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard, Roles } from '../auth/guards/roles.guard';
import { RestaurantsService } from './restaurants.service';
@ -62,6 +62,44 @@ export class RestaurantsController {
return this.restaurantsService.updateHours(req.user.restaurantId, isOpen);
}
// ---- Menu management (restaurant owner) ----
// Must be before :slug to avoid route conflict
@Get('menu/items')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
getMenu(@Request() req) {
return this.restaurantsService.getMenu(req.user.restaurantId);
}
@Post('menu/categories')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
addCategory(@Request() req, @Body('name') name: string) {
return this.restaurantsService.addCategory(req.user.restaurantId, name);
}
@Post('menu/items')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
addMenuItem(@Request() req, @Body() dto: any) {
return this.restaurantsService.addMenuItem(req.user.restaurantId, dto);
}
@Patch('menu/items/:id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
updateMenuItem(@Request() req, @Param('id') id: string, @Body() dto: any) {
return this.restaurantsService.updateMenuItem(req.user.restaurantId, id, dto);
}
@Delete('menu/items/:id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
deleteMenuItem(@Request() req, @Param('id') id: string) {
return this.restaurantsService.deleteMenuItem(req.user.restaurantId, id);
}
@Get(':slug')
findBySlug(@Param('slug') slug: string) {
return this.restaurantsService.findBySlug(slug);

View File

@ -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,125 @@ 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
}
// ============================================================
// MENU MANAGEMENT (restaurant owner)
// ============================================================
async getMenu(restaurantId: string) {
const categories = await this.db.queryMany(
`SELECT mc.id, mc.name, mc.sort_order, mc.is_active
FROM menu_categories mc
WHERE mc.restaurant_id = $1
ORDER BY mc.sort_order, mc.name`,
[restaurantId],
);
const items = await this.db.queryMany(
`SELECT mi.id, mi.category_id, mi.name, mi.description, mi.price,
mi.is_available, mi.is_featured, mi.dietary_tags, mi.sort_order
FROM menu_items mi
JOIN menu_categories mc ON mc.id = mi.category_id
WHERE mc.restaurant_id = $1
ORDER BY mi.sort_order, mi.name`,
[restaurantId],
);
return categories.map((cat) => ({
...cat,
items: items.filter((i) => i.category_id === cat.id),
}));
}
async addCategory(restaurantId: string, name: string) {
const existing = await this.db.queryMany(
`SELECT sort_order FROM menu_categories WHERE restaurant_id = $1`,
[restaurantId],
);
const nextSort = existing.length + 1;
return this.db.queryOne(
`INSERT INTO menu_categories (restaurant_id, name, sort_order)
VALUES ($1, $2, $3) RETURNING *`,
[restaurantId, name.trim(), nextSort],
);
}
async addMenuItem(restaurantId: string, dto: {
categoryId: string;
name: string;
description?: string;
price: number;
dietaryTags?: string[];
}) {
// Verify category belongs to this restaurant
const cat = await this.db.queryOne(
`SELECT id FROM menu_categories WHERE id = $1 AND restaurant_id = $2`,
[dto.categoryId, restaurantId],
);
if (!cat) throw new Error('Category not found');
const count = await this.db.queryOne(
`SELECT COUNT(*) AS n FROM menu_items WHERE category_id = $1`,
[dto.categoryId],
);
const nextSort = Number(count?.n || 0) + 1;
return this.db.queryOne(
`INSERT INTO menu_items (restaurant_id, category_id, name, description, price, dietary_tags, sort_order, is_available)
VALUES ($1, $2, $3, $4, $5, $6, $7, TRUE) RETURNING *`,
[restaurantId, dto.categoryId, dto.name.trim(), dto.description || null, dto.price, dto.dietaryTags || [], nextSort],
);
}
async updateMenuItem(restaurantId: string, itemId: string, dto: {
name?: string;
description?: string;
price?: number;
isAvailable?: boolean;
isFeatured?: boolean;
}) {
const sets: string[] = [];
const params: any[] = [itemId, restaurantId];
let idx = 3;
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name.trim()); }
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
if (dto.price !== undefined) { sets.push(`price = $${idx++}`); params.push(dto.price); }
if (dto.isAvailable !== undefined) { sets.push(`is_available = $${idx++}`); params.push(dto.isAvailable); }
if (dto.isFeatured !== undefined) { sets.push(`is_featured = $${idx++}`); params.push(dto.isFeatured); }
if (sets.length === 0) return this.db.queryOne(`SELECT * FROM menu_items WHERE id = $1`, [itemId]);
sets.push(`updated_at = NOW()`);
return this.db.queryOne(
`UPDATE menu_items SET ${sets.join(', ')}
WHERE id = $1 AND restaurant_id = $2 RETURNING *`,
params,
);
}
async deleteMenuItem(restaurantId: string, itemId: string) {
return this.db.queryOne(
`DELETE FROM menu_items WHERE id = $1 AND restaurant_id = $2 RETURNING id`,
[itemId, restaurantId],
);
}
// ============================================================
// SAVINGS CALCULATOR (standalone - for marketing page)
// ============================================================

View File

@ -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()
);

View File

@ -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';

View File

@ -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",

View File

@ -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<any>(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 (
<Elements stripe={stripePromise} options={{ clientSecret, appearance: { theme: 'stripe' } }}>
<PaymentStep
orderId={orderId}
total={total}
cartSubtotal={cartSubtotal}
deliveryFee={deliveryFee}
tip={tip}
ccFee={ccFee}
restaurantName={restaurantName || ''}
onSuccess={() => {
clearCart()
router.push(`/orders/${orderId}/track`)
}}
/>
</Elements>
<VirtualPaymentConfirmation
orderId={orderId}
restaurantName={restaurantName || ''}
breakdown={paymentBreakdown}
onContinue={() => {
const id = orderId
clearCart()
router.push(`/orders/${id}/track`)
}}
/>
)
}
@ -125,20 +146,57 @@ export default function CheckoutPage() {
{/* Delivery address */}
<div className="bg-white rounded-2xl border border-slate-100 p-4 space-y-3">
<h2 className="font-semibold text-vibe-dark">Delivery Address</h2>
<input
type="text"
value={address}
onChange={(e) => 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 ? (
<AddressAutocomplete
onSelect={(street, resolvedCity, resolvedPostal, resolvedCoords) => {
setStreetAddress(street)
setCity(resolvedCity)
setPostalCode(resolvedPostal)
setCoords(resolvedCoords)
}}
/>
) : (
<input
type="text"
value={streetAddress}
onChange={(e) => 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"
/>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-400 mb-1">City</label>
<input
type="text"
value={city}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">Postal Code</label>
<input
type="text"
value={postalCode}
onChange={(e) => 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"
/>
</div>
</div>
{zoneError && (
<p className="text-red-600 text-sm bg-red-50 rounded-xl px-3 py-2">{zoneError}</p>
)}
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Delivery instructions (optional)"
placeholder="Delivery instructions (optional) — buzz code, leave at door, etc."
rows={2}
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 resize-none"
/>
@ -151,7 +209,7 @@ export default function CheckoutPage() {
{items.map((item) => (
<div key={item.menuItemId} className="flex justify-between text-sm">
<span className="text-slate-600">{item.quantity}× {item.name}</span>
<span className="text-vibe-dark font-medium">${(item.price * item.quantity).toFixed(2)}</span>
<span className="text-vibe-dark font-medium">${(Number(item.price) * item.quantity).toFixed(2)}</span>
</div>
))}
</div>
@ -178,102 +236,172 @@ export default function CheckoutPage() {
</div>
</div>
{/* Price breakdown — full transparency */}
{/* Price breakdown */}
<div className="bg-white rounded-2xl border border-slate-100 p-4 space-y-2 text-sm">
<h2 className="font-semibold text-vibe-dark mb-1">Price Breakdown</h2>
<div className="flex justify-between text-slate-600"><span>Subtotal</span><span>${cartSubtotal.toFixed(2)}</span></div>
<div className="flex justify-between text-slate-600"><span>Delivery fee (flat)</span><span>${deliveryFee.toFixed(2)}</span></div>
{tip > 0 && <div className="flex justify-between text-vibe-green"><span>Tip (100% to driver)</span><span>${tip.toFixed(2)}</span></div>}
<div className="flex justify-between text-slate-400 text-xs"><span>Payment processing (Stripe)</span><span>${ccFee.toFixed(2)}</span></div>
<div className="flex justify-between font-bold text-vibe-dark pt-2 border-t border-slate-100">
<span>Total</span><span>${total.toFixed(2)}</span>
</div>
<div className="bg-vibe-green/5 rounded-xl p-2 text-xs text-slate-500 mt-2">
No service fee. No hidden charges. What you see is what you pay.
No service fee. No hidden charges. No credit card needed virtual payment.
</div>
</div>
<button
onClick={handleProceedToPayment}
disabled={!address.trim() || loading}
disabled={!canProceed || loading}
className="w-full bg-vibe-teal text-white py-4 rounded-xl font-semibold hover:bg-teal-700 transition disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? 'Checking your area...' : `Continue to Payment · $${total.toFixed(2)}`}
{loading ? 'Placing order...' : `Place Order · $${total.toFixed(2)}`}
</button>
</div>
</div>
)
}
// ---- Stripe Payment Step ----
// ---- Google Places autocomplete component ----
function PaymentStep({
orderId, total, cartSubtotal, deliveryFee, tip, ccFee, restaurantName, onSuccess,
function AddressAutocomplete({
onSelect,
}: {
orderId: string; total: number; cartSubtotal: number; deliveryFee: number;
tip: number; ccFee: number; restaurantName: string; onSuccess: () => void;
onSelect: (
streetAddress: string,
city: string,
postalCode: string,
coords: { lng: number; lat: number },
) => void
}) {
const stripe = useStripe()
const elements = useElements()
const [error, setError] = useState('')
const [paying, setPaying] = useState(false)
const {
ready,
value,
suggestions: { status, data },
setValue,
clearSuggestions,
} = usePlacesAutocomplete({
requestOptions: {
componentRestrictions: { country: 'ca' },
types: ['address'],
},
debounce: 300,
})
const handlePay = async (e: React.FormEvent) => {
e.preventDefault()
if (!stripe || !elements) return
setPaying(true)
setError('')
const handleSelect = async (description: string) => {
setValue(description, false)
clearSuggestions()
const { error: stripeError } = await stripe.confirmPayment({
elements,
redirect: 'if_required',
})
try {
const results = await getGeocode({ address: description })
const { lat, lng } = await getLatLng(results[0])
const components = results[0].address_components
if (stripeError) {
setError(stripeError.message || 'Payment failed')
setPaying(false)
} else {
onSuccess()
let resolvedCity = ''
let resolvedPostal = ''
let streetNumber = ''
let route = ''
for (const comp of components) {
if (comp.types.includes('street_number')) streetNumber = comp.long_name
if (comp.types.includes('route')) route = comp.long_name
if (comp.types.includes('locality')) resolvedCity = comp.long_name
if (comp.types.includes('postal_code')) resolvedPostal = comp.long_name
}
const streetAddress = [streetNumber, route].filter(Boolean).join(' ') || description
onSelect(streetAddress, resolvedCity, resolvedPostal, { lng, lat })
} catch {
onSelect(description, '', '', { lng: -79.4196, lat: 43.6389 })
}
}
return (
<div className="relative">
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={!ready}
placeholder="Start typing your 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"
autoComplete="off"
/>
{status === 'OK' && data.length > 0 && (
<ul className="absolute z-30 w-full bg-white border border-slate-200 rounded-xl mt-1 shadow-lg overflow-hidden">
{data.map(({ place_id, description, structured_formatting }) => (
<li
key={place_id}
onClick={() => handleSelect(description)}
className="px-4 py-3 text-sm hover:bg-vibe-cream cursor-pointer border-b border-slate-50 last:border-0"
>
<span className="font-medium text-vibe-dark">{structured_formatting.main_text}</span>
<span className="text-slate-400 text-xs ml-1">{structured_formatting.secondary_text}</span>
</li>
))}
</ul>
)}
</div>
)
}
// ---- Virtual Payment Confirmation ----
function VirtualPaymentConfirmation({
orderId, restaurantName, breakdown, onContinue,
}: {
orderId: string; restaurantName: string;
breakdown: { customerPaid: number; restaurantReceives: number; driverReceives: number; platformFee: number } | null;
onContinue: () => void;
}) {
return (
<div className="min-h-screen bg-vibe-cream">
<div className="bg-white border-b border-slate-100 px-6 py-4">
<div className="max-w-lg mx-auto">
<h1 className="font-bold text-vibe-dark">Payment</h1>
<h1 className="font-bold text-vibe-dark">Order Confirmed!</h1>
<p className="text-slate-500 text-sm">{restaurantName}</p>
</div>
</div>
<div className="max-w-lg mx-auto px-4 py-6 space-y-4">
<div className="bg-white rounded-2xl border border-slate-100 p-4 space-y-1.5 text-sm">
<div className="flex justify-between text-slate-600"><span>Food</span><span>${cartSubtotal.toFixed(2)}</span></div>
<div className="flex justify-between text-slate-600"><span>Delivery</span><span>${deliveryFee.toFixed(2)}</span></div>
{tip > 0 && <div className="flex justify-between text-vibe-green"><span>Tip</span><span>${tip.toFixed(2)}</span></div>}
<div className="flex justify-between text-slate-400 text-xs"><span>Processing</span><span>${ccFee.toFixed(2)}</span></div>
<div className="flex justify-between font-bold text-vibe-dark pt-2 border-t border-slate-100 text-base">
<span>Total</span><span>${total.toFixed(2)}</span>
<div className="bg-white rounded-2xl border border-slate-100 p-6 text-center">
<div className="text-5xl mb-3"></div>
<h2 className="text-xl font-bold text-vibe-dark mb-1">Payment Successful</h2>
<p className="text-slate-500 text-sm">Your order is being prepared.</p>
</div>
{breakdown && (
<div className="bg-white rounded-2xl border border-slate-100 p-4 space-y-2 text-sm">
<h3 className="font-semibold text-vibe-dark mb-2">How your money moves</h3>
<div className="flex justify-between text-slate-600">
<span>You paid</span>
<span className="font-medium">${Number(breakdown.customerPaid).toFixed(2)}</span>
</div>
<div className="flex justify-between text-vibe-green">
<span>Restaurant receives</span>
<span className="font-medium">${Number(breakdown.restaurantReceives).toFixed(2)}</span>
</div>
<div className="flex justify-between text-blue-600">
<span>Driver receives (delivery + tip)</span>
<span className="font-medium">${Number(breakdown.driverReceives).toFixed(2)}</span>
</div>
<div className="flex justify-between text-slate-400 text-xs pt-1 border-t border-slate-100">
<span>Platform fee</span>
<span>${Number(breakdown.platformFee).toFixed(2)}</span>
</div>
</div>
)}
<div className="bg-vibe-teal/5 border border-vibe-teal/20 rounded-xl p-3 text-xs text-slate-600 text-center">
No hidden fees. No commission taken from restaurants. 100% of tips go to drivers.
</div>
<div className="bg-white rounded-2xl border border-slate-100 p-6">
<form onSubmit={handlePay} className="space-y-4">
<PaymentElement />
{error && <p className="text-red-600 text-sm bg-red-50 rounded-xl px-3 py-2">{error}</p>}
<button
type="submit"
disabled={paying || !stripe}
className="w-full bg-vibe-teal text-white py-4 rounded-xl font-semibold hover:bg-teal-700 transition disabled:opacity-40"
>
{paying ? 'Processing...' : `Pay $${total.toFixed(2)}`}
</button>
</form>
</div>
<p className="text-center text-xs text-slate-400">
Payments processed securely by Stripe. The Vibe never stores card details.
</p>
<button
onClick={onContinue}
className="w-full bg-vibe-teal text-white py-4 rounded-xl font-semibold hover:bg-teal-700 transition"
>
Track My Order
</button>
</div>
</div>
)

View File

@ -67,7 +67,7 @@ export default function DriverDashboardPage() {
const handleGoOnline = async () => {
try {
await api.post('/payments/driver/daily-fee') // charge $20
// No upfront card charge — $20 fee is deducted from first deliveries' payouts
const data = await api.post('/drivers/me/session/start')
setSession(data.data.session)
setBreakEven(data.data.breakEven)
@ -109,7 +109,7 @@ export default function DriverDashboardPage() {
: 'bg-vibe-green hover:bg-green-600 text-white'
}`}
>
{isOnline ? 'Go Offline' : 'Go Online ($20)'}
{isOnline ? 'Go Offline' : 'Go Online'}
</button>
</div>
@ -194,9 +194,9 @@ export default function DriverDashboardPage() {
<div className="bg-white border border-slate-200 rounded-2xl p-6 text-center">
<div className="text-4xl mb-3">🏁</div>
<h3 className="font-semibold text-vibe-dark mb-2">Ready to start earning?</h3>
<p className="text-slate-500 text-sm mb-4">Pay $20 to unlock today. Break even after just 4 deliveries.</p>
<p className="text-slate-500 text-sm mb-4">No upfront charge. The $20 daily fee is deducted from your first deliveries.</p>
<div className="bg-slate-50 rounded-xl p-4 text-sm text-slate-600 space-y-1">
<div className="flex justify-between"><span>Daily fee</span><span className="font-medium">$20.00</span></div>
<div className="flex justify-between"><span>Daily access fee</span><span className="font-medium">$20 (deducted from payouts)</span></div>
<div className="flex justify-between"><span>Per delivery</span><span className="font-medium text-vibe-green">+$5.00</span></div>
<div className="flex justify-between"><span>Break-even at</span><span className="font-medium">4 deliveries</span></div>
<div className="flex justify-between"><span>Tips</span><span className="font-medium text-vibe-green">100% yours</span></div>
@ -256,9 +256,20 @@ export default function DriverDashboardPage() {
</div>
)}
{/* Earnings history link */}
<div className="text-center">
<a href="/driver/earnings" className="text-vibe-teal text-sm hover:underline">View full earnings history </a>
{/* Quick links */}
<div className="grid grid-cols-2 gap-3">
<a
href="/driver/orders"
className="flex items-center justify-center gap-2 bg-vibe-teal text-white rounded-2xl py-4 font-semibold text-sm hover:bg-teal-700 transition"
>
<span>📦</span> Find Deliveries
</a>
<a
href="/driver/earnings"
className="flex items-center justify-center gap-2 bg-white border border-slate-200 text-slate-700 rounded-2xl py-4 font-semibold text-sm hover:bg-slate-50 transition"
>
<span>💰</span> Earnings
</a>
</div>
</div>
</div>

View File

@ -2,10 +2,8 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import axios from 'axios'
import { format, parseISO } from 'date-fns'
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'
import { api } from '@/lib/api'
interface DayEarning {
session_date: string
@ -17,18 +15,46 @@ interface DayEarning {
fee_paid: boolean
}
interface Analytics {
summary: {
trips_7d: number; trips_30d: number; trips_total: number
net_7d: number; net_30d: number; net_total: number
tips_30d: number; tips_total: number
avg_daily_net: number; sessions_7d: number; sessions_30d: number
}
weekly: Array<{ dow: number; day_name: string; avg_trips: number; avg_net: number }>
hourly: Array<{ hour: number; deliveries: number; revenue: number }>
peakDay: { day_name: string; avg_trips: number; avg_net: number } | null
peakHour: { hour: number; deliveries: number } | null
}
const HOUR_LABELS = [
'12a','1a','2a','3a','4a','5a','6a','7a','8a','9a','10a','11a',
'12p','1p','2p','3p','4p','5p','6p','7p','8p','9p','10p','11p',
]
function StatCard({ label, value, sub, accent }: { label: string; value: string; sub?: string; accent?: string }) {
return (
<div className="bg-white rounded-2xl border border-slate-100 p-4 shadow-sm">
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">{label}</p>
<p className={`text-2xl font-bold ${accent || 'text-slate-900'}`}>{value}</p>
{sub && <p className="text-xs text-slate-400 mt-0.5">{sub}</p>}
</div>
)
}
function BreakEvenBar({ revenue, fee }: { revenue: number; fee: number }) {
const pct = Math.min(100, Math.round((revenue / fee) * 100))
const broke = revenue >= fee
return (
<div className="w-full">
<div className="flex justify-between text-xs mb-1">
<span className={broke ? 'text-green-600 font-medium' : 'text-gray-500'}>
{broke ? 'Profitable!' : `${Math.ceil((fee - revenue) / 5)} deliveries to break even`}
<span className={broke ? 'text-green-600 font-medium' : 'text-slate-400'}>
{broke ? 'Profitable' : `${Math.ceil((fee - revenue) / 5)} trips to break even`}
</span>
<span className="text-gray-500">{pct}%</span>
<span className="text-slate-400">{pct}%</span>
</div>
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${broke ? 'bg-green-500' : 'bg-amber-400'}`}
style={{ width: `${pct}%` }}
@ -38,37 +64,97 @@ function BreakEvenBar({ revenue, fee }: { revenue: number; fee: number }) {
)
}
function HourHeatmap({ hourly }: { hourly: Analytics['hourly'] }) {
const maxDeliveries = Math.max(...hourly.map(h => Number(h.deliveries)), 1)
return (
<div>
<p className="text-xs text-slate-400 mb-2">Deliveries by hour of day (last 90 days)</p>
<div className="flex gap-0.5 items-end h-12">
{Array.from({ length: 24 }, (_, i) => {
const h = hourly.find(x => Number(x.hour) === i)
const count = h ? Number(h.deliveries) : 0
const ratio = count / maxDeliveries
return (
<div key={i} className="flex-1 flex flex-col items-center" title={`${HOUR_LABELS[i]}: ${count} trips`}>
<div
className={`w-full rounded-sm ${
count === 0 ? 'bg-slate-100' :
ratio > 0.7 ? 'bg-vibe-teal' :
ratio > 0.4 ? 'bg-teal-300' : 'bg-teal-100'
}`}
style={{ height: `${Math.max(4, ratio * 100)}%` }}
/>
</div>
)
})}
</div>
<div className="flex justify-between text-xs text-slate-300 mt-1">
<span>12am</span><span>6am</span><span>12pm</span><span>6pm</span><span>11pm</span>
</div>
</div>
)
}
function DayChart({ weekly }: { weekly: Analytics['weekly'] }) {
const maxTrips = Math.max(...weekly.map(d => Number(d.avg_trips)), 1)
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
return (
<div>
<p className="text-xs text-slate-400 mb-2">Average trips by day of week</p>
<div className="flex gap-1 items-end h-16">
{days.map((day, i) => {
const d = weekly.find(w => Number(w.dow) === i)
const trips = d ? Number(d.avg_trips) : 0
const net = d ? Number(d.avg_net) : 0
const ratio = trips / maxTrips
return (
<div key={day} className="flex-1 flex flex-col items-center gap-0.5" title={`${day}: avg ${trips.toFixed(1)} trips · $${net.toFixed(2)} net`}>
<div
className={`w-full rounded-t ${ratio > 0.7 ? 'bg-vibe-teal' : ratio > 0.4 ? 'bg-teal-300' : 'bg-slate-100'}`}
style={{ height: `${Math.max(6, ratio * 100)}%` }}
/>
<span className="text-xs text-slate-400">{day.slice(0, 2)}</span>
</div>
)
})}
</div>
</div>
)
}
export default function DriverEarningsPage() {
const router = useRouter()
const [days, setDays] = useState(30)
const [earnings, setEarnings] = useState<DayEarning[]>([])
const [analytics, setAnalytics] = useState<Analytics | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
const token = localStorage.getItem('token')
const token = localStorage.getItem('vibe_token') || localStorage.getItem('token')
if (!token) { router.push('/login'); return }
fetchEarnings(token)
fetchData()
}, [days])
const fetchEarnings = async (token: string) => {
const fetchData = async () => {
setLoading(true)
try {
const { data } = await axios.get(`${API}/drivers/me/earnings?days=${days}`, {
headers: { Authorization: `Bearer ${token}` },
})
setEarnings(data)
const [earningsRes, analyticsRes] = await Promise.all([
api.get(`/drivers/me/earnings?days=${days}`),
api.get('/drivers/me/analytics'),
])
setEarnings(earningsRes.data)
setAnalytics(analyticsRes.data)
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to load earnings')
setError(err.response?.data?.message || 'Failed to load data')
} finally {
setLoading(false)
}
}
// Aggregate stats
const totals = earnings.reduce(
(acc, d) => ({
deliveries: acc.deliveries + (d.deliveries_count || 0),
deliveries: acc.deliveries + (Number(d.deliveries_count) || 0),
grossRevenue: acc.grossRevenue + Number(d.delivery_revenue || 0),
tips: acc.tips + Number(d.tips_earned || 0),
netEarnings: acc.netEarnings + Number(d.net_earnings || 0),
@ -77,121 +163,171 @@ export default function DriverEarningsPage() {
}),
{ deliveries: 0, grossRevenue: 0, tips: 0, netEarnings: 0, fees: 0, daysWorked: 0 },
)
const avgPerDay = totals.daysWorked > 0 ? totals.netEarnings / totals.daysWorked : 0
const s = analytics?.summary
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-gray-900 text-white">
<div className="max-w-4xl mx-auto px-4 py-6">
<button onClick={() => router.back()} className="text-gray-400 hover:text-white mb-4 flex items-center gap-1 text-sm">
Back
<div className="bg-slate-900 text-white">
<div className="max-w-2xl mx-auto px-4 py-6">
<button
onClick={() => router.back()}
className="text-slate-400 hover:text-white mb-4 flex items-center gap-1 text-sm"
>
Back to Dashboard
</button>
<h1 className="text-2xl font-bold">Earnings History</h1>
<p className="text-gray-400 text-sm mt-1">Your delivery earnings breakdown</p>
<h1 className="text-2xl font-bold">Virtual Account</h1>
<p className="text-slate-400 text-sm mt-1">Earnings, analytics, and peak time insights</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 py-6 space-y-6">
{/* Period selector */}
<div className="flex gap-2">
{[7, 14, 30, 90].map(d => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
days === d
? 'bg-teal-600 text-white'
: 'bg-white text-gray-700 border hover:border-teal-400'
}`}
>
{d === 7 ? '1 week' : d === 14 ? '2 weeks' : d === 30 ? '30 days' : '3 months'}
</button>
))}
</div>
<div className="max-w-2xl mx-auto px-4 py-6 space-y-5">
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl p-4 shadow-sm border">
<p className="text-gray-500 text-sm">Net Earnings</p>
<p className="text-2xl font-bold text-green-600 mt-1">${totals.netEarnings.toFixed(2)}</p>
<p className="text-gray-400 text-xs mt-1">after $20/day fees</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm border">
<p className="text-gray-500 text-sm">Total Deliveries</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{totals.deliveries}</p>
<p className="text-gray-400 text-xs mt-1">{totals.daysWorked} days worked</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm border">
<p className="text-gray-500 text-sm">Tips Earned</p>
<p className="text-2xl font-bold text-amber-600 mt-1">${totals.tips.toFixed(2)}</p>
<p className="text-gray-400 text-xs mt-1">100% yours</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm border">
<p className="text-gray-500 text-sm">Avg / Day</p>
<p className="text-2xl font-bold text-gray-900 mt-1">${avgPerDay.toFixed(2)}</p>
<p className="text-gray-400 text-xs mt-1">on days worked</p>
</div>
</div>
{/* Break-even insight */}
{totals.daysWorked > 0 && (
<div className="bg-teal-50 border border-teal-200 rounded-xl p-4">
<p className="text-teal-800 font-medium text-sm">
Your average: {(totals.deliveries / totals.daysWorked).toFixed(1)} deliveries/day
break-even at 4/day ($20 ÷ $5 = 4 deliveries)
</p>
<p className="text-teal-700 text-xs mt-1">
You're earning {Math.round(totals.deliveries / totals.daysWorked) > 4 ? 'above' : 'near'} break-even on average.
Every delivery after #4 is pure profit.
</p>
{/* All-time summary cards */}
{s && (
<div className="grid grid-cols-2 gap-3">
<StatCard
label="Total Earned (all time)"
value={`$${Number(s.net_total || 0).toFixed(2)}`}
sub="after daily fees"
accent="text-green-600"
/>
<StatCard
label="Total Deliveries"
value={String(Number(s.trips_total || 0))}
sub="all time"
/>
<StatCard
label="Tips Earned (all time)"
value={`$${Number(s.tips_total || 0).toFixed(2)}`}
sub="100% yours"
accent="text-amber-500"
/>
<StatCard
label="Avg Daily Net"
value={`$${Number(s.avg_daily_net || 0).toFixed(2)}`}
sub="on days worked"
/>
</div>
)}
{/* Day-by-day table */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="px-4 py-3 border-b">
<h2 className="font-semibold text-gray-900">Day-by-Day Breakdown</h2>
{/* Period breakdown */}
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<div className="flex gap-2 mb-4">
{[7, 14, 30, 90].map(d => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-3 py-1.5 rounded-xl text-sm font-medium transition ${
days === d ? 'bg-vibe-teal text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{d === 7 ? '7d' : d === 14 ? '14d' : d === 30 ? '30d' : '90d'}
</button>
))}
</div>
<div className="grid grid-cols-4 gap-3 text-center">
<div>
<p className="text-xl font-bold text-green-600">${totals.netEarnings.toFixed(0)}</p>
<p className="text-xs text-slate-400">Net</p>
</div>
<div>
<p className="text-xl font-bold text-slate-800">{totals.deliveries}</p>
<p className="text-xs text-slate-400">Trips</p>
</div>
<div>
<p className="text-xl font-bold text-amber-500">${totals.tips.toFixed(0)}</p>
<p className="text-xs text-slate-400">Tips</p>
</div>
<div>
<p className="text-xl font-bold text-slate-800">${avgPerDay.toFixed(0)}</p>
<p className="text-xs text-slate-400">Avg/day</p>
</div>
</div>
</div>
{/* Insights banner */}
{analytics && (analytics.peakDay || analytics.peakHour) && (
<div className="bg-vibe-teal/5 border border-vibe-teal/20 rounded-2xl p-4 space-y-1.5">
<p className="font-semibold text-vibe-dark text-sm">Peak Insights</p>
{analytics.peakDay && (
<p className="text-slate-600 text-sm">
Best day: <strong>{analytics.peakDay.day_name}</strong> avg{' '}
{Number(analytics.peakDay.avg_trips).toFixed(1)} trips ·{' '}
${Number(analytics.peakDay.avg_net).toFixed(2)} net
</p>
)}
{analytics.peakHour && (
<p className="text-slate-600 text-sm">
Busiest hour: <strong>{HOUR_LABELS[Number(analytics.peakHour.hour)]}</strong> {' '}
{analytics.peakHour.deliveries} deliveries in last 90 days
</p>
)}
<p className="text-slate-400 text-xs">Schedule your shifts around these windows to maximise earnings.</p>
</div>
)}
{/* Day-of-week chart */}
{analytics?.weekly && analytics.weekly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<h3 className="font-semibold text-slate-800 mb-3">Busiest Days</h3>
<DayChart weekly={analytics.weekly} />
</div>
)}
{/* Hour heatmap */}
{analytics?.hourly && analytics.hourly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<h3 className="font-semibold text-slate-800 mb-3">Delivery Heatmap</h3>
<HourHeatmap hourly={analytics.hourly} />
</div>
)}
{/* Daily breakdown table */}
<div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-50">
<h3 className="font-semibold text-slate-800">Day-by-Day</h3>
</div>
{loading ? (
<div className="p-8 text-center text-gray-400">Loading earnings...</div>
<div className="p-8 text-center text-slate-400">Loading...</div>
) : error ? (
<div className="p-8 text-center text-red-500">{error}</div>
) : earnings.length === 0 ? (
<div className="p-8 text-center">
<p className="text-gray-500">No earnings data for this period</p>
<p className="text-gray-400 text-sm mt-1">Start delivering to see your earnings here</p>
<p className="text-slate-500">No earnings for this period</p>
<p className="text-slate-400 text-sm mt-1">Go online to start earning</p>
</div>
) : (
<div className="divide-y">
<div className="divide-y divide-slate-50">
{earnings.map((day) => {
const net = Number(day.net_earnings || 0)
const fee = Number(day.daily_fee || 20)
const revenue = Number(day.delivery_revenue || 0)
const tips = Number(day.tips_earned || 0)
const isProfitable = net > 0
return (
<div key={day.session_date} className="px-4 py-4">
<div className="flex items-start justify-between mb-2">
<div>
<p className="font-medium text-gray-900">
<p className="font-medium text-slate-800">
{format(parseISO(day.session_date), 'EEE, MMM d')}
</p>
<p className="text-sm text-gray-500">
<p className="text-sm text-slate-400">
{day.deliveries_count} deliveries
{tips > 0 && ` · $${tips.toFixed(2)} tips`}
{!day.fee_paid && (
<span className="ml-1 text-amber-500">· fee recovering</span>
)}
</p>
</div>
<div className="text-right">
<p className={`font-bold text-lg ${isProfitable ? 'text-green-600' : 'text-red-500'}`}>
{isProfitable ? '+' : ''}${net.toFixed(2)}
<p className={`font-bold text-lg ${net >= 0 ? 'text-green-600' : 'text-slate-400'}`}>
{net >= 0 ? '+' : ''}${net.toFixed(2)}
</p>
<p className="text-xs text-gray-400">
$${revenue.toFixed(2)} earned $${fee.toFixed(2)} fee
{!day.fee_paid && ' (unpaid)'}
<p className="text-xs text-slate-400">
${revenue.toFixed(2)} ${fee.toFixed(2)}
</p>
</div>
</div>
@ -203,26 +339,21 @@ export default function DriverEarningsPage() {
)}
</div>
{/* How earnings work */}
<div className="bg-gray-900 text-white rounded-xl p-5">
{/* Model explanation */}
<div className="bg-slate-900 text-white rounded-2xl p-5">
<h3 className="font-semibold mb-3">How Your Earnings Work</h3>
<div className="space-y-2 text-sm text-gray-300">
<div className="flex justify-between">
<span>Daily access fee</span><span className="font-medium text-white">$20.00</span>
</div>
<div className="flex justify-between">
<span>Per delivery</span><span className="font-medium text-white">$5.00 flat</span>
</div>
<div className="flex justify-between">
<span>Tips</span><span className="font-medium text-white">100% yours</span>
</div>
<div className="flex justify-between">
<span>Commission taken</span><span className="font-medium text-green-400">$0.00</span>
</div>
<div className="border-t border-gray-700 pt-2 flex justify-between font-semibold text-white">
<span>Break-even at</span><span>4 deliveries</span>
<div className="space-y-2 text-sm text-slate-300">
<div className="flex justify-between"><span>Daily access fee</span><span className="text-white">$20 (from payouts)</span></div>
<div className="flex justify-between"><span>Per delivery</span><span className="text-white">$5.00 flat</span></div>
<div className="flex justify-between"><span>Tips</span><span className="text-white">100% yours</span></div>
<div className="flex justify-between"><span>Commission</span><span className="text-green-400">$0.00</span></div>
<div className="border-t border-slate-700 pt-2 flex justify-between font-semibold">
<span>Break-even</span><span>4 deliveries</span>
</div>
</div>
<p className="text-xs text-slate-500 mt-3">
No card required. The $20 daily fee is automatically recovered from your first 4 delivery payouts.
</p>
</div>
</div>
</div>

View File

@ -40,6 +40,28 @@ export default function DriverOrdersPage() {
const [isOnline, setIsOnline] = useState(false)
useEffect(() => {
// Load existing available orders on mount
const loadAvailable = async () => {
try {
const { data } = await api.get('/orders/available')
if (Array.isArray(data) && data.length > 0) {
setAvailable(data.map((o: any) => ({
id: o.id,
order_number: o.order_number,
restaurant: { name: o.restaurant_name, address: o.restaurant_address, lng: Number(o.restaurant_lng), lat: Number(o.restaurant_lat) },
delivery_address: o.delivery_address,
subtotal: Number(o.subtotal),
tip_amount: Number(o.tip_amount),
delivery_fee: Number(o.delivery_fee),
distance_km: 0,
duration_minutes: 20,
item_count: Number(o.item_count),
})))
}
} catch {}
}
loadAvailable()
// GPS watch
const watchId = navigator.geolocation?.watchPosition((pos) => {
setDriverLocation([pos.coords.longitude, pos.coords.latitude])

View File

@ -0,0 +1,301 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/api'
interface Restaurant {
id: string
name: string
slug: string
cuisine_type: string[]
logo_url?: string
rating: number
total_reviews: number
avg_prep_time_minutes: number
min_order_amount: number
is_open: boolean
distance_km: number
zone_name: string
}
const CATEGORIES = [
{ label: 'All', value: '', emoji: '🍽️' },
{ label: 'Pizza', value: 'Pizza', emoji: '🍕' },
{ label: 'Burgers', value: 'Burgers', emoji: '🍔' },
{ label: 'Japanese', value: 'Japanese', emoji: '🍣' },
{ label: 'Italian', value: 'Italian', emoji: '🍝' },
{ label: 'Indian', value: 'Indian', emoji: '🍛' },
{ label: 'Mexican', value: 'Mexican', emoji: '🌮' },
{ label: 'Vegan', value: 'Vegan', emoji: '🥗' },
{ label: 'Chinese', value: 'Chinese', emoji: '🍜' },
{ label: 'Breakfast', value: 'Breakfast', emoji: '🥞' },
]
const GRADIENTS = [
'from-orange-400 to-rose-500',
'from-teal-400 to-cyan-600',
'from-violet-500 to-purple-700',
'from-amber-400 to-orange-600',
'from-emerald-400 to-teal-600',
'from-sky-400 to-blue-600',
]
function RestaurantCard({ r, index }: { r: Restaurant; index: number }) {
const gradient = GRADIENTS[index % GRADIENTS.length]
return (
<Link href={`/restaurants/${r.slug}`} className="group block">
<div className="rounded-2xl overflow-hidden bg-white border border-slate-100 hover:shadow-md transition-all duration-200">
<div className="relative h-40 overflow-hidden">
{r.logo_url ? (
<img src={r.logo_url} alt={r.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
) : (
<div className={`w-full h-full bg-gradient-to-br ${gradient} flex items-center justify-center`}>
<span className="text-5xl opacity-80">🍽</span>
</div>
)}
{!r.is_open && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<span className="bg-black/70 text-white text-xs font-semibold px-3 py-1 rounded-full">Closed</span>
</div>
)}
<div className="absolute top-2 right-2">
<span className="bg-white/90 backdrop-blur-sm text-teal-700 text-xs font-bold px-2 py-1 rounded-full">
$5 delivery
</span>
</div>
</div>
<div className="p-3">
<div className="flex items-start justify-between gap-1">
<h3 className="font-bold text-slate-900 text-sm leading-tight group-hover:text-teal-600 transition-colors line-clamp-1">
{r.name}
</h3>
<div className="flex items-center gap-0.5 shrink-0">
<span className="text-amber-400 text-xs"></span>
<span className="text-xs font-semibold text-slate-700">{Number(r.rating).toFixed(1)}</span>
</div>
</div>
<p className="text-xs text-slate-400 mt-0.5 line-clamp-1">{r.cuisine_type?.join(' · ')}</p>
<div className="flex items-center gap-1.5 mt-2 text-xs text-slate-400">
<span>{r.avg_prep_time_minutes}{r.avg_prep_time_minutes + 10} min</span>
{r.distance_km != null && <><span>·</span><span>{Number(r.distance_km).toFixed(1)} km</span></>}
</div>
</div>
</div>
</Link>
)
}
function SkeletonCard() {
return (
<div className="rounded-2xl overflow-hidden bg-white border border-slate-100 animate-pulse">
<div className="h-40 bg-slate-200" />
<div className="p-3 space-y-2">
<div className="h-3.5 bg-slate-200 rounded w-3/4" />
<div className="h-3 bg-slate-100 rounded w-1/2" />
<div className="h-3 bg-slate-100 rounded w-1/3 mt-2" />
</div>
</div>
)
}
export default function HomePage() {
const searchParams = useSearchParams()
const router = useRouter()
const [address, setAddress] = useState(searchParams.get('address') || '')
const [cuisine, setCuisine] = useState('')
const [search, setSearch] = useState('')
const [restaurants, setRestaurants] = useState<Restaurant[]>([])
const [loading, setLoading] = useState(true)
const [coords, setCoords] = useState<[number, number]>([-79.4196, 43.6389])
// Resolve address to coords on mount
useEffect(() => {
const addr = searchParams.get('address')
const lng = searchParams.get('lng')
const lat = searchParams.get('lat')
if (lng && lat) {
setCoords([parseFloat(lng), parseFloat(lat)])
} else if (addr) {
// Geocode via Nominatim
fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(addr + ', Toronto, ON, Canada')}`, {
headers: { 'User-Agent': 'TheVibe/1.0' },
})
.then(r => r.json())
.then(data => {
if (data[0]) setCoords([parseFloat(data[0].lon), parseFloat(data[0].lat)])
})
.catch(() => {})
} else {
navigator.geolocation?.getCurrentPosition(
pos => setCoords([pos.coords.longitude, pos.coords.latitude]),
() => {},
)
}
}, [])
useEffect(() => {
const load = async () => {
setLoading(true)
try {
const { data } = await api.get('/restaurants', {
params: { lng: coords[0], lat: coords[1], cuisine: cuisine || undefined },
})
setRestaurants(data)
} catch {}
setLoading(false)
}
load()
}, [coords, cuisine])
const filtered = search
? restaurants.filter(r =>
r.name.toLowerCase().includes(search.toLowerCase()) ||
r.cuisine_type?.some(c => c.toLowerCase().includes(search.toLowerCase()))
)
: restaurants
const open = filtered.filter(r => r.is_open)
const closed = filtered.filter(r => !r.is_open)
const handleAddressSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (address.trim()) {
router.push(`/home?address=${encodeURIComponent(address)}`)
}
}
return (
<div className="min-h-screen bg-[#f6f6f6]">
{/* Sticky header */}
<header className="bg-white border-b border-slate-100 sticky top-0 z-40 shadow-sm">
<div className="max-w-5xl mx-auto px-4 py-3">
<div className="flex items-center gap-3">
{/* Logo */}
<Link href="/" className="text-lg font-black text-teal-600 shrink-0 hidden sm:block">
TheVibe
</Link>
{/* Address bar */}
<form onSubmit={handleAddressSubmit} className="flex-1 flex items-center gap-2 max-w-md">
<div className="flex-1 flex items-center gap-2 bg-slate-100 rounded-full px-4 py-2.5">
<span className="text-teal-600 text-sm shrink-0">📍</span>
<input
value={address}
onChange={e => setAddress(e.target.value)}
placeholder="Enter delivery address"
className="flex-1 bg-transparent text-sm text-slate-700 placeholder-slate-400 focus:outline-none min-w-0"
/>
</div>
</form>
{/* Search */}
<div className="flex-1 relative max-w-xs">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm pointer-events-none">🔍</span>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search restaurants..."
className="w-full bg-slate-100 rounded-full pl-9 pr-4 py-2.5 text-sm text-slate-700 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:bg-white transition"
/>
</div>
<Link href="/login" className="shrink-0 text-sm font-medium text-slate-600 hover:text-teal-600 transition hidden sm:block">
Sign in
</Link>
</div>
</div>
{/* Category chips */}
<div className="flex gap-1.5 overflow-x-auto px-4 pb-3 pt-1 [-ms-overflow-style:none] [scrollbar-width:none]">
{CATEGORIES.map(c => (
<button
key={c.value}
onClick={() => setCuisine(c.value === cuisine ? '' : c.value)}
className={`shrink-0 flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition ${
cuisine === c.value
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
<span>{c.emoji}</span>
<span>{c.label}</span>
</button>
))}
</div>
</header>
<main className="max-w-5xl mx-auto px-4 py-6">
{/* Delivery address banner */}
{address && (
<div className="flex items-center gap-2 mb-5 bg-teal-50 border border-teal-100 rounded-2xl px-4 py-3">
<span className="text-teal-600 shrink-0">📍</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-teal-600 font-medium">Delivering to</p>
<p className="text-sm text-slate-700 font-semibold truncate">{address}</p>
</div>
<button
onClick={() => router.push('/')}
className="text-xs text-teal-600 hover:text-teal-800 font-medium shrink-0"
>
Change
</button>
</div>
)}
{/* Trust bar */}
<div className="flex items-center gap-2 mb-5 text-xs text-slate-500">
<span className="w-2 h-2 bg-green-500 rounded-full shrink-0" />
<span>$5 flat delivery · No hidden fees · Menu prices match in-restaurant</span>
</div>
{/* Restaurant grid */}
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(8)].map((_, i) => <SkeletonCard key={i} />)}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-24">
<p className="text-5xl mb-4">🍽</p>
<h3 className="font-bold text-slate-800 text-xl mb-2">
{search ? 'No results' : 'No restaurants nearby'}
</h3>
<p className="text-slate-500 text-sm max-w-xs mx-auto">
{search ? 'Try a different search.' : "We're expanding across the GTA. Check back soon!"}
</p>
{search && (
<button onClick={() => setSearch('')} className="mt-4 text-teal-600 text-sm hover:underline">
Clear search
</button>
)}
</div>
) : (
<>
{open.length > 0 && (
<section className="mb-8">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">
Open now · {open.length} place{open.length !== 1 ? 's' : ''}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{open.map((r, i) => <RestaurantCard key={r.id} r={r} index={i} />)}
</div>
</section>
)}
{closed.length > 0 && (
<section>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Closed now</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{closed.map((r, i) => <RestaurantCard key={r.id} r={r} index={open.length + i} />)}
</div>
</section>
)}
</>
)}
</main>
</div>
)
}

View File

@ -52,15 +52,15 @@ export default function LoginPage() {
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">Email</label>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">Email or Phone</label>
<input
type="email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
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 focus:border-transparent"
placeholder="you@example.com"
placeholder="you@example.com or 416-555-0100"
/>
</div>

View File

@ -1,56 +1,105 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { MapView } from '@/components/map/MapView'
import { useOrderTracking } from '@/hooks/useDriverTracking'
import { getRoute, routeToGeoJSON } from '@/lib/osrm'
import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { io, Socket } from 'socket.io-client'
import { api } from '@/lib/api'
// ============================================================
// CUSTOMER ORDER TRACKING PAGE
// Real-time driver location on MapLibre map
// Real-time status updates via Socket.IO
// Polls order status every 10s as fallback
// ============================================================
const STATUS_STEPS = ['confirmed', 'preparing', 'ready_for_pickup', 'driver_assigned', 'picked_up', 'delivered']
const STATUS_LABELS: Record<string, string> = {
pending: 'Order Received',
confirmed: 'Order Confirmed',
preparing: 'Being Prepared',
ready_for_pickup: 'Ready for Pickup',
driver_assigned: 'Driver On the Way',
picked_up: 'Driver Picked Up',
picked_up: 'Out for Delivery',
delivered: 'Delivered!',
cancelled: 'Cancelled',
}
const STATUS_ICONS: Record<string, string> = {
pending: '🕐',
confirmed: '✅',
preparing: '👨‍🍳',
ready_for_pickup: '📦',
driver_assigned: '🚴',
picked_up: '🛣️',
delivered: '🎉',
cancelled: '❌',
}
const STATUS_DESCRIPTIONS: Record<string, string> = {
pending: 'Your order has been received and is awaiting restaurant confirmation.',
confirmed: 'The restaurant has accepted your order.',
preparing: 'The restaurant is preparing your food.',
ready_for_pickup: 'Your order is ready — a driver will pick it up shortly.',
driver_assigned: 'A driver has accepted your delivery.',
picked_up: 'Your food is on its way!',
delivered: 'Enjoy your meal!',
cancelled: 'Your order was cancelled.',
}
export default function OrderTrackingPage() {
const { id: orderId } = useParams<{ id: string }>()
const router = useRouter()
const [order, setOrder] = useState<any>(null)
const [driverLocation, setDriverLocation] = useState<[number, number] | undefined>()
const [routeGeoJSON, setRouteGeoJSON] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const socketRef = useRef<Socket | null>(null)
const pollRef = useRef<NodeJS.Timeout>()
// Load order
useEffect(() => {
api.get(`/orders/${orderId}`).then((r) => {
setOrder(r.data)
setLoading(false)
})
}, [orderId])
// Real-time driver tracking
useOrderTracking(orderId, async (data) => {
const loc: [number, number] = [data.lng, data.lat]
setDriverLocation(loc)
// Fetch updated route from driver to delivery address
if (order?.delivery_location) {
const deliveryLng = order.delivery_location.coordinates[0]
const deliveryLat = order.delivery_location.coordinates[1]
const route = await getRoute(loc, [deliveryLng, deliveryLat])
if (route) setRouteGeoJSON(routeToGeoJSON(route))
const fetchOrder = async () => {
try {
const { data } = await api.get(`/orders/${orderId}`)
setOrder(data)
setError('')
} catch (err: any) {
if (err.response?.status === 404) setError('Order not found')
}
})
setLoading(false)
}
useEffect(() => {
const token = localStorage.getItem('vibe_token')
if (!token) { router.replace('/login?redirect=/orders'); return }
fetchOrder()
// Socket.IO for real-time status updates
const socket = io(`${process.env.NEXT_PUBLIC_WS_URL}/tracking`, {
auth: { token },
transports: ['websocket'],
})
socketRef.current = socket
socket.on('connect', () => {
socket.emit('join:order', orderId)
})
socket.on('order:status', ({ status, ...data }: any) => {
setOrder((prev: any) => prev ? { ...prev, status, ...data } : prev)
})
socket.on('driver:accepted', (info: any) => {
setOrder((prev: any) => prev ? { ...prev, driver: info, status: 'driver_assigned' } : prev)
})
// Fallback polling every 10s
pollRef.current = setInterval(fetchOrder, 10000)
return () => {
socket.disconnect()
if (pollRef.current) clearInterval(pollRef.current)
}
}, [orderId])
if (loading) return (
<div className="min-h-screen flex items-center justify-center">
@ -61,122 +110,140 @@ export default function OrderTrackingPage() {
</div>
)
if (!order) return <div className="min-h-screen flex items-center justify-center text-slate-400">Order not found</div>
if (error) return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4">
<p className="text-slate-500">{error}</p>
<Link href="/orders" className="text-vibe-teal hover:underline">View all orders </Link>
</div>
)
if (!order) return null
const currentStep = STATUS_STEPS.indexOf(order.status)
const deliveryCoords: [number, number] | undefined = order.delivery_location
? [order.delivery_location.coordinates[0], order.delivery_location.coordinates[1]]
: undefined
const isActive = !['delivered', 'cancelled'].includes(order.status)
return (
<div className="min-h-screen bg-white">
{/* Map - takes up most of the screen */}
<div className="h-[55vh] relative">
<MapView
center={driverLocation || deliveryCoords || [-79.3832, 43.6532]}
zoom={14}
driverLocation={driverLocation}
deliveryLocation={deliveryCoords}
routeGeoJSON={routeGeoJSON}
className="rounded-none"
/>
{/* Status overlay on map */}
<div className="absolute bottom-4 left-4 right-4 bg-white/95 backdrop-blur-sm rounded-2xl p-4 shadow-lg">
{order.status === 'delivered' ? (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-vibe-green rounded-full flex items-center justify-center text-xl"></div>
<div>
<p className="font-bold text-vibe-dark">Delivered!</p>
<p className="text-slate-500 text-sm">Enjoy your meal. Rate your experience below.</p>
</div>
</div>
) : order.driver ? (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-vibe-teal rounded-full flex items-center justify-center text-xl">🚴</div>
<div>
<p className="font-bold text-vibe-dark">{order.driver.firstName} is on the way</p>
<p className="text-slate-500 text-sm">
{STATUS_LABELS[order.status] || order.status}
{order.estimated_delivery_at && (
<> · ETA {new Date(order.estimated_delivery_at).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' })}</>
)}
</p>
</div>
</div>
) : (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center text-xl">🍳</div>
<div>
<p className="font-bold text-vibe-dark">{STATUS_LABELS[order.status] || 'Processing'}</p>
<p className="text-slate-500 text-sm">Prep time: ~{order.restaurant?.avg_prep_time || 20} min</p>
</div>
</div>
)}
<div className="min-h-screen bg-vibe-cream">
{/* Header */}
<div className="bg-white border-b border-slate-100 px-6 py-4">
<div className="max-w-lg mx-auto flex items-center gap-3">
<Link href="/orders" className="text-slate-400 hover:text-vibe-dark text-lg"></Link>
<div>
<h1 className="font-bold text-vibe-dark">Order #{order.order_number}</h1>
<p className="text-slate-500 text-sm">{order.restaurant?.name}</p>
</div>
</div>
</div>
{/* Order details panel */}
<div className="px-6 py-6 space-y-6">
{/* Progress steps */}
<div className="flex items-center justify-between">
{STATUS_STEPS.slice(0, 5).map((step, i) => (
<div key={step} className="flex items-center">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all ${
i < currentStep ? 'bg-vibe-teal text-white' :
i === currentStep ? 'bg-vibe-teal text-white ring-4 ring-teal-100' :
'bg-slate-100 text-slate-400'
}`}>
{i < currentStep ? '✓' : i + 1}
</div>
{i < 4 && (
<div className={`flex-1 h-0.5 mx-1 ${i < currentStep ? 'bg-vibe-teal' : 'bg-slate-100'}`} style={{ width: 20 }} />
<div className="max-w-lg mx-auto px-4 py-6 space-y-4">
{/* Status card */}
<div className={`rounded-2xl p-6 text-center ${
order.status === 'delivered' ? 'bg-green-50 border border-green-200' :
order.status === 'cancelled' ? 'bg-red-50 border border-red-200' :
'bg-white border border-slate-100'
}`}>
<div className="text-5xl mb-3">{STATUS_ICONS[order.status] || '📦'}</div>
<h2 className="text-xl font-bold text-vibe-dark mb-1">{STATUS_LABELS[order.status] || order.status}</h2>
<p className="text-slate-500 text-sm">{STATUS_DESCRIPTIONS[order.status]}</p>
{order.driver && order.status !== 'delivered' && (
<div className="mt-4 bg-vibe-teal/5 border border-vibe-teal/20 rounded-xl px-4 py-3 text-sm">
<span className="text-slate-600">Driver: </span>
<span className="font-semibold text-vibe-dark">{order.driver.firstName}</span>
{order.driver.vehicleType && (
<span className="text-slate-400 ml-1">({order.driver.vehicleType})</span>
)}
</div>
))}
)}
{order.estimated_delivery_at && isActive && (
<p className="mt-3 text-sm text-slate-500">
Estimated delivery: {new Date(order.estimated_delivery_at).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
{/* Order summary */}
<div className="bg-slate-50 rounded-2xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-vibe-dark">Order #{order.order_number}</h3>
<span className="text-slate-500 text-sm">{order.restaurant?.name}</span>
{/* Progress bar */}
{order.status !== 'cancelled' && (
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<div className="flex items-center justify-between mb-3">
{STATUS_STEPS.slice(0, 5).map((step, i) => (
<div key={step} className="flex items-center flex-1">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 transition-all ${
i < currentStep ? 'bg-vibe-teal text-white' :
i === currentStep ? 'bg-vibe-teal text-white ring-4 ring-teal-100' :
'bg-slate-100 text-slate-400'
}`}>
{i < currentStep ? '✓' : i + 1}
</div>
{i < 4 && (
<div className={`flex-1 h-0.5 mx-1 transition-all ${i < currentStep ? 'bg-vibe-teal' : 'bg-slate-100'}`} />
)}
</div>
))}
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>Confirmed</span>
<span>Preparing</span>
<span>Ready</span>
<span>Picked up</span>
<span>Delivered</span>
</div>
</div>
)}
{/* Order items */}
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<h3 className="font-semibold text-vibe-dark mb-3">Your Order</h3>
<div className="space-y-2">
{order.items?.map((item: any) => (
<div key={item.id} className="flex justify-between text-sm">
<span className="text-slate-600">{item.quantity}× {item.name}</span>
<span className="text-slate-700 font-medium">${Number(item.subtotal || item.price * item.quantity).toFixed(2)}</span>
</div>
))}
</div>
{order.items?.map((item: any) => (
<div key={item.id} className="flex justify-between text-sm py-1">
<span className="text-slate-600">{item.quantity}× {item.name}</span>
<span className="text-slate-700">${Number(item.subtotal).toFixed(2)}</span>
</div>
))}
<div className="border-t border-slate-200 mt-3 pt-3 space-y-1 text-sm">
<div className="border-t border-slate-100 mt-3 pt-3 space-y-1 text-sm">
<div className="flex justify-between text-slate-600">
<span>Subtotal</span><span>${Number(order.subtotal).toFixed(2)}</span>
</div>
<div className="flex justify-between text-slate-600">
<span>Delivery fee</span><span>${Number(order.delivery_fee).toFixed(2)}</span>
</div>
{order.tip_amount > 0 && (
{Number(order.tip_amount) > 0 && (
<div className="flex justify-between text-vibe-green">
<span>Tip (goes directly to driver)</span>
<span>Tip (100% to driver)</span>
<span>${Number(order.tip_amount).toFixed(2)}</span>
</div>
)}
<div className="flex justify-between text-slate-600">
<span>Processing fee (Stripe)</span><span>${Number(order.cc_processing_fee).toFixed(2)}</span>
</div>
<div className="flex justify-between font-bold text-vibe-dark pt-1 border-t border-slate-200">
<span>Total</span><span>${Number(order.total_customer_pays).toFixed(2)}</span>
<div className="flex justify-between font-bold text-vibe-dark pt-1 border-t border-slate-100">
<span>Total</span>
<span>${Number(order.total_customer_pays).toFixed(2)}</span>
</div>
</div>
</div>
{/* Transparency note */}
<div className="bg-vibe-green/5 border border-vibe-green/20 rounded-xl p-4 text-sm">
<p className="text-vibe-dark font-medium mb-1">No hidden fees ever.</p>
<p className="text-slate-600">This total includes a flat $5 delivery fee. No service fees, no markup on menu prices.</p>
{/* Delivery address */}
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">Delivering to</p>
<p className="text-sm font-medium text-vibe-dark">{order.delivery_address}</p>
</div>
{/* Transparency note */}
<div className="bg-vibe-green/5 border border-vibe-green/20 rounded-xl p-3 text-xs text-slate-500 text-center">
No hidden fees. Restaurant keeps 100% of food revenue (minus $0.10 platform fee). 100% of your tip goes to the driver.
</div>
{order.status === 'delivered' && (
<Link
href="/restaurants"
className="block w-full text-center bg-vibe-teal text-white py-4 rounded-xl font-semibold hover:bg-teal-700 transition"
>
Order Again
</Link>
)}
</div>
</div>
)

View File

@ -0,0 +1,167 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { api } from '@/lib/api'
interface Order {
id: string
order_number: number
status: string
total_customer_pays: number
created_at: string
delivered_at: string | null
restaurant_name: string
logo_url: string | null
}
const STATUS_LABELS: Record<string, string> = {
pending: 'Placing order',
confirmed: 'Confirmed',
preparing: 'Being prepared',
ready_for_pickup: 'Ready for pickup',
driver_assigned: 'Driver on the way',
picked_up: 'Out for delivery',
delivered: 'Delivered',
cancelled: 'Cancelled',
}
const STATUS_STYLES: Record<string, string> = {
pending: 'text-amber-600',
confirmed: 'text-blue-600',
preparing: 'text-purple-600',
ready_for_pickup: 'text-orange-600',
driver_assigned: 'text-teal-600',
picked_up: 'text-teal-600',
delivered: 'text-green-600',
cancelled: 'text-red-500',
}
const ACTIVE_STATUSES = ['pending', 'confirmed', 'preparing', 'ready_for_pickup', 'driver_assigned', 'picked_up']
export default function OrdersPage() {
const router = useRouter()
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('vibe_token')
if (!token) { router.replace('/login?redirect=/orders'); return }
api.get('/orders/mine').then((r) => {
setOrders(r.data || [])
}).catch(() => {}).finally(() => setLoading(false))
}, [])
const activeOrders = orders.filter((o) => ACTIVE_STATUSES.includes(o.status))
const pastOrders = orders.filter((o) => !ACTIVE_STATUSES.includes(o.status))
return (
<div className="min-h-screen bg-vibe-cream">
{/* Header */}
<div className="bg-white border-b border-slate-100 px-6 py-4">
<div className="max-w-lg mx-auto flex items-center gap-3">
<button onClick={() => router.back()} className="text-slate-400 hover:text-vibe-dark text-lg"></button>
<h1 className="font-bold text-vibe-dark">My Orders</h1>
</div>
</div>
<div className="max-w-lg mx-auto px-4 py-6 space-y-6">
{loading ? (
<div className="space-y-3">
{[...Array(4)].map((_, i) => <div key={i} className="bg-white rounded-2xl h-20 animate-pulse" />)}
</div>
) : orders.length === 0 ? (
<div className="text-center py-20">
<div className="text-5xl mb-4">🍔</div>
<h3 className="font-bold text-vibe-dark text-lg mb-2">No orders yet</h3>
<p className="text-slate-500 text-sm mb-6">Your order history will appear here.</p>
<Link href="/restaurants" className="bg-vibe-teal text-white px-6 py-3 rounded-xl font-semibold hover:bg-teal-700 transition">
Browse Restaurants
</Link>
</div>
) : (
<>
{activeOrders.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-3">In Progress</h2>
<div className="space-y-3">
{activeOrders.map((order) => (
<OrderCard key={order.id} order={order} active />
))}
</div>
</section>
)}
{pastOrders.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-3">Past Orders</h2>
<div className="space-y-3">
{pastOrders.map((order) => (
<OrderCard key={order.id} order={order} active={false} />
))}
</div>
</section>
)}
</>
)}
</div>
</div>
)
}
function OrderCard({ order, active }: { order: Order; active: boolean }) {
const date = new Date(order.created_at).toLocaleDateString('en-CA', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})
return (
<Link
href={active ? `/orders/${order.id}/track` : `/orders/${order.id}/track`}
className={`block bg-white rounded-2xl border p-4 hover:border-vibe-teal/40 transition ${
active ? 'border-vibe-teal/30 shadow-sm' : 'border-slate-100'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center text-xl">
{order.logo_url ? (
<img src={order.logo_url} alt="" className="w-10 h-10 rounded-xl object-cover" />
) : '🍽️'}
</div>
<div>
<p className="font-semibold text-vibe-dark">{order.restaurant_name}</p>
<p className="text-xs text-slate-400">#{order.order_number} · {date}</p>
</div>
</div>
<div className="text-right">
<p className="font-bold text-vibe-dark">${Number(order.total_customer_pays).toFixed(2)}</p>
<p className={`text-xs font-medium ${STATUS_STYLES[order.status] || 'text-slate-500'}`}>
{STATUS_LABELS[order.status] || order.status}
</p>
</div>
</div>
{active && (
<div className="mt-3 flex items-center justify-between">
<div className="flex-1 bg-slate-100 rounded-full h-1.5">
<div
className="bg-vibe-teal h-1.5 rounded-full transition-all"
style={{ width: `${getProgress(order.status)}%` }}
/>
</div>
<span className="ml-3 text-xs text-vibe-teal font-medium whitespace-nowrap">Track </span>
</div>
)}
</Link>
)
}
function getProgress(status: string): number {
const steps: Record<string, number> = {
pending: 10, confirmed: 25, preparing: 50,
ready_for_pickup: 65, driver_assigned: 75, picked_up: 88, delivered: 100,
}
return steps[status] || 0
}

View File

@ -1,195 +1,431 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
const stats = [
{ label: 'Delivery Fee', value: '$5', sub: 'flat, always' },
{ label: 'Hidden Fees', value: '$0', sub: 'guaranteed' },
{ label: 'Driver Tips', value: '100%', sub: 'goes to driver' },
{ label: 'Restaurant Profit', value: '100%', sub: 'no commission' },
// ─── Data ───────────────────────────────────────────────────────────────────
const NAV_LINKS = [
{ href: '/register?role=restaurant_owner', label: 'Add your restaurant' },
{ href: '/register?role=driver', label: 'Do deliveries' },
{ href: '/login', label: 'Sign in' },
]
const truths = [
const CATEGORIES = [
{ emoji: '🍕', label: 'Pizza' },
{ emoji: '🍔', label: 'Burgers' },
{ emoji: '🍣', label: 'Sushi' },
{ emoji: '🍛', label: 'Indian' },
{ emoji: '🌮', label: 'Mexican' },
{ emoji: '🍝', label: 'Italian' },
{ emoji: '🥗', label: 'Vegan' },
{ emoji: '🍜', label: 'Chinese' },
{ emoji: '🍱', label: 'Japanese' },
{ emoji: '🥞', label: 'Breakfast' },
]
const TRUST_STATS = [
{ value: '$5', label: 'Flat delivery fee', color: 'text-vibe-teal' },
{ value: '0%', label: 'Restaurant commission', color: 'text-vibe-green' },
{ value: '100%', label: 'Tips to driver', color: 'text-vibe-green' },
{ value: '$0', label: 'Hidden fees', color: 'text-vibe-teal' },
]
const HOW_IT_WORKS = [
{
icon: '🏪',
title: 'Restaurants keep 100% of their profits',
body: 'We charge a flat $500/month + $0.10/order. UberEats charges 30%. On a $40 order that\'s $12 vs $0.10. The math speaks for itself.',
step: '01',
title: 'Enter your address',
body: 'See every restaurant delivering to your neighbourhood in the GTA.',
},
{
icon: '🚴',
title: 'Drivers keep 100% of tips',
body: 'Pay $20 to unlock your day. After 4 deliveries at $5 each, every dollar is yours. Tips are always 100% yours — we never touch them.',
step: '02',
title: 'Pick your food',
body: 'Menu prices are identical to in-restaurant. No inflation, ever.',
},
{
icon: '👤',
title: 'Customers pay a flat $5 delivery fee',
body: 'No service fees. No inflated menu prices. No surprise totals at checkout. What you see is what you pay.',
step: '03',
title: 'Pay one flat fee',
body: '$5 delivery. No service fee. No mystery charges. Just food.',
},
]
export default function HomePage() {
// ─── Components ─────────────────────────────────────────────────────────────
function Navbar() {
return (
<div className="min-h-screen bg-white">
{/* Nav */}
<nav className="border-b border-slate-100 px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-vibe-teal">The Vibe</span>
<span className="text-xs bg-vibe-green/10 text-vibe-green px-2 py-0.5 rounded-full font-medium">GTA</span>
</div>
<div className="flex items-center gap-4">
<Link href="/restaurants" className="text-slate-600 hover:text-vibe-teal text-sm">Order Food</Link>
<Link href="/register?role=restaurant_owner" className="text-slate-600 hover:text-vibe-teal text-sm">For Restaurants</Link>
<Link href="/register?role=driver" className="text-slate-600 hover:text-vibe-teal text-sm">Drive with Us</Link>
<Link href="/login" className="bg-vibe-teal text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-teal-700 transition">
Sign In
<header className="sticky top-0 z-50 bg-[#1a1a1a] text-white">
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between gap-6">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 shrink-0">
<span className="text-xl font-black tracking-tight">
The<span className="text-vibe-teal">Vibe</span>
</span>
<span className="text-[10px] bg-vibe-teal/20 text-vibe-teal px-2 py-0.5 rounded-full font-semibold">
GTA
</span>
</Link>
{/* Nav links */}
<nav className="hidden md:flex items-center gap-6 text-sm text-slate-300">
{NAV_LINKS.slice(0, 2).map((l) => (
<Link key={l.href} href={l.href} className="hover:text-white transition">
{l.label}
</Link>
))}
</nav>
{/* Auth buttons */}
<div className="flex items-center gap-3 shrink-0">
<Link
href="/login"
className="hidden md:inline text-sm text-slate-300 hover:text-white transition"
>
Sign in
</Link>
<Link
href="/register"
className="bg-vibe-teal hover:bg-teal-600 text-white text-sm font-semibold px-4 py-2 rounded-full transition"
>
Get started
</Link>
</div>
</nav>
</div>
</header>
)
}
{/* Hero */}
<section className="bg-vibe-cream px-6 py-24 text-center">
<div className="max-w-3xl mx-auto">
<div className="inline-block bg-vibe-green/10 text-vibe-green text-sm font-semibold px-4 py-1.5 rounded-full mb-6">
Fair-Trade Delivery · Greater Toronto Area
function HeroSection() {
const router = useRouter()
const [address, setAddress] = useState('')
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
router.push(address.trim() ? `/home?address=${encodeURIComponent(address)}` : '/home')
}
return (
<section className="bg-[#1a1a1a] text-white overflow-hidden">
<div className="max-w-7xl mx-auto px-6 py-20 md:py-28 flex flex-col md:flex-row items-center gap-12">
{/* Left — copy + input */}
<div className="flex-1 max-w-xl">
{/* Badge */}
<div className="inline-flex items-center gap-2 bg-vibe-teal/15 border border-vibe-teal/30 text-vibe-teal text-xs font-semibold px-3 py-1.5 rounded-full mb-6">
<span className="w-1.5 h-1.5 bg-vibe-teal rounded-full animate-pulse" />
Now delivering across the GTA
</div>
<h1 className="text-5xl font-bold text-vibe-dark mb-6 leading-tight">
Food delivery that's<br />
<span className="text-vibe-teal">actually fair.</span>
<h1 className="text-5xl md:text-6xl font-black leading-[1.05] mb-6 tracking-tight">
Order food.<br />
<span className="text-vibe-teal">Pay fairly.</span>
</h1>
<p className="text-xl text-slate-600 mb-10 leading-relaxed">
No hidden fees. No commissions. No exploitation.<br />
A flat $5 delivery fee. That's it.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/restaurants" className="bg-vibe-teal text-white px-8 py-4 rounded-xl text-lg font-semibold hover:bg-teal-700 transition">
Order Food Now
</Link>
<Link href="/register?role=restaurant_owner" className="bg-white text-vibe-teal border-2 border-vibe-teal px-8 py-4 rounded-xl text-lg font-semibold hover:bg-vibe-cream transition">
List Your Restaurant Free Trial
</Link>
</div>
</div>
</section>
{/* Transparency Stats */}
<section className="border-y border-slate-100 py-12 px-6">
<div className="max-w-4xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
{stats.map((s) => (
<div key={s.label}>
<div className="text-4xl font-bold text-vibe-teal mb-1">{s.value}</div>
<div className="font-semibold text-vibe-dark">{s.label}</div>
<div className="text-sm text-slate-500">{s.sub}</div>
<p className="text-slate-400 text-lg mb-8 leading-relaxed">
$5 flat delivery. Zero service fees. 100% of tips to your driver.
Real prices from real restaurants.
</p>
{/* Address search */}
<form onSubmit={handleSearch} className="flex gap-2">
<div className="flex-1 flex items-center bg-white rounded-xl overflow-hidden pl-4">
<span className="text-slate-400 mr-2">📍</span>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Enter your delivery address"
className="flex-1 py-4 text-slate-900 placeholder-slate-400 bg-transparent focus:outline-none text-sm"
/>
</div>
))}
</div>
</section>
<button
type="submit"
className="bg-vibe-teal hover:bg-teal-600 text-white font-bold px-6 py-4 rounded-xl transition whitespace-nowrap text-sm"
>
Find Food
</button>
</form>
{/* How it's different */}
<section className="py-20 px-6">
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-center text-vibe-dark mb-4">Why we built this</h2>
<p className="text-center text-slate-500 mb-14 max-w-2xl mx-auto">
Delivery apps charge restaurants up to 30% per order. That's $12 on a $40 meal — money that doesn't go to the cook, the servers, or the drivers. We're done with that.
</p>
<div className="grid md:grid-cols-3 gap-8">
{truths.map((t) => (
<div key={t.title} className="bg-slate-50 rounded-2xl p-6">
<div className="text-3xl mb-4">{t.icon}</div>
<h3 className="font-bold text-vibe-dark mb-3">{t.title}</h3>
<p className="text-slate-600 text-sm leading-relaxed">{t.body}</p>
</div>
{/* Category quick-links */}
<div className="flex flex-wrap gap-2 mt-5">
{CATEGORIES.slice(0, 6).map((c) => (
<Link
key={c.label}
href={`/restaurants?cuisine=${c.label}`}
className="flex items-center gap-1.5 bg-white/10 hover:bg-white/20 text-white text-xs font-medium px-3 py-1.5 rounded-full transition"
>
{c.emoji} {c.label}
</Link>
))}
</div>
</div>
</section>
{/* UberEats comparison */}
<section className="bg-vibe-dark text-white py-20 px-6">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl font-bold mb-4">The $40 order example</h2>
<p className="text-slate-400 mb-12">Same order. Very different outcome.</p>
<div className="grid md:grid-cols-2 gap-6 text-left">
<div className="bg-red-900/30 border border-red-800 rounded-2xl p-6">
<div className="text-red-400 font-bold text-sm uppercase tracking-wide mb-4">UberEats</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between"><span>Order subtotal</span><span>$40.00</span></div>
<div className="flex justify-between text-red-400"><span>Commission (30%)</span><span>-$12.00</span></div>
<div className="flex justify-between text-red-400"><span>Service fee</span><span>-$4.99</span></div>
<div className="flex justify-between text-red-400"><span>Delivery fee</span><span>-$5.99</span></div>
<div className="border-t border-red-800 pt-3 flex justify-between font-bold">
<span>Restaurant receives</span><span className="text-red-400">$28.00</span>
{/* Right — visual */}
<div className="flex-1 flex justify-center md:justify-end">
<div className="relative w-full max-w-sm aspect-square">
{/* Decorative gradient blob */}
<div className="absolute inset-0 bg-gradient-to-br from-vibe-teal/30 via-vibe-green/20 to-transparent rounded-3xl" />
<div className="relative z-10 grid grid-cols-2 gap-3 p-6">
{[
{ emoji: '🍕', name: 'Pizzeria Liberta', time: '25 min', fee: '$5' },
{ emoji: '🍣', name: 'Coho Sushi', time: '30 min', fee: '$5' },
{ emoji: '🍔', name: 'Liberty Burger', time: '20 min', fee: '$5' },
{ emoji: '🥗', name: 'Green Bowl', time: '15 min', fee: '$5' },
].map((r) => (
<div
key={r.name}
className="bg-white/10 backdrop-blur-sm border border-white/10 rounded-2xl p-4 text-center hover:bg-white/15 transition cursor-pointer"
>
<div className="text-3xl mb-2">{r.emoji}</div>
<div className="text-white text-xs font-semibold leading-tight">{r.name}</div>
<div className="flex items-center justify-center gap-1 mt-2">
<span className="text-slate-400 text-[10px]">{r.time}</span>
<span className="text-slate-500 text-[10px]">·</span>
<span className="text-vibe-green text-[10px] font-bold">{r.fee}</span>
</div>
</div>
<div className="flex justify-between font-bold">
<span>Customer pays</span><span>$55.48</span>
</div>
</div>
</div>
<div className="bg-green-900/30 border border-green-700 rounded-2xl p-6">
<div className="text-vibe-green font-bold text-sm uppercase tracking-wide mb-4">The Vibe</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between"><span>Order subtotal</span><span>$40.00</span></div>
<div className="flex justify-between text-vibe-green"><span>Per-order fee</span><span>-$0.10</span></div>
<div className="flex justify-between text-slate-400"><span>Service fee</span><span>$0.00</span></div>
<div className="flex justify-between"><span>Delivery fee</span><span>$5.00</span></div>
<div className="border-t border-green-800 pt-3 flex justify-between font-bold">
<span>Restaurant receives</span><span className="text-vibe-green">$39.90</span>
</div>
<div className="flex justify-between font-bold">
<span>Customer pays</span><span className="text-vibe-green">$46.27</span>
</div>
</div>
))}
</div>
</div>
<p className="mt-8 text-vibe-green font-semibold text-lg">
Restaurant saves $11.90 on this single order. On 3,000 orders/month: $28,200 saved.
</p>
</div>
</section>
{/* Driver CTA */}
<section className="py-20 px-6 bg-amber-50">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl font-bold text-vibe-dark mb-4">Drive with us. Keep everything.</h2>
<p className="text-slate-600 mb-8 text-lg">Pay $20 to unlock your day. Make it back in 4 deliveries. Everything after that is pure profit.</p>
<div className="bg-white rounded-2xl p-8 shadow-sm border border-amber-100 mb-8">
<div className="grid grid-cols-3 gap-6 text-center">
<div>
<div className="text-3xl font-bold text-vibe-teal">$20</div>
<div className="text-sm text-slate-500 mt-1">Daily access</div>
</div>
<div>
<div className="text-3xl font-bold text-vibe-teal">$5</div>
<div className="text-sm text-slate-500 mt-1">Per delivery</div>
</div>
<div>
<div className="text-3xl font-bold text-vibe-green">100%</div>
<div className="text-sm text-slate-500 mt-1">Tips kept</div>
</div>
</div>
<div className="mt-6 bg-vibe-green/10 rounded-xl p-4">
<p className="text-vibe-green font-semibold">4 deliveries = break even. Every delivery after = profit.</p>
</div>
</div>
<Link href="/register?role=driver" className="bg-vibe-teal text-white px-8 py-4 rounded-xl text-lg font-semibold hover:bg-teal-700 transition">
Apply to Drive
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t border-slate-100 py-12 px-6">
<div className="max-w-5xl mx-auto flex flex-col md:flex-row justify-between items-center gap-6">
<div>
<div className="text-xl font-bold text-vibe-teal mb-1">The Vibe</div>
<div className="text-sm text-slate-500">Fair-trade delivery for the Greater Toronto Area</div>
</div>
<div className="flex gap-6 text-sm text-slate-500">
<Link href="/about" className="hover:text-vibe-teal">About</Link>
<Link href="/register?role=restaurant_owner" className="hover:text-vibe-teal">For Restaurants</Link>
<Link href="/register?role=driver" className="hover:text-vibe-teal">For Drivers</Link>
<Link href="/zones" className="hover:text-vibe-teal">Service Areas</Link>
</div>
<div className="text-xs text-slate-400">© 2025 The Vibe Inc. · Toronto, ON</div>
</div>
</footer>
</div>
</div>
</section>
)
}
function StatsBar() {
return (
<section className="bg-vibe-teal text-white py-4">
<div className="max-w-5xl mx-auto px-6 grid grid-cols-2 md:grid-cols-4 divide-x divide-teal-600/40">
{TRUST_STATS.map((s) => (
<div key={s.label} className="text-center px-4 py-2">
<div className="text-2xl font-black">{s.value}</div>
<div className="text-teal-100 text-xs mt-0.5">{s.label}</div>
</div>
))}
</div>
</section>
)
}
function HowItWorks() {
return (
<section className="bg-white py-20 px-6">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-14">
<p className="text-vibe-teal text-sm font-semibold uppercase tracking-widest mb-3">How it works</p>
<h2 className="text-4xl font-black text-slate-900 leading-tight">
Three steps. No surprises.
</h2>
</div>
<div className="grid md:grid-cols-3 gap-8">
{HOW_IT_WORKS.map((s) => (
<div key={s.step} className="relative">
<div className="text-7xl font-black text-slate-100 leading-none mb-4 select-none">
{s.step}
</div>
<h3 className="text-xl font-bold text-slate-900 mb-2">{s.title}</h3>
<p className="text-slate-500 leading-relaxed">{s.body}</p>
</div>
))}
</div>
<div className="mt-14 text-center">
<Link
href="/restaurants"
className="inline-block bg-vibe-teal hover:bg-teal-600 text-white font-bold px-10 py-4 rounded-full text-base transition"
>
Browse restaurants
</Link>
</div>
</div>
</section>
)
}
function ComparisonSection() {
return (
<section className="bg-[#111] text-white py-20 px-6">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-14">
<p className="text-vibe-teal text-sm font-semibold uppercase tracking-widest mb-3">The math</p>
<h2 className="text-4xl font-black leading-tight">
Same $40 order. Very different outcome.
</h2>
<p className="text-slate-400 mt-4">
The other apps take a cut from everyone. We don't.
</p>
</div>
<div className="grid md:grid-cols-2 gap-4">
{/* UberEats card */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-sm font-semibold text-slate-400 uppercase tracking-wide">Typical delivery app</span>
</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between text-slate-300">
<span>Order subtotal</span><span>$40.00</span>
</div>
<div className="flex justify-between text-red-400">
<span>Restaurant commission (30%)</span><span>$12.00</span>
</div>
<div className="flex justify-between text-red-400">
<span>Service fee</span><span>$4.99</span>
</div>
<div className="flex justify-between text-red-400">
<span>Delivery fee</span><span>$5.99</span>
</div>
<div className="border-t border-white/10 pt-3 space-y-2">
<div className="flex justify-between font-bold text-red-400">
<span>Restaurant gets</span><span>$28.00</span>
</div>
<div className="flex justify-between font-bold">
<span>You pay</span><span>$55.48</span>
</div>
</div>
</div>
</div>
{/* The Vibe card */}
<div className="bg-vibe-teal/10 border border-vibe-teal/40 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6">
<div className="w-2 h-2 rounded-full bg-vibe-teal" />
<span className="text-sm font-semibold text-vibe-teal uppercase tracking-wide">The Vibe</span>
</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between text-slate-300">
<span>Order subtotal</span><span>$40.00</span>
</div>
<div className="flex justify-between text-vibe-green">
<span>Commission</span><span>$0.00</span>
</div>
<div className="flex justify-between text-vibe-green">
<span>Service fee</span><span>$0.00</span>
</div>
<div className="flex justify-between text-slate-300">
<span>Delivery fee</span><span>$5.00</span>
</div>
<div className="border-t border-vibe-teal/30 pt-3 space-y-2">
<div className="flex justify-between font-bold text-vibe-green">
<span>Restaurant gets</span><span>$39.90</span>
</div>
<div className="flex justify-between font-bold text-vibe-green">
<span>You pay</span><span>$46.27</span>
</div>
</div>
</div>
</div>
</div>
<p className="text-center mt-8 text-vibe-green font-semibold text-lg">
Restaurant saves $11.90 per order · $35,700/month on 3,000 orders
</p>
</div>
</section>
)
}
function RolesCTA() {
return (
<section className="bg-[#f6f6f6] py-20 px-6">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-14">
<p className="text-vibe-teal text-sm font-semibold uppercase tracking-widest mb-3">Join us</p>
<h2 className="text-4xl font-black text-slate-900">Built for everyone in the chain.</h2>
</div>
<div className="grid md:grid-cols-3 gap-6">
{/* Customers */}
<div className="bg-white rounded-2xl p-8 border border-slate-100 shadow-sm flex flex-col">
<div className="text-4xl mb-5">🛒</div>
<h3 className="text-xl font-black text-slate-900 mb-3">Order food</h3>
<p className="text-slate-500 text-sm leading-relaxed flex-1">
$5 flat delivery. Real menu prices. No service fees or inflated totals at checkout.
</p>
<Link
href="/restaurants"
className="mt-6 block bg-vibe-teal hover:bg-teal-600 text-white font-bold py-3 px-6 rounded-full text-center text-sm transition"
>
Order now
</Link>
</div>
{/* Drivers */}
<div className="bg-[#1a1a1a] rounded-2xl p-8 flex flex-col">
<div className="text-4xl mb-5">🚴</div>
<h3 className="text-xl font-black text-white mb-3">Drive with us</h3>
<p className="text-slate-400 text-sm leading-relaxed flex-1">
No upfront charge. $20 daily fee deducted from your first 4 payouts. 100% of tips, forever.
</p>
<Link
href="/register?role=driver"
className="mt-6 block bg-vibe-green hover:bg-green-500 text-white font-bold py-3 px-6 rounded-full text-center text-sm transition"
>
Start driving
</Link>
</div>
{/* Restaurants */}
<div className="bg-white rounded-2xl p-8 border border-slate-100 shadow-sm flex flex-col">
<div className="text-4xl mb-5">🏪</div>
<h3 className="text-xl font-black text-slate-900 mb-3">List your restaurant</h3>
<p className="text-slate-500 text-sm leading-relaxed flex-1">
$500/month flat. Zero commission. Keep 100% of your food revenue. 14-day free trial.
</p>
<Link
href="/restaurant/onboarding"
className="mt-6 block border-2 border-vibe-teal text-vibe-teal hover:bg-vibe-teal hover:text-white font-bold py-3 px-6 rounded-full text-center text-sm transition"
>
14-day free trial
</Link>
</div>
</div>
</div>
</section>
)
}
function Footer() {
return (
<footer className="bg-[#1a1a1a] text-slate-400 py-12 px-6">
<div className="max-w-5xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-8 pb-8 border-b border-white/10">
<div>
<div className="text-white text-xl font-black mb-1">
The<span className="text-vibe-teal">Vibe</span>
</div>
<div className="text-sm">Fair-trade delivery · Greater Toronto Area</div>
</div>
<div className="flex flex-wrap gap-6 text-sm">
<Link href="/restaurants" className="hover:text-white transition">Order Food</Link>
<Link href="/register?role=restaurant_owner" className="hover:text-white transition">For Restaurants</Link>
<Link href="/register?role=driver" className="hover:text-white transition">For Drivers</Link>
<Link href="/about" className="hover:text-white transition">About</Link>
</div>
</div>
<div className="pt-6 text-xs flex flex-col md:flex-row justify-between gap-2">
<span>© 2025 The Vibe Inc. · Toronto, ON</span>
<span>Delivering fairly in Liberty Village, Downtown, North York, Scarborough &amp; Mississauga</span>
</div>
</div>
</footer>
)
}
// ─── Page ────────────────────────────────────────────────────────────────────
export default function HomePage() {
return (
<>
<Navbar />
<HeroSection />
<StatsBar />
<HowItWorks />
<ComparisonSection />
<RolesCTA />
<Footer />
</>
)
}

View File

@ -31,12 +31,12 @@ export default function RegisterPage() {
e.preventDefault()
setError('')
if (form.password !== form.confirmPassword) {
setError('Passwords do not match')
if (!form.email && !form.phone) {
setError('Email or phone number is required')
return
}
if (form.password.length < 8) {
setError('Password must be at least 8 characters')
if (form.password !== form.confirmPassword) {
setError('Passwords do not match')
return
}
@ -129,19 +129,18 @@ export default function RegisterPage() {
</div>
<div>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">Email</label>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">Email <span className="text-slate-400 font-normal">(or use phone below)</span></label>
<input
type="email"
value={form.email}
onChange={set('email')}
required
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"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">Phone <span className="text-slate-400 font-normal">(optional)</span></label>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">Phone <span className="text-slate-400 font-normal">(or use email above)</span></label>
<input
type="tel"
value={form.phone}
@ -158,9 +157,8 @@ export default function RegisterPage() {
value={form.password}
onChange={set('password')}
required
minLength={8}
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"
placeholder="Min. 8 characters"
placeholder="Password"
/>
</div>

View File

@ -1,51 +1,149 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { io, Socket } from 'socket.io-client'
import { api } from '@/lib/api'
interface SavingsDashboard {
restaurant: {
restaurant_id: string
name: string
total_orders_platform: number
total_savings_vs_uber: number
}
monthly: {
total_orders: number
gross_sales: number
platform_fees_paid: number
uber_would_have_charged: number
total_saved: number
monthly_subscription: number
totalCostOnPlatform: number
message: string
}
recentOrders: any[]
interface ActiveOrder {
id: string
order_number: number
status: string
subtotal: number
tip_amount: number
customer_first_name: string
created_at: string
items: { name: string; quantity: number }[]
}
// ============================================================
// RESTAURANT SAVINGS DASHBOARD
// Shows savings vs UberEats on every order
// ============================================================
const STATUS_LABELS: Record<string, string> = {
pending: 'New — needs acceptance',
confirmed: 'Accepted',
preparing: 'Preparing',
ready_for_pickup: 'Ready for pickup',
}
const STATUS_STYLES: Record<string, string> = {
pending: 'bg-amber-100 text-amber-800 border border-amber-200',
confirmed: 'bg-blue-100 text-blue-800',
preparing: 'bg-purple-100 text-purple-800',
ready_for_pickup: 'bg-orange-100 text-orange-800',
}
const NEXT_ACTION: Record<string, { label: string; endpoint: string }> = {
pending: { label: 'Accept', endpoint: 'confirm' },
confirmed: { label: 'Start Prep', endpoint: 'preparing' },
preparing: { label: 'Ready!', endpoint: 'ready' },
}
export default function RestaurantDashboardPage() {
const [data, setData] = useState<SavingsDashboard | null>(null)
const [isOpen, setIsOpen] = useState(false)
const router = useRouter()
const [restaurantName, setRestaurantName] = useState('')
const [restaurantId, setRestaurantId] = useState('')
const [isOpen, setIsOpen] = useState(true)
const [activeOrders, setActiveOrders] = useState<ActiveOrder[]>([])
const [stats, setStats] = useState({ todayOrders: 0, todayRevenue: 0, monthSaved: 0 })
const [loading, setLoading] = useState(true)
const [needsOnboarding, setNeedsOnboarding] = useState(false)
const [acting, setActing] = useState<string | null>(null)
const socketRef = useRef<Socket | null>(null)
useEffect(() => {
api.get('/restaurants/dashboard/savings')
.then((r) => { setData(r.data); setLoading(false) })
.catch((err) => {
const userStr = localStorage.getItem('vibe_user')
const token = localStorage.getItem('vibe_token')
const user = userStr ? JSON.parse(userStr) : null
if (!user?.restaurantId) {
setNeedsOnboarding(true)
setLoading(false)
return
}
setRestaurantId(user.restaurantId)
// Load active orders
const loadActiveOrders = async () => {
try {
const [ordersRes, savingsRes] = await Promise.all([
api.get('/orders/restaurant'),
api.get('/restaurants/dashboard/savings').catch(() => ({ data: null })),
])
const all: ActiveOrder[] = ordersRes.data || []
setActiveOrders(all.filter((o) => ['pending', 'confirmed', 'preparing', 'ready_for_pickup'].includes(o.status)))
if (savingsRes.data) {
const d = savingsRes.data
setRestaurantName(d.restaurant?.name || '')
setStats({
todayOrders: d.monthly?.total_orders || 0,
todayRevenue: d.monthly?.gross_sales || 0,
monthSaved: d.monthly?.total_saved || 0,
})
}
} catch (err: any) {
if (err.response?.status === 400) setNeedsOnboarding(true)
setLoading(false)
})
}
setLoading(false)
}
loadActiveOrders()
// Socket.IO for live orders
const socket = io(`${process.env.NEXT_PUBLIC_WS_URL}/tracking`, {
auth: { token },
transports: ['websocket'],
})
socketRef.current = socket
socket.on('connect', () => {
socket.emit('join:restaurant', user.restaurantId)
})
socket.on('order:new', (order: any) => {
// Re-fetch to get full order data with items
api.get('/orders/restaurant').then((r) => {
const all: ActiveOrder[] = r.data || []
setActiveOrders(all.filter((o) => ['pending', 'confirmed', 'preparing', 'ready_for_pickup'].includes(o.status)))
}).catch(() => {})
})
socket.on('order:status', ({ orderId, status }: { orderId: string; status: string }) => {
setActiveOrders((prev) =>
['pending', 'confirmed', 'preparing', 'ready_for_pickup'].includes(status)
? prev.map((o) => o.id === orderId ? { ...o, status } : o)
: prev.filter((o) => o.id !== orderId)
)
})
return () => { socket.disconnect() }
}, [])
const advanceStatus = async (orderId: string, endpoint: string) => {
setActing(orderId)
const endpointToStatus: Record<string, string> = {
confirm: 'confirmed',
preparing: 'preparing',
ready: 'ready_for_pickup',
}
try {
await api.patch(`/orders/${orderId}/${endpoint}`)
const newStatus = endpointToStatus[endpoint]
if (['driver_assigned', 'picked_up', 'delivered'].includes(newStatus)) {
setActiveOrders((prev) => prev.filter((o) => o.id !== orderId))
} else {
setActiveOrders((prev) => prev.map((o) => o.id === orderId ? { ...o, status: newStatus } : o))
}
} catch {}
setActing(null)
}
const toggleOpen = async () => {
await api.patch('/restaurants/dashboard/hours', { isOpen: !isOpen })
setIsOpen(!isOpen)
try {
await api.patch('/restaurants/dashboard/hours', { isOpen: !isOpen })
setIsOpen(!isOpen)
} catch {}
}
if (loading) return <div className="min-h-screen flex items-center justify-center text-slate-400">Loading...</div>
@ -53,172 +151,155 @@ export default function RestaurantDashboardPage() {
if (needsOnboarding) return (
<div className="min-h-screen bg-vibe-cream flex flex-col items-center justify-center px-6 text-center">
<div className="text-5xl mb-6">🏪</div>
<h1 className="text-2xl font-bold text-vibe-dark mb-3">Complete your restaurant setup</h1>
<p className="text-slate-500 mb-8 max-w-sm">You haven't listed a restaurant yet. It only takes 2 minutes to get set up and start saving on every order.</p>
<h1 className="text-2xl font-bold text-vibe-dark mb-3">Set up your restaurant</h1>
<p className="text-slate-500 mb-8 max-w-sm">List your restaurant on The Vibe $500/month, zero commission, 100% of profits.</p>
<a href="/restaurant/onboarding" className="bg-vibe-teal text-white px-8 py-4 rounded-xl font-semibold hover:bg-teal-700 transition">
Set Up My Restaurant
</a>
</div>
)
if (!data) return <div className="min-h-screen flex items-center justify-center text-slate-400">Error loading dashboard. Please try refreshing.</div>
const { restaurant, monthly, recentOrders } = data
const pendingCount = activeOrders.filter((o) => o.status === 'pending').length
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-100 px-6 py-4">
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div className="bg-white border-b border-slate-100 px-6 py-4 sticky top-0 z-10">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div>
<h1 className="font-bold text-xl text-vibe-dark">{restaurant.name}</h1>
<h1 className="font-bold text-xl text-vibe-dark">{restaurantName || 'My Restaurant'}</h1>
<p className="text-slate-500 text-sm">Restaurant Dashboard</p>
</div>
<button
onClick={toggleOpen}
className={`px-5 py-2 rounded-full font-semibold text-sm transition ${
isOpen ? 'bg-vibe-green text-white' : 'bg-slate-200 text-slate-600'
}`}
>
{isOpen ? 'Open for Orders ✓' : 'Closed — Open Now'}
</button>
<div className="flex items-center gap-3">
<Link href="/restaurant/menu" className="text-sm font-medium text-slate-500 hover:text-vibe-teal">
Menu
</Link>
<Link
href="/restaurant/orders"
className="relative text-sm font-medium text-vibe-teal hover:underline"
>
All Orders
{pendingCount > 0 && (
<span className="absolute -top-1.5 -right-3 bg-amber-500 text-white text-xs font-bold px-1.5 py-0.5 rounded-full leading-none">
{pendingCount}
</span>
)}
</Link>
<button
onClick={toggleOpen}
className={`px-5 py-2 rounded-full font-semibold text-sm transition ${
isOpen ? 'bg-green-500 text-white' : 'bg-slate-200 text-slate-600'
}`}
>
{isOpen ? 'Open ✓' : 'Closed — Open'}
</button>
</div>
</div>
</div>
<div className="max-w-5xl mx-auto px-6 py-8 space-y-8">
<div className="max-w-4xl mx-auto px-4 py-6 space-y-6">
{/* Savings Hero */}
<div className="bg-vibe-dark text-white rounded-2xl p-8">
<p className="text-slate-400 text-sm uppercase tracking-wide mb-2">This Month vs UberEats</p>
<div className="flex items-end gap-4 mb-6">
<div className="text-5xl font-bold text-vibe-green">
${Number(monthly.total_saved || 0).toFixed(0)}
</div>
<div className="text-slate-400 mb-2">saved so far</div>
</div>
<p className="text-slate-300 text-sm mb-6">{monthly.message}</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white/5 rounded-xl p-4">
<div className="text-2xl font-bold text-white">{monthly.total_orders || 0}</div>
<div className="text-slate-400 text-xs mt-1">Orders this month</div>
</div>
<div className="bg-white/5 rounded-xl p-4">
<div className="text-2xl font-bold text-white">${Number(monthly.gross_sales || 0).toFixed(0)}</div>
<div className="text-slate-400 text-xs mt-1">Gross sales</div>
</div>
<div className="bg-red-900/30 rounded-xl p-4">
<div className="text-2xl font-bold text-red-400">${Number(monthly.uber_would_have_charged || 0).toFixed(0)}</div>
<div className="text-slate-400 text-xs mt-1">UberEats would take</div>
</div>
<div className="bg-green-900/30 rounded-xl p-4">
<div className="text-2xl font-bold text-vibe-green">${Number(monthly.totalCostOnPlatform || 0).toFixed(0)}</div>
<div className="text-slate-400 text-xs mt-1">You pay us (total)</div>
</div>
</div>
</div>
{/* All-time stat */}
<div className="bg-vibe-green/5 border border-vibe-green/20 rounded-2xl p-6 text-center">
<p className="text-slate-500 text-sm mb-1">Total savings since joining The Vibe</p>
<p className="text-4xl font-bold text-vibe-green">${Number(restaurant.total_savings_vs_uber).toFixed(2)}</p>
<p className="text-slate-500 text-sm mt-1">across {restaurant.total_orders_platform} orders</p>
</div>
{/* Recent Orders */}
<div className="bg-white rounded-2xl border border-slate-100">
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 className="font-bold text-vibe-dark">Recent Orders</h2>
<a href="/restaurant/orders" className="text-vibe-teal text-sm hover:underline">View all </a>
{/* Live Active Orders */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-bold text-vibe-dark">
Active Orders
{activeOrders.length > 0 && (
<span className="ml-2 bg-vibe-teal text-white text-xs font-bold px-2 py-0.5 rounded-full">
{activeOrders.length}
</span>
)}
</h2>
<Link href="/restaurant/orders" className="text-sm text-vibe-teal hover:underline">
View all
</Link>
</div>
{recentOrders.length === 0 ? (
<div className="p-8 text-center text-slate-400">No orders yet. They'll appear here when customers start ordering!</div>
{activeOrders.length === 0 ? (
<div className="bg-white rounded-2xl border border-slate-100 p-8 text-center">
<div className="text-3xl mb-2">📡</div>
<p className="text-slate-500 text-sm">Waiting for orders new orders will appear here instantly</p>
</div>
) : (
<div className="divide-y divide-slate-50">
{recentOrders.map((order) => (
<div key={order.id} className="px-6 py-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-vibe-dark">Order #{order.order_number}</span>
<OrderStatusBadge status={order.status} />
</div>
<p className="text-sm text-slate-500 mt-0.5">
{order.customer_first_name} · {new Date(order.created_at).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
<div className="text-right">
<div className="font-semibold text-vibe-dark">${Number(order.subtotal).toFixed(2)}</div>
<div className="text-xs text-slate-400">
Platform fee: <span className="text-vibe-teal">${Number(order.platform_fee).toFixed(2)}</span>
</div>
{order.restaurant_savings > 0 && (
<div className="text-xs text-vibe-green font-medium">
Saved ${Number(order.restaurant_savings).toFixed(2)} vs Uber
<div className="space-y-3">
{activeOrders.map((order) => {
const action = NEXT_ACTION[order.status]
return (
<div
key={order.id}
className={`bg-white rounded-2xl border p-4 ${
order.status === 'pending' ? 'border-amber-300 shadow-sm shadow-amber-50' : 'border-slate-100'
}`}
>
{order.status === 'pending' && (
<div className="flex items-center gap-1.5 mb-2">
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse" />
<span className="text-amber-700 text-xs font-semibold">New Order accept now</span>
</div>
)}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-bold text-vibe-dark">#{order.order_number}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_STYLES[order.status] || ''}`}>
{STATUS_LABELS[order.status] || order.status}
</span>
</div>
<p className="text-sm text-slate-600 mb-1">{order.customer_first_name}</p>
<p className="text-xs text-slate-400">
{order.items?.map((i) => `${i.quantity}× ${i.name}`).join(', ')}
</p>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<div className="font-bold text-vibe-dark">${Number(order.subtotal).toFixed(2)}</div>
{order.tip_amount > 0 && (
<div className="text-xs text-vibe-green">+${Number(order.tip_amount).toFixed(2)} tip</div>
)}
</div>
{action && (
<button
onClick={() => advanceStatus(order.id, action.endpoint)}
disabled={acting === order.id}
className="bg-vibe-teal text-white px-4 py-2 rounded-xl text-sm font-semibold hover:bg-teal-700 transition disabled:opacity-50"
>
{acting === order.id ? '...' : action.label}
</button>
)}
</div>
</div>
</div>
</div>
))}
)
})}
</div>
)}
</div>
</section>
{/* Subscription info */}
<div className="bg-white rounded-2xl border border-slate-100 p-6">
<h3 className="font-semibold text-vibe-dark mb-4">Your Subscription</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Monthly flat fee</span>
<span className="font-medium">$500.00</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Per-order fee</span>
<span className="font-medium">$0.10</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Credit card processing</span>
<span className="font-medium">2.9% + $0.30 (Stripe)</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Commission</span>
<span className="font-medium text-vibe-green">$0.00 never</span>
</div>
<div className="border-t border-slate-100 pt-3 flex justify-between">
<span className="text-slate-500">UberEats alternative</span>
<span className="font-medium text-red-500">30% per order</span>
</div>
{/* Quick stats */}
<section className="grid grid-cols-3 gap-3">
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-center">
<div className="text-2xl font-bold text-vibe-dark">{stats.todayOrders}</div>
<div className="text-xs text-slate-400 mt-1">Orders this month</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-center">
<div className="text-2xl font-bold text-vibe-dark">${Number(stats.todayRevenue).toFixed(0)}</div>
<div className="text-xs text-slate-400 mt-1">Gross sales</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-center">
<div className="text-2xl font-bold text-vibe-green">${Number(stats.monthSaved).toFixed(0)}</div>
<div className="text-xs text-slate-400 mt-1">Saved vs UberEats</div>
</div>
</section>
{/* Subscription reminder */}
<div className="bg-vibe-dark text-white rounded-2xl p-5 flex items-center justify-between">
<div>
<p className="font-bold mb-1">$500/month flat fee</p>
<p className="text-slate-400 text-sm">Zero commission. You keep 100% of every order. No Uber-style 30% cut.</p>
</div>
<div className="text-3xl ml-4">💚</div>
</div>
</div>
</div>
)
}
function OrderStatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700',
confirmed: 'bg-blue-100 text-blue-700',
preparing: 'bg-purple-100 text-purple-700',
ready_for_pickup: 'bg-orange-100 text-orange-700',
driver_assigned: 'bg-teal-100 text-teal-700',
picked_up: 'bg-teal-100 text-teal-700',
delivered: 'bg-green-100 text-green-700',
cancelled: 'bg-red-100 text-red-700',
}
const labels: Record<string, string> = {
pending: 'Pending',
confirmed: 'Confirmed',
preparing: 'Preparing',
ready_for_pickup: 'Ready',
driver_assigned: 'Driver en route',
picked_up: 'Picked up',
delivered: 'Delivered',
cancelled: 'Cancelled',
}
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-slate-100 text-slate-500'}`}>
{labels[status] || status}
</span>
)
}

View File

@ -0,0 +1,479 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { api } from '@/lib/api'
interface MenuItem {
id: string
category_id: string
name: string
description: string | null
price: number
is_available: boolean
is_featured: boolean
}
interface Category {
id: string
name: string
sort_order: number
is_active: boolean
items: MenuItem[]
}
// ────────────────────────────────────────────────────────────────
// RESTAURANT MENU MANAGEMENT
// ────────────────────────────────────────────────────────────────
export default function RestaurantMenuPage() {
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [showAddCategory, setShowAddCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [addingCategory, setAddingCategory] = useState(false)
const [addingToCategory, setAddingToCategory] = useState<string | null>(null)
const [editingItem, setEditingItem] = useState<MenuItem | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const load = async () => {
try {
const { data } = await api.get('/restaurants/menu/items')
setCategories(data)
} catch {}
setLoading(false)
}
useEffect(() => { load() }, [])
const addCategory = async () => {
if (!newCategoryName.trim()) return
setAddingCategory(true)
try {
const { data } = await api.post('/restaurants/menu/categories', { name: newCategoryName })
setCategories((prev) => [...prev, { ...data, items: [] }])
setNewCategoryName('')
setShowAddCategory(false)
} catch {}
setAddingCategory(false)
}
const toggleAvailability = async (item: MenuItem) => {
try {
const { data } = await api.patch(`/restaurants/menu/items/${item.id}`, {
isAvailable: !item.is_available,
})
setCategories((prev) => prev.map((cat) => ({
...cat,
items: cat.items.map((i) => i.id === item.id ? { ...i, is_available: data.is_available } : i),
})))
} catch {}
}
const saveItem = async (catId: string, form: NewItemForm) => {
setSaving(true)
setError('')
try {
const { data } = await api.post('/restaurants/menu/items', {
categoryId: catId,
name: form.name,
description: form.description || undefined,
price: parseFloat(form.price),
dietaryTags: form.dietaryTags.length > 0 ? form.dietaryTags : undefined,
})
setCategories((prev) => prev.map((cat) =>
cat.id === catId ? { ...cat, items: [...cat.items, data] } : cat
))
setAddingToCategory(null)
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to add item')
}
setSaving(false)
}
const saveEdit = async (form: EditItemForm) => {
if (!editingItem) return
setSaving(true)
setError('')
try {
const { data } = await api.patch(`/restaurants/menu/items/${editingItem.id}`, {
name: form.name,
description: form.description || null,
price: parseFloat(form.price),
})
setCategories((prev) => prev.map((cat) => ({
...cat,
items: cat.items.map((i) => i.id === editingItem.id ? { ...i, ...data } : i),
})))
setEditingItem(null)
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to update item')
}
setSaving(false)
}
const deleteItem = async (item: MenuItem) => {
if (!confirm(`Delete "${item.name}"?`)) return
try {
await api.delete(`/restaurants/menu/items/${item.id}`)
setCategories((prev) => prev.map((cat) => ({
...cat,
items: cat.items.filter((i) => i.id !== item.id),
})))
} catch {}
}
const totalItems = categories.reduce((sum, c) => sum + c.items.length, 0)
const liveItems = categories.reduce((sum, c) => sum + c.items.filter((i) => i.is_available).length, 0)
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-100 px-6 py-4 sticky top-0 z-10">
<div className="max-w-3xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/restaurant/dashboard" className="text-slate-400 hover:text-vibe-teal text-sm"> Dashboard</Link>
<span className="text-slate-300">|</span>
<h1 className="font-bold text-vibe-dark">Menu</h1>
<span className="text-slate-400 text-sm">{liveItems}/{totalItems} items live</span>
</div>
<button
onClick={() => setShowAddCategory(true)}
className="bg-vibe-teal text-white px-4 py-2 rounded-xl text-sm font-semibold hover:bg-teal-700 transition"
>
+ Add Category
</button>
</div>
</div>
<div className="max-w-3xl mx-auto px-4 py-6 space-y-6">
{/* Add category inline */}
{showAddCategory && (
<div className="bg-white rounded-2xl border-2 border-vibe-teal/40 p-4">
<h3 className="font-semibold text-vibe-dark mb-3">New Category</h3>
<div className="flex gap-3">
<input
autoFocus
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') setShowAddCategory(false) }}
placeholder="e.g. Burgers, Starters, Drinks..."
className="flex-1 border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
<button
onClick={addCategory}
disabled={addingCategory || !newCategoryName.trim()}
className="bg-vibe-teal text-white px-5 py-2.5 rounded-xl font-semibold text-sm hover:bg-teal-700 transition disabled:opacity-50"
>
{addingCategory ? 'Adding...' : 'Add'}
</button>
<button onClick={() => setShowAddCategory(false)} className="text-slate-400 px-3 hover:text-slate-600"></button>
</div>
</div>
)}
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => <div key={i} className="bg-white rounded-2xl h-40 animate-pulse" />)}
</div>
) : categories.length === 0 ? (
<div className="text-center py-20">
<div className="text-5xl mb-4">🍽</div>
<h3 className="font-bold text-vibe-dark text-lg mb-2">No menu yet</h3>
<p className="text-slate-500 text-sm mb-6">Add a category to get started e.g. "Burgers", "Drinks"</p>
<button
onClick={() => setShowAddCategory(true)}
className="bg-vibe-teal text-white px-6 py-3 rounded-xl font-semibold hover:bg-teal-700 transition"
>
+ Add First Category
</button>
</div>
) : (
categories.map((cat) => (
<CategorySection
key={cat.id}
category={cat}
addingItem={addingToCategory === cat.id}
saving={saving}
error={error}
editingItem={editingItem}
onStartAdd={() => { setAddingToCategory(cat.id); setError('') }}
onCancelAdd={() => setAddingToCategory(null)}
onSaveItem={(form) => saveItem(cat.id, form)}
onToggle={toggleAvailability}
onEdit={(item) => { setEditingItem(item); setError('') }}
onCancelEdit={() => setEditingItem(null)}
onSaveEdit={saveEdit}
onDelete={deleteItem}
/>
))
)}
</div>
{/* Edit modal */}
{editingItem && (
<EditItemModal
item={editingItem}
saving={saving}
error={error}
onSave={saveEdit}
onClose={() => setEditingItem(null)}
/>
)}
</div>
)
}
// ────────────────────────────────────────────────────────────────
interface NewItemForm { name: string; description: string; price: string; dietaryTags: string[] }
interface EditItemForm { name: string; description: string; price: string }
const DIETARY_OPTIONS = ['Vegan', 'Vegetarian', 'Gluten-Free', 'Dairy-Free', 'Nut-Free', 'Halal', 'Spicy']
function CategorySection({ category, addingItem, saving, error, editingItem, onStartAdd, onCancelAdd, onSaveItem, onToggle, onEdit, onCancelEdit, onSaveEdit, onDelete }: {
category: Category
addingItem: boolean
saving: boolean
error: string
editingItem: MenuItem | null
onStartAdd: () => void
onCancelAdd: () => void
onSaveItem: (form: NewItemForm) => void
onToggle: (item: MenuItem) => void
onEdit: (item: MenuItem) => void
onCancelEdit: () => void
onSaveEdit: (form: EditItemForm) => void
onDelete: (item: MenuItem) => void
}) {
const [form, setForm] = useState<NewItemForm>({ name: '', description: '', price: '', dietaryTags: [] })
const liveCount = category.items.filter((i) => i.is_available).length
return (
<div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
{/* Category header */}
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-bold text-vibe-dark">{category.name}</h2>
<p className="text-xs text-slate-400 mt-0.5">
{category.items.length} items · {liveCount} live
</p>
</div>
<button
onClick={onStartAdd}
className="text-vibe-teal text-sm font-semibold hover:underline"
>
+ Add Item
</button>
</div>
{/* Add item form */}
{addingItem && (
<div className="border-b border-slate-100 bg-slate-50 px-5 py-4">
<h3 className="text-sm font-semibold text-slate-600 mb-3">New Item in {category.name}</h3>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<input
autoFocus
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Item name *"
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
</div>
<div>
<div className="relative">
<span className="absolute left-3 top-2.5 text-slate-400 text-sm">$</span>
<input
type="number"
min="0"
step="0.01"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
placeholder="0.00"
className="w-full border border-slate-200 rounded-xl pl-7 pr-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
</div>
</div>
</div>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Description (optional) — ingredients, allergens, portion size..."
rows={2}
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal resize-none"
/>
<div>
<p className="text-xs text-slate-500 mb-2">Dietary tags (optional)</p>
<div className="flex flex-wrap gap-2">
{DIETARY_OPTIONS.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setForm((f) => ({
...f,
dietaryTags: f.dietaryTags.includes(tag)
? f.dietaryTags.filter((t) => t !== tag)
: [...f.dietaryTags, tag],
}))}
className={`text-xs px-3 py-1 rounded-full border transition ${
form.dietaryTags.includes(tag)
? 'bg-vibe-teal text-white border-vibe-teal'
: 'bg-white text-slate-500 border-slate-200 hover:border-vibe-teal'
}`}
>
{tag}
</button>
))}
</div>
</div>
{error && <p className="text-red-600 text-sm bg-red-50 rounded-xl px-3 py-2">{error}</p>}
<div className="flex gap-3">
<button
onClick={() => { onSaveItem(form); setForm({ name: '', description: '', price: '', dietaryTags: [] }) }}
disabled={saving || !form.name.trim() || !form.price}
className="bg-vibe-teal text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-teal-700 transition disabled:opacity-50"
>
{saving ? 'Adding...' : 'Add to Menu'}
</button>
<button onClick={onCancelAdd} className="text-slate-500 text-sm hover:text-slate-700 px-3">Cancel</button>
</div>
</div>
</div>
)}
{/* Items list */}
{category.items.length === 0 && !addingItem ? (
<div className="px-5 py-6 text-center">
<p className="text-slate-400 text-sm">No items yet.</p>
<button onClick={onStartAdd} className="text-vibe-teal text-sm hover:underline mt-1">+ Add the first item</button>
</div>
) : (
<div className="divide-y divide-slate-50">
{category.items.map((item) => (
<div key={item.id} className={`px-5 py-3.5 flex items-center justify-between gap-4 ${!item.is_available ? 'opacity-60' : ''}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-vibe-dark text-sm">{item.name}</span>
{item.is_featured && <span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded-full">Featured</span>}
{!item.is_available && <span className="text-xs bg-slate-100 text-slate-400 px-1.5 py-0.5 rounded-full">Hidden</span>}
</div>
{item.description && (
<p className="text-xs text-slate-400 mt-0.5 truncate">{item.description}</p>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className="font-bold text-vibe-dark text-sm">${Number(item.price).toFixed(2)}</span>
{/* Live/hidden toggle */}
<button
onClick={() => onToggle(item)}
title={item.is_available ? 'Click to hide from customers' : 'Click to make live'}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
item.is_available ? 'bg-vibe-teal' : 'bg-slate-200'
}`}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${
item.is_available ? 'translate-x-4.5' : 'translate-x-0.5'
}`} />
</button>
<button
onClick={() => onEdit(item)}
className="text-slate-400 hover:text-vibe-teal text-xs px-2 py-1 rounded-lg hover:bg-slate-50 transition"
>
Edit
</button>
<button
onClick={() => onDelete(item)}
className="text-slate-300 hover:text-red-500 text-xs px-2 py-1 rounded-lg hover:bg-red-50 transition"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}
function EditItemModal({ item, saving, error, onSave, onClose }: {
item: MenuItem
saving: boolean
error: string
onSave: (form: EditItemForm) => void
onClose: () => void
}) {
const [form, setForm] = useState<EditItemForm>({
name: item.name,
description: item.description || '',
price: String(Number(item.price).toFixed(2)),
})
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4">
<div className="bg-white rounded-2xl w-full max-w-md p-6 shadow-xl">
<h2 className="font-bold text-vibe-dark text-lg mb-4">Edit Item</h2>
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Name *</label>
<input
autoFocus
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
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"
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Description</label>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={3}
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 resize-none"
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Price *</label>
<div className="relative">
<span className="absolute left-4 top-2.5 text-slate-400 text-sm">$</span>
<input
type="number"
min="0"
step="0.01"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
className="w-full border border-slate-200 rounded-xl pl-8 pr-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vibe-teal"
/>
</div>
</div>
{error && <p className="text-red-600 text-sm bg-red-50 rounded-xl px-3 py-2">{error}</p>}
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => onSave(form)}
disabled={saving || !form.name.trim() || !form.price}
className="flex-1 bg-vibe-teal text-white py-3 rounded-xl font-semibold text-sm hover:bg-teal-700 transition disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button onClick={onClose} className="px-5 py-3 text-slate-500 text-sm hover:text-slate-700">
Cancel
</button>
</div>
</div>
</div>
)
}

View File

@ -4,12 +4,10 @@ import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { loadStripe } from '@stripe/stripe-js'
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js'
import axios from 'axios'
import { api } from '@/lib/api'
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'
const CUISINE_TYPES = [
'Canadian', 'Italian', 'Chinese', 'Japanese', 'Indian', 'Mexican',
'Thai', 'Mediterranean', 'American', 'French', 'Middle Eastern', 'Korean',
@ -31,7 +29,6 @@ function StepBusinessInfo({ onNext }: { onNext: (data: any) => void }) {
name: '', description: '', phone: '', email: '',
address: '', postalCode: '', cuisineType: [] as string[],
zoneId: '', avgPrepTimeMinutes: '25', minOrderAmount: '15',
lat: '', lng: '',
})
const [errors, setErrors] = useState<Record<string, string>>({})
@ -54,7 +51,6 @@ function StepBusinessInfo({ onNext }: { onNext: (data: any) => void }) {
if (!form.address.trim()) e.address = 'Address is required'
if (!form.zoneId) e.zoneId = 'Select a delivery zone'
if (form.cuisineType.length === 0) e.cuisineType = 'Select at least one cuisine type'
if (!form.lat || !form.lng) e.lat = 'Location coordinates are required'
setErrors(e)
return Object.keys(e).length === 0
}
@ -177,30 +173,7 @@ function StepBusinessInfo({ onNext }: { onNext: (data: any) => void }) {
</div>
</div>
{/* Coordinates */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Location Coordinates *
<span className="text-gray-400 font-normal ml-2">
(find on <a href="https://www.latlong.net" target="_blank" rel="noopener noreferrer" className="text-teal-600 underline">latlong.net</a>)
</span>
</label>
<div className="grid grid-cols-2 gap-4">
<input
value={form.lat}
onChange={e => set('lat', e.target.value)}
className="border rounded-lg px-3 py-2 focus:ring-2 focus:ring-teal-500 outline-none"
placeholder="Latitude e.g. 43.6532"
/>
<input
value={form.lng}
onChange={e => set('lng', e.target.value)}
className="border rounded-lg px-3 py-2 focus:ring-2 focus:ring-teal-500 outline-none"
placeholder="Longitude e.g. -79.3832"
/>
</div>
{errors.lat && <p className="text-red-500 text-sm mt-1">{errors.lat}</p>}
</div>
{/* Coordinates auto-resolved from address by backend (Nominatim geocoding) */}
{/* Operational */}
<div className="grid grid-cols-2 gap-4">
@ -236,55 +209,53 @@ function StepBusinessInfo({ onNext }: { onNext: (data: any) => void }) {
function SubscriptionForm({ businessData, onSuccess }: { businessData: any; onSuccess: () => void }) {
const stripe = useStripe()
const elements = useElements()
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [skipCard, setSkipCard] = useState(false)
const createRestaurant = async () => {
const { data: restaurant } = await api.post('/restaurants', {
name: businessData.name,
description: businessData.description,
cuisineType: businessData.cuisineType,
phone: businessData.phone,
email: businessData.email,
address: businessData.address,
postalCode: businessData.postalCode,
zoneId: businessData.zoneId,
lat: parseFloat(businessData.lat),
lng: parseFloat(businessData.lng),
avgPrepTimeMinutes: parseInt(businessData.avgPrepTimeMinutes),
minOrderAmount: parseFloat(businessData.minOrderAmount),
})
return restaurant
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!stripe || !elements) return
setLoading(true)
setError('')
try {
const token = localStorage.getItem('token')
const headers = { Authorization: `Bearer ${token}` }
// 1. Create restaurant record (uses api instance — correct token + URL)
await createRestaurant()
// 1. Create the restaurant record
const { data: restaurant } = await axios.post(
`${API}/restaurants`,
{
name: businessData.name,
description: businessData.description,
cuisineType: businessData.cuisineType,
phone: businessData.phone,
email: businessData.email,
address: businessData.address,
postalCode: businessData.postalCode,
zoneId: businessData.zoneId,
lat: parseFloat(businessData.lat),
lng: parseFloat(businessData.lng),
avgPrepTimeMinutes: parseInt(businessData.avgPrepTimeMinutes),
minOrderAmount: parseFloat(businessData.minOrderAmount),
},
{ headers },
)
// 2. Optionally set up Stripe subscription
if (!skipCard && stripe && elements) {
const cardEl = elements.getElement(CardElement)
if (cardEl) {
const { paymentMethod, error: pmError } = await stripe.createPaymentMethod({
type: 'card',
card: cardEl,
billing_details: { name: businessData.name, email: businessData.email },
})
if (pmError) throw new Error(pmError.message)
// 2. Create Stripe payment method
const cardEl = elements.getElement(CardElement)!
const { paymentMethod, error: pmError } = await stripe.createPaymentMethod({
type: 'card',
card: cardEl,
billing_details: { name: businessData.name, email: businessData.email },
})
if (pmError) throw new Error(pmError.message)
// 3. Create subscription
await axios.post(
`${API}/payments/restaurant/subscribe`,
{ paymentMethodId: paymentMethod!.id },
{ headers },
)
await api.post('/payments/restaurant/subscribe', {
paymentMethodId: paymentMethod!.id,
})
}
}
onSuccess()
} catch (err: any) {
@ -330,24 +301,40 @@ function SubscriptionForm({ businessData, onSuccess }: { businessData: any; onSu
</p>
</div>
{/* Card input */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Card Details</label>
<div className="border rounded-lg p-3 focus-within:ring-2 focus-within:ring-teal-500">
<CardElement options={{ style: { base: { fontSize: '16px', color: '#111827' } } }} />
</div>
{/* Card toggle */}
<div className="flex items-center gap-3">
<input
id="skipCard"
type="checkbox"
checked={skipCard}
onChange={e => setSkipCard(e.target.checked)}
className="w-4 h-4 text-teal-600 rounded"
/>
<label htmlFor="skipCard" className="text-sm text-gray-600">
Add payment details later (you can still list your restaurant during the trial)
</label>
</div>
{/* Card input */}
{!skipCard && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Card Details</label>
<div className="border rounded-lg p-3 focus-within:ring-2 focus-within:ring-teal-500">
<CardElement options={{ style: { base: { fontSize: '16px', color: '#111827' } } }} />
</div>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-700 text-sm">{error}</div>
)}
<button
type="submit"
disabled={loading || !stripe}
disabled={loading}
className="w-full bg-teal-600 text-white py-3 rounded-lg font-semibold hover:bg-teal-700 disabled:opacity-50 transition"
>
{loading ? 'Setting up...' : 'Start Free Trial'}
{loading ? 'Setting up...' : skipCard ? 'Create Restaurant →' : 'Start Free Trial'}
</button>
<p className="text-xs text-gray-500 text-center">

View File

@ -1,208 +1,365 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
import Link from 'next/link'
import { io, Socket } from 'socket.io-client'
import { api } from '@/lib/api'
interface OrderItem {
name: string
quantity: number
price: number
}
interface Order {
id: string
order_number: string
order_number: number
status: string
subtotal: number
delivery_fee: number
tip_amount: number
platform_fee: number
total: number
total_customer_pays: number
special_instructions: string | null
customer_first_name: string
customer_last_name: string
customer_phone: string | null
created_at: string
items: { name: string; quantity: number; price: number }[]
estimated_pickup_at: string | null
items: OrderItem[]
}
const STATUS_OPTIONS = ['', 'pending', 'confirmed', 'preparing', 'ready_for_pickup', 'delivered', 'cancelled']
const STATUS_LABELS: Record<string, string> = {
'': 'All',
pending: 'Pending',
pending: 'New Order',
confirmed: 'Confirmed',
preparing: 'Preparing',
ready_for_pickup: 'Ready',
ready_for_pickup: 'Ready for Pickup',
driver_assigned: 'Driver En Route',
picked_up: 'Picked Up',
delivered: 'Delivered',
cancelled: 'Cancelled',
}
const STATUS_STYLES: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700',
confirmed: 'bg-blue-100 text-blue-700',
preparing: 'bg-purple-100 text-purple-700',
ready_for_pickup: 'bg-orange-100 text-orange-700',
driver_assigned: 'bg-teal-100 text-teal-700',
picked_up: 'bg-teal-100 text-teal-700',
delivered: 'bg-green-100 text-green-700',
cancelled: 'bg-red-100 text-red-700',
pending: 'bg-amber-100 text-amber-800 border border-amber-200',
confirmed: 'bg-blue-100 text-blue-800',
preparing: 'bg-purple-100 text-purple-800',
ready_for_pickup: 'bg-orange-100 text-orange-800',
driver_assigned: 'bg-teal-100 text-teal-800',
picked_up: 'bg-teal-100 text-teal-800',
delivered: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
}
// Maps current status → next action
const NEXT_ACTION: Record<string, { label: string; endpoint: string; style: string }> = {
pending: { label: 'Accept Order', endpoint: 'confirm', style: 'bg-vibe-teal text-white hover:bg-teal-700' },
confirmed: { label: 'Start Preparing', endpoint: 'preparing', style: 'bg-blue-600 text-white hover:bg-blue-700' },
preparing: { label: 'Mark Ready for Pickup', endpoint: 'ready', style: 'bg-orange-500 text-white hover:bg-orange-600' },
}
const FILTER_OPTIONS = ['', 'pending', 'confirmed', 'preparing', 'ready_for_pickup', 'delivered', 'cancelled']
export default function RestaurantOrdersPage() {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState('')
const [expandedId, setExpandedId] = useState<string | null>(null)
const [acting, setActing] = useState<string | null>(null)
const [newOrderId, setNewOrderId] = useState<string | null>(null)
const socketRef = useRef<Socket | null>(null)
const loadOrders = async () => {
setLoading(true)
const loadOrders = async (filter = statusFilter) => {
try {
const { data } = await api.get('/orders/restaurant', {
params: { status: statusFilter || undefined },
params: { status: filter || undefined },
})
setOrders(data)
} catch {}
setLoading(false)
}
useEffect(() => { loadOrders() }, [statusFilter])
useEffect(() => {
loadOrders()
const updateStatus = async (orderId: string, newStatus: string) => {
// Socket.IO for live incoming orders
const userStr = localStorage.getItem('vibe_user')
const token = localStorage.getItem('vibe_token')
const user = userStr ? JSON.parse(userStr) : null
if (token && user?.restaurantId) {
const socket = io(`${process.env.NEXT_PUBLIC_WS_URL}/tracking`, {
auth: { token },
transports: ['websocket'],
})
socketRef.current = socket
socket.on('connect', () => {
socket.emit('join:restaurant', user.restaurantId)
})
socket.on('order:new', (order: any) => {
setNewOrderId(order.id)
// Reload to get full order with items
loadOrders('')
setStatusFilter('')
})
socket.on('order:status', ({ orderId, status }: { orderId: string; status: string }) => {
setOrders((prev) => prev.map((o) => o.id === orderId ? { ...o, status } : o))
})
return () => { socket.disconnect() }
}
}, [])
useEffect(() => {
setLoading(true)
loadOrders(statusFilter)
}, [statusFilter])
const advanceStatus = async (orderId: string, endpoint: string) => {
setActing(orderId)
try {
await api.patch(`/orders/${orderId}/status`, { status: newStatus })
setOrders((prev) =>
prev.map((o) => (o.id === orderId ? { ...o, status: newStatus } : o))
)
} catch {}
await api.patch(`/orders/${orderId}/${endpoint}`)
const endpointToStatus: Record<string, string> = {
confirm: 'confirmed',
preparing: 'preparing',
ready: 'ready_for_pickup',
}
setOrders((prev) => prev.map((o) =>
o.id === orderId ? { ...o, status: endpointToStatus[endpoint] || o.status } : o
))
if (newOrderId === orderId) setNewOrderId(null)
} catch (err: any) {
alert(err.response?.data?.message || 'Action failed')
}
setActing(null)
}
const NEXT_STATUS: Record<string, { label: string; value: string }> = {
pending: { label: 'Confirm Order', value: 'confirmed' },
confirmed: { label: 'Start Preparing', value: 'preparing' },
preparing: { label: 'Mark Ready', value: 'ready_for_pickup' },
}
const activeOrders = orders.filter((o) => ['pending', 'confirmed', 'preparing', 'ready_for_pickup'].includes(o.status))
const pastOrders = orders.filter((o) => ['delivered', 'cancelled', 'driver_assigned', 'picked_up'].includes(o.status))
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-100 px-6 py-4">
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div className="bg-white border-b border-slate-100 px-6 py-4 sticky top-0 z-10">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/restaurant/dashboard" className="text-slate-400 hover:text-vibe-teal text-sm"> Dashboard</Link>
<span className="text-slate-300">|</span>
<h1 className="font-bold text-vibe-dark">Orders</h1>
{activeOrders.length > 0 && (
<span className="bg-amber-500 text-white text-xs font-bold px-2 py-0.5 rounded-full">
{activeOrders.length}
</span>
)}
</div>
<button
onClick={loadOrders}
className="text-sm text-vibe-teal hover:underline"
>
<button onClick={() => loadOrders()} className="text-sm text-vibe-teal hover:underline">
Refresh
</button>
</div>
</div>
<div className="max-w-5xl mx-auto px-6 py-6">
{/* Status filters */}
<div className="flex gap-2 flex-wrap mb-6">
{STATUS_OPTIONS.map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition ${
statusFilter === s
? 'bg-vibe-teal text-white'
: 'bg-white border border-slate-200 text-slate-600 hover:border-vibe-teal'
}`}
>
{STATUS_LABELS[s]}
</button>
))}
</div>
<div className="max-w-4xl mx-auto px-4 py-6 space-y-6">
{/* Active orders — always show */}
{loading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-white rounded-2xl h-24 animate-pulse" />
))}
</div>
) : orders.length === 0 ? (
<div className="text-center py-20">
<div className="text-4xl mb-4">📋</div>
<h3 className="font-semibold text-vibe-dark mb-2">No orders found</h3>
<p className="text-slate-500 text-sm">Orders will appear here as customers place them.</p>
</div>
) : (
<div className="space-y-3">
{orders.map((order) => (
<div key={order.id} className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
{/* Order header row */}
<div
className="px-6 py-4 flex items-center justify-between cursor-pointer hover:bg-slate-50 transition"
onClick={() => setExpandedId(expandedId === order.id ? null : order.id)}
>
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-vibe-dark">#{order.order_number}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_STYLES[order.status] || 'bg-slate-100 text-slate-500'}`}>
{STATUS_LABELS[order.status] || order.status}
</span>
</div>
<p className="text-sm text-slate-500 mt-0.5">
{order.customer_first_name} {order.customer_last_name} ·{' '}
{new Date(order.created_at).toLocaleString('en-CA', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="font-semibold text-vibe-dark">${Number(order.subtotal).toFixed(2)}</div>
<div className="text-xs text-slate-400">Fee: ${Number(order.platform_fee).toFixed(2)}</div>
</div>
{NEXT_STATUS[order.status] && (
<button
onClick={(e) => {
e.stopPropagation()
updateStatus(order.id, NEXT_STATUS[order.status].value)
}}
className="bg-vibe-teal text-white px-4 py-2 rounded-xl text-sm font-medium hover:bg-teal-700 transition whitespace-nowrap"
>
{NEXT_STATUS[order.status].label}
</button>
)}
<span className="text-slate-300">{expandedId === order.id ? '▲' : '▼'}</span>
</div>
<>
{/* Active */}
{activeOrders.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-3">
Active Orders ({activeOrders.length})
</h2>
<div className="space-y-3">
{activeOrders.map((order) => (
<OrderCard
key={order.id}
order={order}
isNew={order.id === newOrderId}
expanded={expandedId === order.id}
acting={acting === order.id}
onToggle={() => setExpandedId(expandedId === order.id ? null : order.id)}
onAdvance={(endpoint) => advanceStatus(order.id, endpoint)}
/>
))}
</div>
</section>
)}
{/* Expanded items */}
{expandedId === order.id && (
<div className="border-t border-slate-100 px-6 py-4">
<h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-3">Items</h4>
<div className="space-y-2">
{order.items?.map((item, i) => (
<div key={i} className="flex justify-between text-sm">
<span className="text-vibe-dark">{item.quantity}× {item.name}</span>
<span className="text-slate-500">${Number(item.price * item.quantity).toFixed(2)}</span>
</div>
))}
</div>
<div className="border-t border-slate-100 mt-4 pt-4 space-y-1 text-sm">
<div className="flex justify-between text-slate-500">
<span>Subtotal</span><span>${Number(order.subtotal).toFixed(2)}</span>
</div>
<div className="flex justify-between text-slate-500">
<span>Delivery fee (customer pays)</span><span>${Number(order.delivery_fee).toFixed(2)}</span>
</div>
<div className="flex justify-between font-semibold text-vibe-dark">
<span>Customer total</span><span>${Number(order.total).toFixed(2)}</span>
</div>
<div className="flex justify-between text-vibe-teal text-xs mt-2">
<span>Platform fee deducted</span><span>-${Number(order.platform_fee).toFixed(2)}</span>
</div>
</div>
</div>
)}
{activeOrders.length === 0 && (
<div className="text-center py-12">
<div className="text-4xl mb-3">📋</div>
<h3 className="font-semibold text-vibe-dark mb-1">No active orders</h3>
<p className="text-slate-500 text-sm">New orders will appear here automatically.</p>
</div>
))}
</div>
)}
{/* Status filter for history */}
<section>
<div className="flex gap-2 flex-wrap mb-3">
{FILTER_OPTIONS.map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`px-3 py-1 rounded-full text-xs font-medium transition ${
statusFilter === s
? 'bg-vibe-teal text-white'
: 'bg-white border border-slate-200 text-slate-600 hover:border-vibe-teal'
}`}
>
{STATUS_LABELS[s] || 'All'}
</button>
))}
</div>
{pastOrders.length > 0 && (
<>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-3">
History
</h2>
<div className="space-y-3">
{pastOrders.map((order) => (
<OrderCard
key={order.id}
order={order}
isNew={false}
expanded={expandedId === order.id}
acting={acting === order.id}
onToggle={() => setExpandedId(expandedId === order.id ? null : order.id)}
onAdvance={(endpoint) => advanceStatus(order.id, endpoint)}
/>
))}
</div>
</>
)}
</section>
</>
)}
</div>
</div>
)
}
function OrderCard({ order, isNew, expanded, acting, onToggle, onAdvance }: {
order: Order
isNew: boolean
expanded: boolean
acting: boolean
onToggle: () => void
onAdvance: (endpoint: string) => void
}) {
const action = NEXT_ACTION[order.status]
const timeAgo = getTimeAgo(order.created_at)
return (
<div className={`bg-white rounded-2xl border overflow-hidden transition-all ${
isNew ? 'border-amber-400 shadow-amber-100 shadow-md' : 'border-slate-100'
}`}>
{isNew && (
<div className="bg-amber-50 border-b border-amber-100 px-4 py-1.5 flex items-center gap-2">
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse" />
<span className="text-amber-700 text-xs font-semibold">New Order!</span>
</div>
)}
<div
className="px-5 py-4 flex items-center justify-between cursor-pointer hover:bg-slate-50/50"
onClick={onToggle}
>
<div className="flex items-start gap-3">
<div>
<div className="flex items-center gap-2 mb-0.5">
<span className="font-bold text-vibe-dark">#{order.order_number}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_STYLES[order.status] || 'bg-slate-100 text-slate-500'}`}>
{STATUS_LABELS[order.status] || order.status}
</span>
</div>
<p className="text-sm text-slate-500">
{order.customer_first_name} {order.customer_last_name}
{order.customer_phone && <span className="ml-2 text-slate-400">{order.customer_phone}</span>}
<span className="ml-2 text-slate-300">· {timeAgo}</span>
</p>
<p className="text-xs text-slate-400 mt-0.5">
{order.items.length > 0
? order.items.map((i) => `${i.quantity}× ${i.name}`).join(', ')
: 'Loading items...'}
</p>
</div>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<div className="text-right">
<div className="font-bold text-vibe-dark">${Number(order.subtotal).toFixed(2)}</div>
{order.tip_amount > 0 && (
<div className="text-xs text-vibe-green">+${Number(order.tip_amount).toFixed(2)} tip</div>
)}
</div>
{action && (
<button
onClick={(e) => { e.stopPropagation(); onAdvance(action.endpoint) }}
disabled={acting}
className={`px-4 py-2 rounded-xl text-sm font-semibold transition whitespace-nowrap disabled:opacity-50 ${action.style}`}
>
{acting ? '...' : action.label}
</button>
)}
<span className="text-slate-300 text-sm">{expanded ? '▲' : '▼'}</span>
</div>
</div>
{expanded && (
<div className="border-t border-slate-100 px-5 py-4 space-y-3">
<div className="space-y-1.5">
{order.items.map((item, i) => (
<div key={i} className="flex justify-between text-sm">
<span className="text-vibe-dark">{item.quantity}× {item.name}</span>
<span className="text-slate-500">${Number(Number(item.price) * item.quantity).toFixed(2)}</span>
</div>
))}
</div>
{order.special_instructions && (
<div className="bg-amber-50 border border-amber-100 rounded-xl px-3 py-2 text-sm text-amber-800">
<span className="font-medium">Note: </span>{order.special_instructions}
</div>
)}
<div className="border-t border-slate-100 pt-3 space-y-1 text-sm">
<div className="flex justify-between text-slate-500">
<span>Subtotal</span><span>${Number(order.subtotal).toFixed(2)}</span>
</div>
<div className="flex justify-between text-slate-500">
<span>Delivery fee</span><span>${Number(order.delivery_fee).toFixed(2)}</span>
</div>
{order.tip_amount > 0 && (
<div className="flex justify-between text-vibe-green">
<span>Tip (driver keeps 100%)</span><span>${Number(order.tip_amount).toFixed(2)}</span>
</div>
)}
<div className="flex justify-between text-xs text-slate-400 pt-1">
<span>Platform fee</span><span>-${Number(order.platform_fee).toFixed(2)}</span>
</div>
</div>
</div>
)}
</div>
)
}
function getTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return `${Math.floor(hrs / 24)}d ago`
}

View File

@ -156,7 +156,7 @@ export default function RestaurantMenuPage() {
<p className="text-slate-600 text-sm mt-2">{restaurant.description}</p>
)}
<div className="flex items-center gap-4 mt-3 text-sm text-slate-500">
<span> {restaurant.rating?.toFixed(1)} ({restaurant.total_reviews} reviews)</span>
<span> {Number(restaurant.rating).toFixed(1)} ({restaurant.total_reviews} reviews)</span>
<span>·</span>
<span>{restaurant.avg_prep_time_minutes} min prep</span>
<span>·</span>
@ -226,7 +226,7 @@ export default function RestaurantMenuPage() {
<p className="text-sm text-slate-500 line-clamp-2 mt-0.5">{item.description}</p>
)}
<div className="flex items-center justify-between mt-2">
<span className="font-bold text-vibe-dark">${item.price.toFixed(2)}</span>
<span className="font-bold text-vibe-dark">${Number(item.price).toFixed(2)}</span>
{item.is_available && (
<button
onClick={(e) => { e.stopPropagation(); handleAddItem(item) }}
@ -321,7 +321,7 @@ function CartSidebar({
>+</button>
</div>
<span className="flex-1 text-sm text-vibe-dark">{item.name}</span>
<span className="text-sm font-medium">${(item.price * item.quantity).toFixed(2)}</span>
<span className="text-sm font-medium">${(Number(item.price) * item.quantity).toFixed(2)}</span>
</div>
))}
</div>

View File

@ -2,7 +2,6 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { MapView } from '@/components/map/MapView'
import { api } from '@/lib/api'
interface Restaurant {
@ -14,27 +13,123 @@ interface Restaurant {
rating: number
total_reviews: number
avg_prep_time_minutes: number
min_order_amount: number
is_open: boolean
distance_km: number
lng: number
lat: number
zone_name: string
}
const CATEGORIES = [
{ label: 'All', value: '' },
{ label: '🍕 Pizza', value: 'Pizza' },
{ label: '🍔 Burgers', value: 'Burgers' },
{ label: '🍣 Japanese', value: 'Japanese' },
{ label: '🍝 Italian', value: 'Italian' },
{ label: '🍛 Indian', value: 'Indian' },
{ label: '🌮 Mexican', value: 'Mexican' },
{ label: '🥗 Vegan', value: 'Vegan' },
{ label: '🍜 Chinese', value: 'Chinese' },
{ label: '🥞 Breakfast', value: 'Breakfast' },
]
const HERO_GRADIENTS = [
'from-orange-400 to-rose-500',
'from-teal-400 to-cyan-600',
'from-violet-500 to-purple-700',
'from-amber-400 to-orange-600',
'from-emerald-400 to-teal-600',
'from-sky-400 to-blue-600',
]
function RestaurantCard({ r, index }: { r: Restaurant; index: number }) {
const gradient = HERO_GRADIENTS[index % HERO_GRADIENTS.length]
return (
<Link href={`/restaurants/${r.slug}`} className="group block">
<div className="rounded-2xl overflow-hidden bg-white shadow-sm border border-slate-100 hover:shadow-md transition-shadow duration-200">
{/* Hero image */}
<div className="relative h-44 overflow-hidden">
{r.logo_url ? (
<img
src={r.logo_url}
alt={r.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className={`w-full h-full bg-gradient-to-br ${gradient} flex items-center justify-center`}>
<span className="text-5xl opacity-80">🍽</span>
</div>
)}
{!r.is_open && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<span className="bg-black/70 text-white text-sm font-semibold px-4 py-1.5 rounded-full">
Closed
</span>
</div>
)}
<div className="absolute bottom-3 left-3">
<span className="bg-white text-green-700 text-xs font-bold px-2.5 py-1 rounded-full shadow-sm">
$5 delivery
</span>
</div>
</div>
{/* Info */}
<div className="p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="font-bold text-slate-900 text-base leading-tight group-hover:text-vibe-teal transition-colors">
{r.name}
</h3>
<div className="flex items-center gap-1 shrink-0">
<span className="text-amber-400 text-sm"></span>
<span className="text-sm font-semibold text-slate-700">{Number(r.rating).toFixed(1)}</span>
{r.total_reviews > 0 && (
<span className="text-xs text-slate-400">({r.total_reviews})</span>
)}
</div>
</div>
<p className="text-sm text-slate-500 mt-0.5">{r.cuisine_type?.join(' · ')}</p>
<div className="flex items-center gap-2 mt-3 text-xs text-slate-400">
{r.distance_km != null && <span>{Number(r.distance_km).toFixed(1)} km</span>}
<span>·</span>
<span>{r.avg_prep_time_minutes}{r.avg_prep_time_minutes + 10} min</span>
{r.min_order_amount > 0 && (
<>
<span>·</span>
<span>${Number(r.min_order_amount).toFixed(0)} min order</span>
</>
)}
</div>
</div>
</div>
</Link>
)
}
function SkeletonCard() {
return (
<div className="rounded-2xl overflow-hidden bg-white border border-slate-100 shadow-sm animate-pulse">
<div className="h-44 bg-slate-200" />
<div className="p-4 space-y-2">
<div className="h-4 bg-slate-200 rounded w-2/3" />
<div className="h-3 bg-slate-100 rounded w-1/2" />
<div className="h-3 bg-slate-100 rounded w-1/3 mt-3" />
</div>
</div>
)
}
export default function RestaurantsPage() {
const [restaurants, setRestaurants] = useState<Restaurant[]>([])
const [loading, setLoading] = useState(true)
const [userLocation, setUserLocation] = useState<[number, number]>([-79.3832, 43.6532]) // Default: downtown Toronto
const [view, setView] = useState<'list' | 'map'>('list')
const [userLocation, setUserLocation] = useState<[number, number]>([-79.4196, 43.6389])
const [cuisine, setCuisine] = useState('')
const [search, setSearch] = useState('')
useEffect(() => {
// Try to get user's real location
navigator.geolocation?.getCurrentPosition(
(pos) => {
setUserLocation([pos.coords.longitude, pos.coords.latitude])
},
() => {}, // fallback to downtown Toronto
(pos) => setUserLocation([pos.coords.longitude, pos.coords.latitude]),
() => {},
)
}, [])
@ -42,130 +137,128 @@ export default function RestaurantsPage() {
const load = async () => {
setLoading(true)
try {
const data = await api.get('/restaurants', {
const { data } = await api.get('/restaurants', {
params: { lng: userLocation[0], lat: userLocation[1], cuisine: cuisine || undefined },
})
setRestaurants(data.data)
setRestaurants(data)
} catch {}
setLoading(false)
}
load()
}, [userLocation, cuisine])
const cuisines = ['Pizza', 'Burgers', 'Japanese', 'Italian', 'Indian', 'Chinese', 'Mexican', 'Vegan']
const filtered = search
? restaurants.filter(
(r) =>
r.name.toLowerCase().includes(search.toLowerCase()) ||
r.cuisine_type?.some((c) => c.toLowerCase().includes(search.toLowerCase())),
)
: restaurants
const open = filtered.filter((r) => r.is_open)
const closed = filtered.filter((r) => !r.is_open)
return (
<div className="min-h-screen bg-white">
{/* Header */}
<div className="bg-vibe-teal text-white px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<Link href="/" className="text-xl font-bold">The Vibe</Link>
<div className="text-sm opacity-80">$5 flat delivery · No hidden fees</div>
</div>
</div>
<div className="min-h-screen bg-gray-50">
{/* Sticky header + search */}
<header className="bg-white border-b border-slate-100 sticky top-0 z-30">
<div className="max-w-5xl mx-auto px-4 py-3 flex items-center gap-3">
<Link href="/" className="text-lg font-black text-vibe-teal shrink-0">
The Vibe
</Link>
{/* Filters */}
<div className="border-b border-slate-100 px-6 py-4 sticky top-0 bg-white z-10">
<div className="max-w-6xl mx-auto">
<div className="flex items-center gap-3 overflow-x-auto pb-2">
{/* Search */}
<div className="flex-1 relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
🔍
</span>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search restaurants or cuisine..."
className="w-full bg-slate-100 rounded-xl pl-9 pr-4 py-2.5 text-sm text-slate-700 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-vibe-teal focus:bg-white transition"
/>
</div>
<Link href="/login" className="shrink-0 text-sm font-medium text-slate-600 hover:text-vibe-teal transition">
Sign in
</Link>
</div>
{/* Category filter chips */}
<div className="flex gap-2 overflow-x-auto px-4 pb-3 [-ms-overflow-style:none] [scrollbar-width:none]">
{CATEGORIES.map((c) => (
<button
onClick={() => setCuisine('')}
className={`px-4 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition ${
!cuisine ? 'bg-vibe-teal text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
key={c.value}
onClick={() => setCuisine(c.value === cuisine ? '' : c.value)}
className={`shrink-0 px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition ${
cuisine === c.value
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
All
{c.label}
</button>
{cuisines.map((c) => (
<button
key={c}
onClick={() => setCuisine(c === cuisine ? '' : c)}
className={`px-4 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition ${
cuisine === c ? 'bg-vibe-teal text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{c}
</button>
))}
</div>
<div className="flex gap-2 mt-3">
<button onClick={() => setView('list')} className={`text-sm px-3 py-1 rounded ${view === 'list' ? 'bg-vibe-dark text-white' : 'text-slate-500'}`}>List</button>
<button onClick={() => setView('map')} className={`text-sm px-3 py-1 rounded ${view === 'map' ? 'bg-vibe-dark text-white' : 'text-slate-500'}`}>Map</button>
</div>
))}
</div>
</div>
</header>
<div className="max-w-6xl mx-auto px-6 py-6">
{view === 'map' ? (
<div className="h-[600px] rounded-2xl overflow-hidden">
<MapView
center={userLocation}
zoom={13}
restaurants={restaurants}
/>
<main className="max-w-5xl mx-auto px-4 py-6">
{/* Tagline */}
<div className="flex items-center gap-2 mb-6 text-sm text-slate-500">
<span className="w-2 h-2 bg-green-500 rounded-full" />
<span>$5 flat delivery · No hidden fees · Menu prices always match in-restaurant</span>
</div>
{/* Restaurant grid */}
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{[...Array(6)].map((_, i) => <SkeletonCard key={i} />)}
</div>
) : open.length === 0 && closed.length === 0 ? (
<div className="text-center py-24">
<p className="text-5xl mb-4">🍽</p>
<h3 className="font-bold text-slate-800 text-xl mb-2">
{search ? 'No results' : 'No restaurants nearby'}
</h3>
<p className="text-slate-500 text-sm max-w-xs mx-auto">
{search
? 'Try a different search or browse all categories.'
: "We're expanding across the GTA. Check back soon!"}
</p>
{search && (
<button onClick={() => setSearch('')} className="mt-4 text-vibe-teal text-sm hover:underline">
Clear search
</button>
)}
</div>
) : (
<>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="bg-slate-100 rounded-2xl h-56 animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{restaurants.map((r) => (
<Link key={r.id} href={`/restaurants/${r.slug}`} className="group">
<div className="border border-slate-100 rounded-2xl overflow-hidden hover:shadow-md transition">
<div className="bg-slate-100 h-36 relative">
{r.logo_url ? (
<img src={r.logo_url} alt={r.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-4xl">🍽</div>
)}
{!r.is_open && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<span className="text-white font-semibold text-sm bg-black/60 px-3 py-1 rounded-full">Closed</span>
</div>
)}
</div>
<div className="p-4">
<div className="flex items-start justify-between mb-1">
<h3 className="font-semibold text-vibe-dark group-hover:text-vibe-teal transition">{r.name}</h3>
<div className="flex items-center gap-1 text-sm">
<span className="text-amber-500"></span>
<span>{r.rating?.toFixed(1)}</span>
</div>
</div>
<p className="text-sm text-slate-500 mb-2">{r.cuisine_type?.join(', ')}</p>
<div className="flex items-center justify-between text-xs text-slate-400">
<span>{r.distance_km?.toFixed(1)} km away</span>
<span>{r.avg_prep_time_minutes} min prep</span>
<span className="text-vibe-green font-medium">$5 delivery</span>
</div>
</div>
</div>
</Link>
))}
</div>
{open.length > 0 && (
<section className="mb-8">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-4">
Open now · {open.length} place{open.length !== 1 ? 's' : ''}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{open.map((r, i) => <RestaurantCard key={r.id} r={r} index={i} />)}
</div>
</section>
)}
{!loading && restaurants.length === 0 && (
<div className="text-center py-20">
<div className="text-4xl mb-4">📍</div>
<h3 className="font-semibold text-vibe-dark mb-2">No restaurants found nearby</h3>
<p className="text-slate-500 text-sm">We're expanding across the GTA. Check back soon!</p>
</div>
{closed.length > 0 && (
<section>
<p className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-4">
Closed now
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{closed.map((r, i) => <RestaurantCard key={r.id} r={r} index={open.length + i} />)}
</div>
</section>
)}
</>
)}
{/* Transparency banner */}
<div className="mt-12 bg-vibe-green/5 border border-vibe-green/20 rounded-2xl p-6 text-center">
<p className="text-vibe-dark font-semibold mb-1">Every price you see is the restaurant's real price.</p>
<p className="text-slate-500 text-sm">No markup. No inflation. Menu parity guaranteed. Delivery fee: $5 flat.</p>
</div>
</div>
</main>
</div>
)
}

View File

@ -36,6 +36,8 @@ export const useCart = create<CartStore>()(
deliveryFee: 5.00,
addItem: (restaurantId, restaurantName, restaurantSlug, item) => {
// Coerce price to number (PostgreSQL returns DECIMAL as string)
item = { ...item, price: Number(item.price) }
const { restaurantId: currentRestId, items } = get()
// Switching restaurant — clear cart first
@ -74,7 +76,7 @@ export const useCart = create<CartStore>()(
clearCart: () => set({ restaurantId: null, restaurantName: null, restaurantSlug: null, items: [] }),
subtotal: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
subtotal: () => get().items.reduce((sum, i) => sum + Number(i.price) * i.quantity, 0),
itemCount: () => get().items.reduce((sum, i) => sum + i.quantity, 0),
}),
{ name: 'vibe-cart' },

View File

@ -1,75 +1,137 @@
#!/usr/bin/env bash
# ============================================================
# The Vibe - Stripe Product & Price Setup
# Run once to create the Stripe products and prices needed
# The Vibe - Stripe Setup Script
# Creates all Stripe products, prices, and Connect test accounts
#
# Prerequisites:
# npm install -g stripe (or use the Stripe CLI)
# brew install stripe/stripe-cli/stripe (or download from stripe.com/docs/stripe-cli)
# stripe login
#
# Run: bash scripts/setup-stripe.sh
# ============================================================
set -e
echo "==> Creating Stripe products and prices for The Vibe..."
# ---- RESTAURANT MONTHLY SUBSCRIPTION ----
echo ""
echo "==> Creating Restaurant Monthly Subscription ($500/month)..."
echo "============================================================"
echo " The Vibe — Stripe Setup"
echo "============================================================"
echo ""
# ---- RESTAURANT MONTHLY SUBSCRIPTION ($500 CAD/month) ----
echo "==> Creating Restaurant Monthly Subscription product..."
RESTAURANT_PRODUCT=$(stripe products create \
--name="The Vibe - Restaurant Subscription" \
--description="Monthly flat-fee subscription. No commission. Unlimited orders." \
--description="Monthly flat-fee. No commission. Unlimited orders." \
--metadata[platform]="the-vibe" \
--metadata[type]="restaurant_subscription" \
--format=json)
--format=json 2>/dev/null)
RESTAURANT_PRODUCT_ID=$(echo $RESTAURANT_PRODUCT | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Restaurant product ID: $RESTAURANT_PRODUCT_ID"
RESTAURANT_PRODUCT_ID=$(echo "$RESTAURANT_PRODUCT" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo " Product: $RESTAURANT_PRODUCT_ID"
RESTAURANT_PRICE=$(stripe prices create \
--product="$RESTAURANT_PRODUCT_ID" \
--unit-amount=50000 \
--currency=cad \
--recurring[interval]=month \
--nickname="Restaurant Monthly - CAD $500" \
--format=json)
--nickname="Restaurant Monthly - CAD \$500" \
--format=json 2>/dev/null)
RESTAURANT_PRICE_ID=$(echo $RESTAURANT_PRICE | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Restaurant price ID: $RESTAURANT_PRICE_ID"
RESTAURANT_PRICE_ID=$(echo "$RESTAURANT_PRICE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo " Price: $RESTAURANT_PRICE_ID"
# ---- DRIVER DAILY FEE ----
# ---- DRIVER DAILY FEE ($20 CAD — charged as one-time PaymentIntent) ----
echo ""
echo "==> Creating Driver Daily Access Fee ($20/day)..."
echo "==> Creating Driver Daily Access Fee product..."
DRIVER_PRODUCT=$(stripe products create \
--name="The Vibe - Driver Daily Access" \
--description="Daily login fee. Keep 100% of delivery fees and tips. Break-even after 4 deliveries." \
--description="Daily access fee. Keep 100% of delivery fees and tips." \
--metadata[platform]="the-vibe" \
--metadata[type]="driver_daily_fee" \
--format=json)
--format=json 2>/dev/null)
DRIVER_PRODUCT_ID=$(echo $DRIVER_PRODUCT | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Driver product ID: $DRIVER_PRODUCT_ID"
DRIVER_PRODUCT_ID=$(echo "$DRIVER_PRODUCT" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Note: Driver daily fee is charged as a one-time PaymentIntent, not a recurring price
# This price ID is kept for reference/future recurring use
DRIVER_PRICE=$(stripe prices create \
--product="$DRIVER_PRODUCT_ID" \
--unit-amount=2000 \
--currency=cad \
--nickname="Driver Daily Fee - CAD $20" \
--format=json)
--nickname="Driver Daily Fee - CAD \$20" \
--format=json 2>/dev/null)
DRIVER_PRICE_ID=$(echo $DRIVER_PRICE | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Driver price ID: $DRIVER_PRICE_ID"
DRIVER_PRICE_ID=$(echo "$DRIVER_PRICE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo " Price: $DRIVER_PRICE_ID"
# ---- STRIPE CONNECT — TEST EXPRESS ACCOUNTS ----
echo ""
echo "==> Creating Stripe Connect Express account for test DRIVER..."
DRIVER_ACCOUNT=$(stripe accounts create \
--type=express \
--country=CA \
--email="driver@thevibe.ca" \
--business_type=individual \
--capabilities[transfers][requested]=true \
--metadata[platform]="the-vibe" \
--metadata[role]="driver" \
--format=json 2>/dev/null)
DRIVER_ACCOUNT_ID=$(echo "$DRIVER_ACCOUNT" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo " Driver Connect account: $DRIVER_ACCOUNT_ID"
echo ""
echo "==> Creating Stripe Connect Express account for test RESTAURANT..."
RESTAURANT_ACCOUNT=$(stripe accounts create \
--type=express \
--country=CA \
--email="owner@libertyburgerlv.ca" \
--business_type=company \
--capabilities[transfers][requested]=true \
--metadata[platform]="the-vibe" \
--metadata[role]="restaurant" \
--format=json 2>/dev/null)
RESTAURANT_ACCOUNT_ID=$(echo "$RESTAURANT_ACCOUNT" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo " Restaurant Connect account: $RESTAURANT_ACCOUNT_ID"
# Update .env file in-place
ENV_FILE="$(dirname "$0")/../.env"
if [ -f "$ENV_FILE" ]; then
echo ""
echo "==> Updating .env with generated IDs..."
sed -i '' "s|STRIPE_RESTAURANT_PRICE_ID=.*|STRIPE_RESTAURANT_PRICE_ID=$RESTAURANT_PRICE_ID|" "$ENV_FILE"
sed -i '' "s|DRIVER_CONNECT_ACCOUNT_ID=.*|DRIVER_CONNECT_ACCOUNT_ID=$DRIVER_ACCOUNT_ID|" "$ENV_FILE"
sed -i '' "s|RESTAURANT_CONNECT_ACCOUNT_ID=.*|RESTAURANT_CONNECT_ACCOUNT_ID=$RESTAURANT_ACCOUNT_ID|" "$ENV_FILE"
echo " .env updated."
fi
# ---- OUTPUT ----
echo ""
echo "============================================================"
echo "Add these to your .env file:"
echo "============================================================"
echo "STRIPE_RESTAURANT_PRICE_ID=$RESTAURANT_PRICE_ID"
echo "STRIPE_DRIVER_DAILY_PRICE_ID=$DRIVER_PRICE_ID"
echo " DONE. Values written to .env:"
echo "============================================================"
echo ""
echo "Also set up your webhook:"
echo " STRIPE_RESTAURANT_PRICE_ID=$RESTAURANT_PRICE_ID"
echo " DRIVER_CONNECT_ACCOUNT_ID=$DRIVER_ACCOUNT_ID"
echo " RESTAURANT_CONNECT_ACCOUNT_ID=$RESTAURANT_ACCOUNT_ID"
echo ""
echo "============================================================"
echo " NEXT: Add your Stripe API keys to .env:"
echo "============================================================"
echo ""
echo " STRIPE_SECRET_KEY=sk_test_..."
echo " STRIPE_PUBLISHABLE_KEY=pk_test_..."
echo " NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_..."
echo ""
echo " Then start the webhook listener in a separate terminal:"
echo ""
echo " stripe listen --forward-to localhost:3001/api/v1/payments/webhook"
echo " (copy the webhook secret into STRIPE_WEBHOOK_SECRET)"
echo ""
echo " Copy the 'whsec_...' secret into .env as STRIPE_WEBHOOK_SECRET"
echo ""
echo "============================================================"
echo " Stripe test cards:"
echo " Success: 4242 4242 4242 4242 (any future exp, any CVC)"
echo " Decline: 4000 0000 0000 0002"
echo "============================================================"
echo ""