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:
commit
89cf37f5b5
66
.env.example
Normal file
66
.env.example
Normal 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
87
.gitignore
vendored
Normal 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
219
README.md
Normal 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
70
docker-compose.yml
Normal 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
23637
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
51
packages/backend/package.json
Normal file
51
packages/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
37
packages/backend/src/app.module.ts
Normal file
37
packages/backend/src/app.module.ts
Normal 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 {}
|
||||
31
packages/backend/src/database/database.module.ts
Normal file
31
packages/backend/src/database/database.module.ts
Normal 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 {}
|
||||
37
packages/backend/src/database/database.service.ts
Normal file
37
packages/backend/src/database/database.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
32
packages/backend/src/main.ts
Normal file
32
packages/backend/src/main.ts
Normal 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();
|
||||
54
packages/backend/src/modules/admin/admin.controller.ts
Normal file
54
packages/backend/src/modules/admin/admin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
packages/backend/src/modules/admin/admin.module.ts
Normal file
10
packages/backend/src/modules/admin/admin.module.ts
Normal 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 {}
|
||||
147
packages/backend/src/modules/admin/admin.service.ts
Normal file
147
packages/backend/src/modules/admin/admin.service.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
39
packages/backend/src/modules/auth/auth.controller.ts
Normal file
39
packages/backend/src/modules/auth/auth.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
27
packages/backend/src/modules/auth/auth.module.ts
Normal file
27
packages/backend/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
85
packages/backend/src/modules/auth/auth.service.ts
Normal file
85
packages/backend/src/modules/auth/auth.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
26
packages/backend/src/modules/auth/guards/roles.guard.ts
Normal file
26
packages/backend/src/modules/auth/guards/roles.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
25
packages/backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
25
packages/backend/src/modules/auth/strategies/jwt.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
129
packages/backend/src/modules/drivers/breakeven.service.ts
Normal file
129
packages/backend/src/modules/drivers/breakeven.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
82
packages/backend/src/modules/drivers/drivers.controller.ts
Normal file
82
packages/backend/src/modules/drivers/drivers.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
12
packages/backend/src/modules/drivers/drivers.module.ts
Normal file
12
packages/backend/src/modules/drivers/drivers.module.ts
Normal 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 {}
|
||||
174
packages/backend/src/modules/drivers/drivers.service.ts
Normal file
174
packages/backend/src/modules/drivers/drivers.service.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
9
packages/backend/src/modules/email/email.module.ts
Normal file
9
packages/backend/src/modules/email/email.module.ts
Normal 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 {}
|
||||
174
packages/backend/src/modules/email/email.service.ts
Normal file
174
packages/backend/src/modules/email/email.service.ts
Normal 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>
|
||||
`);
|
||||
}
|
||||
}
|
||||
49
packages/backend/src/modules/menu/menu.controller.ts
Normal file
49
packages/backend/src/modules/menu/menu.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
packages/backend/src/modules/menu/menu.module.ts
Normal file
11
packages/backend/src/modules/menu/menu.module.ts
Normal 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 {}
|
||||
73
packages/backend/src/modules/menu/menu.service.ts
Normal file
73
packages/backend/src/modules/menu/menu.service.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
72
packages/backend/src/modules/orders/orders.controller.ts
Normal file
72
packages/backend/src/modules/orders/orders.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
packages/backend/src/modules/orders/orders.module.ts
Normal file
11
packages/backend/src/modules/orders/orders.module.ts
Normal 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 {}
|
||||
379
packages/backend/src/modules/orders/orders.service.ts
Normal file
379
packages/backend/src/modules/orders/orders.service.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
56
packages/backend/src/modules/payments/payments.controller.ts
Normal file
56
packages/backend/src/modules/payments/payments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
packages/backend/src/modules/payments/payments.module.ts
Normal file
11
packages/backend/src/modules/payments/payments.module.ts
Normal 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 {}
|
||||
265
packages/backend/src/modules/payments/payments.service.ts
Normal file
265
packages/backend/src/modules/payments/payments.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
180
packages/backend/src/modules/restaurants/restaurants.service.ts
Normal file
180
packages/backend/src/modules/restaurants/restaurants.service.ts
Normal 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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
36
packages/backend/src/modules/reviews/reviews.controller.ts
Normal file
36
packages/backend/src/modules/reviews/reviews.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
packages/backend/src/modules/reviews/reviews.module.ts
Normal file
11
packages/backend/src/modules/reviews/reviews.module.ts
Normal 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 {}
|
||||
137
packages/backend/src/modules/reviews/reviews.service.ts
Normal file
137
packages/backend/src/modules/reviews/reviews.service.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
174
packages/backend/src/modules/tracking/tracking.gateway.ts
Normal file
174
packages/backend/src/modules/tracking/tracking.gateway.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
19
packages/backend/src/modules/tracking/tracking.module.ts
Normal file
19
packages/backend/src/modules/tracking/tracking.module.ts
Normal 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 {}
|
||||
8
packages/backend/src/modules/users/users.module.ts
Normal file
8
packages/backend/src/modules/users/users.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
|
||||
@Module({
|
||||
providers: [DatabaseService],
|
||||
exports: [DatabaseService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
22
packages/backend/src/modules/zones/zones.controller.ts
Normal file
22
packages/backend/src/modules/zones/zones.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
11
packages/backend/src/modules/zones/zones.module.ts
Normal file
11
packages/backend/src/modules/zones/zones.module.ts
Normal 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 {}
|
||||
67
packages/backend/src/modules/zones/zones.service.ts
Normal file
67
packages/backend/src/modules/zones/zones.service.ts
Normal 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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
21
packages/backend/tsconfig.json
Normal file
21
packages/backend/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
48
packages/database/reset-dev-passwords.js
Normal file
48
packages/database/reset-dev-passwords.js
Normal 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) })
|
||||
558
packages/database/schema.sql
Normal file
558
packages/database/schema.sql
Normal 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
199
packages/database/seed.sql
Normal 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
46
packages/mobile/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
packages/mobile/app/(tabs)/_layout.tsx
Normal file
42
packages/mobile/app/(tabs)/_layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
211
packages/mobile/app/(tabs)/earnings.tsx
Normal file
211
packages/mobile/app/(tabs)/earnings.tsx
Normal 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 },
|
||||
})
|
||||
263
packages/mobile/app/(tabs)/index.tsx
Normal file
263
packages/mobile/app/(tabs)/index.tsx
Normal 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' },
|
||||
})
|
||||
274
packages/mobile/app/(tabs)/orders.tsx
Normal file
274
packages/mobile/app/(tabs)/orders.tsx
Normal 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 },
|
||||
})
|
||||
24
packages/mobile/app/lib/api.ts
Normal file
24
packages/mobile/app/lib/api.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
38
packages/mobile/package.json
Normal file
38
packages/mobile/package.json
Normal 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
5
packages/web/next-env.d.ts
vendored
Normal 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.
|
||||
21
packages/web/next.config.mjs
Normal file
21
packages/web/next.config.mjs
Normal 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
34
packages/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
packages/web/postcss.config.js
Normal file
6
packages/web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
148
packages/web/src/app/about/page.tsx
Normal file
148
packages/web/src/app/about/page.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
const team = [
|
||||
{
|
||||
role: 'The Problem We Saw',
|
||||
body: 'Delivery apps charge restaurants 25–30% 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>
|
||||
)
|
||||
}
|
||||
239
packages/web/src/app/admin/page.tsx
Normal file
239
packages/web/src/app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
355
packages/web/src/app/admin/zones/page.tsx
Normal file
355
packages/web/src/app/admin/zones/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
280
packages/web/src/app/checkout/page.tsx
Normal file
280
packages/web/src/app/checkout/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
266
packages/web/src/app/driver/dashboard/page.tsx
Normal file
266
packages/web/src/app/driver/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
230
packages/web/src/app/driver/earnings/page.tsx
Normal file
230
packages/web/src/app/driver/earnings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
316
packages/web/src/app/driver/orders/page.tsx
Normal file
316
packages/web/src/app/driver/orders/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
packages/web/src/app/globals.css
Normal file
26
packages/web/src/app/globals.css
Normal 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;
|
||||
}
|
||||
19
packages/web/src/app/layout.tsx
Normal file
19
packages/web/src/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
packages/web/src/app/login/page.tsx
Normal file
113
packages/web/src/app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
244
packages/web/src/app/orders/[id]/review/page.tsx
Normal file
244
packages/web/src/app/orders/[id]/review/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
183
packages/web/src/app/orders/[id]/track/page.tsx
Normal file
183
packages/web/src/app/orders/[id]/track/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
195
packages/web/src/app/page.tsx
Normal file
195
packages/web/src/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
206
packages/web/src/app/register/page.tsx
Normal file
206
packages/web/src/app/register/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
224
packages/web/src/app/restaurant/dashboard/page.tsx
Normal file
224
packages/web/src/app/restaurant/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
454
packages/web/src/app/restaurant/onboarding/page.tsx
Normal file
454
packages/web/src/app/restaurant/onboarding/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
208
packages/web/src/app/restaurant/orders/page.tsx
Normal file
208
packages/web/src/app/restaurant/orders/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
350
packages/web/src/app/restaurants/[slug]/page.tsx
Normal file
350
packages/web/src/app/restaurants/[slug]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
packages/web/src/app/restaurants/page.tsx
Normal file
171
packages/web/src/app/restaurants/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
185
packages/web/src/app/zones/page.tsx
Normal file
185
packages/web/src/app/zones/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
237
packages/web/src/components/map/MapView.tsx
Normal file
237
packages/web/src/components/map/MapView.tsx
Normal 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' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
120
packages/web/src/hooks/useDriverTracking.ts
Normal file
120
packages/web/src/hooks/useDriverTracking.ts
Normal 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
|
||||
}
|
||||
34
packages/web/src/lib/api.ts
Normal file
34
packages/web/src/lib/api.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
82
packages/web/src/lib/cart.ts
Normal file
82
packages/web/src/lib/cart.ts
Normal 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' },
|
||||
),
|
||||
)
|
||||
73
packages/web/src/lib/osrm.ts
Normal file
73
packages/web/src/lib/osrm.ts
Normal 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
|
||||
}
|
||||
31
packages/web/tailwind.config.ts
Normal file
31
packages/web/tailwind.config.ts
Normal 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
|
||||
37
packages/web/tsconfig.json
Normal file
37
packages/web/tsconfig.json
Normal 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
59
scripts/setup-osrm.sh
Normal 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
75
scripts/setup-stripe.sh
Normal 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)"
|
||||
Loading…
x
Reference in New Issue
Block a user