fixing platform admin

This commit is contained in:
root
2026-05-06 22:58:23 -04:00
parent 695a7f7cc7
commit 750ae56a29
175 changed files with 31249 additions and 328 deletions
+16
View File
@@ -0,0 +1,16 @@
node_modules
.turbo
.git
.gitignore
.codex
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.next
dist
coverage
tmp
.env.local
.env.*.local
+23
View File
@@ -0,0 +1,23 @@
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego
REDIS_URL=redis://redis:6379
API_PORT=4000
API_URL=http://localhost:4000
API_INTERNAL_URL=http://api:4000/api/v1
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001
NEXT_PUBLIC_ADMIN_URL=http://localhost:3002
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
DASHBOARD_URL=http://localhost:3001
JWT_SECRET=dev-secret
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
ADMIN_SEED_EMAIL=admin@rentaldrivego.com
ADMIN_SEED_PASSWORD=changeme123
ADMIN_SEED_FIRST_NAME=Platform
ADMIN_SEED_LAST_NAME=Admin
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
NODE_ENV=development
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003
+17
View File
@@ -0,0 +1,17 @@
DATABASE_URL=postgresql://postgres:change-me@postgres:5432/rentaldrivego
REDIS_URL=redis://redis:6379
API_PORT=4000
API_URL=http://localhost:4000
API_INTERNAL_URL=http://api:4000/api/v1
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/explore
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001
NEXT_PUBLIC_ADMIN_URL=http://localhost:3002
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
DASHBOARD_URL=http://localhost:3001
JWT_SECRET=replace-with-a-long-random-secret
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
POSTGRES_PASSWORD=replace-with-a-real-password
NODE_ENV=production
+10
View File
@@ -0,0 +1,10 @@
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test
REDIS_URL=redis://redis:6379
API_PORT=4000
API_URL=http://localhost:4000
API_INTERNAL_URL=http://localhost:4000/api/v1
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
JWT_SECRET=test-secret
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
NODE_ENV=test
+5 -2
View File
@@ -26,14 +26,14 @@ NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
# ─── AmanPay (Primary payment provider) ───────────────────────
# RentFlow's own AmanPay account (for collecting subscription fees)
# RentalDriveGo's own AmanPay account (for collecting subscription fees)
AMANPAY_MERCHANT_ID=your-amanpay-merchant-id
AMANPAY_SECRET_KEY=your-amanpay-secret-key
AMANPAY_BASE_URL=https://api.amanpay.net
AMANPAY_WEBHOOK_SECRET=your-amanpay-webhook-secret
# ─── PayPal (Secondary payment provider) ──────────────────────
# RentFlow's own PayPal account (for collecting subscription fees)
# RentalDriveGo's own PayPal account (for collecting subscription fees)
PAYPAL_CLIENT_ID=your-paypal-client-id
PAYPAL_CLIENT_SECRET=your-paypal-client-secret
PAYPAL_BASE_URL=https://api-m.paypal.com
@@ -67,6 +67,9 @@ NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789
NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123
# The Node API uses Resend for email delivery.
# MAIL_* SMTP variables are intentionally omitted here.
# ─── Redis (Real-time / Socket.io) ────────────────────────────
REDIS_URL=redis://localhost:6379
+131
View File
@@ -0,0 +1,131 @@
## Docker Environments
Three Docker environments are available:
- `Dockerfile.dev` with `docker-compose.dev.yml`
- `Dockerfile.test` with `docker-compose.test.yml`
- `Dockerfile.production` with `docker-compose.production.yml`
### Development
Use the full dev stack for local work with hot reload and bundled Postgres and Redis:
```bash
docker compose -f docker-compose.dev.yml --profile full up --build
```
Services:
- marketplace: `http://localhost:3000`
- dashboard: `http://localhost:3001`
- admin: `http://localhost:3002`
- public-site: `http://localhost:3003`
- api: `http://localhost:4000`
- pgAdmin: `http://localhost:5050`
Each dev app now runs in its own container and can be started independently with a profile tag:
```bash
docker compose -f docker-compose.dev.yml --profile api up --build
docker compose -f docker-compose.dev.yml --profile marketplace up --build
docker compose -f docker-compose.dev.yml --profile dashboard up --build
docker compose -f docker-compose.dev.yml --profile admin up --build
docker compose -f docker-compose.dev.yml --profile public-site up --build
docker compose -f docker-compose.dev.yml --profile tools up --build
```
Notes:
- `api` starts `postgres`, `redis`, and `migrate` automatically through dependencies.
- frontend profiles also start `api` and its dependencies automatically.
- `tools` starts only `pgadmin` plus its required `postgres` dependency.
On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts.
Default dev platform administrator:
- email: `admin@rentaldrivego.com`
- password: `changeme123`
If you intentionally want a fresh dev bootstrap:
```bash
docker compose -f docker-compose.dev.yml down -v
```
If you want to keep the database and only apply new schema changes manually:
```bash
docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy"
```
pgAdmin dev login:
- email: `admin@rentaldrivego.local`
- email: `admin@rentaldrivego.dev`
- password: `admin`
pgAdmin opens with the dev Postgres server pre-registered as `RentalDriveGo Dev DB`.
pgAdmin Postgres connection:
- host: `postgres`
- port: `5432`
- database: `rentaldrivego`
- username: `postgres`
- password: `password`
### Test
Use the test stack to run repeatable containerized verification:
```bash
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
```
The test container runs:
- `npm run db:deploy`
- `npm run db:generate`
- `npm run type-check`
- `npm run build`
### Production
1. Copy `.env.docker.production.example` to `.env.docker.production`
2. Fill in real secrets and domain values
3. Start the stack:
```bash
docker compose -f docker-compose.production.yml up --build -d
```
Production compose starts separate containers for:
- postgres
- redis
- api
- marketplace
- dashboard
- admin
- public-site
### Notes
- The production image builds the whole monorepo once, then each service overrides its runtime command.
- The dev compose file bind-mounts the repo and keeps `node_modules` in a named volume.
- `API_INTERNAL_URL` is used for server-side container-to-container calls, while `NEXT_PUBLIC_API_URL` is used by the browser.
- The Dockerfiles activate the repo's pinned `npm@10.5.0` with `corepack` before install so container builds do not depend on the npm version bundled with the base image.
- The dev compose stack stores Postgres data in `postgres_dev_data` and the bootstrap marker in `postgres_bootstrap_state`, so `up --build` does not reseed an existing local database.
- If you need database schema updates inside Docker, run:
```bash
docker compose -f docker-compose.dev.yml run --rm migrate
```
If a cached base image still fails during `npm ci`, refresh it and rebuild without cache:
```bash
docker pull node:20-bookworm
docker compose -f docker-compose.dev.yml build --no-cache dashboard
```
+15
View File
@@ -0,0 +1,15 @@
FROM node:20-bookworm
WORKDIR /app
RUN npm install -g npm@10.5.0 --no-fund --no-audit
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
COPY apps ./apps
COPY packages ./packages
RUN npm ci --no-fund --no-audit
EXPOSE 3000 3001 3002 3003 4000
CMD ["sh", "-c", "npm run db:generate && npm run dev"]
+30
View File
@@ -0,0 +1,30 @@
FROM node:20-bookworm AS builder
WORKDIR /app
RUN corepack enable && corepack prepare npm@10.5.0 --activate
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
COPY apps ./apps
COPY packages ./packages
RUN npm install
RUN npm run db:generate
RUN npm run build
FROM node:20-bookworm AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable && corepack prepare npm@10.5.0 --activate
COPY --from=builder /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps ./apps
COPY --from=builder /app/packages ./packages
EXPOSE 3000 3001 3002 3003 4000
CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"]
+15
View File
@@ -0,0 +1,15 @@
FROM node:20-bookworm
WORKDIR /app
RUN corepack enable && corepack prepare npm@10.5.0 --activate
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
COPY apps ./apps
COPY packages ./packages
RUN npm install
ENV NODE_ENV=test
CMD ["sh", "-c", "npm run db:generate && npm run type-check && npm run build"]
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+10
View File
@@ -1,9 +1,19 @@
/** @type {import('next').NextConfig} */
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
const nextConfig = {
images: {
domains: ['res.cloudinary.com'],
},
transpilePackages: ['@rentaldrivego/types'],
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${apiOrigin}/api/:path*`,
},
]
},
}
module.exports = nextConfig
@@ -0,0 +1,211 @@
'use client'
import { useEffect, useState } from 'react'
const API_BASE = '/api/v1'
interface AdminUser {
id: string
firstName: string
lastName: string
email: string
role: string
isActive: boolean
createdAt: string
permissions?: { id: string; resource: string; actions: string[] }[]
}
const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']
export default function AdminUsersPage() {
const [admins, setAdmins] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showModal, setShowModal] = useState(false)
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
const [creating, setCreating] = useState(false)
function getToken() { return localStorage.getItem('admin_token') ?? '' }
async function fetchAdmins() {
try {
const res = await fetch(`${API_BASE}/admin/admins`, {
headers: { Authorization: `Bearer ${getToken()}` },
cache: 'no-store',
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed')
setAdmins(json.data ?? [])
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchAdmins() }, [])
async function createAdmin(e: React.FormEvent) {
e.preventDefault()
setCreating(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/admin/admins`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify(form),
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to create')
setShowModal(false)
setForm({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
await fetchAdmins()
} catch (err: any) {
setError(err.message)
} finally {
setCreating(false)
}
}
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40',
ADMIN: 'text-sky-400 bg-sky-950/40',
SUPPORT: 'text-amber-400 bg-amber-950/40',
FINANCE: 'text-violet-400 bg-violet-950/40',
VIEWER: 'text-zinc-400 bg-zinc-800',
}
return (
<div className="shell py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">Platform</p>
<h1 className="mt-1 text-3xl font-black">Admin Users</h1>
</div>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold transition-colors"
>
+ New admin
</button>
</div>
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
<div className="panel overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Name</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Email</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Role</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Permissions</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Status</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{loading ? (
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">Loading</td></tr>
) : admins.length === 0 ? (
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">No admin users found</td></tr>
) : admins.map((a) => (
<tr key={a.id} className="hover:bg-zinc-800/30 transition-colors">
<td className="px-6 py-4 font-medium text-zinc-100">{a.firstName} {a.lastName}</td>
<td className="px-6 py-4 text-zinc-400">{a.email}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ROLE_COLORS[a.role] ?? 'text-zinc-400 bg-zinc-800'}`}>
{a.role}
</span>
</td>
<td className="px-6 py-4 text-xs text-zinc-500">
{a.permissions && a.permissions.length > 0
? a.permissions.map((permission) => permission.resource).join(', ')
: 'Role-based only'}
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${a.isActive ? 'text-emerald-400 bg-emerald-950/40' : 'text-zinc-500 bg-zinc-800'}`}>
{a.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 text-zinc-500 text-xs">{new Date(a.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 p-8 shadow-2xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold">New admin user</h2>
<button onClick={() => setShowModal(false)} className="text-zinc-500 hover:text-zinc-200"></button>
</div>
<form onSubmit={createAdmin} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
<input
required
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Last name</label>
<input
required
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Email</label>
<input
type="email"
required
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Password</label>
<input
type="password"
required
minLength={8}
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
<select
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value })}
>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowModal(false)} className="flex-1 py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium">Cancel</button>
<button type="submit" disabled={creating} className="flex-1 py-2.5 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold disabled:opacity-50">
{creating ? 'Creating…' : 'Create admin'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,100 @@
'use client'
import { useEffect, useState } from 'react'
const API_BASE = '/api/v1'
interface AuditLog {
id: string
action: string
resource: string
resourceId: string | null
createdAt: string
adminUser: { email: string; firstName: string; lastName: string } | null
}
const ACTION_COLORS: Record<string, string> = {
CREATE: 'text-emerald-400 bg-emerald-950/40',
UPDATE: 'text-sky-400 bg-sky-950/40',
DELETE: 'text-red-400 bg-red-950/40',
SUSPEND: 'text-amber-400 bg-amber-950/40',
ACTIVATE: 'text-emerald-400 bg-emerald-950/40',
LOGIN: 'text-zinc-400 bg-zinc-800',
}
export default function AdminAuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState('')
useEffect(() => {
const token = localStorage.getItem('admin_token') ?? ''
fetch(`${API_BASE}/admin/audit-logs`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
})
.then((r) => r.json())
.then((json) => setLogs(json.data ?? []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
const filtered = filter
? logs.filter((l) => l.action.toLowerCase().includes(filter.toLowerCase()) || l.resource.toLowerCase().includes(filter.toLowerCase()))
: logs
return (
<div className="shell py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">Platform</p>
<h1 className="mt-1 text-3xl font-black">Audit Logs</h1>
</div>
<input
className="w-64 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500"
placeholder="Filter by action or entity…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
<div className="panel overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Action</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Entity</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Entity ID</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Admin</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Time</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{loading ? (
<tr><td colSpan={5} className="px-6 py-12 text-center text-zinc-500">Loading</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-12 text-center text-zinc-500">No logs found</td></tr>
) : filtered.map((log) => (
<tr key={log.id} className="hover:bg-zinc-800/30 transition-colors">
<td className="px-6 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ACTION_COLORS[log.action] ?? 'text-zinc-400 bg-zinc-800'}`}>
{log.action}
</span>
</td>
<td className="px-6 py-3 text-zinc-300">{log.resource}</td>
<td className="px-6 py-3 text-zinc-500 font-mono text-xs">{log.resourceId ? `${log.resourceId.slice(0, 12)}` : '—'}</td>
<td className="px-6 py-3 text-zinc-400">{log.adminUser ? `${log.adminUser.firstName} ${log.adminUser.lastName}` : '—'}</td>
<td className="px-6 py-3 text-zinc-500 text-xs">{new Date(log.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,204 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
const API_BASE = '/api/v1'
interface CompanyDetail {
id: string
name: string
slug: string
email: string
phone: string | null
status: string
createdAt: string
subscription: { plan: string; status: string; trialEndAt: string | null } | null
_count: { employees: number; vehicles: number; customers: number; reservations: number }
}
interface AuditLog {
id: string
action: string
entityType: string
entityId: string
createdAt: string
admin: { email: string } | null
}
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'text-emerald-400',
TRIALING: 'text-sky-400',
SUSPENDED: 'text-red-400',
PENDING: 'text-amber-400',
CANCELLED: 'text-zinc-400',
}
export default function AdminCompanyDetailPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [company, setCompany] = useState<CompanyDetail | null>(null)
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actioning, setActioning] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
function getToken() { return localStorage.getItem('admin_token') ?? '' }
async function fetchData() {
const token = getToken()
const headers = { Authorization: `Bearer ${token}` }
try {
const [cRes, aRes] = await Promise.all([
fetch(`${API_BASE}/admin/companies/${id}`, { headers, cache: 'no-store' }),
fetch(`${API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { headers, cache: 'no-store' }),
])
const cJson = await cRes.json()
const aJson = await aRes.json()
if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found')
setCompany(cJson.data)
setAuditLogs(aJson.data ?? [])
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchData() }, [id])
async function changeStatus(status: string) {
setActioning(true)
try {
const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ status }),
})
if (!res.ok) throw new Error('Action failed')
await fetchData()
} catch (err: any) {
setError(err.message)
} finally {
setActioning(false)
}
}
async function deleteCompany() {
setActioning(true)
try {
const res = await fetch(`${API_BASE}/admin/companies/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) throw new Error('Delete failed')
router.push('/dashboard/companies')
} catch (err: any) {
setError(err.message)
setActioning(false)
}
}
if (loading) return <div className="shell py-16 text-center text-zinc-500">Loading</div>
if (error && !company) return <div className="shell py-16 text-center text-red-400">{error}</div>
if (!company) return null
return (
<div className="shell py-8 space-y-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/companies" className="text-zinc-500 hover:text-zinc-300 text-sm"> Companies</Link>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-black">{company.name}</h1>
<p className="mt-1 text-zinc-400 font-mono text-sm">{company.slug}</p>
</div>
<span className={`text-sm font-semibold ${STATUS_COLORS[company.status] ?? 'text-zinc-400'}`}>{company.status}</span>
</div>
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[
{ label: 'Employees', value: company._count.employees },
{ label: 'Vehicles', value: company._count.vehicles },
{ label: 'Customers', value: company._count.customers },
{ label: 'Reservations', value: company._count.reservations },
].map((kpi) => (
<div key={kpi.label} className="panel p-5">
<p className="text-xs text-zinc-500">{kpi.label}</p>
<p className="mt-1 text-2xl font-black">{kpi.value}</p>
</div>
))}
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="panel p-6 space-y-4">
<h2 className="text-base font-semibold">Details</h2>
<dl className="space-y-3 text-sm">
<div className="flex justify-between"><dt className="text-zinc-500">Email</dt><dd>{company.email}</dd></div>
<div className="flex justify-between"><dt className="text-zinc-500">Phone</dt><dd>{company.phone ?? '—'}</dd></div>
<div className="flex justify-between"><dt className="text-zinc-500">Joined</dt><dd>{new Date(company.createdAt).toLocaleDateString()}</dd></div>
{company.subscription && <>
<div className="flex justify-between"><dt className="text-zinc-500">Plan</dt><dd>{company.subscription.plan}</dd></div>
<div className="flex justify-between"><dt className="text-zinc-500">Sub status</dt><dd>{company.subscription.status}</dd></div>
{company.subscription.trialEndAt && (
<div className="flex justify-between"><dt className="text-zinc-500">Trial ends</dt><dd>{new Date(company.subscription.trialEndAt).toLocaleDateString()}</dd></div>
)}
</>}
</dl>
</div>
<div className="panel p-6 space-y-4">
<h2 className="text-base font-semibold">Actions</h2>
<div className="space-y-3">
{company.status === 'SUSPENDED' ? (
<button onClick={() => changeStatus('ACTIVE')} disabled={actioning} className="w-full py-2.5 rounded-xl bg-emerald-900/40 hover:bg-emerald-900/60 text-emerald-400 text-sm font-semibold transition-colors disabled:opacity-50">
Reactivate company
</button>
) : (
<button onClick={() => changeStatus('SUSPENDED')} disabled={actioning} className="w-full py-2.5 rounded-xl bg-red-950/40 hover:bg-red-950/60 text-red-400 text-sm font-semibold transition-colors disabled:opacity-50">
Suspend company
</button>
)}
{!deleteConfirm ? (
<button onClick={() => setDeleteConfirm(true)} className="w-full py-2.5 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-sm font-semibold transition-colors">
Delete company
</button>
) : (
<div className="rounded-xl border border-red-900/50 p-4 space-y-3">
<p className="text-sm text-red-400 font-medium">This will permanently delete all company data. Are you sure?</p>
<div className="flex gap-2">
<button onClick={() => setDeleteConfirm(false)} className="flex-1 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm">Cancel</button>
<button onClick={deleteCompany} disabled={actioning} className="flex-1 py-2 rounded-lg bg-red-700 hover:bg-red-600 text-white text-sm font-semibold disabled:opacity-50">Delete</button>
</div>
</div>
)}
</div>
</div>
</div>
{auditLogs.length > 0 && (
<div className="panel overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-800">
<h2 className="text-sm font-semibold">Recent audit events</h2>
</div>
<div className="divide-y divide-zinc-800/60">
{auditLogs.map((log) => (
<div key={log.id} className="px-6 py-3 flex items-center justify-between text-sm">
<div>
<span className="font-medium text-zinc-200">{log.action}</span>
<span className="ml-2 text-zinc-500">{log.entityType}</span>
</div>
<span className="text-xs text-zinc-600">{new Date(log.createdAt).toLocaleString()}</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,207 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useAdminI18n } from '@/components/I18nProvider'
const API_BASE = '/api/v1'
interface Company {
id: string
name: string
slug: string
status: string
email: string
subscription: { plan: string; status: string } | null
_count: { employees: number; vehicles: number }
}
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
TRIALING: 'text-sky-400 bg-sky-900/30',
SUSPENDED: 'text-red-400 bg-red-900/30',
PENDING: 'text-amber-400 bg-amber-900/30',
CANCELLED: 'text-zinc-400 bg-zinc-800',
}
export default function AdminCompaniesPage() {
const { language, dict } = useAdminI18n()
const copy = {
en: {
title: 'Companies',
search: 'Search by name or slug…',
loading: 'Loading…',
empty: 'No companies found',
company: 'Company',
slug: 'Slug',
status: 'Status',
plan: 'Plan',
fleet: 'Fleet',
vehicles: 'vehicles',
view: 'View',
suspend: 'Suspend',
reactivate: 'Reactivate',
},
fr: {
title: 'Entreprises',
search: 'Rechercher par nom ou slug…',
loading: 'Chargement…',
empty: 'Aucune entreprise trouvée',
company: 'Entreprise',
slug: 'Slug',
status: 'Statut',
plan: 'Plan',
fleet: 'Flotte',
vehicles: 'véhicules',
view: 'Voir',
suspend: 'Suspendre',
reactivate: 'Réactiver',
},
ar: {
title: 'الشركات',
search: 'ابحث بالاسم أو الـ slug…',
loading: 'جارٍ التحميل…',
empty: 'لم يتم العثور على شركات',
company: 'الشركة',
slug: 'Slug',
status: 'الحالة',
plan: 'الخطة',
fleet: 'الأسطول',
vehicles: 'سيارات',
view: 'عرض',
suspend: 'تعليق',
reactivate: 'إعادة التفعيل',
},
}[language]
const [companies, setCompanies] = useState<Company[]>([])
const [filtered, setFiltered] = useState<Company[]>([])
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actioning, setActioning] = useState<string | null>(null)
async function fetchCompanies() {
const token = localStorage.getItem('admin_token')
try {
const res = await fetch(`${API_BASE}/admin/companies`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: 'no-store',
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
setCompanies(json.data ?? [])
setFiltered(json.data ?? [])
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchCompanies() }, [])
useEffect(() => {
const q = search.toLowerCase()
setFiltered(companies.filter((c) => c.name.toLowerCase().includes(q) || c.slug.includes(q) || c.email.toLowerCase().includes(q)))
}, [search, companies])
async function changeStatus(id: string, status: string) {
setActioning(id)
const token = localStorage.getItem('admin_token')
try {
const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ status }),
})
if (!res.ok) throw new Error('Action failed')
await fetchCompanies()
} catch (err: any) {
setError(err.message)
} finally {
setActioning(null)
}
}
return (
<div className="shell py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.platform}</p>
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
</div>
<input
className="w-64 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500"
placeholder={copy.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
<div className="panel overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.company}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.slug}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.status}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.plan}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.fleet}</th>
<th className="px-6 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{loading ? (
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td></tr>
) : filtered.map((c) => (
<tr key={c.id} className="hover:bg-zinc-800/30 transition-colors">
<td className="px-6 py-4">
<p className="font-medium text-zinc-100">{c.name}</p>
<p className="text-xs text-zinc-500">{c.email}</p>
</td>
<td className="px-6 py-4 text-zinc-400 font-mono text-xs">{c.slug}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[c.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
{c.status}
</span>
</td>
<td className="px-6 py-4 text-zinc-400">{c.subscription?.plan ?? '—'}</td>
<td className="px-6 py-4 text-zinc-400">{c._count?.vehicles ?? 0} {copy.vehicles}</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 justify-end">
<Link href={`/dashboard/companies/${c.id}`} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-xs font-medium text-zinc-200 transition-colors">
{copy.view}
</Link>
{c.status === 'ACTIVE' || c.status === 'TRIALING' ? (
<button
onClick={() => changeStatus(c.id, 'SUSPENDED')}
disabled={actioning === c.id}
className="px-3 py-1.5 rounded-lg bg-red-950/50 hover:bg-red-900/50 text-xs font-medium text-red-400 transition-colors disabled:opacity-50"
>
{copy.suspend}
</button>
) : c.status === 'SUSPENDED' ? (
<button
onClick={() => changeStatus(c.id, 'ACTIVE')}
disabled={actioning === c.id}
className="px-3 py-1.5 rounded-lg bg-emerald-950/50 hover:bg-emerald-900/50 text-xs font-medium text-emerald-400 transition-colors disabled:opacity-50"
>
{copy.reactivate}
</button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
+90
View File
@@ -0,0 +1,90 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider'
const navLinks = [
{ href: '/dashboard', key: 'overview', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ href: '/dashboard/companies', key: 'companies', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' },
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
]
export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) {
const { dict } = useAdminI18n()
const pathname = usePathname()
const router = useRouter()
const [ready, setReady] = useState(false)
useEffect(() => {
const token = localStorage.getItem('admin_token')
if (!token) {
router.replace('/login')
} else {
setReady(true)
}
}, [router])
function handleLogout() {
localStorage.removeItem('admin_token')
router.push('/login')
}
if (!ready) {
return (
<div className="flex h-screen items-center justify-center bg-zinc-950">
<div className="h-6 w-6 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" aria-label={dict.loading} />
</div>
)
}
return (
<div className="flex h-screen">
<aside className="w-60 flex-shrink-0 flex flex-col border-r border-zinc-800 bg-zinc-900">
<Link href="/" className="block px-5 py-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
<p className="mt-0.5 text-sm font-semibold text-zinc-100">RentalDriveGo</p>
</Link>
<nav className="flex-1 px-3 space-y-0.5">
{navLinks.map((link) => {
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
return (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
active
? 'bg-zinc-800 text-zinc-100'
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
}`}
>
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
</svg>
{dict.nav[link.key]}
</Link>
)
})}
</nav>
<div className="px-3 py-4">
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-zinc-500 hover:text-red-400 hover:bg-zinc-800/50 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
{dict.logout}
</button>
<div className="mt-4 flex justify-center border-t border-zinc-800 pt-4">
<AdminLanguageSwitcher />
</div>
</div>
</aside>
<main className="flex-1 overflow-y-auto bg-zinc-950">{children}</main>
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useAdminI18n } from '@/components/I18nProvider'
const API_BASE = '/api/v1'
interface Metrics {
totalCompanies: number
activeCompanies: number
totalRenters: number
totalReservations: number
mrr?: number
}
export default function AdminDashboardPage() {
const { language, dict } = useAdminI18n()
const copy = {
en: {
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
platformOverview: 'Platform overview',
cards: [
['Companies', 'List, search, suspend, reactivate, and review subscription state.', 'View all →'],
['Renters', 'Support flows for blocking and unblocking marketplace renters.', 'View all →'],
['Audit logs', 'Full trace of every admin action taken on the platform.', 'View logs →'],
],
},
fr: {
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
platformOverview: 'Vue plateforme',
cards: [
['Entreprises', 'Lister, rechercher, suspendre, réactiver et revoir l’état des abonnements.', 'Voir tout →'],
['Locataires', 'Flux de support pour bloquer et débloquer les locataires marketplace.', 'Voir tout →'],
['Journaux daudit', 'Trace complète de chaque action admin sur la plateforme.', 'Voir les journaux →'],
],
},
ar: {
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
platformOverview: 'نظرة عامة على المنصة',
cards: [
['الشركات', 'اعرض وابحث وعلّق وأعد التفعيل وراجع حالة الاشتراك.', 'عرض الكل ←'],
['المستأجرون', 'مسارات دعم لحظر وفك حظر مستأجري السوق.', 'عرض الكل ←'],
['سجلات التدقيق', 'تتبع كامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'],
],
},
}[language]
const [metrics, setMetrics] = useState<Metrics | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('admin_token') ?? ''
fetch(`${API_BASE}/admin/metrics`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
})
.then((r) => r.json())
.then((json) => setMetrics(json.data))
.catch(() => null)
.finally(() => setLoading(false))
}, [])
const kpis = metrics
? [
{ label: 'Total companies', value: metrics.totalCompanies },
{ label: copy.kpis[1], value: metrics.activeCompanies },
{ label: copy.kpis[2], value: metrics.totalRenters },
{ label: copy.kpis[3], value: metrics.totalReservations },
]
: []
if (kpis.length > 0) kpis[0].label = copy.kpis[0]
return (
<div className="shell py-8 space-y-8">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
<h1 className="mt-1 text-3xl font-black">{copy.platformOverview}</h1>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{loading
? Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="panel p-6 animate-pulse">
<div className="h-3 w-24 rounded bg-zinc-800" />
<div className="mt-3 h-8 w-16 rounded bg-zinc-800" />
</div>
))
: kpis.map((kpi) => (
<div key={kpi.label} className="panel p-6">
<p className="text-xs text-zinc-500">{kpi.label}</p>
<p className="mt-1 text-3xl font-black">{kpi.value?.toLocaleString() ?? '—'}</p>
</div>
))}
</div>
<div className="grid gap-6 md:grid-cols-3">
{[
['/dashboard/companies', ...copy.cards[0]],
['/dashboard/renters', ...copy.cards[1]],
['/dashboard/audit-logs', ...copy.cards[2]],
].map(([href, title, body, cta]) => (
<Link key={href} href={href} className="panel p-6 hover:border-zinc-700 transition-colors block">
<p className="text-sm font-semibold text-zinc-200">{title}</p>
<p className="mt-2 text-sm text-zinc-500">{body}</p>
<p className="mt-4 text-xs text-emerald-400 font-medium">{cta}</p>
</Link>
))}
</div>
</div>
)
}
@@ -0,0 +1,188 @@
'use client'
import { useEffect, useState } from 'react'
import { useAdminI18n } from '@/components/I18nProvider'
const API_BASE = '/api/v1'
interface Renter {
id: string
firstName: string
lastName: string
email: string
phone: string | null
isBlocked: boolean
createdAt: string
}
export default function AdminRentersPage() {
const { language, dict } = useAdminI18n()
const copy = {
en: {
title: 'Renters',
search: 'Search renters…',
name: 'Name',
email: 'Email',
phone: 'Phone',
status: 'Status',
joined: 'Joined',
loading: 'Loading…',
empty: 'No renters found',
blocked: 'Blocked',
active: 'Active',
unblock: 'Unblock',
block: 'Block',
},
fr: {
title: 'Locataires',
search: 'Rechercher des locataires…',
name: 'Nom',
email: 'Email',
phone: 'Téléphone',
status: 'Statut',
joined: 'Inscrit',
loading: 'Chargement…',
empty: 'Aucun locataire trouvé',
blocked: 'Bloqué',
active: 'Actif',
unblock: 'Débloquer',
block: 'Bloquer',
},
ar: {
title: 'المستأجرون',
search: 'ابحث عن مستأجرين…',
name: 'الاسم',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
status: 'الحالة',
joined: 'تاريخ الانضمام',
loading: 'جارٍ التحميل…',
empty: 'لم يتم العثور على مستأجرين',
blocked: 'محظور',
active: 'نشط',
unblock: 'فك الحظر',
block: 'حظر',
},
}[language]
const [renters, setRenters] = useState<Renter[]>([])
const [filtered, setFiltered] = useState<Renter[]>([])
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actioning, setActioning] = useState<string | null>(null)
function getToken() { return localStorage.getItem('admin_token') ?? '' }
async function fetchRenters() {
try {
const res = await fetch(`${API_BASE}/admin/renters`, {
headers: { Authorization: `Bearer ${getToken()}` },
cache: 'no-store',
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed')
setRenters(json.data ?? [])
setFiltered(json.data ?? [])
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchRenters() }, [])
useEffect(() => {
const q = search.toLowerCase()
setFiltered(renters.filter((r) =>
`${r.firstName} ${r.lastName}`.toLowerCase().includes(q) || r.email.toLowerCase().includes(q)
))
}, [search, renters])
async function toggleBlock(id: string, isBlocked: boolean) {
setActioning(id)
try {
const endpoint = isBlocked ? 'unblock' : 'block'
const res = await fetch(`${API_BASE}/admin/renters/${id}/${endpoint}`, {
method: 'POST',
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) throw new Error('Action failed')
await fetchRenters()
} catch (err: any) {
setError(err.message)
} finally {
setActioning(null)
}
}
return (
<div className="shell py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.platform}</p>
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
</div>
<input
className="w-64 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500"
placeholder={copy.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
<div className="panel overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.name}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.email}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.phone}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.status}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.joined}</th>
<th className="px-6 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{loading ? (
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td></tr>
) : filtered.map((r) => (
<tr key={r.id} className="hover:bg-zinc-800/30 transition-colors">
<td className="px-6 py-4 font-medium text-zinc-100">{r.firstName} {r.lastName}</td>
<td className="px-6 py-4 text-zinc-400">{r.email}</td>
<td className="px-6 py-4 text-zinc-400">{r.phone ?? '—'}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
r.isBlocked ? 'text-red-400 bg-red-950/40' : 'text-emerald-400 bg-emerald-950/40'
}`}>
{r.isBlocked ? copy.blocked : copy.active}
</span>
</td>
<td className="px-6 py-4 text-zinc-500 text-xs">{new Date(r.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4">
<button
onClick={() => toggleBlock(r.id, r.isBlocked)}
disabled={actioning === r.id}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 ${
r.isBlocked
? 'bg-emerald-950/50 hover:bg-emerald-900/50 text-emerald-400'
: 'bg-red-950/50 hover:bg-red-900/50 text-red-400'
}`}
>
{r.isBlocked ? copy.unblock : copy.block}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from 'next/server'
export function GET(request: Request) {
const url = new URL('/icon', request.url)
return NextResponse.redirect(url)
}
+15
View File
@@ -0,0 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-zinc-950 text-zinc-50 antialiased;
}
.shell {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
.panel {
@apply rounded-2xl border border-zinc-800 bg-zinc-900 shadow-sm;
}
+32
View File
@@ -0,0 +1,32 @@
import { ImageResponse } from 'next/og'
export const size = {
width: 32,
height: 32,
}
export const contentType = 'image/png'
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#111827',
color: '#ffffff',
fontSize: 18,
fontWeight: 700,
borderRadius: 8,
}}
>
A
</div>
),
size,
)
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from 'next'
import { AdminI18nProvider } from '@/components/I18nProvider'
import './globals.css'
export const metadata: Metadata = {
title: 'RentalDriveGo Admin',
description: 'Platform administration for RentalDriveGo.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body><AdminI18nProvider>{children}</AdminI18nProvider></body>
</html>
)
}
+212
View File
@@ -0,0 +1,212 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAdminI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/PublicShell'
const API_BASE = '/api/v1'
export default function AdminLoginPage() {
const { language } = useAdminI18n()
const dict = {
en: {
loginFailed: 'Login failed',
verifyFailed: '2FA verification failed',
brand: 'Admin console',
access: 'Platform operations access',
email: 'Email',
password: 'Password',
signIn: 'Sign in',
signingIn: 'Signing in…',
enterCode: 'Enter the 6-digit code from your authenticator app',
authCode: 'Authentication code',
verify: 'Verify',
verifying: 'Verifying…',
back: 'Back to login',
},
fr: {
loginFailed: 'Échec de connexion',
verifyFailed: 'Échec de la vérification 2FA',
brand: 'Console admin',
access: 'Accès opérations plateforme',
email: 'Email',
password: 'Mot de passe',
signIn: 'Connexion',
signingIn: 'Connexion…',
enterCode: 'Entrez le code à 6 chiffres de votre application dauthentification',
authCode: 'Code dauthentification',
verify: 'Vérifier',
verifying: 'Vérification…',
back: 'Retour à la connexion',
},
ar: {
loginFailed: 'فشل تسجيل الدخول',
verifyFailed: 'فشل التحقق الثنائي',
brand: 'لوحة الإدارة',
access: 'وصول عمليات المنصة',
email: 'البريد الإلكتروني',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة',
authCode: 'رمز المصادقة',
verify: 'تحقق',
verifying: 'جارٍ التحقق…',
back: 'العودة إلى تسجيل الدخول',
},
}[language]
const router = useRouter()
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totp, setTotp] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleCredentials(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const json = await res.json()
if (res.status === 401 && json?.error === 'totp_required') {
setStep('totp')
return
}
if (!res.ok) throw new Error(json?.message ?? dict.loginFailed)
if (json.data?.token) {
localStorage.setItem('admin_token', json.data.token)
router.push('/dashboard')
}
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, totpCode: totp }),
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? dict.verifyFailed)
if (json.data?.token) {
localStorage.setItem('admin_token', json.data.token)
router.push('/dashboard')
}
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<PublicShell>
<main className="flex flex-1 items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
<h1 className="mt-3 text-3xl font-black tracking-tight">{dict.brand}</h1>
<p className="mt-1 text-sm text-zinc-400">{dict.access}</p>
</div>
<div className="panel p-8">
{error && (
<div className="mb-6 p-3 rounded-xl border border-red-900/50 bg-red-950/50 text-sm text-red-400">
{error}
</div>
)}
{step === 'credentials' ? (
<form onSubmit={handleCredentials} className="space-y-5">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
placeholder="admin@rentaldrivego.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.password}</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
>
{loading ? dict.signingIn : dict.signIn}
</button>
</form>
) : (
<form onSubmit={handleTotp} className="space-y-5">
<div className="text-center mb-2">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900/40 mb-3">
<svg className="h-6 w-6 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<p className="text-sm text-zinc-300">{dict.enterCode}</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.authCode}</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
required
value={totp}
onChange={(e) => setTotp(e.target.value.replace(/\D/g, ''))}
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-center text-2xl tracking-[0.5em] placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
placeholder="000000"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
>
{loading ? dict.verifying : dict.verify}
</button>
<button
type="button"
onClick={() => { setStep('credentials'); setTotp(''); setError(null) }}
className="w-full text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
>
{dict.back}
</button>
</form>
)}
</div>
</div>
</main>
</PublicShell>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function AdminRootPage() {
redirect('/login')
}
+129
View File
@@ -0,0 +1,129 @@
'use client'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
export type AdminLanguage = 'en' | 'fr' | 'ar'
type AdminDictionary = {
nav: Record<string, string>
logout: string
language: string
overview: string
admin: string
platform: string
loading: string
}
const dictionaries: Record<AdminLanguage, AdminDictionary> = {
en: {
nav: {
overview: 'Overview',
companies: 'Companies',
renters: 'Renters',
auditLogs: 'Audit Logs',
adminUsers: 'Admin Users',
},
logout: 'Logout',
language: 'Language',
overview: 'Platform overview',
admin: 'Admin',
platform: 'Platform',
loading: 'Loading',
},
fr: {
nav: {
overview: 'Vue densemble',
companies: 'Entreprises',
renters: 'Locataires',
auditLogs: 'Journaux daudit',
adminUsers: 'Utilisateurs admin',
},
logout: 'Déconnexion',
language: 'Langue',
overview: 'Vue plateforme',
admin: 'Admin',
platform: 'Plateforme',
loading: 'Chargement',
},
ar: {
nav: {
overview: 'نظرة عامة',
companies: 'الشركات',
renters: 'المستأجرون',
auditLogs: 'سجلات التدقيق',
adminUsers: 'مستخدمو الإدارة',
},
logout: 'تسجيل الخروج',
language: 'اللغة',
overview: 'نظرة عامة على المنصة',
admin: 'الإدارة',
platform: 'المنصة',
loading: 'جارٍ التحميل',
},
}
type AdminI18nContext = {
language: AdminLanguage
setLanguage: (value: AdminLanguage) => void
dict: AdminDictionary
}
const Context = createContext<AdminI18nContext | null>(null)
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<AdminLanguage>('en')
useEffect(() => {
const stored = window.localStorage.getItem('admin-language')
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
setLanguage(stored)
}
}, [])
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
window.localStorage.setItem('admin-language', language)
}, [language])
const value = useMemo(() => ({ language, setLanguage, dict: dictionaries[language] }), [language])
return <Context.Provider value={value}>{children}</Context.Provider>
}
export function useAdminI18n() {
const context = useContext(Context)
if (!context) throw new Error('useAdminI18n must be used within AdminI18nProvider')
return context
}
export function AdminLanguageSwitcher() {
const { language, setLanguage, dict } = useAdminI18n()
const [embedded, setEmbedded] = useState(false)
useEffect(() => {
setEmbedded(window.self !== window.top)
}, [])
if (embedded) return null
return (
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">{dict.language}</span>
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
const active = value === language
return (
<button
key={value}
type="button"
onClick={() => setLanguage(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800'
}`}
>
{value.toUpperCase()}
</button>
)
})}
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
'use client'
import Link from 'next/link'
import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider'
export default function PublicShell({ children }: { children: React.ReactNode }) {
const { language } = useAdminI18n()
const dict = {
en: {
admin: 'Admin Console',
signIn: 'Sign in',
preferences: 'Admin preferences',
},
fr: {
admin: 'Console admin',
signIn: 'Connexion',
preferences: 'Preferences admin',
},
ar: {
admin: 'لوحة الإدارة',
signIn: 'تسجيل الدخول',
preferences: 'تفضيلات الإدارة',
},
}[language]
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100">
<header className="sticky top-0 z-30 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur-md">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
<Link href="/" className="flex items-center gap-3">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-400">RentalDriveGo</span>
<span className="hidden text-sm font-semibold text-zinc-400 sm:inline">{dict.admin}</span>
</Link>
<nav className="flex items-center gap-2">
<Link href="/login" className="rounded-full bg-zinc-100 px-4 py-2 text-sm font-semibold text-zinc-950 transition hover:bg-zinc-300">
{dict.signIn}
</Link>
</nav>
</div>
</header>
<div>{children}</div>
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">
{dict.preferences}
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<AdminLanguageSwitcher />
</div>
</div>
</footer>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
const API_BASE =
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1')
?? process.env.NEXT_PUBLIC_API_URL
?? 'http://localhost:4000/api/v1'
export async function adminFetch<T>(path: string, token?: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
cache: 'no-store',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
return json.data as T
}
+26 -23
View File
@@ -9,45 +9,48 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@clerk/clerk-sdk-node": "^5.0.0",
"@react-pdf/renderer": "^3.4.3",
"@rentaldrivego/database": "*",
"@rentaldrivego/types": "*",
"@clerk/clerk-sdk-node": "^5.0.0",
"express": "^4.19.2",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"zod": "^3.23.0",
"jsonwebtoken": "^9.0.2",
"bcryptjs": "^2.4.3",
"multer": "^1.4.5-lts.1",
"cloudinary": "^2.2.0",
"resend": "^3.2.0",
"twilio": "^5.1.0",
"cors": "^2.8.5",
"dayjs": "^1.11.11",
"express": "^4.19.2",
"express-rate-limit": "^8.5.1",
"firebase-admin": "^12.1.0",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"socket.io": "^4.7.5",
"svix": "^1.20.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.16",
"otplib": "^12.0.1",
"qrcode": "^1.5.3",
"@react-pdf/renderer": "^3.4.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"dayjs": "^1.11.11",
"node-cron": "^3.0.3"
"resend": "^3.2.0",
"socket.io": "^4.7.5",
"svix": "^1.20.0",
"twilio": "^5.1.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/morgan": "^1.9.9",
"@types/jsonwebtoken": "^9.0.6",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.11",
"@types/qrcode": "^1.5.5",
"@types/node": "^20.12.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"typescript": "^5.4.0",
"@types/node": "^20.12.0",
"ts-node-dev": "^2.0.0"
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.0"
}
}
+148 -15
View File
@@ -5,11 +5,15 @@ import morgan from 'morgan'
import http from 'http'
import { Server as SocketIOServer } from 'socket.io'
import cron from 'node-cron'
import jwt from 'jsonwebtoken'
import { z } from 'zod'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
// ─── Routes ───────────────────────────────────────────────────
import webhookRouter from './routes/webhooks'
import companyAuthRouter from './routes/auth.company'
import renterAuthRouter from './routes/auth.renter'
import vehiclesRouter from './routes/vehicles'
import reservationsRouter from './routes/reservations'
@@ -20,17 +24,74 @@ import analyticsRouter from './routes/analytics'
import notificationsRouter from './routes/notifications'
import marketplaceRouter from './routes/marketplace'
import adminRouter from './routes/admin'
import companiesRouter from './routes/companies'
import subscriptionsRouter from './routes/subscriptions'
import siteRouter from './routes/site'
import paymentsRouter from './routes/payments'
const app = express()
const server = http.createServer(app)
const v1 = '/api/v1'
const defaultCorsOrigins = [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:3002',
'http://localhost:3003',
'http://127.0.0.1:3000',
'http://127.0.0.1:3001',
'http://127.0.0.1:3002',
'http://127.0.0.1:3003',
]
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
: defaultCorsOrigins
const routeDocs = [
{ method: 'GET', path: '/health', description: 'Health check' },
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
{ method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' },
{ method: 'GET', path: `${v1}/reservations`, description: 'List reservations' },
{ method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' },
{ method: 'GET', path: `${v1}/customers`, description: 'List customers' },
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
]
// ─── Socket.io ────────────────────────────────────────────────
const io = new SocketIOServer(server, {
cors: { origin: '*', methods: ['GET', 'POST'] },
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
})
const redisMessageSchema = z.object({
type: z.string(),
payload: z.unknown(),
})
// Authenticate socket connections via JWT before joining user rooms
io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
;(socket as any).authenticatedUserId = payload.sub
next()
} catch {
next(new Error('invalid_token'))
}
})
io.on('connection', (socket) => {
const userId = socket.handshake.auth?.userId as string | undefined
const userId = (socket as any).authenticatedUserId as string | undefined
if (userId) {
socket.join(`user:${userId}`)
}
@@ -42,8 +103,14 @@ subscriber.psubscribe('notifications:*', (err) => {
if (err) console.error('[Redis] Subscribe error:', err)
})
subscriber.on('pmessage', (_pattern, channel, message) => {
try {
const parsed = JSON.parse(message)
const validated = redisMessageSchema.parse(parsed)
const userId = channel.replace('notifications:', '')
io.to(`user:${userId}`).emit('notification', JSON.parse(message))
io.to(`user:${userId}`).emit('notification', validated)
} catch (err) {
console.error('[Redis] Invalid notification message:', err)
}
})
// ─── Middleware ────────────────────────────────────────────────
@@ -51,29 +118,95 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
app.use(helmet())
app.use(cors({ origin: process.env.CORS_ORIGINS?.split(',') ?? '*', credentials: true }))
app.use(cors({ origin: corsOrigins, credentials: true }))
app.use(morgan('combined'))
app.use(express.json({ limit: '10mb' }))
// ─── API Routes ───────────────────────────────────────────────
const v1 = '/api/v1'
// Auth routes: strict brute-force protection
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/renter`, renterAuthRouter)
app.use(`${v1}/vehicles`, vehiclesRouter)
app.use(`${v1}/reservations`, reservationsRouter)
app.use(`${v1}/team`, teamRouter)
app.use(`${v1}/customers`, customersRouter)
app.use(`${v1}/offers`, offersRouter)
app.use(`${v1}/analytics`, analyticsRouter)
app.use(`${v1}/notifications`, notificationsRouter)
app.use(`${v1}/marketplace`, marketplaceRouter)
app.use(`${v1}/admin`, adminRouter)
// Admin routes: dedicated limit
app.use(`${v1}/admin`, adminLimiter, adminRouter)
// Public unauthenticated routes
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter)
// Authenticated company/renter routes
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
app.use(`${v1}/team`, apiLimiter, teamRouter)
app.use(`${v1}/customers`, apiLimiter, customersRouter)
app.use(`${v1}/offers`, apiLimiter, offersRouter)
app.use(`${v1}/analytics`, apiLimiter, analyticsRouter)
app.use(`${v1}/notifications`, apiLimiter, notificationsRouter)
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
// ─── Health check ─────────────────────────────────────────────
app.get('/health', (_req, res) => {
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
})
app.get(`${v1}/docs`, (_req, res) => {
res.json({
name: 'rentaldrivego-api',
version: '1.0.0',
baseUrl: v1,
docsUrl: '/docs',
routes: routeDocs,
})
})
app.get('/docs', (_req, res) => {
const rows = routeDocs
.map(
(route) => `
<tr>
<td>${route.method}</td>
<td><code>${route.path}</code></td>
<td>${route.description}</td>
</tr>`,
)
.join('')
res.type('html').send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RentalDriveGo API Docs</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; color: #0f172a; background: #f8fafc; }
h1 { margin-bottom: 8px; }
p { color: #475569; }
.meta { margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 12px; overflow: hidden; }
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid #e2e8f0; }
th { background: #e2e8f0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
code { background: #f1f5f9; padding: 2px 6px; border-radius: 6px; }
</style>
</head>
<body>
<h1>RentalDriveGo API</h1>
<p class="meta">Base URL: <code>${v1}</code> · JSON index: <code>${v1}/docs</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</body>
</html>`)
})
// ─── Error handler ────────────────────────────────────────────
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const statusCode = err.statusCode ?? 500
+53
View File
@@ -0,0 +1,53 @@
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
const trustProxy = process.env.NODE_ENV === 'production'
const getClientIpKey = (req: Parameters<typeof ipKeyGenerator>[0]) =>
trustProxy
? ipKeyGenerator((req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? '')
: ipKeyGenerator(req.ip ?? '')
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: 'draft-7',
legacyHeaders: false,
skipSuccessfulRequests: false,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
})
// Standard limiter for general authenticated API endpoints
export const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 120,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const companyId = (req as any).companyId ?? ''
const renterId = (req as any).renterId ?? ''
return `${ip}:${companyId || renterId}`
},
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Tight limiter for public marketplace and site endpoints (no auth)
export const publicLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Tight limiter for admin endpoints
export const adminLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
})
+66 -6
View File
@@ -9,6 +9,11 @@ import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAu
const router = Router()
const permissionSchema = z.object({
resource: z.enum(['COMPANIES', 'COMPANY_EMPLOYEES', 'SUBSCRIPTIONS', 'PAYMENTS', 'OFFERS', 'RENTERS', 'ADMIN_USERS', 'AUDIT_LOGS', 'PLATFORM_METRICS']),
actions: z.array(z.enum(['READ', 'CREATE', 'UPDATE', 'DELETE', 'SUSPEND', 'IMPERSONATE'])).min(1),
})
function signAdminToken(adminId: string) {
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
}
@@ -17,7 +22,7 @@ function signAdminToken(adminId: string) {
router.post('/auth/login', async (req, res, next) => {
try {
const { email, password, totpCode } = z.object({ email: z.string().email(), password: z.string(), totpCode: z.string().optional() }).parse(req.body)
const { email, password, totpCode } = z.object({ email: z.string().email().max(255).trim().toLowerCase(), password: z.string().max(128), totpCode: z.string().length(6).optional() }).parse(req.body)
const admin = await prisma.adminUser.findUnique({ where: { email } })
if (!admin || !admin.isActive) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
@@ -176,11 +181,12 @@ router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { adminId, action, companyId, page = '1', pageSize = '50' } = req.query as Record<string, string>
const { adminId, action, companyId, entityId, page = '1', pageSize = '50' } = req.query as Record<string, string>
const where: any = {}
if (adminId) where.adminUserId = adminId
if (action) where.action = { contains: action }
if (companyId) where.companyId = companyId
if (entityId) where.resourceId = entityId
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({ where, include: { adminUser: { select: { firstName: true, lastName: true, email: true } } }, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }),
prisma.auditLog.count({ where }),
@@ -193,16 +199,48 @@ router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (re
router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
const admins = await prisma.adminUser.findMany({ select: { id: true, email: true, firstName: true, lastName: true, role: true, isActive: true, totpEnabled: true, lastLoginAt: true, createdAt: true } })
const admins = await prisma.adminUser.findMany({
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isActive: true,
totpEnabled: true,
lastLoginAt: true,
createdAt: true,
permissions: true,
},
})
res.json({ data: admins })
} catch (err) { next(err) }
})
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
const body = z.object({ email: z.string().email(), firstName: z.string(), lastName: z.string(), role: z.enum(['ADMIN','SUPPORT','FINANCE','VIEWER']), password: z.string().min(8) }).parse(req.body)
const body = z.object({
email: z.string().email(),
firstName: z.string(),
lastName: z.string(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
password: z.string().min(8),
permissions: z.array(permissionSchema).optional(),
}).parse(req.body)
const passwordHash = await bcrypt.hash(body.password, 12)
const admin = await prisma.adminUser.create({ data: { ...body, passwordHash, password: undefined } as any })
const admin = await prisma.adminUser.create({
data: {
email: body.email,
firstName: body.firstName,
lastName: body.lastName,
role: body.role,
passwordHash,
permissions: body.permissions ? {
create: body.permissions,
} : undefined,
},
include: { permissions: true },
})
const { passwordHash: _, ...safe } = admin
res.status(201).json({ data: safe })
} catch (err) { next(err) }
@@ -210,10 +248,32 @@ router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
const { role } = z.object({ role: z.enum(['ADMIN','SUPPORT','FINANCE','VIEWER']) }).parse(req.body)
const { role } = z.object({ role: z.enum(['SUPER_ADMIN', 'ADMIN','SUPPORT','FINANCE','VIEWER']) }).parse(req.body)
await prisma.adminUser.update({ where: { id: req.params.id }, data: { role } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
const { permissions } = z.object({ permissions: z.array(permissionSchema) }).parse(req.body)
await prisma.adminUser.findUniqueOrThrow({ where: { id: req.params.id } })
await prisma.$transaction([
prisma.adminPermission.deleteMany({ where: { adminUserId: req.params.id } }),
prisma.adminPermission.createMany({
data: permissions.map((permission) => ({
adminUserId: req.params.id,
resource: permission.resource,
actions: permission.actions,
})),
}),
])
const admin = await prisma.adminUser.findUniqueOrThrow({
where: { id: req.params.id },
include: { permissions: true },
})
res.json({ data: admin })
} catch (err) { next(err) }
})
export default router
+132 -4
View File
@@ -8,6 +8,29 @@ import { generateFinancialReport, toCsv } from '../services/financialReportServi
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
function getRangeFromPeriod(period: string) {
const now = new Date()
switch (period) {
case 'WEEKLY': {
const start = new Date(now)
start.setDate(now.getDate() - 7)
return { from: start, to: now }
}
case 'QUARTERLY': {
const start = new Date(now.getFullYear(), now.getMonth() - 2, 1)
return { from: start, to: now }
}
case 'ANNUAL': {
const start = new Date(now.getFullYear(), 0, 1)
return { from: start, to: now }
}
case 'MONTHLY':
default:
return { from: new Date(now.getFullYear(), now.getMonth(), 1), to: now }
}
}
router.get('/summary', async (req, res, next) => {
try {
const { period = '30d' } = req.query as Record<string, string>
@@ -25,6 +48,108 @@ router.get('/summary', async (req, res, next) => {
} catch (err) { next(err) }
})
router.get('/dashboard', async (req, res, next) => {
try {
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const prevMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const prevMonthEnd = new Date(monthStart.getTime() - 1)
const [
totalBookings,
previousBookings,
activeVehicles,
previousActiveVehicles,
totalCustomers,
previousCustomers,
monthlyRevenueAgg,
previousRevenueAgg,
recentReservations,
sourceGroups,
subscription,
] = await Promise.all([
prisma.reservation.count({ where: { companyId: req.companyId, createdAt: { gte: monthStart } } }),
prisma.reservation.count({ where: { companyId: req.companyId, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } } }),
prisma.vehicle.count({ where: { companyId: req.companyId, status: { in: ['AVAILABLE', 'RENTED'] } } }),
prisma.vehicle.count({ where: { companyId: req.companyId, createdAt: { lte: prevMonthEnd }, status: { in: ['AVAILABLE', 'RENTED'] } } }),
prisma.customer.count({ where: { companyId: req.companyId } }),
prisma.customer.count({ where: { companyId: req.companyId, createdAt: { lte: prevMonthEnd } } }),
prisma.reservation.aggregate({ where: { companyId: req.companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: monthStart } }, _sum: { totalAmount: true } }),
prisma.reservation.aggregate({ where: { companyId: req.companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } }, _sum: { totalAmount: true } }),
prisma.reservation.findMany({
where: { companyId: req.companyId },
include: { customer: true, vehicle: true },
orderBy: { createdAt: 'desc' },
take: 8,
}),
prisma.reservation.groupBy({
by: ['source'],
where: { companyId: req.companyId },
_count: { id: true },
_sum: { totalAmount: true },
}),
prisma.subscription.findUnique({ where: { companyId: req.companyId } }),
])
const pct = (current: number, previous: number) => {
if (previous === 0) return current > 0 ? 100 : 0
return Math.round(((current - previous) / previous) * 100)
}
const typedRecentReservations = recentReservations as Array<{
id: string
contractNumber: string | null
startDate: Date
endDate: Date
status: string
totalAmount: number
customer: { firstName: string; lastName: string }
vehicle: { make: string; model: string }
}>
const typedSourceGroups = sourceGroups as Array<{
source: string
_count: { id: number }
_sum: { totalAmount: number | null }
}>
res.json({
data: {
kpis: {
totalBookings,
activeVehicles,
monthlyRevenue: monthlyRevenueAgg._sum.totalAmount ?? 0,
totalCustomers,
bookingsChange: pct(totalBookings, previousBookings),
vehiclesChange: pct(activeVehicles, previousActiveVehicles),
revenueChange: pct(monthlyRevenueAgg._sum.totalAmount ?? 0, previousRevenueAgg._sum.totalAmount ?? 0),
customersChange: pct(totalCustomers, previousCustomers),
},
recentReservations: typedRecentReservations.map((reservation) => ({
id: reservation.id,
bookingRef: reservation.contractNumber ?? reservation.id.slice(-8).toUpperCase(),
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
vehicleName: `${reservation.vehicle.make} ${reservation.vehicle.model}`,
startDate: reservation.startDate,
endDate: reservation.endDate,
status: reservation.status,
totalAmount: reservation.totalAmount,
})),
sourceBreakdown: typedSourceGroups.map((group) => ({
source: group.source,
count: group._count.id,
revenue: group._sum.totalAmount ?? 0,
})),
subscription: subscription ? {
status: subscription.status,
planName: subscription.plan,
trialEndsAt: subscription.trialEndAt,
} : null,
},
})
} catch (err) { next(err) }
})
router.get('/sources', async (req, res, next) => {
try {
const sources = await prisma.reservation.groupBy({ by: ['source'], where: { companyId: req.companyId }, _count: { id: true } })
@@ -34,14 +159,17 @@ router.get('/sources', async (req, res, next) => {
router.get('/report', async (req, res, next) => {
try {
const { from, to, format = 'JSON' } = req.query as Record<string, string>
if (!from || !to) return res.status(400).json({ error: 'missing_params', message: 'from and to date params required', statusCode: 400 })
const { from, to, format = 'JSON', period } = req.query as Record<string, string>
const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } })
const derivedRange = getRangeFromPeriod(period || accountingSettings?.reportingPeriod || 'MONTHLY')
const startDate = from ? new Date(from) : derivedRange.from
const endDate = to ? new Date(to) : derivedRange.to
const report = await generateFinancialReport(req.companyId, new Date(from), new Date(to))
const report = await generateFinancialReport(req.companyId, startDate, endDate)
if (format === 'CSV') {
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="report-${from}-${to}.csv"`)
res.setHeader('Content-Disposition', `attachment; filename="report-${startDate.toISOString().slice(0, 10)}-${endDate.toISOString().slice(0, 10)}.csv"`)
return res.send(toCsv(report.rows))
}
+540
View File
@@ -0,0 +1,540 @@
import { Router } from 'express'
import { z } from 'zod'
import { clerkClient } from '@clerk/clerk-sdk-node'
import { prisma } from '../lib/prisma'
import { sendNotification } from '../services/notificationService'
const router = Router()
const signupSchema = z.object({
firstName: z.string().min(1).max(80),
lastName: z.string().min(1).max(80),
email: z.string().email(),
companyName: z.string().min(2).max(120),
companyPhone: z.string().optional(),
country: z.string().optional(),
city: z.string().optional(),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
})
const ownerWorkspaceSchema = z.object({
companyName: z.string().min(2).max(120),
companyPhone: z.string().nullish(),
country: z.string().nullish(),
city: z.string().nullish(),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
})
const completeSignupMetadataSchema = ownerWorkspaceSchema.extend({
signupFlow: z.literal('company-owner'),
})
const verifySchema = z.object({
email: z.string().email(),
})
function slugifyCompanyName(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50) || 'company'
}
async function generateUniqueSlug(baseName: string) {
const base = slugifyCompanyName(baseName)
for (let attempt = 0; attempt < 25; attempt += 1) {
const slug = attempt === 0 ? base : `${base}-${attempt + 1}`
const existing = await prisma.company.findUnique({ where: { slug } })
if (!existing) return slug
}
return `${base}-${Date.now().toString(36)}`
}
function addDays(date: Date, days: number) {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
}
function buildSignupConfirmationEmail(opts: {
firstName: string
companyName: string
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD' | 'USD' | 'EUR'
paymentProvider?: 'AMANPAY' | 'PAYPAL'
trialEndAt?: Date
isResend?: boolean
usesInvitation?: boolean
}) {
const greetingName = opts.firstName.trim() || 'there'
const lines = [
`Hi ${greetingName},`,
'',
opts.isResend
? `We sent a fresh owner invitation for ${opts.companyName}.`
: `Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
`Currency: ${opts.currency}`,
]
if (opts.paymentProvider) {
lines.push(`Primary payment provider: ${opts.paymentProvider}`)
}
if (opts.trialEndAt) {
lines.push(
`Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`
)
}
lines.push(
'',
opts.usesInvitation === false
? 'Your workspace is ready and the owner record has been created for this environment.'
: 'Open the invitation email from Clerk to confirm your account, finish onboarding, and enter the dashboard.',
'',
'RentalDriveGo'
)
return lines.join('\n')
}
async function createOwnerInvitation(email: string, companyId: string, companyName: string) {
const dashboardUrl = process.env.DASHBOARD_URL
if (!process.env.CLERK_SECRET_KEY || !dashboardUrl) {
throw Object.assign(new Error('Clerk signup is not configured on this environment'), {
statusCode: 503,
code: 'clerk_not_configured',
})
}
return clerkClient.invitations.createInvitation({
emailAddress: email,
redirectUrl: `${dashboardUrl}/onboarding/accept-invite`,
publicMetadata: {
companyId,
companyName,
role: 'OWNER',
signupFlow: 'company-owner',
},
})
}
async function verifyClerkSessionToken(sessionToken: string) {
return clerkClient.sessions.verifySession(sessionToken, sessionToken)
}
function getPrimaryEmail(user: Awaited<ReturnType<typeof clerkClient.users.getUser>>) {
const primary = user.emailAddresses.find((email) => email.id === user.primaryEmailAddressId)
return primary ?? user.emailAddresses[0] ?? null
}
async function createCompanyWorkspaceForOwner(opts: {
clerkUserId: string
firstName: string
lastName: string
email: string
companyName: string
companyPhone?: string | null
country?: string | null
city?: string | null
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD' | 'USD' | 'EUR'
paymentProvider: 'AMANPAY' | 'PAYPAL'
}) {
const existingEmployeeByClerkUser = await prisma.employee.findUnique({
where: { clerkUserId: opts.clerkUserId },
include: { company: true },
})
if (existingEmployeeByClerkUser) {
return {
company: existingEmployeeByClerkUser.company,
employeeId: existingEmployeeByClerkUser.id,
invitationId: null,
trialEndAt: null,
created: false,
}
}
const existingCompany = await prisma.company.findUnique({ where: { email: opts.email } })
if (existingCompany) {
throw Object.assign(new Error('A company account with this email already exists'), {
statusCode: 409,
code: 'email_taken',
})
}
const existingEmployeeByEmail = await prisma.employee.findFirst({ where: { email: opts.email } })
if (existingEmployeeByEmail) {
throw Object.assign(new Error('An employee account with this email already exists'), {
statusCode: 409,
code: 'email_taken',
})
}
const slug = await generateUniqueSlug(opts.companyName)
const now = new Date()
const trialEndAt = addDays(now, 14)
const result = await prisma.$transaction(async (tx) => {
const company = await tx.company.create({
data: {
name: opts.companyName,
slug,
email: opts.email,
phone: opts.companyPhone || null,
status: 'TRIALING',
},
})
await tx.brandSettings.create({
data: {
companyId: company.id,
displayName: opts.companyName,
subdomain: slug,
publicEmail: opts.email,
publicPhone: opts.companyPhone || null,
publicCountry: opts.country || null,
publicCity: opts.city || null,
defaultCurrency: opts.currency,
},
})
await tx.subscription.create({
data: {
companyId: company.id,
plan: opts.plan,
billingPeriod: opts.billingPeriod,
currency: opts.currency,
status: 'TRIALING',
trialStartAt: now,
trialEndAt,
currentPeriodStart: now,
currentPeriodEnd: trialEndAt,
},
})
const employee = await tx.employee.create({
data: {
companyId: company.id,
clerkUserId: opts.clerkUserId,
firstName: opts.firstName,
lastName: opts.lastName,
email: opts.email,
phone: opts.companyPhone || null,
role: 'OWNER',
isActive: true,
},
})
return {
company,
employeeId: employee.id,
trialEndAt,
created: true,
}
})
await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
title: 'Your workspace is ready',
body: buildSignupConfirmationEmail({
firstName: opts.firstName,
companyName: opts.companyName,
plan: opts.plan,
billingPeriod: opts.billingPeriod,
currency: opts.currency,
paymentProvider: opts.paymentProvider,
trialEndAt: result.trialEndAt,
usesInvitation: false,
}),
companyId: result.company.id,
employeeId: result.employeeId,
email: opts.email,
channels: ['EMAIL', 'IN_APP'],
}).catch(() => null)
return {
...result,
invitationId: null,
}
}
router.post('/signup', async (req, res, next) => {
try {
const body = signupSchema.parse(req.body)
const existingCompany = await prisma.company.findUnique({ where: { email: body.email } })
if (existingCompany) {
return res.status(409).json({
error: 'email_taken',
message: 'A company account with this email already exists',
statusCode: 409,
})
}
const existingEmployee = await prisma.employee.findFirst({ where: { email: body.email } })
if (existingEmployee) {
return res.status(409).json({
error: 'email_taken',
message: 'An employee account with this email already exists',
statusCode: 409,
})
}
const slug = await generateUniqueSlug(body.companyName)
const now = new Date()
const trialEndAt = addDays(now, 14)
const usesInvitation = Boolean(process.env.CLERK_SECRET_KEY && process.env.DASHBOARD_URL)
const result = await prisma.$transaction(async (tx) => {
const company = await tx.company.create({
data: {
name: body.companyName,
slug,
email: body.email,
phone: body.companyPhone || null,
status: 'TRIALING',
},
})
await tx.brandSettings.create({
data: {
companyId: company.id,
displayName: body.companyName,
subdomain: slug,
publicEmail: body.email,
publicPhone: body.companyPhone || null,
publicCountry: body.country || null,
publicCity: body.city || null,
defaultCurrency: body.currency,
},
})
const subscription = await tx.subscription.create({
data: {
companyId: company.id,
plan: body.plan,
billingPeriod: body.billingPeriod,
currency: body.currency,
status: 'TRIALING',
trialStartAt: now,
trialEndAt,
currentPeriodStart: now,
currentPeriodEnd: trialEndAt,
},
})
const invitation = usesInvitation
? await createOwnerInvitation(body.email, company.id, body.companyName)
: null
const employee = await tx.employee.create({
data: {
companyId: company.id,
clerkUserId: invitation ? `pending_${invitation.id}` : `local_owner_${company.id}`,
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
phone: body.companyPhone || null,
role: 'OWNER',
isActive: !invitation,
},
})
return {
company,
subscription,
employeeId: employee.id,
invitationId: invitation?.id ?? null,
}
})
await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
title: 'Your workspace is ready',
body: buildSignupConfirmationEmail({
firstName: body.firstName,
companyName: body.companyName,
plan: body.plan,
billingPeriod: body.billingPeriod,
currency: body.currency,
paymentProvider: body.paymentProvider,
trialEndAt,
usesInvitation,
}),
companyId: result.company.id,
employeeId: result.employeeId,
email: body.email,
channels: ['EMAIL', 'IN_APP'],
}).catch(() => null)
res.status(201).json({
data: {
companyId: result.company.id,
companyName: result.company.name,
slug: result.company.slug,
invitationId: result.invitationId,
trialEndsAt: trialEndAt.toISOString(),
nextStep: usesInvitation ? 'check_email_for_invitation' : 'workspace_created',
},
})
} catch (err) {
next(err)
}
})
router.post('/complete-signup', async (req, res, next) => {
try {
const authHeader = req.headers.authorization
const sessionToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!sessionToken) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Authentication required',
statusCode: 401,
})
}
const session = await verifyClerkSessionToken(sessionToken)
const user = await clerkClient.users.getUser(session.userId)
const primaryEmail = getPrimaryEmail(user)
if (!primaryEmail?.emailAddress) {
return res.status(400).json({
error: 'email_missing',
message: 'The signed-in Clerk user does not have a primary email address',
statusCode: 400,
})
}
if (primaryEmail.verification?.status !== 'verified') {
return res.status(400).json({
error: 'email_not_verified',
message: 'Verify the owner email address before creating the workspace',
statusCode: 400,
})
}
const metadata = completeSignupMetadataSchema.parse(user.unsafeMetadata ?? {})
const existingEmployee = await prisma.employee.findUnique({
where: { clerkUserId: user.id },
include: { company: true },
})
if (existingEmployee) {
return res.json({
data: {
companyId: existingEmployee.companyId,
companyName: existingEmployee.company.name,
slug: existingEmployee.company.slug,
trialEndsAt: null,
nextStep: 'enter_dashboard',
},
})
}
const result = await createCompanyWorkspaceForOwner({
clerkUserId: user.id,
firstName: user.firstName?.trim() || 'Owner',
lastName: user.lastName?.trim() || 'Account',
email: primaryEmail.emailAddress,
companyName: metadata.companyName,
companyPhone: metadata.companyPhone ?? null,
country: metadata.country ?? null,
city: metadata.city ?? null,
plan: metadata.plan,
billingPeriod: metadata.billingPeriod,
currency: metadata.currency,
paymentProvider: metadata.paymentProvider,
})
res.status(result.created ? 201 : 200).json({
data: {
companyId: result.company.id,
companyName: result.company.name,
slug: result.company.slug,
trialEndsAt: result.trialEndAt?.toISOString() ?? null,
nextStep: 'enter_dashboard',
},
})
} catch (err) {
next(err)
}
})
router.post('/verify-email', async (req, res, next) => {
try {
const { email } = verifySchema.parse(req.body)
const pendingEmployee = await prisma.employee.findFirst({
where: {
email,
role: 'OWNER',
clerkUserId: { startsWith: 'pending_' },
},
include: { company: true },
})
if (!pendingEmployee) {
return res.status(404).json({
error: 'pending_signup_not_found',
message: 'No pending company signup was found for this email',
statusCode: 404,
})
}
const newInvitation = await createOwnerInvitation(email, pendingEmployee.companyId, pendingEmployee.company.name)
await prisma.employee.update({
where: { id: pendingEmployee.id },
data: { clerkUserId: `pending_${newInvitation.id}` },
})
const subscription = await prisma.subscription.findUnique({
where: { companyId: pendingEmployee.companyId },
})
await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
title: 'Your owner invitation has been resent',
body: buildSignupConfirmationEmail({
firstName: pendingEmployee.firstName,
companyName: pendingEmployee.company.name,
plan: subscription?.plan ?? 'STARTER',
billingPeriod: subscription?.billingPeriod ?? 'MONTHLY',
currency: subscription?.currency === 'USD' || subscription?.currency === 'EUR' ? subscription.currency : 'MAD',
trialEndAt: subscription?.trialEndAt ?? undefined,
isResend: true,
}),
companyId: pendingEmployee.companyId,
employeeId: pendingEmployee.id,
email,
channels: ['EMAIL', 'IN_APP'],
}).catch(() => null)
res.json({
data: {
success: true,
invitationId: newInvitation.id,
message: 'A fresh invitation link has been sent to your email address',
},
})
} catch (err) {
next(err)
}
})
export default router
+59 -44
View File
@@ -1,68 +1,83 @@
import { Router } from 'express'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireRenterAuth } from '../middleware/requireRenterAuth'
const router = Router()
function signRenterToken(renterId: string) {
return jwt.sign({ sub: renterId, type: 'renter' }, process.env.JWT_SECRET!, {
expiresIn: process.env.RENTER_JWT_EXPIRY ?? '7d',
router.post('/signup', async (_req, res) => {
res.status(403).json({
error: 'renter_signup_disabled',
message: 'Renter account creation is disabled. Only company owners can sign in to the platform.',
statusCode: 403,
})
}
router.post('/signup', async (req, res, next) => {
try {
const body = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
password: z.string().min(8),
phone: z.string().optional(),
}).parse(req.body)
const existing = await prisma.renter.findUnique({ where: { email: body.email } })
if (existing) return res.status(409).json({ error: 'email_taken', message: 'Email already in use', statusCode: 409 })
const passwordHash = await bcrypt.hash(body.password, 12)
const renter = await prisma.renter.create({
data: { firstName: body.firstName, lastName: body.lastName, email: body.email, passwordHash, phone: body.phone ?? null },
})
const token = signRenterToken(renter.id)
res.status(201).json({ data: { token, renter: { id: renter.id, email: renter.email, firstName: renter.firstName, lastName: renter.lastName } } })
} catch (err) { next(err) }
router.post('/login', async (_req, res) => {
res.status(403).json({
error: 'renter_login_disabled',
message: 'Renter sign-in is disabled. Only company owners can sign in to the platform.',
statusCode: 403,
})
router.post('/login', async (req, res, next) => {
try {
const body = z.object({ email: z.string().email(), password: z.string() }).parse(req.body)
const renter = await prisma.renter.findUnique({ where: { email: body.email } })
if (!renter || !renter.isActive) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
const valid = await bcrypt.compare(body.password, renter.passwordHash)
if (!valid) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
const token = signRenterToken(renter.id)
res.json({ data: { token, renter: { id: renter.id, email: renter.email, firstName: renter.firstName, lastName: renter.lastName } } })
} catch (err) { next(err) }
})
router.get('/me', requireRenterAuth, async (req, res, next) => {
try {
const renter = await prisma.renter.findUniqueOrThrow({ where: { id: req.renterId }, select: { id: true, firstName: true, lastName: true, email: true, phone: true, preferredLocale: true, preferredCurrency: true, emailVerified: true } })
res.json({ data: renter })
const renter = await prisma.renter.findUniqueOrThrow({
where: { id: req.renterId },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
preferredLocale: true,
preferredCurrency: true,
emailVerified: true,
savedCompanies: {
select: {
companyId: true,
},
},
},
})
const savedCompanyIds = renter.savedCompanies.map((saved) => saved.companyId)
const companies = savedCompanyIds.length === 0
? []
: await prisma.company.findMany({
where: { id: { in: savedCompanyIds } },
select: {
id: true,
brand: {
select: {
displayName: true,
subdomain: true,
logoUrl: true,
},
},
},
})
const companyMap = new Map(companies.map((company) => [company.id, company]))
const data = {
...renter,
savedCompanies: renter.savedCompanies.map((saved) => ({
id: saved.companyId,
brand: companyMap.get(saved.companyId)?.brand ?? null,
})),
}
res.json({ data })
} catch (err) { next(err) }
})
router.patch('/me', requireRenterAuth, async (req, res, next) => {
try {
const body = z.object({
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
phone: z.string().optional(),
firstName: z.string().min(1).max(100).trim().optional(),
lastName: z.string().min(1).max(100).trim().optional(),
phone: z.string().max(30).trim().optional(),
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
preferredCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
}).parse(req.body)
+410
View File
@@ -0,0 +1,410 @@
import { Router } from 'express'
import { z } from 'zod'
import multer from 'multer'
import { prisma } from '../lib/prisma'
import { uploadImage } from '../lib/cloudinary'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
router.use(requireCompanyAuth, requireTenant, requireSubscription)
const companySchema = z.object({
name: z.string().min(1).optional(),
email: z.string().email().optional(),
phone: z.string().optional(),
address: z.record(z.unknown()).optional(),
})
const brandSchema = z.object({
displayName: z.string().min(1).optional(),
tagline: z.string().optional(),
primaryColor: z.string().optional(),
accentColor: z.string().optional(),
publicEmail: z.string().email().optional(),
publicPhone: z.string().optional(),
publicAddress: z.string().optional(),
publicCity: z.string().optional(),
publicCountry: z.string().optional(),
websiteUrl: z.string().url().optional(),
whatsappNumber: z.string().optional(),
defaultLocale: z.string().optional(),
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
amanpayMerchantId: z.string().optional(),
amanpaySecretKey: z.string().optional(),
paypalEmail: z.string().email().optional(),
paypalMerchantId: z.string().optional(),
isListedOnMarketplace: z.boolean().optional(),
})
const contractSettingsSchema = z.object({
legalName: z.string().optional(),
registrationNumber: z.string().optional(),
taxId: z.string().optional(),
terms: z.string().optional(),
fuelPolicy: z.string().optional(),
depositPolicy: z.string().optional(),
lateFeePolicy: z.string().optional(),
damagePolicy: z.string().optional(),
contractFooterNote: z.string().optional(),
invoiceFooterNote: z.string().optional(),
signatureRequired: z.boolean().optional(),
showTax: z.boolean().optional(),
taxRate: z.number().optional(),
taxLabel: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
fuelPolicyNote: z.string().optional(),
fuelChargePerLiter: z.number().int().optional(),
fuelShortfallFee: z.number().int().optional(),
lateFeePerHour: z.number().int().optional(),
additionalDriverCharge: z.enum(['FREE', 'PER_DAY', 'FLAT']).optional(),
additionalDriverDailyRate: z.number().int().optional(),
additionalDriverFlatRate: z.number().int().optional(),
})
const insurancePolicySchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
type: z.enum(['CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'FULL', 'BASIC', 'ROADSIDE', 'PERSONAL', 'CUSTOM']),
chargeType: z.enum(['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL']),
chargeValue: z.number().int().min(0),
isRequired: z.boolean().default(false),
isActive: z.boolean().default(true),
sortOrder: z.number().int().default(0),
})
const pricingRuleSchema = z.object({
name: z.string().min(1),
type: z.enum(['SURCHARGE', 'DISCOUNT']),
condition: z.enum(['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN']),
conditionValue: z.number().int(),
adjustmentType: z.enum(['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL']),
adjustmentValue: z.number().int(),
isActive: z.boolean().default(true),
description: z.string().optional(),
})
const accountingSettingsSchema = z.object({
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
fiscalYearStart: z.number().int().min(1).max(12).optional(),
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
accountantEmail: z.string().email().optional(),
accountantName: z.string().optional(),
autoSendReport: z.boolean().optional(),
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
})
function buildPaymentMethodsEnabled(input: {
amanpayMerchantId?: string | null
amanpaySecretKey?: string | null
paypalEmail?: string | null
}) {
const methods: Array<'AMANPAY' | 'PAYPAL'> = []
if (input.amanpayMerchantId && input.amanpaySecretKey) methods.push('AMANPAY')
if (input.paypalEmail) methods.push('PAYPAL')
return methods
}
router.get('/me', async (req, res, next) => {
try {
const company = await prisma.company.findUniqueOrThrow({
where: { id: req.companyId },
include: {
brand: true,
subscription: true,
_count: { select: { vehicles: true, customers: true, reservations: true } },
},
})
res.json({ data: company })
} catch (err) { next(err) }
})
router.patch('/me', async (req, res, next) => {
try {
const body = companySchema.parse(req.body)
const company = await prisma.company.update({
where: { id: req.companyId },
data: body,
})
res.json({ data: company })
} catch (err) { next(err) }
})
router.get('/me/brand', async (req, res, next) => {
try {
const brand = await prisma.brandSettings.findUnique({
where: { companyId: req.companyId },
})
res.json({ data: brand })
} catch (err) { next(err) }
})
router.patch('/me/brand', async (req, res, next) => {
try {
const body = brandSchema.parse(req.body)
const current = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId } })
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
amanpaySecretKey: body.amanpaySecretKey ?? current?.amanpaySecretKey,
paypalEmail: body.paypalEmail ?? current?.paypalEmail,
})
const brand = await prisma.brandSettings.upsert({
where: { companyId: req.companyId },
update: { ...body, paymentMethodsEnabled },
create: {
companyId: req.companyId,
displayName: body.displayName ?? req.company.name,
subdomain: req.company.slug,
paymentMethodsEnabled,
...body,
},
})
res.json({ data: brand })
} catch (err) { next(err) }
})
router.post('/me/brand/logo', upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'missing_file', message: 'A logo file is required', statusCode: 400 })
}
const url = await uploadImage(req.file.buffer, `brands/${req.companyId}`, 'logo')
const brand = await prisma.brandSettings.upsert({
where: { companyId: req.companyId },
update: { logoUrl: url },
create: {
companyId: req.companyId,
displayName: req.company.name,
subdomain: req.company.slug,
logoUrl: url,
},
})
res.json({ data: brand })
} catch (err) { next(err) }
})
router.post('/me/brand/hero', upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'missing_file', message: 'A hero image file is required', statusCode: 400 })
}
const url = await uploadImage(req.file.buffer, `brands/${req.companyId}`, 'hero')
const brand = await prisma.brandSettings.upsert({
where: { companyId: req.companyId },
update: { heroImageUrl: url },
create: {
companyId: req.companyId,
displayName: req.company.name,
subdomain: req.company.slug,
heroImageUrl: url,
},
})
res.json({ data: brand })
} catch (err) { next(err) }
})
router.post('/me/brand/subdomain/check', async (req, res, next) => {
try {
const { subdomain } = z.object({ subdomain: z.string().min(3) }).parse(req.body)
const existing = await prisma.brandSettings.findFirst({
where: { subdomain, companyId: { not: req.companyId } },
})
res.json({ data: { available: !existing } })
} catch (err) { next(err) }
})
router.post('/me/brand/custom-domain', async (req, res, next) => {
try {
const { customDomain } = z.object({ customDomain: z.string().min(3) }).parse(req.body)
const normalized = customDomain.toLowerCase().trim()
const existing = await prisma.brandSettings.findFirst({
where: { customDomain: normalized, companyId: { not: req.companyId } },
})
if (existing) {
return res.status(409).json({ error: 'domain_taken', message: 'This custom domain is already in use', statusCode: 409 })
}
const brand = await prisma.brandSettings.upsert({
where: { companyId: req.companyId },
update: {
customDomain: normalized,
customDomainVerified: false,
customDomainAddedAt: new Date(),
},
create: {
companyId: req.companyId,
displayName: req.company.name,
subdomain: req.company.slug,
customDomain: normalized,
customDomainVerified: false,
customDomainAddedAt: new Date(),
},
})
res.json({ data: brand })
} catch (err) { next(err) }
})
router.get('/me/brand/custom-domain/status', async (req, res, next) => {
try {
const brand = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId } })
const customDomain = brand?.customDomain ?? null
res.json({
data: {
customDomain,
verified: brand?.customDomainVerified ?? false,
status: customDomain ? (brand?.customDomainVerified ? 'verified' : 'pending_dns') : 'not_configured',
dnsTarget: process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET ?? 'cname.vercel-dns.com',
},
})
} catch (err) { next(err) }
})
router.delete('/me/brand/custom-domain', async (req, res, next) => {
try {
const brand = await prisma.brandSettings.updateMany({
where: { companyId: req.companyId },
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
})
res.json({ data: { success: brand.count > 0 } })
} catch (err) { next(err) }
})
router.get('/me/contract-settings', async (req, res, next) => {
try {
const settings = await prisma.contractSettings.findUnique({ where: { companyId: req.companyId } })
res.json({ data: settings })
} catch (err) { next(err) }
})
router.patch('/me/contract-settings', async (req, res, next) => {
try {
const body = contractSettingsSchema.parse(req.body)
const settings = await prisma.contractSettings.upsert({
where: { companyId: req.companyId },
update: body,
create: { companyId: req.companyId, ...body },
})
res.json({ data: settings })
} catch (err) { next(err) }
})
router.get('/me/insurance-policies', async (req, res, next) => {
try {
const policies = await prisma.insurancePolicy.findMany({
where: { companyId: req.companyId },
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
})
res.json({ data: policies })
} catch (err) { next(err) }
})
router.post('/me/insurance-policies', async (req, res, next) => {
try {
const body = insurancePolicySchema.parse(req.body)
const policy = await prisma.insurancePolicy.create({ data: { companyId: req.companyId, ...body } })
res.status(201).json({ data: policy })
} catch (err) { next(err) }
})
router.patch('/me/insurance-policies/:id', async (req, res, next) => {
try {
const body = insurancePolicySchema.partial().parse(req.body)
const policy = await prisma.insurancePolicy.updateMany({
where: { id: req.params.id, companyId: req.companyId },
data: body,
})
if (policy.count === 0) return res.status(404).json({ error: 'not_found', message: 'Insurance policy not found', statusCode: 404 })
const updated = await prisma.insurancePolicy.findUniqueOrThrow({ where: { id: req.params.id } })
res.json({ data: updated })
} catch (err) { next(err) }
})
router.delete('/me/insurance-policies/:id', async (req, res, next) => {
try {
const deleted = await prisma.insurancePolicy.deleteMany({ where: { id: req.params.id, companyId: req.companyId } })
if (deleted.count === 0) return res.status(404).json({ error: 'not_found', message: 'Insurance policy not found', statusCode: 404 })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.get('/me/pricing-rules', async (req, res, next) => {
try {
const rules = await prisma.pricingRule.findMany({
where: { companyId: req.companyId },
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
})
res.json({ data: rules })
} catch (err) { next(err) }
})
router.post('/me/pricing-rules', async (req, res, next) => {
try {
const body = pricingRuleSchema.parse(req.body)
const rule = await prisma.pricingRule.create({ data: { companyId: req.companyId, ...body } })
res.status(201).json({ data: rule })
} catch (err) { next(err) }
})
router.patch('/me/pricing-rules/:id', async (req, res, next) => {
try {
const body = pricingRuleSchema.partial().parse(req.body)
const updated = await prisma.pricingRule.updateMany({
where: { id: req.params.id, companyId: req.companyId },
data: body,
})
if (updated.count === 0) return res.status(404).json({ error: 'not_found', message: 'Pricing rule not found', statusCode: 404 })
const rule = await prisma.pricingRule.findUniqueOrThrow({ where: { id: req.params.id } })
res.json({ data: rule })
} catch (err) { next(err) }
})
router.delete('/me/pricing-rules/:id', async (req, res, next) => {
try {
const deleted = await prisma.pricingRule.deleteMany({ where: { id: req.params.id, companyId: req.companyId } })
if (deleted.count === 0) return res.status(404).json({ error: 'not_found', message: 'Pricing rule not found', statusCode: 404 })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.get('/me/accounting-settings', async (req, res, next) => {
try {
const settings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } })
res.json({ data: settings })
} catch (err) { next(err) }
})
router.patch('/me/accounting-settings', async (req, res, next) => {
try {
const body = accountingSettingsSchema.parse(req.body)
const settings = await prisma.accountingSettings.upsert({
where: { companyId: req.companyId },
update: body,
create: { companyId: req.companyId, ...body },
})
res.json({ data: settings })
} catch (err) { next(err) }
})
router.get('/me/api-key', async (req, res, next) => {
try {
const company = await prisma.company.findUniqueOrThrow({
where: { id: req.companyId },
select: { apiKey: true },
})
res.json({ data: company })
} catch (err) { next(err) }
})
router.post('/me/api-key/regenerate', async (req, res, next) => {
try {
const company = await prisma.company.update({
where: { id: req.companyId },
data: { apiKey: `api_${Math.random().toString(36).slice(2)}${Date.now()}` },
select: { apiKey: true },
})
res.json({ data: company })
} catch (err) { next(err) }
})
export default router
+21 -14
View File
@@ -10,35 +10,42 @@ import { validateAndFlagLicense } from '../services/licenseValidationService'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
const paginationSchema = z.object({
page: z.coerce.number().int().min(1).max(10000).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
const customerSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
driverLicense: z.string().optional(),
firstName: z.string().min(1).max(100).trim(),
lastName: z.string().min(1).max(100).trim(),
email: z.string().email().max(255).trim().toLowerCase(),
phone: z.string().max(30).trim().optional(),
driverLicense: z.string().max(50).trim().optional(),
dateOfBirth: z.string().datetime().optional(),
nationality: z.string().optional(),
nationality: z.string().max(100).trim().optional(),
address: z.record(z.unknown()).optional(),
notes: z.string().optional(),
notes: z.string().max(2000).trim().optional(),
licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(),
licenseCountry: z.string().optional(),
licenseNumber: z.string().optional(),
licenseCategory: z.string().optional(),
licenseCountry: z.string().max(100).trim().optional(),
licenseNumber: z.string().max(50).trim().optional(),
licenseCategory: z.string().max(20).trim().optional(),
})
router.get('/', async (req, res, next) => {
try {
const { q, flagged, page = '1', pageSize = '20' } = req.query as Record<string, string>
const { q, flagged } = req.query as Record<string, string>
const { page, pageSize } = paginationSchema.parse(req.query)
const safeQ = q ? q.trim().slice(0, 100) : undefined
const where: any = { companyId: req.companyId }
if (flagged !== undefined) where.flagged = flagged === 'true'
if (q) where.OR = [{ firstName: { contains: q, mode: 'insensitive' } }, { lastName: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }]
if (safeQ) where.OR = [{ firstName: { contains: safeQ, mode: 'insensitive' } }, { lastName: { contains: safeQ, mode: 'insensitive' } }, { email: { contains: safeQ, mode: 'insensitive' } }]
const [customers, total] = await Promise.all([
prisma.customer.findMany({ where, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }),
prisma.customer.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: 'desc' } }),
prisma.customer.count({ where }),
])
res.json({ data: customers, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
res.json({ data: customers, total, page, pageSize, totalPages: Math.ceil(total / pageSize) })
} catch (err) { next(err) }
})
+142 -15
View File
@@ -1,10 +1,23 @@
import { Router } from 'express'
import { z } from 'zod'
import { prisma } from '../lib/prisma'
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
const router = Router()
router.use(optionalRenterAuth)
const paginationSchema = z.object({
page: z.coerce.number().int().min(1).max(10000).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
function isDatabaseUnavailableError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const candidate = error as { code?: string; message?: string }
return candidate.code === 'P1001' || candidate.message?.includes("Can't reach database server") === true
}
router.get('/offers', async (req, res, next) => {
try {
const offers = await prisma.offer.findMany({
@@ -14,14 +27,22 @@ router.get('/offers', async (req, res, next) => {
take: 50,
})
res.json({ data: offers })
} catch (err) { next(err) }
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/companies', async (req, res, next) => {
try {
const { city, hasOffer, page = '1', pageSize = '20' } = req.query as Record<string, string>
const { city, hasOffer } = req.query as Record<string, string>
const { page, pageSize } = paginationSchema.parse(req.query)
const safeCity = city ? city.trim().slice(0, 100) : undefined
const where: any = { status: 'ACTIVE', brand: { isListedOnMarketplace: true } }
if (city) where.brand = { ...where.brand, publicCity: { contains: city, mode: 'insensitive' } }
if (safeCity) where.brand = { ...where.brand, publicCity: { contains: safeCity, mode: 'insensitive' } }
if (hasOffer === 'true') {
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
}
const companies = await prisma.company.findMany({
where,
@@ -29,32 +50,69 @@ router.get('/companies', async (req, res, next) => {
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
_count: { select: { vehicles: { where: { isPublished: true } } } },
},
skip: (parseInt(page) - 1) * parseInt(pageSize),
take: parseInt(pageSize),
skip: (page - 1) * pageSize,
take: pageSize,
})
res.json({ data: companies })
} catch (err) { next(err) }
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/search', async (req, res, next) => {
try {
const { city, startDate, endDate, category, maxPrice, page = '1', pageSize = '20' } = req.query as Record<string, string>
const searchSchema = z.object({
city: z.string().trim().max(100).optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
category: z.string().trim().max(50).optional(),
maxPrice: z.coerce.number().int().min(0).max(1_000_000).optional(),
transmission: z.string().trim().max(20).optional(),
})
const { city, startDate, endDate, category, maxPrice, transmission } = searchSchema.parse(req.query)
const { page, pageSize } = paginationSchema.parse(req.query)
const where: any = { isPublished: true, company: { status: 'ACTIVE', brand: { isListedOnMarketplace: true } } }
if (category) where.category = category
if (maxPrice) where.dailyRate = { lte: parseInt(maxPrice) }
if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice }
if (transmission) where.transmission = transmission
if (city) where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: city, mode: 'insensitive' } } }
const vehicles = await prisma.vehicle.findMany({
where,
include: {
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
},
skip: (parseInt(page) - 1) * parseInt(pageSize),
take: parseInt(pageSize),
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { dailyRate: 'asc' },
})
res.json({ data: vehicles })
} catch (err) { next(err) }
const typedVehicles = vehicles as Array<(typeof vehicles)[number]>
const unavailableVehicleIds = startDate && endDate
? new Set((await prisma.reservation.findMany({
where: {
status: { in: ['CONFIRMED', 'ACTIVE'] },
vehicleId: { in: typedVehicles.map((vehicle) => vehicle.id) },
startDate: { lt: new Date(endDate) },
endDate: { gt: new Date(startDate) },
},
select: { vehicleId: true },
})).map((reservation: { vehicleId: string }) => reservation.vehicleId))
: null
res.json({
data: typedVehicles.map((vehicle) => ({
...vehicle,
availability: unavailableVehicleIds ? !unavailableVehicleIds.has(vehicle.id) : null,
})),
})
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug', async (req, res, next) => {
@@ -69,7 +127,12 @@ router.get('/:slug', async (req, res, next) => {
})
if (!company) return res.status(404).json({ error: 'not_found', message: 'Company not found', statusCode: 404 })
res.json({ data: company })
} catch (err) { next(err) }
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug/reviews', async (req, res, next) => {
@@ -82,7 +145,66 @@ router.get('/:slug/reviews', async (req, res, next) => {
take: 50,
})
res.json({ data: reviews })
} catch (err) { next(err) }
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/vehicles', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
const vehicles = await prisma.vehicle.findMany({
where: { companyId: company.id, isPublished: true },
orderBy: { createdAt: 'desc' },
})
res.json({ data: vehicles })
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/vehicles/:id', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
const vehicle = await prisma.vehicle.findFirstOrThrow({
where: { id: req.params.id, companyId: company.id, isPublished: true },
include: {
company: { include: { brand: true } },
reservations: {
where: { status: { in: ['CONFIRMED', 'ACTIVE'] } },
select: { startDate: true, endDate: true },
},
},
})
res.json({ data: vehicle })
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug/offers', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
const offers = await prisma.offer.findMany({
where: {
companyId: company.id,
isPublic: true,
isActive: true,
validFrom: { lte: new Date() },
validUntil: { gte: new Date() },
},
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
})
res.json({ data: offers })
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.post('/offers/:code/validate', async (req, res, next) => {
@@ -95,7 +217,12 @@ router.post('/offers/:code/validate', async (req, res, next) => {
return res.status(409).json({ error: 'code_exhausted', message: 'Promo code has reached its maximum redemptions', statusCode: 409 })
}
res.json({ data: offer })
} catch (err) { next(err) }
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Offer validation is temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
export default router
+51
View File
@@ -23,6 +23,15 @@ router.get('/company', ...companyAuth, async (req: any, res: any, next: any) =>
} catch (err) { next(err) }
})
router.get('/unread-count', ...companyAuth, async (req: any, res: any, next: any) => {
try {
const unread = await prisma.notification.count({
where: { companyId: req.companyId, channel: 'IN_APP', readAt: null },
})
res.json({ data: { unread } })
} catch (err) { next(err) }
})
router.post('/company/:id/read', ...companyAuth, async (req: any, res: any, next: any) => {
try {
await prisma.notification.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { readAt: new Date(), status: 'READ' } })
@@ -74,4 +83,46 @@ router.post('/renter/:id/read', requireRenterAuth, async (req: any, res: any, ne
} catch (err) { next(err) }
})
router.post('/renter/read-all', requireRenterAuth, async (req: any, res: any, next: any) => {
try {
await prisma.notification.updateMany({
where: { renterId: req.renterId, channel: 'IN_APP', readAt: null },
data: { readAt: new Date(), status: 'READ' },
})
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.get('/renter/preferences', requireRenterAuth, async (req: any, res: any, next: any) => {
try {
const prefs = await prisma.notificationPreference.findMany({ where: { renterId: req.renterId } })
res.json({ data: prefs })
} catch (err) { next(err) }
})
router.patch('/renter/preferences', requireRenterAuth, async (req: any, res: any, next: any) => {
try {
const body = z.array(z.object({ notificationType: z.string(), channel: z.string(), enabled: z.boolean() })).parse(req.body)
for (const pref of body) {
await prisma.notificationPreference.upsert({
where: {
renterId_notificationType_channel: {
renterId: req.renterId,
notificationType: pref.notificationType as NotificationType,
channel: pref.channel as NotificationChannel,
},
},
create: {
renterId: req.renterId,
notificationType: pref.notificationType as NotificationType,
channel: pref.channel as NotificationChannel,
enabled: pref.enabled,
},
update: { enabled: pref.enabled },
})
}
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
export default router
+252
View File
@@ -0,0 +1,252 @@
import { Router } from 'express'
import { z } from 'zod'
import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import * as amanpay from '../services/amanpayService'
import * as paypal from '../services/paypalService'
const router = Router()
// ─── Webhook endpoints (no auth — must come before middleware) ──────────────
router.post('/webhooks/amanpay', async (req, res, next) => {
try {
const rawBody = JSON.stringify(req.body)
const signature = req.headers['x-amanpay-signature'] as string ?? ''
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
return res.status(401).json({ error: 'invalid_signature' })
}
const event = req.body
const transactionId = event.transaction_id ?? event.id
const orderId = event.order_id ?? event.metadata?.order_id
const status = event.status?.toUpperCase()
if (status === 'PAID' || status === 'SUCCEEDED') {
const payment = await prisma.rentalPayment.findFirst({ where: { amanpayTransactionId: transactionId } })
if (payment) {
await prisma.rentalPayment.update({
where: { id: payment.id },
data: { status: 'SUCCEEDED', paidAt: new Date() },
})
await prisma.reservation.update({
where: { id: payment.reservationId },
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
})
}
} else if (status === 'FAILED') {
await prisma.rentalPayment.updateMany({
where: { amanpayTransactionId: transactionId },
data: { status: 'FAILED' },
})
}
res.json({ received: true })
} catch (err) { next(err) }
})
router.post('/webhooks/paypal', async (req, res, next) => {
try {
const rawBody = JSON.stringify(req.body)
const isValid = await paypal.verifyWebhookEvent(
req.headers as Record<string, string>,
rawBody,
)
if (paypal.isConfigured() && !isValid) {
return res.status(401).json({ error: 'invalid_signature' })
}
const event = req.body
const eventType = event.event_type as string
if (eventType === 'PAYMENT.CAPTURE.COMPLETED') {
const captureId = event.resource?.id as string
const payment = await prisma.rentalPayment.findFirst({ where: { paypalCaptureId: captureId } })
if (payment) {
await prisma.rentalPayment.update({
where: { id: payment.id },
data: { status: 'SUCCEEDED', paidAt: new Date() },
})
await prisma.reservation.update({
where: { id: payment.reservationId },
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
})
}
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
const captureId = event.resource?.id as string
await prisma.rentalPayment.updateMany({
where: { paypalCaptureId: captureId },
data: { status: 'FAILED' },
})
}
res.json({ received: true })
} catch (err) { next(err) }
})
// ─── Authenticated routes ────────────────────────────────────────────────────
router.use(requireCompanyAuth, requireTenant, requireSubscription)
const chargeSchema = z.object({
provider: z.enum(['AMANPAY', 'PAYPAL']),
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
successUrl: z.string().url(),
failureUrl: z.string().url(),
})
router.get('/reservations/:id', async (req, res, next) => {
try {
const payments = await prisma.rentalPayment.findMany({
where: { reservationId: req.params.id, companyId: req.companyId },
orderBy: { createdAt: 'desc' },
})
res.json({ data: payments })
} catch (err) { next(err) }
})
router.post('/reservations/:id/charge', async (req, res, next) => {
try {
const { provider, type, currency, successUrl, failureUrl } = chargeSchema.parse(req.body)
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: req.companyId },
include: { vehicle: true, customer: true },
})
if (reservation.paymentStatus === 'PAID') {
return res.status(409).json({ error: 'already_paid', message: 'Reservation is already fully paid', statusCode: 409 })
}
const amount = type === 'DEPOSIT' ? reservation.depositAmount : reservation.totalAmount
const description = `${type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
const orderId = `${reservation.id}-${type}-${Date.now()}`
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
let checkoutUrl: string
let amanpayTransactionId: string | null = null
let paypalCaptureId: string | null = null
if (provider === 'AMANPAY') {
if (!amanpay.isConfigured()) {
return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured', statusCode: 503 })
}
const result = await amanpay.createCheckout({
amount,
currency,
orderId,
description,
customerEmail: reservation.customer.email,
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
successUrl,
failureUrl,
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
})
checkoutUrl = result.checkoutUrl
amanpayTransactionId = result.transactionId
} else {
if (!paypal.isConfigured()) {
return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not configured', statusCode: 503 })
}
const result = await paypal.createOrder({
amount,
currency,
orderId,
description,
returnUrl: successUrl,
cancelUrl: failureUrl,
})
checkoutUrl = result.approveUrl
paypalCaptureId = result.orderId
}
const payment = await prisma.rentalPayment.create({
data: {
companyId: req.companyId,
reservationId: reservation.id,
amount,
currency,
status: 'PENDING',
type,
paymentProvider: provider,
amanpayTransactionId,
paypalCaptureId,
},
})
res.json({ data: { payment, checkoutUrl } })
} catch (err) { next(err) }
})
router.post('/reservations/:id/capture-paypal', async (req, res, next) => {
try {
const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body)
const payment = await prisma.rentalPayment.findFirstOrThrow({
where: { paypalCaptureId: paypalOrderId, companyId: req.companyId },
})
const capture = await paypal.captureOrder(paypalOrderId)
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
const updated = await prisma.rentalPayment.update({
where: { id: payment.id },
data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId },
})
await prisma.reservation.update({
where: { id: payment.reservationId },
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
})
res.json({ data: updated })
} catch (err) { next(err) }
})
router.post('/reservations/:reservationId/payments/:paymentId/refund', async (req, res, next) => {
try {
const { amount, reason } = z.object({
amount: z.number().int().positive().optional(),
reason: z.string().optional(),
}).parse(req.body)
const payment = await prisma.rentalPayment.findFirstOrThrow({
where: { id: req.params.paymentId, companyId: req.companyId, reservationId: req.params.reservationId },
})
if (payment.status !== 'SUCCEEDED') {
return res.status(400).json({ error: 'not_capturable', message: 'Only succeeded payments can be refunded', statusCode: 400 })
}
const refundAmount = amount ?? payment.amount
if (payment.paymentProvider === 'AMANPAY') {
if (!payment.amanpayTransactionId) throw new Error('No AmanPay transaction ID')
await amanpay.refundTransaction(payment.amanpayTransactionId, refundAmount, reason)
} else {
if (!payment.paypalCaptureId) throw new Error('No PayPal capture ID')
await paypal.refundCapture(payment.paypalCaptureId, refundAmount, payment.currency, reason)
}
const isPartial = refundAmount < payment.amount
const updated = await prisma.rentalPayment.update({
where: { id: payment.id },
data: { status: isPartial ? 'PARTIALLY_REFUNDED' : 'REFUNDED' },
})
if (!isPartial) {
await prisma.reservation.update({
where: { id: payment.reservationId },
data: { paymentStatus: 'REFUNDED' },
})
}
res.json({ data: updated })
} catch (err) { next(err) }
})
export default router
+212 -4
View File
@@ -4,14 +4,47 @@ import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { applyAdditionalDriversToReservation } from '../services/additionalDriverService'
import { applyInsurancesToReservation } from '../services/insuranceService'
import { applyPricingRules } from '../services/pricingRuleService'
import { validateAndFlagLicense } from '../services/licenseValidationService'
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
import { sendNotification } from '../services/notificationService'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
const additionalDriverSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email().optional(),
phone: z.string().optional(),
driverLicense: z.string().min(1),
licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(),
dateOfBirth: z.string().datetime().optional(),
nationality: z.string().optional(),
})
const inspectionSchema = z.object({
mileage: z.number().int().optional(),
fuelLevel: z.enum(['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']),
fuelCharge: z.number().int().min(0).optional(),
generalCondition: z.string().optional(),
employeeNotes: z.string().optional(),
customerAgreed: z.boolean().default(false),
damagePoints: z.array(
z.object({
viewType: z.enum(['TOP', 'FRONT', 'REAR', 'LEFT', 'RIGHT']),
x: z.number(),
y: z.number(),
damageType: z.enum(['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']),
severity: z.enum(['MINOR', 'MODERATE', 'MAJOR']).default('MINOR'),
description: z.string().optional(),
isPreExisting: z.boolean().default(false),
}),
).default([]),
})
const createSchema = z.object({
vehicleId: z.string().cuid(),
customerId: z.string().cuid(),
@@ -24,8 +57,44 @@ const createSchema = z.object({
depositAmount: z.number().int().min(0).default(0),
notes: z.string().optional(),
selectedInsurancePolicyIds: z.array(z.string()).default([]),
additionalDrivers: z.array(additionalDriverSchema).default([]),
})
async function assertReservationLicenseCompliance(reservationId: string, companyId: string) {
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: reservationId, companyId },
include: { customer: true, additionalDrivers: true },
})
const customerLicense = validateLicense(reservation.customer.licenseExpiry)
const customerDenied = ['DENIED', 'EXPIRED'].includes(reservation.customer.licenseValidationStatus)
if (customerDenied || customerLicense.status === 'EXPIRED') {
throw Object.assign(new Error('Primary driver license is not valid for this reservation'), {
statusCode: 400,
code: 'invalid_primary_license',
})
}
if (customerLicense.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') {
throw Object.assign(new Error('Primary driver license requires manager approval before this reservation can proceed'), {
statusCode: 400,
code: 'primary_license_requires_approval',
})
}
const blockedDriver = reservation.additionalDrivers.find((driver) => {
if (driver.licenseExpired) return true
return driver.requiresApproval && !driver.approvedAt
})
if (blockedDriver) {
throw Object.assign(new Error(`Additional driver ${blockedDriver.firstName} ${blockedDriver.lastName} requires approval before this reservation can proceed`), {
statusCode: 400,
code: 'additional_driver_requires_approval',
})
}
}
router.get('/', async (req, res, next) => {
try {
const { status, vehicleId, source, startDate, endDate, page = '1', pageSize = '20' } = req.query as Record<string, string>
@@ -87,7 +156,7 @@ router.post('/', async (req, res, next) => {
}
const baseAmount = vehicle.dailyRate * totalDays
const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, [], vehicle.dailyRate, totalDays)
const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, body.additionalDrivers, vehicle.dailyRate, totalDays)
const totalAmount = baseAmount - discountAmount + pricingTotal + body.depositAmount
const reservation = await prisma.reservation.create({
@@ -118,6 +187,10 @@ router.post('/', async (req, res, next) => {
await applyInsurancesToReservation(reservation.id, req.companyId, body.selectedInsurancePolicyIds, totalDays, baseAmount)
}
if (body.additionalDrivers.length > 0) {
await applyAdditionalDriversToReservation(reservation.id, req.companyId, body.additionalDrivers, totalDays)
}
// Validate customer license
await validateAndFlagLicense(body.customerId).catch(() => null)
@@ -139,6 +212,7 @@ router.post('/:id/confirm', async (req, res, next) => {
try {
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
if (reservation.status !== 'DRAFT') return res.status(400).json({ error: 'invalid_status', message: 'Only DRAFT reservations can be confirmed', statusCode: 400 })
await assertReservationLicenseCompliance(reservation.id, req.companyId)
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' } })
@@ -155,6 +229,7 @@ router.post('/:id/checkin', async (req, res, next) => {
const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body)
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
if (reservation.status !== 'CONFIRMED') return res.status(400).json({ error: 'invalid_status', message: 'Only CONFIRMED reservations can be checked in', statusCode: 400 })
await assertReservationLicenseCompliance(reservation.id, req.companyId)
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'ACTIVE', checkedInAt: new Date(), checkInMileage: mileage ?? null } })
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'RENTED' } })
res.json({ data: updated })
@@ -201,8 +276,8 @@ router.get('/:id/billing', async (req, res, next) => {
const baseAmount = reservation.dailyRate * reservation.totalDays
const lineItems = [
{ description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}${reservation.totalDays} day(s)`, qty: reservation.totalDays, unitPrice: reservation.dailyRate, total: baseAmount, category: 'RENTAL' },
...reservation.insurances.map((ins) => ({ description: ins.policyName, qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, total: ins.totalCharge, category: 'INSURANCE' })),
...reservation.additionalDrivers.filter((d) => d.totalCharge > 0).map((d) => ({ description: `Additional Driver — ${d.firstName} ${d.lastName}`, qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER' })),
...reservation.insurances.map((ins: (typeof reservation.insurances)[number]) => ({ description: ins.policyName, qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, total: ins.totalCharge, category: 'INSURANCE' })),
...reservation.additionalDrivers.filter((d: (typeof reservation.additionalDrivers)[number]) => d.totalCharge > 0).map((d: (typeof reservation.additionalDrivers)[number]) => ({ description: `Additional Driver — ${d.firstName} ${d.lastName}`, qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER' })),
...(reservation.depositAmount > 0 ? [{ description: 'Security Deposit (refundable)', qty: 1, unitPrice: reservation.depositAmount, total: reservation.depositAmount, category: 'DEPOSIT' }] : []),
]
@@ -212,4 +287,137 @@ router.get('/:id/billing', async (req, res, next) => {
} catch (err) { next(err) }
})
router.get('/:id/inspections', async (req, res, next) => {
try {
const inspections = await prisma.damageInspection.findMany({
where: { reservationId: req.params.id, companyId: req.companyId },
include: { damagePoints: true },
orderBy: { inspectedAt: 'asc' },
})
res.json({ data: inspections })
} catch (err) { next(err) }
})
router.put('/:id/inspections/:type', async (req, res, next) => {
try {
const type = z.enum(['CHECKIN', 'CHECKOUT']).parse(req.params.type.toUpperCase())
const body = inspectionSchema.parse(req.body)
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: req.companyId },
include: { vehicle: true, customer: true },
})
const inspection = await prisma.damageInspection.upsert({
where: { reservationId_type: { reservationId: reservation.id, type } },
update: {
mileage: body.mileage ?? null,
fuelLevel: body.fuelLevel,
fuelCharge: body.fuelCharge ?? null,
generalCondition: body.generalCondition ?? null,
employeeNotes: body.employeeNotes ?? null,
customerAgreed: body.customerAgreed,
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
inspectedAt: new Date(),
damagePoints: {
deleteMany: {},
create: body.damagePoints,
},
},
create: {
reservationId: reservation.id,
companyId: req.companyId,
type,
mileage: body.mileage ?? null,
fuelLevel: body.fuelLevel,
fuelCharge: body.fuelCharge ?? null,
generalCondition: body.generalCondition ?? null,
employeeNotes: body.employeeNotes ?? null,
customerAgreed: body.customerAgreed,
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
damagePoints: {
create: body.damagePoints,
},
},
include: { damagePoints: true },
})
await prisma.damageReport.upsert({
where: { reservationId_type: { reservationId: reservation.id, type } },
update: {
damages: body.damagePoints.map((point) => ({
viewType: point.viewType,
x: point.x,
y: point.y,
damageType: point.damageType,
severity: point.severity,
note: point.description ?? '',
isPreExisting: point.isPreExisting,
})),
fuelLevel: body.fuelLevel,
mileage: body.mileage ?? null,
inspectedAt: new Date(),
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
customerSignedAt: body.customerAgreed ? new Date() : null,
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
},
create: {
reservationId: reservation.id,
companyId: req.companyId,
type,
damages: body.damagePoints.map((point) => ({
viewType: point.viewType,
x: point.x,
y: point.y,
damageType: point.damageType,
severity: point.severity,
note: point.description ?? '',
isPreExisting: point.isPreExisting,
})),
photos: [],
fuelLevel: body.fuelLevel,
mileage: body.mileage ?? null,
inspectedAt: new Date(),
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
customerSignedAt: body.customerAgreed ? new Date() : null,
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
},
})
await prisma.reservation.update({
where: { id: reservation.id },
data: type === 'CHECKIN'
? {
checkInMileage: body.mileage ?? null,
checkInFuelLevel: body.fuelLevel,
}
: {
checkOutMileage: body.mileage ?? null,
checkOutFuelLevel: body.fuelLevel,
},
})
res.json({ data: inspection })
} catch (err) { next(err) }
})
router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next) => {
try {
const { approved, note } = z.object({ approved: z.boolean(), note: z.string().optional() }).parse(req.body)
const driver = await prisma.additionalDriver.findFirstOrThrow({
where: { id: req.params.driverId, reservationId: req.params.id, companyId: req.companyId },
})
const updated = await prisma.additionalDriver.update({
where: { id: driver.id },
data: {
approvedAt: approved ? new Date() : null,
approvedBy: approved ? `${req.employee.firstName} ${req.employee.lastName}` : null,
approvalNote: note ?? driver.approvalNote,
},
})
res.json({ data: updated })
} catch (err) { next(err) }
})
export default router
+499
View File
@@ -0,0 +1,499 @@
import { Router } from 'express'
import { z } from 'zod'
import { prisma } from '../lib/prisma'
import { applyAdditionalDriversToReservation } from '../services/additionalDriverService'
import * as amanpay from '../services/amanpayService'
import { applyInsurancesToReservation } from '../services/insuranceService'
import { applyPricingRules } from '../services/pricingRuleService'
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
import * as paypal from '../services/paypalService'
const router = Router()
function isDatabaseUnavailableError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const candidate = error as { code?: string; message?: string }
return candidate.code === 'P1001' || candidate.message?.includes("Can't reach database server") === true
}
async function findCompanyBySlug(slug: string) {
return prisma.company.findFirstOrThrow({
where: { slug, status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE', 'SUSPENDED'] } },
include: { brand: true, contractSettings: true },
})
}
router.get('/:slug/brand', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
res.json({
data: {
company: {
id: company.id,
slug: company.slug,
name: company.name,
phone: company.phone,
},
brand: company.brand,
},
})
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.json({
data: {
company: { id: 'demo', slug: req.params.slug, name: 'Demo Company', phone: null },
brand: null,
},
})
}
next(err)
}
})
router.get('/:slug/vehicles', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
const vehicles = await prisma.vehicle.findMany({
where: { companyId: company.id, isPublished: true },
orderBy: { createdAt: 'desc' },
})
res.json({ data: vehicles })
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/vehicles/:id', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
const vehicle = await prisma.vehicle.findFirstOrThrow({
where: { id: req.params.id, companyId: company.id, isPublished: true },
include: {
reservations: {
where: { status: { in: ['CONFIRMED', 'ACTIVE'] } },
select: { startDate: true, endDate: true, status: true },
},
},
})
res.json({ data: vehicle })
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug/offers', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
const offers = await prisma.offer.findMany({
where: {
companyId: company.id,
isActive: true,
validFrom: { lte: new Date() },
validUntil: { gte: new Date() },
},
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
})
res.json({ data: offers })
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/booking-options', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
const insurancePolicies = await prisma.insurancePolicy.findMany({
where: { companyId: company.id, isActive: true },
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
})
res.json({
data: {
insurancePolicies,
contractSettings: company.contractSettings,
},
})
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: { insurancePolicies: [], contractSettings: null } })
next(err)
}
})
router.post('/:slug/availability', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
const { vehicleId, startDate, endDate } = z.object({
vehicleId: z.string().cuid(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
}).parse(req.body)
const conflicts = await prisma.reservation.findMany({
where: {
companyId: company.id,
vehicleId,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lt: new Date(endDate) },
endDate: { gt: new Date(startDate) },
},
select: { startDate: true, endDate: true },
})
res.json({ data: { available: conflicts.length === 0, conflicts } })
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.post('/:slug/book/validate-code', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
const { code } = z.object({ code: z.string().min(1) }).parse(req.body)
const offer = await prisma.offer.findFirst({
where: {
companyId: company.id,
promoCode: code,
isActive: true,
validFrom: { lte: new Date() },
validUntil: { gte: new Date() },
},
})
if (!offer) {
return res.status(404).json({ error: 'invalid_code', message: 'Promo code not found or expired', statusCode: 404 })
}
res.json({ data: offer })
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Promo code validation is temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.post('/:slug/book', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
const body = z.object({
vehicleId: z.string().cuid(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
driverLicense: z.string().optional(),
dateOfBirth: z.string().datetime().optional(),
licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(),
nationality: z.string().optional(),
offerId: z.string().cuid().optional(),
promoCodeUsed: z.string().optional(),
notes: z.string().optional(),
source: z.enum(['PUBLIC_SITE', 'MARKETPLACE']).default('PUBLIC_SITE'),
selectedInsurancePolicyIds: z.array(z.string()).default([]),
additionalDrivers: z.array(z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email().optional(),
phone: z.string().optional(),
driverLicense: z.string().min(1),
licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(),
dateOfBirth: z.string().datetime().optional(),
nationality: z.string().optional(),
})).default([]),
}).parse(req.body)
const vehicle = await prisma.vehicle.findFirstOrThrow({
where: { id: body.vehicleId, companyId: company.id, isPublished: true },
})
const customer = await prisma.customer.upsert({
where: { companyId_email: { companyId: company.id, email: body.email } },
update: {
firstName: body.firstName,
lastName: body.lastName,
phone: body.phone ?? null,
driverLicense: body.driverLicense ?? null,
dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null,
licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null,
licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null,
nationality: body.nationality ?? null,
},
create: {
companyId: company.id,
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
phone: body.phone ?? null,
driverLicense: body.driverLicense ?? null,
dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null,
licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null,
licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null,
nationality: body.nationality ?? null,
},
})
const start = new Date(body.startDate)
const end = new Date(body.endDate)
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000))
const baseAmount = vehicle.dailyRate * totalDays
let discountAmount = 0
if (body.promoCodeUsed) {
const offer = await prisma.offer.findFirst({
where: {
companyId: company.id,
promoCode: body.promoCodeUsed,
isActive: true,
validFrom: { lte: new Date() },
validUntil: { gte: new Date() },
},
})
if (offer) {
if (offer.type === 'PERCENTAGE') discountAmount = Math.round(baseAmount * offer.discountValue / 100)
else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue
else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue
}
}
const { applied, total: pricingRulesTotal } = await applyPricingRules(
company.id,
customer.id,
body.additionalDrivers,
vehicle.dailyRate,
totalDays,
)
const primaryLicenseResult = validateLicense(body.licenseExpiry ? new Date(body.licenseExpiry) : null)
if (primaryLicenseResult.status === 'EXPIRED') {
return res.status(400).json({ error: 'license_expired', message: 'The primary driver license is expired', statusCode: 400 })
}
const reservation = await prisma.reservation.create({
data: {
companyId: company.id,
vehicleId: vehicle.id,
customerId: customer.id,
offerId: body.offerId ?? null,
promoCodeUsed: body.promoCodeUsed ?? null,
source: body.source,
startDate: start,
endDate: end,
dailyRate: vehicle.dailyRate,
totalDays,
totalAmount: baseAmount - discountAmount + pricingRulesTotal,
discountAmount,
pricingRulesApplied: applied,
pricingRulesTotal,
notes: body.notes ?? null,
status: 'DRAFT',
},
})
if (body.selectedInsurancePolicyIds.length > 0) {
await applyInsurancesToReservation(reservation.id, company.id, body.selectedInsurancePolicyIds, totalDays, baseAmount)
}
if (body.additionalDrivers.length > 0) {
await applyAdditionalDriversToReservation(reservation.id, company.id, body.additionalDrivers, totalDays)
}
if (body.licenseExpiry) {
await validateAndFlagLicense(customer.id).catch(() => null)
}
const refreshedReservation = await prisma.reservation.findUniqueOrThrow({
where: { id: reservation.id },
include: { insurances: true, additionalDrivers: true },
})
res.status(201).json({
data: {
...refreshedReservation,
requiresManualApproval:
primaryLicenseResult.requiresApproval ||
refreshedReservation.additionalDrivers.some((driver) => driver.requiresApproval),
},
})
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Booking is temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug/booking/:id', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: company.id },
include: { vehicle: true, customer: true },
})
res.json({ data: reservation })
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Booking details are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.post('/:slug/booking/:id/pay', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: company.id },
include: { vehicle: true, customer: true, additionalDrivers: true },
})
if (reservation.paymentStatus === 'PAID') {
return res.status(409).json({ error: 'already_paid', message: 'This reservation is already paid', statusCode: 409 })
}
const customerLicenseResult = validateLicense(reservation.customer.licenseExpiry)
if (
reservation.customer.licenseValidationStatus === 'DENIED' ||
customerLicenseResult.status === 'EXPIRED' ||
(customerLicenseResult.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') ||
reservation.additionalDrivers.some((driver) => driver.licenseExpired || (driver.requiresApproval && !driver.approvedAt))
) {
return res.status(409).json({
error: 'license_review_required',
message: 'This reservation requires license review before payment can be processed',
statusCode: 409,
})
}
const { provider, currency, successUrl, failureUrl } = z.object({
provider: z.enum(['AMANPAY', 'PAYPAL']),
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
successUrl: z.string().url(),
failureUrl: z.string().url(),
}).parse(req.body)
const amount = reservation.totalAmount
const description = `Rental: ${reservation.vehicle.make} ${reservation.vehicle.model}`
const orderId = `res-${reservation.id}-${Date.now()}`
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
let checkoutUrl: string
let amanpayTransactionId: string | null = null
let paypalCaptureId: string | null = null
if (provider === 'AMANPAY') {
if (!amanpay.isConfigured()) {
return res.status(503).json({ error: 'provider_not_configured', message: 'Online payment is not available for this company', statusCode: 503 })
}
const brand = company.brand as any
const merchantId = brand?.amanpayMerchantId ?? process.env.AMANPAY_MERCHANT_ID ?? ''
const secretKey = brand?.amanpaySecretKey ?? process.env.AMANPAY_SECRET_KEY ?? ''
if (!merchantId || !secretKey) {
return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured for this company', statusCode: 503 })
}
const result = await amanpay.createCheckout({
amount,
currency,
orderId,
description,
customerEmail: reservation.customer.email,
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
successUrl,
failureUrl,
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
})
checkoutUrl = result.checkoutUrl
amanpayTransactionId = result.transactionId
} else {
if (!paypal.isConfigured()) {
return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not available for this company', statusCode: 503 })
}
const result = await paypal.createOrder({
amount,
currency,
orderId,
description,
returnUrl: successUrl,
cancelUrl: failureUrl,
})
checkoutUrl = result.approveUrl
paypalCaptureId = result.orderId
}
await prisma.rentalPayment.create({
data: {
companyId: company.id,
reservationId: reservation.id,
amount,
currency,
status: 'PENDING',
type: 'CHARGE',
paymentProvider: provider,
amanpayTransactionId,
paypalCaptureId,
},
})
res.json({ data: { checkoutUrl } })
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Payment initiation is temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.post('/:slug/booking/:id/capture-paypal', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body)
const payment = await prisma.rentalPayment.findFirstOrThrow({
where: { paypalCaptureId: paypalOrderId, companyId: company.id },
})
const capture = await paypal.captureOrder(paypalOrderId)
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
await prisma.rentalPayment.update({
where: { id: payment.id },
data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId },
})
await prisma.reservation.update({
where: { id: payment.reservationId },
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
})
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.post('/:slug/contact', async (req, res, next) => {
try {
const company = await findCompanyBySlug(req.params.slug)
const body = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(1),
}).parse(req.body)
res.json({
data: {
success: true,
deliveredTo: company.brand?.publicEmail ?? company.email,
preview: body,
},
})
} catch (err) { next(err) }
})
export default router
+288
View File
@@ -0,0 +1,288 @@
import { Router } from 'express'
import { z } from 'zod'
import { PLAN_PRICES } from '@rentaldrivego/types'
import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import * as amanpay from '../services/amanpayService'
import * as paypal from '../services/paypalService'
const router = Router()
router.get('/plans', (_req, res) => {
res.json({ data: PLAN_PRICES })
})
// ─── AmanPay subscription webhook (no auth) ──────────────────────────────────
router.post('/webhooks/amanpay', async (req, res, next) => {
try {
const rawBody = JSON.stringify(req.body)
const signature = req.headers['x-amanpay-signature'] as string ?? ''
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
return res.status(401).json({ error: 'invalid_signature' })
}
const event = req.body
const transactionId = event.transaction_id ?? event.id
const status = event.status?.toUpperCase()
if (status === 'PAID' || status === 'SUCCEEDED') {
const invoice = await prisma.subscriptionInvoice.findFirst({
where: { amanpayTransactionId: transactionId },
include: { subscription: true },
})
if (invoice) {
await prisma.subscriptionInvoice.update({
where: { id: invoice.id },
data: { status: 'PAID', paidAt: new Date() },
})
await prisma.subscription.update({
where: { id: invoice.subscriptionId },
data: {
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
},
})
}
}
res.json({ received: true })
} catch (err) { next(err) }
})
// ─── PayPal subscription webhook (no auth) ───────────────────────────────────
router.post('/webhooks/paypal', async (req, res, next) => {
try {
const rawBody = JSON.stringify(req.body)
const isValid = await paypal.verifyWebhookEvent(req.headers as Record<string, string>, rawBody)
if (paypal.isConfigured() && !isValid) {
return res.status(401).json({ error: 'invalid_signature' })
}
const event = req.body
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
const captureId = event.resource?.id as string
const invoice = await prisma.subscriptionInvoice.findFirst({
where: { paypalCaptureId: captureId },
include: { subscription: true },
})
if (invoice) {
await prisma.subscriptionInvoice.update({
where: { id: invoice.id },
data: { status: 'PAID', paidAt: new Date() },
})
await prisma.subscription.update({
where: { id: invoice.subscriptionId },
data: {
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
},
})
}
}
res.json({ received: true })
} catch (err) { next(err) }
})
// ─── PayPal capture redirect (no full auth needed — just valid session) ───────
router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, res, next) => {
try {
const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body)
const invoice = await prisma.subscriptionInvoice.findFirstOrThrow({
where: { paypalCaptureId: paypalOrderId, companyId: req.companyId },
include: { subscription: true },
})
const capture = await paypal.captureOrder(paypalOrderId)
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
await prisma.subscriptionInvoice.update({
where: { id: invoice.id },
data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId },
})
await prisma.subscription.update({
where: { id: invoice.subscriptionId },
data: {
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
},
})
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
// ─── Authenticated subscription routes ───────────────────────────────────────
router.use(requireCompanyAuth, requireTenant)
router.get('/me', async (req, res, next) => {
try {
const subscription = await prisma.subscription.findUnique({
where: { companyId: req.companyId },
include: { invoices: { orderBy: { createdAt: 'desc' }, take: 12 } },
})
res.json({ data: subscription })
} catch (err) { next(err) }
})
router.get('/invoices', async (req, res, next) => {
try {
const invoices = await prisma.subscriptionInvoice.findMany({
where: { companyId: req.companyId },
orderBy: { createdAt: 'desc' },
take: 50,
})
res.json({ data: invoices })
} catch (err) { next(err) }
})
const checkoutSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
provider: z.enum(['AMANPAY', 'PAYPAL']),
successUrl: z.string().url(),
failureUrl: z.string().url(),
})
router.post('/checkout', async (req, res, next) => {
try {
const body = checkoutSchema.parse(req.body)
const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod]
if (!prices) return res.status(400).json({ error: 'invalid_plan', message: 'Invalid plan or billing period', statusCode: 400 })
const amount = prices[body.currency]
if (!amount) return res.status(400).json({ error: 'invalid_currency', message: 'Currency not supported for this plan', statusCode: 400 })
const company = await prisma.company.findUniqueOrThrow({ where: { id: req.companyId } })
let subscription = await prisma.subscription.findUnique({ where: { companyId: req.companyId } })
if (!subscription) {
subscription = await prisma.subscription.create({
data: {
companyId: req.companyId,
plan: body.plan,
billingPeriod: body.billingPeriod,
currency: body.currency,
status: 'PENDING' as any,
},
})
}
const orderId = `sub-${req.companyId}-${Date.now()}`
const description = `${body.plan} plan — ${body.billingPeriod}`
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
let checkoutUrl: string
let amanpayTransactionId: string | null = null
let paypalCaptureId: string | null = null
if (body.provider === 'AMANPAY') {
if (!amanpay.isConfigured()) {
return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured on this platform', statusCode: 503 })
}
const result = await amanpay.createCheckout({
amount,
currency: body.currency,
orderId,
description,
customerEmail: company.email,
customerName: company.name,
successUrl: body.successUrl,
failureUrl: body.failureUrl,
webhookUrl: `${webhookBase}/api/v1/subscriptions/webhooks/amanpay`,
})
checkoutUrl = result.checkoutUrl
amanpayTransactionId = result.transactionId
} else {
if (!paypal.isConfigured()) {
return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not configured on this platform', statusCode: 503 })
}
const result = await paypal.createOrder({
amount,
currency: body.currency,
orderId,
description,
returnUrl: body.successUrl,
cancelUrl: body.failureUrl,
})
checkoutUrl = result.approveUrl
paypalCaptureId = result.orderId
}
const invoice = await prisma.subscriptionInvoice.create({
data: {
companyId: req.companyId,
subscriptionId: subscription.id,
amount,
currency: body.currency,
status: 'PENDING',
paymentProvider: body.provider,
amanpayTransactionId,
paypalCaptureId,
},
})
res.json({ data: { invoice, checkoutUrl } })
} catch (err) { next(err) }
})
router.post('/change-plan', async (req, res, next) => {
try {
const { plan, billingPeriod, currency } = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
}).parse(req.body)
const updated = await prisma.subscription.update({
where: { companyId: req.companyId },
data: { plan, billingPeriod, currency },
})
res.json({ data: updated })
} catch (err) { next(err) }
})
router.post('/cancel', async (req, res, next) => {
try {
const updated = await prisma.subscription.update({
where: { companyId: req.companyId },
data: { cancelAtPeriodEnd: true },
})
res.json({ data: updated })
} catch (err) { next(err) }
})
router.post('/resume', async (req, res, next) => {
try {
const updated = await prisma.subscription.update({
where: { companyId: req.companyId },
data: { cancelAtPeriodEnd: false },
})
res.json({ data: updated })
} catch (err) { next(err) }
})
// ─── Helpers ─────────────────────────────────────────────────────────────────
function addPeriod(date: Date, period: string): Date {
const d = new Date(date)
if (period === 'ANNUAL') {
d.setFullYear(d.getFullYear() + 1)
} else {
d.setMonth(d.getMonth() + 1)
}
return d
}
export default router
+15 -5
View File
@@ -30,27 +30,37 @@ router.get('/stats', async (req, res, next) => {
router.post('/invite', requireRole('OWNER'), async (req, res, next) => {
try {
const body = inviteSchema.parse(req.body)
res.status(201).json({ data: await inviteEmployee(req.companyId, req.employee.id, body) })
res.status(201).json({ data: await inviteEmployee(req.companyId!, req.employee!.id, body) })
} catch (err) { next(err) }
})
router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => {
try {
const { role } = z.object({ role: z.enum(['MANAGER', 'AGENT']) }).parse(req.body)
res.json({ data: await updateEmployeeRole(req.companyId, req.employee.id, req.employee.role, req.params.id, { role }) })
const memberId = req.params.id!
res.json({ data: await updateEmployeeRole(req.companyId!, req.employee!.id, req.employee!.role, memberId, { role }) })
} catch (err) { next(err) }
})
router.post('/:id/deactivate', requireRole('OWNER'), async (req, res, next) => {
try { res.json({ data: await deactivateEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) }
try {
const memberId = req.params.id!
res.json({ data: await deactivateEmployee(req.companyId!, req.employee!.role, memberId) })
} catch (err) { next(err) }
})
router.post('/:id/reactivate', requireRole('OWNER'), async (req, res, next) => {
try { res.json({ data: await reactivateEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) }
try {
const memberId = req.params.id!
res.json({ data: await reactivateEmployee(req.companyId!, req.employee!.role, memberId) })
} catch (err) { next(err) }
})
router.delete('/:id', requireRole('OWNER'), async (req, res, next) => {
try { res.json({ data: await removeEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) }
try {
const memberId = req.params.id!
res.json({ data: await removeEmployee(req.companyId!, req.employee!.role, memberId) })
} catch (err) { next(err) }
})
export default router
+1 -1
View File
@@ -93,7 +93,7 @@ router.delete('/:id/photos/:idx', async (req, res, next) => {
try {
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const idx = parseInt(req.params.idx)
const photos = vehicle.photos.filter((_, i) => i !== idx)
const photos = vehicle.photos.filter((_: string, i: number) => i !== idx)
const updated = await prisma.vehicle.update({ where: { id: vehicle.id }, data: { photos } })
res.json({ data: updated })
} catch (err) { next(err) }
@@ -0,0 +1,95 @@
import { AdditionalDriverCharge } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
import { validateLicense } from './licenseValidationService'
export interface AdditionalDriverInput {
firstName: string
lastName: string
email?: string
phone?: string
driverLicense: string
licenseExpiry?: string | null
licenseIssuedAt?: string | null
dateOfBirth?: string | null
nationality?: string
}
export function calculateAdditionalDriverCharge(
chargeType: AdditionalDriverCharge,
chargeValue: number,
totalDays: number,
) {
switch (chargeType) {
case 'PER_DAY':
return chargeValue * totalDays
case 'FLAT':
return chargeValue
default:
return 0
}
}
export async function applyAdditionalDriversToReservation(
reservationId: string,
companyId: string,
drivers: AdditionalDriverInput[],
totalDays: number,
) {
if (drivers.length === 0) {
return { records: [], additionalDriverTotal: 0, requiresManualApproval: false }
}
const settings = await prisma.contractSettings.findUnique({ where: { companyId } })
const chargeType = settings?.additionalDriverCharge ?? 'FREE'
const chargeValue =
chargeType === 'PER_DAY'
? settings?.additionalDriverDailyRate ?? 0
: chargeType === 'FLAT'
? settings?.additionalDriverFlatRate ?? 0
: 0
const records = drivers.map((driver) => {
const licenseResult = validateLicense(driver.licenseExpiry ? new Date(driver.licenseExpiry) : null)
const totalCharge = calculateAdditionalDriverCharge(chargeType, chargeValue, totalDays)
return {
reservationId,
companyId,
firstName: driver.firstName,
lastName: driver.lastName,
email: driver.email ?? null,
phone: driver.phone ?? null,
driverLicense: driver.driverLicense,
licenseExpiry: driver.licenseExpiry ? new Date(driver.licenseExpiry) : null,
licenseIssuedAt: driver.licenseIssuedAt ? new Date(driver.licenseIssuedAt) : null,
dateOfBirth: driver.dateOfBirth ? new Date(driver.dateOfBirth) : null,
nationality: driver.nationality ?? null,
chargeType,
chargeValue,
totalCharge,
licenseExpired: licenseResult.status === 'EXPIRED',
licenseExpiringSoon: licenseResult.status === 'EXPIRING',
requiresApproval: licenseResult.requiresApproval,
approvalNote: licenseResult.requiresApproval ? licenseResult.message : null,
}
})
const additionalDriverTotal = records.reduce((sum, record) => sum + record.totalCharge, 0)
await prisma.$transaction([
prisma.additionalDriver.createMany({ data: records }),
prisma.reservation.update({
where: { id: reservationId },
data: {
additionalDriverTotal,
totalAmount: { increment: additionalDriverTotal },
},
}),
])
return {
records,
additionalDriverTotal,
requiresManualApproval: records.some((record) => record.requiresApproval),
}
}
+97
View File
@@ -0,0 +1,97 @@
import crypto from 'crypto'
const BASE_URL = process.env.AMANPAY_BASE_URL ?? 'https://api.amanpay.net'
const MERCHANT_ID = process.env.AMANPAY_MERCHANT_ID ?? ''
const SECRET_KEY = process.env.AMANPAY_SECRET_KEY ?? ''
const WEBHOOK_SECRET = process.env.AMANPAY_WEBHOOK_SECRET ?? ''
export interface AmanPayCreateParams {
amount: number
currency: string
orderId: string
description: string
customerEmail?: string
customerName?: string
successUrl: string
failureUrl: string
webhookUrl: string
}
export interface AmanPayCheckoutResult {
transactionId: string
checkoutUrl: string
status: string
}
async function getAuthHeaders() {
return {
'Content-Type': 'application/json',
'x-merchant-id': MERCHANT_ID,
'x-api-key': SECRET_KEY,
}
}
export async function createCheckout(params: AmanPayCreateParams): Promise<AmanPayCheckoutResult> {
const res = await fetch(`${BASE_URL}/v1/payments`, {
method: 'POST',
headers: await getAuthHeaders(),
body: JSON.stringify({
merchant_id: MERCHANT_ID,
amount: params.amount,
currency: params.currency,
order_id: params.orderId,
description: params.description,
customer_email: params.customerEmail,
customer_name: params.customerName,
success_url: params.successUrl,
failure_url: params.failureUrl,
webhook_url: params.webhookUrl,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`AmanPay checkout failed: ${err?.message ?? res.statusText}`)
}
const json = await res.json()
return {
transactionId: json.transaction_id ?? json.id,
checkoutUrl: json.checkout_url ?? json.payment_url,
status: json.status ?? 'PENDING',
}
}
export async function getTransaction(transactionId: string) {
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}`, {
headers: await getAuthHeaders(),
})
if (!res.ok) throw new Error('AmanPay: failed to fetch transaction')
return res.json()
}
export async function refundTransaction(transactionId: string, amount: number, reason?: string) {
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}/refund`, {
method: 'POST',
headers: await getAuthHeaders(),
body: JSON.stringify({ amount, reason }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`AmanPay refund failed: ${err?.message ?? res.statusText}`)
}
return res.json()
}
export function verifyWebhookSignature(rawBody: string, signature: string): boolean {
if (!WEBHOOK_SECRET) return false
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
export function isConfigured(): boolean {
return !!(MERCHANT_ID && SECRET_KEY && MERCHANT_ID !== 'your-amanpay-merchant-id')
}
@@ -15,17 +15,17 @@ export async function generateFinancialReport(companyId: string, startDate: Date
const summary = {
totalReservations: reservations.length,
totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0),
totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0),
totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0),
totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0),
totalPricingRulesAdj: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0),
totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0),
totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0),
averageRentalDays: reservations.length > 0 ? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0,
totalRentalRevenue: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.dailyRate * r.totalDays, 0),
totalDiscounts: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.discountAmount, 0),
totalInsurance: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.insuranceTotal, 0),
totalAdditionalDrivers: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.additionalDriverTotal, 0),
totalPricingRulesAdj: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.pricingRulesTotal, 0),
totalDeposits: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.depositAmount, 0),
totalCollected: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalAmount, 0),
averageRentalDays: reservations.length > 0 ? reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalDays, 0) / reservations.length : 0,
}
const rows = reservations.map((r) => ({
const rows = reservations.map((r: (typeof reservations)[number]) => ({
reservationId: r.id,
contractNumber: r.contractNumber ?? '—',
invoiceNumber: r.invoiceNumber ?? '—',
+9 -3
View File
@@ -22,8 +22,8 @@ export async function applyInsurancesToReservation(
baseRentalAmount: number
) {
const allPolicies = await prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } })
const required = allPolicies.filter((p) => p.isRequired)
const selected = allPolicies.filter((p) => selectedPolicyIds.includes(p.id) && !p.isRequired)
const required = allPolicies.filter((p: InsurancePolicy) => p.isRequired)
const selected = allPolicies.filter((p: InsurancePolicy) => selectedPolicyIds.includes(p.id) && !p.isRequired)
const toApply = [...required, ...selected]
const records = toApply.map((policy) => ({
@@ -40,7 +40,13 @@ export async function applyInsurancesToReservation(
await prisma.$transaction([
prisma.reservationInsurance.createMany({ data: records }),
prisma.reservation.update({ where: { id: reservationId }, data: { insuranceTotal } }),
prisma.reservation.update({
where: { id: reservationId },
data: {
insuranceTotal,
totalAmount: { increment: insuranceTotal },
},
}),
])
return { records, insuranceTotal }
@@ -43,6 +43,8 @@ export async function validateAndFlagLicense(customerId: string) {
licenseExpired: result.status === 'EXPIRED',
licenseExpiringSoon: result.status === 'EXPIRING',
licenseValidationStatus: status,
flagged: result.requiresApproval ? true : customer.flagged,
flagReason: result.requiresApproval ? result.message : customer.flagReason,
},
})
+129 -10
View File
@@ -5,21 +5,93 @@ import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
const resend = new Resend(process.env.RESEND_API_KEY)
const resend =
process.env.RESEND_API_KEY && process.env.RESEND_API_KEY !== 're_...'
? new Resend(process.env.RESEND_API_KEY)
: null
const twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
)
const emailFromAddress =
process.env.EMAIL_FROM && process.env.EMAIL_FROM !== 'noreply@example.com'
? process.env.EMAIL_FROM
: null
if (!admin.apps.length) {
const emailFromName =
process.env.EMAIL_FROM_NAME && process.env.EMAIL_FROM_NAME !== 'Example App'
? process.env.EMAIL_FROM_NAME
: null
const smtpHost = process.env.MAIL_HOST
const smtpPort = Number(process.env.MAIL_PORT ?? 0)
const smtpUser = process.env.MAIL_USERNAME
const smtpPass = process.env.MAIL_PASSWORD
const smtpSecure =
process.env.MAIL_SCHEME === 'smtps' ||
process.env.MAIL_ENCRYPTION === 'ssl' ||
smtpPort === 465
const smtpFromAddress =
process.env.MAIL_FROM_ADDRESS && !process.env.MAIL_FROM_ADDRESS.includes('${')
? process.env.MAIL_FROM_ADDRESS
: null
const smtpFromName =
process.env.MAIL_FROM_NAME && !process.env.MAIL_FROM_NAME.includes('${')
? process.env.MAIL_FROM_NAME
: null
type SmtpTransport = {
sendMail(options: Record<string, unknown>): Promise<{ messageId?: string }>
}
let smtpTransport: SmtpTransport | null = null
if (smtpHost && smtpPort && smtpUser && smtpPass) {
try {
const nodemailer = require('nodemailer') as {
createTransport(options: Record<string, unknown>): SmtpTransport
}
smtpTransport = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass,
},
})
} catch (err: any) {
console.warn('[Notifications] SMTP transport unavailable:', err?.message ?? String(err))
}
}
const twilioClient =
process.env.TWILIO_ACCOUNT_SID &&
process.env.TWILIO_AUTH_TOKEN &&
process.env.TWILIO_ACCOUNT_SID !== 'AC...' &&
process.env.TWILIO_AUTH_TOKEN !== 'your-twilio-auth-token'
? twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
: null
const firebasePrivateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
const hasFirebaseConfig =
!!process.env.FIREBASE_PROJECT_ID &&
!!process.env.FIREBASE_CLIENT_EMAIL &&
!!firebasePrivateKey &&
!firebasePrivateKey.includes('BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY')
if (hasFirebaseConfig && !admin.apps.length) {
try {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
privateKey: firebasePrivateKey,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
}),
})
} catch (err: any) {
console.warn('[Notifications] Firebase init skipped:', err.message)
}
}
interface SendNotificationOptions {
@@ -37,6 +109,22 @@ interface SendNotificationOptions {
locale?: string
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderEmailHtml(body: string) {
return body
.split(/\n{2,}/)
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
.join('')
}
export async function sendNotification(opts: SendNotificationOptions) {
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = []
@@ -60,19 +148,48 @@ export async function sendNotification(opts: SendNotificationOptions) {
let success = false
if (channel === 'EMAIL' && opts.email) {
if (resend) {
if (!emailFromAddress || !emailFromName) {
throw new Error('Email sender identity is not configured')
}
const { data, error } = await resend.emails.send({
from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM}>`,
from: `${emailFromName} <${emailFromAddress}>`,
to: opts.email,
subject: opts.title,
html: `<p>${opts.body}</p>`,
html: renderEmailHtml(opts.body),
text: opts.body,
})
if (!error) {
if (error) {
throw new Error(error.message)
}
providerMessageId = data?.id ?? null
success = true
} else if (smtpTransport) {
if (!smtpFromAddress) {
throw new Error('SMTP sender identity is not configured')
}
const info = await smtpTransport.sendMail({
from: smtpFromName ? `${smtpFromName} <${smtpFromAddress}>` : smtpFromAddress,
to: opts.email,
subject: opts.title,
html: renderEmailHtml(opts.body),
text: opts.body,
replyTo:
process.env.MAIL_REPLY_TO_ADDRESS && !process.env.MAIL_REPLY_TO_ADDRESS.includes('${')
? process.env.MAIL_REPLY_TO_NAME && !process.env.MAIL_REPLY_TO_NAME.includes('${')
? `${process.env.MAIL_REPLY_TO_NAME} <${process.env.MAIL_REPLY_TO_ADDRESS}>`
: process.env.MAIL_REPLY_TO_ADDRESS
: undefined,
})
providerMessageId = info.messageId
success = true
} else {
throw new Error('No email provider is configured')
}
}
if (channel === 'SMS' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({
body: opts.body,
from: process.env.TWILIO_PHONE_NUMBER!,
@@ -83,6 +200,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
}
if (channel === 'WHATSAPP' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({
body: opts.body,
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
@@ -93,6 +211,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
}
if (channel === 'PUSH' && opts.fcmToken) {
if (!admin.apps.length) throw new Error('Firebase is not configured')
const response = await admin.messaging().send({
token: opts.fcmToken,
notification: { title: opts.title, body: opts.body },
+127
View File
@@ -0,0 +1,127 @@
const BASE_URL = process.env.PAYPAL_BASE_URL ?? 'https://api-m.paypal.com'
const CLIENT_ID = process.env.PAYPAL_CLIENT_ID ?? ''
const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET ?? ''
let cachedToken: { token: string; expiresAt: number } | null = null
async function getAccessToken(): Promise<string> {
if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) {
return cachedToken.token
}
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
},
body: 'grant_type=client_credentials',
})
if (!res.ok) throw new Error('PayPal: failed to obtain access token')
const json = await res.json()
cachedToken = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 }
return cachedToken.token
}
async function ppFetch(path: string, init?: RequestInit) {
const token = await getAccessToken()
const res = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(init?.headers as Record<string, string> ?? {}),
},
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`PayPal ${path} failed: ${err?.message ?? res.statusText}`)
}
return res.json()
}
export interface PayPalCreateOrderParams {
amount: number
currency: string
orderId: string
description: string
returnUrl: string
cancelUrl: string
}
export interface PayPalOrderResult {
orderId: string
approveUrl: string
status: string
}
export async function createOrder(params: PayPalCreateOrderParams): Promise<PayPalOrderResult> {
const json = await ppFetch('/v2/checkout/orders', {
method: 'POST',
body: JSON.stringify({
intent: 'CAPTURE',
purchase_units: [
{
reference_id: params.orderId,
description: params.description,
amount: {
currency_code: params.currency,
value: (params.amount / 100).toFixed(2),
},
},
],
application_context: {
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
brand_name: 'RentalDriveGo',
user_action: 'PAY_NOW',
},
}),
})
const approveLink = json.links?.find((l: { rel: string; href: string }) => l.rel === 'approve')
return {
orderId: json.id,
approveUrl: approveLink?.href ?? '',
status: json.status,
}
}
export async function captureOrder(paypalOrderId: string) {
return ppFetch(`/v2/checkout/orders/${paypalOrderId}/capture`, { method: 'POST', body: '{}' })
}
export async function refundCapture(captureId: string, amount: number, currency: string, note?: string) {
return ppFetch(`/v2/payments/captures/${captureId}/refund`, {
method: 'POST',
body: JSON.stringify({
amount: { currency_code: currency, value: (amount / 100).toFixed(2) },
note_to_payer: note,
}),
})
}
export async function verifyWebhookEvent(headers: Record<string, string | undefined>, rawBody: string) {
try {
const json = await ppFetch('/v1/notifications/verify-webhook-signature', {
method: 'POST',
body: JSON.stringify({
auth_algo: headers['paypal-auth-algo'],
cert_url: headers['paypal-cert-url'],
transmission_id: headers['paypal-transmission-id'],
transmission_sig: headers['paypal-transmission-sig'],
transmission_time: headers['paypal-transmission-time'],
webhook_id: process.env.PAYPAL_WEBHOOK_ID ?? '',
webhook_event: JSON.parse(rawBody),
}),
})
return json.verification_status === 'SUCCESS'
} catch {
return false
}
}
export function isConfigured(): boolean {
return !!(CLIENT_ID && CLIENT_SECRET && CLIENT_ID !== 'your-paypal-client-id')
}
+2 -2
View File
@@ -31,7 +31,7 @@ export async function listEmployees(companyId: string): Promise<TeamMember[]> {
})
const hydrated = await Promise.allSettled(
employees.map(async (emp) => {
employees.map(async (emp: (typeof employees)[number]) => {
try {
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
return { ...emp, lastActiveAt: clerkUser.lastActiveAt ? new Date(clerkUser.lastActiveAt) : null, invitationStatus: 'accepted' as const }
@@ -41,7 +41,7 @@ export async function listEmployees(companyId: string): Promise<TeamMember[]> {
})
)
return hydrated.map((r) => (r.status === 'fulfilled' ? r.value : (r as any).value))
return hydrated.flatMap((r): TeamMember[] => (r.status === 'fulfilled' ? [r.value] : []))
}
export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) {
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
@@ -0,0 +1,350 @@
'use client'
import { useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
type Currency = 'MAD' | 'USD' | 'EUR'
interface Subscription {
id: string
plan: Plan
billingPeriod: BillingPeriod
status: string
currency: Currency
trialEndAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
}
interface Invoice {
id: string
amount: number
currency: Currency
status: string
paymentProvider: string
paidAt: string | null
createdAt: string
}
const STATUS_BADGE: Record<string, string> = {
TRIALING: 'bg-sky-100 text-sky-700',
ACTIVE: 'bg-green-100 text-green-700',
PAST_DUE: 'bg-amber-100 text-amber-700',
CANCELLED: 'bg-slate-100 text-slate-600',
UNPAID: 'bg-red-100 text-red-700',
}
const INVOICE_STATUS: Record<string, string> = {
PAID: 'bg-green-100 text-green-700',
PENDING: 'bg-amber-100 text-amber-700',
FAILED: 'bg-red-100 text-red-700',
REFUNDED: 'bg-slate-100 text-slate-600',
}
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
const PLAN_FEATURES: Record<Plan, string[]> = {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
}
export default function BillingPage() {
const [subscription, setSubscription] = useState<Subscription | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const [currency, setCurrency] = useState<Currency>('MAD')
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false)
useEffect(() => {
Promise.all([
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
])
.then(([sub, inv]) => {
if (sub) {
setSubscription(sub)
setSelectedPlan(sub.plan)
setBillingPeriod(sub.billingPeriod)
setCurrency(sub.currency as Currency)
}
setInvoices(inv ?? [])
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
async function handleCheckout() {
setPaying(true)
setError(null)
try {
const base = window.location.origin
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
method: 'POST',
body: JSON.stringify({
plan: selectedPlan,
billingPeriod,
currency,
provider,
successUrl: `${base}/dashboard/billing?payment=success`,
failureUrl: `${base}/dashboard/billing?payment=failed`,
}),
})
window.location.href = result.checkoutUrl
} catch (err: any) {
setError(err.message)
setPaying(false)
}
}
async function handleCancel() {
setCancelling(true)
setError(null)
try {
const sub = await apiFetch<Subscription>('/subscriptions/cancel', { method: 'POST' })
setSubscription(sub)
} catch (err: any) {
setError(err.message)
} finally {
setCancelling(false)
}
}
async function handleResume() {
setCancelling(true)
setError(null)
try {
const sub = await apiFetch<Subscription>('/subscriptions/resume', { method: 'POST' })
setSubscription(sub)
} catch (err: any) {
setError(err.message)
} finally {
setCancelling(false)
}
}
const planPrice = PLAN_PRICES[selectedPlan]?.[billingPeriod]?.[currency]
const daysLeft = subscription?.trialEndAt
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
: null
return (
<div className="space-y-8">
<div>
<h2 className="text-xl font-semibold text-slate-900">Billing</h2>
<p className="text-sm text-slate-500 mt-1">Manage your plan, payment provider, and invoice history.</p>
</div>
{error && (
<div className="card p-4 border-red-200 bg-red-50 text-sm text-red-700">{error}</div>
)}
{/* Trial banner */}
{subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (
<div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between">
<p className="text-sm font-medium text-sky-800">
Free trial <strong>{daysLeft} days</strong> remaining. Subscribe before it ends to keep access.
</p>
</div>
)}
{/* Current plan */}
{subscription && (
<div className="card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Current plan</p>
<div className="mt-1 flex items-center gap-3">
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
{subscription.status}
</span>
</div>
<p className="mt-1 text-sm text-slate-500">
{subscription.billingPeriod} · {subscription.currency}
{subscription.currentPeriodEnd && ` · renews ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
</p>
{subscription.cancelAtPeriodEnd && (
<p className="mt-2 text-sm font-medium text-amber-700">
Cancellation scheduled at end of billing period.{' '}
<button onClick={handleResume} disabled={cancelling} className="underline">Undo</button>
</p>
)}
</div>
{!subscription.cancelAtPeriodEnd && (
<button
onClick={handleCancel}
disabled={cancelling}
className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm"
>
{cancelling ? 'Cancelling…' : 'Cancel plan'}
</button>
)}
</div>
</div>
)}
{/* Plan selector + checkout */}
<div className="card p-6 space-y-6">
<div>
<h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe'}
</h3>
<p className="mt-1 text-sm text-slate-500">Select a plan and payment provider to proceed.</p>
</div>
{/* Billing period toggle */}
<div className="flex items-center gap-2">
{(['MONTHLY', 'ANNUAL'] as BillingPeriod[]).map((p) => (
<button
key={p}
onClick={() => setBillingPeriod(p)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
billingPeriod === p ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{p === 'MONTHLY' ? 'Monthly' : 'Annual (save ~17%)'}
</button>
))}
</div>
{/* Currency selector */}
<div className="flex items-center gap-2">
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
<button
key={c}
onClick={() => setCurrency(c)}
className={`px-3 py-1 rounded-lg text-sm font-medium border transition-colors ${
currency === c ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{c}
</button>
))}
</div>
{/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3">
{PLANS.map((plan) => {
const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency]
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
return (
<button
key={plan}
onClick={() => setSelectedPlan(plan)}
className={`text-left p-5 rounded-xl border-2 transition-all ${
selectedPlan === plan
? 'border-blue-500 bg-blue-50/50'
: 'border-slate-200 hover:border-slate-300 bg-white'
} ${isActive ? 'ring-2 ring-green-200' : ''}`}
>
<div className="flex items-center justify-between">
<p className="font-semibold text-slate-900">{plan}</p>
{isActive && <span className="badge-green">Active</span>}
</div>
<p className="mt-2 text-2xl font-black text-slate-900">
{price ? formatCurrency(price, currency) : '—'}
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? 'mo' : 'yr'}</span>
</p>
<ul className="mt-3 space-y-1">
{PLAN_FEATURES[plan].map((f) => (
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f}
</li>
))}
</ul>
</button>
)
})}
</div>
{/* Provider selector */}
<div>
<p className="text-sm font-medium text-slate-700 mb-2">Payment provider</p>
<div className="flex gap-3">
{(['AMANPAY', 'PAYPAL'] as const).map((p) => (
<button
key={p}
onClick={() => setProvider(p)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
provider === p ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{p === 'AMANPAY' ? '🏦 AmanPay' : '🔵 PayPal'}
</button>
))}
</div>
</div>
{/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
<p className="text-sm text-slate-500">Total</p>
<p className="text-xl font-black text-slate-900">
{planPrice ? formatCurrency(planPrice, currency) : '—'}
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? 'month' : 'year'}</span>
</p>
</div>
<button
onClick={handleCheckout}
disabled={paying || loading}
className="btn-primary px-8 py-3"
>
{paying ? 'Redirecting…' : subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe now'}
</button>
</div>
</div>
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-base font-semibold text-slate-900">Invoice history</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Date</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Provider</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Paid</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">Loading</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">No invoices yet.</td></tr>
) : invoices.map((inv) => (
<tr key={inv.id}>
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
{inv.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
{formatCurrency(inv.amount, inv.currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,71 @@
'use client'
import { useEffect, useState } from 'react'
import type { ApiPaginated } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
interface CustomerRow {
id: string
firstName: string
lastName: string
email: string
phone: string | null
flagged: boolean
licenseValidationStatus: string
}
export default function CustomersPage() {
const [rows, setRows] = useState<CustomerRow[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
apiFetch<ApiPaginated<CustomerRow>>('/customers?pageSize=100')
.then((result) => setRows(result.data))
.catch((err) => setError(err.message))
}, [])
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">Customers</h2>
<p className="text-sm text-slate-500 mt-1">Company-scoped CRM with license validation and risk flags.</p>
</div>
<div className="card overflow-hidden">
{error ? (
<div className="p-8 text-sm text-red-600">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Contact</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">License</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Flags</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4 text-sm font-semibold text-slate-900">{row.firstName} {row.lastName}</td>
<td className="px-6 py-4">
<p className="text-sm text-slate-700">{row.email}</p>
<p className="text-xs text-slate-500">{row.phone ?? 'No phone'}</p>
</td>
<td className="px-6 py-4"><span className="badge-gray">{row.licenseValidationStatus}</span></td>
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">Flagged</span> : <span className="badge-green">Clear</span>}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">No customers yet.</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,73 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import Image from 'next/image'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
interface VehicleDetail {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
status: string
color: string
transmission: string
fuelType: string
seats: number
licensePlate: string
photos: string[]
notes: string | null
}
export default function FleetDetailPage() {
const params = useParams<{ id: string }>()
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
apiFetch<VehicleDetail>(`/vehicles/${params.id}`)
.then(setVehicle)
.catch((err) => setError(err.message))
}, [params.id])
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!vehicle) return <div className="card p-6 text-sm text-slate-500">Loading vehicle</div>
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
<p className="text-sm text-slate-500 mt-1">{vehicle.year} · {vehicle.licensePlate} · {vehicle.status}</p>
</div>
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">Photos</h3>
<div className="grid gap-4 sm:grid-cols-2">
{vehicle.photos.map((photo, index) => (
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100">
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" />
</div>
))}
{vehicle.photos.length === 0 && <div className="text-sm text-slate-400">No photos uploaded yet.</div>}
</div>
</div>
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">Vehicle details</h3>
<dl className="space-y-3 text-sm">
<div><dt className="text-slate-500">Category</dt><dd className="text-slate-900">{vehicle.category}</dd></div>
<div><dt className="text-slate-500">Daily rate</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Seats</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
<div><dt className="text-slate-500">Transmission</dt><dd className="text-slate-900">{vehicle.transmission}</dd></div>
<div><dt className="text-slate-500">Fuel type</dt><dd className="text-slate-900">{vehicle.fuelType}</dd></div>
<div><dt className="text-slate-500">Color</dt><dd className="text-slate-900">{vehicle.color}</dd></div>
<div><dt className="text-slate-500">Notes</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
</dl>
</div>
</div>
</div>
)
}
@@ -7,6 +7,7 @@ import Link from 'next/link'
import { apiFetch } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import dayjs from 'dayjs'
import type { ApiPaginated } from '@rentaldrivego/types'
interface Vehicle {
id: string
@@ -90,7 +91,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Category</label>
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'CONVERTIBLE', 'PICKUP'].map((c) => (
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
@@ -126,7 +127,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label>
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID', 'LPG'].map((f) => (
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'].map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
@@ -158,8 +159,8 @@ export default function FleetPage() {
const fetchVehicles = () => {
setLoading(true)
apiFetch<Vehicle[]>('/vehicles')
.then(setVehicles)
apiFetch<ApiPaginated<Vehicle>>('/vehicles?pageSize=100')
.then((result) => setVehicles(result.data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
@@ -224,7 +225,7 @@ export default function FleetPage() {
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="">All categories</option>
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'CONVERTIBLE', 'PICKUP'].map((c) => (
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
@@ -0,0 +1,208 @@
'use client'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
type NotificationItem = {
id: string
type: string
title: string
body: string
readAt: string | null
createdAt: string
}
type PreferenceItem = {
notificationType: string
channel: string
enabled: boolean
}
const COMPANY_EVENTS = [
'NEW_RESERVATION',
'RESERVATION_CANCELLED',
'PAYMENT_RECEIVED',
'SUBSCRIPTION_TRIAL_ENDING',
'MAINTENANCE_DUE',
'OFFER_EXPIRING',
]
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
export default function DashboardNotificationsPage() {
const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<'inbox' | 'preferences'>('inbox')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function load() {
setLoading(true)
setError(null)
try {
const [notificationData, preferenceData] = await Promise.all([
apiFetch<NotificationItem[]>('/notifications/company'),
apiFetch<PreferenceItem[]>('/notifications/company/preferences'),
])
setNotifications(notificationData)
const mapped = Object.fromEntries(
preferenceData.map((item) => [`${item.notificationType}:${item.channel}`, item.enabled]),
)
setPreferences(mapped)
} catch (err: any) {
setError(err.message ?? 'Failed to load notifications')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
async function markRead(id: string) {
await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' })
setNotifications((current) =>
current.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item)),
)
}
async function markAllRead() {
await apiFetch('/notifications/company/read-all', { method: 'POST' })
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
}
async function savePreferences() {
setSaving(true)
setError(null)
try {
const body = Object.entries(preferences).map(([key, enabled]) => {
const [notificationType, channel] = key.split(':')
return { notificationType, channel, enabled }
})
await apiFetch('/notifications/company/preferences', {
method: 'PATCH',
body: JSON.stringify(body),
})
} catch (err: any) {
setError(err.message ?? 'Failed to save preferences')
} finally {
setSaving(false)
}
}
function preferenceValue(notificationType: string, channel: string) {
return preferences[`${notificationType}:${channel}`] ?? true
}
function setPreference(notificationType: string, channel: string, enabled: boolean) {
setPreferences((current) => ({
...current,
[`${notificationType}:${channel}`]: enabled,
}))
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-slate-900">Notifications</h1>
<p className="mt-1 text-sm text-slate-500">Track in-app alerts and control delivery preferences for your team account.</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setActiveTab('inbox')}
className={activeTab === 'inbox' ? 'btn-primary' : 'btn-secondary'}
>
Inbox
</button>
<button
type="button"
onClick={() => setActiveTab('preferences')}
className={activeTab === 'preferences' ? 'btn-primary' : 'btn-secondary'}
>
Preferences
</button>
</div>
</div>
{error ? <div className="card p-4 text-sm text-red-600">{error}</div> : null}
{activeTab === 'inbox' ? (
<div className="space-y-4">
<div className="flex justify-end">
<button type="button" onClick={markAllRead} className="btn-secondary">
Mark all as read
</button>
</div>
{loading ? <div className="card p-6 text-sm text-slate-500">Loading notifications</div> : null}
{!loading && notifications.length === 0 ? <div className="card p-6 text-sm text-slate-500">No notifications yet.</div> : null}
{!loading
? notifications.map((notification) => (
<article key={notification.id} className="card p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
{notification.type.replaceAll('_', ' ')}
</p>
<h2 className="mt-2 text-lg font-semibold text-slate-900">{notification.title}</h2>
</div>
{notification.readAt ? (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Read</span>
) : (
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
Mark as read
</button>
)}
</div>
<p className="mt-3 text-sm leading-7 text-slate-600">{notification.body}</p>
<p className="mt-4 text-xs text-slate-400">{new Date(notification.createdAt).toLocaleString()}</p>
</article>
))
: null}
</div>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left font-semibold text-slate-600">Event</th>
{COMPANY_CHANNELS.map((channel) => (
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600">
{channel.replace('_', ' ')}
</th>
))}
</tr>
</thead>
<tbody>
{COMPANY_EVENTS.map((eventName) => (
<tr key={eventName} className="border-t border-slate-100">
<td className="px-4 py-3 font-medium text-slate-900">{eventName.replaceAll('_', ' ')}</td>
{COMPANY_CHANNELS.map((channel) => (
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
<input
type="checkbox"
checked={preferenceValue(eventName, channel)}
onChange={(event) => setPreference(eventName, channel, event.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end border-t border-slate-100 p-4">
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save preferences'}
</button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,63 @@
'use client'
import { useEffect, useState } from 'react'
import dayjs from 'dayjs'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
interface OfferRow {
id: string
title: string
type: string
discountValue: number
isActive: boolean
isPublic: boolean
isFeatured: boolean
validUntil: string
promoCode: string | null
}
export default function OffersPage() {
const [rows, setRows] = useState<OfferRow[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
apiFetch<OfferRow[]>('/offers')
.then(setRows)
.catch((err) => setError(err.message))
}, [])
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">Offers</h2>
<p className="text-sm text-slate-500 mt-1">Promotions shown on your site and optionally on the marketplace.</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{rows.map((offer) => (
<div key={offer.id} className="card p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-slate-900">{offer.title}</h3>
<p className="text-sm text-slate-500 mt-1">{offer.type} · ends {dayjs(offer.validUntil).format('MMM D, YYYY')}</p>
</div>
<span className={offer.isActive ? 'badge-green' : 'badge-gray'}>{offer.isActive ? 'Active' : 'Inactive'}</span>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{offer.isPublic && <span className="badge-blue">Marketplace</span>}
{offer.isFeatured && <span className="badge-purple">Featured</span>}
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
</div>
<p className="mt-4 text-2xl font-bold text-slate-900">
{offer.type === 'PERCENTAGE' ? `${offer.discountValue}%` : formatCurrency(offer.discountValue, 'MAD')}
</p>
</div>
))}
{rows.length === 0 && !error && (
<div className="card p-8 text-sm text-slate-400">No offers created yet.</div>
)}
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
</div>
)
}
@@ -0,0 +1,152 @@
'use client'
import { useEffect, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
interface ReportRow {
reservationId: string
customerName: string
vehicle: string
startDate: string
endDate: string
totalAmount: number
source: string
paymentStatus: string
}
interface ReportData {
summary: {
totalReservations: number
totalRentalRevenue: number
totalDiscounts: number
totalInsurance: number
totalAdditionalDrivers: number
totalCollected: number
totalPricingRulesAdj?: number
}
rows: ReportRow[]
}
type ReportPeriod = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'ANNUAL'
const periodLabels: Record<ReportPeriod, string> = {
WEEKLY: 'Weekly',
MONTHLY: 'Monthly',
QUARTERLY: 'Quarterly',
ANNUAL: 'Annual',
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
export default function ReportsPage() {
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
const [report, setReport] = useState<ReportData | null>(null)
const [error, setError] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
useEffect(() => {
apiFetch<ReportData>(`/analytics/report?period=${period}`)
.then(setReport)
.catch((err) => setError(err.message))
}, [period])
async function exportCsv() {
setExporting(true)
setError(null)
try {
const token = (window as any).__clerk?.session ? await (window as any).__clerk.session.getToken() : null
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) {
const json = await res.json().catch(() => null)
throw new Error(json?.message ?? 'Export failed')
}
const csv = await res.text()
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `rentaldrivego-${period.toLowerCase()}-report.csv`
anchor.click()
window.URL.revokeObjectURL(url)
} catch (err: any) {
setError(err.message ?? 'Export failed')
} finally {
setExporting(false)
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">Reports</h2>
<p className="mt-1 text-sm text-slate-500">Accountant-ready exports with insurance, additional-driver, and pricing-rule totals.</p>
</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(periodLabels) as ReportPeriod[]).map((option) => (
<button
key={option}
onClick={() => setPeriod(option)}
className={`rounded-full px-4 py-2 text-sm font-semibold ${period === option ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-700'}`}
>
{periodLabels[option]}
</button>
))}
<button onClick={exportCsv} disabled={exporting} className="rounded-full border border-slate-900 px-4 py-2 text-sm font-semibold text-slate-900">
{exporting ? 'Exporting…' : 'Export CSV'}
</button>
</div>
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
{report && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
<div className="card p-5"><p className="text-sm text-slate-500">Bookings</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Rental</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Insurance</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Drivers</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Discounts</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Collected</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div>
</div>
)}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Reservation</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Vehicle</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Period</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Source</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Payment</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(report?.rows ?? []).map((row) => (
<tr key={row.reservationId}>
<td className="px-6 py-4 text-sm text-slate-700">{row.customerName}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle}</td>
<td className="px-6 py-4 text-sm text-slate-500">{row.startDate} - {row.endDate}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
<td className="px-6 py-4"><span className="badge-gray">{row.paymentStatus}</span></td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
</tr>
))}
{(report?.rows.length ?? 0) === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">No report rows available for this period.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,272 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import dayjs from 'dayjs'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import DamageInspectionCard, { DamageInspection } from '@/components/reservations/DamageInspectionCard'
interface ReservationDetail {
id: string
status: string
source: string
startDate: string
endDate: string
totalAmount: number
discountAmount: number
insuranceTotal: number
additionalDriverTotal: number
pricingRulesTotal: number
paymentStatus: string
pricingRulesApplied: { name: string; amount: number; type: string }[] | null
customer: {
firstName: string
lastName: string
email: string
phone: string | null
driverLicense: string | null
licenseValidationStatus: string
flagged: boolean
}
vehicle: {
make: string
model: string
licensePlate: string
}
insurances: { id: string; policyName: string; totalCharge: number }[]
additionalDrivers: {
id: string
firstName: string
lastName: string
driverLicense: string
totalCharge: number
requiresApproval: boolean
approvedAt: string | null
approvalNote: string | null
}[]
}
export default function ReservationDetailPage() {
const params = useParams<{ id: string }>()
const [reservation, setReservation] = useState<ReservationDetail | null>(null)
const [inspections, setInspections] = useState<DamageInspection[]>([])
const [error, setError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [acting, setActing] = useState(false)
async function loadReservation() {
try {
const [reservationData, inspectionData] = await Promise.all([
apiFetch<ReservationDetail>(`/reservations/${params.id}`),
apiFetch<DamageInspection[]>(`/reservations/${params.id}/inspections`),
])
setReservation(reservationData)
setInspections(inspectionData)
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load reservation')
}
}
useEffect(() => {
loadReservation()
}, [params.id])
async function runAction(action: 'confirm' | 'checkin' | 'checkout') {
setActing(true)
setActionError(null)
try {
await apiFetch(`/reservations/${params.id}/${action}`, {
method: 'POST',
body: JSON.stringify({}),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? `Failed to ${action} reservation`)
} finally {
setActing(false)
}
}
async function approveDriver(driverId: string) {
setActing(true)
setActionError(null)
try {
await apiFetch(`/reservations/${params.id}/additional-drivers/${driverId}/approval`, {
method: 'PATCH',
body: JSON.stringify({ approved: true }),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? 'Failed to approve additional driver')
} finally {
setActing(false)
}
}
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!reservation) return <div className="card p-6 text-sm text-slate-500">Loading reservation</div>
const checkinInspection = inspections.find((inspection) => inspection.type === 'CHECKIN')
const checkoutInspection = inspections.find((inspection) => inspection.type === 'CHECKOUT')
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">Reservation {reservation.id.slice(-8).toUpperCase()}</h2>
<p className="mt-1 text-sm text-slate-500">{reservation.source} · {reservation.status} · {reservation.paymentStatus}</p>
</div>
<div className="flex flex-wrap gap-3">
{reservation.status === 'DRAFT' && (
<button disabled={acting} onClick={() => runAction('confirm')} className="btn-primary">
{acting ? 'Working…' : 'Confirm reservation'}
</button>
)}
{reservation.status === 'CONFIRMED' && (
<button disabled={acting} onClick={() => runAction('checkin')} className="btn-primary">
{acting ? 'Working…' : 'Check in vehicle'}
</button>
)}
{reservation.status === 'ACTIVE' && (
<button disabled={acting} onClick={() => runAction('checkout')} className="btn-primary">
{acting ? 'Working…' : 'Check out vehicle'}
</button>
)}
</div>
</div>
{actionError && <div className="card p-4 text-sm text-red-600">{actionError}</div>}
<div className="grid gap-6 lg:grid-cols-2">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Customer</h3>
<div className="space-y-2 text-sm">
<p className="font-medium text-slate-900">{reservation.customer.firstName} {reservation.customer.lastName}</p>
<p className="text-slate-600">{reservation.customer.email}</p>
<p className="text-slate-600">{reservation.customer.phone ?? 'No phone provided'}</p>
<p className="text-slate-600">License: {reservation.customer.driverLicense ?? 'Not captured'}</p>
<div className="flex flex-wrap gap-2 pt-2">
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
{reservation.customer.flagged && <span className="badge-red">Flagged</span>}
</div>
</div>
</div>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Vehicle</h3>
<div className="space-y-2 text-sm text-slate-600">
<p className="font-medium text-slate-900">{reservation.vehicle.make} {reservation.vehicle.model}</p>
<p>{reservation.vehicle.licensePlate}</p>
<p>{dayjs(reservation.startDate).format('MMM D, YYYY')} - {dayjs(reservation.endDate).format('MMM D, YYYY')}</p>
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="space-y-6">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Charges</h3>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div><dt className="text-slate-500">Discount</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Insurance</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Additional drivers</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Pricing adjustments</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Grand total</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
</dl>
{reservation.insurances.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">Applied insurance</p>
<div className="space-y-2">
{reservation.insurances.map((insurance) => (
<div key={insurance.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
<span className="text-slate-700">{insurance.policyName}</span>
<span className="font-semibold text-slate-900">{formatCurrency(insurance.totalCharge, 'MAD')}</span>
</div>
))}
</div>
</div>
)}
{reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">Pricing rules applied</p>
<div className="space-y-2">
{reservation.pricingRulesApplied.map((rule) => (
<div key={`${rule.name}-${rule.amount}`} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
<span className="text-slate-700">{rule.name}</span>
<span className={rule.amount < 0 ? 'font-semibold text-emerald-700' : 'font-semibold text-amber-700'}>
{rule.amount < 0 ? '-' : '+'}{formatCurrency(Math.abs(rule.amount), 'MAD')}
</span>
</div>
))}
</div>
</div>
)}
</div>
<DamageInspectionCard
reservationId={reservation.id}
type="CHECKIN"
initialInspection={checkinInspection}
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
/>
<DamageInspectionCard
reservationId={reservation.id}
type="CHECKOUT"
initialInspection={checkoutInspection}
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
/>
</div>
<div className="space-y-6">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Additional drivers</h3>
<div className="space-y-3">
{reservation.additionalDrivers.map((driver) => (
<div key={driver.id} className="rounded-2xl border border-slate-200 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium text-slate-900">{driver.firstName} {driver.lastName}</p>
<p className="text-sm text-slate-500">License: {driver.driverLicense}</p>
<p className="mt-1 text-sm text-slate-600">Charge: {formatCurrency(driver.totalCharge, 'MAD')}</p>
</div>
{driver.requiresApproval && !driver.approvedAt ? (
<button onClick={() => approveDriver(driver.id)} disabled={acting} className="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold text-white">
Approve
</button>
) : (
<span className={driver.approvedAt ? 'badge-green' : 'badge-gray'}>
{driver.approvedAt ? 'Approved' : 'No approval needed'}
</span>
)}
</div>
{driver.approvalNote && <p className="mt-2 text-xs text-amber-700">{driver.approvalNote}</p>}
</div>
))}
{reservation.additionalDrivers.length === 0 && (
<div className="text-sm text-slate-400">No additional drivers were added to this reservation.</div>
)}
</div>
</div>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Inspection summary</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">Check-in inspection</span>
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? 'Saved' : 'Pending'}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">Check-out inspection</span>
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? 'Saved' : 'Pending'}</span>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,81 @@
'use client'
import { useEffect, useState } from 'react'
import dayjs from 'dayjs'
import Link from 'next/link'
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
interface ReservationRow {
id: string
status: string
source: string
startDate: string
endDate: string
totalAmount: number
totalDays: number
vehicle: { make: string; model: string }
customer: { firstName: string; lastName: string; email: string }
}
export default function ReservationsPage() {
const [rows, setRows] = useState<ReservationRow[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
apiFetch<ApiPaginated<ReservationRow>>('/reservations?pageSize=100')
.then((result) => setRows(result.data))
.catch((err) => setError(err.message))
}, [])
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">Reservations</h2>
<p className="text-sm text-slate-500 mt-1">All booking sources, including dashboard, public site, and marketplace.</p>
</div>
<div className="card overflow-hidden">
{error ? (
<div className="p-8 text-sm text-red-600">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Dates</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Source</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4">
<Link href={`/dashboard/reservations/${row.id}`} className="text-sm font-semibold text-slate-900 hover:text-blue-700">
{row.customer.firstName} {row.customer.lastName}
</Link>
<p className="text-xs text-slate-500">{row.customer.email}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle.make} {row.vehicle.model}</td>
<td className="px-6 py-4 text-sm text-slate-500">{dayjs(row.startDate).format('MMM D')} - {dayjs(row.endDate).format('MMM D, YYYY')}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
<td className="px-6 py-4"><span className="badge-blue">{row.status}</span></td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">No reservations yet.</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,591 @@
'use client'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
interface BrandSettings {
displayName: string
tagline: string | null
primaryColor: string
accentColor?: string | null
publicEmail: string | null
publicPhone: string | null
publicAddress?: string | null
publicCity: string | null
publicCountry?: string | null
subdomain: string
logoUrl?: string | null
heroImageUrl?: string | null
websiteUrl?: string | null
whatsappNumber?: string | null
paypalEmail: string | null
amanpayMerchantId: string | null
amanpaySecretKey?: string | null
paypalMerchantId?: string | null
customDomain?: string | null
customDomainVerified?: boolean
defaultCurrency?: string
isListedOnMarketplace: boolean
}
interface ContractSettings {
fuelPolicyType: string
fuelPolicyNote: string | null
additionalDriverCharge: 'FREE' | 'PER_DAY' | 'FLAT'
additionalDriverDailyRate: number
additionalDriverFlatRate: number
damagePolicy: string
}
interface InsurancePolicy {
id: string
name: string
type: string
chargeType: string
chargeValue: number
isRequired: boolean
isActive: boolean
}
interface PricingRule {
id: string
name: string
type: string
condition: string
conditionValue: number
adjustmentType: string
adjustmentValue: number
isActive: boolean
}
interface AccountingSettings {
reportingPeriod: string
accountantEmail: string | null
accountantName: string | null
autoSendReport: boolean
reportFormat: string
}
const emptyInsurance = { name: '', type: 'BASIC', chargeType: 'PER_DAY', chargeValue: 0, isRequired: false, isActive: true }
const emptyRule = { name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 0, isActive: true }
export default function SettingsPage() {
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null)
const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([])
const [pricingRules, setPricingRules] = useState<PricingRule[]>([])
const [accountingSettings, setAccountingSettings] = useState<AccountingSettings | null>(null)
const [newInsurance, setNewInsurance] = useState(emptyInsurance)
const [newRule, setNewRule] = useState(emptyRule)
const [customDomain, setCustomDomain] = useState('')
const [uploadingAsset, setUploadingAsset] = useState<'logo' | 'hero' | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
async function load() {
try {
const [brandData, contractData, insuranceData, ruleData, accountingData] = await Promise.all([
apiFetch<BrandSettings | null>('/companies/me/brand'),
apiFetch<ContractSettings | null>('/companies/me/contract-settings'),
apiFetch<InsurancePolicy[]>('/companies/me/insurance-policies'),
apiFetch<PricingRule[]>('/companies/me/pricing-rules'),
apiFetch<AccountingSettings | null>('/companies/me/accounting-settings'),
])
setBrand(brandData)
setCustomDomain(brandData?.customDomain ?? '')
setContractSettings(contractData ?? {
fuelPolicyType: 'FULL_TO_FULL',
fuelPolicyNote: '',
additionalDriverCharge: 'FREE',
additionalDriverDailyRate: 0,
additionalDriverFlatRate: 0,
damagePolicy: '',
})
setInsurancePolicies(insuranceData)
setPricingRules(ruleData)
setAccountingSettings(accountingData ?? {
reportingPeriod: 'MONTHLY',
accountantEmail: '',
accountantName: '',
autoSendReport: false,
reportFormat: 'CSV',
})
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load settings')
}
}
async function saveBrandSettings() {
if (!brand) return
setSaving(true)
setError(null)
try {
const updated = await apiFetch<BrandSettings>('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
displayName: brand.displayName,
tagline: brand.tagline || undefined,
primaryColor: brand.primaryColor,
accentColor: brand.accentColor || undefined,
publicEmail: brand.publicEmail || undefined,
publicPhone: brand.publicPhone || undefined,
publicAddress: brand.publicAddress || undefined,
publicCity: brand.publicCity || undefined,
publicCountry: brand.publicCountry || undefined,
websiteUrl: brand.websiteUrl || undefined,
whatsappNumber: brand.whatsappNumber || undefined,
paypalEmail: brand.paypalEmail || undefined,
amanpayMerchantId: brand.amanpayMerchantId || undefined,
amanpaySecretKey: brand.amanpaySecretKey || undefined,
paypalMerchantId: brand.paypalMerchantId || undefined,
defaultCurrency: brand.defaultCurrency || undefined,
isListedOnMarketplace: brand.isListedOnMarketplace,
}),
})
setBrand(updated)
} catch (err: any) {
setError(err.message ?? 'Failed to save brand settings')
} finally {
setSaving(false)
}
}
async function uploadBrandAsset(kind: 'logo' | 'hero', file: File | null) {
if (!file) return
setUploadingAsset(kind)
setError(null)
try {
const formData = new FormData()
formData.append('file', file)
const updated = await apiFetch<BrandSettings>(`/companies/me/brand/${kind}`, {
method: 'POST',
body: formData,
})
setBrand(updated)
} catch (err: any) {
setError(err.message ?? `Failed to upload ${kind}`)
} finally {
setUploadingAsset(null)
}
}
async function saveCustomDomain() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/brand/custom-domain', {
method: 'POST',
body: JSON.stringify({ customDomain }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to save custom domain')
} finally {
setSaving(false)
}
}
async function removeCustomDomain() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/brand/custom-domain', {
method: 'DELETE',
})
setCustomDomain('')
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to remove custom domain')
} finally {
setSaving(false)
}
}
useEffect(() => {
load()
}, [])
async function saveContractSettings() {
if (!contractSettings) return
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/contract-settings', {
method: 'PATCH',
body: JSON.stringify(contractSettings),
})
} catch (err: any) {
setError(err.message ?? 'Failed to save contract settings')
} finally {
setSaving(false)
}
}
async function saveAccountingSettings() {
if (!accountingSettings) return
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/accounting-settings', {
method: 'PATCH',
body: JSON.stringify(accountingSettings),
})
} catch (err: any) {
setError(err.message ?? 'Failed to save accounting settings')
} finally {
setSaving(false)
}
}
async function createInsurance() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/insurance-policies', {
method: 'POST',
body: JSON.stringify(newInsurance),
})
setNewInsurance(emptyInsurance)
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to create insurance policy')
} finally {
setSaving(false)
}
}
async function toggleInsurance(policy: InsurancePolicy) {
try {
await apiFetch(`/companies/me/insurance-policies/${policy.id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !policy.isActive }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to update insurance policy')
}
}
async function createRule() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/pricing-rules', {
method: 'POST',
body: JSON.stringify(newRule),
})
setNewRule(emptyRule)
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to create pricing rule')
} finally {
setSaving(false)
}
}
async function toggleRule(rule: PricingRule) {
try {
await apiFetch(`/companies/me/pricing-rules/${rule.id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !rule.isActive }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to update pricing rule')
}
}
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">Advanced settings</h2>
<p className="mt-1 text-sm text-slate-500">Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.</p>
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
{brand && (
<div className="grid gap-6">
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">Brand and public profile</h3>
<p className="mt-1 text-sm text-slate-500">Control how your company appears on the marketplace and public booking site.</p>
</div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save brand'}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Display name</label>
<input className="input-field" value={brand.displayName} onChange={(event) => setBrand((current) => current ? { ...current, displayName: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Tagline</label>
<input className="input-field" value={brand.tagline ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, tagline: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Public email</label>
<input className="input-field" type="email" value={brand.publicEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicEmail: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Public phone</label>
<input className="input-field" value={brand.publicPhone ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicPhone: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">City</label>
<input className="input-field" value={brand.publicCity ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCity: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Country</label>
<input className="input-field" value={brand.publicCountry ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCountry: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Website URL</label>
<input className="input-field" value={brand.websiteUrl ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, websiteUrl: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">WhatsApp number</label>
<input className="input-field" value={brand.whatsappNumber ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, whatsappNumber: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Brand color</label>
<input className="input-field" value={brand.primaryColor} onChange={(event) => setBrand((current) => current ? { ...current, primaryColor: event.target.value } : current)} />
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} />
Listed on marketplace
</label>
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">Logo upload</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('logo', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? 'Uploading…' : brand.logoUrl ? 'Logo uploaded' : 'No logo uploaded yet'}</p>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">Hero image upload</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('hero', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? 'Uploading…' : brand.heroImageUrl ? 'Hero image uploaded' : 'No hero image uploaded yet'}</p>
</label>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">Rental payment methods</h3>
<p className="mt-1 text-sm text-slate-500">Configure how renters pay your company on the public booking site.</p>
</div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save payments'}
</button>
</div>
<div className="mt-5 grid gap-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">AmanPay merchant ID</label>
<input className="input-field" value={brand.amanpayMerchantId ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpayMerchantId: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">AmanPay secret key</label>
<input className="input-field" type="password" value={brand.amanpaySecretKey ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpaySecretKey: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">PayPal business email</label>
<input className="input-field" type="email" value={brand.paypalEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, paypalEmail: event.target.value } : current)} />
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
If no payment method is configured, renters can still submit reservation requests and pay on pickup.
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">Custom domain</h3>
<p className="mt-1 text-sm text-slate-500">Point your own domain to the branded booking site.</p>
</div>
<button onClick={saveCustomDomain} disabled={saving || !customDomain.trim()} className="btn-primary">
{saving ? 'Saving…' : 'Save domain'}
</button>
</div>
<div className="mt-5 space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Subdomain</label>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Custom domain</label>
<input className="input-field" placeholder="cars.example.com" value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} />
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
Status: {brand.customDomain ? (brand.customDomainVerified ? 'Verified' : 'Pending DNS verification') : 'Not configured'}
</div>
{brand.customDomain ? (
<button onClick={removeCustomDomain} disabled={saving} className="btn-secondary">
Remove custom domain
</button>
) : null}
</div>
</div>
</div>
</div>
)}
{contractSettings && (
<div className="card p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">Contract and driver policies</h3>
<button onClick={saveContractSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save policies'}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel policy type</label>
<select className="input-field" value={contractSettings.fuelPolicyType} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyType: event.target.value } : current)}>
{['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Additional driver charge</label>
<select className="input-field" value={contractSettings.additionalDriverCharge} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverCharge: event.target.value as ContractSettings['additionalDriverCharge'] } : current)}>
{['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Daily driver rate</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverDailyRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverDailyRate: Number(event.target.value) } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Flat driver rate</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverFlatRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverFlatRate: Number(event.target.value) } : current)} />
</div>
<div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel policy note</label>
<textarea className="input-field min-h-24" value={contractSettings.fuelPolicyNote ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyNote: event.target.value } : current)} />
</div>
<div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">Damage policy</label>
<textarea className="input-field min-h-24" value={contractSettings.damagePolicy ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, damagePolicy: event.target.value } : current)} />
</div>
</div>
</div>
)}
<div className="grid gap-6 xl:grid-cols-2">
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">Insurance policies</h3>
<div className="mt-5 space-y-3">
{insurancePolicies.map((policy) => (
<div key={policy.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{policy.name}</p>
<p className="text-slate-500">{policy.type} · {policy.chargeType} · {policy.chargeValue}</p>
</div>
<button onClick={() => toggleInsurance(policy)} className={policy.isActive ? 'badge-green' : 'badge-gray'}>
{policy.isActive ? 'Active' : 'Inactive'}
</button>
</div>
))}
{insurancePolicies.length === 0 && <div className="text-sm text-slate-400">No insurance policies configured yet.</div>}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder="Policy name" value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newInsurance.type} onChange={(event) => setNewInsurance((current) => ({ ...current, type: event.target.value }))}>
{['BASIC', 'FULL', 'CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'ROADSIDE', 'PERSONAL', 'CUSTOM'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select className="input-field" value={newInsurance.chargeType} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeType: event.target.value }))}>
{['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" placeholder="Charge value" value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} />
</div>
<div className="mt-3 flex items-center gap-4 text-sm">
<label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> Required</label>
<button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? 'Saving…' : 'Add policy'}</button>
</div>
</div>
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">Pricing rules</h3>
<div className="mt-5 space-y-3">
{pricingRules.map((rule) => (
<div key={rule.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{rule.name}</p>
<p className="text-slate-500">{rule.type} · {rule.condition} {rule.conditionValue} · {rule.adjustmentType} {rule.adjustmentValue}</p>
</div>
<button onClick={() => toggleRule(rule)} className={rule.isActive ? 'badge-green' : 'badge-gray'}>
{rule.isActive ? 'Active' : 'Inactive'}
</button>
</div>
))}
{pricingRules.length === 0 && <div className="text-sm text-slate-400">No pricing rules configured yet.</div>}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder="Rule name" value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newRule.type} onChange={(event) => setNewRule((current) => ({ ...current, type: event.target.value }))}>
{['SURCHARGE', 'DISCOUNT'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select className="input-field" value={newRule.condition} onChange={(event) => setNewRule((current) => ({ ...current, condition: event.target.value }))}>
{['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" value={newRule.conditionValue} onChange={(event) => setNewRule((current) => ({ ...current, conditionValue: Number(event.target.value) }))} />
<select className="input-field" value={newRule.adjustmentType} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentType: event.target.value }))}>
{['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" value={newRule.adjustmentValue} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentValue: Number(event.target.value) }))} />
</div>
<div className="mt-3">
<button onClick={createRule} disabled={saving} className="btn-primary">{saving ? 'Saving…' : 'Add rule'}</button>
</div>
</div>
</div>
{accountingSettings && (
<div className="card p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">Accounting and exports</h3>
<button onClick={saveAccountingSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save accounting'}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Reporting period</label>
<select className="input-field" value={accountingSettings.reportingPeriod} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportingPeriod: event.target.value } : current)}>
{['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Report format</label>
<select className="input-field" value={accountingSettings.reportFormat} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportFormat: event.target.value } : current)}>
{['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Accountant name</label>
<input className="input-field" value={accountingSettings.accountantName ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantName: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Accountant email</label>
<input className="input-field" value={accountingSettings.accountantEmail ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantEmail: event.target.value } : current)} />
</div>
</div>
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={accountingSettings.autoSendReport} onChange={(event) => setAccountingSettings((current) => current ? { ...current, autoSendReport: event.target.checked } : current)} />
Auto-send reports to the accountant
</label>
</div>
)}
</div>
)
}
@@ -0,0 +1,317 @@
'use client'
import { useState, useMemo } from 'react'
import { useUser } from '@clerk/nextjs'
import { useTeam, TeamMember } from '@/hooks/useTeam'
import InviteModal from '@/components/team/InviteModal'
import EditMemberModal from '@/components/team/EditMemberModal'
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
}
function timeAgo(dateStr: string | null) {
if (!dateStr) return 'Never'
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 2) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
if (days < 30) return `${days}d ago`
return new Date(dateStr).toLocaleDateString('en', { month: 'short', day: 'numeric' })
}
const AVATAR_BG: string[] = [
'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300',
'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300',
'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300',
'bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300',
]
function avatarColor(id: string) {
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
return AVATAR_BG[hash % AVATAR_BG.length]
}
function RoleBadge({ role }: { role: string }) {
const styles: Record<string, string> = {
OWNER: 'bg-violet-50 dark:bg-violet-950/40 text-violet-700 dark:text-violet-300 border-violet-100 dark:border-violet-900/50',
MANAGER: 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border-blue-100 dark:border-blue-900/50',
AGENT: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-700',
}
return (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${styles[role] ?? styles.AGENT}`}>
{role.charAt(0) + role.slice(1).toLowerCase()}
</span>
)
}
function StatusBadge({ member }: { member: TeamMember }) {
if (member.invitationStatus === 'pending') {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border-amber-100 dark:border-amber-900/50">
Pending invite
</span>
)
}
if (!member.isActive) {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
Deactivated
</span>
)
}
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
Active
</span>
)
}
export default function TeamPage() {
const { user } = useUser()
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [inviteOpen, setInviteOpen] = useState(false)
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
const [toast, setToast] = useState<string | null>(null)
const currentEmployee = members.find((member) => member.email === user?.primaryEmailAddress?.emailAddress)
const isOwner = currentEmployee?.role === 'OWNER'
function showToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const filtered = useMemo(() => {
return members.filter((m) => {
const q = search.toLowerCase()
const matchQ = !q ||
`${m.firstName} ${m.lastName}`.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q)
const matchRole = !roleFilter || m.role === roleFilter
const matchStatus =
!statusFilter ||
(statusFilter === 'active' && m.isActive && m.invitationStatus === 'accepted') ||
(statusFilter === 'pending' && m.invitationStatus === 'pending') ||
(statusFilter === 'inactive' && !m.isActive && m.invitationStatus === 'accepted')
return matchQ && matchRole && matchStatus
})
}, [members, search, roleFilter, statusFilter])
const handleInvite: typeof invite = async (payload) => {
await invite(payload)
showToast(`Invite sent to ${payload.email}`)
}
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
await updateRole(id, role)
showToast('Role updated')
}
const handleDeactivate = async (id: string) => {
await deactivate(id)
showToast('Member deactivated')
}
const handleReactivate = async (id: string) => {
await reactivate(id)
showToast('Member reactivated')
}
const handleRemove = async (id: string) => {
await remove(id)
showToast('Member removed')
}
return (
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">Team</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Manage your employees and their access levels
</p>
</div>
{isOwner && (
<button
onClick={() => setInviteOpen(true)}
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Invite member
</button>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ label: 'Total members', value: stats.total },
{ label: 'Active', value: stats.active },
{ label: 'Pending invite', value: stats.pending },
{ label: 'Deactivated', value: stats.inactive },
].map((s) => (
<div
key={s.label}
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 flex flex-col gap-1"
>
<span className="text-xs text-zinc-500 dark:text-zinc-400">{s.label}</span>
<span className="text-2xl font-medium text-zinc-900 dark:text-white">{s.value}</span>
</div>
))}
</div>
<div className="flex flex-wrap gap-2 mb-4">
<div className="relative flex-1 min-w-48">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 14 14">
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.2" />
<path d="M10 10L13 13" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
<input
type="text"
placeholder="Search by name or email…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">All roles</option>
<option value="OWNER">Owner</option>
<option value="MANAGER">Manager</option>
<option value="AGENT">Agent</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="inactive">Deactivated</option>
</select>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-10">
{loading ? (
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
<span className="text-sm">Loading team</span>
</div>
) : error ? (
<div className="py-12 text-center text-sm text-red-500">{error}</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">No members match your filters</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Member</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Role</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Status</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">Last active</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{filtered.map((member) => (
<tr
key={member.id}
className={[
'border-b border-zinc-100 dark:border-zinc-800 last:border-0',
'hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors',
].join(' ')}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${avatarColor(member.id)}`}>
{initials(member)}
</div>
<div>
<div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName}
{member.email === user?.primaryEmailAddress?.emailAddress && (
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<RoleBadge role={member.role} />
</td>
<td className="px-4 py-3">
<StatusBadge member={member} />
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-zinc-400 dark:text-zinc-500">
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)}
</span>
</td>
<td className="px-4 py-3 text-right">
{isOwner && member.role !== 'OWNER' && (
<button
onClick={() => setEditTarget(member)}
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Manage
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<PermissionsMatrix />
<InviteModal
open={inviteOpen}
onClose={() => setInviteOpen(false)}
onInvite={handleInvite}
/>
<EditMemberModal
member={editTarget}
open={!!editTarget}
onClose={() => setEditTarget(null)}
onUpdateRole={handleUpdateRole}
onDeactivate={handleDeactivate}
onReactivate={handleReactivate}
onRemove={handleRemove}
/>
{toast && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 duration-200">
<svg className="w-4 h-4 text-green-400 dark:text-green-600" fill="none" viewBox="0 0 16 16">
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{toast}
</div>
)}
</div>
)
}
+14 -5
View File
@@ -1,15 +1,24 @@
import Sidebar from '@/components/layout/Sidebar'
import TopBar from '@/components/layout/TopBar'
import { DashboardLanguageSwitcher } from '@/components/I18nProvider'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen bg-slate-50">
<div className="flex min-h-screen bg-slate-50">
<Sidebar />
<div className="flex-1 flex flex-col ml-64 min-w-0">
<div className="ml-64 flex min-h-screen min-w-0 flex-1 flex-col">
<TopBar />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
<main className="flex-1 overflow-y-auto p-6">{children}</main>
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors">
<div className="flex flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">
Workspace preferences
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<DashboardLanguageSwitcher />
</div>
</div>
</footer>
</div>
</div>
)
+32
View File
@@ -0,0 +1,32 @@
import { ImageResponse } from 'next/og'
export const size = {
width: 32,
height: 32,
}
export const contentType = 'image/png'
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0f172a',
color: '#ffffff',
fontSize: 18,
fontWeight: 700,
borderRadius: 8,
}}
>
R
</div>
),
size,
)
}
+7 -8
View File
@@ -1,23 +1,22 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { ClerkProvider } from '@clerk/nextjs'
import { clerkFrontendEnabled } from '@/lib/clerk'
import { DashboardI18nProvider } from '@/components/I18nProvider'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'RentalDriveGo Dashboard',
description: 'Manage your rental car business',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
const content = (
<html lang="en">
<body className={inter.className}>
{children}
<body className="font-sans">
<DashboardI18nProvider>{children}</DashboardI18nProvider>
</body>
</html>
</ClerkProvider>
)
return clerkFrontendEnabled ? <ClerkProvider>{content}</ClerkProvider> : content
}
@@ -0,0 +1,25 @@
import Link from 'next/link'
import PublicShell from '@/components/layout/PublicShell'
export default function AcceptInvitePage() {
return (
<PublicShell>
<main className="flex flex-col items-center justify-center px-4 py-16">
<div className="w-full max-w-md card p-10 text-center">
<div className="mx-auto mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold text-slate-900">Invitation accepted</h1>
<p className="mt-3 text-sm text-slate-500">
Your invitation has been processed. You can now sign in to access the team dashboard.
</p>
<Link href="/sign-in" className="mt-8 btn-primary justify-center w-full">
Sign in to dashboard
</Link>
</div>
</main>
</PublicShell>
)
}
+269
View File
@@ -0,0 +1,269 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api'
import PublicShell from '@/components/layout/PublicShell'
import { marketplaceUrl } from '@/lib/urls'
type Step = 1 | 2 | 3
export default function OnboardingPage() {
const router = useRouter()
const [step, setStep] = useState<Step>(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [company, setCompany] = useState({ name: '', slug: '' })
const [brand, setBrand] = useState({
displayName: '',
tagline: '',
primaryColor: '#2563eb',
publicCity: '',
publicCountry: '',
})
const [payments, setPayments] = useState({
amanpayMerchantId: '',
amanpaySecretKey: '',
paypalEmail: '',
isListedOnMarketplace: true,
})
async function handleStep1() {
if (!company.name.trim()) return setError('Company name is required')
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me', {
method: 'PATCH',
body: JSON.stringify({ name: company.name }),
})
setStep(2)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleStep2() {
if (!brand.displayName.trim()) return setError('Display name is required')
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
displayName: brand.displayName,
tagline: brand.tagline || undefined,
primaryColor: brand.primaryColor,
publicCity: brand.publicCity || undefined,
publicCountry: brand.publicCountry || undefined,
}),
})
setStep(3)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleStep3() {
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
amanpayMerchantId: payments.amanpayMerchantId || undefined,
amanpaySecretKey: payments.amanpaySecretKey || undefined,
paypalEmail: payments.paypalEmail || undefined,
isListedOnMarketplace: payments.isListedOnMarketplace,
}),
})
router.push('/dashboard')
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<PublicShell>
<main className="flex items-center justify-center px-4 py-12">
<div className="w-full max-w-lg">
<div className="mb-8 text-center">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">RentalDriveGo</Link>
<h1 className="mt-2 text-3xl font-bold text-slate-900">Set up your account</h1>
<p className="mt-1 text-sm text-slate-500">Step {step} of 3</p>
</div>
<div className="flex gap-2 mb-8">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`flex-1 h-1.5 rounded-full transition-colors ${
s <= step ? 'bg-blue-600' : 'bg-slate-200'
}`}
/>
))}
</div>
<div className="card p-8">
{error && (
<div className="mb-6 p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
{error}
</div>
)}
{step === 1 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Your company</h2>
<p className="mt-1 text-sm text-slate-500">Tell us the basics about your rental company.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Company name</label>
<input
className="input-field"
placeholder="Casablanca Car Rentals"
value={company.name}
onChange={(e) => setCompany({ ...company, name: e.target.value })}
/>
</div>
<button onClick={handleStep1} disabled={loading} className="btn-primary w-full justify-center">
{loading ? 'Saving…' : 'Continue'}
</button>
</div>
)}
{step === 2 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Brand &amp; identity</h2>
<p className="mt-1 text-sm text-slate-500">Customize how your company appears to renters.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Display name</label>
<input
className="input-field"
placeholder="Casa Rentals"
value={brand.displayName}
onChange={(e) => setBrand({ ...brand, displayName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Tagline <span className="text-slate-400">(optional)</span></label>
<input
className="input-field"
placeholder="The best fleet in the city"
value={brand.tagline}
onChange={(e) => setBrand({ ...brand, tagline: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">City</label>
<input
className="input-field"
placeholder="Casablanca"
value={brand.publicCity}
onChange={(e) => setBrand({ ...brand, publicCity: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Country</label>
<input
className="input-field"
placeholder="Morocco"
value={brand.publicCountry}
onChange={(e) => setBrand({ ...brand, publicCountry: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Brand color</label>
<div className="flex items-center gap-3">
<input
type="color"
className="h-10 w-14 rounded border border-slate-200 cursor-pointer"
value={brand.primaryColor}
onChange={(e) => setBrand({ ...brand, primaryColor: e.target.value })}
/>
<input
className="input-field"
value={brand.primaryColor}
onChange={(e) => setBrand({ ...brand, primaryColor: e.target.value })}
/>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setStep(1)} className="btn-secondary flex-1 justify-center">Back</button>
<button onClick={handleStep2} disabled={loading} className="btn-primary flex-1 justify-center">
{loading ? 'Saving…' : 'Continue'}
</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Payment setup</h2>
<p className="mt-1 text-sm text-slate-500">Connect payment providers so renters can book online. You can skip this for now.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Merchant ID <span className="text-slate-400">(optional)</span></label>
<input
className="input-field"
placeholder="your-merchant-id"
value={payments.amanpayMerchantId}
onChange={(e) => setPayments({ ...payments, amanpayMerchantId: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Secret Key <span className="text-slate-400">(optional)</span></label>
<input
type="password"
className="input-field"
placeholder="your-secret-key"
value={payments.amanpaySecretKey}
onChange={(e) => setPayments({ ...payments, amanpaySecretKey: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">PayPal email <span className="text-slate-400">(optional)</span></label>
<input
type="email"
className="input-field"
placeholder="payments@yourcompany.com"
value={payments.paypalEmail}
onChange={(e) => setPayments({ ...payments, paypalEmail: e.target.value })}
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
checked={payments.isListedOnMarketplace}
onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })}
/>
<span className="text-sm text-slate-700">List my company on the RentalDriveGo marketplace</span>
</label>
<div className="flex gap-3">
<button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button>
<button onClick={handleStep3} disabled={loading} className="btn-primary flex-1 justify-center">
{loading ? 'Finishing…' : 'Go to dashboard'}
</button>
</div>
</div>
)}
</div>
</div>
</main>
</PublicShell>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function DashboardRootPage() {
redirect('/sign-in')
}
@@ -0,0 +1,153 @@
'use client'
import Link from 'next/link'
import { SignIn } from '@clerk/nextjs'
import { clerkFrontendEnabled } from '@/lib/clerk'
import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { marketplaceUrl } from '@/lib/urls'
export default function SignInPage() {
const { language } = useDashboardI18n()
const dict = {
en: {
workspace: 'Company workspace',
body: 'Sign in to manage fleet operations, reservations, billing, team access, and performance reporting from one workspace.',
features: [
['Reservations', 'Track bookings, handoffs, availability, and customer activity without leaving the dashboard.'],
['Billing', 'Review plan status, trial timing, payment setup, and subscription changes in one place.'],
['Team access', 'Owners and staff keep separate access with role-based controls and invited employee accounts.'],
['Analytics', 'Monitor fleet usage, revenue movement, and offer performance from the operations view.'],
],
access: 'Workspace Access',
signIn: 'Sign in',
useAccount: 'Use your owner or employee account to access the company workspace.',
inactive: 'Enter your workspace credentials to continue.',
newAccount: 'New company account?',
createWorkspace: 'Create your workspace',
email: 'Email',
password: 'Password',
},
fr: {
workspace: 'Espace entreprise',
body: 'Connectez-vous pour gérer la flotte, les réservations, la facturation, laccès équipe et le reporting depuis un seul espace.',
features: [
['Réservations', 'Suivez les réservations, remises, disponibilités et activité client sans quitter le dashboard.'],
['Facturation', 'Consultez le plan, la période dessai, la configuration des paiements et les changements dabonnement.'],
['Accès équipe', 'Les propriétaires et collaborateurs ont des accès séparés avec rôles et invitations.'],
['Analytique', 'Surveillez lusage de la flotte, les revenus et la performance des offres.'],
],
access: 'Accès workspace',
signIn: 'Connexion',
useAccount: 'Utilisez votre compte propriétaire ou employé pour accéder à lespace entreprise.',
inactive: 'Saisissez les identifiants de votre espace pour continuer.',
newAccount: 'Nouveau compte entreprise ?',
createWorkspace: 'Créer votre espace',
email: 'Email',
password: 'Mot de passe',
},
ar: {
workspace: 'مساحة الشركة',
body: 'سجّل الدخول لإدارة الأسطول والحجوزات والفوترة ووصول الفريق والتقارير من مساحة واحدة.',
features: [
['الحجوزات', 'تابع الحجوزات والتسليمات والتوفر ونشاط العملاء من داخل اللوحة.'],
['الفوترة', 'راجع الخطة وفترة التجربة وإعدادات الدفع وتغييرات الاشتراك في مكان واحد.'],
['وصول الفريق', 'يحصل المالكون والموظفون على وصول منفصل مع أدوار ودعوات منظمة.'],
['التحليلات', 'راقب استخدام الأسطول وحركة الإيرادات وأداء العروض.'],
],
access: 'الوصول إلى المساحة',
signIn: 'تسجيل الدخول',
useAccount: 'استخدم حساب المالك أو الموظف للوصول إلى مساحة الشركة.',
inactive: 'أدخل بيانات مساحة العمل للمتابعة.',
newAccount: 'حساب شركة جديد؟',
createWorkspace: 'أنشئ مساحتك',
email: 'البريد الإلكتروني',
password: 'كلمة المرور',
},
}[language]
return (
<PublicShell>
<main className="px-4 py-12">
<div className="mx-auto flex min-h-[calc(100vh-11rem)] max-w-6xl items-center">
<div className="grid w-full gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<section className="space-y-8">
<div>
<Link href={marketplaceUrl} className="inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{dict.workspace}</h1>
<p className="mt-4 max-w-xl text-base leading-7 text-slate-600">{dict.body}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{dict.features.map(([title, body]) => (
<FeatureCard key={title} title={title} body={body} />
))}
</div>
</section>
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{dict.access}</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.signIn}</h2>
<p className="mt-3 text-sm leading-6 text-slate-500">{dict.useAccount}</p>
<div className="mt-8">
{clerkFrontendEnabled ? <ConfiguredSignIn /> : <FallbackSignInCard dict={dict} />}
</div>
<div className="mt-6 border-t border-slate-200 pt-6 text-sm text-slate-500">
{dict.newAccount}
<Link href="/sign-up" className="ml-1 font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
{dict.createWorkspace}
</Link>
</div>
</section>
</div>
</div>
</main>
</PublicShell>
)
}
function ConfiguredSignIn() {
return (
<SignIn
routing="path"
path="/sign-in"
signUpUrl="/sign-up"
fallbackRedirectUrl="/dashboard"
/>
)
}
function FallbackSignInCard({ dict }: { dict: { inactive: string; email: string; password: string; signIn: string } }) {
return (
<div className="space-y-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
{dict.inactive}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
<input type="email" disabled placeholder="owner@company.com" className="input-field cursor-not-allowed opacity-70" />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
<input type="password" disabled placeholder="••••••••" className="input-field cursor-not-allowed opacity-70" />
</div>
<button type="button" disabled className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60">
{dict.signIn}
</button>
</div>
)
}
function FeatureCard({ title, body }: { title: string; body: string }) {
return (
<div className="rounded-[1.5rem] border border-slate-200 bg-white p-5 shadow-sm">
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-slate-900">{title}</h3>
<p className="mt-3 text-sm leading-6 text-slate-600">{body}</p>
</div>
)
}
@@ -0,0 +1,632 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { useSignUp } from '@clerk/nextjs'
import { apiFetch } from '@/lib/api'
import { clerkFrontendEnabled } from '@/lib/clerk'
import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { marketplaceUrl } from '@/lib/urls'
type SignupForm = {
firstName: string
lastName: string
email: string
password: string
confirmPassword: string
companyName: string
companyPhone: string
city: string
country: string
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD' | 'USD' | 'EUR'
paymentProvider: 'AMANPAY' | 'PAYPAL'
}
type CompletedSignup = {
companyName: string
email: string
}
export default function SignUpPage() {
return clerkFrontendEnabled ? <ConfiguredSignUpPage /> : <LocalSignUpPage />
}
function ConfiguredSignUpPage() {
const { language } = useDashboardI18n()
const searchParams = useSearchParams()
const dict = {
en: {
loading: 'Authentication is still loading. Try again in a moment.',
passwordShort: 'Choose a password with at least 8 characters.',
passwordMismatch: 'Passwords do not match.',
startFailed: 'Could not start account creation',
resendFailed: 'Could not resend the verification code',
completeFailed: 'Could not verify your email and finish setup',
working: 'Working…',
},
fr: {
loading: 'Lauthentification est en cours de chargement. Réessayez dans un instant.',
passwordShort: 'Choisissez un mot de passe dau moins 8 caractères.',
passwordMismatch: 'Les mots de passe ne correspondent pas.',
startFailed: 'Impossible de démarrer la création du compte',
resendFailed: 'Impossible de renvoyer le code de vérification',
completeFailed: 'Impossible de vérifier votre email et finaliser la configuration',
working: 'Traitement…',
},
ar: {
loading: 'المصادقة ما زالت قيد التحميل. حاول بعد لحظة.',
passwordShort: 'اختر كلمة مرور من 8 أحرف على الأقل.',
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
startFailed: 'تعذر بدء إنشاء الحساب',
resendFailed: 'تعذر إعادة إرسال رمز التحقق',
completeFailed: 'تعذر التحقق من البريد وإكمال الإعداد',
working: 'جارٍ التنفيذ…',
},
}[language]
const { isLoaded, signUp, setActive } = useSignUp()
const initialPlan = normalizePlan(searchParams.get('plan'))
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
const initialCurrency = normalizeCurrency(searchParams.get('currency'))
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [verificationCode, setVerificationCode] = useState('')
const [awaitingVerification, setAwaitingVerification] = useState(false)
const [completedSignup, setCompletedSignup] = useState<CompletedSignup | null>(null)
const [form, setForm] = useState<SignupForm>({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
companyName: '',
companyPhone: '',
city: '',
country: '',
plan: initialPlan,
billingPeriod: initialBillingPeriod,
currency: initialCurrency,
paymentProvider: 'AMANPAY',
})
async function submitSignup() {
if (form.password.length < 8) {
setError(dict.passwordShort)
return
}
if (form.password !== form.confirmPassword) {
setError(dict.passwordMismatch)
return
}
setLoading(true)
setError(null)
try {
if (!isLoaded || !signUp) {
setError(dict.loading)
return
}
await signUp.create({
emailAddress: form.email,
password: form.password,
firstName: form.firstName,
lastName: form.lastName,
unsafeMetadata: {
signupFlow: 'company-owner',
companyName: form.companyName,
companyPhone: form.companyPhone || null,
city: form.city || null,
country: form.country || null,
plan: form.plan,
billingPeriod: form.billingPeriod,
currency: form.currency,
paymentProvider: form.paymentProvider,
},
})
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
setAwaitingVerification(true)
} catch (err) {
setError(getClerkErrorMessage(err, dict.startFailed))
} finally {
setLoading(false)
}
}
async function resendVerificationCode() {
if (!signUp) return
setLoading(true)
setError(null)
try {
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
} catch (err) {
setError(getClerkErrorMessage(err, dict.resendFailed))
} finally {
setLoading(false)
}
}
async function completeSignup() {
if (!signUp || !setActive) {
setError(dict.loading)
return
}
setLoading(true)
setError(null)
try {
const result = await signUp.attemptEmailAddressVerification({ code: verificationCode })
if (result.status !== 'complete' || !result.createdSessionId) {
throw new Error('Email verification is not complete yet.')
}
await setActive({ session: result.createdSessionId })
await waitForActiveSession()
await apiFetch('/auth/company/complete-signup', {
method: 'POST',
})
setCompletedSignup({
companyName: form.companyName,
email: form.email,
})
} catch (err) {
setError(getClerkErrorMessage(err, dict.completeFailed))
} finally {
setLoading(false)
}
}
if (completedSignup) {
return (
<PublicShell>
<main className="flex-1 px-4 py-16">
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">Workspace ready</h1>
<p className="mt-4 text-base leading-7 text-slate-600">
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> is now active and the owner email
<span className="font-semibold text-slate-900"> {completedSignup.email}</span> has been registered.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link href="/dashboard" className="btn-primary justify-center">
Enter dashboard
</Link>
<Link href="/sign-in" className="btn-secondary justify-center">
Sign in on another device
</Link>
</div>
</div>
</main>
</PublicShell>
)
}
if (awaitingVerification) {
return (
<PublicShell>
<main className="flex-1 px-4 py-16">
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">Confirm your email</h1>
<p className="mt-4 text-base leading-7 text-slate-600">
We sent a verification code to <span className="font-semibold text-slate-900">{form.email}</span>. Enter it here to finish creating your workspace.
</p>
<div className="mt-8 space-y-5">
<LabeledInput label="Verification code" value={verificationCode} onChange={setVerificationCode} />
{error ? <div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<button type="button" onClick={() => setAwaitingVerification(false)} className="btn-secondary">
Back
</button>
<div className="flex flex-col gap-3 sm:flex-row">
<button type="button" onClick={resendVerificationCode} disabled={loading} className="btn-secondary disabled:cursor-not-allowed disabled:opacity-60">
{loading ? 'Sending…' : 'Resend code'}
</button>
<button type="button" onClick={completeSignup} disabled={loading || !verificationCode.trim()} className="btn-primary disabled:cursor-not-allowed disabled:opacity-60">
{loading ? 'Verifying…' : 'Verify and create workspace'}
</button>
</div>
</div>
</div>
</div>
</main>
</PublicShell>
)
}
return (
<PublicShell>
<main className="px-4 py-12">
<div className="mx-auto max-w-3xl space-y-8">
<div className="text-center">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">Launch your rental workspace</h1>
<p className="mt-4 text-base leading-7 text-slate-600">Create the owner account, verify your email, and start your 14-day free trial.</p>
</div>
<div className="grid gap-3 sm:grid-cols-4">
{['Account', 'Company', 'Plan', 'Verify'].map((label, index) => (
<div key={label} className={`rounded-full px-4 py-2 text-center text-sm font-semibold ${index + 1 <= step ? 'bg-slate-900 text-white' : 'border border-slate-200 bg-white text-slate-500'}`}>
{label}
</div>
))}
</div>
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
{error ? <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
{step === 1 ? (
<div className="space-y-5">
<SectionIntro title="Owner account" body="This becomes the primary owner account for the company workspace." />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label="First name" value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
<LabeledInput label="Last name" value={form.lastName} onChange={(value) => setForm((current) => ({ ...current, lastName: value }))} />
</div>
<LabeledInput label="Email" type="email" value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: value }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label="Password" type="password" value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
<LabeledInput label="Confirm password" type="password" value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
</div>
<p className="text-xs text-slate-500">Use at least 8 characters so the owner account can sign in directly after verification.</p>
<div className="flex justify-end">
<button type="button" onClick={() => setStep(2)} className="btn-primary">Continue</button>
</div>
</div>
) : null}
{step === 2 ? (
<div className="space-y-5">
<SectionIntro title="Company details" body="Well use these details for your trial workspace and public marketplace profile." />
<LabeledInput label="Company name" value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label="Phone" value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
<LabeledInput label="Country" value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
</div>
<LabeledInput label="City" value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
<NavActions onBack={() => setStep(1)} onNext={() => setStep(3)} />
</div>
) : null}
{step === 3 ? (
<div className="space-y-5">
<SectionIntro title="Choose your plan" body="Your workspace starts immediately on a 14-day free trial in your preferred billing currency." />
<div className="grid gap-4 sm:grid-cols-3">
{['STARTER', 'GROWTH', 'PRO'].map((plan) => (
<button
key={plan}
type="button"
onClick={() => setForm((current) => ({ ...current, plan: plan as SignupForm['plan'] }))}
className={`rounded-3xl border p-5 text-left ${form.plan === plan ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-900'}`}
>
<p className="text-xs font-semibold uppercase tracking-[0.16em]">{plan}</p>
<p className="mt-3 text-sm opacity-80">{plan === 'STARTER' ? 'Core fleet, offers, and bookings.' : plan === 'GROWTH' ? 'Featured marketplace placement and more room to scale.' : 'Full white-label and premium controls.'}</p>
</button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<LabeledSelect label="Billing period" value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
<LabeledSelect label="Currency" value={form.currency} options={['MAD', 'USD', 'EUR']} onChange={(value) => setForm((current) => ({ ...current, currency: value as SignupForm['currency'] }))} />
<LabeledSelect label="Primary provider" value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
</div>
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} />
</div>
) : null}
{step === 4 ? (
<div className="space-y-5">
<SectionIntro title="Verify and launch" body="Well create the owner account in Clerk first, email you a verification code, then provision the workspace after that email is confirmed." />
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
<p><span className="font-semibold text-slate-900">Workspace:</span> {form.companyName || 'Your company'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">Plan:</span> {form.plan} · {form.billingPeriod} · {form.currency}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">Owner email:</span> {form.email || '—'}</p>
</div>
<NavActions
onBack={() => setStep(3)}
onNext={submitSignup}
loading={loading}
nextLabel="Send verification code"
/>
</div>
) : null}
</div>
</div>
</main>
</PublicShell>
)
}
function LocalSignUpPage() {
const searchParams = useSearchParams()
const initialPlan = normalizePlan(searchParams.get('plan'))
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
const initialCurrency = normalizeCurrency(searchParams.get('currency'))
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [completedSignup, setCompletedSignup] = useState<CompletedSignup | null>(null)
const [form, setForm] = useState<SignupForm>({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
companyName: '',
companyPhone: '',
city: '',
country: '',
plan: initialPlan,
billingPeriod: initialBillingPeriod,
currency: initialCurrency,
paymentProvider: 'AMANPAY',
})
async function submitSignup() {
if (form.password.length < 8) {
setError('Choose a password with at least 8 characters.')
return
}
if (form.password !== form.confirmPassword) {
setError('Passwords do not match.')
return
}
setLoading(true)
setError(null)
try {
await apiFetch('/auth/company/signup', {
method: 'POST',
body: JSON.stringify({
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
companyName: form.companyName,
companyPhone: form.companyPhone || undefined,
city: form.city || undefined,
country: form.country || undefined,
plan: form.plan,
billingPeriod: form.billingPeriod,
currency: form.currency,
paymentProvider: form.paymentProvider,
}),
})
setCompletedSignup({
companyName: form.companyName,
email: form.email,
})
} catch (err) {
setError(getClerkErrorMessage(err, 'Could not create workspace'))
} finally {
setLoading(false)
}
}
if (completedSignup) {
return (
<PublicShell>
<main className="flex-1 px-4 py-16">
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">Workspace ready</h1>
<p className="mt-4 text-base leading-7 text-slate-600">
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> is now active and the owner email
<span className="font-semibold text-slate-900"> {completedSignup.email}</span> has been registered.
</p>
<div className="mt-8">
<Link href="/sign-up" className="btn-primary justify-center">
Create another workspace
</Link>
</div>
</div>
</main>
</PublicShell>
)
}
return (
<PublicShell>
<main className="px-4 py-12">
<div className="mx-auto max-w-3xl space-y-8">
<div className="text-center">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">Launch your rental workspace</h1>
<p className="mt-4 text-base leading-7 text-slate-600">Create the owner account and start your 14-day free trial.</p>
</div>
<div className="grid gap-3 sm:grid-cols-4">
{['Account', 'Company', 'Plan', 'Verify'].map((label, index) => (
<div key={label} className={`rounded-full px-4 py-2 text-center text-sm font-semibold ${index + 1 <= step ? 'bg-slate-900 text-white' : 'border border-slate-200 bg-white text-slate-500'}`}>
{label}
</div>
))}
</div>
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
{error ? <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
{step === 1 ? (
<div className="space-y-5">
<SectionIntro title="Owner account" body="This becomes the primary owner account for the company workspace." />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label="First name" value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
<LabeledInput label="Last name" value={form.lastName} onChange={(value) => setForm((current) => ({ ...current, lastName: value }))} />
</div>
<LabeledInput label="Email" type="email" value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: value }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label="Password" type="password" value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
<LabeledInput label="Confirm password" type="password" value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
</div>
<p className="text-xs text-slate-500">Use at least 8 characters for your company owner account.</p>
<div className="flex justify-end">
<button type="button" onClick={() => setStep(2)} className="btn-primary">Continue</button>
</div>
</div>
) : null}
{step === 2 ? (
<div className="space-y-5">
<SectionIntro title="Company details" body="Well use these details for your trial workspace and public marketplace profile." />
<LabeledInput label="Company name" value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label="Phone" value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
<LabeledInput label="Country" value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
</div>
<LabeledInput label="City" value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
<NavActions onBack={() => setStep(1)} onNext={() => setStep(3)} />
</div>
) : null}
{step === 3 ? (
<div className="space-y-5">
<SectionIntro title="Choose your plan" body="Your workspace starts immediately on a 14-day free trial in your preferred billing currency." />
<div className="grid gap-4 sm:grid-cols-3">
{['STARTER', 'GROWTH', 'PRO'].map((plan) => (
<button
key={plan}
type="button"
onClick={() => setForm((current) => ({ ...current, plan: plan as SignupForm['plan'] }))}
className={`rounded-3xl border p-5 text-left ${form.plan === plan ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-900'}`}
>
<p className="text-xs font-semibold uppercase tracking-[0.16em]">{plan}</p>
<p className="mt-3 text-sm opacity-80">{plan === 'STARTER' ? 'Core fleet, offers, and bookings.' : plan === 'GROWTH' ? 'Featured marketplace placement and more room to scale.' : 'Full white-label and premium controls.'}</p>
</button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<LabeledSelect label="Billing period" value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
<LabeledSelect label="Currency" value={form.currency} options={['MAD', 'USD', 'EUR']} onChange={(value) => setForm((current) => ({ ...current, currency: value as SignupForm['currency'] }))} />
<LabeledSelect label="Primary provider" value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
</div>
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} />
</div>
) : null}
{step === 4 ? (
<div className="space-y-5">
<SectionIntro title="Verify and launch" body="Well create the company workspace immediately and start the 14-day trial with the plan you selected." />
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
<p><span className="font-semibold text-slate-900">Workspace:</span> {form.companyName || 'Your company'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">Plan:</span> {form.plan} · {form.billingPeriod} · {form.currency}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">Owner email:</span> {form.email || '—'}</p>
</div>
<NavActions
onBack={() => setStep(3)}
onNext={submitSignup}
loading={loading}
nextLabel="Create workspace"
/>
</div>
) : null}
</div>
</div>
</main>
</PublicShell>
)
}
function SectionIntro({ title, body }: { title: string; body: string }) {
return (
<div>
<h2 className="text-xl font-semibold text-slate-900">{title}</h2>
<p className="mt-2 text-sm leading-7 text-slate-500">{body}</p>
</div>
)
}
function LabeledInput({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (value: string) => void; type?: string }) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
<input type={type} className="input-field" value={value} onChange={(event) => onChange(event.target.value)} />
</label>
)
}
function LabeledSelect({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
<select className="input-field" value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
)
}
function NavActions({ onBack, onNext, loading = false, nextLabel = 'Continue', disableNext = false }: { onBack?: () => void; onNext: () => void; loading?: boolean; nextLabel?: string; disableNext?: boolean }) {
return (
<div className="flex justify-between gap-3">
{onBack ? <button type="button" onClick={onBack} className="btn-secondary">Back</button> : <span />}
<button type="button" onClick={onNext} disabled={loading || disableNext} className="btn-primary disabled:cursor-not-allowed disabled:opacity-60">
{loading ? 'Working…' : nextLabel}
</button>
</div>
)
}
function normalizePlan(value: string | null): SignupForm['plan'] {
return value === 'GROWTH' || value === 'PRO' ? value : 'STARTER'
}
function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod'] {
return value === 'ANNUAL' || value === 'annual' ? 'ANNUAL' : 'MONTHLY'
}
function normalizeCurrency(value: string | null): SignupForm['currency'] {
return value === 'USD' || value === 'EUR' ? value : 'MAD'
}
function getClerkErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === 'object' && 'errors' in error && Array.isArray((error as { errors?: Array<{ longMessage?: string; message?: string }> }).errors)) {
const first = (error as { errors: Array<{ longMessage?: string; message?: string }> }).errors[0]
if (first?.longMessage) return first.longMessage
if (first?.message) return first.message
}
if (error instanceof Error && error.message) {
return error.message
}
return fallback
}
async function waitForActiveSession() {
for (let attempt = 0; attempt < 10; attempt += 1) {
const token = await readActiveSessionToken()
if (token) return token
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error('Signed in successfully, but could not read the active session token.')
}
async function readActiveSessionToken() {
if (typeof window === 'undefined') return null
const clerk = (window as Window & { __clerk?: { session?: { getToken: () => Promise<string | null> } } }).__clerk
if (!clerk?.session?.getToken) return null
try {
return await clerk.session.getToken()
} catch {
return null
}
}
@@ -0,0 +1,192 @@
'use client'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
export type DashboardLanguage = 'en' | 'fr' | 'ar'
type DashboardDictionary = {
nav: Record<string, string>
titles: Record<string, string>
notifications: string
noNewNotifications: string
unreadNotifications: (count: number) => string
signOut: string
demoUser: string
clerkDisabled: string
language: string
light: string
dark: string
}
const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
en: {
nav: {
dashboard: 'Dashboard',
fleet: 'Fleet',
reservations: 'Reservations',
customers: 'Customers',
offers: 'Offers',
team: 'Team',
reports: 'Reports',
billing: 'Billing',
notifications: 'Notifications',
settings: 'Settings',
},
titles: {
'/dashboard': 'Dashboard',
'/dashboard/fleet': 'Fleet Management',
'/dashboard/reservations': 'Reservations',
'/dashboard/customers': 'Customers',
'/dashboard/offers': 'Offers',
'/dashboard/team': 'Team',
'/dashboard/reports': 'Reports',
'/dashboard/billing': 'Billing',
'/dashboard/settings': 'Settings',
},
notifications: 'Notifications',
noNewNotifications: 'No new notifications',
unreadNotifications: (count) => `${count} unread notification${count > 1 ? 's' : ''}`,
signOut: 'Sign out',
demoUser: 'Demo User',
clerkDisabled: 'Clerk disabled in local dev',
language: 'Language',
light: 'Light',
dark: 'Dark',
},
fr: {
nav: {
dashboard: 'Tableau de bord',
fleet: 'Flotte',
reservations: 'Réservations',
customers: 'Clients',
offers: 'Offres',
team: 'Équipe',
reports: 'Rapports',
billing: 'Facturation',
notifications: 'Notifications',
settings: 'Paramètres',
},
titles: {
'/dashboard': 'Tableau de bord',
'/dashboard/fleet': 'Gestion de flotte',
'/dashboard/reservations': 'Réservations',
'/dashboard/customers': 'Clients',
'/dashboard/offers': 'Offres',
'/dashboard/team': 'Équipe',
'/dashboard/reports': 'Rapports',
'/dashboard/billing': 'Facturation',
'/dashboard/settings': 'Paramètres',
},
notifications: 'Notifications',
noNewNotifications: 'Aucune nouvelle notification',
unreadNotifications: (count) => `${count} notification${count > 1 ? 's' : ''} non lue${count > 1 ? 's' : ''}`,
signOut: 'Déconnexion',
demoUser: 'Utilisateur démo',
clerkDisabled: 'Clerk désactivé en local',
language: 'Langue',
light: 'Clair',
dark: 'Sombre',
},
ar: {
nav: {
dashboard: 'لوحة التحكم',
fleet: 'الأسطول',
reservations: 'الحجوزات',
customers: 'العملاء',
offers: 'العروض',
team: 'الفريق',
reports: 'التقارير',
billing: 'الفوترة',
notifications: 'الإشعارات',
settings: 'الإعدادات',
},
titles: {
'/dashboard': 'لوحة التحكم',
'/dashboard/fleet': 'إدارة الأسطول',
'/dashboard/reservations': 'الحجوزات',
'/dashboard/customers': 'العملاء',
'/dashboard/offers': 'العروض',
'/dashboard/team': 'الفريق',
'/dashboard/reports': 'التقارير',
'/dashboard/billing': 'الفوترة',
'/dashboard/settings': 'الإعدادات',
},
notifications: 'الإشعارات',
noNewNotifications: 'لا توجد إشعارات جديدة',
unreadNotifications: (count) => `${count} إشعار غير مقروء`,
signOut: 'تسجيل الخروج',
demoUser: 'مستخدم تجريبي',
clerkDisabled: 'Clerk غير مفعّل محلياً',
language: 'اللغة',
light: 'فاتح',
dark: 'داكن',
},
}
type I18nContextValue = {
language: DashboardLanguage
setLanguage: (value: DashboardLanguage) => void
dict: DashboardDictionary
}
const I18nContext = createContext<I18nContextValue | null>(null)
export function DashboardI18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<DashboardLanguage>('en')
useEffect(() => {
const stored = window.localStorage.getItem('dashboard-language')
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
setLanguage(stored)
}
}, [])
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
window.localStorage.setItem('dashboard-language', language)
}, [language])
const value = useMemo(() => ({ language, setLanguage, dict: dictionaries[language] }), [language])
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
export function useDashboardI18n() {
const context = useContext(I18nContext)
if (!context) throw new Error('useDashboardI18n must be used within DashboardI18nProvider')
return context
}
export function DashboardLanguageSwitcher() {
const { language, setLanguage, dict } = useDashboardI18n()
const [embedded, setEmbedded] = useState(false)
useEffect(() => {
setEmbedded(window.self !== window.top)
}, [])
if (embedded) return null
return (
<div className="flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-1 shadow-sm">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
{dict.language}
</span>
{(['en', 'fr', 'ar'] as DashboardLanguage[]).map((value) => {
const active = value === language
return (
<button
key={value}
type="button"
onClick={() => setLanguage(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
}`}
>
{value.toUpperCase()}
</button>
)
})}
</div>
)
}
@@ -0,0 +1,61 @@
'use client'
import Link from 'next/link'
import { DashboardLanguageSwitcher, useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
export default function PublicShell({ children }: { children: React.ReactNode }) {
const { language } = useDashboardI18n()
const dict = {
en: {
workspace: 'Company Workspace',
signIn: 'Sign in',
createWorkspace: 'Create workspace',
preferences: 'Workspace preferences',
},
fr: {
workspace: 'Espace entreprise',
signIn: 'Connexion',
createWorkspace: 'Créer un espace',
preferences: 'Preferences espace',
},
ar: {
workspace: 'مساحة الشركة',
signIn: 'تسجيل الدخول',
createWorkspace: 'إنشاء مساحة',
preferences: 'تفضيلات المساحة',
},
}[language]
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)]">
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/85 backdrop-blur-md">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
<Link href={marketplaceUrl} className="flex items-center gap-3 text-slate-900">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</span>
<span className="hidden text-sm font-semibold text-slate-500 sm:inline">{dict.workspace}</span>
</Link>
<nav className="flex items-center gap-2">
<Link href="/sign-in" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-900">
{dict.signIn}
</Link>
<Link href="/sign-up" className="rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700">
{dict.createWorkspace}
</Link>
</nav>
</div>
</header>
<div>{children}</div>
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">
{dict.preferences}
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<DashboardLanguageSwitcher />
</div>
</div>
</footer>
</div>
)
}
@@ -11,24 +11,30 @@ import {
UserPlus,
BarChart2,
CreditCard,
Bell,
Settings,
LogOut,
} from 'lucide-react'
import { useClerk, useUser } from '@clerk/nextjs'
import { clerkFrontendEnabled } from '@/lib/clerk'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
const NAV_ITEMS = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: true },
{ href: '/dashboard/fleet', label: 'Fleet', icon: Car },
{ href: '/dashboard/reservations', label: 'Reservations', icon: Calendar },
{ href: '/dashboard/customers', label: 'Customers', icon: Users },
{ href: '/dashboard/offers', label: 'Offers', icon: Tag },
{ href: '/dashboard/team', label: 'Team', icon: UserPlus },
{ href: '/dashboard/reports', label: 'Reports', icon: BarChart2 },
{ href: '/dashboard/billing', label: 'Billing', icon: CreditCard },
{ href: '/dashboard/settings', label: 'Settings', icon: Settings },
]
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
{ href: '/dashboard/fleet', key: 'fleet', icon: Car },
{ href: '/dashboard/reservations', key: 'reservations', icon: Calendar },
{ href: '/dashboard/customers', key: 'customers', icon: Users },
{ href: '/dashboard/offers', key: 'offers', icon: Tag },
{ href: '/dashboard/team', key: 'team', icon: UserPlus },
{ href: '/dashboard/reports', key: 'reports', icon: BarChart2 },
{ href: '/dashboard/billing', key: 'billing', icon: CreditCard },
{ href: '/dashboard/notifications', key: 'notifications', icon: Bell },
{ href: '/dashboard/settings', key: 'settings', icon: Settings },
] as const
export default function Sidebar() {
function SidebarWithClerk() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const { signOut } = useClerk()
const { user } = useUser()
@@ -41,12 +47,12 @@ export default function Sidebar() {
return (
<aside className="fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40">
{/* Logo */}
<div className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
<Link href={marketplaceUrl} className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<Car className="w-4 h-4 text-white" />
</div>
<span className="font-bold text-white text-sm tracking-wide">RentalDriveGo</span>
</div>
</Link>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
@@ -65,7 +71,7 @@ export default function Sidebar() {
].join(' ')}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{item.label}
{dict.nav[item.key]}
</Link>
)
})}
@@ -91,9 +97,68 @@ export default function Sidebar() {
className="mt-2 w-full flex items-center gap-3 px-3 py-2 text-sm text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
>
<LogOut className="w-4 h-4" />
Sign out
{dict.signOut}
</button>
</div>
</aside>
)
}
function SidebarWithoutClerk() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const isActive = (item: typeof NAV_ITEMS[0]) => {
if (item.exact) return pathname === item.href
return pathname.startsWith(item.href)
}
return (
<aside className="fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40">
<Link href={marketplaceUrl} className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<Car className="w-4 h-4 text-white" />
</div>
<span className="font-bold text-white text-sm tracking-wide">RentalDriveGo</span>
</Link>
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
href={item.href}
className={[
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
active
? 'bg-blue-600 text-white'
: 'text-slate-400 hover:text-white hover:bg-slate-800',
].join(' ')}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{dict.nav[item.key]}
</Link>
)
})}
</nav>
<div className="px-3 py-4 border-t border-slate-800">
<div className="flex items-center gap-3 px-3 py-2 rounded-lg">
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
D
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{dict.demoUser}</p>
<p className="text-xs text-slate-400 truncate">{dict.clerkDisabled}</p>
</div>
</div>
</div>
</aside>
)
}
export default function Sidebar() {
return clerkFrontendEnabled ? <SidebarWithClerk /> : <SidebarWithoutClerk />
}
+61 -16
View File
@@ -5,26 +5,17 @@ import { usePathname } from 'next/navigation'
import { useState, useEffect } from 'react'
import { apiFetch } from '@/lib/api'
import { useUser } from '@clerk/nextjs'
import { clerkFrontendEnabled } from '@/lib/clerk'
import { useDashboardI18n } from '@/components/I18nProvider'
const PAGE_TITLES: Record<string, string> = {
'/dashboard': 'Dashboard',
'/dashboard/fleet': 'Fleet Management',
'/dashboard/reservations': 'Reservations',
'/dashboard/customers': 'Customers',
'/dashboard/offers': 'Offers',
'/dashboard/team': 'Team',
'/dashboard/reports': 'Reports',
'/dashboard/billing': 'Billing',
'/dashboard/settings': 'Settings',
}
export default function TopBar() {
function TopBarWithClerk() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const { user } = useUser()
const [unreadCount, setUnreadCount] = useState(0)
const [showNotifs, setShowNotifs] = useState(false)
const title = PAGE_TITLES[pathname] ?? 'Dashboard'
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
useEffect(() => {
apiFetch<{ unread: number }>('/notifications/unread-count')
@@ -53,9 +44,9 @@ export default function TopBar() {
{showNotifs && (
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-slate-200 rounded-xl shadow-lg z-50 p-4">
<p className="text-sm font-medium text-slate-900 mb-2">Notifications</p>
<p className="text-sm font-medium text-slate-900 mb-2">{dict.notifications}</p>
<p className="text-sm text-slate-500">
{unreadCount === 0 ? 'No new notifications' : `${unreadCount} unread notification${unreadCount > 1 ? 's' : ''}`}
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p>
</div>
)}
@@ -69,3 +60,57 @@ export default function TopBar() {
</header>
)
}
function TopBarWithoutClerk() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const [unreadCount, setUnreadCount] = useState(0)
const [showNotifs, setShowNotifs] = useState(false)
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
useEffect(() => {
apiFetch<{ unread: number }>('/notifications/unread-count')
.then((data) => setUnreadCount(data.unread))
.catch(() => {})
}, [pathname])
return (
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6">
<h1 className="text-lg font-semibold text-slate-900">{title}</h1>
<div className="flex items-center gap-3">
<div className="relative">
<button
onClick={() => setShowNotifs(!showNotifs)}
className="relative p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 min-w-[16px] h-4 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{showNotifs && (
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-slate-200 rounded-xl shadow-lg z-50 p-4">
<p className="text-sm font-medium text-slate-900 mb-2">{dict.notifications}</p>
<p className="text-sm text-slate-500">
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p>
</div>
)}
</div>
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-semibold">
D
</div>
</div>
</header>
)
}
export default function TopBar() {
return clerkFrontendEnabled ? <TopBarWithClerk /> : <TopBarWithoutClerk />
}
@@ -0,0 +1,249 @@
'use client'
import { useState } from 'react'
import { apiFetch } from '@/lib/api'
type InspectionType = 'CHECKIN' | 'CHECKOUT'
type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY'
type DamageType = 'SCRATCH' | 'DENT' | 'CRACK' | 'CHIP' | 'MISSING' | 'STAIN' | 'OTHER'
type DamageSeverity = 'MINOR' | 'MODERATE' | 'MAJOR'
export interface DamagePoint {
id?: string
viewType: 'TOP' | 'FRONT' | 'REAR' | 'LEFT' | 'RIGHT'
x: number
y: number
damageType: DamageType
severity: DamageSeverity
description?: string | null
isPreExisting: boolean
}
export interface DamageInspection {
id: string
type: InspectionType
mileage: number | null
fuelLevel: FuelLevel
fuelCharge: number | null
generalCondition: string | null
employeeNotes: string | null
customerAgreed: boolean
damagePoints: DamagePoint[]
}
const fuelLevels: FuelLevel[] = ['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']
const damageTypes: DamageType[] = ['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']
const severityColors: Record<DamageSeverity, string> = {
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
}
export default function DamageInspectionCard({
reservationId,
type,
initialInspection,
onSaved,
}: {
reservationId: string
type: InspectionType
initialInspection?: DamageInspection | null
onSaved: (inspection: DamageInspection) => void
}) {
const [inspection, setInspection] = useState<DamageInspection>({
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
type,
mileage: initialInspection?.mileage ?? null,
fuelLevel: initialInspection?.fuelLevel ?? 'FULL',
fuelCharge: initialInspection?.fuelCharge ?? null,
generalCondition: initialInspection?.generalCondition ?? '',
employeeNotes: initialInspection?.employeeNotes ?? '',
customerAgreed: initialInspection?.customerAgreed ?? false,
damagePoints: initialInspection?.damagePoints ?? [],
})
const [damageType, setDamageType] = useState<DamageType>('SCRATCH')
const [severity, setSeverity] = useState<DamageSeverity>('MINOR')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function saveInspection() {
setSaving(true)
setError(null)
try {
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
method: 'PUT',
body: JSON.stringify({
mileage: inspection.mileage,
fuelLevel: inspection.fuelLevel,
fuelCharge: inspection.fuelCharge,
generalCondition: inspection.generalCondition,
employeeNotes: inspection.employeeNotes,
customerAgreed: inspection.customerAgreed,
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType,
x,
y,
damageType,
severity,
description,
isPreExisting,
})),
}),
})
setInspection(result)
onSaved(result)
} catch (err: any) {
setError(err.message ?? 'Failed to save inspection')
} finally {
setSaving(false)
}
}
function addDamagePoint(event: React.MouseEvent<SVGSVGElement>) {
const rect = event.currentTarget.getBoundingClientRect()
const x = ((event.clientX - rect.left) / rect.width) * 100
const y = ((event.clientY - rect.top) / rect.height) * 180
setInspection((current) => ({
...current,
damagePoints: [
...current.damagePoints,
{
viewType: 'TOP',
x,
y,
damageType,
severity,
description: '',
isPreExisting: type === 'CHECKIN',
},
],
}))
}
function removePoint(index: number) {
setInspection((current) => ({
...current,
damagePoints: current.damagePoints.filter((_, pointIndex) => pointIndex !== index),
}))
}
return (
<div className="card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{type === 'CHECKIN' ? 'Check-in inspection' : 'Check-out inspection'}</h3>
<p className="mt-1 text-sm text-slate-500">Mark existing and newly discovered damage directly on the vehicle diagram.</p>
</div>
<button onClick={saveInspection} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save inspection'}
</button>
</div>
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
<div className="mt-5 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
{damageTypes.map((option) => (
<button
key={option}
type="button"
onClick={() => setDamageType(option)}
className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-slate-900 text-white' : 'bg-white text-slate-600'}`}
>
{option}
</button>
))}
</div>
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
{(Object.keys(severityColors) as DamageSeverity[]).map((option) => (
<button
key={option}
type="button"
onClick={() => setSeverity(option)}
className="rounded-full px-3 py-1.5 font-semibold text-white"
style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }}
>
{option}
</button>
))}
</div>
<svg viewBox="0 0 100 180" className="w-full cursor-crosshair rounded-2xl border border-slate-200 bg-white" onClick={addDamagePoint}>
<rect x="30" y="12" width="40" height="156" rx="18" fill="#e2e8f0" stroke="#94a3b8" />
<rect x="36" y="22" width="28" height="24" rx="8" fill="#cbd5e1" />
<rect x="36" y="54" width="28" height="70" rx="8" fill="#f8fafc" stroke="#cbd5e1" />
<rect x="36" y="132" width="28" height="20" rx="8" fill="#cbd5e1" />
<rect x="18" y="38" width="12" height="28" rx="5" fill="#1e293b" />
<rect x="70" y="38" width="12" height="28" rx="5" fill="#1e293b" />
<rect x="18" y="114" width="12" height="28" rx="5" fill="#1e293b" />
<rect x="70" y="114" width="12" height="28" rx="5" fill="#1e293b" />
{inspection.damagePoints.map((point, index) => (
<g key={`${point.x}-${point.y}-${index}`}>
<circle cx={point.x} cy={point.y} r="3.8" fill={severityColors[point.severity]} stroke="white" strokeWidth="1.5" />
</g>
))}
</svg>
<p className="mt-2 text-xs text-slate-500">Click anywhere on the diagram to add a damage marker with the selected type and severity.</p>
</div>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Mileage</label>
<input
type="number"
className="input-field"
value={inspection.mileage ?? ''}
onChange={(event) => setInspection((current) => ({ ...current, mileage: event.target.value ? Number(event.target.value) : null }))}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel level</label>
<select className="input-field" value={inspection.fuelLevel} onChange={(event) => setInspection((current) => ({ ...current, fuelLevel: event.target.value as FuelLevel }))}>
{fuelLevels.map((fuelLevel) => <option key={fuelLevel} value={fuelLevel}>{fuelLevel}</option>)}
</select>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">General condition</label>
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(event) => setInspection((current) => ({ ...current, generalCondition: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Employee notes</label>
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(event) => setInspection((current) => ({ ...current, employeeNotes: event.target.value }))} />
</div>
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={inspection.customerAgreed} onChange={(event) => setInspection((current) => ({ ...current, customerAgreed: event.target.checked }))} />
Customer acknowledged this inspection
</label>
<div className="rounded-2xl border border-slate-200">
<div className="border-b border-slate-200 px-4 py-3">
<p className="text-sm font-semibold text-slate-900">Damage markers</p>
</div>
<div className="divide-y divide-slate-100">
{inspection.damagePoints.map((point, index) => (
<div key={`${point.x}-${point.y}-${index}`} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{point.damageType} · {point.severity}</p>
<p className="text-xs text-slate-500">x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
</div>
<button type="button" onClick={() => removePoint(index)} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600">
Remove
</button>
</div>
))}
{inspection.damagePoints.length === 0 && (
<div className="px-4 py-6 text-sm text-slate-400">No damage markers saved yet.</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,261 @@
'use client'
import { useState, useEffect } from 'react'
import type { TeamMember } from '@/hooks/useTeam'
interface Props {
member: TeamMember | null
open: boolean
onClose: () => void
onUpdateRole: (memberId: string, role: 'MANAGER' | 'AGENT') => Promise<void>
onDeactivate: (memberId: string) => Promise<void>
onReactivate: (memberId: string) => Promise<void>
onRemove: (memberId: string) => Promise<void>
}
const ROLE_OPTIONS = [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
]
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
export default function EditMemberModal({
member,
open,
onClose,
onUpdateRole,
onDeactivate,
onReactivate,
onRemove,
}: Props) {
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [actionState, setActionState] = useState<ActionState>('idle')
const [confirm, setConfirm] = useState<ConfirmAction>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (member && open) {
setRole(member.role === 'OWNER' ? 'MANAGER' : (member.role as 'MANAGER' | 'AGENT'))
setActionState('idle')
setConfirm(null)
setError(null)
}
}, [member, open])
if (!open || !member) return null
const isPending = member.invitationStatus === 'pending'
const isActive = member.isActive
const handleSave = async () => {
if (role === member.role) { onClose(); return }
setActionState('saving')
setError(null)
try {
await onUpdateRole(member.id, role)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
}
const handleDeactivate = async () => {
setActionState('deactivating')
setError(null)
try {
await onDeactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleReactivate = async () => {
setActionState('reactivating')
setError(null)
try {
await onReactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleRemove = async () => {
setActionState('removing')
setError(null)
try {
await onRemove(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
{initials}
</div>
<div>
<p className="font-medium text-zinc-900 dark:text-white text-sm">
{member.firstName} {member.lastName}
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</p>
</div>
<div className="ml-auto">
{isPending && (
<span className="text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border border-amber-100 dark:border-amber-900/50">
Pending invite
</span>
)}
{!isPending && !isActive && (
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
Deactivated
</span>
)}
</div>
</div>
{confirm && (
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
{confirm === 'deactivate' && 'Deactivate this member?'}
{confirm === 'remove' && 'Permanently remove this member?'}
{confirm === 'reactivate' && 'Reactivate this member?'}
</p>
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
{confirm === 'deactivate' && "They'll immediately lose dashboard access. You can reactivate anytime."}
{confirm === 'remove' && "This will delete the employee record and revoke their Clerk access. This cannot be undone."}
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
</p>
<div className="flex gap-2">
<button
onClick={() => setConfirm(null)}
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
Cancel
</button>
<button
onClick={
confirm === 'deactivate' ? handleDeactivate :
confirm === 'reactivate' ? handleReactivate :
handleRemove
}
disabled={busy}
className={[
'flex-1 text-xs px-3 py-1.5 rounded-lg font-medium disabled:opacity-50',
confirm === 'reactivate'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'bg-red-600 text-white hover:bg-red-700',
].join(' ')}
>
{busy ? 'Working…' : (
confirm === 'deactivate' ? 'Deactivate' :
confirm === 'reactivate' ? 'Reactivate' :
'Remove permanently'
)}
</button>
</div>
</div>
)}
{!isPending && isActive && (
<div className="mb-4">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
Role
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-2.5 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="text-sm font-medium text-zinc-900 dark:text-white">{option.label}</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{option.description}</div>
</button>
))}
</div>
</div>
)}
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
<div className="flex gap-2 mr-auto">
{isActive && !isPending && (
<button
onClick={() => setConfirm('deactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Deactivate
</button>
)}
{!isActive && (
<button
onClick={() => setConfirm('reactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
Reactivate
</button>
)}
<button
onClick={() => setConfirm('remove')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Remove
</button>
</div>
<button
onClick={onClose}
disabled={busy}
className="px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50"
>
Cancel
</button>
{isActive && !isPending && (
<button
onClick={handleSave}
disabled={busy}
className="px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50"
>
{actionState === 'saving' ? 'Saving…' : 'Save changes'}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,199 @@
'use client'
import { useState, useEffect } from 'react'
import type { InvitePayload } from '@/hooks/useTeam'
interface Props {
open: boolean
onClose: () => void
onInvite: (payload: InvitePayload) => Promise<void>
}
const ROLE_OPTIONS = [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet ops — no billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
},
]
export default function InviteModal({ open, onClose, onInvite }: Props) {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) {
setTimeout(() => {
setFirstName('')
setLastName('')
setEmail('')
setRole('AGENT')
setError(null)
}, 200)
}
}, [open])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
onClose()
} catch (err: any) {
setError(err.message ?? 'Failed to send invitation')
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div className="mb-5">
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">Invite a team member</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
They&apos;ll receive an email invitation to join your dashboard
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
First name
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Youssef"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Last name
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Benali"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Email address
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="youssef@yourcompany.com"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Role
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-3 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-zinc-900 dark:text-white">
{option.label}
</span>
{role === option.value && (
<span className="w-4 h-4 rounded-full bg-zinc-900 dark:bg-white flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white dark:text-zinc-900" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</span>
)}
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
{option.description}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{option.permissions.slice(0, 3).map((p) => (
<span
key={p}
className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400"
>
{p}
</span>
))}
{option.permissions.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
+{option.permissions.length - 3} more
</span>
)}
</div>
</button>
))}
</div>
</div>
{error && (
<div className="px-3 py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Sending…' : 'Send invite →'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,113 @@
'use client'
type Access = 'full' | 'partial' | 'none'
interface Feature {
label: string
manager: Access
managerNote?: string
agent: Access
agentNote?: string
}
const FEATURES: Feature[] = [
{ label: 'View fleet & vehicles', manager: 'full', agent: 'full' },
{ label: 'Add / edit vehicles', manager: 'full', agent: 'none' },
{ label: 'Publish / unpublish vehicle', manager: 'full', agent: 'none' },
{ label: 'Upload vehicle photos', manager: 'full', agent: 'none' },
{ label: 'View reservations', manager: 'full', agent: 'full' },
{ label: 'Create reservations', manager: 'full', agent: 'full' },
{ label: 'Cancel reservations', manager: 'full', agent: 'partial', agentNote: 'Own only' },
{ label: 'Check-in / check-out', manager: 'full', agent: 'full' },
{ label: 'Damage inspection', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — view', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — edit', manager: 'full', agent: 'partial', agentNote: 'Notes only' },
{ label: 'Approve driver licenses', manager: 'full', agent: 'none' },
{ label: 'Flag / blacklist customer', manager: 'full', agent: 'none' },
{ label: 'Offers & promotions', manager: 'full', agent: 'none' },
{ label: 'Analytics & reports', manager: 'full', agent: 'none' },
{ label: 'Contract & invoice PDF', manager: 'full', agent: 'full' },
{ label: 'Billing & subscription', manager: 'none', agent: 'none' },
{ label: 'Invite / remove staff', manager: 'none', agent: 'none' },
{ label: 'Brand & site settings', manager: 'none', agent: 'none' },
{ label: 'Payment settings', manager: 'none', agent: 'none' },
]
function AccessCell({ access, note }: { access: Access; note?: string }) {
if (access === 'full') {
return (
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-xs text-zinc-700 dark:text-zinc-300">Full</span>
</div>
)
}
if (access === 'partial') {
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2.5 h-0.5 rounded bg-amber-500" />
</div>
<span className="text-xs text-amber-700 dark:text-amber-400">{note ?? 'Limited'}</span>
</div>
)
}
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
</div>
<span className="text-xs text-zinc-400 dark:text-zinc-600">No access</span>
</div>
)
}
export default function PermissionsMatrix() {
return (
<div>
<div className="mb-4">
<h2 className="text-base font-medium text-zinc-900 dark:text-white">Role permissions</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
What each role can do across the dashboard. Owners have full access to everything.
</p>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
Feature
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Manager
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Agent
</div>
</div>
{FEATURES.map((feature, i) => (
<div
key={feature.label}
className={[
'grid grid-cols-[1fr_120px_120px]',
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
i % 2 === 0 ? '' : 'bg-zinc-50/50 dark:bg-zinc-800/20',
].join(' ')}
>
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
{feature.label}
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.manager} note={feature.managerNote} />
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.agent} note={feature.agentNote} />
</div>
</div>
))}
</div>
</div>
)
}
+138
View File
@@ -0,0 +1,138 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { apiFetch } from '@/lib/api'
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
export type InvitationStatus = 'accepted' | 'pending' | 'revoked'
export interface TeamMember {
id: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
createdAt: string
updatedAt: string
lastActiveAt: string | null
invitationStatus: InvitationStatus
}
export interface TeamStats {
total: number
active: number
pending: number
inactive: number
}
export interface InvitePayload {
firstName: string
lastName: string
email: string
role: 'MANAGER' | 'AGENT'
}
export function useTeam() {
const [members, setMembers] = useState<TeamMember[]>([])
const [stats, setStats] = useState<TeamStats>({ total: 0, active: 0, pending: 0, inactive: 0 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const refetch = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [membersData, statsData] = await Promise.all([
apiFetch<TeamMember[]>('/team'),
apiFetch<TeamStats>('/team/stats'),
])
setMembers(membersData)
setStats(statsData)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { refetch() }, [refetch])
const invite = useCallback(async (payload: InvitePayload) => {
const result = await apiFetch<{ employee: TeamMember; invitationId: string }>(
'/team/invite',
{ method: 'POST', body: JSON.stringify(payload) }
)
setMembers((prev) => [...prev, result.employee])
setStats((prev) => ({
...prev,
total: prev.total + 1,
pending: prev.pending + 1,
}))
return result
}, [])
const updateRole = useCallback(async (memberId: string, role: 'MANAGER' | 'AGENT') => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, role: updated.role } : m))
)
return updated
}, [])
const deactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/deactivate`, {
method: 'POST',
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, isActive: false } : m))
)
setStats((prev) => ({ ...prev, active: prev.active - 1, inactive: prev.inactive + 1 }))
return updated
}, [])
const reactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/reactivate`, {
method: 'POST',
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, isActive: true } : m))
)
setStats((prev) => ({ ...prev, active: prev.active + 1, inactive: prev.inactive - 1 }))
return updated
}, [])
const remove = useCallback(async (memberId: string) => {
await apiFetch<{ success: boolean }>(`/team/${memberId}`, {
method: 'DELETE',
})
const target = members.find((m) => m.id === memberId)
setMembers((prev) => prev.filter((m) => m.id !== memberId))
setStats((prev) => ({
...prev,
total: prev.total - 1,
active: target?.isActive ? prev.active - 1 : prev.active,
pending: target?.invitationStatus === 'pending' ? prev.pending - 1 : prev.pending,
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
? prev.inactive - 1
: prev.inactive,
}))
}, [members])
return {
members,
stats,
loading,
error,
refetch,
invite,
updateRole,
deactivate,
reactivate,
remove,
}
}
+11 -3
View File
@@ -1,4 +1,7 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const API_BASE =
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : process.env.NEXT_PUBLIC_API_URL)
?? process.env.NEXT_PUBLIC_API_URL
?? 'http://localhost:4000/api/v1'
async function getClerkToken(): Promise<string | null> {
try {
@@ -19,12 +22,16 @@ async function getClerkToken(): Promise<string | null> {
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = await getClerkToken()
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options?.headers as Record<string, string> ?? {}),
}
if (!isFormData) {
headers['Content-Type'] = 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
@@ -53,10 +60,11 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
}
export async function apiFetchServer<T>(path: string, token: string, options?: RequestInit): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
'Authorization': `Bearer ${token}`,
...(options?.headers as Record<string, string> ?? {}),
},
+5
View File
@@ -0,0 +1,5 @@
export const clerkFrontendEnabled = Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY)
export const clerkBackendEnabled = Boolean(process.env.CLERK_SECRET_KEY)
export const clerkMiddlewareEnabled = clerkFrontendEnabled && clerkBackendEnabled
+1
View File
@@ -0,0 +1 @@
export const marketplaceUrl = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
+7 -1
View File
@@ -1,15 +1,21 @@
import { NextResponse } from 'next/server'
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
import { clerkMiddlewareEnabled } from '@/lib/clerk'
const isProtectedRoute = createRouteMatcher([
'/dashboard(.*)',
'/onboarding(.*)',
])
export default clerkMiddleware((auth, req) => {
export default clerkMiddlewareEnabled
? clerkMiddleware((auth, req) => {
if (isProtectedRoute(req)) {
auth().protect()
}
})
: function middleware() {
return NextResponse.next()
}
export const config = {
matcher: [
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+365
View File
@@ -0,0 +1,365 @@
'use client'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
const copy = {
en: {
heroKicker: 'RentalDriveGo',
heroTitle: 'Marketplace discovery with a sharper front door.',
heroBody:
'Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the companys own branded checkout.',
startTrial: 'Start free trial',
exploreVehicles: 'Explore vehicles',
surfaceLabel: 'Marketplace surface',
surfaceTitle: 'Designed for two audiences at once.',
surfaceBody:
'Operators need control, renters need confidence. The marketplace should show both without feeling like a template.',
liveLabel: 'Live network',
trustedFleets: 'Trusted fleets',
brandedFlows: 'Branded booking flows',
multiTenant: 'Multi-tenant operations',
companyKicker: 'Operator workflow',
companyTitle: 'Control inventory, offers, billing, and staff from one command layer.',
companyBody:
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
renterKicker: 'Renter experience',
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
renterBody:
'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.',
pillars: [
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'],
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'],
['Direct revenue path', 'Bookings move into the companys own payment flow, so customer ownership and cash collection stay with the business.'],
],
metrics: [
['01', 'Shared marketplace visibility'],
['02', 'Direct company checkout'],
['03', 'Company-owned payments'],
],
featureLabel: 'What the product covers',
features: [
'Fleet management with multi-photo uploads',
'Marketplace offers and redirect booking flow',
'Branded public booking site per company',
'Customer CRM, analytics, and billing controls',
],
stepsTitle: 'How companies launch',
steps: [
['1', 'Create your company workspace', 'Pick a plan, launch your 14-day trial, and verify the owner account.'],
['2', 'Publish vehicles and offers', 'Upload photos once and control what appears on the marketplace and branded site.'],
['3', 'Accept bookings on your own site', 'Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow.'],
],
stepLabel: 'Step',
readyKicker: 'Ready to launch',
readyTitle: 'A marketplace homepage should sell the system in seconds.',
readyBody:
'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.',
viewPricing: 'View pricing',
createWorkspace: 'Create workspace',
},
fr: {
heroKicker: 'RentalDriveGo',
heroTitle: 'Une vitrine marketplace plus nette et plus forte.',
heroBody:
'Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune, et chaque réservation se termine sur le paiement de marque de la société.',
startTrial: 'Commencer lessai gratuit',
exploreVehicles: 'Explorer les véhicules',
surfaceLabel: 'Surface marketplace',
surfaceTitle: 'Pensée pour deux audiences à la fois.',
surfaceBody:
'Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.',
liveLabel: 'Réseau actif',
trustedFleets: 'Flottes fiables',
brandedFlows: 'Parcours de marque',
multiTenant: 'Opérations multi-tenant',
companyKicker: 'Flux opérateur',
companyTitle: 'Pilotez inventaire, offres, facturation et équipe depuis une seule couche de commande.',
companyBody:
'Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.',
renterKicker: 'Expérience client',
renterTitle: 'Laissez les clients comparer rapidement puis dirigez-les vers le bon site entreprise.',
renterBody:
'La marketplace agit comme moteur de découverte, pas comme agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.',
pillars: [
['Publication unifiée', 'Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque.'],
['Découverte qualifiée', 'Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement.'],
['Revenus en direct', 'Les réservations basculent vers le paiement propre à lentreprise, pour garder la relation client et lencaissement.'],
],
metrics: [
['01', 'Visibilité marketplace partagée'],
['02', 'Paiement direct entreprise'],
['03', 'Paiements détenus par la société'],
],
featureLabel: 'Ce que couvre le produit',
features: [
'Gestion de flotte avec téléversement multi-photos',
'Offres marketplace et redirection vers la réservation',
'Site public de réservation par entreprise',
'CRM client, analytics et contrôle de facturation',
],
stepsTitle: 'Comment les entreprises démarrent',
steps: [
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire.'],
['2', 'Publiez véhicules et offres', 'Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque.'],
['3', 'Acceptez les réservations sur votre site', 'Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation.'],
],
stepLabel: 'Étape',
readyKicker: 'Prêt à démarrer',
readyTitle: 'Une homepage marketplace doit vendre le système en quelques secondes.',
readyBody:
'Utilisez la marketplace pour capter la demande, puis faites passer les clients vers une expérience de marque qui garde prix, paiements et confiance liés à votre entreprise.',
viewPricing: 'Voir les tarifs',
createWorkspace: 'Créer lespace',
},
ar: {
heroKicker: 'RentalDriveGo',
heroTitle: 'واجهة سوق أوضح وأقوى.',
heroBody:
'شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.',
startTrial: 'ابدأ التجربة المجانية',
exploreVehicles: 'استكشف السيارات',
surfaceLabel: 'واجهة السوق',
surfaceTitle: 'مصممة لجمهورين في الوقت نفسه.',
surfaceBody:
'المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.',
liveLabel: 'شبكة نشطة',
trustedFleets: 'أساطيل موثوقة',
brandedFlows: 'مسارات حجز مخصصة',
multiTenant: 'عمليات متعددة الشركات',
companyKicker: 'تدفق المشغل',
companyTitle: 'تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.',
companyBody:
'انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.',
renterKicker: 'تجربة المستأجر',
renterTitle: 'دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.',
renterBody:
'السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.',
pillars: [
['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'],
['اكتشاف مؤهل', 'العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة.'],
['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه.'],
],
metrics: [
['01', 'ظهور مشترك في السوق'],
['02', 'دفع مباشر للشركة'],
['03', 'مدفوعات مملوكة للشركة'],
],
featureLabel: 'ما الذي يغطيه المنتج',
features: [
'إدارة الأسطول مع رفع عدة صور',
'عروض السوق والتحويل إلى مسار الحجز',
'موقع حجز عام مخصص لكل شركة',
'إدارة العملاء والتحليلات والفوترة',
],
stepsTitle: 'كيف تبدأ الشركات',
steps: [
['1', 'أنشئ مساحة شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك.'],
['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'],
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
],
stepLabel: 'الخطوة',
readyKicker: 'جاهز للانطلاق',
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
readyBody:
'استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.',
viewPricing: 'عرض الأسعار',
createWorkspace: 'إنشاء المساحة',
},
} as const
export default function HomeContent() {
const { language } = useMarketplacePreferences()
const dict = copy[language]
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY&currency=MAD`
return (
<main className="min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f8f5ef_0%,#f4efe6_28%,#fffdf8_58%,#ffffff_100%)] dark:bg-[linear-gradient(180deg,#14110f_0%,#17120d_35%,#0c0a09_100%)]">
<section className="relative">
<div className="absolute inset-x-0 top-0 h-[38rem] bg-[radial-gradient(circle_at_top_left,rgba(217,119,6,0.24),transparent_34%),radial-gradient(circle_at_top_right,rgba(15,118,110,0.18),transparent_26%)] dark:bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_34%),radial-gradient(circle_at_top_right,rgba(45,212,191,0.14),transparent_26%)]" />
<div className="shell relative py-10 sm:py-14 lg:py-20">
<div className="grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_24rem] lg:items-stretch xl:grid-cols-[minmax(0,1.2fr)_28rem]">
<div className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-stone-800 dark:bg-stone-900/70">
<p className="text-xs font-bold uppercase tracking-[0.32em] text-amber-700 dark:text-amber-300">{dict.heroKicker}</p>
<h1 className="mt-5 max-w-4xl text-5xl font-black leading-none tracking-[-0.04em] text-stone-950 dark:text-stone-50 sm:text-6xl lg:text-7xl">
{dict.heroTitle}
</h1>
<p className="mt-6 max-w-2xl text-base leading-8 text-stone-600 dark:text-stone-300 sm:text-lg">
{dict.heroBody}
</p>
<div className="mt-8 flex flex-wrap gap-3">
<a
href={starterSignupHref}
className="rounded-full bg-stone-950 px-6 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
>
{dict.startTrial}
</a>
<a
href="/explore"
className="rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-950 hover:text-stone-950 dark:border-stone-700 dark:bg-stone-950/30 dark:text-stone-200 dark:hover:border-stone-200 dark:hover:text-white"
>
{dict.exploreVehicles}
</a>
</div>
<div className="mt-10 grid gap-3 sm:grid-cols-3">
{dict.metrics.map(([value, label]) => (
<div key={value} className="rounded-[1.5rem] border border-stone-200/80 bg-stone-50/90 p-4 dark:border-stone-800 dark:bg-stone-950/50">
<p className="text-xs font-bold tracking-[0.28em] text-stone-400 dark:text-stone-500">{value}</p>
<p className="mt-3 text-sm font-semibold leading-6 text-stone-900 dark:text-stone-100">{label}</p>
</div>
))}
</div>
</div>
<div className="relative overflow-hidden rounded-[2rem] border border-stone-200/80 bg-stone-950 p-6 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] dark:border-stone-700">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(245,158,11,0.34),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(20,184,166,0.25),transparent_32%)]" />
<div className="relative">
<div className="flex items-center justify-between gap-3">
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
{dict.surfaceLabel}
</span>
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
{dict.liveLabel}
</span>
</div>
<h2 className="mt-6 text-3xl font-black leading-tight tracking-[-0.03em]">{dict.surfaceTitle}</h2>
<p className="mt-4 text-sm leading-7 text-stone-300">{dict.surfaceBody}</p>
<div className="mt-8 space-y-3">
{[
['01', dict.trustedFleets],
['02', dict.brandedFlows],
['03', dict.multiTenant],
].map(([step, label]) => (
<div key={step} className="flex items-center justify-between rounded-[1.25rem] border border-white/10 bg-white/5 px-4 py-4">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-xs font-bold text-stone-950">
{step}
</span>
<p className="text-sm font-semibold text-white">{label}</p>
</div>
<span className="text-lg text-amber-300">+</span>
</div>
))}
</div>
</div>
</div>
</div>
<div className="mt-8 grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
<article className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 backdrop-blur dark:border-stone-800 dark:bg-stone-900/60">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.companyKicker}</p>
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-stone-950 dark:text-stone-50 sm:text-3xl">{dict.companyTitle}</h2>
<p className="mt-4 max-w-3xl text-sm leading-7 text-stone-600 dark:text-stone-300">{dict.companyBody}</p>
</article>
<article className="rounded-[2rem] border border-stone-200/80 bg-[#f7efe2] p-7 dark:border-stone-800 dark:bg-[#1d1712]">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-300">{dict.renterKicker}</p>
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-stone-950 dark:text-stone-50 sm:text-3xl">{dict.renterTitle}</h2>
<p className="mt-4 text-sm leading-7 text-stone-700 dark:text-stone-300">{dict.renterBody}</p>
</article>
</div>
</div>
</section>
<section className="shell pb-10">
<div className="grid gap-4 lg:grid-cols-3">
{dict.pillars.map(([title, body], index) => (
<article
key={title}
className={`rounded-[2rem] border p-7 shadow-sm ${
index === 1
? 'border-stone-900 bg-stone-950 text-white dark:border-amber-300 dark:bg-amber-300 dark:text-stone-950'
: 'border-stone-200 bg-white dark:border-stone-800 dark:bg-stone-900'
}`}
>
<p
className={`text-xs font-bold uppercase tracking-[0.26em] ${
index === 1 ? 'text-stone-300 dark:text-stone-700' : 'text-stone-400 dark:text-stone-500'
}`}
>
0{index + 1}
</p>
<h3 className="mt-4 text-2xl font-black tracking-[-0.03em]">{title}</h3>
<p
className={`mt-4 text-sm leading-7 ${
index === 1 ? 'text-stone-200 dark:text-stone-800' : 'text-stone-600 dark:text-stone-300'
}`}
>
{body}
</p>
</article>
))}
</div>
</section>
<section className="shell py-10">
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
<div className="rounded-[2rem] border border-stone-200 bg-[linear-gradient(160deg,#fffdf8_0%,#f1ebe1_100%)] p-8 dark:border-stone-800 dark:bg-[linear-gradient(160deg,#17120d_0%,#221a14_100%)]">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.featureLabel}</p>
<div className="mt-6 space-y-3">
{dict.features.map((item, index) => (
<div key={item} className="flex items-start gap-4 rounded-[1.25rem] border border-stone-200/80 bg-white/85 px-4 py-4 dark:border-stone-700 dark:bg-stone-950/40">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-stone-950 text-xs font-bold text-white dark:bg-amber-300 dark:text-stone-950">
{index + 1}
</span>
<p className="text-sm font-semibold leading-6 text-stone-800 dark:text-stone-100">{item}</p>
</div>
))}
</div>
</div>
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm dark:border-stone-800 dark:bg-stone-900">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-300">{dict.readyKicker}</p>
<h2 className="mt-4 text-3xl font-black tracking-[-0.03em] text-stone-950 dark:text-stone-50">{dict.stepsTitle}</h2>
</div>
</div>
<div className="mt-8 space-y-4">
{dict.steps.map(([step, title, body]) => (
<article key={step} className="rounded-[1.5rem] border border-stone-200 bg-stone-50 p-5 dark:border-stone-800 dark:bg-stone-950">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">
{dict.stepLabel} {step}
</p>
<h3 className="mt-3 text-lg font-black text-stone-950 dark:text-stone-50">{title}</h3>
<p className="mt-3 text-sm leading-7 text-stone-600 dark:text-stone-300">{body}</p>
</article>
))}
</div>
</div>
</div>
</section>
<section className="shell pb-16 pt-6 sm:pb-20">
<div className="overflow-hidden rounded-[2rem] border border-stone-200 bg-stone-950 px-8 py-10 text-white dark:border-stone-700">
<div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
<div>
<p className="text-xs font-bold uppercase tracking-[0.28em] text-amber-300">{dict.readyKicker}</p>
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight tracking-[-0.04em] sm:text-5xl">{dict.readyTitle}</h2>
<p className="mt-5 max-w-2xl text-sm leading-8 text-stone-300 sm:text-base">{dict.readyBody}</p>
</div>
<div className="flex flex-wrap gap-3 lg:justify-end">
<a
href="/pricing"
className="rounded-full border border-white/20 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white hover:text-stone-950"
>
{dict.viewPricing}
</a>
<a
href={starterSignupHref}
className="rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-stone-950 transition hover:bg-amber-200"
>
{dict.createWorkspace}
</a>
</div>
</div>
</div>
</section>
</main>
)
}
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function BrandedWebsitePage() {
redirect('/explore')
}
@@ -0,0 +1,5 @@
import WorkspaceFrame from '@/components/WorkspaceFrame'
export default function CompanyWorkspacePage() {
return <WorkspaceFrame target="dashboard" />
}
@@ -0,0 +1,148 @@
import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types'
interface CompanyProfile {
id: string
name: string
slug: string
brand: {
displayName: string
logoUrl: string | null
publicCity: string | null
publicCountry: string | null
publicPhone: string | null
whatsappNumber: string | null
marketplaceRating: number | null
} | null
vehicles: Array<{ id: string; make: string; model: string; dailyRate: number; photos: string[]; category: string }>
offers: Array<{ id: string; title: string; discountValue: number; validUntil: string }>
}
export default async function CompanyProfilePage({ params }: { params: { slug: string } }) {
const language = getMarketplaceLanguage()
const dict = {
en: {
unavailable: 'Marketplace unavailable',
unavailableTitle: 'Company details are temporarily unavailable.',
unavailableBody: 'The marketplace API is running, but the database is not reachable in this local environment.',
backToExplore: 'Back to explore',
partner: 'Marketplace partner',
vehicles: 'vehicles',
offers: 'active offers',
rating: 'Rating',
new: 'New',
currentOffers: 'Current offers',
validUntil: 'Valid until',
noOffers: 'No active offers right now.',
fleet: 'Fleet',
viewVehicle: 'View vehicle',
},
fr: {
unavailable: 'Marketplace indisponible',
unavailableTitle: 'Les détails de lentreprise sont temporairement indisponibles.',
unavailableBody: 'LAPI marketplace fonctionne, mais la base de données nest pas accessible dans cet environnement local.',
backToExplore: 'Retour à explorer',
partner: 'Partenaire marketplace',
vehicles: 'véhicules',
offers: 'offres actives',
rating: 'Note',
new: 'Nouveau',
currentOffers: 'Offres actuelles',
validUntil: 'Valable jusquau',
noOffers: 'Aucune offre active pour le moment.',
fleet: 'Flotte',
viewVehicle: 'Voir le véhicule',
},
ar: {
unavailable: 'السوق غير متاح',
unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.',
unavailableBody: 'واجهة marketplace تعمل، لكن قاعدة البيانات غير متاحة في هذا البيئة المحلية.',
backToExplore: 'العودة إلى الاستكشاف',
partner: 'شريك في السوق',
vehicles: 'سيارات',
offers: 'عروض نشطة',
rating: 'التقييم',
new: 'جديد',
currentOffers: 'العروض الحالية',
validUntil: 'صالح حتى',
noOffers: 'لا توجد عروض نشطة حالياً.',
fleet: 'الأسطول',
viewVehicle: 'عرض السيارة',
},
}[language]
const company = await marketplaceFetchOrDefault<CompanyProfile | null>(`/marketplace/${params.slug}`, null)
if (!company) {
return (
<main className="min-h-screen bg-stone-50 py-10">
<div className="shell">
<section className="card p-8">
<p className="text-sm uppercase tracking-[0.18em] text-amber-700">{dict.unavailable}</p>
<h1 className="mt-3 text-3xl font-black text-stone-900">{dict.unavailableTitle}</h1>
<p className="mt-3 text-stone-600">{dict.unavailableBody}</p>
<div className="mt-6">
<Link href="/explore" className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.backToExplore}</Link>
</div>
</section>
</div>
</main>
)
}
return (
<main className="min-h-screen bg-stone-50 py-10">
<div className="shell space-y-8">
<section className="card p-8">
<p className="text-sm uppercase tracking-[0.18em] text-amber-700">{company.brand?.publicCity ?? dict.partner}</p>
<h1 className="mt-3 text-4xl font-black text-stone-900">{company.brand?.displayName ?? company.name}</h1>
<p className="mt-3 text-stone-600">{company.brand?.publicCountry ?? ''}</p>
<div className="mt-6 flex flex-wrap gap-3 text-sm text-stone-500">
<span>{company.vehicles.length} {dict.vehicles}</span>
<span>{company.offers.length} {dict.offers}</span>
<span>{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</span>
</div>
</section>
<section>
<h2 className="text-2xl font-bold text-stone-900">{dict.currentOffers}</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{company.offers.map((offer) => (
<div key={offer.id} className="card p-5">
<p className="text-sm font-semibold text-stone-900">{offer.title}</p>
<p className="mt-2 text-3xl font-black text-amber-700">{offer.discountValue}%</p>
<p className="mt-2 text-sm text-stone-500">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
</div>
))}
{company.offers.length === 0 && <div className="card p-6 text-sm text-stone-500">{dict.noOffers}</div>}
</div>
</section>
<section>
<h2 className="text-2xl font-bold text-stone-900">{dict.fleet}</h2>
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{company.vehicles.map((vehicle) => (
<article key={vehicle.id} className="card overflow-hidden">
<div className="aspect-[16/10] bg-stone-100">
{vehicle.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
) : null}
</div>
<div className="p-5">
<h3 className="text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
<p className="mt-1 text-sm text-stone-500">{vehicle.category}</p>
<div className="mt-4 flex items-end justify-between">
<p className="text-xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
<Link href={`/explore/${params.slug}/vehicles/${vehicle.id}`} className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.viewVehicle}</Link>
</div>
</div>
</article>
))}
</div>
</section>
</div>
</main>
)
}
@@ -0,0 +1,118 @@
import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types'
interface VehicleDetail {
id: string
make: string
model: string
year: number
category: string
seats: number
transmission: string
fuelType: string
features: string[]
dailyRate: number
photos: string[]
company: {
brand: {
displayName: string
subdomain: string
publicPhone: string | null
whatsappNumber: string | null
} | null
}
}
export default async function VehicleDetailPage({ params }: { params: { slug: string; id: string } }) {
const language = getMarketplaceLanguage()
const dict = {
en: {
unavailable: 'Marketplace unavailable',
unavailableTitle: 'Vehicle details are temporarily unavailable.',
unavailableBody: 'The marketplace frontend is running, but the backing database is not reachable right now.',
backToFleet: 'Back to company fleet',
noPhotos: 'No photos available.',
rentalCompany: 'Rental company',
perDay: '/ day',
continueTo: 'Continue to',
bookingSite: 'booking site',
},
fr: {
unavailable: 'Marketplace indisponible',
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
unavailableBody: 'Le frontend marketplace fonctionne, mais la base de données nest pas accessible pour le moment.',
backToFleet: 'Retour à la flotte',
noPhotos: 'Aucune photo disponible.',
rentalCompany: 'Société de location',
perDay: '/ jour',
continueTo: 'Continuer vers le site de réservation de',
bookingSite: '',
},
ar: {
unavailable: 'السوق غير متاح',
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.',
backToFleet: 'العودة إلى أسطول الشركة',
noPhotos: 'لا توجد صور متاحة.',
rentalCompany: 'شركة تأجير',
perDay: '/ يوم',
continueTo: 'الانتقال إلى موقع حجز',
bookingSite: '',
},
}[language]
const vehicle = await marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${params.slug}/vehicles/${params.id}`, null)
if (!vehicle) {
return (
<main className="min-h-screen bg-stone-50 py-10">
<div className="shell">
<section className="card p-8">
<p className="text-sm uppercase tracking-[0.18em] text-amber-700">{dict.unavailable}</p>
<h1 className="mt-3 text-3xl font-black text-stone-900">{dict.unavailableTitle}</h1>
<p className="mt-3 text-stone-600">{dict.unavailableBody}</p>
<div className="mt-6">
<Link href={`/explore/${params.slug}`} className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.backToFleet}</Link>
</div>
</section>
</div>
</main>
)
}
const destination = `https://${params.slug}.RentalDriveGo.com/book?vehicleId=${vehicle.id}&ref=marketplace`
return (
<main className="min-h-screen bg-stone-50 py-10">
<div className="shell grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
<section className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
{vehicle.photos.map((photo, index) => (
<div key={`${photo}-${index}`} className="card overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 1}`} className="h-full w-full object-cover" />
</div>
))}
{vehicle.photos.length === 0 && <div className="card p-8 text-sm text-stone-500">{dict.noPhotos}</div>}
</div>
</section>
<aside className="card p-6 h-fit">
<p className="text-xs uppercase tracking-[0.18em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
<h1 className="mt-3 text-4xl font-black text-stone-900">{vehicle.make} {vehicle.model}</h1>
<p className="mt-3 text-stone-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
<p className="mt-6 text-3xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-stone-500"> {dict.perDay}</span></p>
<div className="mt-6 flex flex-wrap gap-2">
{vehicle.features.map((feature) => (
<span key={feature} className="rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-700">{feature}</span>
))}
</div>
<div className="mt-8 space-y-3">
<Link href={destination} className="block rounded-full bg-stone-900 px-6 py-3 text-center text-sm font-semibold text-white">{dict.continueTo} {vehicle.company.brand?.displayName ?? dict.rentalCompany} {dict.bookingSite}</Link>
<Link href={`/explore/${params.slug}`} className="block rounded-full border border-stone-300 px-6 py-3 text-center text-sm font-semibold text-stone-700">{dict.backToFleet}</Link>
</div>
</aside>
</div>
</main>
)
}
+286
View File
@@ -0,0 +1,286 @@
import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types'
interface Vehicle {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
photos: string[]
availability?: boolean | null
company: {
slug: string
brand: {
displayName: string
logoUrl: string | null
subdomain: string
marketplaceRating: number | null
} | null
}
}
interface Offer {
id: string
title: string
discountValue: number
validUntil: string
company: {
brand: {
displayName: string
subdomain: string
} | null
}
}
interface CompanyCard {
id: string
brand: {
displayName: string
subdomain: string
publicCity: string | null
marketplaceRating: number | null
} | null
_count: {
vehicles: number
}
}
export default async function ExplorePage({ searchParams }: { searchParams?: Record<string, string | string[] | undefined> }) {
const language = getMarketplaceLanguage()
const dict = {
en: {
kicker: 'Discovery only',
title: 'Find your next rental from trusted local companies.',
body: 'Every listing links you to the companys own booking and payment flow.',
city: 'City or location',
allCategories: 'All categories',
anyTransmission: 'Any transmission',
search: 'Search',
currentDeals: 'Current deals',
featuredOffers: 'Featured marketplace offers',
company: 'Company',
validUntil: 'Valid until',
offersUnavailable: 'Marketplace offers are unavailable right now.',
availableVehicles: 'Available vehicles',
listings: 'listings',
rentalCompany: 'Rental company',
checkAvailability: 'Check availability',
available: 'Available',
from: 'From',
bookNow: 'Book now',
details: 'Details',
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
browseByCategory: 'Browse by category',
browseByCompany: 'Browse by company',
marketplacePartners: 'Marketplace partners',
marketplacePartner: 'Marketplace partner',
rating: 'Rating',
new: 'New',
publishedVehicles: 'published vehicles',
viewFleet: 'View fleet',
categories: ['Economy', 'Compact', 'SUV', 'Luxury', 'Van', 'Truck', 'Electric'],
},
fr: {
kicker: 'Découverte uniquement',
title: 'Trouvez votre prochaine location auprès dentreprises locales fiables.',
body: 'Chaque annonce vous redirige vers le parcours de réservation et de paiement propre à lentreprise.',
city: 'Ville ou emplacement',
allCategories: 'Toutes les catégories',
anyTransmission: 'Toute transmission',
search: 'Rechercher',
currentDeals: 'Offres du moment',
featuredOffers: 'Offres marketplace mises en avant',
company: 'Entreprise',
validUntil: 'Valable jusquau',
offersUnavailable: 'Les offres marketplace sont indisponibles pour le moment.',
availableVehicles: 'Véhicules disponibles',
listings: 'annonces',
rentalCompany: 'Société de location',
checkAvailability: 'Vérifier la disponibilité',
available: 'Disponible',
from: 'À partir de',
bookNow: 'Réserver',
details: 'Détails',
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
browseByCategory: 'Parcourir par catégorie',
browseByCompany: 'Parcourir par entreprise',
marketplacePartners: 'Partenaires marketplace',
marketplacePartner: 'Partenaire marketplace',
rating: 'Note',
new: 'Nouveau',
publishedVehicles: 'véhicules publiés',
viewFleet: 'Voir la flotte',
categories: ['Économie', 'Compacte', 'SUV', 'Luxe', 'Van', 'Camion', 'Électrique'],
},
ar: {
kicker: 'للاستكشاف فقط',
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
body: 'كل إعلان يوجّهك إلى مسار الحجز والدفع الخاص بالشركة نفسها.',
city: 'المدينة أو الموقع',
allCategories: 'كل الفئات',
anyTransmission: 'أي ناقل حركة',
search: 'بحث',
currentDeals: 'العروض الحالية',
featuredOffers: 'عروض السوق المميزة',
company: 'شركة',
validUntil: 'صالح حتى',
offersUnavailable: 'عروض السوق غير متاحة حالياً.',
availableVehicles: 'السيارات المتاحة',
listings: 'إعلانات',
rentalCompany: 'شركة تأجير',
checkAvailability: 'تحقق من التوفر',
available: 'متاح',
from: 'ابتداءً من',
bookNow: 'احجز الآن',
details: 'التفاصيل',
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
browseByCategory: 'تصفح حسب الفئة',
browseByCompany: 'تصفح حسب الشركة',
marketplacePartners: 'شركاء السوق',
marketplacePartner: 'شريك في السوق',
rating: 'التقييم',
new: 'جديد',
publishedVehicles: 'سيارات منشورة',
viewFleet: 'عرض الأسطول',
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
},
}[language]
const city = typeof searchParams?.city === 'string' ? searchParams.city : ''
const category = typeof searchParams?.category === 'string' ? searchParams.category : ''
const transmission = typeof searchParams?.transmission === 'string' ? searchParams.transmission : ''
const query = new URLSearchParams()
if (city) query.set('city', city)
if (category) query.set('category', category)
if (transmission) query.set('transmission', transmission)
query.set('pageSize', '24')
const [offers, vehicles, companies] = await Promise.all([
marketplaceFetchOrDefault<Offer[]>('/marketplace/offers', []),
marketplaceFetchOrDefault<Vehicle[]>(`/marketplace/search?${query.toString()}`, []),
marketplaceFetchOrDefault<CompanyCard[]>('/marketplace/companies?pageSize=8', []),
])
return (
<main className="min-h-screen bg-stone-50 py-10">
<div className="shell space-y-10">
<section className="rounded-[2rem] bg-[linear-gradient(135deg,#1c1917,#57534e)] px-8 py-12 text-white">
<p className="text-sm uppercase tracking-[0.2em] text-amber-300">{dict.kicker}</p>
<h1 className="mt-4 text-4xl font-black tracking-tight">{dict.title}</h1>
<p className="mt-4 max-w-2xl text-stone-300">{dict.body}</p>
<form className="mt-8 grid gap-3 rounded-[1.5rem] bg-white/10 p-4 md:grid-cols-4">
<input name="city" defaultValue={city} placeholder={dict.city} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900" />
<select name="category" defaultValue={category} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900">
<option value="">{dict.allCategories}</option>
{['ECONOMY', 'COMPACT', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select name="transmission" defaultValue={transmission} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900">
<option value="">{dict.anyTransmission}</option>
{['AUTOMATIC', 'MANUAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<button className="rounded-2xl bg-amber-400 px-4 py-3 text-sm font-semibold text-stone-950">{dict.search}</button>
</form>
</section>
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900">{dict.currentDeals}</h2>
<p className="text-sm text-stone-500">{dict.featuredOffers}</p>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{offers.map((offer) => (
<Link key={offer.id} href={`/explore/${offer.company.brand?.subdomain ?? ''}#offers`} className="card p-5 hover:border-amber-300 transition-colors">
<p className="text-xs uppercase tracking-[0.18em] text-amber-700">{offer.company.brand?.displayName ?? dict.company}</p>
<h3 className="mt-3 text-lg font-semibold text-stone-900">{offer.title}</h3>
<p className="mt-3 text-3xl font-black text-stone-900">{offer.discountValue}%</p>
<p className="mt-2 text-sm text-stone-500">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
</Link>
))}
{offers.length === 0 && <div className="card p-6 text-sm text-stone-500 md:col-span-2 xl:col-span-4">{dict.offersUnavailable}</div>}
</div>
</section>
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900">{dict.availableVehicles}</h2>
<p className="text-sm text-stone-500">{vehicles.length} {dict.listings}</p>
</div>
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{vehicles.map((vehicle) => (
<article key={vehicle.id} className="card overflow-hidden">
<div className="aspect-[16/10] bg-stone-100">
{vehicle.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
) : null}
</div>
<div className="p-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
<h3 className="mt-2 text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
<p className="mt-1 text-sm text-stone-500">{vehicle.year} · {vehicle.category}</p>
</div>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'}`}>
{vehicle.availability === false ? dict.checkAvailability : dict.available}
</span>
</div>
<div className="mt-5 flex items-end justify-between">
<div>
<p className="text-sm text-stone-500">{dict.from}</p>
<p className="text-2xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
</div>
<div className="flex flex-wrap gap-2">
<a
href={`https://${vehicle.company.brand?.subdomain ?? vehicle.company.slug}.RentalDriveGo.com/book?vehicleId=${vehicle.id}&ref=marketplace`}
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white"
>
{dict.bookNow}
</a>
<Link href={`/explore/${vehicle.company.slug}/vehicles/${vehicle.id}`} className="rounded-full border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-700">
{dict.details}
</Link>
</div>
</div>
</div>
</article>
))}
{vehicles.length === 0 && <div className="card p-6 text-sm text-stone-500 md:col-span-2 xl:col-span-3">{dict.vehicleUnavailable}</div>}
</div>
</section>
<section>
<h2 className="text-2xl font-bold text-stone-900">{dict.browseByCategory}</h2>
<div className="mt-4 flex flex-wrap gap-3">
{dict.categories.map((categoryName, index) => (
<Link key={categoryName} href={`/explore?category=${['ECONOMY', 'COMPACT', 'SUV', 'LUXURY', 'VAN', 'TRUCK', 'ELECTRIC'][index]}`} className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
{categoryName}
</Link>
))}
</div>
</section>
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900">{dict.browseByCompany}</h2>
<p className="text-sm text-stone-500">{dict.marketplacePartners}</p>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{companies.map((company) => (
<Link key={company.id} href={`/explore/${company.brand?.subdomain ?? ''}`} className="card p-5 hover:border-amber-300 transition-colors">
<p className="text-xs uppercase tracking-[0.18em] text-stone-500">{company.brand?.publicCity ?? dict.marketplacePartner}</p>
<h3 className="mt-3 text-lg font-semibold text-stone-900">{company.brand?.displayName ?? dict.rentalCompany}</h3>
<p className="mt-2 text-sm text-stone-500">{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</p>
<p className="mt-1 text-sm text-stone-500">{company._count.vehicles} {dict.publishedVehicles}</p>
<span className="mt-4 inline-flex rounded-full bg-stone-900 px-4 py-2 text-sm font-semibold text-white">{dict.viewFleet}</span>
</Link>
))}
</div>
</section>
</div>
</main>
)
}
@@ -0,0 +1,71 @@
'use client'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
const copy = {
en: {
kicker: 'Features',
title: 'Operations, marketplace discovery, and branded booking.',
body: 'RentalDriveGo combines company-only operations with a public marketplace that sends renters to each companys own payment flow.',
cards: [
['Private dashboard', 'Fleet, reservations, CRM, team permissions, billing, and reports stay isolated per company.'],
['Marketplace discovery', 'Published vehicles and public offers appear on `/explore` for cross-company browsing.'],
['Branded booking site', 'Each company gets its own subdomain where renters complete booking and payment directly.'],
['Payment flexibility', 'Use AmanPay or PayPal for subscriptions and for company-side renter payments.'],
['Advanced rental ops', 'Insurance, additional drivers, pricing rules, and structured fuel policies live in dashboard settings.'],
['Notification controls', 'Both company staff and renters can manage event-by-channel notification preferences.'],
],
},
fr: {
kicker: 'Fonctionnalités',
title: 'Opérations, découverte marketplace et réservation de marque.',
body: 'RentalDriveGo combine des opérations privées pour lentreprise avec une marketplace publique qui envoie les clients vers le flux de paiement propre à chaque société.',
cards: [
['Tableau de bord privé', 'Flotte, réservations, CRM, permissions d’équipe, facturation et rapports restent isolés par entreprise.'],
['Découverte marketplace', 'Les véhicules publiés et les offres publiques apparaissent sur `/explore` pour une navigation multi-entreprises.'],
['Site de réservation de marque', 'Chaque entreprise dispose de son propre sous-domaine où les clients terminent réservation et paiement.'],
['Flexibilité de paiement', 'Utilisez AmanPay ou PayPal pour les abonnements et pour les paiements côté entreprise.'],
['Opérations avancées', 'Assurance, conducteurs supplémentaires, règles tarifaires et politiques carburant structurées vivent dans le dashboard.'],
['Contrôle des notifications', 'Le personnel comme les clients peuvent gérer leurs préférences de notification par événement et canal.'],
],
},
ar: {
kicker: 'المزايا',
title: 'العمليات، اكتشاف السوق، والحجز المخصص.',
body: 'يجمع RentalDriveGo بين عمليات الشركة الخاصة وسوق عام يوجّه المستأجرين إلى مسار الدفع الخاص بكل شركة.',
cards: [
['لوحة تحكم خاصة', 'الأسطول والحجوزات وCRM وصلاحيات الفريق والفوترة والتقارير تبقى معزولة لكل شركة.'],
['اكتشاف عبر السوق', 'تظهر السيارات المنشورة والعروض العامة في `/explore` للتصفح بين الشركات.'],
['موقع حجز مخصص', 'تحصل كل شركة على نطاق فرعي خاص بها لإتمام الحجز والدفع مباشرة.'],
['مرونة الدفع', 'استخدم AmanPay أو PayPal للاشتراكات ومدفوعات المستأجرين من جهة الشركة.'],
['عمليات متقدمة', 'التأمين والسائقون الإضافيون وقواعد التسعير وسياسات الوقود المنظمة موجودة في إعدادات اللوحة.'],
['التحكم في الإشعارات', 'يمكن للموظفين والمستأجرين إدارة تفضيلات الإشعارات حسب الحدث والقناة.'],
],
},
} as const
export default function FeaturesPage() {
const { language } = useMarketplacePreferences()
const dict = copy[language]
return (
<main className="min-h-screen bg-stone-50">
<div className="shell space-y-12 py-16">
<div className="max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">{dict.kicker}</p>
<h1 className="mt-4 text-5xl font-black tracking-tight text-stone-900">{dict.title}</h1>
<p className="mt-5 text-lg leading-8 text-stone-600">{dict.body}</p>
</div>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{dict.cards.map(([title, body]) => (
<article key={title} className="card p-8">
<h2 className="text-xl font-black text-stone-900">{title}</h2>
<p className="mt-4 text-sm leading-7 text-stone-600">{body}</p>
</article>
))}
</div>
</div>
</main>
)
}
+15
View File
@@ -0,0 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-stone-50 text-stone-900 antialiased transition-colors dark:bg-stone-950 dark:text-stone-100;
}
.shell {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
.card {
@apply rounded-2xl border border-stone-200 bg-white shadow-sm transition-colors dark:border-stone-800 dark:bg-stone-900;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from 'next'
import MarketplaceShell from '@/components/MarketplaceShell'
import './globals.css'
export const metadata: Metadata = {
title: 'RentalDriveGo Marketplace',
description: 'Discover vehicles from trusted rental companies.',
icons: {
icon: '/rentalcardrive.png',
shortcut: '/favicon.ico',
apple: '/rentalcardrive.png',
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning>
<MarketplaceShell>{children}</MarketplaceShell>
</body>
</html>
)
}
+5
View File
@@ -0,0 +1,5 @@
import HomeContent from './HomeContent'
export default function HomePage() {
return <HomeContent />
}
@@ -0,0 +1,5 @@
import WorkspaceFrame from '@/components/WorkspaceFrame'
export default function PlatformOperationsPage() {
return <WorkspaceFrame target="admin" />
}
@@ -0,0 +1,269 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
type Currency = 'MAD' | 'USD' | 'EUR'
type Billing = 'monthly' | 'annual'
const SYMBOL: Record<Currency, string> = { MAD: 'MAD', USD: '$', EUR: '€' }
const PRICES: Record<string, Record<Currency, { monthly: number; annual: number }>> = {
STARTER: {
MAD: { monthly: 299, annual: 2990 },
USD: { monthly: 29, annual: 290 },
EUR: { monthly: 27, annual: 270 },
},
GROWTH: {
MAD: { monthly: 599, annual: 5990 },
USD: { monthly: 59, annual: 590 },
EUR: { monthly: 55, annual: 550 },
},
PRO: {
MAD: { monthly: 999, annual: 9990 },
USD: { monthly: 99, annual: 990 },
EUR: { monthly: 92, annual: 920 },
},
}
const PLANS = [
{
key: 'STARTER',
name: 'Starter',
tagline: 'Launch your fleet online',
features: [
'Up to 10 vehicles',
'1 user account',
'Basic analytics',
'Marketplace listing',
],
highlight: false,
},
{
key: 'GROWTH',
name: 'Growth',
tagline: 'Scale with confidence',
features: [
'Up to 50 vehicles',
'5 user accounts',
'Full analytics',
'Priority marketplace placement',
'Custom branding',
],
highlight: true,
},
{
key: 'PRO',
name: 'Pro',
tagline: 'Enterprise-grade power',
features: [
'Unlimited vehicles',
'Unlimited user accounts',
'Advanced reports',
'API access',
'Dedicated support',
],
highlight: false,
},
]
const copy = {
en: {
monthly: 'Monthly',
annual: 'Annual',
yearly: '/ year',
youSave: 'You save',
withAnnual: 'with annual billing — billed as one payment per year.',
mostPopular: 'Most popular',
perMonth: '/mo',
billedAs: 'Billed as',
getStarted: 'Get started',
footer: 'All plans include a 14-day free trial. No credit card required.',
},
fr: {
monthly: 'Mensuel',
annual: 'Annuel',
yearly: '/ an',
youSave: 'Vous économisez',
withAnnual: 'avec la facturation annuelle — prélevée en un seul paiement par an.',
mostPopular: 'Le plus populaire',
perMonth: '/mois',
billedAs: 'Facturé à',
getStarted: 'Commencer',
footer: 'Tous les plans incluent un essai gratuit de 14 jours. Aucune carte bancaire requise.',
},
ar: {
monthly: 'شهري',
annual: 'سنوي',
yearly: '/ سنة',
youSave: 'توفر',
withAnnual: 'مع الفوترة السنوية — دفعة واحدة كل سنة.',
mostPopular: 'الأكثر شيوعاً',
perMonth: '/شهر',
billedAs: 'يتم الفوترة بمبلغ',
getStarted: 'ابدأ الآن',
footer: 'جميع الخطط تشمل تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.',
},
} as const
function fmt(value: number, currency: Currency, billing: Billing): string {
const sym = SYMBOL[currency]
const amount = billing === 'annual' ? Math.round(value / 12) : value
return currency === 'MAD' ? `${amount} ${sym}` : `${sym}${amount}`
}
function annualSavingsPct(plan: string, currency: Currency): number {
const { monthly, annual } = PRICES[plan][currency]
const wouldPay = monthly * 12
return Math.round(((wouldPay - annual) / wouldPay) * 100)
}
export default function PricingClient() {
const { language } = useMarketplacePreferences()
const dict = copy[language]
const [currency, setCurrency] = useState<Currency>('MAD')
const [billing, setBilling] = useState<Billing>('monthly')
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
const savings = annualSavingsPct('STARTER', currency) // same % for all plans
return (
<div className="space-y-10">
{/* Toggles */}
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
{/* Billing toggle */}
<div className="flex items-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm">
{(['monthly', 'annual'] as Billing[]).map((b) => (
<button
key={b}
onClick={() => setBilling(b)}
className={`relative rounded-full px-5 py-2 text-sm font-semibold transition-colors ${
billing === b
? 'bg-stone-900 text-white'
: 'text-stone-600 hover:text-stone-900'
}`}
>
{b === 'monthly' ? dict.monthly : dict.annual}
{b === 'annual' && billing !== 'annual' && (
<span className="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-700">
-{savings}%
</span>
)}
</button>
))}
</div>
{/* Currency toggle */}
<div className="flex items-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm">
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
<button
key={c}
onClick={() => setCurrency(c)}
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
currency === c
? 'bg-amber-500 text-white'
: 'text-stone-600 hover:text-stone-900'
}`}
>
{c}
</button>
))}
</div>
</div>
{billing === 'annual' && (
<p className="text-center text-sm text-amber-700 font-medium">
{dict.youSave} {savings}% {dict.withAnnual}
</p>
)}
{/* Plan cards */}
<div className="grid gap-6 md:grid-cols-3">
{PLANS.map((plan) => {
const price = PRICES[plan.key][currency]
const displayPrice = billing === 'annual'
? Math.round(price.annual / 12)
: price.monthly
const sym = SYMBOL[currency]
return (
<div
key={plan.key}
className={`card relative flex flex-col overflow-hidden ${
plan.highlight
? 'border-amber-400 ring-2 ring-amber-400 ring-offset-2'
: ''
}`}
>
{plan.highlight && (
<div className="bg-amber-500 py-1.5 text-center text-xs font-bold uppercase tracking-widest text-white">
{dict.mostPopular}
</div>
)}
<div className="flex flex-1 flex-col p-8">
<p className="text-xs font-bold uppercase tracking-[0.2em] text-amber-700">
{plan.name}
</p>
<p className="mt-1 text-sm text-stone-500">{plan.tagline}</p>
<div className="mt-6">
<div className="flex items-end gap-1">
<span className="text-4xl font-black text-stone-900">
{currency === 'MAD' ? displayPrice : `${sym}${displayPrice}`}
</span>
{currency === 'MAD' && (
<span className="mb-1 text-lg font-semibold text-stone-500">{sym}</span>
)}
<span className="mb-1 text-sm text-stone-400">{dict.perMonth}</span>
</div>
{billing === 'annual' && (
<p className="mt-1 text-xs text-stone-400">
{dict.billedAs}{' '}
{currency === 'MAD'
? `${price.annual} ${sym}`
: `${sym}${price.annual}`}{' '}
{dict.yearly}
</p>
)}
</div>
<ul className="mt-8 flex-1 space-y-3">
{plan.features.map((f) => (
<li key={f} className="flex items-start gap-2.5 text-sm text-stone-700">
<svg
className="mt-0.5 h-4 w-4 shrink-0 text-amber-500"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{f}
</li>
))}
</ul>
<Link
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}&currency=${currency}`}
className={`mt-10 block rounded-full px-6 py-3 text-center text-sm font-semibold transition-colors ${
plan.highlight
? 'bg-amber-500 text-white hover:bg-amber-600'
: 'bg-stone-900 text-white hover:bg-stone-700'
}`}
>
{dict.getStarted}
</Link>
</div>
</div>
)
})}
</div>
{/* Footer note */}
<p className="text-center text-sm text-stone-400">{dict.footer}</p>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More