Initial commit — The Vibe fair-trade delivery platform

- NestJS backend: auth, restaurants, orders, drivers, payments, tracking, reviews, zones, admin, email
- Next.js 14 frontend: landing, restaurants, checkout, tracking, dashboards, onboarding
- Expo mobile app: driver orders and earnings screens
- PostgreSQL + PostGIS schema with seed data
- Docker Compose for local dev (Postgres, Redis, OSRM)
- MapLibre GL + OpenStreetMap integration
- Stripe subscription and payment processing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-03-04 13:26:55 -05:00
commit 89cf37f5b5
91 changed files with 33909 additions and 0 deletions

66
.env.example Normal file
View File

@ -0,0 +1,66 @@
# ============================================================
# The Vibe - Fair-Trade Delivery Platform
# Copy this file to .env and fill in your values
# ============================================================
# App
NODE_ENV=development
PORT=3001
# PostgreSQL + PostGIS
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=vibe
POSTGRES_PASSWORD=vibepass
POSTGRES_DB=vibe_db
DATABASE_URL=postgresql://vibe:vibepass@localhost:5432/vibe_db
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_URL=redis://localhost:6379
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=7d
# Stripe
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
STRIPE_RESTAURANT_PRICE_ID=price_monthly_500
STRIPE_DRIVER_DAILY_PRICE_ID=price_daily_20
# Stripe - Public (used in frontend)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
# OSRM Routing
OSRM_BASE_URL=http://localhost:5000
# Maps (MapTiler - free tier for OSM tiles)
NEXT_PUBLIC_MAPTILER_KEY=your_maptiler_api_key
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_WS_URL=ws://localhost:3001
# Platform Fee Config
DRIVER_DAILY_FEE=20
DRIVER_DELIVERY_FEE=5
RESTAURANT_MONTHLY_FEE=500
RESTAURANT_PER_ORDER_FEE=0.10
PLATFORM_CC_RATE=0.029
PLATFORM_CC_FIXED=0.30
# Email (transactional — use Resend, Mailgun, SendGrid, or any SMTP)
# Leave SMTP_USER empty to disable email (logs to console instead)
SMTP_HOST=smtp.resend.com
SMTP_PORT=587
SMTP_USER=resend
SMTP_PASS=re_your_resend_api_key_here
EMAIL_FROM="The Vibe" <noreply@thevibe.ca>
# Frontend base URL (used in email links)
FRONTEND_URL=http://localhost:3000
# Admin
ADMIN_EMAIL=admin@thevibe.ca

87
.gitignore vendored Normal file
View File

@ -0,0 +1,87 @@
# ============================================================
# The Vibe - .gitignore
# ============================================================
# Dependencies
node_modules/
.pnp
.pnp.js
# Build outputs
dist/
build/
.next/
out/
# Environment files (never commit secrets)
.env
.env.local
.env.*.local
.env.production
# Keep .env.example (it has no real secrets)
!.env.example
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# IDE
.vscode/
.idea/
*.swp
*.swo
*.suo
*.user
# TypeScript
*.tsbuildinfo
tsconfig.tsbuildinfo
# Testing
coverage/
.nyc_output/
# OSRM processed map data (large binary files)
osrm_data/
*.osrm
*.osrm.*
*.osm.pbf
# Database
*.dump
*.sql.bak
# Expo / React Native
packages/mobile/.expo/
packages/mobile/dist/
packages/mobile/web-build/
packages/mobile/android/
packages/mobile/ios/
packages/mobile/.expo-shared/
# Next.js
packages/web/.next/
packages/web/out/
# NestJS
packages/backend/dist/
# Misc
*.pem
.vercel
.turbo

219
README.md Normal file
View File

@ -0,0 +1,219 @@
# The Vibe — Fair-Trade Delivery Platform
> Flat-fee food delivery for the Greater Toronto Area.
> No commissions. No exploitation. Restaurants keep 100% of profits.
---
## Quick Start
```bash
# 1. Copy env and fill in your keys
cp .env.example .env
# 2. Start PostgreSQL/PostGIS + Redis
npm run docker:up
# 3. Install all dependencies
npm install
# 4. Start backend + frontend
npm run dev
```
Backend: http://localhost:3001
Frontend: http://localhost:3000
PgAdmin: http://localhost:5050 (after `docker-compose --profile dev up`)
---
## Project Structure
```
The_Vibe/
├── packages/
│ ├── database/
│ │ ├── schema.sql # Full PostgreSQL + PostGIS schema
│ │ └── seed.sql # GTA zones + dev data
│ ├── backend/ # NestJS API (port 3001)
│ │ └── src/
│ │ ├── modules/
│ │ │ ├── auth/ # JWT auth, roles guard
│ │ │ ├── drivers/ # Sessions, break-even algorithm
│ │ │ ├── restaurants/ # Menu, savings dashboard
│ │ │ ├── orders/ # Full order lifecycle
│ │ │ ├── payments/ # Stripe subscriptions + daily fee
│ │ │ ├── tracking/ # Socket.IO real-time gateway
│ │ │ ├── zones/ # GTA PostGIS geofencing
│ │ │ ├── menu/ # Menu CRUD
│ │ │ └── admin/ # Platform analytics
│ │ └── database/ # pg Pool + transaction helper
│ └── web/ # Next.js 14 frontend (port 3000)
│ └── src/
│ ├── app/
│ │ ├── page.tsx # Landing page
│ │ ├── restaurants/page.tsx # Browse + map
│ │ ├── orders/[id]/track/ # Real-time order tracking
│ │ ├── driver/dashboard/ # Break-even dashboard
│ │ ├── restaurant/dashboard/ # Savings dashboard
│ │ └── admin/page.tsx # Platform admin
│ ├── components/
│ │ └── map/MapView.tsx # MapLibre GL JS
│ ├── hooks/
│ │ └── useDriverTracking.ts # GPS + Socket.IO
│ └── lib/
│ ├── api.ts # Axios client
│ └── osrm.ts # OSRM routing client
└── docker-compose.yml
```
---
## Pricing Model
| Stakeholder | Model | Amount |
|---|---|---|
| Restaurant | Monthly subscription | $500/month |
| Restaurant | Per-order fee | $0.10/order |
| Restaurant | CC processing (Stripe) | 2.9% + $0.30 |
| Driver | Daily access fee | $20/day |
| Driver | Delivery fee | $5 per delivery (kept 100%) |
| Driver | Tips | 100% kept |
| Customer | Delivery fee | $5 flat |
| Customer | Hidden fees | $0 |
### Driver Break-Even
- 4 deliveries × $5 = $20 → break even
- Every delivery after #4 = pure profit
- Tips never counted against break-even
### Restaurant Savings Example (100 orders/day)
- UberEats at 30% on $35 avg: **$31,500/month**
- The Vibe: $500 + $300 + CC ≈ **$3,400/month**
- **Savings: ~$28,000/month**
---
## API Endpoints
### Auth
```
POST /api/v1/auth/register
POST /api/v1/auth/login
GET /api/v1/auth/me
```
### Restaurants
```
GET /api/v1/restaurants?lng=&lat=&radius=&cuisine=
GET /api/v1/restaurants/savings-calculator?orders=&avgValue=
GET /api/v1/restaurants/:slug
POST /api/v1/restaurants (restaurant_owner)
GET /api/v1/restaurants/dashboard/savings (restaurant_owner)
```
### Orders
```
POST /api/v1/orders (customer)
GET /api/v1/orders/mine (customer)
GET /api/v1/orders/:id
PATCH /api/v1/orders/:id/confirm (restaurant_owner)
PATCH /api/v1/orders/:id/ready (restaurant_owner)
PATCH /api/v1/orders/:id/pickup (driver)
PATCH /api/v1/orders/:id/delivered (driver)
```
### Drivers
```
GET /api/v1/drivers/me/session
POST /api/v1/drivers/me/session/start
POST /api/v1/drivers/me/session/end
PATCH /api/v1/drivers/me/location
GET /api/v1/drivers/nearby?lng=&lat=
```
### Payments (Stripe)
```
POST /api/v1/payments/restaurant/subscribe
POST /api/v1/payments/driver/daily-fee
POST /api/v1/payments/driver/payment-method
POST /api/v1/payments/orders/:orderId/intent
POST /api/v1/payments/webhook
```
### Zones (GTA Geofencing)
```
GET /api/v1/zones
GET /api/v1/zones/geojson # GeoJSON FeatureCollection
GET /api/v1/zones/check?lng=&lat=
```
### Admin
```
GET /api/v1/admin/stats
GET /api/v1/admin/revenue?days=30
GET /api/v1/admin/restaurants
GET /api/v1/admin/drivers
PATCH /api/v1/admin/drivers/:id/approve
```
---
## Map Stack
- **MapLibre GL JS** — open-source map renderer (browser)
- **OpenStreetMap** tiles via MapTiler (free tier)
- **OSRM** — self-hosted routing engine (ontario-latest.osm.pbf)
- **PostGIS** — geofencing, spatial queries, driver proximity
### GTA Zones (active at launch)
1. Downtown Toronto (priority 10)
2. Liberty Village (priority 9)
3. North York (priority 8)
4. Scarborough (priority 7)
5. Mississauga (priority 6)
---
## Real-Time Architecture
```
Driver App Socket.IO Gateway Customer App
│ │ │
├─ emit driver:location ──► │
│ ├─ emit driver:moved ────►│
│ │ │
│ ◄── emit join:order ──────┤
│ │ │
│ Orders Service │
│ ◄── order:new ────────────┤
│ ├─ emit to restaurant room │
```
Driver location updates every 5 seconds via WebSocket.
DB breadcrumbs written to `delivery_tracking` table.
---
## Environment Variables Required
See `.env.example` for full list. Key ones:
- `DATABASE_URL` — PostgreSQL + PostGIS connection string
- `STRIPE_SECRET_KEY` — Stripe API key
- `STRIPE_RESTAURANT_PRICE_ID` — $500/month Price ID in Stripe
- `JWT_SECRET` — random secret, min 32 chars
- `NEXT_PUBLIC_MAPTILER_KEY` — free MapTiler account for OSM tiles
---
## Performance Targets
| Metric | Target |
|---|---|
| Restaurants | 1,000+ |
| Active drivers | 5,000 |
| Orders/day | 50,000 |
| Location updates/sec | ~50,000 (drivers × 0.2hz) |
| DB connections | max 20 (pg pool) |
Scale path: Redis adapter for Socket.IO → horizontal Node scaling → Postgres read replicas.

70
docker-compose.yml Normal file
View File

@ -0,0 +1,70 @@
services:
postgres:
image: postgis/postgis:16-3.4
container_name: vibe_postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-vibe}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vibepass}
POSTGRES_DB: ${POSTGRES_DB:-vibe_db}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./packages/database/schema.sql:/docker-entrypoint-initdb.d/01_schema.sql
- ./packages/database/seed.sql:/docker-entrypoint-initdb.d/02_seed.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vibe -d vibe_db"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: vibe_redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
osrm:
image: osrm/osrm-backend:latest
container_name: vibe_osrm
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- osrm_data:/data
command: osrm-routed --algorithm mld /data/ontario-latest.osrm
profiles:
- routing
pgadmin:
image: dpage/pgadmin4:latest
container_name: vibe_pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@thevibe.ca}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
ports:
- "5050:80"
depends_on:
- postgres
profiles:
- dev
volumes:
postgres_data:
redis_data:
osrm_data:
networks:
default:
name: vibe_network

23637
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "the-vibe",
"version": "1.0.0",
"private": true,
"description": "Fair-Trade Delivery SaaS Platform - GTA",
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:web\"",
"dev:backend": "npm run dev --workspace=packages/backend",
"dev:web": "npm run dev --workspace=packages/web",
"build": "npm run build --workspaces",
"db:migrate": "npm run migrate --workspace=packages/backend",
"db:seed": "npm run seed --workspace=packages/backend",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down"
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}

View File

@ -0,0 +1,51 @@
{
"name": "@vibe/backend",
"version": "1.0.0",
"description": "The Vibe - Fair-Trade Delivery Platform API",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main",
"migrate": "node dist/database/migrate",
"seed": "node dist/database/seed",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"test": "jest"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.3.0",
"@nestjs/throttler": "^5.1.1",
"@nestjs/websockets": "^10.3.0",
"axios": "^1.6.7",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.1",
"nodemailer": "^6.10.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.11.3",
"redis": "^4.6.12",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"socket.io": "^4.6.2",
"stripe": "^14.14.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/node": "^20.11.5",
"@types/nodemailer": "^7.0.11",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.11.0",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { RestaurantsModule } from './modules/restaurants/restaurants.module';
import { MenuModule } from './modules/menu/menu.module';
import { OrdersModule } from './modules/orders/orders.module';
import { DriversModule } from './modules/drivers/drivers.module';
import { ZonesModule } from './modules/zones/zones.module';
import { PaymentsModule } from './modules/payments/payments.module';
import { TrackingModule } from './modules/tracking/tracking.module';
import { AdminModule } from './modules/admin/admin.module';
import { EmailModule } from './modules/email/email.module';
import { ReviewsModule } from './modules/reviews/reviews.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: ['../../.env', '.env'] }),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
DatabaseModule,
EmailModule,
AuthModule,
UsersModule,
RestaurantsModule,
MenuModule,
OrdersModule,
DriversModule,
ZonesModule,
PaymentsModule,
TrackingModule,
AdminModule,
ReviewsModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,31 @@
import { Module, Global } from '@nestjs/common';
import { Pool } from 'pg';
import { ConfigService } from '@nestjs/config';
export const DB_POOL = 'DB_POOL';
@Global()
@Module({
providers: [
{
provide: DB_POOL,
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const pool = new Pool({
connectionString: config.get('DATABASE_URL'),
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('error', (err) => {
console.error('Unexpected PostgreSQL pool error', err);
});
return pool;
},
},
],
exports: [DB_POOL],
})
export class DatabaseModule {}

View File

@ -0,0 +1,37 @@
import { Injectable, Inject } from '@nestjs/common';
import { Pool, QueryResult } from 'pg';
import { DB_POOL } from './database.module';
@Injectable()
export class DatabaseService {
constructor(@Inject(DB_POOL) private readonly pool: Pool) {}
async query<T = any>(sql: string, params?: any[]): Promise<QueryResult<T>> {
return this.pool.query<T>(sql, params);
}
async queryOne<T = any>(sql: string, params?: any[]): Promise<T | null> {
const result = await this.pool.query<T>(sql, params);
return result.rows[0] ?? null;
}
async queryMany<T = any>(sql: string, params?: any[]): Promise<T[]> {
const result = await this.pool.query<T>(sql, params);
return result.rows;
}
async transaction<T>(fn: (client: any) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
}

View File

@ -0,0 +1,32 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
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
],
credentials: true,
});
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
const port = process.env.PORT || 3001;
await app.listen(port);
console.log(`The Vibe API running on port ${port}`);
}
bootstrap();

View File

@ -0,0 +1,54 @@
import { Controller, Get, Patch, Param, Query, Body, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard, Roles } from '../auth/guards/roles.guard';
import { AdminService } from './admin.service';
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get('stats')
getStats() {
return this.adminService.getPlatformStats();
}
@Get('revenue')
getRevenue(@Query('days') days?: string) {
return this.adminService.getRevenueByDay(days ? parseInt(days) : 30);
}
@Get('restaurants')
getRestaurants(@Query('page') page?: string, @Query('limit') limit?: string) {
return this.adminService.getRestaurants(page ? parseInt(page) : 1, limit ? parseInt(limit) : 20);
}
@Get('drivers')
getDrivers(@Query('page') page?: string, @Query('limit') limit?: string) {
return this.adminService.getDrivers(page ? parseInt(page) : 1, limit ? parseInt(limit) : 20);
}
@Patch('drivers/:id/approve')
approveDriver(@Param('id') id: string) {
return this.adminService.approveDriver(id);
}
@Patch('restaurants/:id/suspend')
suspendRestaurant(@Param('id') id: string) {
return this.adminService.suspendRestaurant(id);
}
@Get('zones')
getZones() {
return this.adminService.getZones();
}
@Patch('zones/:id/status')
updateZoneStatus(
@Param('id') id: string,
@Body('status') status: 'active' | 'coming_soon' | 'inactive',
) {
return this.adminService.updateZoneStatus(id, status);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { DatabaseService } from '../../database/database.service';
@Module({
controllers: [AdminController],
providers: [AdminService, DatabaseService],
})
export class AdminModule {}

View File

@ -0,0 +1,147 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
@Injectable()
export class AdminService {
constructor(private readonly db: DatabaseService) {}
async getPlatformStats() {
const [totals, today, zones] = await Promise.all([
// All-time totals
this.db.queryOne(`
SELECT
(SELECT COUNT(*) FROM restaurants WHERE is_active = TRUE) AS total_restaurants,
(SELECT COUNT(*) FROM drivers WHERE is_approved = TRUE) AS total_drivers,
(SELECT COUNT(*) FROM users WHERE role = 'customer') AS total_customers,
(SELECT COUNT(*) FROM orders WHERE status = 'delivered') AS total_orders_delivered,
(SELECT COALESCE(SUM(delivery_fee), 0) FROM orders WHERE status = 'delivered') AS total_delivery_revenue,
(SELECT COALESCE(SUM(daily_fee), 0) FROM driver_sessions WHERE fee_paid = TRUE) AS total_driver_fee_revenue,
(SELECT COALESCE(SUM(platform_fee), 0) FROM orders WHERE status = 'delivered') AS total_per_order_revenue
`),
// Today
this.db.queryOne(`
SELECT
COUNT(*) FILTER (WHERE status = 'delivered') AS orders_delivered_today,
COUNT(*) FILTER (WHERE status = 'pending' OR status = 'confirmed' OR status = 'preparing' OR status = 'driver_assigned') AS orders_active,
COALESCE(SUM(delivery_fee) FILTER (WHERE status = 'delivered'), 0) AS revenue_today,
(SELECT COUNT(*) FROM driver_sessions WHERE session_date = CURRENT_DATE AND status = 'active') AS drivers_online,
(SELECT COUNT(*) FROM driver_sessions WHERE session_date = CURRENT_DATE AND fee_paid = TRUE) AS drivers_paid_today
FROM orders
WHERE created_at >= CURRENT_DATE
`),
// Zone breakdown
this.db.queryMany(`
SELECT
z.name, z.slug, z.status,
COUNT(DISTINCT r.id) AS restaurant_count,
COUNT(DISTINCT dl.driver_id) FILTER (WHERE dl.is_online = TRUE) AS online_drivers,
COUNT(o.id) FILTER (WHERE o.created_at >= CURRENT_DATE) AS orders_today
FROM zones z
LEFT JOIN restaurants r ON r.zone_id = z.id AND r.is_active = TRUE
LEFT JOIN driver_locations dl ON ST_Within(dl.location::geometry, z.boundary::geometry)
LEFT JOIN orders o ON o.zone_id = z.id
GROUP BY z.id, z.name, z.slug, z.status
ORDER BY z.priority DESC
`),
]);
return { totals, today, zones };
}
async getRestaurants(page: number = 1, limit: number = 20) {
const offset = (page - 1) * limit;
const [restaurants, count] = await Promise.all([
this.db.queryMany(
`SELECT r.id, r.name, r.slug, r.is_active, r.subscription_status,
r.total_orders_platform, r.total_savings_vs_uber,
u.email AS owner_email, z.name AS zone_name
FROM restaurants r
JOIN users u ON u.id = r.owner_id
LEFT JOIN zones z ON z.id = r.zone_id
ORDER BY r.created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset],
),
this.db.queryOne(`SELECT COUNT(*) AS total FROM restaurants`),
]);
return { restaurants, total: count.total, page, limit };
}
async getDrivers(page: number = 1, limit: number = 20) {
const offset = (page - 1) * limit;
return this.db.queryMany(
`SELECT d.id, u.first_name, u.last_name, u.email, d.vehicle_type,
d.is_approved, d.total_deliveries, d.total_earnings,
dl.is_online, z.name AS zone_name
FROM drivers d
JOIN users u ON u.id = d.user_id
LEFT JOIN driver_locations dl ON dl.driver_id = d.id
LEFT JOIN zones z ON z.id = d.zone_id
ORDER BY d.created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset],
);
}
async approveDriver(driverId: string) {
return this.db.queryOne(
`UPDATE drivers SET is_approved = TRUE WHERE id = $1 RETURNING id`,
[driverId],
);
}
async suspendRestaurant(restaurantId: string) {
return this.db.queryOne(
`UPDATE restaurants SET is_active = FALSE WHERE id = $1 RETURNING id, name`,
[restaurantId],
);
}
async getZones() {
return this.db.queryMany(
`SELECT
z.id, z.name, z.slug, z.status, z.priority, z.radius_km,
ST_X(z.center::geometry) AS center_lng,
ST_Y(z.center::geometry) AS center_lat,
ST_AsGeoJSON(z.boundary) AS boundary_geojson,
COUNT(DISTINCT r.id) AS restaurant_count,
COUNT(DISTINCT d.id) FILTER (WHERE d.is_approved = TRUE) AS driver_count
FROM zones z
LEFT JOIN restaurants r ON r.zone_id = z.id AND r.is_active = TRUE
LEFT JOIN drivers d ON d.zone_id = z.id
GROUP BY z.id, z.name, z.slug, z.status, z.priority, z.radius_km, z.center, z.boundary
ORDER BY z.priority DESC`,
);
}
async updateZoneStatus(zoneId: string, status: 'active' | 'coming_soon' | 'inactive') {
return this.db.queryOne(
`UPDATE zones SET status = $2, updated_at = NOW()
WHERE id = $1 RETURNING id, name, status`,
[zoneId, status],
);
}
async getRevenueByDay(days: number = 30) {
return this.db.queryMany(
`SELECT
generate_series::DATE AS date,
COALESCE(SUM(o.delivery_fee), 0) AS delivery_revenue,
COALESCE(COUNT(o.id), 0) AS order_count,
COALESCE(SUM(ds.daily_fee) FILTER (WHERE ds.fee_paid = TRUE), 0) AS driver_fee_revenue
FROM generate_series(
CURRENT_DATE - INTERVAL '${days} days',
CURRENT_DATE,
'1 day'::interval
)
LEFT JOIN orders o
ON o.created_at::DATE = generate_series::DATE AND o.status = 'delivered'
LEFT JOIN driver_sessions ds
ON ds.session_date = generate_series::DATE
GROUP BY generate_series::DATE
ORDER BY date ASC`,
);
}
}

View File

@ -0,0 +1,39 @@
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';
class RegisterDto {
@IsEmail() email: string;
@IsString() @MinLength(8) password: string;
@IsString() firstName: string;
@IsString() lastName: string;
@IsOptional() @IsString() phone?: string;
@IsIn(['customer', 'driver', 'restaurant_owner']) role: string;
}
class LoginDto {
@IsEmail() email: string;
@IsString() password: string;
}
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto as any);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto.email, dto.password);
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@Request() req) {
return req.user;
}
}

View File

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { DatabaseModule } from '../../database/database.module';
import { DatabaseService } from '../../database/database.service';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET'),
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '7d') },
}),
}),
DatabaseModule,
],
providers: [AuthService, JwtStrategy, DatabaseService],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}

View File

@ -0,0 +1,85 @@
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { DatabaseService } from '../../database/database.service';
@Injectable()
export class AuthService {
constructor(
private readonly db: DatabaseService,
private readonly jwt: JwtService,
) {}
async register(dto: {
email: 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');
const passwordHash = await bcrypt.hash(dto.password, 12);
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],
);
// Create role-specific record
if (dto.role === 'driver') {
await this.db.query(
`INSERT INTO drivers (user_id) VALUES ($1)`,
[user.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
);
}
return { user, token: this.signToken(user) };
}
async login(email: string, password: string) {
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()],
);
if (!user) throw new UnauthorizedException('Invalid credentials');
if (!user.is_active) throw new UnauthorizedException('Account suspended');
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) };
}
async validateToken(payload: { sub: string; role: string }) {
return this.db.queryOne(
`SELECT u.id, u.email, u.first_name, u.last_name, u.role, u.is_active,
r.id AS "restaurantId",
d.id AS "driverId"
FROM users u
LEFT JOIN restaurants r ON r.owner_id = u.id AND u.role = 'restaurant_owner'
LEFT JOIN drivers d ON d.user_id = u.id AND u.role = 'driver'
WHERE u.id = $1 AND u.is_active = TRUE`,
[payload.sub],
);
}
private signToken(user: { id: string; role: string }) {
return this.jwt.sign({ sub: user.id, role: user.role });
}
}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@ -0,0 +1,26 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
export const Roles = (...roles: string[]) =>
(target: any, key?: string, descriptor?: any) => {
Reflect.defineMetadata('roles', roles, descriptor?.value ?? target);
return descriptor ?? target;
};
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.get<string[]>('roles', context.getHandler());
if (!roles || roles.length === 0) return true;
const { user } = context.switchToHttp().getRequest();
if (!user) throw new ForbiddenException();
if (!roles.includes(user.role)) {
throw new ForbiddenException(`Requires role: ${roles.join(' or ')}`);
}
return true;
}
}

View File

@ -0,0 +1,25 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get('JWT_SECRET'),
});
}
async validate(payload: { sub: string; role: string }) {
const user = await this.authService.validateToken(payload);
if (!user) throw new UnauthorizedException();
return user;
}
}

View File

@ -0,0 +1,129 @@
import { Injectable } from '@nestjs/common';
export interface DriverSession {
daily_fee: number;
delivery_revenue: number;
tips_earned: number;
deliveries_count: number;
net_earnings: number;
}
export interface BreakEvenStatus {
dailyFee: number;
deliveriesCompleted: number;
deliveryRevenue: number;
tipsEarned: number;
totalEarned: number; // revenue + tips
netEarnings: number; // totalEarned - dailyFee
remainingCost: number; // max(0, dailyFee - deliveryRevenue)
deliveriesToBreakEven: number; // deliveries still needed at $5 each
hasBreakEven: boolean;
profitAmount: number; // earnings above break-even
progressPercent: number; // 0-100 for progress bar
nextDeliveryIsProfit: boolean;
message: string;
}
// ============================================================
// DRIVER BREAK-EVEN ALGORITHM
//
// Daily fee: $20
// Delivery fee: $5 per delivery
// Break-even at: 4 deliveries ($20 / $5)
//
// After break-even: every delivery is pure profit
// Tips are ALWAYS profit (not counted toward break-even)
// ============================================================
@Injectable()
export class BreakEvenService {
private readonly DAILY_FEE = 20;
private readonly DELIVERY_FEE = 5;
calculate(session: DriverSession): BreakEvenStatus {
const dailyFee = Number(session.daily_fee) || this.DAILY_FEE;
const deliveryRevenue = Number(session.delivery_revenue) || 0;
const tipsEarned = Number(session.tips_earned) || 0;
const deliveriesCompleted = Number(session.deliveries_count) || 0;
const totalEarned = deliveryRevenue + tipsEarned;
const remainingCost = Math.max(0, dailyFee - deliveryRevenue);
const hasBreakEven = deliveryRevenue >= dailyFee;
const netEarnings = totalEarned - dailyFee;
const profitAmount = hasBreakEven ? netEarnings : 0;
// How many more deliveries at $5 to cover remaining cost
const deliveriesToBreakEven = hasBreakEven
? 0
: Math.ceil(remainingCost / this.DELIVERY_FEE);
// Progress: what % of the daily fee has been covered by deliveries
const progressPercent = Math.min(100, Math.round((deliveryRevenue / dailyFee) * 100));
// "Next delivery is profit" — one more delivery would break even
const nextDeliveryIsProfit =
!hasBreakEven && remainingCost <= this.DELIVERY_FEE;
const message = this.buildMessage(
hasBreakEven,
nextDeliveryIsProfit,
deliveriesToBreakEven,
remainingCost,
profitAmount,
deliveriesCompleted,
);
return {
dailyFee,
deliveriesCompleted,
deliveryRevenue,
tipsEarned,
totalEarned,
netEarnings,
remainingCost,
deliveriesToBreakEven,
hasBreakEven,
profitAmount,
progressPercent,
nextDeliveryIsProfit,
message,
};
}
private buildMessage(
hasBreakEven: boolean,
nextDeliveryIsProfit: boolean,
deliveriesToBreakEven: number,
remainingCost: number,
profitAmount: number,
deliveriesCompleted: number,
): string {
if (deliveriesCompleted === 0) {
return `Complete 4 deliveries to cover your $${this.DAILY_FEE} day fee. Everything after is yours.`;
}
if (hasBreakEven) {
return `You've broken even! Every delivery now is pure profit. You're up $${profitAmount.toFixed(2)} today.`;
}
if (nextDeliveryIsProfit) {
return `Next delivery = profit! Just $${remainingCost.toFixed(2)} left to cover.`;
}
return `${deliveriesToBreakEven} more ${deliveriesToBreakEven === 1 ? 'delivery' : 'deliveries'} to break even. $${remainingCost.toFixed(2)} remaining.`;
}
// Batch calculate for multiple sessions (analytics)
calculateBatch(sessions: DriverSession[]): BreakEvenStatus[] {
return sessions.map((s) => this.calculate(s));
}
// Earnings projection given target deliveries
projectEarnings(targetDeliveries: number, avgTipPerDelivery: number = 2): {
grossRevenue: number;
netAfterFee: number;
totalWithTips: number;
} {
const grossRevenue = targetDeliveries * this.DELIVERY_FEE;
const netAfterFee = grossRevenue - this.DAILY_FEE;
const totalWithTips = netAfterFee + targetDeliveries * avgTipPerDelivery;
return { grossRevenue, netAfterFee, totalWithTips };
}
}

View File

@ -0,0 +1,82 @@
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards, Request } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard, Roles } from '../auth/guards/roles.guard';
import { DriversService } from './drivers.service';
class UpdateLocationDto {
lng: number;
lat: number;
heading?: number;
speedKmh?: number;
}
@Controller('drivers')
@UseGuards(JwtAuthGuard, RolesGuard)
export class DriversController {
constructor(private readonly driversService: DriversService) {}
// ---- Driver self-service endpoints ----
@Get('me/profile')
@Roles('driver')
getProfile(@Request() req) {
return this.driversService.getProfile(req.user.driverId);
}
@Get('me/session')
@Roles('driver')
getSession(@Request() req) {
return this.driversService.getSession(req.user.driverId);
}
@Post('me/session/start')
@Roles('driver')
startSession(@Request() req) {
return this.driversService.startSession(req.user.driverId);
}
@Post('me/session/end')
@Roles('driver')
endSession(@Request() req) {
return this.driversService.endSession(req.user.driverId);
}
@Get('me/earnings')
@Roles('driver')
getEarnings(@Request() req, @Query('days') days?: string) {
return this.driversService.getEarningsHistory(req.user.driverId, days ? parseInt(days) : 30);
}
@Patch('me/location')
@Roles('driver')
updateLocation(@Request() req, @Body() dto: UpdateLocationDto) {
return this.driversService.updateLocation(
req.user.driverId,
dto.lng,
dto.lat,
dto.heading,
dto.speedKmh,
);
}
@Patch('me/availability')
@Roles('driver')
setAvailability(@Request() req, @Body('available') available: boolean) {
return this.driversService.setAvailability(req.user.driverId, available);
}
// ---- Public endpoints ----
@Get('nearby')
getNearbyDrivers(
@Query('lng') lng: string,
@Query('lat') lat: string,
@Query('radius') radius?: string,
) {
return this.driversService.getNearbyDrivers(
parseFloat(lng),
parseFloat(lat),
radius ? parseFloat(radius) : 5,
);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DriversController } from './drivers.controller';
import { DriversService } from './drivers.service';
import { BreakEvenService } from './breakeven.service';
import { DatabaseService } from '../../database/database.service';
@Module({
controllers: [DriversController],
providers: [DriversService, BreakEvenService, DatabaseService],
exports: [DriversService, BreakEvenService],
})
export class DriversModule {}

View File

@ -0,0 +1,174 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { BreakEvenService } from './breakeven.service';
@Injectable()
export class DriversService {
constructor(
private readonly db: DatabaseService,
private readonly breakEven: BreakEvenService,
) {}
// ============================================================
// DRIVER SESSION - Daily Login Flow
// ============================================================
async startSession(driverId: string): Promise<any> {
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`,
[driverId],
);
if (!driver) throw new NotFoundException('Driver not found or not approved');
// Check if session already exists today
const existing = await this.db.queryOne(
`SELECT * FROM driver_sessions
WHERE driver_id = $1 AND session_date = CURRENT_DATE`,
[driverId],
);
if (existing) return { session: existing, breakEven: this.breakEven.calculate(existing) };
// Create new session (fee charged separately via Stripe)
const session = await this.db.queryOne(
`INSERT INTO driver_sessions (driver_id, daily_fee)
VALUES ($1, $2)
RETURNING *`,
[driverId, 20.00],
);
// Mark driver as online
await this.db.query(
`UPDATE driver_locations SET is_online = TRUE, is_available = TRUE, updated_at = NOW()
WHERE driver_id = $1`,
[driverId],
);
return { session, breakEven: this.breakEven.calculate(session) };
}
async endSession(driverId: string): Promise<any> {
const session = await this.db.queryOne(
`UPDATE driver_sessions
SET logout_at = NOW(), status = 'inactive'
WHERE driver_id = $1 AND session_date = CURRENT_DATE AND status = 'active'
RETURNING *`,
[driverId],
);
await this.db.query(
`UPDATE driver_locations SET is_online = FALSE, is_available = FALSE WHERE driver_id = $1`,
[driverId],
);
return { session, breakEven: session ? this.breakEven.calculate(session) : null };
}
async getSession(driverId: string): Promise<any> {
const session = await this.db.queryOne(
`SELECT * FROM driver_sessions
WHERE driver_id = $1 AND session_date = CURRENT_DATE`,
[driverId],
);
if (!session) return { session: null, breakEven: null };
const earnings = await this.db.queryMany(
`SELECT de.*, o.order_number, o.delivered_at
FROM driver_earnings de
JOIN orders o ON o.id = de.order_id
WHERE de.session_id = $1
ORDER BY de.created_at DESC`,
[session.id],
);
return {
session,
breakEven: this.breakEven.calculate(session),
earnings,
};
}
// ============================================================
// DRIVER PROFILE & EARNINGS
// ============================================================
async getProfile(driverId: string) {
return this.db.queryOne(
`SELECT d.*, u.first_name, u.last_name, u.email, u.phone, u.avatar_url,
z.name AS zone_name
FROM drivers d
JOIN users u ON u.id = d.user_id
LEFT JOIN zones z ON z.id = d.zone_id
WHERE d.id = $1`,
[driverId],
);
}
async getEarningsHistory(driverId: string, days: number = 30) {
return this.db.queryMany(
`SELECT
ds.session_date,
ds.deliveries_count,
ds.delivery_revenue,
ds.tips_earned,
ds.net_earnings,
ds.daily_fee,
ds.fee_paid
FROM driver_sessions ds
WHERE ds.driver_id = $1
AND ds.session_date >= CURRENT_DATE - INTERVAL '${days} days'
ORDER BY ds.session_date DESC`,
[driverId],
);
}
async updateLocation(
driverId: string,
lng: number,
lat: number,
heading?: number,
speedKmh?: number,
) {
return this.db.query(
`INSERT INTO driver_locations (driver_id, location, heading, speed_kmh, updated_at)
VALUES ($1, ST_GeographyFromText($2), $3, $4, NOW())
ON CONFLICT (driver_id) DO UPDATE
SET location = EXCLUDED.location,
heading = EXCLUDED.heading,
speed_kmh = EXCLUDED.speed_kmh,
updated_at = NOW()`,
[`POINT(${lng} ${lat})`, driverId, heading, speedKmh],
);
}
async setAvailability(driverId: string, isAvailable: boolean) {
return this.db.query(
`UPDATE driver_locations SET is_available = $2 WHERE driver_id = $1`,
[driverId, isAvailable],
);
}
async getNearbyDrivers(lng: number, lat: number, radiusKm: number = 5) {
return this.db.queryMany(
`SELECT
dl.driver_id,
u.first_name,
d.vehicle_type,
d.rating,
ST_Distance(dl.location, ST_GeographyFromText($1)) / 1000 AS distance_km,
ST_X(dl.location::geometry) AS lng,
ST_Y(dl.location::geometry) AS lat
FROM driver_locations dl
JOIN drivers d ON d.id = dl.driver_id
JOIN users u ON u.id = d.user_id
WHERE dl.is_online = TRUE
AND dl.is_available = TRUE
AND ST_DWithin(dl.location, ST_GeographyFromText($1), $2 * 1000)
ORDER BY distance_km ASC
LIMIT 20`,
[`POINT(${lng} ${lat})`, radiusKm],
);
}
}

View File

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { EmailService } from './email.service';
@Global()
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View File

@ -0,0 +1,174 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private transporter: nodemailer.Transporter;
private from: string;
constructor(private readonly config: ConfigService) {
this.from = config.get('EMAIL_FROM', '"The Vibe" <noreply@thevibe.ca>');
this.transporter = nodemailer.createTransport({
host: config.get('SMTP_HOST', 'smtp.mailtrap.io'),
port: parseInt(config.get('SMTP_PORT', '587')),
auth: {
user: config.get('SMTP_USER', ''),
pass: config.get('SMTP_PASS', ''),
},
});
}
private async send(to: string, subject: string, html: string) {
if (!this.config.get('SMTP_USER')) {
this.logger.debug(`[Email skipped - no SMTP] To: ${to} | Subject: ${subject}`);
return;
}
try {
await this.transporter.sendMail({ from: this.from, to, subject, html });
} catch (err) {
this.logger.error(`Failed to send email to ${to}: ${err.message}`);
}
}
async sendOrderConfirmation(to: string, data: {
customerName: string;
orderNumber: string;
restaurantName: string;
items: Array<{ name: string; quantity: number; price: number }>;
subtotal: number;
deliveryFee: number;
total: number;
}) {
const itemRows = data.items
.map(i => `<tr><td>${i.name}</td><td>×${i.quantity}</td><td>$${(i.price * i.quantity).toFixed(2)}</td></tr>`)
.join('');
await this.send(to, `Order Confirmed #${data.orderNumber}`, `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto">
<div style="background:#0d9488;padding:24px;text-align:center">
<h1 style="color:white;margin:0">Order Confirmed!</h1>
</div>
<div style="padding:24px">
<p>Hi ${data.customerName}, your order from <strong>${data.restaurantName}</strong> is confirmed.</p>
<table style="width:100%;border-collapse:collapse">
<thead><tr style="border-bottom:2px solid #eee">
<th align="left">Item</th><th align="left">Qty</th><th align="left">Price</th>
</tr></thead>
<tbody>${itemRows}</tbody>
</table>
<div style="margin-top:16px;padding-top:16px;border-top:2px solid #eee">
<p>Subtotal: <strong>$${data.subtotal.toFixed(2)}</strong></p>
<p>Delivery: <strong>$${data.deliveryFee.toFixed(2)}</strong></p>
<p style="font-size:18px">Total: <strong>$${data.total.toFixed(2)}</strong></p>
</div>
<p style="color:#666;font-size:12px">
Flat $5 delivery. No hidden commissions. The restaurant keeps what they earn.
</p>
</div>
</div>
`);
}
async sendDriverAssigned(to: string, data: {
customerName: string;
orderNumber: string;
driverName: string;
vehicleType: string;
estimatedDeliveryAt: string;
}) {
await this.send(to, `Driver on the way Order #${data.orderNumber}`, `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto">
<div style="background:#0d9488;padding:24px;text-align:center">
<h1 style="color:white;margin:0">Your driver is on the way!</h1>
</div>
<div style="padding:24px">
<p>Hi ${data.customerName},</p>
<p><strong>${data.driverName}</strong> (${data.vehicleType}) has picked up your order #${data.orderNumber}.</p>
<p>Estimated delivery: <strong>${data.estimatedDeliveryAt}</strong></p>
<p>Track your order in real-time in the app.</p>
</div>
</div>
`);
}
async sendOrderDelivered(to: string, data: {
customerName: string;
orderNumber: string;
restaurantName: string;
orderId: string;
}) {
await this.send(to, `Delivered! Leave a review Order #${data.orderNumber}`, `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto">
<div style="background:#0d9488;padding:24px;text-align:center">
<h1 style="color:white;margin:0">Enjoy your meal!</h1>
</div>
<div style="padding:24px">
<p>Hi ${data.customerName}, your order from <strong>${data.restaurantName}</strong> has been delivered.</p>
<p>How was it? Leave a quick review to help the restaurant and driver.</p>
<a href="${this.config.get('FRONTEND_URL', 'http://localhost:3000')}/orders/${data.orderId}/review"
style="display:inline-block;background:#0d9488;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;margin-top:16px">
Leave a Review
</a>
</div>
</div>
`);
}
async sendRestaurantWelcome(to: string, data: {
ownerName: string;
restaurantName: string;
trialEndsAt: string;
}) {
await this.send(to, `Welcome to The Vibe ${data.restaurantName}`, `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto">
<div style="background:#0d9488;padding:24px;text-align:center">
<h1 style="color:white;margin:0">Welcome to The Vibe!</h1>
</div>
<div style="padding:24px">
<p>Hi ${data.ownerName},</p>
<p>Your restaurant <strong>${data.restaurantName}</strong> is now live on The Vibe.</p>
<h3>Your 14-day free trial runs until ${data.trialEndsAt}</h3>
<ul>
<li>Flat $500/month after trial no commissions</li>
<li>You keep 100% of food revenue</li>
<li>We only charge $0.10 per order + Stripe's 2.9%+$0.30 CC fee</li>
</ul>
<a href="${this.config.get('FRONTEND_URL', 'http://localhost:3000')}/restaurant/dashboard"
style="display:inline-block;background:#0d9488;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;margin-top:16px">
Go to Dashboard
</a>
</div>
</div>
`);
}
async sendNewOrderToRestaurant(to: string, data: {
restaurantName: string;
orderNumber: string;
items: Array<{ name: string; quantity: number }>;
subtotal: number;
specialInstructions?: string;
}) {
const itemRows = data.items.map(i => `<li>${i.quantity}× ${i.name}</li>`).join('');
await this.send(to, `New Order #${data.orderNumber} Action Required`, `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto">
<div style="background:#f59e0b;padding:24px;text-align:center">
<h1 style="color:white;margin:0">New Order!</h1>
</div>
<div style="padding:24px">
<p>New order #${data.orderNumber} for <strong>${data.restaurantName}</strong>.</p>
<ul>${itemRows}</ul>
<p>Subtotal: <strong>$${data.subtotal.toFixed(2)}</strong></p>
${data.specialInstructions ? `<p>Notes: <em>${data.specialInstructions}</em></p>` : ''}
<a href="${this.config.get('FRONTEND_URL', 'http://localhost:3000')}/restaurant/dashboard"
style="display:inline-block;background:#f59e0b;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;margin-top:16px">
Confirm Order
</a>
</div>
</div>
`);
}
}

View File

@ -0,0 +1,49 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard, Roles } from '../auth/guards/roles.guard';
import { MenuService } from './menu.service';
@Controller('menu')
export class MenuController {
constructor(private readonly menuService: MenuService) {}
@Get(':restaurantId')
getMenu(@Param('restaurantId') restaurantId: string) {
return this.menuService.getMenu(restaurantId);
}
@Post('items')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
addItem(@Request() req, @Body() dto: any) {
return this.menuService.addItem(req.user.restaurantId, dto);
}
@Patch('items/:id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
updateItem(@Param('id') id: string, @Request() req, @Body() dto: any) {
return this.menuService.updateItem(id, req.user.restaurantId, dto);
}
@Patch('items/:id/toggle')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
toggle(@Param('id') id: string, @Request() req) {
return this.menuService.toggleAvailability(id, req.user.restaurantId);
}
@Delete('items/:id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
delete(@Param('id') id: string, @Request() req) {
return this.menuService.deleteItem(id, req.user.restaurantId);
}
@Post('categories')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
addCategory(@Request() req, @Body() dto: { name: string; sortOrder?: number }) {
return this.menuService.addCategory(req.user.restaurantId, dto.name, dto.sortOrder);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MenuController } from './menu.controller';
import { MenuService } from './menu.service';
import { DatabaseService } from '../../database/database.service';
@Module({
controllers: [MenuController],
providers: [MenuService, DatabaseService],
exports: [MenuService],
})
export class MenuModule {}

View File

@ -0,0 +1,73 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
@Injectable()
export class MenuService {
constructor(private readonly db: DatabaseService) {}
async getMenu(restaurantId: string) {
return this.db.queryMany(
`SELECT mc.id, mc.name AS category, mc.sort_order,
json_agg(
json_build_object(
'id', mi.id, 'name', mi.name, 'description', mi.description,
'price', mi.price, 'image_url', mi.image_url,
'dietary_tags', mi.dietary_tags, 'is_available', mi.is_available,
'is_featured', mi.is_featured
) ORDER BY mi.sort_order
) FILTER (WHERE mi.id IS NOT NULL) AS items
FROM menu_categories mc
LEFT JOIN menu_items mi ON mi.category_id = mc.id
WHERE mc.restaurant_id = $1 AND mc.is_active = TRUE
GROUP BY mc.id
ORDER BY mc.sort_order`,
[restaurantId],
);
}
async addItem(restaurantId: string, dto: any) {
return this.db.queryOne(
`INSERT INTO menu_items (restaurant_id, category_id, name, description, price, image_url, dietary_tags, allergens, prep_time_min, calories)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[restaurantId, dto.categoryId, dto.name, dto.description, dto.price, dto.imageUrl, dto.dietaryTags, dto.allergens, dto.prepTimeMin, dto.calories],
);
}
async updateItem(itemId: string, restaurantId: string, dto: any) {
const sets = Object.entries(dto)
.filter(([, v]) => v !== undefined)
.map(([k, v], i) => `${k} = $${i + 3}`)
.join(', ');
const vals = Object.values(dto).filter((v) => v !== undefined);
return this.db.queryOne(
`UPDATE menu_items SET ${sets}, updated_at = NOW()
WHERE id = $1 AND restaurant_id = $2 RETURNING *`,
[itemId, restaurantId, ...vals],
);
}
async toggleAvailability(itemId: string, restaurantId: string) {
return this.db.queryOne(
`UPDATE menu_items SET is_available = NOT is_available
WHERE id = $1 AND restaurant_id = $2
RETURNING id, name, is_available`,
[itemId, restaurantId],
);
}
async deleteItem(itemId: string, restaurantId: string) {
return this.db.query(
`DELETE FROM menu_items WHERE id = $1 AND restaurant_id = $2`,
[itemId, restaurantId],
);
}
async addCategory(restaurantId: string, name: string, sortOrder?: number) {
return this.db.queryOne(
`INSERT INTO menu_categories (restaurant_id, name, sort_order) VALUES ($1, $2, $3) RETURNING *`,
[restaurantId, name, sortOrder ?? 0],
);
}
}

View File

@ -0,0 +1,72 @@
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards, Request } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard, Roles } from '../auth/guards/roles.guard';
import { OrdersService } from './orders.service';
@Controller('orders')
@UseGuards(JwtAuthGuard)
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@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);
}
@Get(':id')
getOrder(@Param('id') id: string) {
return this.ordersService.getOrderById(id);
}
// Restaurant actions
@Patch(':id/confirm')
@Roles('restaurant_owner')
confirm(@Param('id') id: string, @Request() req) {
return this.ordersService.confirmOrder(id, req.user.restaurantId);
}
@Patch(':id/preparing')
@Roles('restaurant_owner')
preparing(@Param('id') id: string, @Request() req) {
return this.ordersService.markPreparing(id, req.user.restaurantId);
}
@Patch(':id/ready')
@Roles('restaurant_owner')
ready(@Param('id') id: string, @Request() req) {
return this.ordersService.markReadyForPickup(id, req.user.restaurantId);
}
// 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);
}
// Driver actions
@Patch(':id/pickup')
@Roles('driver')
pickup(@Param('id') id: string, @Request() req) {
return this.ordersService.markPickedUp(id, req.user.driverId);
}
@Patch(':id/delivered')
@Roles('driver')
delivered(@Param('id') id: string, @Request() req) {
return this.ordersService.markDelivered(id, req.user.driverId);
}
@Patch(':id/cancel')
cancel(@Param('id') id: string, @Request() req, @Body('reason') reason: string) {
return this.ordersService.cancelOrder(id, req.user.id, reason);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { DatabaseService } from '../../database/database.service';
@Module({
controllers: [OrdersController],
providers: [OrdersService, DatabaseService],
exports: [OrdersService],
})
export class OrdersModule {}

View File

@ -0,0 +1,379 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { EmailService } from '../email/email.service';
const DELIVERY_FEE = 5.00;
const PLATFORM_PER_ORDER_FEE = 0.10;
const CC_RATE = 0.029;
const CC_FIXED = 0.30;
const UBER_COMMISSION_RATE = 0.30; // 30% - used for savings display
@Injectable()
export class OrdersService {
constructor(
private readonly db: DatabaseService,
private readonly email: EmailService,
) {}
// ============================================================
// PLACE ORDER
// ============================================================
async placeOrder(customerId: string, dto: {
restaurantId: string;
items: Array<{ menuItemId: string; quantity: number; specialRequest?: string }>;
deliveryAddress: string;
deliveryLng: number;
deliveryLat: number;
tipAmount?: number;
specialInstructions?: string;
}) {
const result = await this.db.transaction(async (client) => {
// 1. Validate restaurant
const restaurant = await client.query(
`SELECT id, name, location, is_active, accepts_orders, email
FROM restaurants WHERE id = $1`,
[dto.restaurantId],
);
const rest = restaurant.rows[0];
if (!rest?.is_active || !rest?.accepts_orders) {
throw new BadRequestException('Restaurant is not accepting orders');
}
// 2. Validate delivery location is in an active zone
const zoneResult = await client.query(
`SELECT check_delivery_in_zone(ST_GeographyFromText($1)) AS zone_id`,
[`POINT(${dto.deliveryLng} ${dto.deliveryLat})`],
);
const zoneId = zoneResult.rows[0]?.zone_id;
if (!zoneId) {
throw new BadRequestException('Delivery address is outside our current service area (GTA)');
}
// 3. Fetch and validate menu items
const itemIds = dto.items.map((i) => i.menuItemId);
const menuItems = await client.query(
`SELECT id, name, price, is_available, restaurant_id
FROM menu_items WHERE id = ANY($1::uuid[])`,
[itemIds],
);
const menuMap = new Map<string, any>(menuItems.rows.map((m) => [m.id, m]));
let subtotal = 0;
const orderItems = [];
for (const item of dto.items) {
const menuItem: any = menuMap.get(item.menuItemId);
if (!menuItem) throw new BadRequestException(`Menu item not found: ${item.menuItemId}`);
if (menuItem.restaurant_id !== dto.restaurantId) {
throw new BadRequestException('Menu item does not belong to this restaurant');
}
if (!menuItem.is_available) {
throw new BadRequestException(`${menuItem.name} is currently unavailable`);
}
const itemSubtotal = menuItem.price * item.quantity;
subtotal += itemSubtotal;
orderItems.push({ ...item, name: menuItem.name, price: menuItem.price, subtotal: itemSubtotal });
}
// 4. Calculate all fees (fully transparent)
const tipAmount = dto.tipAmount || 0;
const ccFee = Math.round((subtotal + DELIVERY_FEE + tipAmount) * CC_RATE * 100 + CC_FIXED * 100) / 100;
const totalCustomerPays = subtotal + DELIVERY_FEE + tipAmount + ccFee;
const restaurantReceives = subtotal - PLATFORM_PER_ORDER_FEE;
const driverReceives = DELIVERY_FEE + tipAmount;
// 5. Savings calculation (vs Uber Eats 30%)
const uberEquivalentFee = subtotal * UBER_COMMISSION_RATE;
const restaurantSavings = uberEquivalentFee - PLATFORM_PER_ORDER_FEE;
// 6. Create order
const orderResult = await client.query(
`INSERT INTO orders (
customer_id, restaurant_id, zone_id,
delivery_address, delivery_location, restaurant_location,
subtotal, delivery_fee, tip_amount,
platform_fee, cc_processing_fee,
total_customer_pays, restaurant_receives, driver_receives,
uber_equivalent_fee, restaurant_savings,
special_instructions, status
) VALUES (
$1, $2, $3,
$4, ST_GeographyFromText($5), $6,
$7, $8, $9,
$10, $11,
$12, $13, $14,
$15, $16,
$17, 'pending'
) RETURNING *`,
[
customerId, dto.restaurantId, zoneId,
dto.deliveryAddress, `POINT(${dto.deliveryLng} ${dto.deliveryLat})`, rest.location,
subtotal, DELIVERY_FEE, tipAmount,
PLATFORM_PER_ORDER_FEE, ccFee,
totalCustomerPays, restaurantReceives, driverReceives,
uberEquivalentFee, restaurantSavings,
dto.specialInstructions,
],
);
const order = orderResult.rows[0];
// 7. Insert order items
for (const item of orderItems) {
await client.query(
`INSERT INTO order_items (order_id, menu_item_id, name, price, quantity, subtotal, special_request)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[order.id, item.menuItemId, item.name, item.price, item.quantity, item.subtotal, item.specialRequest],
);
}
// 8. Fetch customer email for notification
const customerResult = await client.query(
`SELECT email, first_name FROM users WHERE id = $1`,
[customerId],
);
const customer = customerResult.rows[0];
return {
order,
customer,
rest,
orderItems,
breakdown: {
subtotal,
deliveryFee: DELIVERY_FEE,
tipAmount,
creditCardFee: ccFee,
totalCustomerPays,
restaurantReceives,
driverReceives,
savings: {
uberWouldCharge: uberEquivalentFee,
restaurantSaves: restaurantSavings,
message: `Restaurant saves $${restaurantSavings.toFixed(2)} vs UberEats`,
},
},
};
});
// Send emails (outside transaction - non-critical)
setImmediate(async () => {
try {
await this.email.sendOrderConfirmation(result.customer.email, {
customerName: result.customer.first_name,
orderNumber: result.order.order_number,
restaurantName: result.rest.name,
items: result.orderItems.map(i => ({ name: i.name, quantity: i.quantity, price: i.price })),
subtotal: result.breakdown.subtotal,
deliveryFee: result.breakdown.deliveryFee,
total: result.breakdown.totalCustomerPays,
});
if (result.rest.email) {
await this.email.sendNewOrderToRestaurant(result.rest.email, {
restaurantName: result.rest.name,
orderNumber: result.order.order_number,
items: result.orderItems.map(i => ({ name: i.name, quantity: i.quantity })),
subtotal: result.breakdown.subtotal,
specialInstructions: result.order.special_instructions,
});
}
} catch { /* email failure is non-fatal */ }
});
return { order: result.order, breakdown: result.breakdown };
}
// ============================================================
// ORDER LIFECYCLE
// ============================================================
async confirmOrder(orderId: string, restaurantId: string) {
return 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],
);
}
async markPreparing(orderId: string, restaurantId: string) {
return this.db.queryOne(
`UPDATE orders SET status = 'preparing' WHERE id = $1 AND restaurant_id = $2 RETURNING *`,
[orderId, restaurantId],
);
}
async markReadyForPickup(orderId: string, restaurantId: string) {
return this.db.queryOne(
`UPDATE orders SET status = 'ready_for_pickup' WHERE id = $1 AND restaurant_id = $2 RETURNING *`,
[orderId, restaurantId],
);
}
async assignDriver(orderId: string, driverId: string) {
return this.db.transaction(async (client) => {
const order = 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'
RETURNING *`,
[orderId, driverId],
);
// Mark driver as unavailable
await client.query(
`UPDATE driver_locations SET is_available = FALSE WHERE driver_id = $1`,
[driverId],
);
return order.rows[0];
});
}
async markPickedUp(orderId: string, driverId: string) {
return 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],
);
}
async markDelivered(orderId: string, driverId: string) {
const order = await this.db.transaction(async (client) => {
const orderResult = await client.query(
`UPDATE orders SET status = 'delivered', delivered_at = NOW()
WHERE id = $1 AND driver_id = $2 AND status = 'picked_up'
RETURNING *`,
[orderId, driverId],
);
const o = orderResult.rows[0];
if (!o) throw new NotFoundException('Order not found or cannot be marked delivered');
// Get driver's current session
const session = await client.query(
`SELECT * FROM driver_sessions WHERE driver_id = $1 AND session_date = CURRENT_DATE`,
[driverId],
);
const sess = session.rows[0];
if (sess) {
// Record driver earning
await client.query(
`INSERT INTO driver_earnings (driver_id, session_id, order_id, delivery_fee, tip_amount, total)
VALUES ($1, $2, $3, $4, $5, $6)`,
[driverId, sess.id, orderId, o.driver_receives - o.tip_amount, o.tip_amount, o.driver_receives],
);
}
// Mark driver available again
await client.query(
`UPDATE driver_locations SET is_available = TRUE WHERE driver_id = $1`,
[driverId],
);
return o;
});
// Send delivered email (non-critical)
setImmediate(async () => {
try {
const info = await this.db.queryOne(
`SELECT u.email, u.first_name, r.name AS restaurant_name
FROM orders o
JOIN users u ON u.id = o.customer_id
JOIN restaurants r ON r.id = o.restaurant_id
WHERE o.id = $1`,
[orderId],
);
if (info) {
await this.email.sendOrderDelivered(info.email, {
customerName: info.first_name,
orderNumber: order.order_number,
restaurantName: info.restaurant_name,
orderId,
});
}
} catch { /* email failure is non-fatal */ }
});
return order;
}
async cancelOrder(orderId: string, userId: string, reason: string) {
return this.db.queryOne(
`UPDATE orders
SET status = 'cancelled', cancelled_at = NOW(), cancellation_reason = $3
WHERE id = $1
AND (customer_id = $2 OR restaurant_id IN (
SELECT id FROM restaurants WHERE owner_id = $2
))
AND status IN ('pending', 'confirmed')
RETURNING *`,
[orderId, userId, reason],
);
}
// ============================================================
// QUERIES
// ============================================================
async getOrderById(orderId: string) {
const order = await this.db.queryOne(
`SELECT o.*,
json_build_object(
'id', r.id, 'name', r.name, 'address', r.address, 'phone', r.phone
) AS restaurant,
json_build_object(
'id', u.id, 'firstName', u.first_name, 'lastName', u.last_name
) AS customer,
CASE WHEN o.driver_id IS NOT NULL THEN json_build_object(
'id', d.id, 'firstName', du.first_name, 'vehicleType', d.vehicle_type
) END AS driver
FROM orders o
JOIN restaurants r ON r.id = o.restaurant_id
JOIN users u ON u.id = o.customer_id
LEFT JOIN drivers d ON d.id = o.driver_id
LEFT JOIN users du ON du.id = d.user_id
WHERE o.id = $1`,
[orderId],
);
if (!order) throw new NotFoundException('Order not found');
const items = await this.db.queryMany(
`SELECT * FROM order_items WHERE order_id = $1`,
[orderId],
);
return { ...order, items };
}
async getCustomerOrders(customerId: string, status?: string) {
const where = status ? `AND o.status = '${status}'` : '';
return this.db.queryMany(
`SELECT o.id, o.order_number, o.status, o.total_customer_pays,
o.created_at, o.delivered_at,
r.name AS restaurant_name, r.logo_url
FROM orders o
JOIN restaurants r ON r.id = o.restaurant_id
WHERE o.customer_id = $1 ${where}
ORDER BY o.created_at DESC
LIMIT 50`,
[customerId],
);
}
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
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.restaurant_id = $1 ${where}
GROUP BY o.id
ORDER BY o.created_at DESC`,
[restaurantId],
);
}
}

View File

@ -0,0 +1,56 @@
import { Controller, Post, Body, Param, Headers, RawBodyRequest, Req, UseGuards, Request } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard, Roles } from '../auth/guards/roles.guard';
import { PaymentsService } from './payments.service';
@Controller('payments')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
// Stripe webhook (no auth - verified by signature)
@Post('webhook')
webhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') sig: string,
) {
return this.paymentsService.handleWebhook(req.rawBody, sig);
}
// Restaurant subscription
@Post('restaurant/subscribe')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
subscribe(@Request() req, @Body('paymentMethodId') pmId: string) {
return this.paymentsService.createRestaurantSubscription(req.user.restaurantId, pmId);
}
@Post('restaurant/cancel')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
cancelSubscription(@Request() req) {
return this.paymentsService.cancelRestaurantSubscription(req.user.restaurantId);
}
// Driver daily fee
@Post('driver/daily-fee')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('driver')
chargeDailyFee(@Request() req) {
return this.paymentsService.chargeDriverDailyFee(req.user.driverId);
}
@Post('driver/payment-method')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('driver')
savePaymentMethod(@Request() req, @Body('paymentMethodId') pmId: string) {
return this.paymentsService.saveDriverPaymentMethod(req.user.driverId, pmId);
}
// Customer order payment
@Post('orders/:orderId/intent')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('customer')
createIntent(@Param('orderId') orderId: string, @Request() req) {
return this.paymentsService.createOrderPaymentIntent(orderId, req.user.id);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { DatabaseService } from '../../database/database.service';
@Module({
controllers: [PaymentsController],
providers: [PaymentsService, DatabaseService],
exports: [PaymentsService],
})
export class PaymentsModule {}

View File

@ -0,0 +1,265 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
import { DatabaseService } from '../../database/database.service';
@Injectable()
export class PaymentsService {
private stripe: Stripe;
constructor(
private readonly config: ConfigService,
private readonly db: DatabaseService,
) {
this.stripe = new Stripe(config.get('STRIPE_SECRET_KEY'), {
apiVersion: '2023-10-16',
});
}
// ============================================================
// RESTAURANT - Monthly Subscription ($500/month)
// ============================================================
async createRestaurantSubscription(restaurantId: string, paymentMethodId: string) {
const restaurant = await this.db.queryOne(
`SELECT r.*, u.email, u.first_name, u.last_name
FROM restaurants r JOIN users u ON u.id = r.owner_id
WHERE r.id = $1`,
[restaurantId],
);
// Create or retrieve Stripe customer
let customerId = restaurant.stripe_customer_id;
if (!customerId) {
const customer = await this.stripe.customers.create({
email: restaurant.email || restaurant.owner_email,
name: restaurant.name,
metadata: { restaurantId, platform: 'the-vibe' },
});
customerId = customer.id;
await this.db.query(
`UPDATE restaurants SET stripe_customer_id = $2 WHERE id = $1`,
[restaurantId, customerId],
);
}
// Attach payment method
await this.stripe.paymentMethods.attach(paymentMethodId, { customer: customerId });
await this.stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
// Create subscription with 14-day free trial
const subscription = await this.stripe.subscriptions.create({
customer: customerId,
items: [{ price: this.config.get('STRIPE_RESTAURANT_PRICE_ID') }],
trial_period_days: 14,
metadata: { restaurantId },
});
// Store subscription record
await this.db.query(
`INSERT INTO restaurant_subscriptions
(restaurant_id, stripe_subscription_id, stripe_customer_id, status, current_period_start, current_period_end, trial_ends_at)
VALUES ($1, $2, $3, $4, to_timestamp($5), to_timestamp($6), to_timestamp($7))
ON CONFLICT (stripe_subscription_id) DO UPDATE
SET status = $4`,
[
restaurantId,
subscription.id,
customerId,
subscription.status,
subscription.current_period_start,
subscription.current_period_end,
subscription.trial_end,
],
);
await this.db.query(
`UPDATE restaurants SET subscription_id = $2, subscription_status = $3 WHERE id = $1`,
[restaurantId, subscription.id, subscription.status],
);
return { subscription, trialEnds: new Date(subscription.trial_end * 1000) };
}
async cancelRestaurantSubscription(restaurantId: string) {
const sub = await this.db.queryOne(
`SELECT stripe_subscription_id FROM restaurant_subscriptions WHERE restaurant_id = $1 AND status = 'active'`,
[restaurantId],
);
if (!sub) throw new BadRequestException('No active subscription found');
const cancelled = await this.stripe.subscriptions.cancel(sub.stripe_subscription_id);
await this.db.query(
`UPDATE restaurant_subscriptions SET status = 'cancelled', cancelled_at = NOW()
WHERE stripe_subscription_id = $1`,
[sub.stripe_subscription_id],
);
return cancelled;
}
// ============================================================
// DRIVER - Daily Login Fee ($20/day)
// ============================================================
async chargeDriverDailyFee(driverId: string): Promise<{ success: boolean; paymentIntentId: string }> {
const driver = await this.db.queryOne(
`SELECT d.*, u.email, u.first_name
FROM drivers d JOIN users u ON u.id = d.user_id
WHERE d.id = $1`,
[driverId],
);
if (!driver.stripe_payment_method) {
throw new BadRequestException('No payment method on file. Please add a card first.');
}
// Get or create Stripe customer for driver
let customerId = driver.stripe_customer_id;
if (!customerId) {
const customer = await this.stripe.customers.create({
email: driver.email,
name: `${driver.first_name} (Driver)`,
metadata: { driverId, platform: 'the-vibe' },
});
customerId = customer.id;
await this.db.query(
`UPDATE users SET stripe_customer_id = $2 WHERE id = $1`,
[driver.user_id, customerId],
);
}
// Charge $20 daily fee
const paymentIntent = await this.stripe.paymentIntents.create({
amount: 2000, // $20.00 in cents
currency: 'cad',
customer: customerId,
payment_method: driver.stripe_payment_method,
confirm: true,
description: `The Vibe - Daily Driver Access Fee (${new Date().toLocaleDateString('en-CA')})`,
metadata: { driverId, type: 'daily_driver_fee' },
off_session: true,
});
// Update session as paid
await this.db.query(
`UPDATE driver_sessions
SET fee_paid = TRUE, fee_payment_intent = $2, fee_paid_at = NOW()
WHERE driver_id = $1 AND session_date = CURRENT_DATE`,
[driverId, paymentIntent.id],
);
return { success: true, paymentIntentId: paymentIntent.id };
}
async saveDriverPaymentMethod(driverId: string, paymentMethodId: string) {
const driver = await this.db.queryOne(
`SELECT d.user_id, u.email FROM drivers d JOIN users u ON u.id = d.user_id WHERE d.id = $1`,
[driverId],
);
await this.db.query(
`UPDATE drivers SET stripe_payment_method = $2 WHERE id = $1`,
[driverId, paymentMethodId],
);
return { success: true };
}
// ============================================================
// CUSTOMER ORDER PAYMENT
// ============================================================
async createOrderPaymentIntent(orderId: string, customerId: string) {
const order = await this.db.queryOne(
`SELECT o.*, u.stripe_customer_id, u.email
FROM orders o JOIN users u ON u.id = o.customer_id
WHERE o.id = $1 AND o.customer_id = $2`,
[orderId, customerId],
);
if (!order) throw new BadRequestException('Order not found');
const amountCents = Math.round(order.total_customer_pays * 100);
const paymentIntent = await this.stripe.paymentIntents.create({
amount: amountCents,
currency: 'cad',
metadata: {
orderId,
customerId,
restaurantId: order.restaurant_id,
type: 'order_payment',
},
description: `The Vibe Order #${order.order_number}`,
});
await this.db.query(
`UPDATE orders SET payment_intent_id = $2, payment_status = 'pending' WHERE id = $1`,
[orderId, paymentIntent.id],
);
return { clientSecret: paymentIntent.client_secret };
}
// ============================================================
// STRIPE WEBHOOKS
// ============================================================
async handleWebhook(payload: Buffer, signature: string) {
const webhookSecret = this.config.get('STRIPE_WEBHOOK_SECRET');
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
} catch {
throw new BadRequestException('Invalid webhook signature');
}
switch (event.type) {
case 'payment_intent.succeeded': {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata.type === 'order_payment') {
await this.db.query(
`UPDATE orders SET payment_status = 'succeeded', status = 'confirmed'
WHERE payment_intent_id = $1`,
[pi.id],
);
}
break;
}
case 'payment_intent.payment_failed': {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata.type === 'order_payment') {
await this.db.query(
`UPDATE orders SET payment_status = 'failed', status = 'cancelled'
WHERE payment_intent_id = $1`,
[pi.id],
);
}
break;
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await this.db.query(
`UPDATE restaurant_subscriptions SET status = $2, updated_at = NOW()
WHERE stripe_subscription_id = $1`,
[sub.id, sub.status],
);
await this.db.query(
`UPDATE restaurants SET subscription_status = $2 WHERE subscription_id = $1`,
[sub.id, sub.status],
);
break;
}
}
return { received: true };
}
}

View File

@ -0,0 +1,69 @@
import { Controller, Get, Post, Patch, 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';
@Controller('restaurants')
export class RestaurantsController {
constructor(private readonly restaurantsService: RestaurantsService) {}
// ---- Public endpoints ----
@Get()
findNearby(
@Query('lng') lng: string,
@Query('lat') lat: string,
@Query('radius') radius?: string,
@Query('cuisine') cuisine?: string,
) {
return this.restaurantsService.findNearby(
parseFloat(lng),
parseFloat(lat),
radius ? parseFloat(radius) : 5,
cuisine,
);
}
@Get('savings-calculator')
savingsCalculator(
@Query('orders') orders: string,
@Query('avgValue') avgValue: string,
) {
return this.restaurantsService.calculateSavings(
parseInt(orders),
parseFloat(avgValue),
);
}
// ---- Authenticated restaurant owner endpoints ----
// NOTE: these must be declared BEFORE @Get(':slug') to avoid being swallowed by the wildcard
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
create(@Request() req, @Body() dto: any) {
return this.restaurantsService.create(req.user.id, dto);
}
@Get('dashboard/savings')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
getSavings(@Request() req) {
if (!req.user.restaurantId) {
throw new BadRequestException('No restaurant found. Please complete onboarding at /restaurant/onboarding');
}
return this.restaurantsService.getSavingsDashboard(req.user.restaurantId);
}
@Patch('dashboard/hours')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('restaurant_owner')
updateHours(@Request() req, @Body('isOpen') isOpen: boolean) {
return this.restaurantsService.updateHours(req.user.restaurantId, isOpen);
}
@Get(':slug')
findBySlug(@Param('slug') slug: string) {
return this.restaurantsService.findBySlug(slug);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { RestaurantsController } from './restaurants.controller';
import { RestaurantsService } from './restaurants.service';
import { DatabaseService } from '../../database/database.service';
@Module({
controllers: [RestaurantsController],
providers: [RestaurantsService, DatabaseService],
exports: [RestaurantsService],
})
export class RestaurantsModule {}

View File

@ -0,0 +1,180 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
const UBER_COMMISSION_RATE = 0.30;
const PLATFORM_PER_ORDER_FEE = 0.10;
const MONTHLY_SUBSCRIPTION = 500;
@Injectable()
export class RestaurantsService {
constructor(private readonly db: DatabaseService) {}
// ============================================================
// DISCOVERY (Customer-facing)
// ============================================================
async findNearby(lng: number, lat: number, radiusKm: number = 5, cuisine?: string) {
const cuisineFilter = cuisine
? `AND $4 = ANY(r.cuisine_type)`
: '';
return this.db.queryMany(
`SELECT
r.id, r.name, r.slug, r.description, r.cuisine_type,
r.logo_url, r.rating, r.total_reviews, r.avg_prep_time_minutes,
r.min_order_amount, r.is_open,
ST_Distance(r.location, ST_GeographyFromText($1)) / 1000 AS distance_km,
ST_X(r.location::geometry) AS lng,
ST_Y(r.location::geometry) AS lat,
z.name AS zone_name
FROM restaurants r
LEFT JOIN zones z ON z.id = r.zone_id
WHERE r.is_active = TRUE
AND ST_DWithin(r.location, ST_GeographyFromText($1), $2 * 1000)
${cuisineFilter}
ORDER BY r.is_open DESC, distance_km ASC
LIMIT 50`,
cuisine
? [`POINT(${lng} ${lat})`, radiusKm, null, cuisine]
: [`POINT(${lng} ${lat})`, radiusKm],
);
}
async findBySlug(slug: string) {
const restaurant = await this.db.queryOne(
`SELECT r.*, u.email AS owner_email, z.name AS zone_name
FROM restaurants r
JOIN users u ON u.id = r.owner_id
LEFT JOIN zones z ON z.id = r.zone_id
WHERE r.slug = $1 AND r.is_active = TRUE`,
[slug],
);
if (!restaurant) throw new NotFoundException('Restaurant not found');
const categories = await this.db.queryMany(
`SELECT mc.*, json_agg(
json_build_object(
'id', mi.id, 'name', mi.name, 'description', mi.description,
'price', mi.price, 'image_url', mi.image_url,
'dietary_tags', mi.dietary_tags, 'is_available', mi.is_available,
'is_featured', mi.is_featured
) ORDER BY mi.sort_order
) FILTER (WHERE mi.id IS NOT NULL) AS items
FROM menu_categories mc
LEFT JOIN menu_items mi ON mi.category_id = mc.id AND mi.is_available = TRUE
WHERE mc.restaurant_id = $1 AND mc.is_active = TRUE
GROUP BY mc.id
ORDER BY mc.sort_order`,
[restaurant.id],
);
return { ...restaurant, menu: categories };
}
// ============================================================
// RESTAURANT DASHBOARD
// ============================================================
async getSavingsDashboard(restaurantId: string) {
const restaurant = await this.db.queryOne(
`SELECT * FROM v_restaurant_savings WHERE restaurant_id = $1`,
[restaurantId],
);
if (!restaurant) throw new NotFoundException('Restaurant not found');
// Recent orders with savings breakdown
const recentOrders = await this.db.queryMany(
`SELECT
o.id, o.order_number, o.subtotal, o.status, o.created_at,
o.platform_fee, o.cc_processing_fee,
o.uber_equivalent_fee, o.restaurant_savings,
o.restaurant_receives,
u.first_name AS customer_first_name
FROM orders o
JOIN users u ON u.id = o.customer_id
WHERE o.restaurant_id = $1
ORDER BY o.created_at DESC
LIMIT 20`,
[restaurantId],
);
// Monthly summary
const monthly = await this.db.queryOne(
`SELECT
COUNT(*) AS total_orders,
SUM(subtotal) AS gross_sales,
SUM(platform_fee) AS platform_fees_paid,
SUM(uber_equivalent_fee) AS uber_would_have_charged,
SUM(restaurant_savings) AS total_saved,
$2::DECIMAL AS monthly_subscription
FROM orders
WHERE restaurant_id = $1
AND status = 'delivered'
AND created_at >= date_trunc('month', NOW())`,
[restaurantId, MONTHLY_SUBSCRIPTION],
);
return {
restaurant,
monthly: {
...monthly,
totalCostOnPlatform: Number(monthly?.platform_fees_paid || 0) + MONTHLY_SUBSCRIPTION,
message: monthly?.total_saved
? `You saved $${Number(monthly.total_saved).toFixed(2)} vs UberEats this month`
: 'Start taking orders to see your savings',
},
recentOrders,
};
}
async getRestaurantByOwner(ownerId: string) {
return this.db.queryOne(
`SELECT * FROM restaurants WHERE owner_id = $1`,
[ownerId],
);
}
async updateHours(restaurantId: string, isOpen: boolean) {
return this.db.queryOne(
`UPDATE restaurants SET is_open = $2, updated_at = NOW()
WHERE id = $1 RETURNING id, name, is_open`,
[restaurantId, isOpen],
);
}
async create(ownerId: string, dto: any) {
const slug = dto.name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
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)
RETURNING *`,
[
ownerId, dto.name, slug, dto.description, dto.cuisineType,
dto.phone, dto.email, dto.address, dto.postalCode,
`POINT(${dto.lng} ${dto.lat})`, dto.zoneId,
],
);
}
// ============================================================
// SAVINGS CALCULATOR (standalone - for marketing page)
// ============================================================
calculateSavings(monthlyOrders: number, avgOrderValue: number) {
const uberTotalFees = monthlyOrders * avgOrderValue * UBER_COMMISSION_RATE;
const platformTotalFees =
MONTHLY_SUBSCRIPTION + monthlyOrders * PLATFORM_PER_ORDER_FEE;
const monthlySavings = uberTotalFees - platformTotalFees;
const annualSavings = monthlySavings * 12;
return {
monthlyOrders,
avgOrderValue,
uberEatsFees: uberTotalFees,
platformFees: platformTotalFees,
monthlySavings,
annualSavings,
savingsPercent: Math.round((monthlySavings / uberTotalFees) * 100),
breakdownMessage: `UberEats would charge $${uberTotalFees.toFixed(2)}/month. We charge $${platformTotalFees.toFixed(2)}/month.`,
};
}
}

View File

@ -0,0 +1,36 @@
import { Controller, Get, Post, Body, Param, Query, UseGuards, Request } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard, Roles } from '../auth/guards/roles.guard';
import { ReviewsService } from './reviews.service';
@Controller('reviews')
export class ReviewsController {
constructor(private readonly reviewsService: ReviewsService) {}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('customer')
create(@Request() req, @Body() dto: any) {
return this.reviewsService.createReview(req.user.id, dto);
}
@Get('restaurant/:restaurantId')
getRestaurantReviews(
@Param('restaurantId') restaurantId: string,
@Query('page') page?: string,
) {
return this.reviewsService.getRestaurantReviews(restaurantId, page ? parseInt(page) : 1);
}
@Get('order/:orderId')
getOrderReview(@Param('orderId') orderId: string) {
return this.reviewsService.getOrderReview(orderId);
}
@Get('driver/me')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('driver')
getMyReviews(@Request() req) {
return this.reviewsService.getDriverReviews(req.user.driverId);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ReviewsService } from './reviews.service';
import { ReviewsController } from './reviews.controller';
import { DatabaseService } from '../../database/database.service';
@Module({
providers: [ReviewsService, DatabaseService],
controllers: [ReviewsController],
exports: [ReviewsService],
})
export class ReviewsModule {}

View File

@ -0,0 +1,137 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
@Injectable()
export class ReviewsService {
constructor(private readonly db: DatabaseService) {}
async createReview(customerId: string, dto: {
orderId: string;
restaurantId: string;
driverId?: string;
restaurantRating: number;
restaurantComment?: string;
driverRating?: number;
driverComment?: string;
}) {
// Verify the order belongs to this customer and is delivered
const order = await this.db.queryOne(
`SELECT id, customer_id, restaurant_id, driver_id, status
FROM orders WHERE id = $1 AND customer_id = $2`,
[dto.orderId, customerId],
);
if (!order) throw new NotFoundException('Order not found');
if (order.status !== 'delivered') {
throw new BadRequestException('Can only review delivered orders');
}
// Check for duplicate
const existing = await this.db.queryOne(
`SELECT id FROM reviews WHERE order_id = $1`,
[dto.orderId],
);
if (existing) throw new BadRequestException('Review already submitted for this order');
// Validate rating range
if (dto.restaurantRating < 1 || dto.restaurantRating > 5) {
throw new BadRequestException('Rating must be between 1 and 5');
}
const review = await this.db.queryOne(
`INSERT INTO reviews (
order_id, customer_id, restaurant_id, driver_id,
restaurant_rating, restaurant_comment,
driver_rating, driver_comment
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
dto.orderId, customerId, dto.restaurantId, dto.driverId || order.driver_id,
dto.restaurantRating, dto.restaurantComment || null,
dto.driverRating || null, dto.driverComment || null,
],
);
// Update restaurant aggregate rating
await this.db.query(
`UPDATE restaurants
SET rating = (
SELECT ROUND(AVG(restaurant_rating)::numeric, 1)
FROM reviews WHERE restaurant_id = $1
),
total_reviews = (
SELECT COUNT(*) FROM reviews WHERE restaurant_id = $1
)
WHERE id = $1`,
[dto.restaurantId],
);
// Update driver aggregate rating if rated
if (dto.driverRating && order.driver_id) {
await this.db.query(
`UPDATE drivers
SET rating = (
SELECT ROUND(AVG(driver_rating)::numeric, 1)
FROM reviews WHERE driver_id = $1 AND driver_rating IS NOT NULL
)
WHERE id = $1`,
[order.driver_id],
);
}
return review;
}
async getRestaurantReviews(restaurantId: string, page: number = 1, limit: number = 20) {
const offset = (page - 1) * limit;
const [reviews, summary] = await Promise.all([
this.db.queryMany(
`SELECT
r.id, r.restaurant_rating, r.restaurant_comment,
r.driver_rating, r.driver_comment,
r.created_at,
u.first_name AS customer_name,
o.order_number
FROM reviews r
JOIN users u ON u.id = r.customer_id
JOIN orders o ON o.id = r.order_id
WHERE r.restaurant_id = $1
ORDER BY r.created_at DESC
LIMIT $2 OFFSET $3`,
[restaurantId, limit, offset],
),
this.db.queryOne(
`SELECT
ROUND(AVG(restaurant_rating)::numeric, 1) AS avg_rating,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE restaurant_rating = 5) AS five_star,
COUNT(*) FILTER (WHERE restaurant_rating = 4) AS four_star,
COUNT(*) FILTER (WHERE restaurant_rating = 3) AS three_star,
COUNT(*) FILTER (WHERE restaurant_rating <= 2) AS low_star
FROM reviews WHERE restaurant_id = $1`,
[restaurantId],
),
]);
return { reviews, summary, page, limit };
}
async getOrderReview(orderId: string) {
return this.db.queryOne(
`SELECT * FROM reviews WHERE order_id = $1`,
[orderId],
);
}
async getDriverReviews(driverId: string) {
return this.db.queryMany(
`SELECT r.driver_rating, r.driver_comment, r.created_at,
u.first_name AS customer_name
FROM reviews r
JOIN users u ON u.id = r.customer_id
WHERE r.driver_id = $1 AND r.driver_rating IS NOT NULL
ORDER BY r.created_at DESC
LIMIT 50`,
[driverId],
);
}
}

View File

@ -0,0 +1,174 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import { DatabaseService } from '../../database/database.service';
// ============================================================
// REAL-TIME DELIVERY TRACKING GATEWAY
//
// Rooms:
// order:{orderId} - customer + driver for a specific order
// restaurant:{id} - restaurant owner for incoming orders
// driver:{id} - driver for order assignments
// zone:{slug} - all drivers in a zone (dispatch)
// ============================================================
@WebSocketGateway({
cors: {
origin: '*',
credentials: true,
},
namespace: '/tracking',
})
export class TrackingGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server;
private connectedUsers = new Map<string, { userId: string; role: string; socketId: string }>();
constructor(
private readonly jwt: JwtService,
private readonly db: DatabaseService,
) {}
async handleConnection(client: Socket) {
try {
const token = client.handshake.auth?.token || client.handshake.headers?.authorization?.split(' ')[1];
if (!token) { client.disconnect(); return; }
const payload = this.jwt.verify(token) as { sub: string; role: string };
client.data.userId = payload.sub;
client.data.role = payload.role;
this.connectedUsers.set(client.id, {
userId: payload.sub,
role: payload.role,
socketId: client.id,
});
// Auto-join personal room
client.join(`user:${payload.sub}`);
console.log(`[WS] Connected: ${payload.role} ${payload.sub}`);
} catch {
client.disconnect();
}
}
handleDisconnect(client: Socket) {
this.connectedUsers.delete(client.id);
if (client.data.userId && client.data.role === 'driver') {
// Mark driver offline in DB when WS disconnects
this.db.query(
`UPDATE driver_locations SET is_online = FALSE, is_available = FALSE WHERE driver_id = (
SELECT id FROM drivers WHERE user_id = $1
)`,
[client.data.userId],
).catch(() => {});
}
}
// ---- CLIENT EVENTS ----
@SubscribeMessage('join:order')
joinOrderRoom(@ConnectedSocket() client: Socket, @MessageBody() orderId: string) {
client.join(`order:${orderId}`);
return { joined: `order:${orderId}` };
}
@SubscribeMessage('join:restaurant')
joinRestaurantRoom(@ConnectedSocket() client: Socket, @MessageBody() restaurantId: string) {
if (client.data.role !== 'restaurant_owner' && client.data.role !== 'admin') return;
client.join(`restaurant:${restaurantId}`);
return { joined: `restaurant:${restaurantId}` };
}
@SubscribeMessage('join:zone')
joinZoneRoom(@ConnectedSocket() client: Socket, @MessageBody() zoneSlug: string) {
client.join(`zone:${zoneSlug}`);
return { joined: `zone:${zoneSlug}` };
}
// ---- DRIVER LOCATION UPDATE ----
// Driver sends location every 5 seconds while on a delivery
@SubscribeMessage('driver:location')
async driverLocation(
@ConnectedSocket() client: Socket,
@MessageBody() data: { lng: number; lat: number; heading?: number; speed?: number; orderId?: string },
) {
if (client.data.role !== 'driver') return;
const driverResult = await this.db.queryOne(
`SELECT id FROM drivers WHERE user_id = $1`,
[client.data.userId],
);
if (!driverResult) return;
const driverId = driverResult.id;
// Update driver_locations table
await this.db.query(
`UPDATE driver_locations
SET location = ST_GeographyFromText($1),
heading = $2, speed_kmh = $3, updated_at = NOW()
WHERE driver_id = $4`,
[`POINT(${data.lng} ${data.lat})`, data.heading, data.speed, driverId],
);
// If on an active delivery, emit to order room and record breadcrumb
if (data.orderId) {
this.server.to(`order:${data.orderId}`).emit('driver:moved', {
orderId: data.orderId,
driverId,
lat: data.lat,
lng: data.lng,
heading: data.heading,
speed: data.speed,
timestamp: new Date().toISOString(),
});
// Store breadcrumb (fire-and-forget)
this.db.query(
`INSERT INTO delivery_tracking (order_id, driver_id, location, heading, speed_kmh)
VALUES ($1, $2, ST_GeographyFromText($3), $4, $5)`,
[data.orderId, driverId, `POINT(${data.lng} ${data.lat})`, data.heading, data.speed],
).catch(() => {});
}
}
// ---- SERVER-SIDE EMIT HELPERS (called by other services) ----
// Notify restaurant of new order
emitNewOrder(restaurantId: string, order: any) {
this.server.to(`restaurant:${restaurantId}`).emit('order:new', order);
}
// Notify customer of status change
emitOrderStatusUpdate(orderId: string, status: string, data?: any) {
this.server.to(`order:${orderId}`).emit('order:status', { orderId, status, ...data });
}
// Notify driver of new delivery assignment
emitDeliveryAssigned(driverUserId: string, order: any) {
this.server.to(`user:${driverUserId}`).emit('delivery:assigned', order);
}
// Broadcast available order to zone drivers
emitOrderToZone(zoneSlug: string, order: any) {
this.server.to(`zone:${zoneSlug}`).emit('order:available', order);
}
// Driver accepted - notify restaurant + customer
emitDriverAccepted(orderId: string, restaurantId: string, driverInfo: any) {
this.server.to(`order:${orderId}`).emit('driver:accepted', driverInfo);
this.server.to(`restaurant:${restaurantId}`).emit('driver:accepted', { orderId, ...driverInfo });
}
}

View File

@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { TrackingGateway } from './tracking.gateway';
import { DatabaseService } from '../../database/database.service';
@Module({
imports: [
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET'),
}),
}),
],
providers: [TrackingGateway, DatabaseService],
exports: [TrackingGateway],
})
export class TrackingModule {}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class UsersModule {}

View File

@ -0,0 +1,22 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ZonesService } from './zones.service';
@Controller('zones')
export class ZonesController {
constructor(private readonly zonesService: ZonesService) {}
@Get()
getActive() {
return this.zonesService.getActive();
}
@Get('geojson')
getGeoJSON() {
return this.zonesService.getGTAGeoJSON();
}
@Get('check')
checkPoint(@Query('lng') lng: string, @Query('lat') lat: string) {
return this.zonesService.checkPoint(parseFloat(lng), parseFloat(lat));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ZonesController } from './zones.controller';
import { ZonesService } from './zones.service';
import { DatabaseService } from '../../database/database.service';
@Module({
controllers: [ZonesController],
providers: [ZonesService, DatabaseService],
exports: [ZonesService],
})
export class ZonesModule {}

View File

@ -0,0 +1,67 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
@Injectable()
export class ZonesService {
constructor(private readonly db: DatabaseService) {}
async getAll() {
return this.db.queryMany(
`SELECT
id, name, slug, status, priority, radius_km,
ST_X(center::geometry) AS center_lng,
ST_Y(center::geometry) AS center_lat,
ST_AsGeoJSON(boundary) AS boundary_geojson
FROM zones
ORDER BY priority DESC`,
);
}
async getActive() {
return this.db.queryMany(
`SELECT
id, name, slug, radius_km,
ST_X(center::geometry) AS center_lng,
ST_Y(center::geometry) AS center_lat,
ST_AsGeoJSON(boundary) AS boundary_geojson
FROM zones WHERE status = 'active'
ORDER BY priority DESC`,
);
}
async checkPoint(lng: number, lat: number) {
const zone = await this.db.queryOne(
`SELECT id, name, slug
FROM zones
WHERE status = 'active'
AND ST_Within(ST_GeographyFromText($1)::geometry, boundary::geometry)
ORDER BY priority DESC
LIMIT 1`,
[`POINT(${lng} ${lat})`],
);
return {
inServiceArea: !!zone,
zone: zone || null,
};
}
async getGTAGeoJSON() {
// Returns all zone boundaries as a GeoJSON FeatureCollection
const zones = await this.db.queryMany(
`SELECT
id, name, slug, status, priority,
ST_AsGeoJSON(boundary) AS geometry
FROM zones`,
);
return {
type: 'FeatureCollection',
features: zones.map((z) => ({
type: 'Feature',
properties: { id: z.id, name: z.name, slug: z.slug, status: z.status, priority: z.priority },
geometry: JSON.parse(z.geometry),
})),
};
}
}

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}

View File

@ -0,0 +1,48 @@
/**
* Reset dev seed user passwords to known values.
* Run once after docker compose up:
* node packages/database/reset-dev-passwords.js
*/
const { Client } = require('pg')
const bcrypt = require('bcrypt')
const DEV_USERS = [
{ email: 'admin@thevibe.ca', password: 'Admin@123', role: 'admin' },
{ email: 'owner@pizzanapoli.ca', password: 'Owner@123', role: 'restaurant_owner' },
{ email: 'owner@burgerhaus.ca', password: 'Owner@123', role: 'restaurant_owner' },
{ email: 'owner@tokyoramen.ca', password: 'Owner@123', role: 'restaurant_owner' },
{ email: 'driver@example.ca', password: 'Driver@123', role: 'driver' },
{ email: 'customer@example.ca', password: 'Customer@123', role: 'customer' },
]
async function main() {
const client = new Client({
host: process.env.POSTGRES_HOST || 'localhost',
port: parseInt(process.env.POSTGRES_PORT || '5432'),
user: process.env.POSTGRES_USER || 'vibe',
password: process.env.POSTGRES_PASSWORD || 'vibepass',
database: process.env.POSTGRES_DB || 'vibe_db',
})
await client.connect()
console.log('Connected to database\n')
for (const user of DEV_USERS) {
const hash = await bcrypt.hash(user.password, 12)
const result = await client.query(
'UPDATE users SET password_hash = $1 WHERE email = $2 RETURNING email',
[hash, user.email]
)
if (result.rowCount > 0) {
console.log(`${user.email} → password: ${user.password}`)
} else {
console.log(`${user.email} — user not found (seed may not have run)`)
}
}
await client.end()
console.log('\nDone. You can now log in with any of the accounts above.')
}
main().catch((e) => { console.error(e); process.exit(1) })

View File

@ -0,0 +1,558 @@
-- ============================================================
-- The Vibe - Fair-Trade Delivery Platform
-- PostgreSQL + PostGIS Schema
-- ============================================================
-- Extensions
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- fuzzy search for restaurant names
-- ============================================================
-- ENUMS
-- ============================================================
CREATE TYPE user_role AS ENUM ('customer', 'driver', 'restaurant_owner', 'admin');
CREATE TYPE order_status AS ENUM (
'pending',
'confirmed',
'preparing',
'ready_for_pickup',
'driver_assigned',
'picked_up',
'delivered',
'cancelled'
);
CREATE TYPE subscription_status AS ENUM ('active', 'past_due', 'cancelled', 'trialing');
CREATE TYPE driver_session_status AS ENUM ('active', 'inactive', 'suspended');
CREATE TYPE zone_status AS ENUM ('active', 'inactive', 'coming_soon');
CREATE TYPE payment_status AS ENUM ('pending', 'succeeded', 'failed', 'refunded');
-- ============================================================
-- USERS (all roles share this table)
-- ============================================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20),
password_hash TEXT NOT NULL,
role user_role NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
avatar_url TEXT,
is_verified BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
stripe_customer_id VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_users_stripe ON users(stripe_customer_id);
-- ============================================================
-- ZONES (GTA Geofencing - PostGIS)
-- ============================================================
CREATE TABLE zones (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL, -- e.g. "Downtown Toronto"
slug VARCHAR(100) UNIQUE NOT NULL, -- e.g. "downtown-toronto"
boundary GEOGRAPHY(POLYGON, 4326) NOT NULL,
center GEOGRAPHY(POINT, 4326) NOT NULL,
radius_km DECIMAL(6,2), -- for quick distance checks
status zone_status DEFAULT 'active',
priority INTEGER DEFAULT 0, -- higher = launched first
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_zones_boundary ON zones USING GIST(boundary);
CREATE INDEX idx_zones_center ON zones USING GIST(center);
CREATE INDEX idx_zones_status ON zones(status);
-- ============================================================
-- RESTAURANTS
-- ============================================================
CREATE TABLE restaurants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
cuisine_type VARCHAR(100)[], -- array: ["Italian", "Pizza"]
phone VARCHAR(20),
email VARCHAR(255),
address TEXT NOT NULL,
city VARCHAR(100) DEFAULT 'Toronto',
province VARCHAR(10) DEFAULT 'ON',
postal_code VARCHAR(10),
location GEOGRAPHY(POINT, 4326) NOT NULL,
zone_id UUID REFERENCES zones(id),
logo_url TEXT,
banner_url TEXT,
is_active BOOLEAN DEFAULT TRUE,
is_open BOOLEAN DEFAULT FALSE,
accepts_orders BOOLEAN DEFAULT TRUE,
avg_prep_time_minutes INTEGER DEFAULT 20,
min_order_amount DECIMAL(10,2) DEFAULT 0,
rating DECIMAL(3,2), -- 0.00 to 5.00
total_reviews INTEGER DEFAULT 0,
-- Savings tracking vs competitors
total_orders_platform INTEGER DEFAULT 0,
total_savings_vs_uber DECIMAL(12,2) DEFAULT 0, -- cumulative savings
-- Stripe
stripe_account_id VARCHAR(255), -- for future payout features
subscription_id VARCHAR(255), -- Stripe subscription ID
subscription_status subscription_status,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_restaurants_location ON restaurants USING GIST(location);
CREATE INDEX idx_restaurants_zone ON restaurants(zone_id);
CREATE INDEX idx_restaurants_owner ON restaurants(owner_id);
CREATE INDEX idx_restaurants_active ON restaurants(is_active, is_open);
CREATE INDEX idx_restaurants_name ON restaurants USING gin(name gin_trgm_ops);
-- ============================================================
-- MENUS & MENU ITEMS
-- ============================================================
CREATE TABLE menu_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
restaurant_id UUID NOT NULL REFERENCES restaurants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_menu_categories_restaurant ON menu_categories(restaurant_id);
CREATE TABLE menu_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
restaurant_id UUID NOT NULL REFERENCES restaurants(id) ON DELETE CASCADE,
category_id UUID REFERENCES menu_categories(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10,2) NOT NULL, -- in-store price (parity guaranteed)
image_url TEXT,
is_available BOOLEAN DEFAULT TRUE,
is_featured BOOLEAN DEFAULT FALSE,
dietary_tags VARCHAR(50)[], -- ["vegan", "gluten-free", "halal"]
allergens VARCHAR(50)[], -- ["nuts", "dairy"]
prep_time_min INTEGER DEFAULT 10,
calories INTEGER,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_menu_items_restaurant ON menu_items(restaurant_id);
CREATE INDEX idx_menu_items_category ON menu_items(category_id);
CREATE INDEX idx_menu_items_available ON menu_items(is_available);
-- ============================================================
-- DRIVERS
-- ============================================================
CREATE TABLE drivers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE RESTRICT,
zone_id UUID REFERENCES zones(id),
vehicle_type VARCHAR(50), -- "bicycle", "car", "ebike", "scooter"
vehicle_plate VARCHAR(20),
license_number VARCHAR(50),
is_background_checked BOOLEAN DEFAULT FALSE,
is_approved BOOLEAN DEFAULT FALSE,
rating DECIMAL(3,2),
total_deliveries INTEGER DEFAULT 0,
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
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_drivers_user ON drivers(user_id);
CREATE INDEX idx_drivers_zone ON drivers(zone_id);
CREATE INDEX idx_drivers_approved ON drivers(is_approved);
-- ============================================================
-- DRIVER SESSIONS (daily login fee tracking)
-- ============================================================
CREATE TABLE driver_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
driver_id UUID NOT NULL REFERENCES drivers(id) ON DELETE CASCADE,
session_date DATE NOT NULL DEFAULT CURRENT_DATE,
login_at TIMESTAMPTZ DEFAULT NOW(),
logout_at TIMESTAMPTZ,
daily_fee DECIMAL(10,2) DEFAULT 20.00,
fee_paid BOOLEAN DEFAULT FALSE,
fee_payment_intent VARCHAR(255), -- Stripe PaymentIntent ID
fee_paid_at TIMESTAMPTZ,
-- Break-even tracking
deliveries_count INTEGER DEFAULT 0,
delivery_revenue DECIMAL(10,2) DEFAULT 0, -- deliveries × $5
tips_earned DECIMAL(10,2) DEFAULT 0,
net_earnings DECIMAL(10,2) DEFAULT 0, -- revenue + tips - daily_fee
status driver_session_status DEFAULT 'active',
UNIQUE(driver_id, session_date)
);
CREATE INDEX idx_driver_sessions_driver ON driver_sessions(driver_id);
CREATE INDEX idx_driver_sessions_date ON driver_sessions(session_date);
CREATE INDEX idx_driver_sessions_fee ON driver_sessions(fee_paid);
-- ============================================================
-- DRIVER LOCATION (real-time PostGIS)
-- ============================================================
CREATE TABLE driver_locations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
driver_id UUID NOT NULL UNIQUE REFERENCES drivers(id) ON DELETE CASCADE,
location GEOGRAPHY(POINT, 4326) NOT NULL,
heading DECIMAL(5,2), -- degrees 0-360
speed_kmh DECIMAL(6,2),
is_online BOOLEAN DEFAULT FALSE,
is_available BOOLEAN DEFAULT FALSE, -- online AND not on a delivery
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_driver_locations_location ON driver_locations USING GIST(location);
CREATE INDEX idx_driver_locations_available ON driver_locations(is_online, is_available);
-- ============================================================
-- ORDERS
-- ============================================================
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
order_number SERIAL, -- human-readable order number
customer_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
restaurant_id UUID NOT NULL REFERENCES restaurants(id) ON DELETE RESTRICT,
driver_id UUID REFERENCES drivers(id) ON DELETE SET NULL,
zone_id UUID REFERENCES zones(id),
-- Addresses (stored as text + geo)
delivery_address TEXT NOT NULL,
delivery_location GEOGRAPHY(POINT, 4326) NOT NULL,
restaurant_location GEOGRAPHY(POINT, 4326), -- snapshot at order time
-- Pricing (all transparent, no hidden fees)
subtotal DECIMAL(10,2) NOT NULL, -- sum of items
delivery_fee DECIMAL(10,2) DEFAULT 5.00,
tip_amount DECIMAL(10,2) DEFAULT 0,
platform_fee DECIMAL(10,2) DEFAULT 0.10, -- restaurant pays
cc_processing_fee DECIMAL(10,2), -- calculated at checkout
total_customer_pays DECIMAL(10,2) NOT NULL, -- subtotal + delivery + tip + cc
restaurant_receives DECIMAL(10,2), -- subtotal - platform_fee - cc
driver_receives DECIMAL(10,2), -- delivery_fee + tip
-- Savings display
uber_equivalent_fee DECIMAL(10,2), -- what UberEats would charge (30%)
restaurant_savings DECIMAL(10,2), -- uber_fee - platform_fee
-- Status
status order_status DEFAULT 'pending',
special_instructions TEXT,
estimated_pickup_at TIMESTAMPTZ,
estimated_delivery_at TIMESTAMPTZ,
picked_up_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
cancellation_reason TEXT,
-- Stripe
payment_intent_id VARCHAR(255),
payment_status payment_status DEFAULT 'pending',
-- OSRM route snapshot
route_polyline TEXT, -- encoded polyline
distance_km DECIMAL(8,3),
duration_minutes INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_restaurant ON orders(restaurant_id);
CREATE INDEX idx_orders_driver ON orders(driver_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created ON orders(created_at DESC);
CREATE INDEX idx_orders_delivery_location ON orders USING GIST(delivery_location);
-- ============================================================
-- ORDER ITEMS
-- ============================================================
CREATE TABLE order_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
menu_item_id UUID NOT NULL REFERENCES menu_items(id) ON DELETE RESTRICT,
name VARCHAR(255) NOT NULL, -- snapshot at order time
price DECIMAL(10,2) NOT NULL, -- snapshot at order time
quantity INTEGER NOT NULL DEFAULT 1,
subtotal DECIMAL(10,2) NOT NULL, -- price × quantity
special_request TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_order_items_order ON order_items(order_id);
-- ============================================================
-- DRIVER EARNINGS (per-delivery record)
-- ============================================================
CREATE TABLE driver_earnings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
driver_id UUID NOT NULL REFERENCES drivers(id) ON DELETE CASCADE,
session_id UUID NOT NULL REFERENCES driver_sessions(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE RESTRICT,
delivery_fee DECIMAL(10,2) DEFAULT 5.00,
tip_amount DECIMAL(10,2) DEFAULT 0,
total DECIMAL(10,2) NOT NULL, -- delivery_fee + tip
is_profit BOOLEAN DEFAULT FALSE, -- true if past break-even
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_driver_earnings_driver ON driver_earnings(driver_id);
CREATE INDEX idx_driver_earnings_session ON driver_earnings(session_id);
CREATE INDEX idx_driver_earnings_order ON driver_earnings(order_id);
-- ============================================================
-- RESTAURANT SUBSCRIPTIONS
-- ============================================================
CREATE TABLE restaurant_subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
restaurant_id UUID NOT NULL REFERENCES restaurants(id) ON DELETE CASCADE,
stripe_subscription_id VARCHAR(255) UNIQUE NOT NULL,
stripe_customer_id VARCHAR(255) NOT NULL,
status subscription_status DEFAULT 'active',
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
monthly_fee DECIMAL(10,2) DEFAULT 500.00,
trial_ends_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_restaurant ON restaurant_subscriptions(restaurant_id);
CREATE INDEX idx_subscriptions_stripe ON restaurant_subscriptions(stripe_subscription_id);
CREATE INDEX idx_subscriptions_status ON restaurant_subscriptions(status);
-- ============================================================
-- DELIVERY TRACKING (breadcrumb trail)
-- ============================================================
CREATE TABLE delivery_tracking (
id BIGSERIAL PRIMARY KEY, -- high insert rate, use bigserial
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
driver_id UUID NOT NULL REFERENCES drivers(id) ON DELETE CASCADE,
location GEOGRAPHY(POINT, 4326) NOT NULL,
heading DECIMAL(5,2),
speed_kmh DECIMAL(6,2),
recorded_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_delivery_tracking_order ON delivery_tracking(order_id);
CREATE INDEX idx_delivery_tracking_recorded ON delivery_tracking(recorded_at DESC);
-- Partition by month for performance at scale
-- CREATE TABLE delivery_tracking_2025_01 PARTITION OF delivery_tracking
-- FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
-- ============================================================
-- PLATFORM ANALYTICS (materialized for performance)
-- ============================================================
CREATE TABLE daily_platform_stats (
stat_date DATE PRIMARY KEY,
total_orders INTEGER DEFAULT 0,
total_revenue DECIMAL(12,2) DEFAULT 0, -- all delivery fees collected
total_driver_fees DECIMAL(12,2) DEFAULT 0, -- daily login fees
total_restaurant_fees DECIMAL(12,2) DEFAULT 0, -- per-order fees
active_drivers INTEGER DEFAULT 0,
active_restaurants INTEGER DEFAULT 0,
new_customers INTEGER DEFAULT 0,
avg_delivery_time INTEGER, -- minutes
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- REVIEWS
-- ============================================================
CREATE TABLE reviews (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
order_id UUID NOT NULL UNIQUE REFERENCES orders(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES users(id),
restaurant_id UUID REFERENCES restaurants(id),
driver_id UUID REFERENCES drivers(id),
restaurant_rating INTEGER CHECK (restaurant_rating BETWEEN 1 AND 5),
driver_rating INTEGER CHECK (driver_rating BETWEEN 1 AND 5),
comment TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_reviews_restaurant ON reviews(restaurant_id);
CREATE INDEX idx_reviews_driver ON reviews(driver_id);
-- ============================================================
-- FUNCTIONS & TRIGGERS
-- ============================================================
-- Auto-update updated_at
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_restaurants_updated_at
BEFORE UPDATE ON restaurants FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_menu_items_updated_at
BEFORE UPDATE ON menu_items FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_orders_updated_at
BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Update driver session when an earning is recorded
CREATE OR REPLACE FUNCTION update_driver_session_on_earning()
RETURNS TRIGGER AS $$
DECLARE
v_daily_fee DECIMAL(10,2);
BEGIN
SELECT daily_fee INTO v_daily_fee
FROM driver_sessions WHERE id = NEW.session_id;
UPDATE driver_sessions SET
deliveries_count = deliveries_count + 1,
delivery_revenue = delivery_revenue + NEW.delivery_fee,
tips_earned = tips_earned + NEW.tip_amount,
net_earnings = (delivery_revenue + NEW.delivery_fee) + (tips_earned + NEW.tip_amount) - daily_fee
WHERE id = NEW.session_id;
-- Mark earning as profit if past break-even
UPDATE driver_earnings SET
is_profit = (
SELECT net_earnings >= 0
FROM driver_sessions WHERE id = NEW.session_id
)
WHERE id = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_driver_earnings_session
AFTER INSERT ON driver_earnings
FOR EACH ROW EXECUTE FUNCTION update_driver_session_on_earning();
-- Update restaurant savings on order completion
CREATE OR REPLACE FUNCTION update_restaurant_savings()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status = 'delivered' AND OLD.status != 'delivered' THEN
UPDATE restaurants SET
total_orders_platform = total_orders_platform + 1,
total_savings_vs_uber = total_savings_vs_uber + COALESCE(NEW.restaurant_savings, 0)
WHERE id = NEW.restaurant_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_order_savings
AFTER UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION update_restaurant_savings();
-- Check delivery location is within an active zone
CREATE OR REPLACE FUNCTION check_delivery_in_zone(p_location GEOGRAPHY)
RETURNS UUID AS $$
DECLARE
v_zone_id UUID;
BEGIN
SELECT id INTO v_zone_id
FROM zones
WHERE status = 'active'
AND ST_Within(p_location::geometry, boundary::geometry)
ORDER BY priority DESC
LIMIT 1;
RETURN v_zone_id;
END;
$$ LANGUAGE plpgsql;
-- Find available drivers within radius of a point
CREATE OR REPLACE FUNCTION find_nearby_drivers(
p_location GEOGRAPHY,
p_radius_km DECIMAL DEFAULT 5.0,
p_limit INTEGER DEFAULT 10
)
RETURNS TABLE(driver_id UUID, distance_m FLOAT) AS $$
BEGIN
RETURN QUERY
SELECT
dl.driver_id,
ST_Distance(dl.location, p_location) AS distance_m
FROM driver_locations dl
JOIN drivers d ON d.id = dl.driver_id
WHERE
dl.is_online = TRUE
AND dl.is_available = TRUE
AND d.is_approved = TRUE
AND ST_DWithin(dl.location, p_location, p_radius_km * 1000)
ORDER BY distance_m ASC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql;
-- ============================================================
-- VIEWS
-- ============================================================
-- Driver dashboard view
CREATE VIEW v_driver_dashboard AS
SELECT
d.id AS driver_id,
u.first_name,
u.last_name,
ds.session_date,
ds.deliveries_count,
ds.daily_fee,
ds.delivery_revenue,
ds.tips_earned,
ds.net_earnings,
-- Break-even logic
GREATEST(0, ds.daily_fee - ds.delivery_revenue) AS remaining_to_break_even,
CEIL(GREATEST(0, ds.daily_fee - ds.delivery_revenue) / 5.0) AS deliveries_to_break_even,
ds.delivery_revenue >= ds.daily_fee AS has_broken_even,
ds.fee_paid
FROM drivers d
JOIN users u ON u.id = d.user_id
LEFT JOIN driver_sessions ds ON ds.driver_id = d.id
AND ds.session_date = CURRENT_DATE;
-- Restaurant savings dashboard view
CREATE VIEW v_restaurant_savings AS
SELECT
r.id AS restaurant_id,
r.name,
r.total_orders_platform,
r.total_savings_vs_uber,
-- Monthly stats
COUNT(o.id) FILTER (WHERE o.created_at >= date_trunc('month', NOW())) AS orders_this_month,
SUM(o.platform_fee) FILTER (WHERE o.created_at >= date_trunc('month', NOW())) AS platform_fees_this_month,
SUM(o.uber_equivalent_fee) FILTER (WHERE o.created_at >= date_trunc('month', NOW())) AS uber_would_have_charged,
SUM(o.restaurant_savings) FILTER (WHERE o.created_at >= date_trunc('month', NOW())) AS saved_this_month
FROM restaurants r
LEFT JOIN orders o ON o.restaurant_id = r.id AND o.status = 'delivered'
GROUP BY r.id, r.name, r.total_orders_platform, r.total_savings_vs_uber;

199
packages/database/seed.sql Normal file
View File

@ -0,0 +1,199 @@
-- ============================================================
-- The Vibe - Seed Data
-- GTA Zones + Sample Data for Development
-- ============================================================
-- ============================================================
-- GTA ZONES (PostGIS Polygons)
-- ============================================================
INSERT INTO zones (id, name, slug, boundary, center, radius_km, status, priority) VALUES
-- Downtown Toronto (priority 1 - launched first)
(
uuid_generate_v4(),
'Downtown Toronto',
'downtown-toronto',
ST_GeographyFromText('POLYGON((-79.4200 43.6400, -79.3500 43.6400, -79.3500 43.6700, -79.4200 43.6700, -79.4200 43.6400))'),
ST_GeographyFromText('POINT(-79.3832 43.6532)'),
5.0,
'active',
10
),
-- Liberty Village
(
uuid_generate_v4(),
'Liberty Village',
'liberty-village',
ST_GeographyFromText('POLYGON((-79.4300 43.6350, -79.4100 43.6350, -79.4100 43.6450, -79.4300 43.6450, -79.4300 43.6350))'),
ST_GeographyFromText('POINT(-79.4196 43.6389)'),
2.0,
'active',
9
),
-- North York
(
uuid_generate_v4(),
'North York',
'north-york',
ST_GeographyFromText('POLYGON((-79.4800 43.7500, -79.3800 43.7500, -79.3800 43.7900, -79.4800 43.7900, -79.4800 43.7500))'),
ST_GeographyFromText('POINT(-79.4282 43.7615)'),
6.0,
'active',
8
),
-- Scarborough
(
uuid_generate_v4(),
'Scarborough',
'scarborough',
ST_GeographyFromText('POLYGON((-79.2800 43.7200, -79.1700 43.7200, -79.1700 43.7800, -79.2800 43.7800, -79.2800 43.7200))'),
ST_GeographyFromText('POINT(-79.2314 43.7568)'),
8.0,
'active',
7
),
-- Mississauga
(
uuid_generate_v4(),
'Mississauga',
'mississauga',
ST_GeographyFromText('POLYGON((-79.7500 43.5500, -79.5800 43.5500, -79.5800 43.6800, -79.7500 43.6800, -79.7500 43.5500))'),
ST_GeographyFromText('POINT(-79.6441 43.5890)'),
12.0,
'active',
6
),
-- Etobicoke (coming soon)
(
uuid_generate_v4(),
'Etobicoke',
'etobicoke',
ST_GeographyFromText('POLYGON((-79.5800 43.6200, -79.4800 43.6200, -79.4800 43.7000, -79.5800 43.7000, -79.5800 43.6200))'),
ST_GeographyFromText('POINT(-79.5300 43.6580)'),
7.0,
'coming_soon',
5
),
-- East York (coming soon)
(
uuid_generate_v4(),
'East York',
'east-york',
ST_GeographyFromText('POLYGON((-79.3500 43.6800, -79.2900 43.6800, -79.2900 43.7200, -79.3500 43.7200, -79.3500 43.6800))'),
ST_GeographyFromText('POINT(-79.3179 43.6967)'),
4.0,
'coming_soon',
4
);
-- ============================================================
-- SAMPLE ADMIN USER
-- ============================================================
-- Password: Admin@123 (bcrypt hash - change in production)
INSERT INTO users (id, email, phone, password_hash, role, first_name, last_name, is_verified, is_active) VALUES
(
'a0000000-0000-0000-0000-000000000001',
'admin@thevibe.ca',
'416-000-0001',
'$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewqmqNF1.4ABCDEF',
'admin',
'Platform',
'Admin',
TRUE,
TRUE
);
-- ============================================================
-- SAMPLE RESTAURANTS (Development Only)
-- ============================================================
-- Restaurant owner users
INSERT INTO users (id, email, phone, password_hash, role, first_name, last_name, is_verified) VALUES
('b0000000-0000-0000-0000-000000000001', 'owner@pizzanapoli.ca', '416-555-0101', '$2b$12$placeholder', 'restaurant_owner', 'Marco', 'Rossi', TRUE),
('b0000000-0000-0000-0000-000000000002', 'owner@burgerhaus.ca', '416-555-0102', '$2b$12$placeholder', 'restaurant_owner', 'Hans', 'Mueller', TRUE),
('b0000000-0000-0000-0000-000000000003', 'owner@tokyoramen.ca', '416-555-0103', '$2b$12$placeholder', 'restaurant_owner', 'Yuki', 'Tanaka', TRUE);
-- Sample restaurants in Downtown Toronto
INSERT INTO restaurants (id, owner_id, name, slug, description, cuisine_type, phone, email, address, postal_code, location, zone_id, is_active, is_open, rating) VALUES
(
'c0000000-0000-0000-0000-000000000001',
'b0000000-0000-0000-0000-000000000001',
'Pizza Napoli',
'pizza-napoli',
'Authentic Neapolitan pizza made with imported ingredients',
ARRAY['Italian', 'Pizza'],
'416-555-0101',
'hello@pizzanapoli.ca',
'123 King Street West, Toronto, ON',
'M5H 1J9',
ST_GeographyFromText('POINT(-79.3867 43.6481)'),
(SELECT id FROM zones WHERE slug = 'downtown-toronto'),
TRUE, TRUE, 4.8
),
(
'c0000000-0000-0000-0000-000000000002',
'b0000000-0000-0000-0000-000000000002',
'Burger Haus',
'burger-haus',
'Premium smash burgers made fresh daily',
ARRAY['American', 'Burgers'],
'416-555-0102',
'hello@burgerhaus.ca',
'456 Queen Street West, Toronto, ON',
'M5V 2B1',
ST_GeographyFromText('POINT(-79.4034 43.6487)'),
(SELECT id FROM zones WHERE slug = 'downtown-toronto'),
TRUE, TRUE, 4.6
),
(
'c0000000-0000-0000-0000-000000000003',
'b0000000-0000-0000-0000-000000000003',
'Tokyo Ramen House',
'tokyo-ramen-house',
'Slow-cooked tonkotsu and shoyu ramen bowls',
ARRAY['Japanese', 'Ramen'],
'416-555-0103',
'hello@tokyoramen.ca',
'789 Dundas Street West, Toronto, ON',
'M6J 1V4',
ST_GeographyFromText('POINT(-79.4134 43.6487)'),
(SELECT id FROM zones WHERE slug = 'downtown-toronto'),
TRUE, TRUE, 4.7
);
-- Sample menu categories and items for Pizza Napoli
INSERT INTO menu_categories (id, restaurant_id, name, sort_order) VALUES
('d0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001', 'Pizzas', 1),
('d0000000-0000-0000-0000-000000000002', 'c0000000-0000-0000-0000-000000000001', 'Appetizers', 2),
('d0000000-0000-0000-0000-000000000003', 'c0000000-0000-0000-0000-000000000001', 'Drinks', 3);
INSERT INTO menu_items (restaurant_id, category_id, name, description, price, dietary_tags, is_featured) VALUES
('c0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'Margherita', 'San Marzano tomatoes, fresh mozzarella, basil', 18.00, ARRAY['vegetarian'], TRUE),
('c0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'Diavola', 'Spicy salami, tomato, fior di latte', 21.00, ARRAY[]::VARCHAR[], FALSE),
('c0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'Quattro Formaggi', 'Mozzarella, gorgonzola, parmesan, ricotta', 23.00, ARRAY['vegetarian'], FALSE),
('c0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000002', 'Bruschetta', 'Tomato, basil, garlic on grilled bread', 9.00, ARRAY['vegan'], FALSE),
('c0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000002', 'Arancini', 'Saffron risotto balls, tomato sauce', 11.00, ARRAY['vegetarian'], FALSE),
('c0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000003', 'San Pellegrino', 'Sparkling water 500ml', 4.00, ARRAY['vegan'], FALSE),
('c0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000003', 'Italian Soda', 'Aranciata or Limonata', 4.50, ARRAY['vegan'], FALSE);
-- Sample driver user
INSERT INTO users (id, email, phone, password_hash, role, first_name, last_name, is_verified) VALUES
('e0000000-0000-0000-0000-000000000001', 'driver@example.ca', '416-555-0201', '$2b$12$placeholder', 'driver', 'James', 'Driver', TRUE);
INSERT INTO drivers (id, user_id, zone_id, vehicle_type, is_approved, is_background_checked) VALUES
('f0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', (SELECT id FROM zones WHERE slug = 'downtown-toronto'), 'bicycle', TRUE, TRUE);
INSERT INTO driver_locations (driver_id, location, is_online, is_available) VALUES
('f0000000-0000-0000-0000-000000000001', ST_GeographyFromText('POINT(-79.3832 43.6532)'), FALSE, FALSE);
-- 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);

46
packages/mobile/app.json Normal file
View File

@ -0,0 +1,46 @@
{
"expo": {
"name": "The Vibe",
"slug": "the-vibe",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"splash": {
"backgroundColor": "#0F172A"
},
"ios": {
"supportsTablet": false,
"bundleIdentifier": "ca.thevibe.driver",
"infoPlist": {
"NSLocationAlwaysAndWhenInUseUsageDescription": "The Vibe needs your location to find nearby deliveries and track your route.",
"NSLocationWhenInUseUsageDescription": "The Vibe needs your location to find nearby deliveries."
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#0F172A"
},
"package": "ca.thevibe.driver",
"permissions": [
"ACCESS_COARSE_LOCATION",
"ACCESS_FINE_LOCATION",
"ACCESS_BACKGROUND_LOCATION"
]
},
"plugins": [
"expo-router",
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "The Vibe uses your location to match you with nearby deliveries."
}
]
],
"extra": {
"eas": {
"projectId": "your-eas-project-id"
}
}
}
}

View File

@ -0,0 +1,42 @@
import { Tabs } from 'expo-router'
import { StyleSheet, Text } from 'react-native'
export default function DriverTabLayout() {
return (
<Tabs
screenOptions={{
headerStyle: { backgroundColor: '#0F172A' },
headerTintColor: '#fff',
headerTitleStyle: { fontWeight: 'bold' },
tabBarStyle: { backgroundColor: '#0F172A', borderTopColor: '#1E293B' },
tabBarActiveTintColor: '#0D9488',
tabBarInactiveTintColor: '#64748B',
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Dashboard',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>📊</Text>,
headerTitle: 'The Vibe',
}}
/>
<Tabs.Screen
name="orders"
options={{
title: 'Orders',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>📦</Text>,
headerTitle: 'Available Orders',
}}
/>
<Tabs.Screen
name="earnings"
options={{
title: 'Earnings',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>💰</Text>,
headerTitle: 'My Earnings',
}}
/>
</Tabs>
)
}

View File

@ -0,0 +1,211 @@
import { useEffect, useState } from 'react'
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, ActivityIndicator, RefreshControl,
} from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { api } from '../../app/lib/api'
const COLORS = { bg: '#0F172A', card: '#1E293B', teal: '#0D9488', amber: '#F59E0B', green: '#10B981', text: '#F8FAFC', muted: '#94A3B8' }
interface DayEarning {
session_date: string
deliveries_count: number
delivery_revenue: number
tips_earned: number
net_earnings: number
daily_fee: number
fee_paid: boolean
}
function BreakEvenBar({ revenue, fee }: { revenue: number; fee: number }) {
const pct = Math.min(100, (revenue / fee) * 100)
const broke = revenue >= fee
return (
<View style={{ marginTop: 6 }}>
<View style={styles.barBg}>
<View style={[styles.barFill, { width: `${pct}%` as any, backgroundColor: broke ? COLORS.green : COLORS.amber }]} />
</View>
</View>
)
}
export default function EarningsScreen() {
const [days, setDays] = useState(7)
const [earnings, setEarnings] = useState<DayEarning[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
useEffect(() => { load() }, [days])
const load = async () => {
setLoading(true)
try {
const token = await AsyncStorage.getItem('token')
if (!token) return
const data = await api.get(`/drivers/me/earnings?days=${days}`, token)
setEarnings(data)
} catch { /* silent */ } finally {
setLoading(false)
setRefreshing(false)
}
}
const totals = earnings.reduce(
(acc, d) => ({
deliveries: acc.deliveries + (d.deliveries_count || 0),
gross: acc.gross + Number(d.delivery_revenue || 0),
tips: acc.tips + Number(d.tips_earned || 0),
net: acc.net + Number(d.net_earnings || 0),
daysWorked: acc.daysWorked + 1,
}),
{ deliveries: 0, gross: 0, tips: 0, net: 0, daysWorked: 0 },
)
const avgPerDay = totals.daysWorked > 0 ? totals.net / totals.daysWorked : 0
const avgDeliveries = totals.daysWorked > 0 ? totals.deliveries / totals.daysWorked : 0
const PERIODS = [
{ label: '7d', value: 7 }, { label: '14d', value: 14 },
{ label: '30d', value: 30 }, { label: '90d', value: 90 },
]
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ paddingBottom: 32 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load() }} tintColor={COLORS.teal} />}
>
{/* Period selector */}
<View style={styles.periodRow}>
{PERIODS.map(p => (
<TouchableOpacity
key={p.value}
onPress={() => setDays(p.value)}
style={[styles.periodBtn, days === p.value && styles.periodBtnActive]}
>
<Text style={[styles.periodBtnText, days === p.value && { color: '#fff' }]}>{p.label}</Text>
</TouchableOpacity>
))}
</View>
{/* Summary cards */}
<View style={styles.cardRow}>
<View style={[styles.card, { flex: 1 }]}>
<Text style={styles.cardLabel}>Net Earnings</Text>
<Text style={[styles.cardValue, { color: COLORS.green }]}>${totals.net.toFixed(2)}</Text>
<Text style={styles.cardSub}>after daily fees</Text>
</View>
<View style={[styles.card, { flex: 1, marginLeft: 10 }]}>
<Text style={styles.cardLabel}>Tips</Text>
<Text style={[styles.cardValue, { color: COLORS.amber }]}>${totals.tips.toFixed(2)}</Text>
<Text style={styles.cardSub}>100% yours</Text>
</View>
</View>
<View style={styles.cardRow}>
<View style={[styles.card, { flex: 1 }]}>
<Text style={styles.cardLabel}>Deliveries</Text>
<Text style={styles.cardValue}>{totals.deliveries}</Text>
<Text style={styles.cardSub}>{totals.daysWorked} days worked</Text>
</View>
<View style={[styles.card, { flex: 1, marginLeft: 10 }]}>
<Text style={styles.cardLabel}>Avg / Day</Text>
<Text style={styles.cardValue}>${avgPerDay.toFixed(2)}</Text>
<Text style={styles.cardSub}>{avgDeliveries.toFixed(1)} deliveries avg</Text>
</View>
</View>
{/* Break-even insight */}
{totals.daysWorked > 0 && (
<View style={[styles.card, { marginHorizontal: 16, marginBottom: 16 }]}>
<Text style={{ color: COLORS.teal, fontWeight: '600', fontSize: 13 }}>
{avgDeliveries >= 4 ? '✅ Breaking even on average!' : '⚠️ Below break-even average'}
</Text>
<Text style={{ color: COLORS.muted, fontSize: 12, marginTop: 4 }}>
You average {avgDeliveries.toFixed(1)} deliveries/day. Break-even = 4 deliveries ($5 × 4 = $20 fee).
Every delivery past #4 is pure profit.
</Text>
</View>
)}
{/* Day-by-day */}
<View style={[styles.card, { marginHorizontal: 16 }]}>
<Text style={styles.sectionTitle}>Daily Breakdown</Text>
{loading ? (
<ActivityIndicator color={COLORS.teal} style={{ marginVertical: 24 }} />
) : earnings.length === 0 ? (
<Text style={{ color: COLORS.muted, textAlign: 'center', paddingVertical: 24 }}>
No earnings in this period
</Text>
) : (
earnings.map((day, i) => {
const net = Number(day.net_earnings || 0)
const revenue = Number(day.delivery_revenue || 0)
const fee = Number(day.daily_fee || 20)
const tips = Number(day.tips_earned || 0)
const date = new Date(day.session_date)
const isProfitable = net > 0
return (
<View key={day.session_date} style={[styles.dayRow, i > 0 && { borderTopColor: '#334155', borderTopWidth: 1 }]}>
<View style={{ flex: 1 }}>
<Text style={{ color: COLORS.text, fontWeight: '600', fontSize: 14 }}>
{date.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' })}
</Text>
<Text style={{ color: COLORS.muted, fontSize: 12, marginTop: 2 }}>
{day.deliveries_count} deliveries{tips > 0 ? ` · $${tips.toFixed(2)} tips` : ''}
</Text>
<BreakEvenBar revenue={revenue} fee={fee} />
</View>
<View style={{ alignItems: 'flex-end', marginLeft: 12 }}>
<Text style={{ fontSize: 16, fontWeight: 'bold', color: isProfitable ? COLORS.green : '#EF4444' }}>
{isProfitable ? '+' : ''}${net.toFixed(2)}
</Text>
<Text style={{ color: COLORS.muted, fontSize: 11, marginTop: 2 }}>
${revenue.toFixed(2)} $20
</Text>
</View>
</View>
)
})
)}
</View>
{/* How it works */}
<View style={[styles.card, { marginHorizontal: 16, marginTop: 16 }]}>
<Text style={styles.sectionTitle}>How Your Pay Works</Text>
{[
['Daily access fee', '$20.00'],
['Per delivery', '$5.00 flat'],
['Tips', '100% yours'],
['Commission taken', '$0.00'],
['Break-even at', '4 deliveries'],
].map(([label, val]) => (
<View key={label} style={styles.infoRow}>
<Text style={{ color: COLORS.muted, fontSize: 13 }}>{label}</Text>
<Text style={{ color: COLORS.text, fontSize: 13, fontWeight: '600' }}>{val}</Text>
</View>
))}
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
periodRow: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8 },
periodBtn: { flex: 1, paddingVertical: 8, backgroundColor: COLORS.card, borderRadius: 8, alignItems: 'center' },
periodBtnActive: { backgroundColor: COLORS.teal },
periodBtnText: { color: COLORS.muted, fontWeight: '600', fontSize: 13 },
cardRow: { flexDirection: 'row', paddingHorizontal: 16, marginBottom: 10 },
card: { backgroundColor: COLORS.card, borderRadius: 12, padding: 14 },
cardLabel: { color: COLORS.muted, fontSize: 12, marginBottom: 4 },
cardValue: { color: COLORS.text, fontSize: 22, fontWeight: 'bold' },
cardSub: { color: COLORS.muted, fontSize: 11, marginTop: 2 },
sectionTitle: { color: COLORS.text, fontWeight: '700', fontSize: 15, marginBottom: 12 },
dayRow: { paddingVertical: 12, flexDirection: 'row', alignItems: 'center' },
barBg: { height: 4, backgroundColor: '#334155', borderRadius: 2, marginTop: 4 },
barFill: { height: 4, borderRadius: 2 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6, borderBottomColor: '#334155', borderBottomWidth: 1 },
})

View File

@ -0,0 +1,263 @@
import React, { useEffect, useState } from 'react'
import {
View, Text, StyleSheet, ScrollView, TouchableOpacity,
ActivityIndicator, Alert, Switch,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { api } from '../lib/api'
interface BreakEvenStatus {
dailyFee: number
deliveriesCompleted: number
deliveryRevenue: number
tipsEarned: number
totalEarned: number
netEarnings: number
remainingCost: number
deliveriesToBreakEven: number
hasBreakEven: boolean
progressPercent: number
nextDeliveryIsProfit: boolean
message: string
profitAmount: number
}
export default function DriverDashboardScreen() {
const [session, setSession] = useState<any>(null)
const [breakEven, setBreakEven] = useState<BreakEvenStatus | null>(null)
const [earnings, setEarnings] = useState<any[]>([])
const [isOnline, setIsOnline] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => { loadSession() }, [])
const loadSession = async () => {
setLoading(true)
try {
const { data } = await api.get('/drivers/me/session')
setSession(data.session)
setBreakEven(data.breakEven)
setEarnings(data.earnings || [])
setIsOnline(data.session?.status === 'active')
} catch {}
setLoading(false)
}
const toggleOnline = async (value: boolean) => {
try {
if (value) {
await api.post('/payments/driver/daily-fee')
const { data } = await api.post('/drivers/me/session/start')
setSession(data.session)
setBreakEven(data.breakEven)
setIsOnline(true)
} else {
Alert.alert('Go offline?', 'You will stop receiving new orders.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Go Offline',
style: 'destructive',
onPress: async () => {
await api.post('/drivers/me/session/end')
setIsOnline(false)
loadSession()
},
},
])
}
} catch (err: any) {
Alert.alert('Error', err.response?.data?.message || 'Something went wrong')
}
}
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator color="#0D9488" size="large" />
</View>
)
}
return (
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* Online toggle */}
<View style={styles.onlineCard}>
<View>
<Text style={styles.onlineLabel}>{isOnline ? 'You are online' : 'You are offline'}</Text>
<Text style={styles.onlineSub}>{isOnline ? 'Receiving delivery requests' : 'Pay $20 to start your day'}</Text>
</View>
<Switch
value={isOnline}
onValueChange={toggleOnline}
trackColor={{ false: '#334155', true: '#0D9488' }}
thumbColor="#fff"
/>
</View>
{/* Break-even card */}
{breakEven ? (
<View style={[styles.card, breakEven.hasBreakEven && styles.cardGreen]}>
<View style={styles.cardHeader}>
<Text style={[styles.cardTitle, breakEven.hasBreakEven && styles.textWhite]}>
Break-Even Progress
</Text>
<Text style={[styles.percent, breakEven.hasBreakEven && styles.textWhite]}>
{breakEven.progressPercent}%
</Text>
</View>
{/* Progress bar */}
<View style={styles.progressBg}>
<View
style={[
styles.progressFill,
{ width: `${breakEven.progressPercent}%` as any },
breakEven.hasBreakEven && { backgroundColor: '#fff' },
breakEven.nextDeliveryIsProfit && { backgroundColor: '#F59E0B' },
]}
/>
</View>
{/* Stats row */}
<View style={styles.statsRow}>
<StatBox label="Deliveries" value={String(breakEven.deliveriesCompleted)} inverted={breakEven.hasBreakEven} />
<StatBox label="Earned" value={`$${breakEven.totalEarned.toFixed(2)}`} inverted={breakEven.hasBreakEven} />
<StatBox
label="Net"
value={`${breakEven.netEarnings >= 0 ? '+' : ''}$${breakEven.netEarnings.toFixed(2)}`}
inverted={breakEven.hasBreakEven}
highlight={breakEven.netEarnings >= 0}
/>
</View>
{/* Message */}
<View style={[styles.messageBadge, breakEven.hasBreakEven && styles.messageBadgeGreen]}>
<Text style={[styles.messageText, breakEven.hasBreakEven && styles.textWhite]}>
{breakEven.message}
</Text>
</View>
{breakEven.tipsEarned > 0 && (
<Text style={[styles.tipsText, breakEven.hasBreakEven && styles.textWhiteAlpha]}>
Tips: ${breakEven.tipsEarned.toFixed(2)} always 100% yours
</Text>
)}
</View>
) : (
<View style={styles.card}>
<Text style={styles.cardTitle}>Ready to start?</Text>
<Text style={styles.subtleText}>Toggle online above to begin your day.</Text>
<View style={styles.explainer}>
<Row label="Daily fee" value="$20.00" />
<Row label="Per delivery" value="+$5.00" valueColor="#22C55E" />
<Row label="Break-even" value="4 deliveries" />
<Row label="Tips" value="100% yours" valueColor="#22C55E" />
</View>
</View>
)}
{/* Today's deliveries */}
{earnings.length > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Today's Deliveries</Text>
{earnings.map((e, i) => (
<View key={e.id} style={styles.earningRow}>
<View>
<Text style={styles.earningLabel}>Delivery #{i + 1}</Text>
{e.is_profit && (
<View style={styles.profitBadge}>
<Text style={styles.profitText}>Profit</Text>
</View>
)}
</View>
<View style={styles.earningRight}>
<Text style={styles.earningAmount}>${Number(e.total).toFixed(2)}</Text>
{e.tip_amount > 0 && (
<Text style={styles.tipLine}>+${Number(e.tip_amount).toFixed(2)} tip</Text>
)}
</View>
</View>
))}
</View>
)}
</ScrollView>
</SafeAreaView>
)
}
function StatBox({ label, value, inverted, highlight }: any) {
return (
<View style={[styles.statBox, inverted && styles.statBoxInverted]}>
<Text style={[styles.statValue, inverted && styles.textWhite, highlight && !inverted && { color: '#22C55E' }]}>
{value}
</Text>
<Text style={[styles.statLabel, inverted && styles.textWhiteAlpha]}>{label}</Text>
</View>
)
}
function Row({ label, value, valueColor }: any) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={[styles.rowValue, valueColor && { color: valueColor }]}>{value}</Text>
</View>
)
}
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: '#0F172A' },
scroll: { flex: 1 },
scrollContent: { padding: 16, gap: 12 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#0F172A' },
onlineCard: {
backgroundColor: '#1E293B', borderRadius: 16, padding: 16,
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
},
onlineLabel: { color: '#fff', fontWeight: '700', fontSize: 16 },
onlineSub: { color: '#94A3B8', fontSize: 12, marginTop: 2 },
card: { backgroundColor: '#1E293B', borderRadius: 16, padding: 16 },
cardGreen: { backgroundColor: '#15803D' },
cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
cardTitle: { color: '#fff', fontWeight: '700', fontSize: 16 },
percent: { color: '#0D9488', fontWeight: '800', fontSize: 18 },
progressBg: { height: 8, backgroundColor: '#334155', borderRadius: 4, marginBottom: 12, overflow: 'hidden' },
progressFill: { height: '100%', backgroundColor: '#0D9488', borderRadius: 4 },
statsRow: { flexDirection: 'row', gap: 8, marginBottom: 12 },
statBox: { flex: 1, backgroundColor: '#0F172A', borderRadius: 12, padding: 10, alignItems: 'center' },
statBoxInverted: { backgroundColor: 'rgba(255,255,255,0.15)' },
statValue: { color: '#fff', fontWeight: '800', fontSize: 18 },
statLabel: { color: '#94A3B8', fontSize: 11, marginTop: 2 },
messageBadge: { backgroundColor: '#0F172A', borderRadius: 10, padding: 10, marginBottom: 8 },
messageBadgeGreen: { backgroundColor: 'rgba(255,255,255,0.15)' },
messageText: { color: '#94A3B8', fontSize: 13, textAlign: 'center', fontWeight: '600' },
tipsText: { color: '#94A3B8', fontSize: 12, textAlign: 'center' },
textWhite: { color: '#fff' },
textWhiteAlpha: { color: 'rgba(255,255,255,0.7)' },
subtleText: { color: '#64748B', fontSize: 13, marginTop: 4, marginBottom: 12 },
explainer: { gap: 8 },
row: { flexDirection: 'row', justifyContent: 'space-between' },
rowLabel: { color: '#94A3B8', fontSize: 13 },
rowValue: { color: '#fff', fontSize: 13, fontWeight: '600' },
earningRow: {
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#0F172A',
},
earningLabel: { color: '#fff', fontSize: 14, fontWeight: '600' },
earningRight: { alignItems: 'flex-end' },
earningAmount: { color: '#0D9488', fontSize: 16, fontWeight: '700' },
tipLine: { color: '#22C55E', fontSize: 11, marginTop: 2 },
profitBadge: { backgroundColor: '#14532D', borderRadius: 6, paddingHorizontal: 6, paddingVertical: 2, marginTop: 3, alignSelf: 'flex-start' },
profitText: { color: '#22C55E', fontSize: 10, fontWeight: '700' },
})

View File

@ -0,0 +1,274 @@
import React, { useEffect, useState, useRef } from 'react'
import {
View, Text, StyleSheet, FlatList, TouchableOpacity,
ActivityIndicator, Alert,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { io, Socket } from 'socket.io-client'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { api } from '../lib/api'
interface AvailableOrder {
id: string
order_number: number
restaurant: { name: string; address: string }
delivery_address: string
delivery_fee: number
tip_amount: number
distance_km: number
duration_minutes: number
item_count: number
}
export default function OrdersScreen() {
const [available, setAvailable] = useState<AvailableOrder[]>([])
const [activeOrder, setActiveOrder] = useState<any | null>(null)
const [connected, setConnected] = useState(false)
const [accepting, setAccepting] = useState<string | null>(null)
const socketRef = useRef<Socket | null>(null)
useEffect(() => {
connectSocket()
return () => { socketRef.current?.disconnect() }
}, [])
const connectSocket = async () => {
const token = await AsyncStorage.getItem('vibe_token')
const socket = io(`${process.env.EXPO_PUBLIC_WS_URL}/tracking`, {
auth: { token },
transports: ['websocket'],
})
socketRef.current = socket
socket.on('connect', () => {
setConnected(true)
socket.emit('join:zone', 'downtown-toronto')
})
socket.on('disconnect', () => setConnected(false))
socket.on('order:available', (order: AvailableOrder) => {
setAvailable((prev) => prev.some((o) => o.id === order.id) ? prev : [order, ...prev])
})
socket.on('order:taken', ({ orderId }: { orderId: string }) => {
setAvailable((prev) => prev.filter((o) => o.id !== orderId))
})
socket.on('delivery:assigned', (order: any) => {
setActiveOrder(order)
setAvailable([])
})
}
const acceptOrder = async (orderId: string) => {
setAccepting(orderId)
try {
const { data } = await api.patch(`/orders/${orderId}/assign-driver`)
setActiveOrder(data)
setAvailable([])
} catch (err: any) {
setAvailable((prev) => prev.filter((o) => o.id !== orderId))
Alert.alert('Order unavailable', err.response?.data?.message || 'This order was taken by another driver.')
} finally {
setAccepting(null)
}
}
const markPickedUp = () => {
Alert.alert('Confirm pickup', 'Have you picked up the order from the restaurant?', [
{ text: 'Not yet', style: 'cancel' },
{
text: 'Yes, picked up',
onPress: async () => {
await api.patch(`/orders/${activeOrder.id}/pickup`)
setActiveOrder((o: any) => ({ ...o, status: 'picked_up' }))
},
},
])
}
const markDelivered = () => {
Alert.alert('Confirm delivery', 'Has the customer received their order?', [
{ text: 'Not yet', style: 'cancel' },
{
text: 'Yes, delivered!',
onPress: async () => {
await api.patch(`/orders/${activeOrder.id}/delivered`)
setActiveOrder(null)
},
},
])
}
if (activeOrder) {
const earnings = Number(activeOrder.delivery_fee) + Number(activeOrder.tip_amount)
return (
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
<View style={styles.activeOrder}>
<View style={[styles.statusBadge, activeOrder.status === 'picked_up' && styles.statusBadgeAmber]}>
<Text style={styles.statusText}>
{activeOrder.status === 'driver_assigned' ? '🏪 Go to Restaurant' : '🏠 Deliver to Customer'}
</Text>
</View>
<Text style={styles.orderNum}>Order #{activeOrder.order_number}</Text>
{activeOrder.status === 'driver_assigned' && (
<View style={styles.section}>
<Text style={styles.sectionLabel}>Pickup from</Text>
<Text style={styles.sectionTitle}>{activeOrder.restaurant?.name}</Text>
<Text style={styles.sectionSub}>{activeOrder.restaurant?.address}</Text>
</View>
)}
<View style={styles.section}>
<Text style={styles.sectionLabel}>Deliver to</Text>
<Text style={styles.sectionTitle}>{activeOrder.delivery_address}</Text>
</View>
<View style={styles.earningsBox}>
<Text style={styles.earningsLabel}>This delivery earns you</Text>
<Text style={styles.earningsAmount}>${earnings.toFixed(2)}</Text>
{activeOrder.tip_amount > 0 && (
<Text style={styles.tipLine}>incl. ${Number(activeOrder.tip_amount).toFixed(2)} tip (100% yours)</Text>
)}
</View>
{activeOrder.status === 'driver_assigned' ? (
<TouchableOpacity style={styles.btnAmber} onPress={markPickedUp}>
<Text style={styles.btnText}>I've Picked It Up </Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.btnGreen} onPress={markDelivered}>
<Text style={styles.btnText}>Mark as Delivered </Text>
</TouchableOpacity>
)}
</View>
</SafeAreaView>
)
}
return (
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
<View style={styles.header}>
<View style={[styles.dot, connected && styles.dotGreen]} />
<Text style={styles.headerText}>{connected ? 'Connected — watching for orders' : 'Connecting...'}</Text>
</View>
<FlatList
data={available}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyIcon}>📡</Text>
<Text style={styles.emptyTitle}>Waiting for orders...</Text>
<Text style={styles.emptySub}>New orders in your zone appear here instantly</Text>
</View>
}
renderItem={({ item }) => (
<View style={styles.orderCard}>
<View style={styles.orderTop}>
<Text style={styles.orderEarnings}>
${(item.delivery_fee + item.tip_amount).toFixed(2)}
</Text>
{item.tip_amount > 0 && (
<Text style={styles.orderTip}>+${item.tip_amount.toFixed(2)} tip</Text>
)}
<View style={styles.orderMeta}>
<Text style={styles.orderMetaText}>{item.distance_km?.toFixed(1)} km</Text>
<Text style={styles.orderMetaText}>~{item.duration_minutes} min</Text>
</View>
</View>
<View style={styles.route}>
<View style={styles.routeRow}>
<Text style={styles.routeIcon}>📍</Text>
<View>
<Text style={styles.routeLabel}>Pickup</Text>
<Text style={styles.routeMain}>{item.restaurant.name}</Text>
<Text style={styles.routeSub}>{item.restaurant.address}</Text>
</View>
</View>
<View style={[styles.routeRow, { marginTop: 8 }]}>
<Text style={styles.routeIcon}>🏠</Text>
<View>
<Text style={styles.routeLabel}>Deliver to</Text>
<Text style={styles.routeSub}>{item.delivery_address}</Text>
</View>
</View>
</View>
<TouchableOpacity
style={[styles.acceptBtn, accepting === item.id && styles.acceptBtnDisabled]}
onPress={() => acceptOrder(item.id)}
disabled={accepting === item.id}
>
{accepting === item.id ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.acceptBtnText}>Accept </Text>
)}
</TouchableOpacity>
</View>
)}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: '#0F172A' },
header: {
flexDirection: 'row', alignItems: 'center', gap: 8,
paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#1E293B',
},
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#475569' },
dotGreen: { backgroundColor: '#22C55E' },
headerText: { color: '#94A3B8', fontSize: 13 },
list: { padding: 16, gap: 12 },
empty: { alignItems: 'center', paddingTop: 80 },
emptyIcon: { fontSize: 48, marginBottom: 12 },
emptyTitle: { color: '#fff', fontSize: 18, fontWeight: '700', marginBottom: 6 },
emptySub: { color: '#64748B', fontSize: 14, textAlign: 'center' },
orderCard: { backgroundColor: '#1E293B', borderRadius: 16, padding: 16 },
orderTop: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 12 },
orderEarnings: { color: '#22C55E', fontSize: 28, fontWeight: '800' },
orderTip: { color: '#22C55E', fontSize: 12, flex: 1 },
orderMeta: { alignItems: 'flex-end' },
orderMetaText: { color: '#64748B', fontSize: 12 },
route: { gap: 0, marginBottom: 14 },
routeRow: { flexDirection: 'row', gap: 10, alignItems: 'flex-start' },
routeIcon: { fontSize: 16 },
routeLabel: { color: '#64748B', fontSize: 11 },
routeMain: { color: '#fff', fontSize: 14, fontWeight: '600' },
routeSub: { color: '#94A3B8', fontSize: 12 },
acceptBtn: { backgroundColor: '#22C55E', borderRadius: 12, padding: 14, alignItems: 'center' },
acceptBtnDisabled: { opacity: 0.5 },
acceptBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
// Active order
activeOrder: { flex: 1, padding: 16, gap: 16 },
statusBadge: { backgroundColor: '#1E3A5F', borderRadius: 10, padding: 10, alignItems: 'center' },
statusBadgeAmber: { backgroundColor: '#451A03' },
statusText: { color: '#fff', fontWeight: '700', fontSize: 15 },
orderNum: { color: '#64748B', fontSize: 13 },
section: { backgroundColor: '#1E293B', borderRadius: 12, padding: 14 },
sectionLabel: { color: '#64748B', fontSize: 11, marginBottom: 4 },
sectionTitle: { color: '#fff', fontSize: 16, fontWeight: '600' },
sectionSub: { color: '#94A3B8', fontSize: 13, marginTop: 2 },
earningsBox: { backgroundColor: '#14532D', borderRadius: 12, padding: 16, alignItems: 'center' },
earningsLabel: { color: '#86EFAC', fontSize: 13 },
earningsAmount: { color: '#22C55E', fontSize: 40, fontWeight: '800', marginTop: 4 },
tipLine: { color: '#86EFAC', fontSize: 12, marginTop: 4 },
btnAmber: { backgroundColor: '#D97706', borderRadius: 14, padding: 16, alignItems: 'center' },
btnGreen: { backgroundColor: '#15803D', borderRadius: 14, padding: 16, alignItems: 'center' },
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
})

View File

@ -0,0 +1,24 @@
import axios from 'axios'
import AsyncStorage from '@react-native-async-storage/async-storage'
export const api = axios.create({
baseURL: `${process.env.EXPO_PUBLIC_API_URL}/api/v1`,
headers: { 'Content-Type': 'application/json' },
})
api.interceptors.request.use(async (config) => {
const token = await AsyncStorage.getItem('vibe_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
AsyncStorage.removeItem('vibe_token')
// Navigation handled at app level
}
return Promise.reject(err)
},
)

View File

@ -0,0 +1,38 @@
{
"name": "@vibe/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"expo": "~51.0.0",
"expo-router": "~3.5.0",
"expo-location": "~17.0.1",
"expo-status-bar": "~1.12.1",
"expo-font": "~12.0.7",
"react": "18.2.0",
"react-native": "0.74.1",
"react-native-maps": "1.14.0",
"@stripe/stripe-react-native": "0.37.2",
"socket.io-client": "^4.6.2",
"axios": "^1.6.7",
"zustand": "^4.5.0",
"@react-navigation/native": "^6.1.17",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/stack": "^6.3.29",
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "3.31.1",
"react-native-gesture-handler": "~2.16.1",
"@react-native-async-storage/async-storage": "1.23.1",
"nativewind": "^4.0.1"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~18.2.79",
"typescript": "^5.3.3"
}
}

5
packages/web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@ -0,0 +1,21 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverComponentsExternalPackages: ['maplibre-gl'],
},
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
'maplibre-gl': 'maplibre-gl',
}
return config
},
images: {
remotePatterns: [
{ protocol: 'https', hostname: '**.supabase.co' },
{ protocol: 'https', hostname: 'api.maptiler.com' },
],
},
}
export default nextConfig

34
packages/web/package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "@vibe/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"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"
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,148 @@
import Link from 'next/link'
const team = [
{
role: 'The Problem We Saw',
body: 'Delivery apps charge restaurants 2530% per order. On a $40 meal that\'s $12 gone before the cook, server, or driver sees a dime. Meanwhile customers pay service fees, inflated menu prices, and surprise totals at checkout.',
},
{
role: 'What We Built',
body: 'A flat-fee platform: $500/month + $0.10/order for restaurants. $5 flat delivery for customers. $20/day unlock fee for drivers who keep 100% of tips. Simple. Honest. Sustainable.',
},
{
role: 'Who We Serve',
body: 'Independent restaurants across the Greater Toronto Area who are tired of subsidizing billion-dollar platforms. Drivers who deserve to keep what they earn. Customers who want transparency at checkout.',
},
]
const values = [
{ icon: '⚖️', title: 'Radical Transparency', body: 'Every fee is published. No hidden charges. No fine print. You always know exactly what you\'re paying and why.' },
{ icon: '🤝', title: 'Fair for Everyone', body: 'Our model only works if restaurants profit, drivers earn well, and customers get honest prices. We designed it that way on purpose.' },
{ icon: '🏙️', title: 'Local First', body: 'We\'re starting in the GTA because we know it. Every zone we open is staffed with local support, not a chatbot.' },
{ icon: '📐', title: 'Simple by Design', body: 'One delivery fee. One subscription tier. No surge pricing, no tipping prompts hiding our cut, no algorithmic manipulation.' },
]
export default function AboutPage() {
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">
<Link href="/" className="text-2xl font-bold text-vibe-teal">The Vibe</Link>
<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="/login" className="bg-vibe-teal text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-teal-700 transition">Sign In</Link>
</div>
</nav>
{/* Hero */}
<section className="bg-vibe-cream px-6 py-20 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">
Built in Toronto · 2025
</div>
<h1 className="text-5xl font-bold text-vibe-dark mb-6 leading-tight">
We built the delivery app<br />
<span className="text-vibe-teal">restaurants actually asked for.</span>
</h1>
<p className="text-xl text-slate-600 leading-relaxed">
No commission. No exploitation. Just a fair platform that works for everyone in the chain from the kitchen to the door.
</p>
</div>
</section>
{/* Our Story */}
<section className="py-20 px-6">
<div className="max-w-4xl mx-auto">
<h2 className="text-3xl font-bold text-center text-vibe-dark mb-14">Our story</h2>
<div className="space-y-10">
{team.map((t) => (
<div key={t.role} className="flex gap-6">
<div className="w-2 bg-vibe-teal rounded-full flex-shrink-0" />
<div>
<h3 className="font-bold text-vibe-dark text-lg mb-2">{t.role}</h3>
<p className="text-slate-600 leading-relaxed">{t.body}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Values */}
<section className="bg-slate-50 py-20 px-6">
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-center text-vibe-dark mb-14">What we stand for</h2>
<div className="grid md:grid-cols-2 gap-8">
{values.map((v) => (
<div key={v.title} className="bg-white rounded-2xl p-6 border border-slate-100">
<div className="text-3xl mb-4">{v.icon}</div>
<h3 className="font-bold text-vibe-dark mb-2">{v.title}</h3>
<p className="text-slate-600 text-sm leading-relaxed">{v.body}</p>
</div>
))}
</div>
</div>
</section>
{/* Numbers */}
<section className="py-20 px-6">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-3xl font-bold text-vibe-dark mb-4">The math that convinced us</h2>
<p className="text-slate-500 mb-14 max-w-xl mx-auto">A restaurant doing 3,000 orders/month at $40 average. UberEats vs The Vibe.</p>
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-red-50 border border-red-100 rounded-2xl p-8 text-left">
<div className="text-red-500 font-bold text-sm uppercase tracking-wide mb-6">UberEats (30% commission)</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between"><span className="text-slate-600">Monthly orders</span><span className="font-medium">3,000</span></div>
<div className="flex justify-between"><span className="text-slate-600">Avg order</span><span className="font-medium">$40</span></div>
<div className="flex justify-between"><span className="text-slate-600">Gross revenue</span><span className="font-medium">$120,000</span></div>
<div className="flex justify-between text-red-500"><span>Commission (30%)</span><span className="font-bold">-$36,000</span></div>
<div className="border-t border-red-200 pt-3 flex justify-between font-bold">
<span className="text-slate-700">Restaurant keeps</span><span className="text-red-500">$84,000</span>
</div>
</div>
</div>
<div className="bg-green-50 border border-green-100 rounded-2xl p-8 text-left">
<div className="text-vibe-green font-bold text-sm uppercase tracking-wide mb-6">The Vibe ($500/mo + $0.10/order)</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between"><span className="text-slate-600">Monthly orders</span><span className="font-medium">3,000</span></div>
<div className="flex justify-between"><span className="text-slate-600">Avg order</span><span className="font-medium">$40</span></div>
<div className="flex justify-between"><span className="text-slate-600">Gross revenue</span><span className="font-medium">$120,000</span></div>
<div className="flex justify-between text-vibe-teal"><span>Platform fees</span><span className="font-bold">-$800</span></div>
<div className="border-t border-green-200 pt-3 flex justify-between font-bold">
<span className="text-slate-700">Restaurant keeps</span><span className="text-vibe-green">$119,200</span>
</div>
</div>
</div>
</div>
<p className="mt-8 text-2xl font-bold text-vibe-green">$35,200 more per month. $422,400 more per year.</p>
</div>
</section>
{/* CTA */}
<section className="bg-vibe-dark text-white py-20 px-6 text-center">
<div className="max-w-2xl mx-auto">
<h2 className="text-3xl font-bold mb-4">Ready to join us?</h2>
<p className="text-slate-400 mb-8">Whether you're a restaurant owner, a driver, or a customer who's tired of hidden fees there's a place for you at The Vibe.</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/register?role=restaurant_owner" className="bg-vibe-green text-white px-8 py-4 rounded-xl font-semibold hover:bg-green-500 transition">
List Your Restaurant
</Link>
<Link href="/register?role=driver" className="bg-white/10 text-white px-8 py-4 rounded-xl font-semibold hover:bg-white/20 transition">
Drive with Us
</Link>
<Link href="/restaurants" className="bg-vibe-teal text-white px-8 py-4 rounded-xl font-semibold hover:bg-teal-700 transition">
Order Food
</Link>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-slate-100 py-8 px-6 text-center text-sm text-slate-400">
© 2025 The Vibe Inc. · Toronto, ON ·{' '}
<Link href="/" className="hover:text-vibe-teal">Home</Link>
</footer>
</div>
)
}

View File

@ -0,0 +1,239 @@
'use client'
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
// ============================================================
// ADMIN DASHBOARD
// Platform stats, revenue, driver/restaurant management
// ============================================================
export default function AdminDashboardPage() {
const [stats, setStats] = useState<any>(null)
const [revenue, setRevenue] = useState<any[]>([])
const [activeTab, setActiveTab] = useState<'overview' | 'restaurants' | 'drivers'>('overview')
const [restaurants, setRestaurants] = useState<any[]>([])
const [drivers, setDrivers] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.get('/admin/stats'),
api.get('/admin/revenue?days=30'),
]).then(([s, r]) => {
setStats(s.data)
setRevenue(r.data)
setLoading(false)
})
}, [])
useEffect(() => {
if (activeTab === 'restaurants') {
api.get('/admin/restaurants').then((r) => setRestaurants(r.data.restaurants))
} else if (activeTab === 'drivers') {
api.get('/admin/drivers').then((r) => setDrivers(r.data))
}
}, [activeTab])
const approveDriver = async (id: string) => {
await api.patch(`/admin/drivers/${id}/approve`)
setDrivers((d) => d.map((dr) => dr.id === id ? { ...dr, is_approved: true } : dr))
}
if (loading) return <div className="min-h-screen flex items-center justify-center text-slate-400">Loading...</div>
const { totals, today, zones } = stats
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-vibe-dark text-white px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div>
<h1 className="font-bold text-xl">Admin Dashboard</h1>
<p className="text-slate-400 text-sm">The Vibe Platform</p>
</div>
<div className="text-sm text-slate-400">{new Date().toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white border-b border-slate-100 px-6">
<div className="max-w-6xl mx-auto flex gap-0">
{(['overview', 'restaurants', 'drivers'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-6 py-3 text-sm font-medium capitalize border-b-2 transition ${
activeTab === tab
? 'border-vibe-teal text-vibe-teal'
: 'border-transparent text-slate-500 hover:text-vibe-dark'
}`}
>
{tab}
</button>
))}
</div>
</div>
<div className="max-w-6xl mx-auto px-6 py-8">
{activeTab === 'overview' && (
<div className="space-y-8">
{/* Live stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard label="Restaurants" value={totals?.total_restaurants} sub="active" color="teal" />
<StatCard label="Drivers" value={totals?.total_drivers} sub="approved" color="green" />
<StatCard label="Orders Today" value={today?.orders_delivered_today} sub="delivered" color="blue" />
<StatCard label="Drivers Online" value={today?.drivers_online} sub="right now" color="amber" />
</div>
{/* Revenue today */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-2xl border border-slate-100 p-6">
<p className="text-slate-500 text-sm mb-1">Delivery Revenue Today</p>
<p className="text-3xl font-bold text-vibe-teal">${Number(today?.revenue_today || 0).toFixed(2)}</p>
<p className="text-slate-400 text-xs mt-1">${Number(today?.orders_delivered_today || 0) * 5} from $5/delivery</p>
</div>
<div className="bg-white rounded-2xl border border-slate-100 p-6">
<p className="text-slate-500 text-sm mb-1">Driver Fees Today</p>
<p className="text-3xl font-bold text-vibe-green">${Number(today?.drivers_paid_today || 0) * 20}</p>
<p className="text-slate-400 text-xs mt-1">{today?.drivers_paid_today} drivers × $20</p>
</div>
<div className="bg-white rounded-2xl border border-slate-100 p-6">
<p className="text-slate-500 text-sm mb-1">Active Orders</p>
<p className="text-3xl font-bold text-amber-500">{today?.orders_active || 0}</p>
<p className="text-slate-400 text-xs mt-1">in progress</p>
</div>
</div>
{/* All-time */}
<div className="bg-white rounded-2xl border border-slate-100 p-6">
<h2 className="font-bold text-vibe-dark mb-4">Platform Totals</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
<div>
<div className="text-2xl font-bold text-vibe-dark">{totals?.total_orders_delivered?.toLocaleString()}</div>
<div className="text-slate-500 text-sm mt-1">Orders Delivered</div>
</div>
<div>
<div className="text-2xl font-bold text-vibe-teal">${Number(totals?.total_delivery_revenue || 0).toLocaleString()}</div>
<div className="text-slate-500 text-sm mt-1">Delivery Revenue</div>
</div>
<div>
<div className="text-2xl font-bold text-vibe-green">${Number(totals?.total_driver_fee_revenue || 0).toLocaleString()}</div>
<div className="text-slate-500 text-sm mt-1">Driver Fee Revenue</div>
</div>
<div>
<div className="text-2xl font-bold text-slate-700">{totals?.total_customers?.toLocaleString()}</div>
<div className="text-slate-500 text-sm mt-1">Customers</div>
</div>
</div>
</div>
{/* Zone breakdown */}
<div className="bg-white rounded-2xl border border-slate-100">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-bold text-vibe-dark">GTA Zone Activity</h2>
</div>
<div className="divide-y divide-slate-50">
{zones?.map((z: any) => (
<div key={z.slug} className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full ${z.status === 'active' ? 'bg-vibe-green' : 'bg-amber-400'}`} />
<span className="font-medium text-vibe-dark">{z.name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${z.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
{z.status}
</span>
</div>
<div className="flex items-center gap-6 text-sm text-slate-500">
<span>{z.restaurant_count} restaurants</span>
<span>{z.online_drivers} drivers online</span>
<span className="text-vibe-teal font-medium">{z.orders_today} orders today</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
{activeTab === 'restaurants' && (
<div className="bg-white rounded-2xl border border-slate-100">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-bold text-vibe-dark">Restaurants ({restaurants.length})</h2>
</div>
<div className="divide-y divide-slate-50">
{restaurants.map((r) => (
<div key={r.id} className="px-6 py-4 flex items-center justify-between">
<div>
<p className="font-medium text-vibe-dark">{r.name}</p>
<p className="text-sm text-slate-500">{r.owner_email} · {r.zone_name}</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
r.subscription_status === 'active' ? 'bg-green-100 text-green-700' :
r.subscription_status === 'trialing' ? 'bg-blue-100 text-blue-700' :
'bg-red-100 text-red-700'
}`}>{r.subscription_status || 'no subscription'}</span>
<span className="text-slate-500">{r.total_orders_platform} orders</span>
<span className="text-vibe-green">${Number(r.total_savings_vs_uber).toFixed(0)} saved</span>
</div>
</div>
))}
</div>
</div>
)}
{activeTab === 'drivers' && (
<div className="bg-white rounded-2xl border border-slate-100">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-bold text-vibe-dark">Drivers ({drivers.length})</h2>
</div>
<div className="divide-y divide-slate-50">
{drivers.map((d) => (
<div key={d.id} className="px-6 py-4 flex items-center justify-between">
<div>
<p className="font-medium text-vibe-dark">{d.first_name} {d.last_name}</p>
<p className="text-sm text-slate-500">{d.email} · {d.vehicle_type} · {d.zone_name}</p>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-500">{d.total_deliveries} deliveries</span>
{d.is_online && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Online</span>
)}
{!d.is_approved ? (
<button
onClick={() => approveDriver(d.id)}
className="text-xs bg-vibe-teal text-white px-3 py-1.5 rounded-lg hover:bg-teal-700 transition"
>
Approve
</button>
) : (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Approved</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}
function StatCard({ label, value, sub, color }: { label: string; value: any; sub: string; color: string }) {
const colors: Record<string, string> = {
teal: 'text-vibe-teal',
green: 'text-vibe-green',
blue: 'text-blue-600',
amber: 'text-amber-500',
}
return (
<div className="bg-white rounded-2xl border border-slate-100 p-6">
<p className="text-slate-500 text-sm mb-1">{label}</p>
<p className={`text-3xl font-bold ${colors[color]}`}>{value?.toLocaleString() ?? '—'}</p>
<p className="text-slate-400 text-xs mt-1">{sub}</p>
</div>
)
}

View File

@ -0,0 +1,355 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import axios from 'axios'
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'
interface Zone {
id: string
name: string
slug: string
status: 'active' | 'coming_soon' | 'inactive'
priority: number
radius_km: number
center_lng: number
center_lat: number
boundary_geojson: string
restaurant_count: number
driver_count: number
}
const STATUS_CONFIG = {
active: { label: 'Active', color: 'bg-green-100 text-green-800 border-green-200', dot: 'bg-green-500' },
coming_soon: { label: 'Coming Soon', color: 'bg-amber-100 text-amber-800 border-amber-200', dot: 'bg-amber-400' },
inactive: { label: 'Inactive', color: 'bg-gray-100 text-gray-600 border-gray-200', dot: 'bg-gray-400' },
}
function ZoneMap({ zones, selectedId, onSelect }: { zones: Zone[]; selectedId: string | null; onSelect: (id: string) => void }) {
const mapRef = useRef<HTMLDivElement>(null)
const mapInstance = useRef<any>(null)
useEffect(() => {
if (!mapRef.current || typeof window === 'undefined') return
import('maplibre-gl').then(({ default: maplibre }) => {
if (mapInstance.current) return
const map = new maplibre.Map({
container: mapRef.current!,
style: `https://api.maptiler.com/maps/streets/style.json?key=${process.env.NEXT_PUBLIC_MAPTILER_KEY || 'demo'}`,
center: [-79.38, 43.65],
zoom: 9,
})
mapInstance.current = map
map.on('load', () => {
// Add zone boundaries as a GeoJSON layer
const features = zones
.filter(z => z.boundary_geojson)
.map(z => ({
type: 'Feature' as const,
properties: { id: z.id, name: z.name, status: z.status },
geometry: JSON.parse(z.boundary_geojson),
}))
map.addSource('zones', {
type: 'geojson',
data: { type: 'FeatureCollection', features },
})
// Fill
map.addLayer({
id: 'zones-fill',
type: 'fill',
source: 'zones',
paint: {
'fill-color': [
'match', ['get', 'status'],
'active', '#0d9488',
'coming_soon', '#f59e0b',
'#9ca3af',
],
'fill-opacity': 0.15,
},
})
// Outline
map.addLayer({
id: 'zones-outline',
type: 'line',
source: 'zones',
paint: {
'line-color': [
'match', ['get', 'status'],
'active', '#0d9488',
'coming_soon', '#f59e0b',
'#9ca3af',
],
'line-width': 2,
},
})
// Labels
map.addLayer({
id: 'zones-label',
type: 'symbol',
source: 'zones',
layout: {
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-size': 12,
},
paint: {
'text-color': '#1f2937',
'text-halo-color': '#ffffff',
'text-halo-width': 2,
},
})
// Click handler
map.on('click', 'zones-fill', (e) => {
const id = e.features?.[0]?.properties?.id
if (id) onSelect(id)
})
map.on('mouseenter', 'zones-fill', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'zones-fill', () => { map.getCanvas().style.cursor = '' })
})
})
return () => {
mapInstance.current?.remove()
mapInstance.current = null
}
}, []) // only mount once
// Highlight selected zone
useEffect(() => {
if (!mapInstance.current || !selectedId) return
const map = mapInstance.current
if (!map.getLayer('zones-fill')) return
map.setPaintProperty('zones-fill', 'fill-opacity', [
'case', ['==', ['get', 'id'], selectedId], 0.35, 0.15,
])
}, [selectedId])
return (
<div ref={mapRef} className="w-full h-full rounded-xl overflow-hidden" />
)
}
export default function AdminZonesPage() {
const router = useRouter()
const [zones, setZones] = useState<Zone[]>([])
const [selected, setSelected] = useState<Zone | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) { router.push('/login'); return }
fetchZones(token)
}, [])
const fetchZones = async (token?: string) => {
const t = token || localStorage.getItem('token')
try {
const { data } = await axios.get(`${API}/admin/zones`, {
headers: { Authorization: `Bearer ${t}` },
})
setZones(data)
if (data.length > 0 && !selected) setSelected(data[0])
} catch {
router.push('/login')
} finally {
setLoading(false)
}
}
const handleStatusChange = async (zoneId: string, status: Zone['status']) => {
setSaving(true)
setMessage('')
try {
const token = localStorage.getItem('token')
await axios.patch(
`${API}/admin/zones/${zoneId}/status`,
{ status },
{ headers: { Authorization: `Bearer ${token}` } },
)
setZones(prev => prev.map(z => z.id === zoneId ? { ...z, status } : z))
setSelected(prev => prev?.id === zoneId ? { ...prev, status } : prev)
setMessage(`Zone status updated to "${STATUS_CONFIG[status].label}"`)
setTimeout(() => setMessage(''), 3000)
} catch (err: any) {
setMessage('Failed to update zone status')
} finally {
setSaving(false)
}
}
const activeZones = zones.filter(z => z.status === 'active')
const comingSoonZones = zones.filter(z => z.status === 'coming_soon')
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-gray-900 text-white">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div>
<button onClick={() => router.push('/admin')} className="text-gray-400 hover:text-white text-sm mb-1"> Admin</button>
<h1 className="text-xl font-bold">Zone Management</h1>
</div>
<div className="flex gap-4 text-sm">
<span className="text-green-400 font-medium">{activeZones.length} active</span>
<span className="text-amber-400">{comingSoonZones.length} coming soon</span>
</div>
</div>
</div>
{message && (
<div className="bg-teal-600 text-white text-sm text-center py-2">{message}</div>
)}
<div className="max-w-7xl mx-auto px-4 py-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Zone list */}
<div className="space-y-3">
<h2 className="font-semibold text-gray-900">GTA Delivery Zones</h2>
{loading ? (
<div className="text-gray-400 text-sm">Loading zones...</div>
) : (
zones.map(zone => {
const cfg = STATUS_CONFIG[zone.status]
const isSelected = selected?.id === zone.id
return (
<button
key={zone.id}
onClick={() => setSelected(zone)}
className={`w-full text-left p-4 rounded-xl border transition ${
isSelected
? 'border-teal-500 bg-teal-50 shadow-sm'
: 'border-gray-200 bg-white hover:border-teal-300'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-gray-900">{zone.name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${cfg.color}`}>
{cfg.label}
</span>
</div>
<div className="flex gap-3 text-xs text-gray-500">
<span>{zone.restaurant_count} restaurants</span>
<span>{zone.driver_count} drivers</span>
<span>Priority {zone.priority}</span>
</div>
</button>
)
})
)}
</div>
{/* Zone detail + map */}
<div className="lg:col-span-2 space-y-4">
{/* Map */}
<div className="h-72 lg:h-96">
{!loading && <ZoneMap zones={zones} selectedId={selected?.id || null} onSelect={id => setSelected(zones.find(z => z.id === id) || null)} />}
</div>
{/* Zone editor */}
{selected && (
<div className="bg-white rounded-xl border shadow-sm p-5">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-bold text-gray-900">{selected.name}</h3>
<p className="text-sm text-gray-500 mt-0.5">
{selected.center_lat?.toFixed(4)}, {selected.center_lng?.toFixed(4)} · {selected.radius_km}km radius
</p>
</div>
<div className="flex gap-2 text-sm">
<div className="text-center">
<p className="font-bold text-gray-900">{selected.restaurant_count}</p>
<p className="text-gray-500 text-xs">restaurants</p>
</div>
<div className="text-center">
<p className="font-bold text-gray-900">{selected.driver_count}</p>
<p className="text-gray-500 text-xs">drivers</p>
</div>
<div className="text-center">
<p className="font-bold text-gray-900">{selected.priority}</p>
<p className="text-gray-500 text-xs">priority</p>
</div>
</div>
</div>
{/* Status controls */}
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Zone Status</p>
<div className="flex gap-2">
{(['active', 'coming_soon', 'inactive'] as const).map(s => {
const cfg = STATUS_CONFIG[s]
const isCurrent = selected.status === s
return (
<button
key={s}
onClick={() => handleStatusChange(selected.id, s)}
disabled={isCurrent || saving}
className={`flex-1 py-2.5 px-3 rounded-lg border text-sm font-medium transition ${
isCurrent
? `${cfg.color} cursor-default`
: 'bg-white border-gray-300 text-gray-700 hover:border-teal-400 hover:text-teal-700 disabled:opacity-50'
}`}
>
<span className={`inline-block w-2 h-2 rounded-full mr-1.5 ${cfg.dot}`} />
{cfg.label}
{isCurrent && ' ✓'}
</button>
)
})}
</div>
</div>
{/* Impact warning */}
{selected.status === 'active' && (
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800">
<strong>Live zone:</strong> {selected.restaurant_count} restaurants and {selected.driver_count} drivers
are actively using this zone. Deactivating will prevent new orders in this area.
</div>
)}
{selected.status === 'coming_soon' && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
Activating this zone will open delivery for restaurants and drivers in {selected.name}.
Make sure you have drivers ready!
</div>
)}
</div>
)}
{/* Launch checklist */}
<div className="bg-gray-900 text-white rounded-xl p-5">
<h3 className="font-semibold mb-3">Zone Launch Checklist</h3>
<div className="space-y-2 text-sm">
{zones.map(z => (
<div key={z.id} className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${STATUS_CONFIG[z.status].dot}`} />
<span className="text-gray-300">{z.name}</span>
<span className="ml-auto text-gray-400 text-xs">
{z.restaurant_count}r · {z.driver_count}d
</span>
<span className={`text-xs ${z.status === 'active' ? 'text-green-400' : 'text-gray-500'}`}>
{STATUS_CONFIG[z.status].label}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,280 @@
'use client'
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 { 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
// ============================================================
export default function CheckoutPage() {
const { items, subtotal, restaurantId, restaurantName, clearCart, deliveryFee } = useCart()
const router = useRouter()
const [address, setAddress] = useState('')
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 cartSubtotal = subtotal()
const ccFee = Math.round((cartSubtotal + deliveryFee + tip) * 0.029 * 100 + 30) / 100
const total = cartSubtotal + deliveryFee + tip + ccFee
// Redirect if cart is empty
useEffect(() => {
if (items.length === 0) router.replace('/restaurants')
}, [items.length])
const handleProceedToPayment = async () => {
if (!address.trim()) return
setLoading(true)
setZoneError('')
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 },
})
if (!zoneCheck.data.inServiceArea) {
setZoneError('Sorry, your address is outside our current service area. We\'re expanding soon!')
setLoading(false)
return
}
// 2. Place order
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,
tipAmount: tip,
specialInstructions: instructions,
})
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)
setStep('payment')
} catch (err: any) {
setZoneError(err.response?.data?.message || 'Failed to place order. Please try again.')
} finally {
setLoading(false)
}
}
const TIP_OPTIONS = [0, 2, 3, 5, 8]
if (step === 'payment' && clientSecret) {
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>
)
}
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 flex items-center gap-3">
<button onClick={() => router.back()} className="text-slate-500 hover:text-vibe-dark"></button>
<h1 className="font-bold text-vibe-dark">Checkout</h1>
</div>
</div>
<div className="max-w-lg mx-auto px-4 py-6 space-y-4">
{/* Restaurant */}
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">Ordering from</p>
<p className="font-semibold text-vibe-dark">{restaurantName}</p>
</div>
{/* 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"
/>
{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)"
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"
/>
</div>
{/* Order items */}
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<h2 className="font-semibold text-vibe-dark mb-3">Your Order</h2>
<div className="space-y-2">
{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>
</div>
))}
</div>
</div>
{/* Tip */}
<div className="bg-white rounded-2xl border border-slate-100 p-4">
<h2 className="font-semibold text-vibe-dark mb-1">Tip your driver</h2>
<p className="text-xs text-slate-400 mb-3">100% of tips go directly to your driver always.</p>
<div className="flex gap-2 flex-wrap">
{TIP_OPTIONS.map((t) => (
<button
key={t}
onClick={() => setTip(t)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition ${
tip === t
? 'bg-vibe-teal text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{t === 0 ? 'No tip' : `$${t}`}
</button>
))}
</div>
</div>
{/* Price breakdown — full transparency */}
<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.
</div>
</div>
<button
onClick={handleProceedToPayment}
disabled={!address.trim() || 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)}`}
</button>
</div>
</div>
)
}
// ---- Stripe Payment Step ----
function PaymentStep({
orderId, total, cartSubtotal, deliveryFee, tip, ccFee, restaurantName, onSuccess,
}: {
orderId: string; total: number; cartSubtotal: number; deliveryFee: number;
tip: number; ccFee: number; restaurantName: string; onSuccess: () => void;
}) {
const stripe = useStripe()
const elements = useElements()
const [error, setError] = useState('')
const [paying, setPaying] = useState(false)
const handlePay = async (e: React.FormEvent) => {
e.preventDefault()
if (!stripe || !elements) return
setPaying(true)
setError('')
const { error: stripeError } = await stripe.confirmPayment({
elements,
redirect: 'if_required',
})
if (stripeError) {
setError(stripeError.message || 'Payment failed')
setPaying(false)
} else {
onSuccess()
}
}
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>
<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>
</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>
</div>
</div>
)
}

View File

@ -0,0 +1,266 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import { api } from '@/lib/api'
import { MapView } from '@/components/map/MapView'
import { useDriverTracking } from '@/hooks/useDriverTracking'
interface BreakEvenStatus {
dailyFee: number
deliveriesCompleted: number
deliveryRevenue: number
tipsEarned: number
totalEarned: number
netEarnings: number
remainingCost: number
deliveriesToBreakEven: number
hasBreakEven: boolean
profitAmount: number
progressPercent: number
nextDeliveryIsProfit: boolean
message: string
}
interface DriverSession {
id: string
session_date: string
deliveries_count: number
delivery_revenue: number
tips_earned: number
net_earnings: number
fee_paid: boolean
}
// ============================================================
// DRIVER DASHBOARD
// Break-even progress, earnings, delivery map
// ============================================================
export default function DriverDashboardPage() {
const [session, setSession] = useState<DriverSession | null>(null)
const [breakEven, setBreakEven] = useState<BreakEvenStatus | null>(null)
const [earnings, setEarnings] = useState<any[]>([])
const [isOnline, setIsOnline] = useState(false)
const [loading, setLoading] = useState(true)
const [userLocation, setUserLocation] = useState<[number, number]>([-79.3832, 43.6532])
const { startTracking, stopTracking } = useDriverTracking()
useEffect(() => {
loadSession()
navigator.geolocation?.watchPosition((pos) => {
setUserLocation([pos.coords.longitude, pos.coords.latitude])
})
}, [])
const loadSession = async () => {
setLoading(true)
try {
const data = await api.get('/drivers/me/session')
setSession(data.data.session)
setBreakEven(data.data.breakEven)
setEarnings(data.data.earnings || [])
setIsOnline(!!data.data.session && data.data.session.status === 'active')
} catch {}
setLoading(false)
}
const handleGoOnline = async () => {
try {
await api.post('/payments/driver/daily-fee') // charge $20
const data = await api.post('/drivers/me/session/start')
setSession(data.data.session)
setBreakEven(data.data.breakEven)
setIsOnline(true)
startTracking()
} catch (e: any) {
alert(e.response?.data?.message || 'Failed to start session')
}
}
const handleGoOffline = async () => {
try {
await api.post('/drivers/me/session/end')
setIsOnline(false)
stopTracking()
loadSession()
} catch {}
}
if (loading) {
return <div className="min-h-screen flex items-center justify-center">
<div className="text-slate-400">Loading your dashboard...</div>
</div>
}
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-vibe-dark text-white px-6 py-4 flex items-center justify-between">
<div>
<h1 className="font-bold text-lg">Driver Dashboard</h1>
<p className="text-slate-400 text-sm">{new Date().toLocaleDateString('en-CA', { weekday: 'long', month: 'long', day: 'numeric' })}</p>
</div>
<button
onClick={isOnline ? handleGoOffline : handleGoOnline}
className={`px-6 py-2.5 rounded-full font-semibold text-sm transition ${
isOnline
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-vibe-green hover:bg-green-600 text-white'
}`}
>
{isOnline ? 'Go Offline' : 'Go Online ($20)'}
</button>
</div>
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{/* Break-Even Progress Card */}
{breakEven && (
<div className={`rounded-2xl p-6 ${
breakEven.hasBreakEven
? 'bg-vibe-green text-white'
: 'bg-white border border-slate-200'
}`}>
<div className="flex items-center justify-between mb-4">
<h2 className={`font-bold text-lg ${breakEven.hasBreakEven ? 'text-white' : 'text-vibe-dark'}`}>
Break-Even Progress
</h2>
<span className={`text-2xl font-bold ${breakEven.hasBreakEven ? 'text-white' : 'text-vibe-teal'}`}>
{breakEven.progressPercent}%
</span>
</div>
{/* Progress bar */}
<div className={`w-full h-3 rounded-full mb-4 ${breakEven.hasBreakEven ? 'bg-green-400/30' : 'bg-slate-100'}`}>
<div
className={`h-full rounded-full transition-all duration-500 ${
breakEven.hasBreakEven ? 'bg-white' : breakEven.nextDeliveryIsProfit ? 'bg-amber-400' : 'bg-vibe-teal'
}`}
style={{ width: `${breakEven.progressPercent}%` }}
/>
</div>
{/* Stats grid */}
<div className="grid grid-cols-3 gap-4 mb-4">
<div className={`text-center p-3 rounded-xl ${breakEven.hasBreakEven ? 'bg-green-400/20' : 'bg-slate-50'}`}>
<div className={`text-2xl font-bold ${breakEven.hasBreakEven ? 'text-white' : 'text-vibe-dark'}`}>
{breakEven.deliveriesCompleted}
</div>
<div className={`text-xs mt-0.5 ${breakEven.hasBreakEven ? 'text-green-100' : 'text-slate-500'}`}>
Deliveries
</div>
</div>
<div className={`text-center p-3 rounded-xl ${breakEven.hasBreakEven ? 'bg-green-400/20' : 'bg-slate-50'}`}>
<div className={`text-2xl font-bold ${breakEven.hasBreakEven ? 'text-white' : 'text-vibe-teal'}`}>
${breakEven.totalEarned.toFixed(2)}
</div>
<div className={`text-xs mt-0.5 ${breakEven.hasBreakEven ? 'text-green-100' : 'text-slate-500'}`}>
Total Earned
</div>
</div>
<div className={`text-center p-3 rounded-xl ${breakEven.hasBreakEven ? 'bg-green-400/20' : 'bg-slate-50'}`}>
<div className={`text-2xl font-bold ${breakEven.hasBreakEven ? 'text-white' : (breakEven.netEarnings >= 0 ? 'text-vibe-green' : 'text-red-500')}`}>
{breakEven.netEarnings >= 0 ? '+' : ''}${breakEven.netEarnings.toFixed(2)}
</div>
<div className={`text-xs mt-0.5 ${breakEven.hasBreakEven ? 'text-green-100' : 'text-slate-500'}`}>
Net Earnings
</div>
</div>
</div>
{/* Status message */}
<div className={`text-center text-sm font-semibold py-2 px-4 rounded-xl ${
breakEven.hasBreakEven
? 'bg-green-400/20 text-white'
: breakEven.nextDeliveryIsProfit
? 'bg-amber-50 text-amber-700 border border-amber-200'
: 'bg-slate-50 text-slate-600'
}`}>
{breakEven.message}
</div>
{/* Tips breakdown */}
{breakEven.tipsEarned > 0 && (
<div className={`mt-3 text-center text-sm ${breakEven.hasBreakEven ? 'text-green-100' : 'text-slate-500'}`}>
Tips earned: <strong className={breakEven.hasBreakEven ? 'text-white' : 'text-vibe-green'}>${breakEven.tipsEarned.toFixed(2)}</strong> always 100% yours
</div>
)}
</div>
)}
{/* Not online yet */}
{!session && (
<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>
<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>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>
</div>
</div>
)}
{/* Live Map */}
{isOnline && (
<div className="bg-white border border-slate-200 rounded-2xl overflow-hidden">
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<h3 className="font-semibold text-vibe-dark">Your Location</h3>
<span className="flex items-center gap-1.5 text-vibe-green text-sm font-medium">
<span className="w-2 h-2 bg-vibe-green rounded-full animate-pulse" />
Online
</span>
</div>
<div className="h-64">
<MapView
center={userLocation}
zoom={14}
driverLocation={userLocation}
/>
</div>
</div>
)}
{/* Today's Deliveries */}
{earnings.length > 0 && (
<div className="bg-white border border-slate-200 rounded-2xl">
<div className="px-4 py-3 border-b border-slate-100">
<h3 className="font-semibold text-vibe-dark">Today's Deliveries</h3>
</div>
<div className="divide-y divide-slate-50">
{earnings.map((e, i) => (
<div key={e.id} className="px-4 py-3 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-vibe-dark">Delivery #{i + 1}</span>
{e.is_profit && (
<span className="text-xs bg-vibe-green/10 text-vibe-green px-2 py-0.5 rounded-full font-medium">Profit</span>
)}
</div>
<p className="text-xs text-slate-400 mt-0.5">
{e.delivered_at ? new Date(e.delivered_at).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' }) : ''}
</p>
</div>
<div className="text-right">
<div className="font-semibold text-vibe-teal">${Number(e.total).toFixed(2)}</div>
{e.tip_amount > 0 && (
<div className="text-xs text-vibe-green">incl. ${Number(e.tip_amount).toFixed(2)} tip</div>
)}
</div>
</div>
))}
</div>
</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>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,230 @@
'use client'
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'
interface DayEarning {
session_date: string
deliveries_count: number
delivery_revenue: number
tips_earned: number
net_earnings: number
daily_fee: number
fee_paid: boolean
}
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>
<span className="text-gray-500">{pct}%</span>
</div>
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${broke ? 'bg-green-500' : 'bg-amber-400'}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
}
export default function DriverEarningsPage() {
const router = useRouter()
const [days, setDays] = useState(30)
const [earnings, setEarnings] = useState<DayEarning[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) { router.push('/login'); return }
fetchEarnings(token)
}, [days])
const fetchEarnings = async (token: string) => {
setLoading(true)
try {
const { data } = await axios.get(`${API}/drivers/me/earnings?days=${days}`, {
headers: { Authorization: `Bearer ${token}` },
})
setEarnings(data)
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to load earnings')
} finally {
setLoading(false)
}
}
// Aggregate stats
const totals = earnings.reduce(
(acc, d) => ({
deliveries: acc.deliveries + (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),
fees: acc.fees + Number(d.daily_fee || 0),
daysWorked: acc.daysWorked + 1,
}),
{ deliveries: 0, grossRevenue: 0, tips: 0, netEarnings: 0, fees: 0, daysWorked: 0 },
)
const avgPerDay = totals.daysWorked > 0 ? totals.netEarnings / totals.daysWorked : 0
return (
<div className="min-h-screen bg-gray-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
</button>
<h1 className="text-2xl font-bold">Earnings History</h1>
<p className="text-gray-400 text-sm mt-1">Your delivery earnings breakdown</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>
{/* 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>
</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>
</div>
{loading ? (
<div className="p-8 text-center text-gray-400">Loading earnings...</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>
</div>
) : (
<div className="divide-y">
{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">
{format(parseISO(day.session_date), 'EEE, MMM d')}
</p>
<p className="text-sm text-gray-500">
{day.deliveries_count} deliveries
{tips > 0 && ` · $${tips.toFixed(2)} tips`}
</p>
</div>
<div className="text-right">
<p className={`font-bold text-lg ${isProfitable ? 'text-green-600' : 'text-red-500'}`}>
{isProfitable ? '+' : ''}${net.toFixed(2)}
</p>
<p className="text-xs text-gray-400">
$${revenue.toFixed(2)} earned $${fee.toFixed(2)} fee
{!day.fee_paid && ' (unpaid)'}
</p>
</div>
</div>
<BreakEvenBar revenue={revenue} fee={fee} />
</div>
)
})}
</div>
)}
</div>
{/* How earnings work */}
<div className="bg-gray-900 text-white rounded-xl 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>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,316 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { io, Socket } from 'socket.io-client'
import { api } from '@/lib/api'
import { MapView } from '@/components/map/MapView'
import { getRoute, routeToGeoJSON } from '@/lib/osrm'
interface AvailableOrder {
id: string
order_number: number
restaurant: { name: string; address: string; lng: number; lat: number }
delivery_address: string
subtotal: number
tip_amount: number
delivery_fee: number
distance_km: number
duration_minutes: number
item_count: number
}
// ============================================================
// DRIVER ORDER ACCEPTANCE SCREEN
//
// - Shows available orders in zone (broadcast via Socket.IO)
// - Driver sees: restaurant, delivery address, earnings, distance
// - Accept button claims the order
// - Map shows route from driver → restaurant → customer
// ============================================================
export default function DriverOrdersPage() {
const router = useRouter()
const socketRef = useRef<Socket | null>(null)
const [available, setAvailable] = useState<AvailableOrder[]>([])
const [activeOrder, setActiveOrder] = useState<any | null>(null)
const [driverLocation, setDriverLocation] = useState<[number, number]>([-79.3832, 43.6532])
const [routeGeoJSON, setRouteGeoJSON] = useState<any>(null)
const [accepting, setAccepting] = useState<string | null>(null)
const [isOnline, setIsOnline] = useState(false)
useEffect(() => {
// GPS watch
const watchId = navigator.geolocation?.watchPosition((pos) => {
setDriverLocation([pos.coords.longitude, pos.coords.latitude])
})
// Socket.IO connection
const token = localStorage.getItem('vibe_token')
const socket = io(`${process.env.NEXT_PUBLIC_WS_URL}/tracking`, {
auth: { token },
transports: ['websocket'],
})
socketRef.current = socket
socket.on('connect', () => {
socket.emit('join:zone', 'downtown-toronto')
setIsOnline(true)
})
socket.on('disconnect', () => setIsOnline(false))
// New order available in zone
socket.on('order:available', (order: AvailableOrder) => {
setAvailable((prev) => {
if (prev.some((o) => o.id === order.id)) return prev
return [order, ...prev]
})
})
// Order was taken by another driver
socket.on('order:taken', ({ orderId }: { orderId: string }) => {
setAvailable((prev) => prev.filter((o) => o.id !== orderId))
})
// Assignment confirmed (after accepting)
socket.on('delivery:assigned', (order: any) => {
setActiveOrder(order)
setAvailable([])
})
return () => {
socket.disconnect()
if (watchId) navigator.geolocation?.clearWatch(watchId)
}
}, [])
// When active order changes, fetch route: driver → restaurant
useEffect(() => {
if (!activeOrder) return
const fetchRoute = async () => {
const restLng = activeOrder.restaurant_location?.coordinates?.[0]
const restLat = activeOrder.restaurant_location?.coordinates?.[1]
if (!restLng) return
const route = await getRoute(driverLocation, [restLng, restLat])
if (route) setRouteGeoJSON(routeToGeoJSON(route))
}
fetchRoute()
}, [activeOrder, driverLocation])
const acceptOrder = async (orderId: string) => {
setAccepting(orderId)
try {
const { data } = await api.patch(`/orders/${orderId}/assign-driver`)
setActiveOrder(data)
setAvailable([])
} catch (err: any) {
// Order may have been taken — remove it
setAvailable((prev) => prev.filter((o) => o.id !== orderId))
alert(err.response?.data?.message || 'Order no longer available')
} finally {
setAccepting(null)
}
}
const markPickedUp = async () => {
if (!activeOrder) return
await api.patch(`/orders/${activeOrder.id}/pickup`)
setActiveOrder((o: any) => ({ ...o, status: 'picked_up' }))
// Update route: driver → delivery address
const delivLng = activeOrder.delivery_location?.coordinates?.[0]
const delivLat = activeOrder.delivery_location?.coordinates?.[1]
if (delivLng) {
const route = await getRoute(driverLocation, [delivLng, delivLat])
if (route) setRouteGeoJSON(routeToGeoJSON(route))
}
}
const markDelivered = async () => {
if (!activeOrder) return
await api.patch(`/orders/${activeOrder.id}/delivered`)
setActiveOrder(null)
setRouteGeoJSON(null)
router.push('/driver/dashboard')
}
return (
<div className="min-h-screen bg-slate-900 text-white flex flex-col">
{/* Header */}
<div className="px-4 py-3 flex items-center justify-between border-b border-slate-700">
<button onClick={() => router.push('/driver/dashboard')} className="text-slate-400 text-sm"> Dashboard</button>
<h1 className="font-bold">Deliveries</h1>
<div className={`flex items-center gap-1.5 text-sm ${isOnline ? 'text-vibe-green' : 'text-slate-500'}`}>
<span className={`w-2 h-2 rounded-full ${isOnline ? 'bg-vibe-green animate-pulse' : 'bg-slate-500'}`} />
{isOnline ? 'Online' : 'Offline'}
</div>
</div>
{/* Map */}
<div className="h-72 relative">
<MapView
center={driverLocation}
zoom={14}
driverLocation={driverLocation}
routeGeoJSON={routeGeoJSON}
className="rounded-none"
/>
</div>
{/* Active order */}
{activeOrder ? (
<div className="flex-1 p-4 space-y-4">
<div className="bg-slate-800 rounded-2xl p-4">
<div className="flex items-center gap-2 mb-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
activeOrder.status === 'driver_assigned' ? 'bg-blue-900 text-blue-300' :
activeOrder.status === 'picked_up' ? 'bg-amber-900 text-amber-300' : ''
}`}>
{activeOrder.status === 'driver_assigned' ? 'Go to restaurant' : 'Deliver to customer'}
</span>
<span className="text-slate-400 text-xs">Order #{activeOrder.order_number}</span>
</div>
{activeOrder.status === 'driver_assigned' && (
<>
<div className="mb-4">
<p className="text-xs text-slate-400 mb-1">Pickup from</p>
<p className="font-semibold">{activeOrder.restaurant?.name}</p>
<p className="text-slate-400 text-sm">{activeOrder.restaurant?.address}</p>
</div>
<div>
<p className="text-xs text-slate-400 mb-1">Deliver to</p>
<p className="text-slate-300 text-sm">{activeOrder.delivery_address}</p>
</div>
<button
onClick={markPickedUp}
className="w-full mt-4 bg-amber-500 text-white py-3 rounded-xl font-semibold hover:bg-amber-600 transition"
>
Picked Up Heading to Customer
</button>
</>
)}
{activeOrder.status === 'picked_up' && (
<>
<div className="mb-4">
<p className="text-xs text-slate-400 mb-1">Delivering to</p>
<p className="font-semibold">{activeOrder.delivery_address}</p>
</div>
<button
onClick={markDelivered}
className="w-full bg-vibe-green text-white py-3 rounded-xl font-semibold hover:bg-green-600 transition"
>
Mark as Delivered
</button>
</>
)}
</div>
{/* Earnings for this delivery */}
<div className="bg-slate-800 rounded-2xl p-4">
<p className="text-slate-400 text-sm mb-2">Earnings this delivery</p>
<div className="flex justify-between items-center">
<div>
<span className="text-2xl font-bold text-vibe-green">
${(Number(activeOrder.delivery_fee) + Number(activeOrder.tip_amount)).toFixed(2)}
</span>
{activeOrder.tip_amount > 0 && (
<p className="text-slate-400 text-xs mt-0.5">incl. ${Number(activeOrder.tip_amount).toFixed(2)} tip</p>
)}
</div>
<div className="text-right text-sm text-slate-400">
<p>{activeOrder.distance_km?.toFixed(1)} km</p>
<p>{activeOrder.duration_minutes} min est.</p>
</div>
</div>
</div>
</div>
) : (
/* Available orders list */
<div className="flex-1 p-4">
{available.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center py-16">
<div className="text-4xl mb-3">📡</div>
<p className="font-semibold text-slate-300 mb-1">Waiting for orders...</p>
<p className="text-slate-500 text-sm">New orders in your zone will appear here instantly</p>
</div>
) : (
<div className="space-y-3">
<p className="text-slate-400 text-sm">{available.length} order{available.length !== 1 ? 's' : ''} available</p>
{available.map((order) => (
<AvailableOrderCard
key={order.id}
order={order}
accepting={accepting === order.id}
onAccept={() => acceptOrder(order.id)}
/>
))}
</div>
)}
</div>
)}
</div>
)
}
function AvailableOrderCard({ order, accepting, onAccept }: {
order: AvailableOrder; accepting: boolean; onAccept: () => void;
}) {
const totalEarnings = order.delivery_fee + order.tip_amount
return (
<div className="bg-slate-800 rounded-2xl p-4 border border-slate-700">
{/* Earnings highlight */}
<div className="flex items-start justify-between mb-3">
<div>
<span className="text-3xl font-bold text-vibe-green">${totalEarnings.toFixed(2)}</span>
{order.tip_amount > 0 && (
<span className="ml-2 text-xs text-vibe-green">incl. ${order.tip_amount.toFixed(2)} tip</span>
)}
</div>
<div className="text-right text-sm text-slate-400">
<p>{order.distance_km?.toFixed(1)} km</p>
<p>~{order.duration_minutes} min</p>
</div>
</div>
{/* Route info */}
<div className="space-y-2 mb-4">
<div className="flex items-start gap-2">
<span className="text-vibe-teal mt-0.5">📍</span>
<div>
<p className="text-xs text-slate-400">Pickup</p>
<p className="text-sm font-medium text-white">{order.restaurant.name}</p>
<p className="text-xs text-slate-400">{order.restaurant.address}</p>
</div>
</div>
<div className="ml-5 border-l border-slate-600 pl-4">
<div className="w-1.5 h-1.5 bg-slate-600 rounded-full -ml-5 mb-2" />
</div>
<div className="flex items-start gap-2">
<span className="text-amber-400 mt-0.5">🏠</span>
<div>
<p className="text-xs text-slate-400">Deliver to</p>
<p className="text-sm text-slate-300">{order.delivery_address}</p>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-slate-400">{order.item_count} item{order.item_count !== 1 ? 's' : ''}</div>
<button
onClick={onAccept}
disabled={accepting}
className="bg-vibe-green text-white px-6 py-2.5 rounded-xl font-semibold text-sm hover:bg-green-600 transition disabled:opacity-50"
>
{accepting ? 'Accepting...' : 'Accept →'}
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,26 @@
@import 'maplibre-gl/dist/maplibre-gl.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--vibe-green: #22C55E;
--vibe-teal: #0D9488;
--vibe-dark: #0F172A;
--vibe-cream: #FFF7ED;
}
body {
background-color: #ffffff;
color: #0F172A;
}
/* MapLibre overrides */
.maplibregl-map {
font-family: inherit;
}
.maplibregl-ctrl-logo {
display: none !important;
}

View File

@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'The Vibe - Fair Delivery for Toronto',
description: 'Flat-fee delivery platform. No commissions. Restaurants keep 100% of their profits. Drivers keep 100% of tips.',
keywords: 'food delivery, Toronto, GTA, fair trade, no commissions, flat fee',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}

View File

@ -0,0 +1,113 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { api } from '@/lib/api'
const ROLE_REDIRECTS: Record<string, string> = {
customer: '/restaurants',
driver: '/driver/dashboard',
restaurant_owner: '/restaurant/dashboard',
admin: '/admin',
}
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const { data } = await api.post('/auth/login', { email, password })
localStorage.setItem('vibe_token', data.token)
localStorage.setItem('vibe_user', JSON.stringify(data.user))
router.push(ROLE_REDIRECTS[data.user.role] || '/restaurants')
} catch (err: any) {
setError(err.response?.data?.message || 'Invalid email or password')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-vibe-cream flex flex-col">
<nav className="px-6 py-4">
<Link href="/" className="text-xl font-bold text-vibe-teal">The Vibe</Link>
</nav>
<div className="flex-1 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-vibe-dark mb-2">Welcome back</h1>
<p className="text-slate-500 text-sm">Sign in to your account</p>
</div>
<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>
<input
type="email"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
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 focus:border-transparent"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-2.5 rounded-xl">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-vibe-teal text-white py-3 rounded-xl font-semibold text-sm hover:bg-teal-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="mt-6 pt-6 border-t border-slate-100 space-y-3">
<Link href="/register?role=customer" className="flex items-center justify-between text-sm text-slate-600 hover:text-vibe-teal p-3 rounded-xl hover:bg-slate-50 transition">
<span>New customer? Create account</span>
<span></span>
</Link>
<Link href="/register?role=driver" className="flex items-center justify-between text-sm text-slate-600 hover:text-vibe-teal p-3 rounded-xl hover:bg-slate-50 transition">
<span>Want to drive? Apply here</span>
<span></span>
</Link>
<Link href="/register?role=restaurant_owner" className="flex items-center justify-between text-sm text-slate-600 hover:text-vibe-teal p-3 rounded-xl hover:bg-slate-50 transition">
<span>Restaurant owner? List your place</span>
<span></span>
</Link>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,244 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import axios from 'axios'
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'
function StarRating({ value, onChange, label }: { value: number; onChange: (n: number) => void; label: string }) {
const [hover, setHover] = useState(0)
const labels = ['', 'Poor', 'Fair', 'Good', 'Great', 'Excellent!']
return (
<div>
<p className="text-sm font-medium text-gray-700 mb-2">{label}</p>
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map(n => (
<button
key={n}
type="button"
onClick={() => onChange(n)}
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
className="p-1 transition-transform hover:scale-110"
>
<svg
className={`w-8 h-8 transition-colors ${(hover || value) >= n ? 'text-amber-400' : 'text-gray-300'}`}
fill="currentColor" viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
))}
{(hover || value) > 0 && (
<span className="ml-2 text-sm text-amber-600 font-medium">{labels[hover || value]}</span>
)}
</div>
</div>
)
}
export default function ReviewPage() {
const router = useRouter()
const params = useParams()
const orderId = params.id as string
const [order, setOrder] = useState<any>(null)
const [existing, setExisting] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
const [restaurantRating, setRestaurantRating] = useState(0)
const [restaurantComment, setRestaurantComment] = useState('')
const [driverRating, setDriverRating] = useState(0)
const [driverComment, setDriverComment] = useState('')
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) { router.push('/login'); return }
loadData(token)
}, [orderId])
const loadData = async (token: string) => {
try {
const [orderRes, reviewRes] = await Promise.all([
axios.get(`${API}/orders/${orderId}`, { headers: { Authorization: `Bearer ${token}` } }),
axios.get(`${API}/reviews/order/${orderId}`).catch(() => ({ data: null })),
])
setOrder(orderRes.data)
if (reviewRes.data) setExisting(reviewRes.data)
} catch {
setError('Order not found')
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (restaurantRating === 0) { setError('Please rate the restaurant'); return }
setSubmitting(true)
setError('')
try {
const token = localStorage.getItem('token')
await axios.post(
`${API}/reviews`,
{
orderId,
restaurantId: order.restaurant.id,
driverId: order.driver?.id,
restaurantRating,
restaurantComment: restaurantComment || undefined,
driverRating: driverRating || undefined,
driverComment: driverComment || undefined,
},
{ headers: { Authorization: `Bearer ${token}` } },
)
setSuccess(true)
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to submit review')
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-500">Loading order...</div>
</div>
)
}
if (!order) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-red-500">Order not found</div>
</div>
)
}
if (success || existing) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="bg-white rounded-2xl shadow-sm border p-8 max-w-md w-full text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">
{existing ? 'Review Already Submitted' : 'Thanks for your review!'}
</h2>
<p className="text-gray-600 text-sm mb-6">
{existing
? 'You have already reviewed this order.'
: 'Your feedback helps the restaurant and driver improve.'}
</p>
{existing && (
<div className="bg-gray-50 rounded-xl p-4 text-left mb-4 space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Restaurant:</span>
<div className="flex">
{[1,2,3,4,5].map(n => (
<svg key={n} className={`w-4 h-4 ${n <= existing.restaurant_rating ? 'text-amber-400' : 'text-gray-200'}`} fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
</div>
{existing.restaurant_comment && <p className="text-sm text-gray-700 italic">"{existing.restaurant_comment}"</p>}
</div>
)}
<button
onClick={() => router.push('/restaurants')}
className="w-full bg-teal-600 text-white py-2.5 rounded-lg font-medium hover:bg-teal-700 transition"
>
Order Again
</button>
</div>
</div>
)
}
if (order.status !== 'delivered') {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="bg-white rounded-2xl shadow-sm border p-8 max-w-md w-full text-center">
<p className="text-gray-600">Reviews are only available after delivery is complete.</p>
<button onClick={() => router.back()} className="mt-4 text-teal-600 hover:underline">Go back</button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 py-12 px-4">
<div className="max-w-lg mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">Leave a Review</h1>
<p className="text-gray-600 mt-1">Order #{order.order_number} from {order.restaurant.name}</p>
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow-sm border p-6 space-y-6">
{/* Restaurant rating */}
<div className="space-y-3">
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
<span className="text-xl">🍽</span> {order.restaurant.name}
</h3>
<StarRating value={restaurantRating} onChange={setRestaurantRating} label="Rate your food & experience *" />
<div>
<label className="block text-sm text-gray-600 mb-1">Comments (optional)</label>
<textarea
value={restaurantComment}
onChange={e => setRestaurantComment(e.target.value)}
rows={3}
placeholder="What did you love? Any suggestions?"
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-teal-500 outline-none resize-none"
/>
</div>
</div>
{/* Driver rating (if assigned) */}
{order.driver && (
<div className="space-y-3 pt-4 border-t">
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
<span className="text-xl">🚗</span> {order.driver.firstName} (Driver)
</h3>
<StarRating value={driverRating} onChange={setDriverRating} label="Rate your delivery (optional)" />
{driverRating > 0 && (
<div>
<label className="block text-sm text-gray-600 mb-1">Comments (optional)</label>
<textarea
value={driverComment}
onChange={e => setDriverComment(e.target.value)}
rows={2}
placeholder="How was the delivery?"
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-teal-500 outline-none resize-none"
/>
</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={submitting || restaurantRating === 0}
className="w-full bg-teal-600 text-white py-3 rounded-lg font-semibold hover:bg-teal-700 disabled:opacity-50 transition"
>
{submitting ? 'Submitting...' : 'Submit Review'}
</button>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,183 @@
'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 { api } from '@/lib/api'
// ============================================================
// CUSTOMER ORDER TRACKING PAGE
// Real-time driver location on MapLibre map
// ============================================================
const STATUS_STEPS = ['confirmed', 'preparing', 'ready_for_pickup', 'driver_assigned', 'picked_up', 'delivered']
const STATUS_LABELS: Record<string, string> = {
confirmed: 'Order Confirmed',
preparing: 'Being Prepared',
ready_for_pickup: 'Ready for Pickup',
driver_assigned: 'Driver On the Way',
picked_up: 'Driver Picked Up',
delivered: 'Delivered!',
}
export default function OrderTrackingPage() {
const { id: orderId } = useParams<{ id: string }>()
const [order, setOrder] = useState<any>(null)
const [driverLocation, setDriverLocation] = useState<[number, number] | undefined>()
const [routeGeoJSON, setRouteGeoJSON] = useState<any>(null)
const [loading, setLoading] = useState(true)
// 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))
}
})
if (loading) return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 border-4 border-vibe-teal border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-slate-500">Loading your order...</p>
</div>
</div>
)
if (!order) return <div className="min-h-screen flex items-center justify-center text-slate-400">Order not found</div>
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
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>
</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>
))}
</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>
</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="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 && (
<div className="flex justify-between text-vibe-green">
<span>Tip (goes directly 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>
</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>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,195 @@
import Link from 'next/link'
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' },
]
const truths = [
{
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.',
},
{
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.',
},
{
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.',
},
]
export default function HomePage() {
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
</Link>
</div>
</nav>
{/* 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
</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>
<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>
</div>
))}
</div>
</section>
{/* 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>
))}
</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>
</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>
)
}

View File

@ -0,0 +1,206 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { api } from '@/lib/api'
const ROLES = [
{ id: 'customer', label: 'Order food', icon: '🛒', desc: 'Browse restaurants and get $5 flat delivery.' },
{ id: 'driver', label: 'Deliver food', icon: '🚴', desc: 'Pay $20/day. Keep 100% of delivery fees and tips.' },
{ id: 'restaurant_owner', label: 'List my restaurant', icon: '🏪', desc: '$500/month. No commission. Keep 100% of profits.' },
]
const ROLE_REDIRECTS: Record<string, string> = {
customer: '/restaurants',
driver: '/driver/dashboard',
restaurant_owner: '/restaurant/dashboard',
}
export default function RegisterPage() {
const router = useRouter()
const searchParams = useSearchParams()
const defaultRole = searchParams.get('role') || 'customer'
const [role, setRole] = useState(defaultRole)
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', phone: '', password: '', confirmPassword: '' })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (form.password !== form.confirmPassword) {
setError('Passwords do not match')
return
}
if (form.password.length < 8) {
setError('Password must be at least 8 characters')
return
}
setLoading(true)
try {
const { data } = await api.post('/auth/register', {
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
phone: form.phone,
password: form.password,
role,
})
localStorage.setItem('vibe_token', data.token)
localStorage.setItem('vibe_user', JSON.stringify(data.user))
router.push(ROLE_REDIRECTS[role] || '/restaurants')
} catch (err: any) {
setError(err.response?.data?.message || 'Registration failed. Please try again.')
} finally {
setLoading(false)
}
}
const set = (key: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value }))
return (
<div className="min-h-screen bg-vibe-cream">
<nav className="px-6 py-4">
<Link href="/" className="text-xl font-bold text-vibe-teal">The Vibe</Link>
</nav>
<div className="max-w-lg mx-auto px-4 py-8">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-vibe-dark mb-2">Join The Vibe</h1>
<p className="text-slate-500 text-sm">Fair delivery for everyone in the GTA</p>
</div>
{/* Role selector */}
<div className="grid grid-cols-3 gap-3 mb-6">
{ROLES.map((r) => (
<button
key={r.id}
type="button"
onClick={() => setRole(r.id)}
className={`p-4 rounded-2xl border-2 text-center transition ${
role === r.id
? 'border-vibe-teal bg-white shadow-sm'
: 'border-slate-200 bg-white/50 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-1">{r.icon}</div>
<div className={`text-xs font-semibold ${role === r.id ? 'text-vibe-teal' : 'text-slate-600'}`}>
{r.label}
</div>
</button>
))}
</div>
{/* Role description */}
<div className="bg-vibe-teal/5 border border-vibe-teal/20 rounded-xl p-3 mb-6 text-sm text-slate-600 text-center">
{ROLES.find((r) => r.id === role)?.desc}
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">First name</label>
<input
type="text"
value={form.firstName}
onChange={set('firstName')}
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="Jane"
/>
</div>
<div>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">Last name</label>
<input
type="text"
value={form.lastName}
onChange={set('lastName')}
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="Smith"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">Email</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>
<input
type="tel"
value={form.phone}
onChange={set('phone')}
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="416-555-0100"
/>
</div>
<div>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">Password</label>
<input
type="password"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-vibe-dark mb-1.5">Confirm password</label>
<input
type="password"
value={form.confirmPassword}
onChange={set('confirmPassword')}
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="Repeat your password"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-2.5 rounded-xl">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-vibe-teal text-white py-3 rounded-xl font-semibold text-sm hover:bg-teal-700 transition disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
<p className="text-center text-xs text-slate-400">
By registering you agree to our terms of service and privacy policy.
</p>
</form>
<div className="mt-6 pt-4 border-t border-slate-100 text-center text-sm">
<span className="text-slate-500">Already have an account? </span>
<Link href="/login" className="text-vibe-teal font-medium hover:underline">Sign in</Link>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,224 @@
'use client'
import { useEffect, useState } from 'react'
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[]
}
// ============================================================
// RESTAURANT SAVINGS DASHBOARD
// Shows savings vs UberEats on every order
// ============================================================
export default function RestaurantDashboardPage() {
const [data, setData] = useState<SavingsDashboard | null>(null)
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [needsOnboarding, setNeedsOnboarding] = useState(false)
useEffect(() => {
api.get('/restaurants/dashboard/savings')
.then((r) => { setData(r.data); setLoading(false) })
.catch((err) => {
if (err.response?.status === 400) setNeedsOnboarding(true)
setLoading(false)
})
}, [])
const toggleOpen = async () => {
await api.patch('/restaurants/dashboard/hours', { isOpen: !isOpen })
setIsOpen(!isOpen)
}
if (loading) return <div className="min-h-screen flex items-center justify-center text-slate-400">Loading...</div>
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>
<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
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>
<h1 className="font-bold text-xl text-vibe-dark">{restaurant.name}</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>
</div>
<div className="max-w-5xl mx-auto px-6 py-8 space-y-8">
{/* 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>
</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>
) : (
<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>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* 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>
</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,454 @@
'use client'
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'
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',
'Vietnamese', 'Greek', 'Vegan', 'Breakfast', 'Pizza', 'Seafood',
]
const GTA_ZONES = [
{ id: 'downtown-toronto', name: 'Downtown Toronto' },
{ id: 'liberty-village', name: 'Liberty Village' },
{ id: 'north-york', name: 'North York' },
{ id: 'scarborough', name: 'Scarborough' },
{ id: 'mississauga', name: 'Mississauga' },
]
// ── Step 1: Business Info ───────────────────────────────────────────────────
function StepBusinessInfo({ onNext }: { onNext: (data: any) => void }) {
const [form, setForm] = useState({
name: '', description: '', phone: '', email: '',
address: '', postalCode: '', cuisineType: [] as string[],
zoneId: '', avgPrepTimeMinutes: '25', minOrderAmount: '15',
lat: '', lng: '',
})
const [errors, setErrors] = useState<Record<string, string>>({})
const set = (key: string, val: string) => setForm(f => ({ ...f, [key]: val }))
const toggleCuisine = (c: string) => {
setForm(f => ({
...f,
cuisineType: f.cuisineType.includes(c)
? f.cuisineType.filter(x => x !== c)
: [...f.cuisineType, c].slice(0, 3),
}))
}
const validate = () => {
const e: Record<string, string> = {}
if (!form.name.trim()) e.name = 'Restaurant name is required'
if (!form.phone.trim()) e.phone = 'Phone number is required'
if (!form.email.trim()) e.email = 'Email is required'
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
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validate()) onNext(form)
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900">Your Restaurant</h2>
<p className="text-gray-600 mt-1">Tell us about your restaurant</p>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Restaurant Name *</label>
<input
value={form.name}
onChange={e => set('name', e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-teal-500 outline-none"
placeholder="e.g. Mama Mia's Kitchen"
/>
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={form.description}
onChange={e => set('description', e.target.value)}
rows={3}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-teal-500 outline-none resize-none"
placeholder="What makes your restaurant special?"
/>
</div>
{/* Cuisine */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Cuisine Type * (up to 3)</label>
<div className="flex flex-wrap gap-2">
{CUISINE_TYPES.map(c => (
<button
key={c} type="button"
onClick={() => toggleCuisine(c)}
className={`px-3 py-1.5 rounded-full text-sm border transition ${
form.cuisineType.includes(c)
? 'bg-teal-600 text-white border-teal-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-teal-400'
}`}
>
{c}
</button>
))}
</div>
{errors.cuisineType && <p className="text-red-500 text-sm mt-1">{errors.cuisineType}</p>}
</div>
{/* Contact */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone *</label>
<input
value={form.phone}
onChange={e => set('phone', e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-teal-500 outline-none"
placeholder="+1 (416) 555-0100"
/>
{errors.phone && <p className="text-red-500 text-sm mt-1">{errors.phone}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Contact Email *</label>
<input
type="email"
value={form.email}
onChange={e => set('email', e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-teal-500 outline-none"
placeholder="orders@yourrestaurant.com"
/>
{errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
</div>
</div>
{/* Address */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Street Address *</label>
<input
value={form.address}
onChange={e => set('address', e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-teal-500 outline-none"
placeholder="123 King St W"
/>
{errors.address && <p className="text-red-500 text-sm mt-1">{errors.address}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Postal Code</label>
<input
value={form.postalCode}
onChange={e => set('postalCode', e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-teal-500 outline-none"
placeholder="M5V 1J2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Delivery Zone *</label>
<select
value={form.zoneId}
onChange={e => set('zoneId', e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-teal-500 outline-none bg-white"
>
<option value="">Select zone...</option>
{GTA_ZONES.map(z => <option key={z.id} value={z.id}>{z.name}</option>)}
</select>
{errors.zoneId && <p className="text-red-500 text-sm mt-1">{errors.zoneId}</p>}
</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>
{/* Operational */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Avg Prep Time (min)</label>
<input
type="number"
value={form.avgPrepTimeMinutes}
onChange={e => set('avgPrepTimeMinutes', e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-teal-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Min Order ($)</label>
<input
type="number"
value={form.minOrderAmount}
onChange={e => set('minOrderAmount', e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-teal-500 outline-none"
/>
</div>
</div>
<button type="submit" className="w-full bg-teal-600 text-white py-3 rounded-lg font-semibold hover:bg-teal-700 transition">
Continue to Payment Setup
</button>
</form>
)
}
// ── Step 2: Stripe Subscription Setup ──────────────────────────────────────
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 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 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. 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 },
)
onSuccess()
} catch (err: any) {
setError(err.response?.data?.message || err.message || 'Something went wrong')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900">Payment Setup</h2>
<p className="text-gray-600 mt-1">Your 14-day free trial starts now. No charge until the trial ends.</p>
</div>
{/* Pricing summary */}
<div className="bg-teal-50 border border-teal-200 rounded-xl p-4 space-y-2">
<div className="flex justify-between">
<span className="text-gray-700">Monthly subscription</span>
<span className="font-semibold">$500 CAD/month</span>
</div>
<div className="flex justify-between text-sm text-gray-600">
<span>Per-order platform fee</span>
<span>$0.10/order</span>
</div>
<div className="flex justify-between text-sm text-gray-600">
<span>Credit card processing</span>
<span>2.9% + $0.30 (passed through)</span>
</div>
<div className="border-t border-teal-200 pt-2 flex justify-between font-semibold text-teal-800">
<span>Commission rate</span>
<span>0% keep 100% of food revenue</span>
</div>
</div>
{/* Trial callout */}
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<p className="text-green-800 font-medium">14-day free trial</p>
<p className="text-green-700 text-sm mt-1">
Your card will not be charged until {new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString('en-CA')}.
Cancel any time before then with no charge.
</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>
</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}
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'}
</button>
<p className="text-xs text-gray-500 text-center">
Payments processed securely by Stripe. We never store your card details.
</p>
</form>
)
}
// ── Step 3: Success ─────────────────────────────────────────────────────────
function StepSuccess() {
const router = useRouter()
return (
<div className="text-center space-y-6 py-8">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-10 h-10 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h2 className="text-3xl font-bold text-gray-900">You're live on The Vibe!</h2>
<p className="text-gray-600 mt-2">
Your restaurant is now set up. Check your email for confirmation.
Your 14-day free trial has started.
</p>
</div>
<div className="bg-teal-50 rounded-xl p-4 text-left space-y-2">
<p className="font-medium text-teal-800">Next steps:</p>
<ul className="text-teal-700 text-sm space-y-1 list-disc list-inside">
<li>Add your menu items in the dashboard</li>
<li>Set your opening hours</li>
<li>Share your restaurant link with customers</li>
</ul>
</div>
<button
onClick={() => router.push('/restaurant/dashboard')}
className="w-full bg-teal-600 text-white py-3 rounded-lg font-semibold hover:bg-teal-700 transition"
>
Go to Dashboard
</button>
</div>
)
}
// ── Main Page ───────────────────────────────────────────────────────────────
const STEPS = ['Business Info', 'Payment Setup', 'All Done!']
export default function RestaurantOnboardingPage() {
const [step, setStep] = useState(0)
const [businessData, setBusinessData] = useState<any>(null)
return (
<div className="min-h-screen bg-gray-50 py-12 px-4">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-teal-700">Join The Vibe</h1>
<p className="text-gray-600 mt-2">Fair-trade delivery. No commissions. Just a flat fee.</p>
</div>
{/* Step indicator */}
<div className="flex items-center justify-center mb-8">
{STEPS.map((label, i) => (
<div key={i} className="flex items-center">
<div className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
i < step ? 'bg-teal-600 text-white' :
i === step ? 'bg-teal-100 text-teal-700 border-2 border-teal-600' :
'bg-gray-200 text-gray-500'
}`}>
{i < step ? '✓' : i + 1}
</div>
<span className={`ml-2 text-sm ${i === step ? 'font-medium text-teal-700' : 'text-gray-500'}`}>{label}</span>
{i < STEPS.length - 1 && <div className="w-12 h-px bg-gray-300 mx-3" />}
</div>
))}
</div>
{/* Card */}
<div className="bg-white rounded-2xl shadow-sm border p-8">
{step === 0 && (
<StepBusinessInfo onNext={(data) => { setBusinessData(data); setStep(1) }} />
)}
{step === 1 && (
<Elements stripe={stripePromise}>
<SubscriptionForm businessData={businessData} onSuccess={() => setStep(2)} />
</Elements>
)}
{step === 2 && <StepSuccess />}
</div>
{/* Savings pitch */}
{step < 2 && (
<div className="mt-6 bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
<p className="text-amber-800 text-sm">
A restaurant doing 100 orders/day at $25 avg saves <strong>~$28,000/month</strong> vs UberEats (30% commission).
</p>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,208 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { api } from '@/lib/api'
interface Order {
id: string
order_number: string
status: string
subtotal: number
delivery_fee: number
platform_fee: number
total: number
customer_first_name: string
customer_last_name: string
created_at: string
items: { name: string; quantity: number; price: number }[]
}
const STATUS_OPTIONS = ['', 'pending', 'confirmed', 'preparing', 'ready_for_pickup', 'delivered', 'cancelled']
const STATUS_LABELS: Record<string, string> = {
'': 'All',
pending: 'Pending',
confirmed: 'Confirmed',
preparing: 'Preparing',
ready_for_pickup: 'Ready',
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',
}
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 loadOrders = async () => {
setLoading(true)
try {
const { data } = await api.get('/orders/restaurant', {
params: { status: statusFilter || undefined },
})
setOrders(data)
} catch {}
setLoading(false)
}
useEffect(() => { loadOrders() }, [statusFilter])
const updateStatus = async (orderId: string, newStatus: string) => {
try {
await api.patch(`/orders/${orderId}/status`, { status: newStatus })
setOrders((prev) =>
prev.map((o) => (o.id === orderId ? { ...o, status: newStatus } : o))
)
} catch {}
}
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' },
}
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="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>
</div>
<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>
{loading ? (
<div className="space-y-3">
{[...Array(5)].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>
</div>
{/* 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>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,350 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/api'
import { useCart } from '@/lib/cart'
interface MenuItem {
id: string
name: string
description?: string
price: number
image_url?: string
dietary_tags?: string[]
is_available: boolean
is_featured: boolean
}
interface MenuCategory {
id: string
category: string
items: MenuItem[]
}
interface Restaurant {
id: string
name: string
description?: string
cuisine_type: string[]
logo_url?: string
banner_url?: string
rating: number
total_reviews: number
avg_prep_time_minutes: number
is_open: boolean
address: string
min_order_amount: number
menu: MenuCategory[]
}
export default function RestaurantMenuPage() {
const { slug } = useParams<{ slug: string }>()
const router = useRouter()
const [restaurant, setRestaurant] = useState<Restaurant | null>(null)
const [loading, setLoading] = useState(true)
const [activeCategory, setActiveCategory] = useState<string>('')
const [switchWarning, setSwitchWarning] = useState(false)
const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({})
const { addItem, items, subtotal, itemCount, restaurantId, restaurantSlug, deliveryFee } = useCart()
useEffect(() => {
api.get(`/restaurants/${slug}`)
.then((r) => {
setRestaurant(r.data)
setActiveCategory(r.data.menu?.[0]?.id || '')
})
.finally(() => setLoading(false))
}, [slug])
const handleAddItem = (item: MenuItem) => {
if (!restaurant) return
if (restaurantId && restaurantId !== restaurant.id) {
setSwitchWarning(true)
return
}
addItem(restaurant.id, restaurant.name, slug, {
menuItemId: item.id,
name: item.name,
price: item.price,
})
}
const scrollToCategory = (catId: string) => {
categoryRefs.current[catId]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
setActiveCategory(catId)
}
const cartSubtotal = subtotal()
const ccFee = Math.round((cartSubtotal + deliveryFee) * 0.029 * 100 + 30) / 100
const cartTotal = cartSubtotal + deliveryFee + ccFee
if (loading) return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-8 h-8 border-4 border-vibe-teal border-t-transparent rounded-full animate-spin" />
</div>
)
if (!restaurant) return (
<div className="min-h-screen flex items-center justify-center text-slate-400">Restaurant not found</div>
)
return (
<div className="min-h-screen bg-white">
{/* Switch restaurant warning */}
{switchWarning && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl p-6 max-w-sm w-full shadow-xl">
<h3 className="font-bold text-vibe-dark mb-2">Start a new order?</h3>
<p className="text-slate-500 text-sm mb-4">
You have items from <strong>{useCart.getState().restaurantName}</strong> in your cart. Starting a new order will clear it.
</p>
<div className="flex gap-3">
<button
onClick={() => setSwitchWarning(false)}
className="flex-1 py-2.5 border border-slate-200 rounded-xl text-sm font-medium"
>
Keep cart
</button>
<button
onClick={() => {
useCart.getState().clearCart()
setSwitchWarning(false)
}}
className="flex-1 py-2.5 bg-vibe-teal text-white rounded-xl text-sm font-medium"
>
Clear & switch
</button>
</div>
</div>
</div>
)}
{/* Banner */}
<div className="relative h-48 bg-slate-100">
{restaurant.banner_url ? (
<img src={restaurant.banner_url} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-r from-teal-600 to-teal-800" />
)}
<div className="absolute inset-0 bg-black/20" />
<Link href="/restaurants" className="absolute top-4 left-4 bg-white/90 backdrop-blur-sm text-vibe-dark text-sm font-medium px-3 py-1.5 rounded-full hover:bg-white transition">
Back
</Link>
</div>
<div className="max-w-5xl mx-auto px-4 -mt-6 relative">
{/* Restaurant info card */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 mb-6">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-slate-100 flex items-center justify-center text-3xl flex-shrink-0 overflow-hidden">
{restaurant.logo_url ? (
<img src={restaurant.logo_url} alt="" className="w-full h-full object-cover" />
) : '🍽️'}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-bold text-vibe-dark">{restaurant.name}</h1>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${restaurant.is_open ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
{restaurant.is_open ? 'Open' : 'Closed'}
</span>
</div>
<p className="text-slate-500 text-sm mt-0.5">{restaurant.cuisine_type?.join(' · ')}</p>
{restaurant.description && (
<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>·</span>
<span>{restaurant.avg_prep_time_minutes} min prep</span>
<span>·</span>
<span className="text-vibe-green font-medium">$5 delivery</span>
</div>
</div>
</div>
{/* Transparency banner */}
<div className="mt-4 bg-vibe-green/5 border border-vibe-green/20 rounded-xl px-4 py-2.5 flex items-center gap-2 text-sm">
<span className="text-vibe-green"></span>
<span className="text-slate-600">Menu prices match in-store prices always. No markup, ever.</span>
</div>
</div>
<div className="flex gap-8">
{/* Left: Menu */}
<div className="flex-1 min-w-0">
{/* Category nav */}
<div className="flex gap-2 overflow-x-auto pb-2 mb-6 sticky top-0 bg-white pt-2">
{restaurant.menu.map((cat) => (
<button
key={cat.id}
onClick={() => scrollToCategory(cat.id)}
className={`px-4 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition ${
activeCategory === cat.id
? 'bg-vibe-teal text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{cat.category}
</button>
))}
</div>
{/* Menu sections */}
{restaurant.menu.map((cat) => (
<div
key={cat.id}
ref={(el) => { categoryRefs.current[cat.id] = el }}
className="mb-8"
>
<h2 className="font-bold text-vibe-dark text-lg mb-4">{cat.category}</h2>
<div className="space-y-3">
{cat.items?.map((item) => (
<div
key={item.id}
className={`flex items-center gap-4 p-4 rounded-2xl border transition ${
item.is_available
? 'border-slate-100 hover:border-teal-200 hover:bg-teal-50/30 cursor-pointer'
: 'border-slate-100 opacity-50 cursor-not-allowed'
}`}
onClick={() => item.is_available && handleAddItem(item)}
>
{item.image_url && (
<img src={item.image_url} alt={item.name} className="w-20 h-20 rounded-xl object-cover flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-0.5">
<h3 className="font-semibold text-vibe-dark">{item.name}</h3>
{item.is_featured && <span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded">Popular</span>}
{item.dietary_tags?.map((t) => (
<span key={t} className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded capitalize">{t}</span>
))}
</div>
{item.description && (
<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>
{item.is_available && (
<button
onClick={(e) => { e.stopPropagation(); handleAddItem(item) }}
className="w-8 h-8 bg-vibe-teal text-white rounded-full flex items-center justify-center text-lg hover:bg-teal-700 transition"
>
+
</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Right: Cart (sticky) */}
<div className="w-80 flex-shrink-0 hidden lg:block">
<div className="sticky top-4">
<CartSidebar
items={items}
subtotal={cartSubtotal}
deliveryFee={deliveryFee}
ccFee={ccFee}
total={cartTotal}
restaurantSlug={restaurantSlug}
restaurantId={restaurantId}
currentRestaurantId={restaurant.id}
isOpen={restaurant.is_open}
onUpdateQuantity={(id, qty) => useCart.getState().updateQuantity(id, qty)}
onCheckout={() => router.push('/checkout')}
/>
</div>
</div>
</div>
</div>
{/* Mobile cart bar */}
{itemCount() > 0 && restaurantId === restaurant.id && (
<div className="fixed bottom-0 left-0 right-0 bg-vibe-teal text-white p-4 lg:hidden">
<button
onClick={() => router.push('/checkout')}
className="w-full flex items-center justify-between font-semibold"
>
<span className="bg-white/20 px-2.5 py-1 rounded-full text-sm">{itemCount()}</span>
<span>View Cart</span>
<span>${cartTotal.toFixed(2)}</span>
</button>
</div>
)}
</div>
)
}
function CartSidebar({
items, subtotal, deliveryFee, ccFee, total, restaurantSlug, restaurantId,
currentRestaurantId, isOpen, onUpdateQuantity, onCheckout
}: any) {
if (!items.length || restaurantId !== currentRestaurantId) {
return (
<div className="bg-slate-50 rounded-2xl border border-slate-100 p-6 text-center">
<div className="text-3xl mb-3">🛒</div>
<p className="text-slate-500 text-sm">Add items to start your order</p>
<div className="mt-4 pt-4 border-t border-slate-100 space-y-1.5 text-left text-xs text-slate-400">
<div className="flex justify-between"><span>Delivery fee</span><span className="text-vibe-green font-medium">$5.00 flat</span></div>
<div className="flex justify-between"><span>Service fee</span><span className="text-vibe-green font-medium">$0.00</span></div>
<div className="flex justify-between"><span>Hidden fees</span><span className="text-vibe-green font-medium">None. Ever.</span></div>
</div>
</div>
)
}
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-slate-100">
<h3 className="font-semibold text-vibe-dark">Your Order</h3>
</div>
<div className="divide-y divide-slate-50 max-h-72 overflow-y-auto">
{items.map((item: any) => (
<div key={item.menuItemId} className="px-4 py-3 flex items-center gap-3">
<div className="flex items-center gap-2">
<button
onClick={() => onUpdateQuantity(item.menuItemId, item.quantity - 1)}
className="w-6 h-6 rounded-full bg-slate-100 flex items-center justify-center text-slate-600 hover:bg-slate-200 text-sm"
></button>
<span className="text-sm font-medium w-4 text-center">{item.quantity}</span>
<button
onClick={() => onUpdateQuantity(item.menuItemId, item.quantity + 1)}
className="w-6 h-6 rounded-full bg-vibe-teal text-white flex items-center justify-center text-sm hover:bg-teal-700"
>+</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>
</div>
))}
</div>
<div className="px-4 py-3 border-t border-slate-100 space-y-1.5 text-sm">
<div className="flex justify-between text-slate-500"><span>Subtotal</span><span>${subtotal.toFixed(2)}</span></div>
<div className="flex justify-between text-slate-500"><span>Delivery</span><span>${deliveryFee.toFixed(2)}</span></div>
<div className="flex justify-between text-slate-400 text-xs"><span>CC processing (Stripe)</span><span>${ccFee.toFixed(2)}</span></div>
<div className="flex justify-between font-bold text-vibe-dark pt-1 border-t border-slate-100">
<span>Total</span><span>${total.toFixed(2)}</span>
</div>
<p className="text-xs text-slate-400">No service fees. No hidden charges.</p>
</div>
<div className="px-4 pb-4">
<button
onClick={onCheckout}
disabled={!isOpen}
className="w-full bg-vibe-teal text-white py-3 rounded-xl font-semibold text-sm hover:bg-teal-700 transition disabled:opacity-40 disabled:cursor-not-allowed"
>
{isOpen ? 'Go to Checkout →' : 'Restaurant is closed'}
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,171 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { MapView } from '@/components/map/MapView'
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
is_open: boolean
distance_km: number
lng: number
lat: number
zone_name: string
}
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 [cuisine, setCuisine] = useState('')
useEffect(() => {
// Try to get user's real location
navigator.geolocation?.getCurrentPosition(
(pos) => {
setUserLocation([pos.coords.longitude, pos.coords.latitude])
},
() => {}, // fallback to downtown Toronto
)
}, [])
useEffect(() => {
const load = async () => {
setLoading(true)
try {
const data = await api.get('/restaurants', {
params: { lng: userLocation[0], lat: userLocation[1], cuisine: cuisine || undefined },
})
setRestaurants(data.data)
} catch {}
setLoading(false)
}
load()
}, [userLocation, cuisine])
const cuisines = ['Pizza', 'Burgers', 'Japanese', 'Italian', 'Indian', 'Chinese', 'Mexican', 'Vegan']
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>
{/* 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">
<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'
}`}
>
All
</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>
<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}
/>
</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>
)}
{!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>
)}
</>
)}
{/* 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>
</div>
)
}

View File

@ -0,0 +1,185 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { api } from '@/lib/api'
interface Zone {
id: string
name: string
city: string
status: 'active' | 'coming_soon' | 'inactive'
restaurant_count?: number
driver_count?: number
}
const STATUS_CONFIG = {
active: { label: 'Active', color: 'bg-vibe-green/10 text-vibe-green border-vibe-green/20', dot: 'bg-vibe-green' },
coming_soon: { label: 'Coming Soon', color: 'bg-amber-50 text-amber-700 border-amber-200', dot: 'bg-amber-400' },
inactive: { label: 'Not yet available', color: 'bg-slate-100 text-slate-500 border-slate-200', dot: 'bg-slate-300' },
}
// Fallback zones if API not available
const FALLBACK_ZONES: Zone[] = [
{ id: '1', name: 'Downtown Toronto', city: 'Toronto', status: 'active' },
{ id: '2', name: 'North York', city: 'Toronto', status: 'active' },
{ id: '3', name: 'Scarborough', city: 'Toronto', status: 'coming_soon' },
{ id: '4', name: 'Etobicoke', city: 'Toronto', status: 'coming_soon' },
{ id: '5', name: 'Mississauga', city: 'Mississauga', status: 'coming_soon' },
{ id: '6', name: 'Brampton', city: 'Brampton', status: 'inactive' },
{ id: '7', name: 'Markham', city: 'Markham', status: 'inactive' },
{ id: '8', name: 'Richmond Hill', city: 'Richmond Hill', status: 'inactive' },
]
export default function ZonesPage() {
const [zones, setZones] = useState<Zone[]>(FALLBACK_ZONES)
useEffect(() => {
api.get('/zones')
.then((r) => { if (r.data?.length) setZones(r.data) })
.catch(() => {}) // use fallback
}, [])
const active = zones.filter((z) => z.status === 'active')
const comingSoon = zones.filter((z) => z.status === 'coming_soon')
const inactive = zones.filter((z) => z.status === 'inactive')
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">
<Link href="/" className="text-2xl font-bold text-vibe-teal">The Vibe</Link>
<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="/about" className="text-slate-600 hover:text-vibe-teal text-sm">About</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</Link>
</div>
</nav>
{/* Hero */}
<section className="bg-vibe-cream px-6 py-16 text-center">
<div className="max-w-2xl mx-auto">
<div className="inline-block bg-vibe-teal/10 text-vibe-teal text-sm font-semibold px-4 py-1.5 rounded-full mb-6">
Greater Toronto Area
</div>
<h1 className="text-4xl font-bold text-vibe-dark mb-4">Service Areas</h1>
<p className="text-lg text-slate-600">
We're expanding zone by zone, making sure each area has enough restaurants and drivers before we launch. Quality over speed.
</p>
</div>
</section>
<div className="max-w-4xl mx-auto px-6 py-12 space-y-12">
{/* Active zones */}
{active.length > 0 && (
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-3 h-3 rounded-full bg-vibe-green" />
<h2 className="text-xl font-bold text-vibe-dark">Live Now</h2>
<span className="text-sm text-slate-400"> Order delivery today</span>
</div>
<div className="grid md:grid-cols-2 gap-4">
{active.map((zone) => (
<div key={zone.id} className="border border-vibe-green/20 bg-vibe-green/5 rounded-2xl p-5 flex items-center justify-between">
<div>
<h3 className="font-semibold text-vibe-dark">{zone.name}</h3>
<p className="text-sm text-slate-500 mt-0.5">{zone.city}</p>
{(zone.restaurant_count != null) && (
<p className="text-xs text-vibe-green mt-1">
{zone.restaurant_count} restaurants · {zone.driver_count} drivers
</p>
)}
</div>
<Link
href="/restaurants"
className="bg-vibe-teal text-white text-sm px-4 py-2 rounded-xl font-medium hover:bg-teal-700 transition"
>
Order
</Link>
</div>
))}
</div>
</div>
)}
{/* Coming soon */}
{comingSoon.length > 0 && (
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-3 h-3 rounded-full bg-amber-400" />
<h2 className="text-xl font-bold text-vibe-dark">Coming Soon</h2>
<span className="text-sm text-slate-400"> In preparation</span>
</div>
<div className="grid md:grid-cols-2 gap-4">
{comingSoon.map((zone) => (
<div key={zone.id} className="border border-amber-200 bg-amber-50 rounded-2xl p-5 flex items-center justify-between">
<div>
<h3 className="font-semibold text-vibe-dark">{zone.name}</h3>
<p className="text-sm text-slate-500 mt-0.5">{zone.city}</p>
</div>
<span className="text-xs text-amber-600 font-semibold bg-amber-100 px-3 py-1.5 rounded-full">Soon</span>
</div>
))}
</div>
</div>
)}
{/* Planned */}
{inactive.length > 0 && (
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-3 h-3 rounded-full bg-slate-300" />
<h2 className="text-xl font-bold text-vibe-dark">Planned</h2>
<span className="text-sm text-slate-400"> On the roadmap</span>
</div>
<div className="grid md:grid-cols-3 gap-3">
{inactive.map((zone) => (
<div key={zone.id} className="border border-slate-100 rounded-xl p-4">
<h3 className="font-medium text-slate-600">{zone.name}</h3>
<p className="text-xs text-slate-400 mt-0.5">{zone.city}</p>
</div>
))}
</div>
</div>
)}
{/* Restaurant CTA */}
<div className="bg-vibe-dark text-white rounded-2xl p-8 text-center">
<h2 className="text-2xl font-bold mb-3">Restaurant in an upcoming zone?</h2>
<p className="text-slate-400 mb-6">
Register now and we'll notify you the moment your zone goes live. First 10 restaurants in each zone get 3 months free.
</p>
<Link
href="/register?role=restaurant_owner"
className="bg-vibe-green text-white px-8 py-3 rounded-xl font-semibold hover:bg-green-500 transition inline-block"
>
Pre-Register Your Restaurant
</Link>
</div>
{/* Driver CTA */}
<div className="bg-amber-50 border border-amber-100 rounded-2xl p-8 text-center">
<h2 className="text-2xl font-bold text-vibe-dark mb-3">Drive in your neighbourhood</h2>
<p className="text-slate-600 mb-6">
Apply now. We'll activate you as soon as your zone is live. Priority given to drivers who sign up early.
</p>
<Link
href="/register?role=driver"
className="bg-vibe-teal text-white px-8 py-3 rounded-xl font-semibold hover:bg-teal-700 transition inline-block"
>
Apply to Drive
</Link>
</div>
</div>
{/* Footer */}
<footer className="border-t border-slate-100 py-8 px-6 text-center text-sm text-slate-400">
© 2025 The Vibe Inc. · Toronto, ON ·{' '}
<Link href="/" className="hover:text-vibe-teal">Home</Link>
{' · '}
<Link href="/about" className="hover:text-vibe-teal">About</Link>
</footer>
</div>
)
}

View File

@ -0,0 +1,237 @@
'use client'
import { useEffect, useRef, useCallback } from 'react'
import maplibregl from 'maplibre-gl'
interface Restaurant {
id: string
name: string
slug: string
lng: number
lat: number
is_open: boolean
cuisine_type: string[]
rating?: number
}
interface DriverMarker {
id: string
lng: number
lat: number
heading?: number
}
interface MapViewProps {
center: [number, number] // [lng, lat]
zoom?: number
restaurants?: Restaurant[]
drivers?: DriverMarker[]
driverLocation?: [number, number]
deliveryLocation?: [number, number]
routeGeoJSON?: any // OSRM GeoJSON route
onRestaurantClick?: (id: string) => void
className?: string
}
// ============================================================
// MapLibre GL JS + OpenStreetMap Integration
// Tiles: MapTiler (free OSM-based tiles, no proprietary data)
// ============================================================
export function MapView({
center,
zoom = 13,
restaurants = [],
drivers = [],
driverLocation,
deliveryLocation,
routeGeoJSON,
onRestaurantClick,
className = '',
}: MapViewProps) {
const mapContainer = useRef<HTMLDivElement>(null)
const map = useRef<maplibregl.Map | null>(null)
const markersRef = useRef<maplibregl.Marker[]>([])
const driverMarkerRef = useRef<maplibregl.Marker | null>(null)
// Initialize map
useEffect(() => {
if (!mapContainer.current || map.current) return
const apiKey = process.env.NEXT_PUBLIC_MAPTILER_KEY
map.current = new maplibregl.Map({
container: mapContainer.current,
// MapTiler OSM style (free tier - open data only)
style: apiKey
? `https://api.maptiler.com/maps/streets/style.json?key=${apiKey}`
: 'https://demotiles.maplibre.org/style.json', // fallback demo tiles
center: center,
zoom: zoom,
})
map.current.addControl(new maplibregl.NavigationControl(), 'top-right')
// GTA service area boundary (loaded on init)
map.current.on('load', () => {
loadGTAZones()
})
return () => {
map.current?.remove()
map.current = null
}
}, [])
const loadGTAZones = async () => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/zones/geojson`)
const geojson = await res.json()
if (!map.current) return
map.current.addSource('zones', { type: 'geojson', data: geojson })
// Zone fill
map.current.addLayer({
id: 'zones-fill',
type: 'fill',
source: 'zones',
paint: {
'fill-color': [
'match', ['get', 'status'],
'active', '#0D9488',
'coming_soon', '#F59E0B',
'#94A3B8',
],
'fill-opacity': 0.08,
},
})
// Zone border
map.current.addLayer({
id: 'zones-border',
type: 'line',
source: 'zones',
paint: {
'line-color': [
'match', ['get', 'status'],
'active', '#0D9488',
'coming_soon', '#F59E0B',
'#94A3B8',
],
'line-width': 2,
'line-dasharray': ['match', ['get', 'status'], 'coming_soon', ['literal', [4, 4]], ['literal', [1]]],
},
})
} catch (e) {
console.warn('Could not load zone boundaries:', e)
}
}
// Restaurant markers
useEffect(() => {
if (!map.current) return
// Remove old markers
markersRef.current.forEach((m) => m.remove())
markersRef.current = []
restaurants.forEach((restaurant) => {
const el = document.createElement('div')
el.className = 'restaurant-marker'
el.innerHTML = `
<div class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg cursor-pointer border-2 transition-transform hover:scale-110 ${
restaurant.is_open
? 'bg-white border-teal-600'
: 'bg-slate-100 border-slate-300 opacity-60'
}">
🍽
</div>
`
const popup = new maplibregl.Popup({ offset: 25, closeButton: false }).setHTML(`
<div class="p-2 min-w-[140px]">
<p class="font-semibold text-sm text-gray-900">${restaurant.name}</p>
<p class="text-xs text-gray-500 mt-0.5">${restaurant.cuisine_type?.join(', ')}</p>
${restaurant.is_open
? '<p class="text-xs text-teal-600 font-medium mt-1">Open · $5 delivery</p>'
: '<p class="text-xs text-gray-400 mt-1">Closed</p>'
}
</div>
`)
const marker = new maplibregl.Marker({ element: el })
.setLngLat([restaurant.lng, restaurant.lat])
.setPopup(popup)
.addTo(map.current!)
el.addEventListener('click', () => {
if (restaurant.is_open) onRestaurantClick?.(restaurant.id)
})
markersRef.current.push(marker)
})
}, [restaurants])
// Driver location marker
useEffect(() => {
if (!map.current || !driverLocation) return
if (driverMarkerRef.current) {
driverMarkerRef.current.setLngLat(driverLocation)
} else {
const el = document.createElement('div')
el.innerHTML = `
<div class="w-10 h-10 bg-vibe-teal rounded-full flex items-center justify-center shadow-lg border-2 border-white">
🚴
</div>
`
driverMarkerRef.current = new maplibregl.Marker({ element: el })
.setLngLat(driverLocation)
.addTo(map.current)
}
}, [driverLocation])
// Delivery destination marker
useEffect(() => {
if (!map.current || !deliveryLocation) return
const el = document.createElement('div')
el.innerHTML = `<div class="text-2xl">📍</div>`
new maplibregl.Marker({ element: el })
.setLngLat(deliveryLocation)
.addTo(map.current)
}, [deliveryLocation])
// OSRM route polyline
useEffect(() => {
if (!map.current || !routeGeoJSON) return
const mapRef = map.current
const addRoute = () => {
if (mapRef.getSource('route')) {
(mapRef.getSource('route') as maplibregl.GeoJSONSource).setData(routeGeoJSON)
} else {
mapRef.addSource('route', { type: 'geojson', data: routeGeoJSON })
mapRef.addLayer({
id: 'route-line',
type: 'line',
source: 'route',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': '#0D9488', 'line-width': 4, 'line-opacity': 0.8 },
})
}
}
if (mapRef.loaded()) addRoute()
else mapRef.on('load', addRoute)
}, [routeGeoJSON])
return (
<div
ref={mapContainer}
className={`w-full h-full rounded-2xl ${className}`}
style={{ minHeight: '400px' }}
/>
)
}

View File

@ -0,0 +1,120 @@
import { useEffect, useRef, useCallback } from 'react'
import { io, Socket } from 'socket.io-client'
import { api } from '@/lib/api'
// ============================================================
// DRIVER TRACKING HOOK
// Sends GPS position to backend every 5 seconds via:
// 1. REST API (persistent, for DB record)
// 2. Socket.IO (real-time, for customer map)
// ============================================================
export function useDriverTracking(activeOrderId?: string) {
const socketRef = useRef<Socket | null>(null)
const watchIdRef = useRef<number | null>(null)
const isTrackingRef = useRef(false)
const sendLocation = useCallback(
async (position: GeolocationPosition) => {
const { longitude: lng, latitude: lat, heading, speed } = position.coords
const speedKmh = speed ? speed * 3.6 : undefined
// 1. Send to Socket.IO (real-time to customers)
if (socketRef.current?.connected) {
socketRef.current.emit('driver:location', {
lng, lat,
heading: heading ?? undefined,
speed: speedKmh,
orderId: activeOrderId,
})
}
// 2. REST API update (persisted to DB)
api.patch('/drivers/me/location', { lng, lat, heading, speedKmh }).catch(() => {})
},
[activeOrderId],
)
const startTracking = useCallback(() => {
if (isTrackingRef.current) return
isTrackingRef.current = true
// Connect Socket.IO
const token = typeof window !== 'undefined' ? localStorage.getItem('vibe_token') : null
socketRef.current = io(`${process.env.NEXT_PUBLIC_WS_URL}/tracking`, {
auth: { token },
transports: ['websocket'],
})
// Join zone room for order dispatching
socketRef.current.on('connect', () => {
socketRef.current?.emit('join:zone', 'downtown-toronto') // TODO: driver's actual zone
})
// Start GPS watch
watchIdRef.current = navigator.geolocation?.watchPosition(
sendLocation,
(err) => console.warn('GPS error:', err),
{
enableHighAccuracy: true,
maximumAge: 5000,
timeout: 10000,
},
)
}, [sendLocation])
const stopTracking = useCallback(() => {
isTrackingRef.current = false
if (watchIdRef.current !== null) {
navigator.geolocation?.clearWatch(watchIdRef.current)
watchIdRef.current = null
}
socketRef.current?.disconnect()
socketRef.current = null
}, [])
// Cleanup on unmount
useEffect(() => {
return () => stopTracking()
}, [stopTracking])
return { startTracking, stopTracking, socket: socketRef.current }
}
// ============================================================
// ORDER TRACKING HOOK (for customers)
// Subscribes to real-time driver location updates
// ============================================================
export function useOrderTracking(orderId: string, onLocation: (data: any) => void) {
const socketRef = useRef<Socket | null>(null)
useEffect(() => {
if (!orderId) return
const token = typeof window !== 'undefined' ? localStorage.getItem('vibe_token') : null
socketRef.current = io(`${process.env.NEXT_PUBLIC_WS_URL}/tracking`, {
auth: { token },
transports: ['websocket'],
})
socketRef.current.on('connect', () => {
socketRef.current?.emit('join:order', orderId)
})
socketRef.current.on('driver:moved', onLocation)
socketRef.current.on('order:status', (data) => {
console.log('Order status update:', data)
})
return () => {
socketRef.current?.disconnect()
}
}, [orderId])
return socketRef.current
}

View File

@ -0,0 +1,34 @@
import axios from 'axios'
const BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'
export const api = axios.create({
baseURL: `${BASE}/api/v1`,
headers: { 'Content-Type': 'application/json' },
})
// Read token — supports both key names used across the codebase
export const getToken = () => {
if (typeof window === 'undefined') return null
return localStorage.getItem('vibe_token') || localStorage.getItem('token')
}
// Attach JWT on every request
api.interceptors.request.use((config) => {
const token = getToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// Handle 401 → redirect to login
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401 && typeof window !== 'undefined') {
localStorage.removeItem('vibe_token')
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(err)
},
)

View File

@ -0,0 +1,82 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface CartItem {
menuItemId: string
name: string
price: number
quantity: number
specialRequest?: string
}
interface CartStore {
restaurantId: string | null
restaurantName: string | null
restaurantSlug: string | null
items: CartItem[]
addItem: (restaurantId: string, restaurantName: string, restaurantSlug: string, item: Omit<CartItem, 'quantity'>) => void
removeItem: (menuItemId: string) => void
updateQuantity: (menuItemId: string, quantity: number) => void
clearCart: () => void
// Computed
subtotal: () => number
itemCount: () => number
deliveryFee: number
}
export const useCart = create<CartStore>()(
persist(
(set, get) => ({
restaurantId: null,
restaurantName: null,
restaurantSlug: null,
items: [],
deliveryFee: 5.00,
addItem: (restaurantId, restaurantName, restaurantSlug, item) => {
const { restaurantId: currentRestId, items } = get()
// Switching restaurant — clear cart first
if (currentRestId && currentRestId !== restaurantId) {
set({ restaurantId, restaurantName, restaurantSlug, items: [{ ...item, quantity: 1 }] })
return
}
const existing = items.find((i) => i.menuItemId === item.menuItemId)
if (existing) {
set({
items: items.map((i) =>
i.menuItemId === item.menuItemId ? { ...i, quantity: i.quantity + 1 } : i,
),
})
} else {
set({
restaurantId,
restaurantName,
restaurantSlug,
items: [...items, { ...item, quantity: 1 }],
})
}
},
removeItem: (menuItemId) =>
set({ items: get().items.filter((i) => i.menuItemId !== menuItemId) }),
updateQuantity: (menuItemId, quantity) => {
if (quantity <= 0) {
get().removeItem(menuItemId)
return
}
set({ items: get().items.map((i) => (i.menuItemId === menuItemId ? { ...i, quantity } : i)) })
},
clearCart: () => set({ restaurantId: null, restaurantName: null, restaurantSlug: null, items: [] }),
subtotal: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
itemCount: () => get().items.reduce((sum, i) => sum + i.quantity, 0),
}),
{ name: 'vibe-cart' },
),
)

View File

@ -0,0 +1,73 @@
// ============================================================
// OSRM Routing Client
// Fetches turn-by-turn routes from our self-hosted OSRM instance
// Falls back to straight line if OSRM unavailable
// ============================================================
const OSRM_BASE = process.env.NEXT_PUBLIC_OSRM_URL || 'http://localhost:5000'
export interface OSRMRoute {
distance: number // meters
duration: number // seconds
geometry: GeoJSON.LineString
legs: OSRMLeg[]
}
export interface OSRMLeg {
distance: number
duration: number
steps: OSRMStep[]
}
export interface OSRMStep {
instruction: string
distance: number
duration: number
maneuver: {
type: string
modifier?: string
}
}
export async function getRoute(
origin: [number, number], // [lng, lat]
destination: [number, number],
waypoints?: [number, number][],
): Promise<OSRMRoute | null> {
try {
const coords = [origin, ...(waypoints || []), destination]
.map(([lng, lat]) => `${lng},${lat}`)
.join(';')
const url = `${OSRM_BASE}/route/v1/driving/${coords}?overview=full&geometries=geojson&steps=true`
const res = await fetch(url)
if (!res.ok) return null
const data = await res.json()
if (data.code !== 'Ok' || !data.routes?.[0]) return null
return data.routes[0] as OSRMRoute
} catch {
return null
}
}
export function routeToGeoJSON(route: OSRMRoute) {
return {
type: 'Feature',
geometry: route.geometry,
properties: {
distance_m: route.distance,
duration_s: route.duration,
distance_km: (route.distance / 1000).toFixed(2),
duration_min: Math.ceil(route.duration / 60),
},
}
}
// ETA calculator with traffic buffer
export function calculateETA(durationSeconds: number, bufferMinutes: number = 5): Date {
const eta = new Date()
eta.setSeconds(eta.getSeconds() + durationSeconds + bufferMinutes * 60)
return eta
}

View File

@ -0,0 +1,31 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
// The Vibe brand colors - ethical, warm, trustworthy
vibe: {
green: '#22C55E', // profit, fairness
teal: '#0D9488', // primary action
dark: '#0F172A', // text
slate: '#334155', // secondary text
cream: '#FFF7ED', // warm background
amber: '#F59E0B', // break-even indicator
red: '#EF4444', // alerts
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}
export default config

View File

@ -0,0 +1,37 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"incremental": true,
"module": "esnext",
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

59
scripts/setup-osrm.sh Normal file
View File

@ -0,0 +1,59 @@
#!/usr/bin/env bash
# ============================================================
# The Vibe - OSRM Routing Engine Setup
# Downloads Ontario OSM data and processes it for OSRM
#
# Run this ONCE before starting docker-compose with --profile routing
# Estimated time: 5-15 minutes depending on machine
# ============================================================
set -e
OSRM_DATA_DIR="./osrm_data"
OSM_FILE="ontario-latest.osm.pbf"
OSM_URL="https://download.geofabrik.de/north-america/canada/ontario-latest.osm.pbf"
echo "==> Setting up OSRM for Ontario, Canada"
echo ""
# Create data directory
mkdir -p "$OSRM_DATA_DIR"
# Step 1: Download Ontario OSM data
if [ ! -f "$OSRM_DATA_DIR/$OSM_FILE" ]; then
echo "==> Downloading Ontario OSM data (~200MB)..."
curl -L "$OSM_URL" -o "$OSRM_DATA_DIR/$OSM_FILE"
echo "==> Download complete."
else
echo "==> Ontario OSM file already exists, skipping download."
fi
# Step 2: Extract using car profile
echo "==> Extracting road network (car profile)..."
docker run --rm \
-v "$(pwd)/$OSRM_DATA_DIR:/data" \
osrm/osrm-backend:latest \
osrm-extract -p /opt/car.lua /data/$OSM_FILE
# Step 3: Partition
echo "==> Partitioning..."
docker run --rm \
-v "$(pwd)/$OSRM_DATA_DIR:/data" \
osrm/osrm-backend:latest \
osrm-partition /data/ontario-latest.osrm
# Step 4: Customize
echo "==> Customizing..."
docker run --rm \
-v "$(pwd)/$OSRM_DATA_DIR:/data" \
osrm/osrm-backend:latest \
osrm-customize /data/ontario-latest.osrm
echo ""
echo "==> OSRM setup complete!"
echo ""
echo "Start the OSRM routing engine with:"
echo " docker-compose --profile routing up -d osrm"
echo ""
echo "Test it:"
echo " curl 'http://localhost:5000/route/v1/driving/-79.3832,43.6532;-79.4034,43.6487?overview=false'"

75
scripts/setup-stripe.sh Normal file
View File

@ -0,0 +1,75 @@
#!/usr/bin/env bash
# ============================================================
# The Vibe - Stripe Product & Price Setup
# Run once to create the Stripe products and prices needed
#
# Prerequisites:
# npm install -g stripe (or use the Stripe CLI)
# stripe login
# ============================================================
set -e
echo "==> Creating Stripe products and prices for The Vibe..."
# ---- RESTAURANT MONTHLY SUBSCRIPTION ----
echo ""
echo "==> Creating Restaurant Monthly Subscription ($500/month)..."
RESTAURANT_PRODUCT=$(stripe products create \
--name="The Vibe - Restaurant Subscription" \
--description="Monthly flat-fee subscription. No commission. Unlimited orders." \
--metadata[platform]="the-vibe" \
--metadata[type]="restaurant_subscription" \
--format=json)
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_PRICE=$(stripe prices create \
--product="$RESTAURANT_PRODUCT_ID" \
--unit-amount=50000 \
--currency=cad \
--recurring[interval]=month \
--nickname="Restaurant Monthly - CAD $500" \
--format=json)
RESTAURANT_PRICE_ID=$(echo $RESTAURANT_PRICE | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Restaurant price ID: $RESTAURANT_PRICE_ID"
# ---- DRIVER DAILY FEE ----
echo ""
echo "==> Creating Driver Daily Access Fee ($20/day)..."
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." \
--metadata[platform]="the-vibe" \
--metadata[type]="driver_daily_fee" \
--format=json)
DRIVER_PRODUCT_ID=$(echo $DRIVER_PRODUCT | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Driver product ID: $DRIVER_PRODUCT_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)
DRIVER_PRICE_ID=$(echo $DRIVER_PRICE | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Driver price ID: $DRIVER_PRICE_ID"
# ---- 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 "============================================================"
echo ""
echo "Also set up your webhook:"
echo " stripe listen --forward-to localhost:3001/api/v1/payments/webhook"
echo " (copy the webhook secret into STRIPE_WEBHOOK_SECRET)"