fixing platform admin
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
Vendored
+5
@@ -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.
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 d’audit', '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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 d’authentification',
|
||||
authCode: 'Code d’authentification',
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function AdminRootPage() {
|
||||
redirect('/login')
|
||||
}
|
||||
@@ -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 d’ensemble',
|
||||
companies: 'Entreprises',
|
||||
renters: 'Locataires',
|
||||
auditLogs: 'Journaux d’audit',
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 ?? '—',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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 },
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+5
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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, l’accè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 d’essai, la configuration des paiements et les changements d’abonnement.'],
|
||||
['Accès équipe', 'Les propriétaires et collaborateurs ont des accès séparés avec rôles et invitations.'],
|
||||
['Analytique', 'Surveillez l’usage 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 à l’espace 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: 'L’authentification est en cours de chargement. Réessayez dans un instant.',
|
||||
passwordShort: 'Choisissez un mot de passe d’au 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="We’ll 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="We’ll 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="We’ll 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="We’ll 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 />
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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> ?? {}),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export const marketplaceUrl = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
|
||||
@@ -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: [
|
||||
|
||||
Vendored
+5
@@ -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 |
@@ -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 company’s 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 company’s 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 l’essai 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 à l’entreprise, pour garder la relation client et l’encaissement.'],
|
||||
],
|
||||
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 l’espace',
|
||||
},
|
||||
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¤cy=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 l’entreprise sont temporairement indisponibles.',
|
||||
unavailableBody: 'L’API marketplace fonctionne, mais la base de données n’est 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 jusqu’au',
|
||||
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 n’est 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>
|
||||
)
|
||||
}
|
||||
@@ -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 company’s 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 d’entreprises locales fiables.',
|
||||
body: 'Chaque annonce vous redirige vers le parcours de réservation et de paiement propre à l’entreprise.',
|
||||
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 jusqu’au',
|
||||
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 company’s 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 l’entreprise 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()}¤cy=${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
Reference in New Issue
Block a user