fix first online resevation

This commit is contained in:
root
2026-05-09 20:01:51 -04:00
parent c4a45c8b21
commit 09b0e3b55f
75 changed files with 6394 additions and 2190 deletions
+14
View File
@@ -21,3 +21,17 @@ 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
# Email - disable Resend (placeholder), use SMTP instead
RESEND_API_KEY=re_...
EMAIL_FROM=moulay.elabidi@gmail.com
EMAIL_FROM_NAME=RentalDriveGo
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
MAIL_USERNAME=moulay.elabidi@gmail.com
MAIL_PASSWORD=seld rlvl bzuy npar
MAIL_FROM_ADDRESS=moulay.elabidi@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=moulay.elabidi@gmail.com
MAIL_REPLY_TO_NAME=RentalDriveGo
+12
View File
@@ -5,6 +5,7 @@ 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`
- `docker-compose.pgmanage.yml` for a standalone pgManage container
### Development
@@ -75,6 +76,17 @@ pgAdmin Postgres connection:
- username: `postgres`
- password: `password`
### Standalone pgManage
If you want a standalone Postgres management UI without starting the full development stack:
```bash
docker compose -f docker-compose.pgmanage.yml up -d
```
It publishes `http://localhost:8000` with a standard Docker port mapping and persists its data in the named Docker volume `pgmanage_data`.
From inside the container, connect to the local Postgres service through `host.docker.internal:5432`.
### Test
Use the test stack to run repeatable containerized verification:
+13 -1
View File
@@ -1,9 +1,21 @@
/** @type {import('next').NextConfig} */
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
const apiUrl = new URL(apiOrigin)
const nextConfig = {
images: {
domains: ['res.cloudinary.com'],
remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
},
{
protocol: apiUrl.protocol.replace(':', ''),
hostname: apiUrl.hostname,
...(apiUrl.port ? { port: apiUrl.port } : {}),
pathname: '/storage/**',
},
],
},
transpilePackages: ['@rentaldrivego/types'],
async rewrites() {
+29
View File
@@ -0,0 +1,29 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function AuthRedirectPage() {
const router = useRouter()
useEffect(() => {
const hash = window.location.hash
const params = new URLSearchParams(hash.replace(/^#/, ''))
const token = params.get('token')
const next = params.get('next') || '/dashboard'
if (token) {
localStorage.setItem('admin_token', token)
// Clear the token from the URL
window.history.replaceState(null, '', window.location.pathname)
}
router.replace(next)
}, [router])
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-950">
<p className="text-sm text-zinc-400">Redirecting to admin dashboard</p>
</div>
)
}
@@ -0,0 +1,472 @@
'use client'
import { useEffect, useState } from 'react'
import { useAdminI18n } from '@/components/I18nProvider'
const API_BASE = '/api/v1'
interface Company {
id: string
name: string
email: string
slug: string
status: string
}
interface Invoice {
id: string
amount: number
currency: string
status: string
paymentProvider: string
amanpayTransactionId?: string | null
paypalCaptureId?: string | null
paidAt: string | null
createdAt: string
}
function buildInvoiceNumber(invoiceId: string, createdAt: string) {
const year = new Date(createdAt).getFullYear()
const short = invoiceId.slice(-6).toUpperCase()
return `INV-${year}-${short}`
}
interface Subscription {
id: string
companyId: string
company: Company
plan: string
billingPeriod: string
status: string
currency: string
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
invoices: Invoice[]
_count: { invoices: number }
createdAt: string
}
interface Stats {
mrr: number
activeCount: number
trialingCount: number
pastDueCount: number
cancelledCount: number
}
const SUB_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
TRIALING: 'text-sky-400 bg-sky-900/30',
PAST_DUE: 'text-amber-400 bg-amber-900/30',
CANCELLED: 'text-zinc-400 bg-zinc-800',
UNPAID: 'text-red-400 bg-red-900/30',
}
const INVOICE_STATUS_COLORS: Record<string, string> = {
PAID: 'text-emerald-400 bg-emerald-900/30',
PENDING: 'text-amber-400 bg-amber-900/30',
FAILED: 'text-red-400 bg-red-900/30',
REFUNDED: 'text-zinc-400 bg-zinc-800',
}
const PLAN_COLORS: Record<string, string> = {
STARTER: 'text-zinc-300',
GROWTH: 'text-sky-400',
PRO: 'text-violet-400',
}
function fmt(amount: number, currency: string) {
return `${(amount / 100).toFixed(2)} ${currency}`
}
function fmtDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
export default function AdminBillingPage() {
const { language } = useAdminI18n()
const copy = {
en: {
title: 'Billing',
eyebrow: 'Platform',
mrr: 'MRR (MAD)',
active: 'Active',
trialing: 'Trialing',
pastDue: 'Past Due',
cancelled: 'Cancelled',
company: 'Company',
plan: 'Plan',
period: 'Period',
status: 'Status',
currency: 'Currency',
nextRenewal: 'Next Renewal',
invoices: 'Invoices',
actions: 'Actions',
viewInvoices: 'Invoices',
loading: 'Loading…',
empty: 'No subscriptions found',
filterAll: 'All',
filterActive: 'Active',
filterTrialing: 'Trialing',
filterPastDue: 'Past Due',
filterCancelled: 'Cancelled',
closeModal: 'Close',
invoicesFor: 'Invoices for',
noInvoices: 'No invoices found',
amount: 'Amount',
invoiceStatus: 'Status',
provider: 'Provider',
paidAt: 'Paid At',
date: 'Date',
invoiceNo: 'Invoice #',
download: 'PDF',
cancelAtEnd: 'Cancels at period end',
},
fr: {
title: 'Facturation',
eyebrow: 'Plateforme',
mrr: 'MRR (MAD)',
active: 'Actifs',
trialing: 'Essai',
pastDue: 'En retard',
cancelled: 'Annulés',
company: 'Entreprise',
plan: 'Plan',
period: 'Période',
status: 'Statut',
currency: 'Devise',
nextRenewal: 'Prochain renouvellement',
invoices: 'Factures',
actions: 'Actions',
viewInvoices: 'Factures',
loading: 'Chargement…',
empty: 'Aucun abonnement trouvé',
filterAll: 'Tous',
filterActive: 'Actifs',
filterTrialing: 'Essai',
filterPastDue: 'En retard',
filterCancelled: 'Annulés',
closeModal: 'Fermer',
invoicesFor: 'Factures pour',
noInvoices: 'Aucune facture trouvée',
amount: 'Montant',
invoiceStatus: 'Statut',
provider: 'Fournisseur',
paidAt: 'Payé le',
date: 'Date',
invoiceNo: 'N° Facture',
download: 'PDF',
cancelAtEnd: 'Annulé en fin de période',
},
ar: {
title: 'الفوترة',
eyebrow: 'المنصة',
mrr: 'الإيراد الشهري (MAD)',
active: 'نشط',
trialing: 'تجريبي',
pastDue: 'متأخر',
cancelled: 'ملغى',
company: 'الشركة',
plan: 'الخطة',
period: 'الفترة',
status: 'الحالة',
currency: 'العملة',
nextRenewal: 'التجديد القادم',
invoices: 'الفواتير',
actions: 'الإجراءات',
viewInvoices: 'الفواتير',
loading: 'جارٍ التحميل…',
empty: 'لا توجد اشتراكات',
filterAll: 'الكل',
filterActive: 'نشط',
filterTrialing: 'تجريبي',
filterPastDue: 'متأخر',
filterCancelled: 'ملغى',
closeModal: 'إغلاق',
invoicesFor: 'فواتير',
noInvoices: 'لا توجد فواتير',
amount: 'المبلغ',
invoiceStatus: 'الحالة',
provider: 'المزود',
paidAt: 'تاريخ الدفع',
date: 'التاريخ',
invoiceNo: 'رقم الفاتورة',
download: 'PDF',
cancelAtEnd: 'يُلغى في نهاية الفترة',
},
}[language]
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [statusFilter, setStatusFilter] = useState('')
const [invoiceModal, setInvoiceModal] = useState<{ companyId: string; companyName: string } | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [invoicesLoading, setInvoicesLoading] = useState(false)
const [invoicesError, setInvoicesError] = useState<string | null>(null)
async function fetchBilling(status?: string) {
setLoading(true)
setError(null)
const token = localStorage.getItem('admin_token')
try {
const params = new URLSearchParams({ pageSize: '50' })
if (status) params.set('status', status)
const res = await fetch(`${API_BASE}/admin/billing?${params}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: 'no-store',
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
setSubscriptions(json.data ?? [])
setStats(json.stats ?? null)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchBilling(statusFilter || undefined) }, [statusFilter])
async function downloadInvoicePdf(invoiceId: string, createdAt: string) {
const token = localStorage.getItem('admin_token')
try {
const res = await fetch(`${API_BASE}/admin/billing/invoices/${invoiceId}/pdf`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error('Failed to generate PDF')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${buildInvoiceNumber(invoiceId, createdAt)}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch {
// silent — no toast system available here
}
}
async function openInvoices(companyId: string, companyName: string) {
setInvoiceModal({ companyId, companyName })
setInvoices([])
setInvoicesError(null)
setInvoicesLoading(true)
const token = localStorage.getItem('admin_token')
try {
const res = await fetch(`${API_BASE}/admin/billing/${companyId}/invoices?pageSize=50`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? `Error ${res.status}`)
setInvoices(json.data ?? [])
} catch (err: any) {
setInvoicesError(err.message)
} finally {
setInvoicesLoading(false)
}
}
const filters = [
{ key: '', label: copy.filterAll },
{ key: 'ACTIVE', label: copy.filterActive },
{ key: 'TRIALING', label: copy.filterTrialing },
{ key: 'PAST_DUE', label: copy.filterPastDue },
{ key: 'CANCELLED', label: copy.filterCancelled },
]
return (
<div className="shell py-8 space-y-6">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{copy.eyebrow}</p>
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
</div>
{stats && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
<div className="col-span-2 sm:col-span-1 panel p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.mrr}</p>
<p className="mt-1 text-2xl font-bold text-emerald-400">{(stats.mrr / 100).toFixed(0)}</p>
</div>
<div className="panel p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.active}</p>
<p className="mt-1 text-2xl font-bold text-zinc-100">{stats.activeCount}</p>
</div>
<div className="panel p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.trialing}</p>
<p className="mt-1 text-2xl font-bold text-sky-400">{stats.trialingCount}</p>
</div>
<div className="panel p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.pastDue}</p>
<p className="mt-1 text-2xl font-bold text-amber-400">{stats.pastDueCount}</p>
</div>
<div className="panel p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.cancelled}</p>
<p className="mt-1 text-2xl font-bold text-zinc-400">{stats.cancelledCount}</p>
</div>
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
{filters.map((f) => (
<button
key={f.key}
onClick={() => setStatusFilter(f.key)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
statusFilter === f.key
? 'bg-emerald-600 text-white'
: 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
}`}
>
{f.label}
</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">{copy.company}</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.period}</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.currency}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.nextRenewal}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.invoices}</th>
<th className="px-6 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{loading ? (
<tr><td colSpan={8} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td></tr>
) : subscriptions.length === 0 ? (
<tr><td colSpan={8} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td></tr>
) : subscriptions.map((sub) => (
<tr key={sub.id} className="hover:bg-zinc-800/30 transition-colors">
<td className="px-6 py-4">
<p className="font-medium text-zinc-100">{sub.company.name}</p>
<p className="text-xs text-zinc-500">{sub.company.email}</p>
</td>
<td className="px-6 py-4">
<span className={`font-semibold ${PLAN_COLORS[sub.plan] ?? 'text-zinc-300'}`}>{sub.plan}</span>
</td>
<td className="px-6 py-4 text-zinc-400 text-xs">{sub.billingPeriod}</td>
<td className="px-6 py-4">
<div className="flex flex-col gap-1">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium w-fit ${SUB_STATUS_COLORS[sub.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
{sub.status}
</span>
{sub.cancelAtPeriodEnd && (
<span className="text-xs text-amber-500">{copy.cancelAtEnd}</span>
)}
</div>
</td>
<td className="px-6 py-4 text-zinc-400 text-xs">{sub.currency}</td>
<td className="px-6 py-4 text-zinc-400 text-xs">{fmtDate(sub.currentPeriodEnd)}</td>
<td className="px-6 py-4 text-zinc-400 text-xs">{sub._count.invoices}</td>
<td className="px-6 py-4">
<button
onClick={() => openInvoices(sub.company.id, sub.company.name)}
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.viewInvoices}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{invoiceModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-2xl rounded-2xl bg-zinc-900 border border-zinc-800 shadow-2xl flex flex-col max-h-[80vh]">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.invoicesFor}</p>
<p className="font-semibold text-zinc-100">{invoiceModal.companyName}</p>
</div>
<button
onClick={() => { setInvoiceModal(null); setInvoicesError(null) }}
className="p-2 rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="overflow-y-auto flex-1">
{invoicesLoading ? (
<div className="px-6 py-12 text-center text-zinc-500">{copy.loading}</div>
) : invoicesError ? (
<div className="px-6 py-12 text-center text-red-400 text-sm">{invoicesError}</div>
) : invoices.length === 0 ? (
<div className="px-6 py-12 text-center text-zinc-500">{copy.noInvoices}</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-zinc-900">
<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.invoiceNo}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.date}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.amount}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.invoiceStatus}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.provider}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.paidAt}</th>
<th className="px-6 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{invoices.map((inv) => (
<tr key={inv.id} className="hover:bg-zinc-800/30 transition-colors">
<td className="px-6 py-3 text-zinc-300 text-xs font-mono">{buildInvoiceNumber(inv.id, inv.createdAt)}</td>
<td className="px-6 py-3 text-zinc-400 text-xs">{fmtDate(inv.createdAt)}</td>
<td className="px-6 py-3 font-medium text-zinc-100">{fmt(inv.amount, inv.currency)}</td>
<td className="px-6 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS_COLORS[inv.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
{inv.status}
</span>
</td>
<td className="px-6 py-3 text-zinc-500 text-xs">{inv.paymentProvider}</td>
<td className="px-6 py-3 text-zinc-400 text-xs">{fmtDate(inv.paidAt)}</td>
<td className="px-6 py-3">
<button
onClick={() => downloadInvoicePdf(inv.id, inv.createdAt)}
title="Download PDF"
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-xs font-medium text-zinc-300 transition-colors"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
{copy.download}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="px-6 py-4 border-t border-zinc-800 flex justify-end">
<button
onClick={() => { setInvoiceModal(null); setInvoicesError(null) }}
className="px-4 py-2 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-sm font-medium text-zinc-200 transition-colors"
>
{copy.closeModal}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,437 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
const API_BASE = '/api/v1'
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
interface CompanyContainer {
id: string
companyId: string
dockerId: string | null
containerName: string
status: ContainerStatus
port: number
image: string
errorMessage: string | null
createdAt: string
updatedAt: string
company: {
id: string
name: string
slug: string
status: string
}
}
function authHeaders() {
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : ''
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
}
function StatusBadge({ status }: { status: ContainerStatus }) {
const map: Record<ContainerStatus, { label: string; className: string }> = {
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
RUNNING: { label: 'Running', className: 'bg-emerald-900 text-emerald-300' },
STOPPED: { label: 'Stopped', className: 'bg-yellow-900 text-yellow-300' },
RESTARTING: { label: 'Restarting…',className: 'bg-orange-900 text-orange-300 animate-pulse' },
REMOVING: { label: 'Removing…', className: 'bg-red-900 text-red-300 animate-pulse' },
ERROR: { label: 'Error', className: 'bg-red-950 text-red-400' },
}
const { label, className } = map[status] ?? map.ERROR
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
<span className={`h-1.5 w-1.5 rounded-full ${status === 'RUNNING' ? 'bg-emerald-400' : 'bg-current opacity-60'}`} />
{label}
</span>
)
}
function LogsModal({ companyId, companyName, onClose }: { companyId: string; companyName: string; onClose: () => void }) {
const [logs, setLogs] = useState<string>('')
const [loading, setLoading] = useState(true)
const [tail, setTail] = useState(150)
const bottomRef = useRef<HTMLDivElement>(null)
const fetchLogs = useCallback(async (lines: number) => {
setLoading(true)
try {
const res = await fetch(`${API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders() })
const json = await res.json()
setLogs(json.data?.logs ?? '')
} catch {
setLogs('Failed to fetch logs.')
} finally {
setLoading(false)
}
}, [companyId])
useEffect(() => { fetchLogs(tail) }, [fetchLogs, tail])
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
<div>
<p className="text-sm font-semibold text-zinc-100">Container Logs</p>
<p className="text-xs text-zinc-400">{companyName}</p>
</div>
<div className="flex items-center gap-3">
<select
value={tail}
onChange={(e) => setTail(Number(e.target.value))}
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-200 focus:outline-none"
>
<option value={50}>Last 50 lines</option>
<option value={150}>Last 150 lines</option>
<option value={500}>Last 500 lines</option>
<option value={1000}>Last 1000 lines</option>
</select>
<button onClick={() => fetchLogs(tail)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
Refresh
</button>
<button onClick={onClose} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
Close
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<p className="text-xs text-zinc-500">Loading</p>
) : (
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-relaxed text-zinc-300">
{logs || 'No logs available.'}
</pre>
)}
<div ref={bottomRef} />
</div>
</div>
</div>
)
}
type ProvisionResult = { companyId: string; name: string; status: 'created' | 'error'; error?: string }
export default function ContainersPage() {
const [containers, setContainers] = useState<CompanyContainer[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [logsFor, setLogsFor] = useState<{ companyId: string; companyName: string } | null>(null)
const [search, setSearch] = useState('')
const [provisioning, setProvisioning] = useState(false)
const [provisionResults, setProvisionResults] = useState<ProvisionResult[] | null>(null)
const fetchContainers = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/admin/containers`, { headers: authHeaders() })
const json = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(json?.message ?? 'Failed to load containers.')
}
setContainers(json.data ?? [])
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load containers.')
/* silent — keep old data */
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchContainers()
const id = setInterval(fetchContainers, 8000)
return () => clearInterval(id)
}, [fetchContainers])
async function provisionAll() {
setProvisioning(true)
setProvisionResults(null)
try {
const res = await fetch(`${API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders() })
const json = await res.json().catch(() => null)
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
setProvisionResults(json.data?.results ?? [])
setError(null)
await fetchContainers()
} catch (err) {
setError(err instanceof Error ? err.message : 'Provisioning failed.')
} finally {
setProvisioning(false)
}
}
async function act(companyId: string, action: 'start' | 'stop' | 'restart' | 'deploy' | 'remove') {
setBusy((b) => ({ ...b, [companyId]: true }))
try {
const method = action === 'remove' ? 'DELETE' : 'POST'
const url =
action === 'remove'
? `${API_BASE}/admin/containers/${companyId}`
: `${API_BASE}/admin/containers/${companyId}/${action}`
const res = await fetch(url, { method, headers: authHeaders() })
const json = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(json?.message ?? `Failed to ${action} container.`)
}
setError(null)
await fetchContainers()
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${action} container.`)
} finally {
setBusy((b) => ({ ...b, [companyId]: false }))
}
}
const filtered = containers.filter(
(c) =>
c.company.name.toLowerCase().includes(search.toLowerCase()) ||
c.containerName.toLowerCase().includes(search.toLowerCase()) ||
c.company.slug.toLowerCase().includes(search.toLowerCase()),
)
const stats = {
running: containers.filter((c) => c.status === 'RUNNING').length,
stopped: containers.filter((c) => c.status === 'STOPPED').length,
error: containers.filter((c) => c.status === 'ERROR').length,
total: containers.length,
}
return (
<div className="min-h-full p-8">
{logsFor && (
<LogsModal
companyId={logsFor.companyId}
companyName={logsFor.companyName}
onClose={() => setLogsFor(null)}
/>
)}
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold text-zinc-100">Containers</h1>
<p className="mt-1 text-sm text-zinc-400">Manage isolated Docker Compose services for each company workspace.</p>
</div>
<button
onClick={provisionAll}
disabled={provisioning}
className="flex items-center gap-2 rounded-xl bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{provisioning ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
Provisioning
</>
) : (
<>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
Provision All Accounts
</>
)}
</button>
</div>
{error && (
<div className="mb-6 rounded-xl border border-red-900 bg-red-950/70 px-4 py-3 text-sm text-red-200">
{error}
</div>
)}
{provisionResults !== null && (
<div className="mb-6 rounded-xl border border-zinc-800 bg-zinc-900 p-4">
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-medium text-zinc-200">
Provisioning complete {' '}
<span className="text-emerald-400">{provisionResults.filter((r) => r.status === 'created').length} created</span>
{provisionResults.some((r) => r.status === 'error') && (
<>, <span className="text-red-400">{provisionResults.filter((r) => r.status === 'error').length} failed</span></>
)}
</p>
<button onClick={() => setProvisionResults(null)} className="text-xs text-zinc-500 hover:text-zinc-300">Dismiss</button>
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{provisionResults.map((r) => (
<div key={r.companyId} className="flex items-center gap-3 rounded-lg px-3 py-2 bg-zinc-800/60">
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${r.status === 'created' ? 'bg-emerald-400' : 'bg-red-400'}`} />
<span className="text-sm text-zinc-300 flex-1">{r.name}</span>
{r.status === 'error' && <span className="text-xs text-red-400 truncate max-w-xs">{r.error}</span>}
{r.status === 'created' && <span className="text-xs text-emerald-500">Service created</span>}
</div>
))}
</div>
</div>
)}
{/* Stats */}
<div className="mb-6 grid grid-cols-4 gap-4">
{[
{ label: 'Total', value: stats.total, color: 'text-zinc-100' },
{ label: 'Running', value: stats.running, color: 'text-emerald-400' },
{ label: 'Stopped', value: stats.stopped, color: 'text-yellow-400' },
{ label: 'Error', value: stats.error, color: 'text-red-400' },
].map((s) => (
<div key={s.label} className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
<p className="text-xs text-zinc-500">{s.label}</p>
<p className={`mt-1 text-2xl font-bold ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* Search */}
<div className="mb-4">
<input
type="text"
placeholder="Search by company name or container…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full max-w-sm rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* Table */}
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center text-sm text-zinc-500">
{search ? 'No containers match your search.' : 'No containers yet. They are created automatically on company signup.'}
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-left text-xs text-zinc-500">
<th className="px-5 py-3 font-medium">Company</th>
<th className="px-5 py-3 font-medium">Container</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium">Port</th>
<th className="px-5 py-3 font-medium">Image</th>
<th className="px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{filtered.map((c) => {
const isBusy = busy[c.companyId] ?? false
const isRunning = c.status === 'RUNNING'
const isStopped = c.status === 'STOPPED' || c.status === 'ERROR'
const isTransitioning = ['CREATING', 'RESTARTING', 'REMOVING'].includes(c.status)
return (
<tr key={c.id} className="hover:bg-zinc-800/40">
<td className="px-5 py-4">
<p className="font-medium text-zinc-100">{c.company.name}</p>
<p className="text-xs text-zinc-500">{c.company.slug}</p>
{c.errorMessage && (
<p className="mt-1 text-xs text-red-400" title={c.errorMessage}>
{c.errorMessage.slice(0, 60)}{c.errorMessage.length > 60 ? '…' : ''}
</p>
)}
</td>
<td className="px-5 py-4 font-mono text-xs text-zinc-400">
{c.containerName}
{c.dockerId && (
<p className="mt-0.5 text-zinc-600">{c.dockerId.slice(0, 12)}</p>
)}
</td>
<td className="px-5 py-4">
<StatusBadge status={c.status} />
</td>
<td className="px-5 py-4 font-mono text-xs text-zinc-400">:{c.port}</td>
<td className="px-5 py-4 font-mono text-xs text-zinc-500">{c.image}</td>
<td className="px-5 py-4">
<div className="flex items-center gap-1.5">
{isStopped && (
<ActionButton
label="Start"
color="emerald"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'start')}
/>
)}
{isRunning && (
<ActionButton
label="Stop"
color="yellow"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'stop')}
/>
)}
{(isRunning || isStopped) && (
<ActionButton
label="Restart"
color="blue"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'restart')}
/>
)}
<ActionButton
label="Redeploy"
color="purple"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'deploy')}
/>
<ActionButton
label="Logs"
color="zinc"
disabled={isBusy || !c.dockerId}
onClick={() => setLogsFor({ companyId: c.companyId, companyName: c.company.name })}
/>
<ActionButton
label="Remove"
color="red"
disabled={isBusy || isTransitioning}
onClick={() => {
if (confirm(`Remove container for ${c.company.name}? This cannot be undone.`)) {
act(c.companyId, 'remove')
}
}}
/>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
)
}
function ActionButton({
label,
color,
disabled,
onClick,
}: {
label: string
color: 'emerald' | 'yellow' | 'blue' | 'purple' | 'zinc' | 'red'
disabled: boolean
onClick: () => void
}) {
const colorMap: Record<string, string> = {
emerald: 'border-emerald-800 text-emerald-400 hover:bg-emerald-900/40',
yellow: 'border-yellow-800 text-yellow-400 hover:bg-yellow-900/40',
blue: 'border-blue-800 text-blue-400 hover:bg-blue-900/40',
purple: 'border-purple-800 text-purple-400 hover:bg-purple-900/40',
zinc: 'border-zinc-700 text-zinc-400 hover:bg-zinc-700/40',
red: 'border-red-900 text-red-400 hover:bg-red-900/30',
}
return (
<button
onClick={onClick}
disabled={disabled}
className={`rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${colorMap[color]}`}
>
{label}
</button>
)
}
+27 -14
View File
@@ -1,9 +1,23 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider'
import {
AdminLanguageSwitcher,
AdminThemeSwitcher,
useAdminI18n,
} from '@/components/I18nProvider'
const DASHBOARD_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
function buildUnifiedLoginUrl(nextPath: string) {
const params = new URLSearchParams({
portal: 'admin',
next: nextPath || '/dashboard',
})
return `${DASHBOARD_URL}/sign-in?${params.toString()}`
}
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' },
@@ -12,26 +26,26 @@ const navLinks = [
{ 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' },
{ href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
]
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')
window.location.replace(buildUnifiedLoginUrl(pathname))
} else {
setReady(true)
}
}, [router])
}, [pathname])
function handleLogout() {
localStorage.removeItem('admin_token')
router.push('/login')
window.location.href = buildUnifiedLoginUrl('/dashboard')
}
if (!ready) {
@@ -43,8 +57,8 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
}
return (
<div className="flex h-screen">
<aside className="w-60 flex-shrink-0 flex flex-col border-r border-zinc-800 bg-zinc-900">
<div className="flex h-screen bg-zinc-950 text-zinc-100 transition-colors">
<aside className="w-60 flex-shrink-0 flex flex-col border-r border-zinc-800 bg-zinc-900 transition-colors">
<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>
@@ -57,9 +71,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
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'
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}>
@@ -73,19 +85,20 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
<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"
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-zinc-500 transition-colors hover:bg-zinc-800/50 hover:text-red-400"
>
<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">
<div className="mt-4 flex flex-col items-center gap-3 border-t border-zinc-800 pt-4">
<AdminLanguageSwitcher />
<AdminThemeSwitcher />
</div>
</div>
</aside>
<main className="flex-1 overflow-y-auto bg-zinc-950">{children}</main>
<main className="flex-1 overflow-y-auto bg-zinc-950 transition-colors">{children}</main>
</div>
)
}
@@ -0,0 +1,91 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import PublicShell from '@/components/PublicShell'
const API_BASE = '/api/v1'
export default function AdminForgotPasswordPage() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/admin/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
if (!res.ok) throw new Error()
setSent(true)
} catch {
setError('Something went wrong. Please try again.')
} 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">Forgot password</h1>
<p className="mt-1 text-sm text-zinc-400">Enter your admin email to receive a reset link.</p>
</div>
<div className="panel p-8">
{sent ? (
<div className="space-y-3 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900/40">
<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="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm text-zinc-300">Check your email</p>
<p className="text-xs text-zinc-500">If that email is registered, a reset link has been sent. It expires in 60 minutes.</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Email</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@rentaldrivego.com"
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"
/>
</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 ? 'Sending…' : 'Send reset link'}
</button>
</form>
)}
<div className="mt-6 text-center">
<Link href="/login" className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
Back to sign in
</Link>
</div>
</div>
</div>
</main>
</PublicShell>
)
}
+61 -1
View File
@@ -2,8 +2,16 @@
@tailwind components;
@tailwind utilities;
html.light {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body {
@apply bg-zinc-950 text-zinc-50 antialiased;
@apply bg-zinc-950 text-zinc-50 antialiased transition-colors;
}
.shell {
@@ -13,3 +21,55 @@ body {
.panel {
@apply rounded-2xl border border-zinc-800 bg-zinc-900 shadow-sm;
}
@layer utilities {
.light body {
background-color: rgb(248 250 252);
color: rgb(15 23 42);
}
.light .bg-zinc-950 {
background-color: rgb(248 250 252);
}
.light .bg-zinc-950\/90 {
background-color: rgb(255 255 255 / 0.9);
}
.light .bg-zinc-900 {
background-color: rgb(255 255 255);
}
.light .bg-zinc-800,
.light .bg-zinc-800\/50 {
background-color: rgb(241 245 249);
}
.light .border-zinc-800,
.light .border-zinc-700 {
border-color: rgb(226 232 240);
}
.light .text-zinc-100,
.light .text-zinc-200 {
color: rgb(15 23 42);
}
.light .text-zinc-500 {
color: rgb(100 116 139);
}
.light .text-zinc-400,
.light .text-zinc-300 {
color: rgb(71 85 105);
}
.light .hover\:bg-zinc-800:hover,
.light .hover\:bg-zinc-800\/50:hover {
background-color: rgb(241 245 249);
}
.light .hover\:text-zinc-200:hover {
color: rgb(15 23 42);
}
}
+10 -2
View File
@@ -9,8 +9,16 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body><AdminI18nProvider>{children}</AdminI18nProvider></body>
<html lang="en" className="dark" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var theme=localStorage.getItem('admin-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme}catch(e){}})();",
}}
/>
</head>
<body suppressHydrationWarning><AdminI18nProvider>{children}</AdminI18nProvider></body>
</html>
)
}
+3 -208
View File
@@ -1,212 +1,7 @@
'use client'
import { redirect } from 'next/navigation'
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'
const DASHBOARD_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
export default function AdminLoginPage() {
const { language } = useAdminI18n()
const dict = {
en: {
loginFailed: 'Login failed',
verifyFailed: '2FA verification failed',
brand: 'Admin console',
access: 'Platform operations access',
email: 'Email',
password: 'Password',
signIn: 'Sign in',
signingIn: 'Signing in…',
enterCode: 'Enter the 6-digit code from your authenticator app',
authCode: 'Authentication code',
verify: 'Verify',
verifying: 'Verifying…',
back: 'Back to login',
},
fr: {
loginFailed: 'Échec de connexion',
verifyFailed: 'Échec de la vérification 2FA',
brand: 'Console admin',
access: 'Accès opérations plateforme',
email: 'Email',
password: 'Mot de passe',
signIn: 'Connexion',
signingIn: 'Connexion…',
enterCode: 'Entrez le code à 6 chiffres de votre application dauthentification',
authCode: 'Code dauthentification',
verify: 'Vérifier',
verifying: 'Vérification…',
back: 'Retour à la connexion',
},
ar: {
loginFailed: 'فشل تسجيل الدخول',
verifyFailed: 'فشل التحقق الثنائي',
brand: 'لوحة الإدارة',
access: 'وصول عمليات المنصة',
email: 'البريد الإلكتروني',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة',
authCode: 'رمز المصادقة',
verify: 'تحقق',
verifying: 'جارٍ التحقق…',
back: 'العودة إلى تسجيل الدخول',
},
}[language]
const router = useRouter()
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totp, setTotp] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleCredentials(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const json = await res.json()
if (res.status === 401 && json?.error === 'totp_required') {
setStep('totp')
return
}
if (!res.ok) throw new Error(json?.message ?? dict.loginFailed)
if (json.data?.token) {
localStorage.setItem('admin_token', json.data.token)
router.push('/dashboard')
}
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, totpCode: totp }),
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? dict.verifyFailed)
if (json.data?.token) {
localStorage.setItem('admin_token', json.data.token)
router.push('/dashboard')
}
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<PublicShell>
<main className="flex flex-1 items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
<h1 className="mt-3 text-3xl font-black tracking-tight">{dict.brand}</h1>
<p className="mt-1 text-sm text-zinc-400">{dict.access}</p>
</div>
<div className="panel p-8">
{error && (
<div className="mb-6 p-3 rounded-xl border border-red-900/50 bg-red-950/50 text-sm text-red-400">
{error}
</div>
)}
{step === 'credentials' ? (
<form onSubmit={handleCredentials} className="space-y-5">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
placeholder="admin@rentaldrivego.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.password}</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
>
{loading ? dict.signingIn : dict.signIn}
</button>
</form>
) : (
<form onSubmit={handleTotp} className="space-y-5">
<div className="text-center mb-2">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900/40 mb-3">
<svg className="h-6 w-6 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<p className="text-sm text-zinc-300">{dict.enterCode}</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.authCode}</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
required
value={totp}
onChange={(e) => setTotp(e.target.value.replace(/\D/g, ''))}
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-center text-2xl tracking-[0.5em] placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
placeholder="000000"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
>
{loading ? dict.verifying : dict.verify}
</button>
<button
type="button"
onClick={() => { setStep('credentials'); setTotp(''); setError(null) }}
className="w-full text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
>
{dict.back}
</button>
</form>
)}
</div>
</div>
</main>
</PublicShell>
)
redirect(`${DASHBOARD_URL}/sign-in?portal=admin&next=/dashboard`)
}
+152
View File
@@ -0,0 +1,152 @@
'use client'
import Link from 'next/link'
import { useState, Suspense } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import PublicShell from '@/components/PublicShell'
const API_BASE = '/api/v1'
export default function AdminResetPasswordPage() {
return (
<Suspense>
<AdminResetPasswordContent />
</Suspense>
)
}
function AdminResetPasswordContent() {
const searchParams = useSearchParams()
const router = useRouter()
const token = searchParams.get('token')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (password.length < 8) { setError('Password must be at least 8 characters.'); return }
if (password !== confirm) { setError('Passwords do not match.'); return }
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/admin/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
})
const json = await res.json()
if (!res.ok) {
if (json?.error === 'invalid_token') throw new Error('This reset link is invalid or has expired.')
throw new Error('Something went wrong. Please try again.')
}
setDone(true)
} catch (err: any) {
setError(err.message ?? 'Something went wrong.')
} finally {
setLoading(false)
}
}
if (!token) {
return (
<AdminResetShell>
<p className="text-sm text-zinc-400 text-center">No reset token found.</p>
<div className="mt-4 text-center">
<Link href="/forgot-password" className="text-sm text-emerald-400 hover:text-emerald-300">
Request a new reset link
</Link>
</div>
</AdminResetShell>
)
}
if (done) {
return (
<AdminResetShell>
<div className="space-y-3 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900/40">
<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="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm text-zinc-300 font-semibold">Password updated</p>
<p className="text-xs text-zinc-500">You can now sign in with your new password.</p>
<button
type="button"
onClick={() => router.push('/login')}
className="mt-2 w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors"
>
Sign in
</button>
</div>
</AdminResetShell>
)
}
return (
<AdminResetShell>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">New password</label>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Confirm password</label>
<input
type="password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
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"
/>
</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 ? 'Resetting…' : 'Reset password'}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/login" className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
Back to sign in
</Link>
</div>
</AdminResetShell>
)
}
function AdminResetShell({ children }: { children: React.ReactNode }) {
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">Reset password</h1>
</div>
<div className="panel p-8">{children}</div>
</div>
</main>
</PublicShell>
)
}
+78 -4
View File
@@ -3,11 +3,15 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
export type AdminLanguage = 'en' | 'fr' | 'ar'
export type AdminTheme = 'light' | 'dark'
type AdminDictionary = {
nav: Record<string, string>
logout: string
language: string
theme: string
light: string
dark: string
overview: string
admin: string
platform: string
@@ -23,9 +27,13 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
renters: 'Renters',
auditLogs: 'Audit Logs',
adminUsers: 'Admin Users',
billing: 'Billing',
},
logout: 'Logout',
language: 'Language',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
overview: 'Platform overview',
admin: 'Admin',
platform: 'Platform',
@@ -33,15 +41,19 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
},
fr: {
nav: {
overview: 'Vue densemble',
overview: "Vue d'ensemble",
companies: 'Entreprises',
siteConfig: 'Config site',
renters: 'Locataires',
auditLogs: 'Journaux daudit',
auditLogs: "Journaux d'audit",
adminUsers: 'Utilisateurs admin',
billing: 'Facturation',
},
logout: 'Déconnexion',
language: 'Langue',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
overview: 'Vue plateforme',
admin: 'Admin',
platform: 'Plateforme',
@@ -55,9 +67,13 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
renters: 'المستأجرون',
auditLogs: 'سجلات التدقيق',
adminUsers: 'مستخدمو الإدارة',
billing: 'الفوترة',
},
logout: 'تسجيل الخروج',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
overview: 'نظرة عامة على المنصة',
admin: 'الإدارة',
platform: 'المنصة',
@@ -68,6 +84,8 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
type AdminI18nContext = {
language: AdminLanguage
setLanguage: (value: AdminLanguage) => void
theme: AdminTheme
setTheme: (value: AdminTheme) => void
dict: AdminDictionary
}
@@ -75,12 +93,23 @@ const Context = createContext<AdminI18nContext | null>(null)
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<AdminLanguage>('en')
const [theme, setTheme] = useState<AdminTheme>('dark')
useEffect(() => {
const stored = window.localStorage.getItem('admin-language')
const storedTheme = window.localStorage.getItem('admin-theme')
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
setLanguage(stored)
}
if (storedTheme === 'light' || storedTheme === 'dark') {
setTheme(storedTheme)
return
}
if (!window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('light')
}
}, [])
useEffect(() => {
@@ -89,7 +118,18 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
window.localStorage.setItem('admin-language', language)
}, [language])
const value = useMemo(() => ({ language, setLanguage, dict: dictionaries[language] }), [language])
useEffect(() => {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(theme)
document.documentElement.style.colorScheme = theme
document.body.dataset.theme = theme
window.localStorage.setItem('admin-theme', theme)
}, [theme])
const value = useMemo(
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }),
[language, theme],
)
return <Context.Provider value={value}>{children}</Context.Provider>
}
@@ -110,7 +150,7 @@ export function AdminLanguageSwitcher() {
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">
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm transition-colors">
<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
@@ -130,3 +170,37 @@ export function AdminLanguageSwitcher() {
</div>
)
}
export function AdminThemeSwitcher() {
const { theme, setTheme, 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 transition-colors">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">
{dict.theme}
</span>
{(['light', 'dark'] as AdminTheme[]).map((value) => {
const active = value === theme
return (
<button
key={value}
type="button"
onClick={() => setTheme(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 === 'light' ? dict.light : dict.dark}
</button>
)
})}
</div>
)
}
+10 -5
View File
@@ -1,7 +1,11 @@
'use client'
import Link from 'next/link'
import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider'
import {
AdminLanguageSwitcher,
AdminThemeSwitcher,
useAdminI18n,
} from '@/components/I18nProvider'
export default function PublicShell({ children }: { children: React.ReactNode }) {
const { language } = useAdminI18n()
@@ -24,8 +28,8 @@ export default function PublicShell({ children }: { children: React.ReactNode })
}[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="min-h-screen bg-zinc-950 text-zinc-100 transition-colors">
<header className="sticky top-0 z-30 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur-md transition-colors">
<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>
@@ -39,13 +43,14 @@ export default function PublicShell({ children }: { children: React.ReactNode })
</div>
</header>
<div>{children}</div>
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors">
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-zinc-800 dark:bg-zinc-950/90">
<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">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-zinc-500">
{dict.preferences}
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<AdminLanguageSwitcher />
<AdminThemeSwitcher />
</div>
</div>
</footer>
+2 -5
View File
@@ -3,18 +3,16 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"dev": "node --env-file=../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"start": "node --env-file=../../.env.local dist/index.js",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@clerk/clerk-sdk-node": "^5.0.0",
"@react-pdf/renderer": "^3.4.3",
"@rentaldrivego/database": "*",
"@rentaldrivego/types": "*",
"bcryptjs": "^2.4.3",
"cloudinary": "^2.2.0",
"cors": "^2.8.5",
"dayjs": "^1.11.11",
"express": "^4.19.2",
@@ -33,7 +31,6 @@
"react-dom": "^18.3.1",
"resend": "^3.2.0",
"socket.io": "^4.7.5",
"svix": "^1.20.0",
"twilio": "^5.1.0",
"zod": "^3.23.0"
},
+9 -4
View File
@@ -13,8 +13,9 @@ import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middlewa
// ─── Routes ───────────────────────────────────────────────────
import webhookRouter from './routes/webhooks'
import companyAuthRouter from './routes/auth.company'
import renterAuthRouter from './routes/auth.renter'
import companyAuthRouter from './routes/auth.company'
import employeeAuthRouter from './routes/auth.employee'
import renterAuthRouter from './routes/auth.renter'
import vehiclesRouter from './routes/vehicles'
import reservationsRouter from './routes/reservations'
import teamRouter from './routes/team'
@@ -114,6 +115,9 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
})
// ─── Middleware ────────────────────────────────────────────────
// Serve local file storage
app.use('/storage', express.static(require('path').resolve(__dirname, 'lib/storage')))
// Webhook must use raw body BEFORE express.json()
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
@@ -124,8 +128,9 @@ app.use(express.json({ limit: '10mb' }))
// ─── API Routes ───────────────────────────────────────────────
// 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`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
// Admin routes: dedicated limit
app.use(`${v1}/admin`, adminLimiter, adminRouter)
-31
View File
@@ -1,31 +0,0 @@
import { v2 as cloudinary } from 'cloudinary'
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
secure: true,
})
export { cloudinary }
export async function uploadImage(
buffer: Buffer,
folder: string,
publicId?: string
): Promise<string> {
return new Promise((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{ folder, public_id: publicId, resource_type: 'image', quality: 'auto', fetch_format: 'auto' },
(error, result) => {
if (error) return reject(error)
resolve(result!.secure_url)
}
)
uploadStream.end(buffer)
})
}
export async function deleteImage(publicId: string): Promise<void> {
await cloudinary.uploader.destroy(publicId)
}
+35
View File
@@ -0,0 +1,35 @@
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
const STORAGE_ROOT = path.resolve(__dirname, 'storage')
function getApiBase(): string {
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
}
export async function uploadImage(
buffer: Buffer,
folder: string,
publicId?: string
): Promise<string> {
const folderPath = path.join(STORAGE_ROOT, folder)
fs.mkdirSync(folderPath, { recursive: true })
const filename = publicId
? `${publicId}.jpg`
: `${crypto.randomBytes(16).toString('hex')}.jpg`
fs.writeFileSync(path.join(folderPath, filename), buffer)
return `${getApiBase()}/storage/${folder}/${filename}`
}
export async function deleteImage(imageUrl: string): Promise<void> {
const marker = '/storage/'
const idx = imageUrl.indexOf(marker)
if (idx === -1) return
const relative = imageUrl.slice(idx + marker.length)
const filePath = path.join(STORAGE_ROOT, relative)
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
}
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+19 -19
View File
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express'
import { clerkClient } from '@clerk/clerk-sdk-node'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
@@ -15,26 +15,26 @@ export async function requireCompanyAuth(req: Request, res: Response, next: Next
}
try {
const session = await clerkClient.sessions.verifySession(sessionToken, sessionToken)
const clerkUserId = session.userId
const employee = await prisma.employee.findUnique({
where: { clerkUserId },
include: { company: true },
})
if (!employee || !employee.isActive) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Employee account not found or inactive',
statusCode: 401,
const payload = jwt.verify(sessionToken, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type === 'employee') {
const employee = await prisma.employee.findUnique({
where: { id: payload.sub },
include: { company: true },
})
}
req.employee = employee
req.company = employee.company
req.companyId = employee.companyId
next()
if (!employee || !employee.isActive) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Employee account not found or inactive',
statusCode: 401,
})
}
req.employee = employee
req.company = employee.company
req.companyId = employee.companyId
return next()
}
} catch {
return res.status(401).json({
error: 'invalid_token',
+188
View File
@@ -2,11 +2,14 @@ import { Router } from 'express'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
import { authenticator } from 'otplib'
import qrcode from 'qrcode'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAuth'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../services/platformContentService'
import { generateInvoicePdf, buildInvoiceNumber } from '../services/invoicePdfService'
import { sendTransactionalEmail } from '../services/notificationService'
import type { MarketplaceHomepageContent, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep } from '@rentaldrivego/types'
const router = Router()
@@ -311,6 +314,70 @@ router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
} catch (err) { next(err) }
})
const ADMIN_RESET_TOKEN_TTL_MINUTES = 60
router.post('/auth/forgot-password', async (req, res, next) => {
try {
const { email } = z.object({ email: z.string().email().max(255).trim().toLowerCase() }).parse(req.body)
const admin = await prisma.adminUser.findUnique({ where: { email } })
if (admin && admin.isActive) {
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + ADMIN_RESET_TOKEN_TTL_MINUTES * 60 * 1000)
await prisma.adminUser.update({
where: { id: admin.id },
data: { passwordResetToken: rawToken, passwordResetExpiresAt: expiresAt },
})
const adminUrl = process.env.ADMIN_URL ?? 'http://localhost:3002'
const resetUrl = `${adminUrl}/reset-password?token=${rawToken}`
await sendTransactionalEmail({
to: email,
subject: 'Reset your RentalDriveGo admin password',
html: `<p>Hi ${admin.firstName},</p><p>Reset your admin password here:</p><p><a href="${resetUrl}" style="background:#059669;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset password</a></p><p>This link expires in ${ADMIN_RESET_TOKEN_TTL_MINUTES} minutes.</p><p>RentalDriveGo</p>`,
text: `Hi ${admin.firstName},\n\nReset your admin password here: ${resetUrl}\n\nThis link expires in ${ADMIN_RESET_TOKEN_TTL_MINUTES} minutes.\n\nRentalDriveGo`,
}).catch((err) => console.error('[AdminForgotPassword] Email send failed:', err?.message))
}
res.json({ data: { message: 'If that email is registered, a reset link has been sent.' } })
} catch (err) { next(err) }
})
router.post('/auth/reset-password', async (req, res, next) => {
try {
const { token, password } = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
}).parse(req.body)
const admin = await prisma.adminUser.findFirst({
where: {
passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() },
},
})
if (!admin) {
return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 })
}
const passwordHash = await bcrypt.hash(password, 12)
await prisma.adminUser.update({
where: { id: admin.id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
res.json({ data: { message: 'Password updated successfully. You can now sign in.' } })
} catch (err) { next(err) }
})
// ─── Companies ────────────────────────────────────────────────
router.get('/companies', requireAdminAuth, async (req, res, next) => {
@@ -665,4 +732,125 @@ router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPE
} catch (err) { next(err) }
})
// ─── Billing ──────────────────────────────────────────────────
const PLAN_MONTHLY_AMOUNT: Record<string, number> = {
STARTER: 29900,
GROWTH: 59900,
PRO: 99900,
}
router.get('/billing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { status, plan, page = '1', pageSize = '20' } = req.query as Record<string, string>
const where: any = {}
if (status) where.status = status
if (plan) where.plan = plan
const [subscriptions, total] = await Promise.all([
prisma.subscription.findMany({
where,
include: {
company: { select: { id: true, name: true, email: true, slug: true, status: true } },
invoices: { select: { id: true, amount: true, currency: true, status: true, paidAt: true, createdAt: true }, orderBy: { createdAt: 'desc' }, take: 5 },
_count: { select: { invoices: true } },
},
skip: (parseInt(page) - 1) * parseInt(pageSize),
take: parseInt(pageSize),
orderBy: { createdAt: 'desc' },
}),
prisma.subscription.count({ where }),
])
const activeStatuses = ['ACTIVE', 'TRIALING']
const allActive = await prisma.subscription.findMany({
where: { status: { in: activeStatuses } },
select: { plan: true, billingPeriod: true, currency: true },
})
let mrr = 0
for (const sub of allActive) {
const monthly = PLAN_MONTHLY_AMOUNT[sub.plan] ?? 0
mrr += sub.billingPeriod === 'ANNUAL' ? Math.round(monthly * 10 / 12) : monthly
}
const [activeCount, trialingCount, pastDueCount, cancelledCount] = await Promise.all([
prisma.subscription.count({ where: { status: 'ACTIVE' } }),
prisma.subscription.count({ where: { status: 'TRIALING' } }),
prisma.subscription.count({ where: { status: 'PAST_DUE' } }),
prisma.subscription.count({ where: { status: 'CANCELLED' } }),
])
res.json({
data: subscriptions,
total,
page: parseInt(page),
pageSize: parseInt(pageSize),
totalPages: Math.ceil(total / parseInt(pageSize)),
stats: { mrr, activeCount, trialingCount, pastDueCount, cancelledCount },
})
} catch (err) { next(err) }
})
router.get('/billing/:companyId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query as Record<string, string>
const [invoices, total] = await Promise.all([
prisma.subscriptionInvoice.findMany({
where: { companyId: req.params.companyId },
orderBy: { createdAt: 'desc' },
skip: (parseInt(page) - 1) * parseInt(pageSize),
take: parseInt(pageSize),
}),
prisma.subscriptionInvoice.count({ where: { companyId: req.params.companyId } }),
])
res.json({ data: invoices, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
} catch (err) { next(err) }
})
router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const invoice = await prisma.subscriptionInvoice.findUniqueOrThrow({
where: { id: req.params.invoiceId },
include: {
company: { select: { name: true, email: true, phone: true, address: true } },
subscription: { select: { plan: true, billingPeriod: true, currency: true, currentPeriodStart: true, currentPeriodEnd: true } },
},
})
const invoiceNumber = buildInvoiceNumber(invoice.id, invoice.createdAt)
const transactionId = invoice.amanpayTransactionId ?? invoice.paypalCaptureId ?? null
const pdfBuffer = await generateInvoicePdf({
invoiceNumber,
issueDate: invoice.createdAt.toISOString(),
dueDate: invoice.status === 'PENDING' ? invoice.createdAt.toISOString() : null,
company: {
name: invoice.company.name,
email: invoice.company.email,
phone: invoice.company.phone,
address: invoice.company.address,
},
subscription: {
plan: invoice.subscription.plan,
billingPeriod: invoice.subscription.billingPeriod,
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
currency: invoice.subscription.currency,
},
amount: invoice.amount,
currency: invoice.currency,
status: invoice.status,
paymentProvider: invoice.paymentProvider,
transactionId,
paidAt: invoice.paidAt?.toISOString(),
})
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${invoiceNumber}.pdf"`)
res.setHeader('Content-Length', pdfBuffer.length)
res.end(pdfBuffer)
} catch (err) { next(err) }
})
export default router
+52 -358
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'
import { z } from 'zod'
import { clerkClient } from '@clerk/clerk-sdk-node'
import bcrypt from 'bcryptjs'
import { prisma } from '../lib/prisma'
import { sendNotification } from '../services/notificationService'
@@ -10,6 +10,7 @@ const signupSchema = z.object({
firstName: z.string().min(1).max(80),
lastName: z.string().min(1).max(80),
email: z.string().email(),
password: z.string().min(8).max(128),
companyName: z.string().min(2).max(120),
companyPhone: z.string().optional(),
country: z.string().optional(),
@@ -20,24 +21,11 @@ const signupSchema = z.object({
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(),
})
type EmailDeliveryResult = {
attempted: boolean
success: boolean
error: string | null
}
function slugifyCompanyName(value: string) {
return value
@@ -69,207 +57,42 @@ function buildSignupConfirmationEmail(opts: {
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD' | 'USD' | 'EUR'
paymentProvider?: 'AMANPAY' | 'PAYPAL'
trialEndAt?: Date
isResend?: boolean
usesInvitation?: boolean
paymentProvider: 'AMANPAY' | 'PAYPAL'
trialEndAt: Date
}) {
const greetingName = opts.firstName.trim() || 'there'
const lines = [
return [
`Hi ${greetingName},`,
'',
opts.isResend
? `We sent a fresh owner invitation for ${opts.companyName}.`
: `Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
`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(
`Primary payment provider: ${opts.paymentProvider}`,
`Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`,
'',
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.',
'Your workspace is ready and you can sign in with the email address and password you chose during signup.',
'',
'RentalDriveGo'
)
return lines.join('\n')
'RentalDriveGo',
].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',
})
async function sendSignupEmailNotification(opts: Parameters<typeof sendNotification>[0]): Promise<EmailDeliveryResult> {
const result = await sendNotification(opts)
const emailResult = result.find((entry) => entry.channel === 'EMAIL')
if (!emailResult) {
return { attempted: false, success: false, error: null }
}
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,
attempted: true,
success: emailResult.success,
error: emailResult.error ?? null,
}
}
@@ -298,7 +121,7 @@ router.post('/signup', async (req, res, next) => {
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 passwordHash = await bcrypt.hash(body.password, 12)
const result = await prisma.$transaction(async (tx) => {
const company = await tx.company.create({
@@ -324,7 +147,7 @@ router.post('/signup', async (req, res, next) => {
},
})
const subscription = await tx.subscription.create({
await tx.subscription.create({
data: {
companyId: company.id,
plan: body.plan,
@@ -338,32 +161,27 @@ router.post('/signup', async (req, res, next) => {
},
})
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}`,
clerkUserId: `local_owner_${company.id}`,
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
phone: body.companyPhone || null,
passwordHash,
role: 'OWNER',
isActive: !invitation,
isActive: true,
},
})
return {
company,
subscription,
employeeId: employee.id,
invitationId: invitation?.id ?? null,
}
})
await sendNotification({
const emailDelivery = await sendSignupEmailNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
title: 'Your workspace is ready',
body: buildSignupConfirmationEmail({
@@ -374,22 +192,22 @@ router.post('/signup', async (req, res, next) => {
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,
invitationId: null,
trialEndsAt: trialEndAt.toISOString(),
nextStep: usesInvitation ? 'check_email_for_invitation' : 'workspace_created',
nextStep: 'workspace_created',
emailDelivery,
},
})
} catch (err) {
@@ -397,144 +215,20 @@ router.post('/signup', async (req, res, next) => {
}
})
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('/complete-signup', (_req, res) => {
res.status(410).json({
error: 'disabled',
message: 'Clerk-based signup has been removed. Use /auth/company/signup instead.',
statusCode: 410,
})
})
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)
}
router.post('/verify-email', (_req, res) => {
res.status(410).json({
error: 'disabled',
message: 'Email verification resend via Clerk has been removed from this project.',
statusCode: 410,
})
})
export default router
+139
View File
@@ -0,0 +1,139 @@
import { Router } from 'express'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
import { prisma } from '../lib/prisma'
import { sendTransactionalEmail } from '../services/notificationService'
const router = Router()
const RESET_TOKEN_TTL_MINUTES = 60
function signEmployeeToken(employeeId: string) {
return jwt.sign(
{ sub: employeeId, type: 'employee' },
process.env.JWT_SECRET!,
{ expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'] },
)
}
function buildResetEmailHtml(resetUrl: string, firstName: string) {
return `
<p>Hi ${firstName},</p>
<p>You requested a password reset for your RentalDriveGo workspace account.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset your password</a></p>
<p>This link expires in ${RESET_TOKEN_TTL_MINUTES} minutes. If you did not request this, you can safely ignore this email.</p>
<p>RentalDriveGo</p>
`
}
router.post('/login', async (req, res, next) => {
try {
const { email, password } = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
password: z.string().max(128),
}).parse(req.body)
const employee = await prisma.employee.findFirst({
where: { email },
include: { company: true },
})
if (!employee || !employee.isActive) {
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
}
if (!employee.passwordHash) {
return res.status(401).json({ error: 'password_not_set', message: 'This account does not have a password yet. Use the invitation or reset email to set one first.', statusCode: 401 })
}
const valid = await bcrypt.compare(password, employee.passwordHash)
if (!valid) {
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
}
const token = signEmployeeToken(employee.id)
res.json({
data: {
token,
employee: {
id: employee.id,
email: employee.email,
firstName: employee.firstName,
lastName: employee.lastName,
role: employee.role,
companyId: employee.companyId,
companyName: employee.company.name,
companySlug: employee.company.slug,
},
},
})
} catch (err) { next(err) }
})
router.post('/forgot-password', async (req, res, next) => {
try {
const { email } = z.object({ email: z.string().email().max(255).trim().toLowerCase() }).parse(req.body)
// Always return success to avoid user enumeration
const employee = await prisma.employee.findFirst({ where: { email, isActive: true } })
if (employee) {
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000)
await prisma.employee.update({
where: { id: employee.id },
data: { passwordResetToken: rawToken, passwordResetExpiresAt: expiresAt },
})
const dashboardUrl = process.env.DASHBOARD_URL ?? 'http://localhost:3001'
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
await sendTransactionalEmail({
to: email,
subject: 'Reset your RentalDriveGo password',
html: buildResetEmailHtml(resetUrl, employee.firstName),
text: `Hi ${employee.firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${RESET_TOKEN_TTL_MINUTES} minutes.\n\nRentalDriveGo`,
}).catch((err) => console.error('[ForgotPassword] Email send failed:', err?.message))
}
res.json({ data: { message: 'If that email is registered, a reset link has been sent.' } })
} catch (err) { next(err) }
})
router.post('/reset-password', async (req, res, next) => {
try {
const { token, password } = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
}).parse(req.body)
const employee = await prisma.employee.findFirst({
where: {
passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() },
},
})
if (!employee) {
return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 })
}
const passwordHash = await bcrypt.hash(password, 12)
await prisma.employee.update({
where: { id: employee.id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
res.json({ data: { message: 'Password updated successfully. You can now sign in.' } })
} catch (err) { next(err) }
})
export default router
+3 -3
View File
@@ -2,7 +2,7 @@ import { Router } from 'express'
import { z } from 'zod'
import multer from 'multer'
import { prisma } from '../lib/prisma'
import { uploadImage } from '../lib/cloudinary'
import { uploadImage } from '../lib/storage'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
@@ -261,7 +261,7 @@ router.post('/me/brand/logo', upload.single('file'), async (req, res, next) => {
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 url = await uploadImage(req.file.buffer, `companies/${req.companyId}/brand`, 'logo')
const brand = await prisma.brandSettings.upsert({
where: { companyId: req.companyId },
update: { logoUrl: url },
@@ -281,7 +281,7 @@ router.post('/me/brand/hero', upload.single('file'), async (req, res, next) => {
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 url = await uploadImage(req.file.buffer, `companies/${req.companyId}/brand`, 'hero')
const brand = await prisma.brandSettings.upsert({
where: { companyId: req.companyId },
update: { heroImageUrl: url },
+129 -7
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import { z } from 'zod'
import { prisma } from '../lib/prisma'
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
const router = Router()
router.use(optionalRenterAuth)
@@ -38,7 +39,7 @@ router.get('/companies', async (req, res, next) => {
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 } }
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }
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() } } }
@@ -69,14 +70,18 @@ router.get('/search', async (req, res, next) => {
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(),
make: z.string().trim().max(60).optional(),
model: z.string().trim().max(60).optional(),
})
const { city, startDate, endDate, category, maxPrice, transmission } = searchSchema.parse(req.query)
const { city, startDate, endDate, category, maxPrice, transmission, make, model } = searchSchema.parse(req.query)
const { page, pageSize } = paginationSchema.parse(req.query)
const where: any = { isPublished: true, company: { status: 'ACTIVE', brand: { isListedOnMarketplace: true } } }
const where: any = { isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } } }
if (category) where.category = category
if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice }
if (transmission) where.transmission = transmission
if (make) where.make = { contains: make, mode: 'insensitive' }
if (model) where.model = { contains: model, mode: 'insensitive' }
if (city) where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: city, mode: 'insensitive' } } }
const vehicles = await prisma.vehicle.findMany({
@@ -115,10 +120,127 @@ router.get('/search', async (req, res, next) => {
}
})
router.post('/reservations', async (req, res, next) => {
try {
const schema = z.object({
vehicleId: z.string().cuid(),
companySlug: z.string().trim().max(100),
firstName: z.string().min(1).max(100),
lastName: z.string().min(1).max(100),
email: z.string().email(),
phone: z.string().max(30).optional(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
notes: z.string().max(500).optional(),
})
const body = schema.parse(req.body)
const startDate = new Date(body.startDate)
const endDate = new Date(body.endDate)
if (endDate <= startDate) {
return res.status(400).json({ error: 'invalid_dates', message: 'End date must be after start date', statusCode: 400 })
}
const vehicle = await prisma.vehicle.findFirst({
where: { id: body.vehicleId, isPublished: true, company: { slug: body.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
include: { company: { include: { brand: true } } },
})
if (!vehicle) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
const conflict = await prisma.reservation.findFirst({
where: {
vehicleId: body.vehicleId,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lt: endDate },
endDate: { gt: startDate },
},
})
if (conflict) return res.status(409).json({ error: 'unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 })
const customer = await prisma.customer.upsert({
where: { companyId_email: { companyId: vehicle.companyId, email: body.email } },
create: { companyId: vehicle.companyId, firstName: body.firstName, lastName: body.lastName, email: body.email, phone: body.phone },
update: { firstName: body.firstName, lastName: body.lastName, ...(body.phone ? { phone: body.phone } : {}) },
})
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
const totalAmount = vehicle.dailyRate * totalDays
const reservation = await prisma.reservation.create({
data: {
companyId: vehicle.companyId,
vehicleId: body.vehicleId,
customerId: customer.id,
source: 'MARKETPLACE',
status: 'DRAFT',
startDate,
endDate,
dailyRate: vehicle.dailyRate,
totalDays,
totalAmount,
notes: body.notes,
},
})
const companyName = vehicle.company.brand?.displayName ?? vehicle.company.name
const rateDisplay = (vehicle.dailyRate / 100).toFixed(2)
const totalDisplay = (totalAmount / 100).toFixed(2)
const dateOpts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
const startStr = startDate.toLocaleDateString('en-GB', dateOpts)
const endStr = endDate.toLocaleDateString('en-GB', dateOpts)
try {
await sendTransactionalEmail({
to: body.email,
subject: `Reservation Request Received — ${vehicle.make} ${vehicle.model}`,
html: `
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
<h2 style="margin-bottom:4px">Hi ${body.firstName},</h2>
<p style="color:#57534e">Your reservation request has been received and is pending company confirmation.</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
<h3 style="margin-bottom:12px">${vehicle.year} ${vehicle.make} ${vehicle.model}</h3>
<p><strong>Company:</strong> ${companyName}</p>
<p><strong>Pick-up date:</strong> ${startStr}</p>
<p><strong>Return date:</strong> ${endStr}</p>
<p><strong>Duration:</strong> ${totalDays} day${totalDays > 1 ? 's' : ''}</p>
<p><strong>Daily rate:</strong> ${rateDisplay} MAD</p>
<p><strong>Estimated total:</strong> ${totalDisplay} MAD</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
<p style="color:#57534e;font-size:13px">The company will review your request and get in touch with you shortly. You may be contacted at ${body.email}${body.phone ? ` or ${body.phone}` : ''}.</p>
</div>
`,
text: `Hi ${body.firstName},\n\nYour reservation request for the ${vehicle.year} ${vehicle.make} ${vehicle.model} from ${companyName} has been received.\n\nDates: ${startStr}${endStr}\nDuration: ${totalDays} day(s)\nDaily rate: ${rateDisplay} MAD\nEstimated total: ${totalDisplay} MAD\n\nThe company will confirm your request shortly.\n\nThank you!`,
})
} catch {
// email failure is non-blocking — reservation still created
}
try {
await sendNotification({
type: 'NEW_BOOKING',
title: 'New Marketplace Reservation Request',
body: `${body.firstName} ${body.lastName} requested ${vehicle.make} ${vehicle.model} from ${startStr} to ${endStr}.`,
companyId: vehicle.companyId,
channels: ['IN_APP'],
})
} catch {
// notification failure is non-blocking
}
res.status(201).json({ data: { reservationId: reservation.id } })
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug', async (req, res, next) => {
try {
const company = await prisma.company.findFirst({
where: { slug: req.params.slug, status: 'ACTIVE' },
where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } },
include: {
brand: true,
vehicles: { where: { isPublished: true }, orderBy: { createdAt: 'desc' } },
@@ -153,7 +275,7 @@ router.get('/:slug/reviews', async (req, res, next) => {
router.get('/:slug/vehicles', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const vehicles = await prisma.vehicle.findMany({
where: { companyId: company.id, isPublished: true },
orderBy: { createdAt: 'desc' },
@@ -167,7 +289,7 @@ router.get('/:slug/vehicles', async (req, res, next) => {
router.get('/:slug/vehicles/:id', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const vehicle = await prisma.vehicle.findFirstOrThrow({
where: { id: req.params.id, companyId: company.id, isPublished: true },
include: {
@@ -189,7 +311,7 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
router.get('/:slug/offers', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const offers = await prisma.offer.findMany({
where: {
companyId: company.id,
+3 -3
View File
@@ -2,7 +2,7 @@ import { Router } from 'express'
import { z } from 'zod'
import multer from 'multer'
import { prisma } from '../lib/prisma'
import { uploadImage } from '../lib/cloudinary'
import { uploadImage } from '../lib/storage'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
@@ -16,7 +16,7 @@ const vehicleSchema = z.object({
make: z.string().min(1),
model: z.string().min(1),
year: z.number().int().min(1990).max(new Date().getFullYear() + 1),
color: z.string().min(1),
color: z.string().default(''),
licensePlate: z.string().min(1),
vin: z.string().optional(),
category: z.enum(['ECONOMY','COMPACT','MIDSIZE','FULLSIZE','SUV','LUXURY','VAN','TRUCK']),
@@ -83,7 +83,7 @@ router.post('/:id/photos', upload.array('photos', 10), async (req, res, next) =>
try {
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const files = req.files as Express.Multer.File[]
const urls = await Promise.all(files.map((f) => uploadImage(f.buffer, `vehicles/${req.companyId}`)))
const urls = await Promise.all(files.map((f) => uploadImage(f.buffer, `companies/${req.companyId}/vehicles`)))
const updated = await prisma.vehicle.update({ where: { id: vehicle.id }, data: { photos: [...vehicle.photos, ...urls] } })
res.json({ data: updated })
} catch (err) { next(err) }
+6 -32
View File
@@ -1,39 +1,13 @@
import { Router } from 'express'
import { Webhook } from 'svix'
import { EmployeeRole } from '@rentaldrivego/database'
import { handleInvitationAccepted } from '../services/teamService'
const router = Router()
router.post('/clerk', async (req, res) => {
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET
if (!webhookSecret) return res.status(500).json({ error: 'Webhook secret not configured' })
const wh = new Webhook(webhookSecret)
let event: any
try {
event = wh.verify(req.body, {
'svix-id': req.headers['svix-id'] as string,
'svix-timestamp': req.headers['svix-timestamp'] as string,
'svix-signature': req.headers['svix-signature'] as string,
})
} catch {
return res.status(400).json({ error: 'Invalid webhook signature' })
}
if (event.type === 'user.created') {
const clerkUserId: string = event.data.id
const email: string = event.data.email_addresses?.[0]?.email_address ?? ''
const meta = event.data.public_metadata ?? {}
const { companyId, role } = meta as { companyId?: string; role?: EmployeeRole }
if (companyId && role && email) {
await handleInvitationAccepted(clerkUserId, email, companyId, role).catch(console.error)
}
}
res.json({ received: true })
router.post('/clerk', (_req, res) => {
res.status(410).json({
error: 'disabled',
message: 'Clerk webhooks are disabled because Clerk has been removed from this project.',
statusCode: 410,
})
})
export default router
+467
View File
@@ -0,0 +1,467 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import * as fs from 'fs/promises'
import * as path from 'path'
import { prisma } from '../lib/prisma'
const execFileAsync = promisify(execFile)
let composeCommand: 'docker-compose' | 'docker' | null = null
// ── Config ────────────────────────────────────────────────────────────────────
const COMPOSE_DIR = process.env.COMPANIES_COMPOSE_DIR || '/opt/companies'
const COMPOSE_FILE = path.join(COMPOSE_DIR, 'docker-compose.companies.yml')
const SERVICES_FILE = path.join(COMPOSE_DIR, 'companies-services.json') // our source of truth
const DASHBOARD_IMAGE = process.env.DASHBOARD_CONTAINER_IMAGE || 'rentaldrivego/dashboard:latest'
const PORT_RANGE_START = parseInt(process.env.CONTAINER_PORT_START || '5100', 10)
const PORT_RANGE_END = parseInt(process.env.CONTAINER_PORT_END || '5999', 10)
const API_INTERNAL_URL = process.env.API_INTERNAL_URL || 'http://api:4000'
const CONTAINER_NETWORK = process.env.CONTAINER_NETWORK || 'rentaldrivego_default'
// ── Types ─────────────────────────────────────────────────────────────────────
interface ServiceDef {
companyId: string
slug: string
image: string
port: number
}
type ServicesMap = Record<string, ServiceDef> // key = service name e.g. "company-slug"
type ServiceStatus = 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'ERROR'
// ── Internal helpers ──────────────────────────────────────────────────────────
function serviceName(slug: string) {
return `company-${slug}`
}
function containerName(slug: string) {
return `rdg-company-${slug}`
}
async function ensureDir(): Promise<void> {
await fs.mkdir(COMPOSE_DIR, { recursive: true })
}
async function readServices(): Promise<ServicesMap> {
try {
const raw = await fs.readFile(SERVICES_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return {}
}
}
async function writeServices(services: ServicesMap): Promise<void> {
await ensureDir()
await fs.writeFile(SERVICES_FILE, JSON.stringify(services, null, 2), 'utf8')
await fs.writeFile(COMPOSE_FILE, buildComposeYaml(services), 'utf8')
}
async function composeFilesExist(): Promise<boolean> {
try {
await fs.access(COMPOSE_FILE)
await fs.access(SERVICES_FILE)
return true
} catch {
return false
}
}
async function rebuildServicesFromDatabase(): Promise<ServicesMap> {
const records = await prisma.companyContainer.findMany({
include: { company: { select: { slug: true } } },
})
return Object.fromEntries(
records.map((record) => [
serviceName(record.company.slug),
{
companyId: record.companyId,
slug: record.company.slug,
image: record.image,
port: record.port,
},
]),
)
}
async function ensureComposeFiles(): Promise<void> {
if (await composeFilesExist()) return
const services = await rebuildServicesFromDatabase()
await writeServices(services)
}
function buildComposeYaml(services: ServicesMap): string {
const lines: string[] = ['services:']
for (const [name, svc] of Object.entries(services)) {
lines.push(` ${name}:`)
lines.push(` image: "${svc.image}"`)
lines.push(` container_name: "${containerName(svc.slug)}"`)
lines.push(` restart: unless-stopped`)
lines.push(` environment:`)
lines.push(` COMPANY_ID: "${svc.companyId}"`)
lines.push(` COMPANY_SLUG: "${svc.slug}"`)
lines.push(` API_URL: "${API_INTERNAL_URL}"`)
lines.push(` NEXT_PUBLIC_API_URL: "${API_INTERNAL_URL}"`)
lines.push(` PORT: "3000"`)
lines.push(` NODE_ENV: "production"`)
lines.push(` ports:`)
lines.push(` - "${svc.port}:3000"`)
lines.push(` networks:`)
lines.push(` - ${CONTAINER_NETWORK}`)
lines.push(` labels:`)
lines.push(` rdg.managed: "true"`)
lines.push(` rdg.company.id: "${svc.companyId}"`)
lines.push(` rdg.company.slug: "${svc.slug}"`)
}
lines.push('')
lines.push('networks:')
lines.push(` ${CONTAINER_NETWORK}:`)
lines.push(` external: true`)
lines.push('')
return lines.join('\n')
}
async function resolveComposeCommand(): Promise<'docker-compose' | 'docker'> {
if (composeCommand) return composeCommand
try {
await execFileAsync('docker-compose', ['version'])
composeCommand = 'docker-compose'
return composeCommand
} catch {
try {
await execFileAsync('docker', ['compose', 'version'])
composeCommand = 'docker'
return composeCommand
} catch (err) {
throw mapDockerError(err)
}
}
}
async function compose(...args: string[]): Promise<{ stdout: string; stderr: string }> {
try {
await ensureComposeFiles()
const command = await resolveComposeCommand()
if (command === 'docker-compose') {
return await execFileAsync('docker-compose', ['-f', COMPOSE_FILE, ...args])
}
return await execFileAsync('docker', ['compose', '-f', COMPOSE_FILE, ...args])
} catch (err) {
throw mapDockerError(err)
}
}
function dockerUnavailableError(message: string, details?: string) {
const err = Object.assign(new Error(message), {
statusCode: 503,
code: 'docker_unavailable',
})
if (details) {
;(err as Error & { details?: string }).details = details
}
return err
}
function mapDockerError(err: unknown) {
if (err && typeof err === 'object') {
const error = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }
const stderr = error.stderr?.trim()
if (error.code === 'ENOENT' && error.path === 'docker') {
return dockerUnavailableError(
'Docker CLI is not available to the API service.',
'Install Docker in the API container or run the API on a host with Docker available in PATH.',
)
}
if (error.code === 'ENOENT' && error.path === 'docker-compose') {
return dockerUnavailableError(
'Docker Compose is not available to the API service.',
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
)
}
if (
stderr?.includes("docker: 'compose' is not a docker command.") ||
stderr?.includes("unknown shorthand flag: 'f' in -f")
) {
return dockerUnavailableError(
'Docker Compose is not available to the API service.',
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
)
}
if (
stderr?.includes('Cannot connect to the Docker daemon') ||
stderr?.includes('permission denied while trying to connect to the Docker daemon socket') ||
stderr?.includes('error during connect')
) {
return dockerUnavailableError(
'Docker daemon is not reachable from the API service.',
'Mount `/var/run/docker.sock` into the API container and ensure the Docker daemon is running.',
)
}
// Catch-all: docker ran but exited non-zero — surface stderr as a readable 502
if (typeof error.code === 'number' && error.code !== 0) {
const detail = stderr || error.stdout?.trim() || 'docker compose exited with a non-zero status'
return Object.assign(new Error(detail), { statusCode: 502, code: 'docker_error' })
}
}
return err
}
async function setContainerError(companyId: string, message: string): Promise<void> {
await prisma.companyContainer.update({
where: { companyId },
data: { status: 'ERROR', errorMessage: message },
}).catch(() => null)
}
async function runContainerAction(
companyId: string,
slug: string,
command: 'start' | 'stop' | 'restart',
successStatus: Exclude<ServiceStatus, 'ERROR'>,
preStatus?: Exclude<ServiceStatus, 'ERROR'>,
): Promise<void> {
if (preStatus) {
await prisma.companyContainer.update({ where: { companyId }, data: { status: preStatus, errorMessage: null } })
}
try {
// Use `up -d --no-recreate` for start so it creates the container if it doesn't exist yet
const args = command === 'start'
? ['up', '-d', '--no-recreate', serviceName(slug)]
: [command, serviceName(slug)]
await compose(...args)
await prisma.companyContainer.update({
where: { companyId },
data: { status: successStatus, errorMessage: null },
})
} catch (err) {
const message = err instanceof Error ? err.message : `Failed to ${command} container`
await setContainerError(companyId, message)
throw err
}
}
async function allocatePort(): Promise<number> {
const last = await prisma.companyContainer.findFirst({
orderBy: { port: 'desc' },
select: { port: true },
})
const next = last ? last.port + 1 : PORT_RANGE_START
if (next > PORT_RANGE_END) throw new Error('No available ports in container port range')
return next
}
async function getDockerServiceId(slug: string): Promise<string | null> {
try {
const name = serviceName(slug)
const { stdout } = await compose('ps', '--format', 'json', name)
const line = stdout.trim().split('\n')[0]
if (!line) return null
const info = JSON.parse(line)
return info.ID ?? info.Id ?? null
} catch {
return null
}
}
// Maps docker compose State strings to our DB enum
function mapDockerState(state: string): ServiceStatus {
switch (state.toLowerCase()) {
case 'running': return 'RUNNING'
case 'restarting': return 'RESTARTING'
case 'exited':
case 'created':
case 'paused': return 'STOPPED'
default: return 'ERROR'
}
}
// ── Public API ────────────────────────────────────────────────────────────────
export async function createCompanyContainer(company: {
id: string
slug: string
name: string
}): Promise<void> {
const name = serviceName(company.slug)
const port = await allocatePort()
const record = await prisma.companyContainer.create({
data: {
companyId: company.id,
containerName: containerName(company.slug),
status: 'CREATING',
port,
image: DASHBOARD_IMAGE,
},
})
try {
const services = await readServices()
services[name] = { companyId: company.id, slug: company.slug, image: DASHBOARD_IMAGE, port }
await writeServices(services)
await compose('up', '-d', '--no-recreate', name)
const dockerId = await getDockerServiceId(company.slug)
await prisma.companyContainer.update({
where: { id: record.id },
data: { dockerId, status: 'RUNNING' },
})
} catch (err) {
await prisma.companyContainer.update({
where: { id: record.id },
data: {
status: 'ERROR',
errorMessage: err instanceof Error ? err.message : 'Unknown error during service creation',
},
})
throw err
}
}
export async function startCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'start', 'RUNNING')
}
export async function stopCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'stop', 'STOPPED')
}
export async function restartCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'restart', 'RUNNING', 'RESTARTING')
}
export async function removeCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
const name = serviceName(record.company.slug)
await prisma.companyContainer.update({ where: { companyId }, data: { status: 'REMOVING' } })
try {
await compose('stop', name)
} catch { /* already stopped */ }
try {
await compose('rm', '-f', name)
} catch { /* already removed */ }
const services = await readServices()
delete services[name]
await writeServices(services)
await prisma.companyContainer.delete({ where: { companyId } })
}
export async function redeployCompanyContainer(company: {
id: string
slug: string
name: string
}): Promise<void> {
const name = serviceName(company.slug)
const existing = await prisma.companyContainer.findUnique({ where: { companyId: company.id } })
if (existing) {
try { await compose('stop', name) } catch { /* ok */ }
try { await compose('rm', '-f', name) } catch { /* ok */ }
await prisma.companyContainer.delete({ where: { companyId: company.id } })
}
// Remove from compose file too, then recreate
const services = await readServices()
delete services[name]
await writeServices(services)
await createCompanyContainer(company)
}
export async function getContainerLogs(companyId: string, tail = 150): Promise<string> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
try {
const { stdout } = await compose(
'logs',
'--no-log-prefix',
`--tail=${tail}`,
serviceName(record.company.slug),
)
return stdout
} catch {
return ''
}
}
export async function syncContainerStatuses(): Promise<void> {
const records = await prisma.companyContainer.findMany({
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
include: { company: { select: { slug: true } } },
})
try {
// Get all compose service states in one shot
const { stdout } = await compose('ps', '--format', 'json')
const rows = stdout
.trim()
.split('\n')
.filter(Boolean)
.map((line) => {
try { return JSON.parse(line) } catch { return null }
})
.filter(Boolean) as Array<{ Service: string; State: string }>
const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State]))
await Promise.allSettled(
records.map(async (rec) => {
const name = serviceName(rec.company.slug)
const dockerState = stateByService[name]
const status = dockerState ? mapDockerState(dockerState) : 'STOPPED'
if (rec.status !== status) {
await prisma.companyContainer.update({ where: { id: rec.id }, data: { status } })
}
}),
)
} catch {
// Docker daemon unreachable — leave statuses as-is
}
}
+497
View File
@@ -0,0 +1,497 @@
import React from 'react'
import { renderToBuffer, Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
interface InvoiceData {
invoiceNumber: string
issueDate: string
dueDate: string | null
company: {
name: string
email: string
phone?: string | null
address?: any
}
subscription: {
plan: string
billingPeriod: string
currentPeriodStart?: string | null
currentPeriodEnd?: string | null
currency: string
}
amount: number
currency: string
status: string
paymentProvider: string
transactionId?: string | null
paidAt?: string | null
}
const PLAN_LABEL: Record<string, string> = {
STARTER: 'Starter',
GROWTH: 'Growth',
PRO: 'Pro',
}
const PERIOD_LABEL: Record<string, string> = {
MONTHLY: 'Monthly',
ANNUAL: 'Annual',
}
const STATUS_COLORS: Record<string, string> = {
PAID: '#10b981',
PENDING: '#f59e0b',
FAILED: '#ef4444',
REFUNDED: '#6b7280',
}
const colors = {
bg: '#0f0f11',
surface: '#18181b',
border: '#27272a',
textPrimary: '#f4f4f5',
textSecondary: '#a1a1aa',
textMuted: '#71717a',
accent: '#10b981',
white: '#ffffff',
}
const s = StyleSheet.create({
page: {
backgroundColor: colors.bg,
paddingHorizontal: 48,
paddingVertical: 48,
fontFamily: 'Helvetica',
color: colors.textPrimary,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 40,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
brandName: {
fontSize: 22,
fontFamily: 'Helvetica-Bold',
color: colors.accent,
letterSpacing: 0.5,
},
brandTagline: {
fontSize: 9,
color: colors.textMuted,
marginTop: 3,
},
invoiceMeta: {
alignItems: 'flex-end',
},
invoiceTitle: {
fontSize: 26,
fontFamily: 'Helvetica-Bold',
color: colors.white,
letterSpacing: 1,
},
invoiceNumber: {
fontSize: 11,
color: colors.textSecondary,
marginTop: 4,
},
section: {
marginBottom: 24,
},
sectionLabel: {
fontSize: 8,
fontFamily: 'Helvetica-Bold',
color: colors.textMuted,
letterSpacing: 1.2,
textTransform: 'uppercase',
marginBottom: 8,
},
row: {
flexDirection: 'row',
gap: 24,
marginBottom: 24,
},
col: {
flex: 1,
backgroundColor: colors.surface,
borderRadius: 8,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
},
colLabel: {
fontSize: 8,
fontFamily: 'Helvetica-Bold',
color: colors.textMuted,
letterSpacing: 1,
textTransform: 'uppercase',
marginBottom: 10,
},
companyName: {
fontSize: 13,
fontFamily: 'Helvetica-Bold',
color: colors.textPrimary,
marginBottom: 4,
},
companyDetail: {
fontSize: 10,
color: colors.textSecondary,
marginBottom: 2,
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 6,
},
metaKey: {
fontSize: 9,
color: colors.textMuted,
},
metaValue: {
fontSize: 9,
color: colors.textSecondary,
fontFamily: 'Helvetica-Bold',
},
table: {
backgroundColor: colors.surface,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
marginBottom: 20,
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#1c1c1f',
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
tableHeaderCell: {
fontSize: 8,
fontFamily: 'Helvetica-Bold',
color: colors.textMuted,
letterSpacing: 0.8,
textTransform: 'uppercase',
},
tableRow: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
tableCell: {
fontSize: 10,
color: colors.textPrimary,
},
tableCellMuted: {
fontSize: 10,
color: colors.textSecondary,
},
col60: { flex: 6 },
col20: { flex: 2, textAlign: 'right' as const },
col20Center: { flex: 2, textAlign: 'center' as const },
totalRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginBottom: 6,
},
totalLabel: {
fontSize: 11,
color: colors.textSecondary,
marginRight: 32,
},
totalValue: {
fontSize: 11,
fontFamily: 'Helvetica-Bold',
color: colors.white,
minWidth: 100,
textAlign: 'right' as const,
},
grandTotalRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 8,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: colors.border,
},
grandTotalLabel: {
fontSize: 13,
fontFamily: 'Helvetica-Bold',
color: colors.textSecondary,
marginRight: 32,
},
grandTotalValue: {
fontSize: 15,
fontFamily: 'Helvetica-Bold',
color: colors.accent,
minWidth: 100,
textAlign: 'right' as const,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
alignSelf: 'flex-start',
marginTop: 6,
},
statusText: {
fontSize: 9,
fontFamily: 'Helvetica-Bold',
letterSpacing: 0.5,
color: colors.white,
},
paymentBox: {
backgroundColor: colors.surface,
borderRadius: 8,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
marginBottom: 24,
},
paymentRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
paymentKey: {
fontSize: 9,
color: colors.textMuted,
},
paymentValue: {
fontSize: 9,
color: colors.textSecondary,
fontFamily: 'Helvetica-Bold',
maxWidth: 280,
textAlign: 'right' as const,
},
footer: {
position: 'absolute',
bottom: 32,
left: 48,
right: 48,
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 12,
flexDirection: 'row',
justifyContent: 'space-between',
},
footerText: {
fontSize: 8,
color: colors.textMuted,
},
})
function fmt(amount: number, currency: string) {
return `${(amount / 100).toFixed(2)} ${currency}`
}
function fmtDate(iso: string | null | undefined) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })
}
function formatAddress(address: any): string {
if (!address) return ''
if (typeof address === 'string') return address
const parts = [address.street, address.city, address.region, address.country, address.zip]
return parts.filter(Boolean).join(', ')
}
function InvoiceDocument({ data }: { data: InvoiceData }) {
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
const addressStr = formatAddress(data.company.address)
const planLabel = PLAN_LABEL[data.subscription.plan] ?? data.subscription.plan
const periodLabel = PERIOD_LABEL[data.subscription.billingPeriod] ?? data.subscription.billingPeriod
const description = `${planLabel} Plan — ${periodLabel} Subscription`
const periodStr = data.subscription.currentPeriodStart && data.subscription.currentPeriodEnd
? `${fmtDate(data.subscription.currentPeriodStart)} ${fmtDate(data.subscription.currentPeriodEnd)}`
: '—'
return React.createElement(
Document,
{ author: 'RentalDriveGo', title: `Invoice ${data.invoiceNumber}`, subject: 'Subscription Invoice' },
React.createElement(
Page,
{ size: 'A4', style: s.page },
// ── Header ──────────────────────────────────────────────────
React.createElement(
View,
{ style: s.header },
React.createElement(
View,
null,
React.createElement(Text, { style: s.brandName }, 'RentalDriveGo'),
React.createElement(Text, { style: s.brandTagline }, 'Car Rental Management Platform'),
),
React.createElement(
View,
{ style: s.invoiceMeta },
React.createElement(Text, { style: s.invoiceTitle }, 'INVOICE'),
React.createElement(Text, { style: s.invoiceNumber }, data.invoiceNumber),
React.createElement(
View,
{ style: [s.statusBadge, { backgroundColor: statusColor }] },
React.createElement(Text, { style: s.statusText }, data.status),
),
),
),
// ── Bill To / Invoice Dates ──────────────────────────────────
React.createElement(
View,
{ style: s.row },
React.createElement(
View,
{ style: s.col },
React.createElement(Text, { style: s.colLabel }, 'Bill To'),
React.createElement(Text, { style: s.companyName }, data.company.name),
React.createElement(Text, { style: s.companyDetail }, data.company.email),
data.company.phone
? React.createElement(Text, { style: s.companyDetail }, data.company.phone)
: null,
addressStr
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, addressStr)
: null,
),
React.createElement(
View,
{ style: s.col },
React.createElement(Text, { style: s.colLabel }, 'Invoice Details'),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Invoice Number'),
React.createElement(Text, { style: s.metaValue }, data.invoiceNumber),
),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Issue Date'),
React.createElement(Text, { style: s.metaValue }, fmtDate(data.issueDate)),
),
data.dueDate
? React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Due Date'),
React.createElement(Text, { style: s.metaValue }, fmtDate(data.dueDate)),
)
: null,
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Currency'),
React.createElement(Text, { style: s.metaValue }, data.currency),
),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Billing Period'),
React.createElement(Text, { style: s.metaValue }, periodLabel),
),
React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Plan'),
React.createElement(Text, { style: s.metaValue }, planLabel),
),
),
),
// ── Line Items ───────────────────────────────────────────────
React.createElement(
View,
{ style: s.table },
React.createElement(
View,
{ style: s.tableHeader },
React.createElement(Text, { style: [s.tableHeaderCell, s.col60] }, 'Description'),
React.createElement(Text, { style: [s.tableHeaderCell, s.col20Center] }, 'Period'),
React.createElement(Text, { style: [s.tableHeaderCell, s.col20] }, 'Amount'),
),
React.createElement(
View,
{ style: s.tableRow },
React.createElement(Text, { style: [s.tableCell, s.col60] }, description),
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr),
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(data.amount, data.currency)),
),
),
// ── Totals ───────────────────────────────────────────────────
React.createElement(
View,
{ style: s.totalRow },
React.createElement(Text, { style: s.totalLabel }, 'Subtotal'),
React.createElement(Text, { style: s.totalValue }, fmt(data.amount, data.currency)),
),
React.createElement(
View,
{ style: s.grandTotalRow },
React.createElement(Text, { style: s.grandTotalLabel }, 'Total Due'),
React.createElement(Text, { style: s.grandTotalValue }, fmt(data.amount, data.currency)),
),
// ── Payment Info ─────────────────────────────────────────────
React.createElement(
View,
{ style: [s.paymentBox, { marginTop: 24 }] },
React.createElement(Text, { style: [s.colLabel, { marginBottom: 10 }] }, 'Payment Information'),
React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Payment Provider'),
React.createElement(Text, { style: s.paymentValue }, data.paymentProvider),
),
data.transactionId
? React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Transaction ID'),
React.createElement(Text, { style: s.paymentValue }, data.transactionId),
)
: null,
React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Payment Date'),
React.createElement(Text, { style: s.paymentValue }, fmtDate(data.paidAt)),
),
React.createElement(
View,
{ style: s.paymentRow },
React.createElement(Text, { style: s.paymentKey }, 'Status'),
React.createElement(Text, { style: [s.paymentValue, { color: statusColor }] }, data.status),
),
),
// ── Footer ───────────────────────────────────────────────────
React.createElement(
View,
{ style: s.footer },
React.createElement(Text, { style: s.footerText }, 'RentalDriveGo — Car Rental Management Platform'),
React.createElement(Text, { style: s.footerText }, `Generated on ${fmtDate(new Date().toISOString())}`),
),
),
)
}
export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
const doc = React.createElement(InvoiceDocument, { data })
const buffer = await renderToBuffer(doc as any)
return buffer as unknown as Buffer
}
export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
const year = createdAt.getFullYear()
const short = invoiceId.slice(-6).toUpperCase()
return `INV-${year}-${short}`
}
@@ -252,3 +252,37 @@ export async function sendNotification(opts: SendNotificationOptions) {
return results
}
export async function sendTransactionalEmail(opts: {
to: string
subject: string
html: string
text: string
}) {
if (resend) {
if (!emailFromAddress || !emailFromName) throw new Error('Email sender identity is not configured')
const { error } = await resend.emails.send({
from: `${emailFromName} <${emailFromAddress}>`,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
})
if (error) throw new Error(error.message)
return
}
if (smtpTransport) {
if (!smtpFromAddress) throw new Error('SMTP sender identity is not configured')
await smtpTransport.sendMail({
from: smtpFromName ? `${smtpFromName} <${smtpFromAddress}>` : smtpFromAddress,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
})
return
}
throw new Error('No email provider is configured')
}
+63 -64
View File
@@ -1,6 +1,9 @@
import crypto from 'crypto'
import { EmployeeRole } from '@rentaldrivego/database'
import { clerkClient } from '@clerk/clerk-sdk-node'
import { prisma } from '../lib/prisma'
import { sendTransactionalEmail } from './notificationService'
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
export interface InvitePayload {
firstName: string
@@ -24,24 +27,33 @@ export interface TeamMember {
invitationStatus?: 'accepted' | 'pending' | 'revoked'
}
function buildInviteEmail(resetUrl: string, companyName: string, firstName: string, role: EmployeeRole) {
const greetingName = firstName.trim() || 'there'
return {
subject: `You have been invited to ${companyName}`,
html: `
<p>Hi ${greetingName},</p>
<p>You were invited to join ${companyName} on RentalDriveGo as a ${role.toLowerCase()}.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Set your password</a></p>
<p>This invitation link expires in 7 days.</p>
<p>RentalDriveGo</p>
`,
text: `Hi ${greetingName},\n\nYou were invited to join ${companyName} on RentalDriveGo as a ${role.toLowerCase()}.\n\nSet your password here: ${resetUrl}\n\nThis invitation link expires in 7 days.\n\nRentalDriveGo`,
}
}
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
const employees = await prisma.employee.findMany({
where: { companyId },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
})
const hydrated = await Promise.allSettled(
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 }
} catch {
return { ...emp, lastActiveAt: null, invitationStatus: 'pending' as const }
}
})
)
return hydrated.flatMap((r): TeamMember[] => (r.status === 'fulfilled' ? [r.value] : []))
return employees.map((employee) => ({
...employee,
lastActiveAt: null,
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
}))
}
export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) {
@@ -54,39 +66,53 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' })
}
const company = await prisma.company.findUniqueOrThrow({
where: { id: companyId },
include: { brand: { select: { subdomain: true, displayName: true } } },
})
let clerkInvitation: any
try {
clerkInvitation = await clerkClient.invitations.createInvitation({
emailAddress: payload.email,
redirectUrl: `${process.env.DASHBOARD_URL}/onboarding/accept-invite`,
publicMetadata: { companyId, companyName: company.brand?.displayName ?? company.name, role: payload.role, invitedBy: inviterId },
})
} catch (err: any) {
if (err?.errors?.[0]?.code === 'duplicate_record') {
throw Object.assign(new Error('This email already has a pending invitation'), { statusCode: 409, code: 'duplicate_invitation' })
}
throw err
}
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
const employee = await prisma.employee.create({
data: { companyId, clerkUserId: `pending_${clerkInvitation.id}`, firstName: payload.firstName, lastName: payload.lastName, email: payload.email, role: payload.role, isActive: false },
data: {
companyId,
clerkUserId: `local_member_${crypto.randomUUID()}`,
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
role: payload.role,
isActive: true,
passwordResetToken: rawToken,
passwordResetExpiresAt: expiresAt,
},
})
return { employee, invitationId: clerkInvitation.id }
const dashboardUrl = process.env.DASHBOARD_URL ?? 'http://localhost:3001'
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
const message = buildInviteEmail(resetUrl, company.name, payload.firstName, payload.role)
await sendTransactionalEmail({
to: payload.email,
subject: message.subject,
html: message.html,
text: message.text,
})
return {
employee: {
...employee,
lastActiveAt: null,
invitationStatus: 'pending' as const,
},
invitationId: employee.id,
invitedBy: inviterId,
}
}
export async function updateEmployeeRole(companyId: string, requesterId: string, requesterRole: EmployeeRole, employeeId: string, payload: { role: EmployeeRole }) {
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can change team member roles'), { statusCode: 403, code: 'forbidden' })
if (target.role === 'OWNER') throw Object.assign(new Error("Cannot change the role of the account owner"), { statusCode: 400 })
if (payload.role === 'OWNER') throw Object.assign(new Error("Cannot assign the OWNER role via this endpoint"), { statusCode: 400 })
if (target.id === requesterId) throw Object.assign(new Error("You cannot change your own role"), { statusCode: 400 })
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot change the role of the account owner'), { statusCode: 400 })
if (payload.role === 'OWNER') throw Object.assign(new Error('Cannot assign the OWNER role via this endpoint'), { statusCode: 400 })
if (target.id === requesterId) throw Object.assign(new Error('You cannot change your own role'), { statusCode: 400 })
return prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } })
}
@@ -96,20 +122,12 @@ export async function deactivateEmployee(companyId: string, requesterRole: Emplo
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot deactivate the account owner'), { statusCode: 400 })
if (!target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.users.banUser(target.clerkUserId) } catch { /* non-fatal */ }
}
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } })
}
export async function reactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can reactivate team members'), { statusCode: 403 })
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (!target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.users.unbanUser(target.clerkUserId) } catch { /* non-fatal */ }
}
await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } })
}
@@ -119,25 +137,6 @@ export async function removeEmployee(companyId: string, requesterRole: EmployeeR
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot remove the account owner'), { statusCode: 400 })
if (target.clerkUserId.startsWith('pending_')) {
try { await clerkClient.invitations.revokeInvitation(target.clerkUserId.replace('pending_', '')) } catch { /* expired */ }
} else {
try { await clerkClient.users.banUser(target.clerkUserId) } catch { /* non-fatal */ }
}
await prisma.employee.delete({ where: { id: employeeId } })
return { success: true }
}
export async function handleInvitationAccepted(clerkUserId: string, email: string, companyId: string, role: EmployeeRole) {
const pendingEmployee = await prisma.employee.findFirst({
where: { companyId, email, clerkUserId: { startsWith: 'pending_' } },
})
if (!pendingEmployee) return null
return prisma.employee.update({
where: { id: pendingEmployee.id },
data: { clerkUserId, isActive: true, role },
})
}
+31 -1
View File
@@ -1,7 +1,37 @@
/** @type {import('next').NextConfig} */
const toStoragePattern = (rawUrl) => {
try {
const u = new URL(rawUrl.replace(/\/api\/v1\/?$/, ''))
return {
protocol: u.protocol.replace(':', ''),
hostname: u.hostname,
...(u.port ? { port: u.port } : {}),
pathname: '/storage/**',
}
} catch {
return null
}
}
const storagePatterns = [
process.env.API_INTERNAL_URL ?? 'http://localhost:4000',
process.env.API_URL,
process.env.NEXT_PUBLIC_API_URL,
]
.filter(Boolean)
.map(toStoragePattern)
.filter(Boolean)
.filter((p, i, arr) => arr.findIndex((q) => q.hostname === p.hostname && q.port === p.port) === i)
const nextConfig = {
images: {
domains: ['res.cloudinary.com'],
remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
},
...storagePatterns,
],
},
transpilePackages: ['@rentaldrivego/types'],
}
+7 -7
View File
@@ -10,18 +10,18 @@
},
"dependencies": {
"@rentaldrivego/types": "*",
"@clerk/nextjs": "^5.0.0",
"autoprefixer": "^10.4.19",
"dayjs": "^1.11.11",
"lucide-react": "^0.376.0",
"next": "14.2.3",
"postcss": "^8.4.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.3",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"zod": "^3.23.0",
"dayjs": "^1.11.11",
"recharts": "^2.12.7",
"sharp": "^0.34.5",
"socket.io-client": "^4.7.5",
"lucide-react": "^0.376.0"
"tailwindcss": "^3.4.3",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.12.0",
@@ -1,7 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import type { ApiPaginated } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
interface CustomerRow {
@@ -19,8 +18,8 @@ export default function CustomersPage() {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
apiFetch<ApiPaginated<CustomerRow>>('/customers?pageSize=100')
.then((result) => setRows(result.data))
apiFetch<CustomerRow[]>('/customers?pageSize=100')
.then((result) => setRows(result ?? []))
.catch((err) => setError(err.message))
}, [])
@@ -1,8 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Image from 'next/image'
import { ArrowLeft, Edit, X, Check, Upload, Trash2 } from 'lucide-react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
@@ -18,15 +19,78 @@ interface VehicleDetail {
transmission: string
fuelType: string
seats: number
mileage: number | null
licensePlate: string
photos: string[]
notes: string | null
isPublished: boolean
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
const MODELS_BY_MAKE: Record<string, string[]> = {
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
function initMakeSelect(make: string) {
return PRESET_MAKES.includes(make) ? make : (make ? 'custom' : '')
}
function initModelSelect(make: string, model: string) {
const presets = MODELS_BY_MAKE[make]
if (!presets) return model ? 'custom' : ''
return presets.includes(model) ? model : (model ? 'custom' : '')
}
function initColorSelect(color: string) {
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
}
export default function FleetDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
// edit form state
const [form, setForm] = useState<{
make: string; model: string; year: number; category: string
dailyRate: string; licensePlate: string; color: string
seats: number; transmission: string; fuelType: string
status: string; notes: string; mileage: string
} | null>(null)
const [makeSelect, setMakeSelect] = useState('')
const [modelSelect, setModelSelect] = useState('')
const [colorSelect, setColorSelect] = useState('')
// photo management
const [deletingPhotoIdx, setDeletingPhotoIdx] = useState<number | null>(null)
const [newPhotoFiles, setNewPhotoFiles] = useState<File[]>([])
const [newPhotoPreviews, setNewPhotoPreviews] = useState<string[]>([])
const [uploadingPhotos, setUploadingPhotos] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
apiFetch<VehicleDetail>(`/vehicles/${params.id}`)
@@ -34,38 +98,400 @@ export default function FleetDetailPage() {
.catch((err) => setError(err.message))
}, [params.id])
const startEdit = () => {
if (!vehicle) return
setForm({
make: vehicle.make,
model: vehicle.model,
year: vehicle.year,
category: vehicle.category,
dailyRate: (vehicle.dailyRate / 100).toFixed(2),
licensePlate: vehicle.licensePlate,
color: vehicle.color ?? '',
seats: vehicle.seats,
transmission: vehicle.transmission,
fuelType: vehicle.fuelType,
status: vehicle.status,
notes: vehicle.notes ?? '',
mileage: vehicle.mileage != null ? String(vehicle.mileage) : '',
})
setMakeSelect(initMakeSelect(vehicle.make))
setModelSelect(initModelSelect(vehicle.make, vehicle.model))
setColorSelect(initColorSelect(vehicle.color ?? ''))
setSaveError(null)
setEditing(true)
}
const cancelEdit = () => {
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
setNewPhotoFiles([])
setNewPhotoPreviews([])
setEditing(false)
setForm(null)
setSaveError(null)
}
const handleSave = async () => {
if (!form || !vehicle) return
if (!form.make) { setSaveError('Make is required.'); return }
if (!form.model) { setSaveError('Model is required.'); return }
if (!form.licensePlate) { setSaveError('License plate is required.'); return }
const rate = parseFloat(form.dailyRate)
if (isNaN(rate) || rate < 0) { setSaveError('Please enter a valid daily rate.'); return }
setSaving(true)
setSaveError(null)
try {
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}`, {
method: 'PATCH',
body: JSON.stringify({
make: form.make,
model: form.model,
year: Number(form.year),
category: form.category,
dailyRate: Math.round(rate * 100),
licensePlate: form.licensePlate,
color: form.color,
seats: Number(form.seats),
transmission: form.transmission,
fuelType: form.fuelType,
status: form.status,
notes: form.notes || undefined,
mileage: form.mileage ? Number(form.mileage) : undefined,
}),
})
let finalVehicle = updated
if (newPhotoFiles.length > 0) {
setUploadingPhotos(true)
const fd = new FormData()
newPhotoFiles.forEach((f) => fd.append('photos', f))
finalVehicle = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
setNewPhotoFiles([])
setNewPhotoPreviews([])
setUploadingPhotos(false)
}
setVehicle(finalVehicle)
setEditing(false)
setForm(null)
} catch (err: any) {
setSaveError(err.message ?? 'Failed to save changes.')
setUploadingPhotos(false)
} finally {
setSaving(false)
}
}
const handleDeletePhoto = async (idx: number) => {
if (!vehicle) return
setDeletingPhotoIdx(idx)
try {
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos/${idx}`, { method: 'DELETE' })
setVehicle(updated)
} catch (err: any) {
alert(err.message)
} finally {
setDeletingPhotoIdx(null)
}
}
const handleNewPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const totalExisting = (vehicle?.photos.length ?? 0)
const combined = [...newPhotoFiles, ...files].slice(0, 10 - totalExisting)
setNewPhotoFiles(combined)
setNewPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
e.target.value = ''
}
const removeNewPhoto = (i: number) => {
URL.revokeObjectURL(newPhotoPreviews[i])
setNewPhotoFiles(newPhotoFiles.filter((_, idx) => idx !== i))
setNewPhotoPreviews(newPhotoPreviews.filter((_, idx) => idx !== i))
}
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>
const presetModels = form && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
const totalPhotos = vehicle.photos.length + newPhotoFiles.length
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>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
<p className="text-sm text-slate-500 mt-0.5">{vehicle.year} · {vehicle.licensePlate} · {vehicle.status}</p>
</div>
</div>
{!editing ? (
<button onClick={startEdit} className="btn-secondary flex items-center gap-2">
<Edit className="w-4 h-4" /> Edit
</button>
) : (
<div className="flex gap-2">
<button onClick={cancelEdit} className="btn-secondary flex items-center gap-2">
<X className="w-4 h-4" /> Cancel
</button>
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
<Check className="w-4 h-4" />
{saving ? (uploadingPhotos ? 'Uploading photos…' : 'Saving…') : 'Save'}
</button>
</div>
)}
</div>
{saveError && (
<div className="px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{saveError}</div>
)}
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
{/* Photos */}
<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">
{/* Existing photos */}
{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 key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group">
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" unoptimized={!photo.includes('res.cloudinary.com')} priority={index === 0} />
{editing && (
<button
onClick={() => handleDeletePhoto(index)}
disabled={deletingPhotoIdx === index}
className="absolute top-2 right-2 p-1.5 bg-red-600 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-60"
>
{deletingPhotoIdx === index
? <div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
))}
{vehicle.photos.length === 0 && <div className="text-sm text-slate-400">No photos uploaded yet.</div>}
{/* New photo previews (edit mode) */}
{editing && newPhotoPreviews.map((src, i) => (
<div key={`new-${i}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group ring-2 ring-blue-400">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={`new photo ${i + 1}`} className="object-cover w-full h-full" />
<button
onClick={() => removeNewPhoto(i)}
className="absolute top-2 right-2 p-1.5 bg-black/60 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3.5 h-3.5" />
</button>
<span className="absolute bottom-2 left-2 text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full">New</span>
</div>
))}
{vehicle.photos.length === 0 && !editing && (
<div className="text-sm text-slate-400">No photos uploaded yet.</div>
)}
</div>
{/* Upload new photos (edit mode) */}
{editing && totalPhotos < 10 && (
<div className="mt-4">
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleNewPhotoChange} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Upload className="w-4 h-4" />
{totalPhotos === 0 ? 'Click to add photos' : `Add more photos (${totalPhotos}/10)`}
</button>
</div>
)}
{editing && totalPhotos >= 10 && (
<p className="mt-3 text-xs text-slate-400 text-center">Maximum 10 photos reached.</p>
)}
</div>
{/* Details / Edit form */}
<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>
{!editing || !form ? (
<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">Status</dt><dd className="text-slate-900">{vehicle.status}</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">Odometer</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div>
<div><dt className="text-slate-500">Notes</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
</dl>
) : (
<div className="space-y-4 text-sm">
{/* Make */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label>
<select
className="input-field"
value={makeSelect}
onChange={(e) => {
setMakeSelect(e.target.value)
setModelSelect('')
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">Select make</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
</select>
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type make…" value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
)}
</div>
{/* Model */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label>
{!presetModels ? (
<input className="input-field" placeholder="Model" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">Select model</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type model…" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
)}
</>
)}
</div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label>
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
</div>
<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', 'TRUCK'].map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
{/* Daily Rate & License Plate */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label>
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">License Plate *</label>
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
</div>
{/* Status */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Status</label>
<select className="input-field" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* Color & Seats */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label>
<select
className="input-field"
value={colorSelect}
onChange={(e) => {
setColorSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
else setForm({ ...form, color: '' })
}}
>
<option value="">Select color</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">Other</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type color…" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
</div>
</div>
{/* Transmission & Fuel */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Transmission</label>
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
<option value="MANUAL">Manual</option>
<option value="AUTOMATIC">Automatic</option>
</select>
</div>
<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'].map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
</div>
{/* Odometer */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Odometer (km)</label>
<input
type="number"
className="input-field"
placeholder="e.g. 45000"
min="0"
value={form.mileage}
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
/>
</div>
{/* Notes */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Notes</label>
<textarea
className="input-field resize-none"
rows={3}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Optional notes…"
/>
</div>
</div>
)}
</div>
</div>
</div>
@@ -1,13 +1,11 @@
'use client'
import { useEffect, useState } from 'react'
import { Plus, Edit, Eye, ChevronDown, Search } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { Plus, Edit, Eye, Search, Upload, X } from 'lucide-react'
import Image from 'next/image'
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
@@ -35,32 +33,106 @@ const STATUS_BADGE: Record<Vehicle['status'], string> = {
OUT_OF_SERVICE: 'badge-red',
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
const MODELS_BY_MAKE: Record<string, string[]> = {
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
const [form, setForm] = useState({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL', fuelType: 'GASOLINE',
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL',
fuelType: 'GASOLINE', mileage: '',
})
const [colorSelect, setColorSelect] = useState('')
const [makeSelect, setMakeSelect] = useState('')
const [modelSelect, setModelSelect] = useState('')
const [photoFiles, setPhotoFiles] = useState<File[]>([])
const [photoPreviews, setPhotoPreviews] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const combined = [...photoFiles, ...files].slice(0, 10)
setPhotoFiles(combined)
setPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
e.target.value = ''
}
const removePhoto = (index: number) => {
URL.revokeObjectURL(photoPreviews[index])
const files = photoFiles.filter((_, i) => i !== index)
const previews = photoPreviews.filter((_, i) => i !== index)
setPhotoFiles(files)
setPhotoPreviews(previews)
}
const reset = () => {
setForm({ make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY', dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL', fuelType: 'GASOLINE', mileage: '' })
setColorSelect('')
setMakeSelect('')
setModelSelect('')
photoPreviews.forEach((p) => URL.revokeObjectURL(p))
setPhotoFiles([])
setPhotoPreviews([])
setError(null)
}
const handleClose = () => { reset(); onClose() }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.make) { setError('Please select or enter a make.'); return }
if (!form.model) { setError('Please select or enter a model.'); return }
if (!form.licensePlate) { setError('Please enter a license plate.'); return }
if (!form.dailyRate || isNaN(parseFloat(form.dailyRate))) { setError('Please enter a valid daily rate.'); return }
setLoading(true)
setError(null)
try {
await apiFetch('/vehicles', {
const vehicle = await apiFetch<{ id: string }>('/vehicles', {
method: 'POST',
body: JSON.stringify({
...form,
dailyRate: Math.round(parseFloat(form.dailyRate) * 100),
year: Number(form.year),
seats: Number(form.seats),
mileage: form.mileage ? Number(form.mileage) : undefined,
}),
})
if (photoFiles.length > 0) {
const fd = new FormData()
photoFiles.forEach((f) => fd.append('photos', f))
await apiFetch(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
}
onSaved()
onClose()
handleClose()
} catch (err: any) {
setError(err.message)
setError(err.message ?? 'Failed to add vehicle. Please check all fields and try again.')
} finally {
setLoading(false)
}
@@ -68,21 +140,66 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
if (!open) return null
const presetModels = form.make && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto">
<h2 className="text-lg font-semibold text-slate-900 mb-5">Add New Vehicle</h2>
{error && (
<div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Make & Model */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label>
<input className="input-field" placeholder="Toyota" required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
<select
className="input-field"
value={makeSelect}
onChange={(e) => {
setMakeSelect(e.target.value)
setModelSelect('')
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">Select make</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
</select>
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type make…" required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} autoFocus />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label>
<input className="input-field" placeholder="Corolla" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
{!presetModels ? (
<input className="input-field" placeholder="Model" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">Select model</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type model…" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} autoFocus />
)}
</>
)}
</div>
</div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label>
@@ -97,6 +214,8 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
</select>
</div>
</div>
{/* Daily Rate & License Plate */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label>
@@ -107,10 +226,27 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
</div>
{/* Color, Seats, Transmission */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label>
<input className="input-field" placeholder="White" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
<select
className="input-field"
value={colorSelect}
onChange={(e) => {
setColorSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
else setForm({ ...form, color: '' })
}}
>
<option value="">Select</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">Other</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type color…" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} autoFocus />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
@@ -124,19 +260,73 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
</select>
</div>
</div>
<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'].map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
{/* Fuel Type & Odometer */}
<div className="grid grid-cols-2 gap-4">
<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'].map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Odometer (km)</label>
<input
type="number"
className="input-field"
placeholder="e.g. 45000"
min="0"
value={form.mileage}
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
/>
</div>
</div>
{error && (
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
{/* Photos */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Photos (up to 10)</label>
{photoPreviews.length > 0 && (
<div className="grid grid-cols-3 gap-2 mb-3">
{photoPreviews.map((src, i) => (
<div key={i} className="relative h-24 rounded-lg overflow-hidden bg-slate-100 group">
<Image src={src} alt={`preview ${i + 1}`} fill className="object-cover" unoptimized />
<button
type="button"
onClick={() => removePhoto(i)}
className="absolute top-1 right-1 p-0.5 bg-black/60 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{photoFiles.length < 10 && (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handlePhotoChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Upload className="w-4 h-4" />
{photoFiles.length === 0 ? 'Click to add photos' : 'Add more photos'}
</button>
</>
)}
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="btn-secondary flex-1">Cancel</button>
<button type="button" onClick={handleClose} className="btn-secondary flex-1">Cancel</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">
{loading ? 'Adding…' : 'Add Vehicle'}
</button>
@@ -159,8 +349,8 @@ export default function FleetPage() {
const fetchVehicles = () => {
setLoading(true)
apiFetch<ApiPaginated<Vehicle>>('/vehicles?pageSize=100')
.then((result) => setVehicles(result.data))
apiFetch<Vehicle[]>('/vehicles?pageSize=100')
.then((result) => setVehicles(result ?? []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
@@ -209,31 +399,19 @@ export default function FleetPage() {
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="input-field w-36"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<select className="input-field w-36" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">All statuses</option>
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
className="input-field w-36"
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<select className="input-field w-36" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
<option value="">All categories</option>
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<select
className="input-field w-36"
value={publishedFilter}
onChange={(e) => setPublishedFilter(e.target.value)}
>
<select className="input-field w-36" value={publishedFilter} onChange={(e) => setPublishedFilter(e.target.value)}>
<option value="">All visibility</option>
<option value="published">Published</option>
<option value="unpublished">Unpublished</option>
@@ -270,7 +448,7 @@ export default function FleetPage() {
<div className="flex items-center gap-3">
<div className="w-12 h-10 rounded-lg bg-slate-100 overflow-hidden flex-shrink-0">
{vehicle.primaryPhoto ? (
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" />
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" unoptimized={!vehicle.primaryPhoto.includes('res.cloudinary.com')} />
) : (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">No photo</div>
)}
@@ -0,0 +1,325 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import dayjs from 'dayjs'
import Link from 'next/link'
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react'
interface OnlineReservation {
id: string
status: 'DRAFT' | 'CONFIRMED' | 'CANCELLED' | 'ACTIVE' | 'COMPLETED' | 'NO_SHOW'
source: string
startDate: string
endDate: string
totalAmount: number
totalDays: number
dailyRate: number
notes: string | null
cancelReason: string | null
createdAt: string
vehicle: { make: string; model: string; year: number; licensePlate: string }
customer: { firstName: string; lastName: string; email: string; phone: string | null }
}
const STATUS_STYLE: Record<string, string> = {
DRAFT: 'bg-amber-100 text-amber-800',
CONFIRMED: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-rose-100 text-rose-700',
ACTIVE: 'bg-blue-100 text-blue-700',
COMPLETED: 'bg-slate-100 text-slate-600',
NO_SHOW: 'bg-orange-100 text-orange-700',
}
const STATUS_LABEL: Record<string, string> = {
DRAFT: 'Pending',
CONFIRMED: 'Confirmed',
CANCELLED: 'Declined',
ACTIVE: 'Active',
COMPLETED: 'Completed',
NO_SHOW: 'No show',
}
function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: string) => void; onCancel: () => void; loading: boolean }) {
const [reason, setReason] = useState('')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onCancel}>
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-base font-semibold text-slate-900">Decline reservation</h3>
<p className="mt-1 text-sm text-slate-500">Optionally provide a reason it will be visible in the reservation record.</p>
<textarea
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Vehicle not available for these dates"
className="mt-4 w-full rounded-xl border border-slate-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900 resize-none"
/>
<div className="mt-4 flex gap-3">
<button onClick={onCancel} className="flex-1 rounded-full border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700">Cancel</button>
<button
disabled={loading}
onClick={() => onConfirm(reason)}
className="flex-1 rounded-full bg-rose-600 px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-60"
>
{loading ? 'Declining…' : 'Decline'}
</button>
</div>
</div>
</div>
)
}
export default function OnlineReservationsPage() {
const [rows, setRows] = useState<OnlineReservation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [acting, setActing] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [declineTarget, setDeclineTarget] = useState<string | null>(null)
const load = useCallback(async () => {
try {
setLoading(true)
const result = await apiFetch<ApiPaginated<OnlineReservation>>('/reservations?source=MARKETPLACE&pageSize=100')
setRows(
(result.data ?? []).sort((a, b) => {
// DRAFT first, then by createdAt desc
if (a.status === 'DRAFT' && b.status !== 'DRAFT') return -1
if (a.status !== 'DRAFT' && b.status === 'DRAFT') return 1
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
})
)
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load reservations')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
async function confirm(id: string) {
setActing(id)
setActionError(null)
try {
await apiFetch(`/reservations/${id}/confirm`, { method: 'POST', body: JSON.stringify({}) })
await load()
} catch (err: any) {
setActionError(err.message ?? 'Failed to confirm reservation')
} finally {
setActing(null)
}
}
async function decline(id: string, reason: string) {
setActing(id)
setActionError(null)
try {
await apiFetch(`/reservations/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason: reason || undefined }) })
setDeclineTarget(null)
await load()
} catch (err: any) {
setActionError(err.message ?? 'Failed to decline reservation')
} finally {
setActing(null)
}
}
const pending = rows.filter((r) => r.status === 'DRAFT')
const history = rows.filter((r) => r.status !== 'DRAFT')
return (
<div className="space-y-8">
{declineTarget && (
<DeclineModal
loading={acting === declineTarget}
onConfirm={(reason) => decline(declineTarget, reason)}
onCancel={() => setDeclineTarget(null)}
/>
)}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-blue-600" />
<h2 className="text-xl font-semibold text-slate-900">Online Reservations</h2>
{pending.length > 0 && (
<span className="rounded-full bg-amber-500 px-2.5 py-0.5 text-xs font-bold text-white">{pending.length}</span>
)}
</div>
<p className="mt-1 text-sm text-slate-500">Requests submitted by customers through the marketplace. Confirm or decline each one.</p>
</div>
<button onClick={load} className="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">
Refresh
</button>
</div>
{actionError && (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{actionError}</div>
)}
{/* Pending section */}
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<Clock className="h-4 w-4" /> Pending approval
{pending.length > 0 && <span className="ml-1 text-amber-600">({pending.length})</span>}
</h3>
{loading ? (
<div className="card p-8 text-center text-sm text-slate-400">Loading</div>
) : pending.length === 0 ? (
<div className="card flex flex-col items-center gap-3 p-10 text-center">
<CheckCircle className="h-10 w-10 text-emerald-400" />
<p className="text-sm font-medium text-slate-600">All caught up no pending requests.</p>
</div>
) : (
<div className="space-y-4">
{pending.map((r) => (
<ReservationCard
key={r.id}
r={r}
acting={acting === r.id}
onConfirm={() => confirm(r.id)}
onDecline={() => setDeclineTarget(r.id)}
/>
))}
</div>
)}
</section>
{/* History section */}
{!loading && history.length > 0 && (
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
History
</h3>
<div className="card overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Customer</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Vehicle</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Dates</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Status</th>
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">Total</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{history.map((r) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-5 py-3.5">
<p className="text-sm font-medium text-slate-900">{r.customer.firstName} {r.customer.lastName}</p>
<p className="text-xs text-slate-500">{r.customer.email}</p>
</td>
<td className="px-5 py-3.5 text-sm text-slate-700">{r.vehicle.year} {r.vehicle.make} {r.vehicle.model}</td>
<td className="px-5 py-3.5 text-sm text-slate-500">
{dayjs(r.startDate).format('MMM D')} {dayjs(r.endDate).format('MMM D, YYYY')}
</td>
<td className="px-5 py-3.5">
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_STYLE[r.status] ?? 'bg-slate-100 text-slate-600'}`}>
{STATUS_LABEL[r.status] ?? r.status}
</span>
{r.cancelReason && (
<p className="mt-0.5 text-xs text-slate-400 truncate max-w-[160px]" title={r.cancelReason}>{r.cancelReason}</p>
)}
</td>
<td className="px-5 py-3.5 text-right text-sm font-semibold text-slate-900">
{formatCurrency(r.totalAmount, 'MAD')}
</td>
<td className="px-5 py-3.5">
<Link href={`/dashboard/reservations/${r.id}`} className="text-slate-400 hover:text-slate-700">
<ChevronRight className="h-4 w-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
)
}
function ReservationCard({ r, acting, onConfirm, onDecline }: {
r: OnlineReservation
acting: boolean
onConfirm: () => void
onDecline: () => void
}) {
const days = r.totalDays
return (
<div className="card overflow-hidden border-l-4 border-l-amber-400">
<div className="p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
{/* Left: customer + vehicle + dates */}
<div className="space-y-3 min-w-0">
<div className="flex items-center gap-2">
<User className="h-4 w-4 flex-shrink-0 text-slate-400" />
<div>
<p className="text-sm font-semibold text-slate-900">{r.customer.firstName} {r.customer.lastName}</p>
<p className="text-xs text-slate-500">{r.customer.email}{r.customer.phone ? ` · ${r.customer.phone}` : ''}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Car className="h-4 w-4 flex-shrink-0 text-slate-400" />
<p className="text-sm text-slate-700">{r.vehicle.year} {r.vehicle.make} {r.vehicle.model} <span className="text-slate-400">· {r.vehicle.licensePlate}</span></p>
</div>
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 flex-shrink-0 text-slate-400" />
<p className="text-sm text-slate-700">
{dayjs(r.startDate).format('MMM D, YYYY')} {dayjs(r.endDate).format('MMM D, YYYY')}
<span className="ml-2 text-slate-400">{days} day{days > 1 ? 's' : ''}</span>
</p>
</div>
{r.notes && (
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 flex-shrink-0 text-slate-400 mt-0.5" />
<p className="text-sm text-slate-600 italic">"{r.notes}"</p>
</div>
)}
</div>
{/* Right: total + actions */}
<div className="flex flex-col items-end gap-3 flex-shrink-0">
<div className="text-right">
<p className="text-xs text-slate-400">Estimated total</p>
<p className="text-xl font-bold text-slate-900">{formatCurrency(r.totalAmount, 'MAD')}</p>
<p className="text-xs text-slate-400">{formatCurrency(r.dailyRate, 'MAD')}/day</p>
</div>
<div className="flex gap-2">
<button
onClick={onDecline}
disabled={acting}
className="flex items-center gap-1.5 rounded-full border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
<XCircle className="h-4 w-4" />
Decline
</button>
<button
onClick={onConfirm}
disabled={acting}
className="flex items-center gap-1.5 rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
<CheckCircle className="h-4 w-4" />
{acting ? 'Confirming…' : 'Confirm'}
</button>
</div>
<p className="text-xs text-slate-400">Received {dayjs(r.createdAt).format('MMM D [at] HH:mm')}</p>
</div>
</div>
</div>
<div className="border-t border-slate-100 bg-slate-50 px-5 py-2.5 flex items-center justify-between">
<p className="text-xs text-slate-400">Ref: {r.id.slice(-10).toUpperCase()}</p>
<Link href={`/dashboard/reservations/${r.id}`} className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-700">
View full detail <ChevronRight className="h-3 w-3" />
</Link>
</div>
</div>
)
}
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
interface ReportRow {
reservationId: string
@@ -55,7 +55,7 @@ export default function ReportsPage() {
setExporting(true)
setError(null)
try {
const token = (window as any).__clerk?.session ? await (window as any).__clerk.session.getToken() : null
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
@@ -24,7 +24,7 @@ export default function ReservationsPage() {
useEffect(() => {
apiFetch<ApiPaginated<ReservationRow>>('/reservations?pageSize=100')
.then((result) => setRows(result.data))
.then((result) => setRows(result.data ?? []))
.catch((err) => setError(err.message))
}, [])
@@ -1,11 +1,11 @@
'use client'
import { useState, useMemo } from 'react'
import { useUser } from '@clerk/nextjs'
import { useEffect, useMemo, useState } from 'react'
import { useTeam, TeamMember } from '@/hooks/useTeam'
import InviteModal from '@/components/team/InviteModal'
import EditMemberModal from '@/components/team/EditMemberModal'
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
@@ -73,8 +73,8 @@ function StatusBadge({ member }: { member: TeamMember }) {
}
export default function TeamPage() {
const { user } = useUser()
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('')
@@ -83,7 +83,22 @@ export default function TeamPage() {
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
const [toast, setToast] = useState<string | null>(null)
const currentEmployee = members.find((member) => member.email === user?.primaryEmailAddress?.emailAddress)
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) return
try {
const encoded = token.split('.')[1] ?? ''
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const payload = JSON.parse(atob(padded))
setCurrentEmployeeId(typeof payload?.sub === 'string' ? payload.sub : null)
} catch {
setCurrentEmployeeId(null)
}
}, [])
const currentEmployee = members.find((member) => member.id === currentEmployeeId)
const isOwner = currentEmployee?.role === 'OWNER'
function showToast(msg: string) {
@@ -247,7 +262,7 @@ export default function TeamPage() {
<div>
<div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName}
{member.email === user?.primaryEmailAddress?.emailAddress && (
{member.id === currentEmployeeId && (
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
)}
</div>
@@ -1,21 +1,22 @@
import Sidebar from '@/components/layout/Sidebar'
import TopBar from '@/components/layout/TopBar'
import { DashboardLanguageSwitcher } from '@/components/I18nProvider'
import { DashboardLanguageSwitcher, DashboardThemeSwitcher } from '@/components/I18nProvider'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-slate-50">
<div className="flex min-h-screen bg-slate-50 transition-colors dark:bg-slate-950">
<Sidebar />
<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>
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors">
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90">
<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">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-slate-500">
Workspace preferences
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<DashboardLanguageSwitcher />
<DashboardThemeSwitcher />
</div>
</div>
</footer>
@@ -0,0 +1,129 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
export default function ForgotPasswordPage() {
const { language } = useDashboardI18n()
const dict = {
en: {
title: 'Forgot your password?',
subtitle: "Enter your work email and we'll send you a reset link.",
email: 'Email',
submit: 'Send reset link',
submitting: 'Sending…',
backToLogin: 'Back to sign in',
successTitle: 'Check your email',
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
error: 'Something went wrong. Please try again.',
},
fr: {
title: 'Mot de passe oublié ?',
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
email: 'Email',
submit: 'Envoyer le lien',
submitting: 'Envoi…',
backToLogin: 'Retour à la connexion',
successTitle: 'Vérifiez votre email',
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
error: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
title: 'نسيت كلمة المرور؟',
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
email: 'البريد الإلكتروني',
submit: 'إرسال رابط الإعادة',
submitting: 'جارٍ الإرسال…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تحقق من بريدك الإلكتروني',
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/auth/employee/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
if (!res.ok) throw new Error()
setSent(true)
} catch {
setError(dict.error)
} finally {
setLoading(false)
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">
RentalDriveGo
</Link>
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.title}</h1>
<p className="mt-2 text-sm text-slate-500">{dict.subtitle}</p>
</div>
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
{sent ? (
<div className="space-y-4 text-center">
<div className="mx-auto 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>
<h2 className="text-xl font-bold text-slate-900">{dict.successTitle}</h2>
<p className="text-sm text-slate-500">{dict.successBody}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com"
className="input-field"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.submitting : dict.submit}
</button>
</form>
)}
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
{dict.backToLogin}
</Link>
</div>
</section>
</div>
</main>
)
}
+58 -4
View File
@@ -2,6 +2,14 @@
@tailwind components;
@tailwind utilities;
html.light {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
@@ -17,7 +25,7 @@
}
body {
@apply bg-slate-50 text-slate-900 antialiased;
@apply bg-slate-50 text-slate-900 antialiased transition-colors dark:bg-slate-950 dark:text-slate-100;
}
h1, h2, h3, h4, h5, h6 {
@@ -31,7 +39,7 @@
}
.btn-secondary {
@apply inline-flex items-center gap-2 px-4 py-2 bg-white hover:bg-slate-50 text-slate-700 text-sm font-medium rounded-lg border border-slate-200 transition-colors;
@apply inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800;
}
.btn-danger {
@@ -39,11 +47,11 @@
}
.input-field {
@apply w-full px-3 py-2 text-sm border border-slate-200 rounded-lg bg-white text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors;
@apply w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 transition-colors placeholder:text-slate-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500;
}
.card {
@apply bg-white border border-slate-200 rounded-xl shadow-sm;
@apply rounded-xl border border-slate-200 bg-white shadow-sm transition-colors dark:border-slate-800 dark:bg-slate-900;
}
.badge-green {
@@ -70,3 +78,49 @@
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700;
}
}
@layer utilities {
.dark .bg-slate-50 {
background-color: rgb(2 6 23);
}
.dark .bg-white {
background-color: rgb(15 23 42);
}
.dark .bg-white\/90 {
background-color: rgb(15 23 42 / 0.9);
}
.dark .border-slate-200,
.dark .border-stone-200 {
border-color: rgb(30 41 59);
}
.dark .text-slate-900 {
color: rgb(241 245 249);
}
.dark .text-slate-700 {
color: rgb(203 213 225);
}
.dark .text-slate-600,
.dark .text-stone-500 {
color: rgb(148 163 184);
}
.dark .text-slate-500,
.dark .text-slate-400,
.dark .text-stone-400 {
color: rgb(100 116 139);
}
.dark .hover\:bg-slate-100:hover {
background-color: rgb(30 41 59);
}
.dark .hover\:text-slate-900:hover {
color: rgb(248 250 252);
}
}
+10 -6
View File
@@ -1,6 +1,4 @@
import type { Metadata } from 'next'
import { ClerkProvider } from '@clerk/nextjs'
import { clerkFrontendEnabled } from '@/lib/clerk'
import { DashboardI18nProvider } from '@/components/I18nProvider'
import './globals.css'
@@ -10,13 +8,19 @@ export const metadata: Metadata = {
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
const content = (
<html lang="en">
return (
<html lang="en" className="light" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var theme=localStorage.getItem('dashboard-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme}catch(e){}})();",
}}
/>
</head>
<body className="font-sans">
<DashboardI18nProvider>{children}</DashboardI18nProvider>
</body>
</html>
)
return clerkFrontendEnabled ? <ClerkProvider>{content}</ClerkProvider> : content
}
@@ -0,0 +1,202 @@
'use client'
import Link from 'next/link'
import { useState, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
export default function ResetPasswordPage() {
return (
<Suspense>
<ResetPasswordContent />
</Suspense>
)
}
function ResetPasswordContent() {
const { language } = useDashboardI18n()
const dict = {
en: {
title: 'Set new password',
subtitle: 'Choose a strong password for your workspace account.',
newPassword: 'New password',
confirmPassword: 'Confirm password',
submit: 'Reset password',
submitting: 'Resetting…',
backToLogin: 'Back to sign in',
successTitle: 'Password updated',
successBody: 'Your password has been reset. You can now sign in with your new password.',
signIn: 'Sign in',
errorMismatch: 'Passwords do not match.',
errorShort: 'Password must be at least 8 characters.',
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
errorGeneric: 'Something went wrong. Please try again.',
noToken: 'No reset token found. Please request a new password reset.',
requestNew: 'Request new reset link',
},
fr: {
title: 'Nouveau mot de passe',
subtitle: "Choisissez un mot de passe fort pour votre compte.",
newPassword: 'Nouveau mot de passe',
confirmPassword: 'Confirmer le mot de passe',
submit: 'Réinitialiser',
submitting: 'Traitement…',
backToLogin: 'Retour à la connexion',
successTitle: 'Mot de passe mis à jour',
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
signIn: 'Se connecter',
errorMismatch: 'Les mots de passe ne correspondent pas.',
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
noToken: 'Aucun jeton trouvé. Veuillez demander une réinitialisation.',
requestNew: 'Demander un nouveau lien',
},
ar: {
title: 'تعيين كلمة مرور جديدة',
subtitle: 'اختر كلمة مرور قوية لحساب مساحة العمل.',
newPassword: 'كلمة المرور الجديدة',
confirmPassword: 'تأكيد كلمة المرور',
submit: 'إعادة تعيين',
submitting: 'جارٍ المعالجة…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تم تحديث كلمة المرور',
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
signIn: 'تسجيل الدخول',
errorMismatch: 'كلمتا المرور غير متطابقتين.',
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
noToken: 'لم يتم العثور على رمز. يرجى طلب إعادة تعيين.',
requestNew: 'طلب رابط جديد',
},
}[language]
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (password.length < 8) { setError(dict.errorShort); return }
if (password !== confirm) { setError(dict.errorMismatch); return }
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
})
const json = await res.json()
if (!res.ok) {
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken)
throw new Error(dict.errorGeneric)
}
setDone(true)
} catch (err: any) {
setError(err.message ?? dict.errorGeneric)
} finally {
setLoading(false)
}
}
if (!token) {
return (
<ResetShell title={dict.title}>
<p className="text-sm text-slate-500 text-center">{dict.noToken}</p>
<div className="mt-4 text-center">
<Link href="/forgot-password" className="btn-primary inline-flex">{dict.requestNew}</Link>
</div>
</ResetShell>
)
}
if (done) {
return (
<ResetShell title={dict.title}>
<div className="space-y-4 text-center">
<div className="mx-auto 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>
<h2 className="text-xl font-bold text-slate-900">{dict.successTitle}</h2>
<p className="text-sm text-slate-500">{dict.successBody}</p>
<Link href="/sign-in" className="btn-primary inline-flex justify-center">{dict.signIn}</Link>
</div>
</ResetShell>
)
}
return (
<ResetShell title={dict.title} subtitle={dict.subtitle}>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.newPassword}</label>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.confirmPassword}</label>
<input
type="password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
className="input-field"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.submitting : dict.submit}
</button>
</form>
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
{dict.backToLogin}
</Link>
</div>
</ResetShell>
)
}
function ResetShell({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</span>
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{title}</h1>
{subtitle && <p className="mt-2 text-sm text-slate-500">{subtitle}</p>}
</div>
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
{children}
</section>
</div>
</main>
)
}
@@ -1,46 +1,82 @@
'use client'
import Link from 'next/link'
import { SignIn } from '@clerk/nextjs'
import { clerkFrontendEnabled } from '@/lib/clerk'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const ADMIN_URL = process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002'
export default function SignInPage() {
const { language } = useDashboardI18n()
const searchParams = useSearchParams()
const portal = searchParams.get('portal') === 'admin' ? 'admin' : 'workspace'
const dict = {
en: {
workspace: 'Company workspace',
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',
workspace: 'Sign in',
admin: 'Admin sign in',
subtitle: 'Enter your credentials to access your workspace.',
adminSubtitle: 'Use the same sign-in page for platform admin access.',
email: 'Email',
password: 'Password',
signIn: 'Sign in',
signingIn: 'Signing in…',
verify: 'Verify code',
verifying: 'Verifying…',
authCode: 'Authentication code',
enterCode: 'Enter the 6-digit code from your authenticator app.',
back: 'Back to credentials',
forgotPassword: 'Forgot your password?',
newAccount: 'New company account?',
createWorkspace: 'Create your workspace',
invalidCredentials: 'Invalid email or password.',
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
unexpectedError: 'Something went wrong. Please try again.',
},
fr: {
workspace: 'Espace entreprise',
access: 'Accès workspace',
signIn: 'Connexion',
useAccount: 'Utilisez votre compte propriétaire ou employé pour accéder à lespace entreprise.',
inactive: 'Saisissez les identifiants de votre espace pour continuer.',
newAccount: 'Nouveau compte entreprise ?',
createWorkspace: 'Créer votre espace',
workspace: 'Connexion',
admin: 'Connexion admin',
subtitle: 'Saisissez vos identifiants pour accéder à votre espace.',
adminSubtitle: 'Utilisez cette page unique pour accéder à la plateforme admin.',
email: 'Email',
password: 'Mot de passe',
signIn: 'Connexion',
signingIn: 'Connexion…',
verify: 'Vérifier le code',
verifying: 'Vérification…',
authCode: 'Code dauthentification',
enterCode: 'Entrez le code à 6 chiffres de votre application dauthentification.',
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
newAccount: 'Nouveau compte entreprise ?',
createWorkspace: 'Créer votre espace',
invalidCredentials: 'Email ou mot de passe invalide.',
passwordNotSet: 'Aucun mot de passe défini. Vérifiez votre e-mail d\'invitation ou utilisez « Mot de passe oublié ? »',
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
workspace: 'مساحة الشركة',
access: 'الوصول إلى المساحة',
signIn: 'تسجيل الدخول',
useAccount: 'استخدم حساب المالك أو الموظف للوصول إلى مساحة الشركة.',
inactive: 'أدخل بيانات مساحة العمل للمتابعة.',
newAccount: 'حساب شركة جديد؟',
createWorkspace: 'أنشئ مساحتك',
workspace: 'تسجيل الدخول',
admin: 'دخول الإدارة',
subtitle: 'أدخل بياناتك للوصول إلى مساحة العمل.',
adminSubtitle: 'استخدم صفحة الدخول نفسها للوصول إلى لوحة الإدارة.',
email: 'البريد الإلكتروني',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
verify: 'تحقق من الرمز',
verifying: 'جارٍ التحقق…',
authCode: 'رمز المصادقة',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟',
newAccount: 'حساب شركة جديد؟',
createWorkspace: 'أنشئ مساحتك',
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
@@ -51,61 +87,258 @@ export default function SignInPage() {
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">
RentalDriveGo
</Link>
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.workspace}</h1>
<p className="mt-2 text-sm text-slate-500">{dict.useAccount}</p>
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">
{portal === 'admin' ? dict.admin : dict.workspace}
</h1>
<p className="mt-2 text-sm text-slate-500">
{portal === 'admin' ? dict.adminSubtitle : dict.subtitle}
</p>
</div>
<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>
<LocalSignInForm dict={dict} portal={portal} />
<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>
{portal !== 'admin' ? (
<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>
) : null}
</section>
</div>
</main>
)
}
function ConfiguredSignIn() {
function LocalSignInForm({ dict, portal }: {
dict: {
email: string
password: string
signIn: string
signingIn: string
verify: string
verifying: string
authCode: string
enterCode: string
back: string
forgotPassword: string
invalidCredentials: string
passwordNotSet: string
unexpectedError: string
}
portal: 'admin' | 'workspace'
}) {
const router = useRouter()
const searchParams = useSearchParams()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const adminNext = searchParams.get('next') || '/dashboard'
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
const forgotPasswordHref = portal === 'admin' ? `${ADMIN_URL}/forgot-password` : '/forgot-password'
function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString()
window.location.href = `${ADMIN_URL}/auth-redirect#${hash}`
}
async function handleCredentials(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
if (portal === 'admin') {
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
setStep('totp')
return
}
setError(dict.invalidCredentials)
return
}
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const empJson = await empRes.json()
if (empRes.ok && empJson?.data?.token) {
const token = empJson.data.token
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
router.push(employeeRedirect)
return
}
if (empJson?.error === 'password_not_set') {
setError(dict.passwordNotSet)
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
} finally {
setLoading(false)
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, totpCode }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
} finally {
setLoading(false)
}
}
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>
<>
{error ? (
<div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
{step === 'credentials' ? (
<form onSubmit={handleCredentials} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="input-field pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-slate-400 hover:text-slate-600"
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.signingIn : dict.signIn}
</button>
<div className="text-center">
<Link href={forgotPasswordHref} className="text-sm text-slate-500 hover:text-slate-700 underline decoration-slate-300 underline-offset-4">
{dict.forgotPassword}
</Link>
</div>
</form>
) : (
<form onSubmit={handleTotp} className="space-y-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-center text-sm text-slate-600">
{dict.enterCode}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.authCode}</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
required
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
className="input-field text-center text-xl tracking-[0.45em]"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.verifying : dict.verify}
</button>
<button
type="button"
onClick={() => {
setStep('credentials')
setTotpCode('')
setError(null)
}}
className="w-full text-sm text-slate-500 hover:text-slate-700"
>
{dict.back}
</button>
</form>
)}
</>
)
}
@@ -3,10 +3,7 @@
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'
@@ -29,325 +26,23 @@ type SignupForm = {
type CompletedSignup = {
companyName: string
email: string
emailWarning?: string | null
}
type SignupResponse = {
companyId: string
companyName: string
slug: string
trialEndsAt: string | null
nextStep: string
emailDelivery?: {
attempted: boolean
success: boolean
error: string | null
} | null
}
export default function SignUpPage() {
return clerkFrontendEnabled ? <ConfiguredSignUpPage /> : <LocalSignUpPage />
}
function ConfiguredSignUpPage() {
const { language } = useDashboardI18n()
const searchParams = useSearchParams()
const dict = {
en: {
loading: 'Authentication is still loading. Try again in a moment.',
passwordShort: 'Choose a password with at least 8 characters.',
passwordMismatch: 'Passwords do not match.',
startFailed: 'Could not start account creation',
resendFailed: 'Could not resend the verification code',
completeFailed: 'Could not verify your email and finish setup',
working: 'Working…',
},
fr: {
loading: 'Lauthentification est en cours de chargement. Réessayez dans un instant.',
passwordShort: 'Choisissez un mot de passe dau moins 8 caractères.',
passwordMismatch: 'Les mots de passe ne correspondent pas.',
startFailed: 'Impossible de démarrer la création du compte',
resendFailed: 'Impossible de renvoyer le code de vérification',
completeFailed: 'Impossible de vérifier votre email et finaliser la configuration',
working: 'Traitement…',
},
ar: {
loading: 'المصادقة ما زالت قيد التحميل. حاول بعد لحظة.',
passwordShort: 'اختر كلمة مرور من 8 أحرف على الأقل.',
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
startFailed: 'تعذر بدء إنشاء الحساب',
resendFailed: 'تعذر إعادة إرسال رمز التحقق',
completeFailed: 'تعذر التحقق من البريد وإكمال الإعداد',
working: 'جارٍ التنفيذ…',
},
}[language]
const { isLoaded, signUp, setActive } = useSignUp()
const initialPlan = normalizePlan(searchParams.get('plan'))
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
const initialCurrency = normalizeCurrency(searchParams.get('currency'))
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [verificationCode, setVerificationCode] = useState('')
const [awaitingVerification, setAwaitingVerification] = useState(false)
const [completedSignup, setCompletedSignup] = useState<CompletedSignup | null>(null)
const [form, setForm] = useState<SignupForm>({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
companyName: '',
companyPhone: '',
city: '',
country: '',
plan: initialPlan,
billingPeriod: initialBillingPeriod,
currency: initialCurrency,
paymentProvider: 'AMANPAY',
})
async function submitSignup() {
if (form.password.length < 8) {
setError(dict.passwordShort)
return
}
if (form.password !== form.confirmPassword) {
setError(dict.passwordMismatch)
return
}
setLoading(true)
setError(null)
try {
if (!isLoaded || !signUp) {
setError(dict.loading)
return
}
await signUp.create({
emailAddress: form.email,
password: form.password,
firstName: form.firstName,
lastName: form.lastName,
unsafeMetadata: {
signupFlow: 'company-owner',
companyName: form.companyName,
companyPhone: form.companyPhone || null,
city: form.city || null,
country: form.country || null,
plan: form.plan,
billingPeriod: form.billingPeriod,
currency: form.currency,
paymentProvider: form.paymentProvider,
},
})
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
setAwaitingVerification(true)
} catch (err) {
setError(getClerkErrorMessage(err, dict.startFailed))
} finally {
setLoading(false)
}
}
async function resendVerificationCode() {
if (!signUp) return
setLoading(true)
setError(null)
try {
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
} catch (err) {
setError(getClerkErrorMessage(err, dict.resendFailed))
} finally {
setLoading(false)
}
}
async function completeSignup() {
if (!signUp || !setActive) {
setError(dict.loading)
return
}
setLoading(true)
setError(null)
try {
const result = await signUp.attemptEmailAddressVerification({ code: verificationCode })
if (result.status !== 'complete' || !result.createdSessionId) {
throw new Error('Email verification is not complete yet.')
}
await setActive({ session: result.createdSessionId })
await waitForActiveSession()
await apiFetch('/auth/company/complete-signup', {
method: 'POST',
})
setCompletedSignup({
companyName: form.companyName,
email: form.email,
})
} catch (err) {
setError(getClerkErrorMessage(err, dict.completeFailed))
} finally {
setLoading(false)
}
}
if (completedSignup) {
return (
<PublicShell>
<main className="flex-1 px-4 py-16">
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">Workspace ready</h1>
<p className="mt-4 text-base leading-7 text-slate-600">
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> is now active and the owner email
<span className="font-semibold text-slate-900"> {completedSignup.email}</span> has been registered.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link href="/dashboard" className="btn-primary justify-center">
Enter dashboard
</Link>
<Link href="/sign-in" className="btn-secondary justify-center">
Sign in on another device
</Link>
</div>
</div>
</main>
</PublicShell>
)
}
if (awaitingVerification) {
return (
<PublicShell>
<main className="flex-1 px-4 py-16">
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">Confirm your email</h1>
<p className="mt-4 text-base leading-7 text-slate-600">
We sent a verification code to <span className="font-semibold text-slate-900">{form.email}</span>. Enter it here to finish creating your workspace.
</p>
<div className="mt-8 space-y-5">
<LabeledInput label="Verification code" value={verificationCode} onChange={setVerificationCode} />
{error ? <div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<button type="button" onClick={() => setAwaitingVerification(false)} className="btn-secondary">
Back
</button>
<div className="flex flex-col gap-3 sm:flex-row">
<button type="button" onClick={resendVerificationCode} disabled={loading} className="btn-secondary disabled:cursor-not-allowed disabled:opacity-60">
{loading ? 'Sending…' : 'Resend code'}
</button>
<button type="button" onClick={completeSignup} disabled={loading || !verificationCode.trim()} className="btn-primary disabled:cursor-not-allowed disabled:opacity-60">
{loading ? 'Verifying…' : 'Verify and create workspace'}
</button>
</div>
</div>
</div>
</div>
</main>
</PublicShell>
)
}
return (
<PublicShell>
<main className="px-4 py-12">
<div className="mx-auto max-w-3xl space-y-8">
<div className="text-center">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">Launch your rental workspace</h1>
<p className="mt-4 text-base leading-7 text-slate-600">Create the owner account, verify your email, and start your 14-day free trial.</p>
</div>
<div className="grid gap-3 sm:grid-cols-4">
{['Account', 'Company', 'Plan', 'Verify'].map((label, index) => (
<div key={label} className={`rounded-full px-4 py-2 text-center text-sm font-semibold ${index + 1 <= step ? 'bg-slate-900 text-white' : 'border border-slate-200 bg-white text-slate-500'}`}>
{label}
</div>
))}
</div>
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
{error ? <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
{step === 1 ? (
<div className="space-y-5">
<SectionIntro title="Owner account" body="This becomes the primary owner account for the company workspace." />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label="First name" value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
<LabeledInput label="Last name" value={form.lastName} onChange={(value) => setForm((current) => ({ ...current, lastName: value }))} />
</div>
<LabeledInput label="Email" type="email" value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: value }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label="Password" type="password" value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
<LabeledInput label="Confirm password" type="password" value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
</div>
<p className="text-xs text-slate-500">Use at least 8 characters so the owner account can sign in directly after verification.</p>
<div className="flex justify-end">
<button type="button" onClick={() => setStep(2)} className="btn-primary">Continue</button>
</div>
</div>
) : null}
{step === 2 ? (
<div className="space-y-5">
<SectionIntro title="Company details" body="Well use these details for your trial workspace and public marketplace profile." />
<LabeledInput label="Company name" value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label="Phone" value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
<LabeledInput label="Country" value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
</div>
<LabeledInput label="City" value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
<NavActions onBack={() => setStep(1)} onNext={() => setStep(3)} />
</div>
) : null}
{step === 3 ? (
<div className="space-y-5">
<SectionIntro title="Choose your plan" body="Your workspace starts immediately on a 14-day free trial in your preferred billing currency." />
<div className="grid gap-4 sm:grid-cols-3">
{['STARTER', 'GROWTH', 'PRO'].map((plan) => (
<button
key={plan}
type="button"
onClick={() => setForm((current) => ({ ...current, plan: plan as SignupForm['plan'] }))}
className={`rounded-3xl border p-5 text-left ${form.plan === plan ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-900'}`}
>
<p className="text-xs font-semibold uppercase tracking-[0.16em]">{plan}</p>
<p className="mt-3 text-sm opacity-80">{plan === 'STARTER' ? 'Core fleet, offers, and bookings.' : plan === 'GROWTH' ? 'Featured marketplace placement and more room to scale.' : 'Full white-label and premium controls.'}</p>
</button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<LabeledSelect label="Billing period" value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
<LabeledSelect label="Currency" value={form.currency} options={['MAD', 'USD', 'EUR']} onChange={(value) => setForm((current) => ({ ...current, currency: value as SignupForm['currency'] }))} />
<LabeledSelect label="Primary provider" value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
</div>
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} />
</div>
) : null}
{step === 4 ? (
<div className="space-y-5">
<SectionIntro title="Verify and launch" body="Well create the owner account in Clerk first, email you a verification code, then provision the workspace after that email is confirmed." />
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
<p><span className="font-semibold text-slate-900">Workspace:</span> {form.companyName || 'Your company'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">Plan:</span> {form.plan} · {form.billingPeriod} · {form.currency}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">Owner email:</span> {form.email || '—'}</p>
</div>
<NavActions
onBack={() => setStep(3)}
onNext={submitSignup}
loading={loading}
nextLabel="Send verification code"
/>
</div>
) : null}
</div>
</div>
</main>
</PublicShell>
)
}
function LocalSignUpPage() {
const searchParams = useSearchParams()
const initialPlan = normalizePlan(searchParams.get('plan'))
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
@@ -387,12 +82,13 @@ function LocalSignUpPage() {
setError(null)
try {
await apiFetch('/auth/company/signup', {
const response = await apiFetch<SignupResponse>('/auth/company/signup', {
method: 'POST',
body: JSON.stringify({
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
password: form.password,
companyName: form.companyName,
companyPhone: form.companyPhone || undefined,
city: form.city || undefined,
@@ -407,9 +103,10 @@ function LocalSignUpPage() {
setCompletedSignup({
companyName: form.companyName,
email: form.email,
emailWarning: getEmailWarning(response.emailDelivery),
})
} catch (err) {
setError(getClerkErrorMessage(err, 'Could not create workspace'))
setError(getErrorMessage(err, 'Could not create workspace'))
} finally {
setLoading(false)
}
@@ -426,9 +123,17 @@ function LocalSignUpPage() {
<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
{completedSignup.emailWarning ? (
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{completedSignup.emailWarning}
</div>
) : null}
<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>
@@ -517,7 +222,7 @@ function LocalSignUpPage() {
{step === 4 ? (
<div className="space-y-5">
<SectionIntro title="Verify and launch" body="Well create the company workspace immediately and start the 14-day trial with the plan you selected." />
<SectionIntro title="Review and launch" body="Well create the company workspace immediately and start the 14-day trial with the plan you selected." />
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
<p><span className="font-semibold text-slate-900">Workspace:</span> {form.companyName || 'Your company'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">Plan:</span> {form.plan} · {form.billingPeriod} · {form.currency}</p>
@@ -547,6 +252,20 @@ function SectionIntro({ title, body }: { title: string; body: string }) {
)
}
function getEmailWarning(emailDelivery: SignupResponse['emailDelivery']) {
if (!emailDelivery) return null
if (emailDelivery.success) return null
if (emailDelivery.attempted) {
return `The account was created, but the confirmation email could not be delivered${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
}
return 'The account was created, but email sending is not configured on this environment yet.'
}
function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) return error.message
return fallback
}
function LabeledInput({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (value: string) => void; type?: string }) {
return (
<label className="block">
@@ -562,20 +281,22 @@ function LabeledSelect({ label, value, options, onChange }: { label: string; val
<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>
<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 }) {
function NavActions({ onBack, onNext, loading = false, nextLabel = 'Continue' }: { onBack?: () => void; onNext: () => void; loading?: boolean; nextLabel?: string }) {
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">
<div className="flex gap-3">
{onBack ? (
<button type="button" onClick={onBack} className="btn-secondary flex-1 justify-center">
Back
</button>
) : null}
<button type="button" onClick={onNext} disabled={loading} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
{loading ? 'Working…' : nextLabel}
</button>
</div>
@@ -583,50 +304,15 @@ function NavActions({ onBack, onNext, loading = false, nextLabel = 'Continue', d
}
function normalizePlan(value: string | null): SignupForm['plan'] {
return value === 'GROWTH' || value === 'PRO' ? value : 'STARTER'
if (value === 'GROWTH' || value === 'PRO' || value === 'STARTER') return value
return 'STARTER'
}
function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod'] {
return value === 'ANNUAL' || value === 'annual' ? 'ANNUAL' : 'MONTHLY'
return 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
}
if (value === 'USD' || value === 'EUR' || value === 'MAD') return value
return 'MAD'
}
+85 -11
View File
@@ -3,6 +3,7 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
export type DashboardLanguage = 'en' | 'fr' | 'ar'
export type DashboardTheme = 'light' | 'dark'
type DashboardDictionary = {
nav: Record<string, string>
@@ -11,9 +12,10 @@ type DashboardDictionary = {
noNewNotifications: string
unreadNotifications: (count: number) => string
signOut: string
demoUser: string
clerkDisabled: string
workspaceUser: string
localAuth: string
language: string
theme: string
light: string
dark: string
}
@@ -24,6 +26,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
dashboard: 'Dashboard',
fleet: 'Fleet',
reservations: 'Reservations',
onlineReservations: 'Online Reservations',
customers: 'Customers',
offers: 'Offers',
team: 'Team',
@@ -36,6 +39,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/dashboard': 'Dashboard',
'/dashboard/fleet': 'Fleet Management',
'/dashboard/reservations': 'Reservations',
'/dashboard/online-reservations': 'Online Reservations',
'/dashboard/customers': 'Customers',
'/dashboard/offers': 'Offers',
'/dashboard/team': 'Team',
@@ -47,9 +51,10 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
noNewNotifications: 'No new notifications',
unreadNotifications: (count) => `${count} unread notification${count > 1 ? 's' : ''}`,
signOut: 'Sign out',
demoUser: 'Demo User',
clerkDisabled: 'Clerk disabled in local dev',
workspaceUser: 'Workspace user',
localAuth: 'Local authentication',
language: 'Language',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
},
@@ -58,6 +63,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
dashboard: 'Tableau de bord',
fleet: 'Flotte',
reservations: 'Réservations',
onlineReservations: 'Réservations en ligne',
customers: 'Clients',
offers: 'Offres',
team: 'Équipe',
@@ -70,6 +76,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/dashboard': 'Tableau de bord',
'/dashboard/fleet': 'Gestion de flotte',
'/dashboard/reservations': 'Réservations',
'/dashboard/online-reservations': 'Réservations en ligne',
'/dashboard/customers': 'Clients',
'/dashboard/offers': 'Offres',
'/dashboard/team': 'Équipe',
@@ -81,9 +88,10 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
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',
workspaceUser: 'Utilisateur espace',
localAuth: 'Authentification locale',
language: 'Langue',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
},
@@ -92,6 +100,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
dashboard: 'لوحة التحكم',
fleet: 'الأسطول',
reservations: 'الحجوزات',
onlineReservations: 'الحجوزات الإلكترونية',
customers: 'العملاء',
offers: 'العروض',
team: 'الفريق',
@@ -104,6 +113,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/dashboard': 'لوحة التحكم',
'/dashboard/fleet': 'إدارة الأسطول',
'/dashboard/reservations': 'الحجوزات',
'/dashboard/online-reservations': 'الحجوزات الإلكترونية',
'/dashboard/customers': 'العملاء',
'/dashboard/offers': 'العروض',
'/dashboard/team': 'الفريق',
@@ -115,9 +125,10 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
noNewNotifications: 'لا توجد إشعارات جديدة',
unreadNotifications: (count) => `${count} إشعار غير مقروء`,
signOut: 'تسجيل الخروج',
demoUser: 'مستخدم تجريبي',
clerkDisabled: 'Clerk غير مفعّل محلياً',
workspaceUser: 'مستخدم مساحة العمل',
localAuth: 'مصادقة محلية',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
},
@@ -126,6 +137,8 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
type I18nContextValue = {
language: DashboardLanguage
setLanguage: (value: DashboardLanguage) => void
theme: DashboardTheme
setTheme: (value: DashboardTheme) => void
dict: DashboardDictionary
}
@@ -133,12 +146,24 @@ const I18nContext = createContext<I18nContextValue | null>(null)
export function DashboardI18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<DashboardLanguage>('en')
const [theme, setTheme] = useState<DashboardTheme>('light')
useEffect(() => {
const stored = window.localStorage.getItem('dashboard-language')
const storedTheme = window.localStorage.getItem('dashboard-theme')
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
setLanguage(stored)
}
if (storedTheme === 'light' || storedTheme === 'dark') {
setTheme(storedTheme)
return
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('dark')
}
}, [])
useEffect(() => {
@@ -147,7 +172,18 @@ export function DashboardI18nProvider({ children }: { children: React.ReactNode
window.localStorage.setItem('dashboard-language', language)
}, [language])
const value = useMemo(() => ({ language, setLanguage, dict: dictionaries[language] }), [language])
useEffect(() => {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(theme)
document.documentElement.style.colorScheme = theme
document.body.dataset.theme = theme
window.localStorage.setItem('dashboard-theme', theme)
}, [theme])
const value = useMemo(
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }),
[language, theme],
)
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
@@ -168,7 +204,7 @@ export function DashboardLanguageSwitcher() {
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">
<div className="flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-1 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
{dict.language}
</span>
@@ -180,7 +216,9 @@ export function DashboardLanguageSwitcher() {
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'
active
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-950'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
{value.toUpperCase()}
@@ -190,3 +228,39 @@ export function DashboardLanguageSwitcher() {
</div>
)
}
export function DashboardThemeSwitcher() {
const { theme, setTheme, 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 transition-colors dark:border-slate-700 dark:bg-slate-900">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{dict.theme}
</span>
{(['light', 'dark'] as DashboardTheme[]).map((value) => {
const active = value === theme
return (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-950'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
{value === 'light' ? dict.light : dict.dark}
</button>
)
})}
</div>
)
}
@@ -1,7 +1,11 @@
'use client'
import Link from 'next/link'
import { DashboardLanguageSwitcher, useDashboardI18n } from '@/components/I18nProvider'
import {
DashboardLanguageSwitcher,
DashboardThemeSwitcher,
useDashboardI18n,
} from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
export default function PublicShell({ children }: { children: React.ReactNode }) {
@@ -28,31 +32,32 @@ export default function PublicShell({ children }: { children: React.ReactNode })
}[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="min-h-screen bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] transition-colors dark:bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.18),transparent_35%),linear-gradient(180deg,#020617,#0f172a)] dark:text-slate-100">
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/85 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/85">
<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">
<Link href={marketplaceUrl} className="flex items-center gap-3 text-slate-900 dark:text-slate-100">
<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>
<span className="hidden text-sm font-semibold text-slate-500 dark:text-slate-400 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">
<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 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100">
{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">
<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 dark:bg-slate-100 dark:text-slate-950 dark:hover:bg-slate-200">
{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">
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90">
<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">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-slate-500">
{dict.preferences}
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<DashboardLanguageSwitcher />
<DashboardThemeSwitcher />
</div>
</div>
</footer>
@@ -1,11 +1,12 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { usePathname, useRouter } from 'next/navigation'
import {
LayoutDashboard,
Car,
Calendar,
Globe,
Users,
Tag,
UserPlus,
@@ -15,15 +16,15 @@ import {
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'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
const NAV_ITEMS = [
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
{ href: '/dashboard/fleet', key: 'fleet', icon: Car },
{ href: '/dashboard/reservations', key: 'reservations', icon: Calendar },
{ href: '/dashboard/online-reservations', key: 'onlineReservations', icon: Globe },
{ href: '/dashboard/customers', key: 'customers', icon: Users },
{ href: '/dashboard/offers', key: 'offers', icon: Tag },
{ href: '/dashboard/team', key: 'team', icon: UserPlus },
@@ -33,29 +34,32 @@ const NAV_ITEMS = [
{ href: '/dashboard/settings', key: 'settings', icon: Settings },
] as const
function SidebarWithClerk() {
export default function Sidebar() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const { signOut } = useClerk()
const { user } = useUser()
const router = useRouter()
const isActive = (item: typeof NAV_ITEMS[0]) => {
if (item.exact) return pathname === item.href
return pathname.startsWith(item.href)
}
function signOut() {
localStorage.removeItem(EMPLOYEE_TOKEN_KEY)
document.cookie = 'employee_token=; path=/; max-age=0; samesite=lax'
router.push('/sign-in')
}
return (
<aside className="fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40">
{/* Logo */}
<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" />
<aside className="fixed inset-y-0 left-0 z-40 flex w-64 flex-col bg-slate-900">
<Link href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-500">
<Car className="h-4 w-4 text-white" />
</div>
<span className="font-bold text-white text-sm tracking-wide">RentalDriveGo</span>
<span className="text-sm font-bold tracking-wide text-white">RentalDriveGo</span>
</Link>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = isActive(item)
@@ -64,101 +68,35 @@ function SidebarWithClerk() {
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',
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
active ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white',
].join(' ')}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<Icon className="h-4 w-4 flex-shrink-0" />
{dict.nav[item.key]}
</Link>
)
})}
</nav>
{/* User menu */}
<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">
{user?.firstName?.[0]?.toUpperCase() ?? 'U'}
<div className="border-t border-slate-800 px-3 py-4">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
W
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs text-slate-400 truncate">
{user?.primaryEmailAddress?.emailAddress}
</p>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{dict.workspaceUser}</p>
<p className="truncate text-xs text-slate-400">{dict.localAuth}</p>
</div>
</div>
<button
onClick={() => signOut()}
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"
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
>
<LogOut className="w-4 h-4" />
<LogOut className="h-4 w-4" />
{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 />
}
+47 -106
View File
@@ -4,113 +4,54 @@ import { Bell } from 'lucide-react'
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'
function TopBarWithClerk() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const { user } = useUser()
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">
{/* Notification bell */}
<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>
{/* User avatar */}
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-semibold">
{user?.firstName?.[0]?.toUpperCase() ?? 'U'}
</div>
</div>
</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 />
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="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 transition-colors dark:border-slate-800 dark:bg-slate-950">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
<div className="flex items-center gap-3">
<div className="relative">
<button
onClick={() => setShowNotifs(!showNotifs)}
className="relative rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 ? (
<span className="absolute right-1 top-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button>
{showNotifs ? (
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-slate-200 bg-white p-4 shadow-lg dark:border-slate-700 dark:bg-slate-900">
<p className="mb-2 text-sm font-medium text-slate-900 dark:text-slate-100">{dict.notifications}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p>
</div>
) : null}
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
W
</div>
</div>
</header>
)
}
@@ -143,7 +143,7 @@ export default function EditMemberModal({
</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 === 'remove' && "This will delete the employee record and revoke their dashboard access. This cannot be undone."}
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
</p>
<div className="flex gap-2">
+6 -12
View File
@@ -3,25 +3,19 @@ const API_BASE =
?? process.env.NEXT_PUBLIC_API_URL
?? 'http://localhost:4000/api/v1'
async function getClerkToken(): Promise<string | null> {
export const EMPLOYEE_TOKEN_KEY = 'employee_token'
async function getAuthToken(): Promise<string | null> {
if (typeof window === 'undefined') return null
try {
// In Next.js App Router, the Clerk session token is available via the Clerk SDK
// For client-side calls, we read it from a cookie set by @clerk/nextjs
if (typeof window !== 'undefined') {
// Client-side: use window.__clerk if available
const clerk = (window as any).__clerk
if (clerk?.session) {
return await clerk.session.getToken()
}
}
return null
return localStorage.getItem(EMPLOYEE_TOKEN_KEY)
} catch {
return null
}
}
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = await getClerkToken()
const token = await getAuthToken()
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const headers: Record<string, string> = {
-5
View File
@@ -1,5 +0,0 @@
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
+19 -14
View File
@@ -1,21 +1,26 @@
import { NextResponse } from 'next/server'
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
import { clerkMiddlewareEnabled } from '@/lib/clerk'
import type { NextRequest } from 'next/server'
const isProtectedRoute = createRouteMatcher([
'/dashboard(.*)',
'/onboarding(.*)',
])
function isProtectedRoute(req: NextRequest) {
return req.nextUrl.pathname === '/dashboard' || req.nextUrl.pathname.startsWith('/dashboard/')
}
export default clerkMiddlewareEnabled
? clerkMiddleware((auth, req) => {
if (isProtectedRoute(req)) {
auth().protect()
}
})
: function middleware() {
return NextResponse.next()
function localJwtMiddleware(req: NextRequest): NextResponse {
if (!isProtectedRoute(req)) return NextResponse.next()
// Check for employee_token cookie (set on login) OR allow if coming from sign-in
const token = req.cookies.get('employee_token')?.value
if (!token) {
const signInUrl = new URL('/sign-in', req.url)
signInUrl.searchParams.set('redirect', req.nextUrl.pathname)
return NextResponse.redirect(signInUrl)
}
return NextResponse.next()
}
export default function middleware(req: NextRequest) {
return localJwtMiddleware(req)
}
export const config = {
matcher: [
@@ -0,0 +1,337 @@
'use client'
import { useState } from 'react'
import { marketplacePost } from '@/lib/api'
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 Dict {
rentalCompany: string
checkAvailability: string
available: string
from: string
bookNow: string
details: string
vehicleUnavailable: string
listings: string
availableVehicles: string
// modal
reserveVehicle: string
pickupDate: string
returnDate: string
firstName: string
lastName: string
email: string
phone: string
notes: string
cancel: string
submitRequest: string
submitting: string
successTitle: string
successBody: string
close: string
errorUnavailable: string
errorGeneric: string
}
function formatCents(cents: number) {
return `${(cents / 100).toLocaleString('fr-MA', { minimumFractionDigits: 2 })} MAD`
}
function daysBetween(start: string, end: string) {
const ms = new Date(end).getTime() - new Date(start).getTime()
return Math.max(1, Math.ceil(ms / 86_400_000))
}
const today = () => new Date().toISOString().slice(0, 10)
const tomorrow = () => {
const d = new Date()
d.setDate(d.getDate() + 1)
return d.toISOString().slice(0, 10)
}
export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehicle[]; dict: Dict }) {
const [selected, setSelected] = useState<Vehicle | null>(null)
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
startDate: today(),
endDate: tomorrow(),
notes: '',
})
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
function openModal(vehicle: Vehicle) {
setSelected(vehicle)
setStatus('idle')
setErrorMsg('')
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: today(), endDate: tomorrow(), notes: '' })
}
function closeModal() {
setSelected(null)
setStatus('idle')
setErrorMsg('')
}
function set(field: keyof typeof form) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((prev) => ({ ...prev, [field]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!selected) return
setStatus('submitting')
setErrorMsg('')
try {
await marketplacePost('/marketplace/reservations', {
vehicleId: selected.id,
companySlug: selected.company.slug,
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
phone: form.phone || undefined,
startDate: new Date(form.startDate).toISOString(),
endDate: new Date(form.endDate).toISOString(),
notes: form.notes || undefined,
})
setStatus('success')
} catch (err: any) {
setStatus('error')
if (err?.code === 'unavailable') {
setErrorMsg(dict.errorUnavailable)
} else {
setErrorMsg(err?.message ?? dict.errorGeneric)
}
}
}
const days = form.startDate && form.endDate ? daysBetween(form.startDate, form.endDate) : 0
const estimatedTotal = selected ? selected.dailyRate * days : 0
return (
<>
<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">{formatCents(vehicle.dailyRate)}</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => openModal(vehicle)}
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white"
>
{dict.bookNow}
</button>
</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>
{selected && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={closeModal}>
<div
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-3xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start gap-4 p-6 border-b border-stone-100">
{selected.photos[0] && (
// eslint-disable-next-line @next/next/no-img-element
<img src={selected.photos[0]} alt="" className="h-16 w-24 rounded-xl object-cover flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-xs uppercase tracking-widest text-stone-400">{selected.company.brand?.displayName ?? dict.rentalCompany}</p>
<h2 className="mt-1 text-xl font-bold text-stone-900 truncate">{selected.year} {selected.make} {selected.model}</h2>
<p className="mt-0.5 text-sm text-stone-500">{selected.category} · {formatCents(selected.dailyRate)}/day</p>
</div>
<button onClick={closeModal} className="flex-shrink-0 rounded-full p-2 hover:bg-stone-100 text-stone-500">
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" /></svg>
</button>
</div>
{status === 'success' ? (
<div className="p-8 text-center space-y-4">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
</div>
<h3 className="text-xl font-bold text-stone-900">{dict.successTitle}</h3>
<p className="text-stone-500">{dict.successBody}</p>
<button onClick={closeModal} className="mt-4 rounded-full bg-stone-900 px-8 py-3 text-sm font-semibold text-white">
{dict.close}
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="p-6 space-y-5">
<h3 className="font-semibold text-stone-900">{dict.reserveVehicle}</h3>
{/* Dates */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupDate}</label>
<input
type="date"
required
min={today()}
value={form.startDate}
onChange={set('startDate')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.returnDate}</label>
<input
type="date"
required
min={form.startDate || today()}
value={form.endDate}
onChange={set('endDate')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
</div>
{/* Estimated total */}
{days > 0 && (
<div className="rounded-2xl bg-stone-50 px-4 py-3 flex items-center justify-between">
<p className="text-sm text-stone-500">{days} day{days > 1 ? 's' : ''} × {formatCents(selected.dailyRate)}</p>
<p className="font-bold text-stone-900">{formatCents(estimatedTotal)}</p>
</div>
)}
{/* Customer info */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.firstName}</label>
<input
type="text"
required
value={form.firstName}
onChange={set('firstName')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.lastName}</label>
<input
type="text"
required
value={form.lastName}
onChange={set('lastName')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.email}</label>
<input
type="email"
required
value={form.email}
onChange={set('email')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.phone}</label>
<input
type="tel"
value={form.phone}
onChange={set('phone')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.notes}</label>
<textarea
rows={3}
value={form.notes}
onChange={set('notes')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900 resize-none"
/>
</div>
{status === 'error' && (
<p className="rounded-2xl bg-rose-50 px-4 py-3 text-sm text-rose-700">{errorMsg}</p>
)}
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={closeModal}
className="flex-1 rounded-full border border-stone-300 px-4 py-3 text-sm font-semibold text-stone-700"
>
{dict.cancel}
</button>
<button
type="submit"
disabled={status === 'submitting'}
className="flex-1 rounded-full bg-stone-900 px-4 py-3 text-sm font-semibold text-white disabled:opacity-60"
>
{status === 'submitting' ? dict.submitting : dict.submitRequest}
</button>
</div>
</form>
)}
</div>
</div>
)}
</>
)
}
+76 -58
View File
@@ -1,7 +1,7 @@
import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types'
import ExploreVehicleGrid from './ExploreVehicleGrid'
interface Vehicle {
id: string
@@ -55,10 +55,12 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
en: {
kicker: 'Discovery only',
title: 'Find your next rental from trusted local companies.',
body: 'Every listing links you to the companys own booking and payment flow.',
body: 'Browse, filter, and reserve — the company confirms and contacts you directly.',
city: 'City or location',
allCategories: 'All categories',
anyTransmission: 'Any transmission',
makePlaceholder: 'Make (e.g. Toyota)',
modelPlaceholder: 'Model (e.g. Corolla)',
search: 'Search',
currentDeals: 'Current deals',
featuredOffers: 'Featured marketplace offers',
@@ -71,7 +73,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
checkAvailability: 'Check availability',
available: 'Available',
from: 'From',
bookNow: 'Book now',
bookNow: 'Reserve',
details: 'Details',
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
browseByCategory: 'Browse by category',
@@ -83,19 +85,38 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
publishedVehicles: 'published vehicles',
viewFleet: 'View fleet',
categories: ['Economy', 'Compact', 'SUV', 'Luxury', 'Van', 'Truck', 'Electric'],
// modal
reserveVehicle: 'Reserve this vehicle',
pickupDate: 'Pick-up date',
returnDate: 'Return date',
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
phone: 'Phone number (optional)',
notes: 'Additional notes (optional)',
cancel: 'Cancel',
submitRequest: 'Send reservation request',
submitting: 'Sending…',
successTitle: 'Request sent!',
successBody: 'Your reservation request has been received. Check your inbox for a confirmation email — the company will contact you shortly.',
close: 'Close',
errorUnavailable: 'This vehicle is no longer available for the selected dates. Please choose different dates.',
errorGeneric: 'Something went wrong. Please try again.',
},
fr: {
kicker: 'Découverte uniquement',
title: 'Trouvez votre prochaine location auprès dentreprises locales fiables.',
body: 'Chaque annonce vous redirige vers le parcours de réservation et de paiement propre à lentreprise.',
title: "Trouvez votre prochaine location auprès d'entreprises locales fiables.",
body: "Parcourez, filtrez et réservez — l'entreprise confirme et vous contacte directement.",
city: 'Ville ou emplacement',
allCategories: 'Toutes les catégories',
anyTransmission: 'Toute transmission',
makePlaceholder: 'Marque (ex. Toyota)',
modelPlaceholder: 'Modèle (ex. Corolla)',
search: 'Rechercher',
currentDeals: 'Offres du moment',
featuredOffers: 'Offres marketplace mises en avant',
company: 'Entreprise',
validUntil: 'Valable jusquau',
validUntil: "Valable jusqu'au",
offersUnavailable: 'Les offres marketplace sont indisponibles pour le moment.',
availableVehicles: 'Véhicules disponibles',
listings: 'annonces',
@@ -115,14 +136,32 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
publishedVehicles: 'véhicules publiés',
viewFleet: 'Voir la flotte',
categories: ['Économie', 'Compacte', 'SUV', 'Luxe', 'Van', 'Camion', 'Électrique'],
reserveVehicle: 'Réserver ce véhicule',
pickupDate: 'Date de prise en charge',
returnDate: 'Date de retour',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Adresse e-mail',
phone: 'Numéro de téléphone (optionnel)',
notes: 'Notes supplémentaires (optionnel)',
cancel: 'Annuler',
submitRequest: 'Envoyer la demande',
submitting: 'Envoi…',
successTitle: 'Demande envoyée !',
successBody: "Votre demande de réservation a été reçue. Vérifiez votre boîte mail — l'entreprise vous contactera prochainement.",
close: 'Fermer',
errorUnavailable: "Ce véhicule n'est plus disponible pour les dates sélectionnées.",
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
kicker: 'للاستكشاف فقط',
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
body: 'كل إعلان يوجّهك إلى مسار الحجز والدفع الخاص بالشركة نفسها.',
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
city: 'المدينة أو الموقع',
allCategories: 'كل الفئات',
anyTransmission: 'أي ناقل حركة',
makePlaceholder: 'الماركة (مثل Toyota)',
modelPlaceholder: 'الموديل (مثل Corolla)',
search: 'بحث',
currentDeals: 'العروض الحالية',
featuredOffers: 'عروض السوق المميزة',
@@ -135,7 +174,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
checkAvailability: 'تحقق من التوفر',
available: 'متاح',
from: 'ابتداءً من',
bookNow: 'احجز الآن',
bookNow: 'احجز',
details: 'التفاصيل',
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
browseByCategory: 'تصفح حسب الفئة',
@@ -147,15 +186,37 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
publishedVehicles: 'سيارات منشورة',
viewFleet: 'عرض الأسطول',
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
reserveVehicle: 'احجز هذه السيارة',
pickupDate: 'تاريخ الاستلام',
returnDate: 'تاريخ الإرجاع',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'رقم الهاتف (اختياري)',
notes: 'ملاحظات إضافية (اختياري)',
cancel: 'إلغاء',
submitRequest: 'إرسال طلب الحجز',
submitting: 'جارٍ الإرسال…',
successTitle: 'تم إرسال الطلب!',
successBody: 'تم استلام طلب الحجز. تحقق من بريدك الإلكتروني — ستتواصل معك الشركة قريباً.',
close: 'إغلاق',
errorUnavailable: 'السيارة غير متاحة في التواريخ المحددة. يرجى اختيار تواريخ أخرى.',
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[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 make = typeof searchParams?.make === 'string' ? searchParams.make : ''
const model = typeof searchParams?.model === 'string' ? searchParams.model : ''
const query = new URLSearchParams()
if (city) query.set('city', city)
if (category) query.set('category', category)
if (transmission) query.set('transmission', transmission)
if (make) query.set('make', make)
if (model) query.set('model', model)
query.set('pageSize', '24')
const [offers, vehicles, companies] = await Promise.all([
@@ -171,11 +232,15 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
<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">
<form className="mt-8 grid gap-3 rounded-[1.5rem] bg-white/10 p-4 md:grid-cols-3 xl:grid-cols-6">
<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" />
<input name="make" defaultValue={make} placeholder={dict.makePlaceholder} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900" />
<input name="model" defaultValue={model} placeholder={dict.modelPlaceholder} 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>)}
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', '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>
@@ -203,54 +268,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
</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>
<ExploreVehicleGrid vehicles={vehicles} dict={dict} />
<section>
<h2 className="text-2xl font-bold text-stone-900">{dict.browseByCategory}</h2>
+12
View File
@@ -29,3 +29,15 @@ export async function marketplaceFetchOrDefault<T>(path: string, fallback: T): P
return fallback
}
}
export async function marketplacePost<T>(path: string, body: unknown): Promise<T> {
const base = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const res = await fetch(`${base}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json().catch(() => null)
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error)
return json.data as T
}
+83
View File
@@ -2,6 +2,14 @@
@tailwind components;
@tailwind utilities;
html.light {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body {
@apply bg-white text-slate-900 antialiased;
}
@@ -13,3 +21,78 @@ body {
.card {
@apply rounded-2xl border border-slate-200 bg-white shadow-sm;
}
@layer base {
body {
@apply transition-colors dark:bg-slate-950 dark:text-slate-100;
}
.card {
@apply transition-colors dark:border-slate-800 dark:bg-slate-900;
}
}
@layer utilities {
.dark .bg-slate-50 {
background-color: rgb(2 6 23);
}
.dark .bg-slate-100 {
background-color: rgb(15 23 42);
}
.dark .bg-white {
background-color: rgb(15 23 42);
}
.dark .bg-white\/90 {
background-color: rgb(15 23 42 / 0.9);
}
.dark .border-slate-200,
.dark .border-stone-200,
.dark .border-slate-300 {
border-color: rgb(30 41 59);
}
.dark .text-slate-900 {
color: rgb(241 245 249);
}
.dark .text-slate-700 {
color: rgb(203 213 225);
}
.dark .text-slate-600,
.dark .text-stone-500 {
color: rgb(148 163 184);
}
.dark .text-slate-500,
.dark .text-slate-400,
.dark .text-stone-400 {
color: rgb(100 116 139);
}
.dark .text-sky-700 {
color: rgb(125 211 252);
}
.dark .bg-sky-100 {
background-color: rgb(12 74 110);
}
.dark .text-sky-800 {
color: rgb(224 242 254);
}
.dark .hover\:bg-slate-100:hover,
.dark .hover\:bg-stone-100:hover {
background-color: rgb(30 41 59);
}
.dark .hover\:text-slate-900:hover,
.dark .hover\:text-stone-900:hover {
color: rgb(248 250 252);
}
}
+21 -11
View File
@@ -5,6 +5,7 @@ import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { getPublicSiteDictionary } from '@/lib/i18n'
import PublicLanguageSwitcher from '@/components/PublicLanguageSwitcher'
import PublicThemeSwitcher from '@/components/PublicThemeSwitcher'
import type { PublicSitePageSections } from '@rentaldrivego/types'
export const metadata: Metadata = {
@@ -69,13 +70,21 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const bookCarLabel = resolveText(menuConfig?.bookCarLabel, dict.nav.bookCar)
return (
<html lang={dict.lang} dir={dict.dir}>
<body>
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur-sm">
<html lang={dict.lang} dir={dict.dir} className="light" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var theme=localStorage.getItem('public-site-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme}catch(e){}})();",
}}
/>
</head>
<body suppressHydrationWarning>
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur-sm transition-colors dark:border-slate-800 dark:bg-slate-950/90">
<div className="shell flex h-14 items-center justify-between gap-6">
<Link
href="/"
className="flex items-center gap-2 font-black text-slate-900 tracking-tight text-lg"
className="flex items-center gap-2 text-lg font-black tracking-tight text-slate-900 dark:text-slate-100"
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-sky-600 text-white text-xs font-black select-none">
{companyName.charAt(0).toUpperCase()}
@@ -91,9 +100,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<div className="flex items-center gap-3">
<PublicLanguageSwitcher currentLanguage={language} />
<PublicThemeSwitcher currentLanguage={language} />
<Link
href="/vehicles"
className="hidden rounded-full bg-slate-900 px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-slate-700 sm:inline-flex"
className="hidden rounded-full bg-slate-900 px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-950 dark:hover:bg-slate-200 sm:inline-flex"
>
{bookCarLabel}
</Link>
@@ -103,20 +113,20 @@ export default async function RootLayout({ children }: { children: React.ReactNo
{children}
<footer className="mt-16 border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors">
<footer className="mt-16 border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-slate-950/90">
<div className="shell 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">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">
{footerLabel}
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<nav className="flex flex-wrap items-center justify-center gap-2 text-sm text-stone-500">
<nav className="flex flex-wrap items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400">
{headerMenuItems.filter((item) => item.visible).map((item) => (
<Link key={item.href} href={item.href} className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">
<Link key={item.href} href={item.href} className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900 dark:hover:bg-stone-800 dark:hover:text-stone-100">
{item.label}
</Link>
))}
</nav>
<p className="text-sm text-stone-500">
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} {companyName}
</p>
</div>
@@ -131,7 +141,7 @@ function NavLink({ href, children }: { href: string; children: React.ReactNode }
return (
<Link
href={href}
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors"
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{children}
</Link>
@@ -0,0 +1,67 @@
'use client'
import { useEffect, useState } from 'react'
import type { PublicSiteLanguage } from '@/lib/i18n'
type Theme = 'light' | 'dark'
const labels: Record<PublicSiteLanguage, { theme: string; light: string; dark: string }> = {
en: { theme: 'Theme', light: 'Light', dark: 'Dark' },
fr: { theme: 'Mode', light: 'Clair', dark: 'Sombre' },
ar: { theme: 'الوضع', light: 'فاتح', dark: 'داكن' },
}
export default function PublicThemeSwitcher({
currentLanguage,
}: {
currentLanguage: PublicSiteLanguage
}) {
const [theme, setTheme] = useState<Theme>('light')
useEffect(() => {
const storedTheme = window.localStorage.getItem('public-site-theme')
if (storedTheme === 'light' || storedTheme === 'dark') {
setTheme(storedTheme)
return
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('dark')
}
}, [])
useEffect(() => {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(theme)
document.documentElement.style.colorScheme = theme
document.body.dataset.theme = theme
window.localStorage.setItem('public-site-theme', theme)
}, [theme])
const copy = labels[currentLanguage]
return (
<div className="flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-1 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{copy.theme}
</span>
{(['light', 'dark'] as Theme[]).map((value) => {
const active = value === theme
return (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-950'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
{value === 'light' ? copy.light : copy.dark}
</button>
)
})}
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
services:
pgmanage:
image: cmdpromptinc/pgmanage-enterprise:latest
container_name: pgmanage
user: "0:0"
stdin_open: true
tty: true
ports:
- "8000:8000"
environment:
PGMANAGE_LISTEN_PORT: "8000"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- pgmanage_data:/appdata
volumes:
pgmanage_data:
+3
View File
@@ -0,0 +1,3 @@
# Memory Index
- [Auth Architecture](project_auth_architecture.md) — Multi-app auth: employee JWT + Clerk + admin JWT, unified login page in dashboard app
+27
View File
@@ -0,0 +1,27 @@
---
name: Auth Architecture
description: Multi-app auth system with unified login - employee JWT + Clerk + admin JWT
type: project
---
Three-tier SaaS auth system (RentalDriveGo car management monorepo):
1. **Admins** — JWT auth (`type: 'admin'`), stored in `localStorage.admin_token` in admin app (port 3002)
2. **Employees (Owner/Manager/Agent)** — Clerk OAuth when configured, local JWT (`type: 'employee'`) when Clerk is not configured. Token stored in `localStorage.employee_token` + `employee_token` cookie in dashboard app (port 3001)
3. **Renters** — JWT infrastructure exists but login is currently disabled (returns 403)
**Unified login page:** `apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx`
- Shows Clerk component when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` is a real key
- Otherwise shows local form that tries employee login, then admin login
- Admin users are redirected to admin app via `${ADMIN_URL}/auth-redirect#token=xxx`
**Key API endpoints:**
- `POST /api/v1/auth/employee/login` — local employee auth (email + password → JWT)
- `POST /api/v1/admin/auth/login` — admin auth (email + password + optional TOTP → JWT)
- `POST /api/v1/auth/company/signup` — company signup (accepts optional `password` field)
**requireCompanyAuth middleware** — accepts local employee JWT OR Clerk session token.
**Why:** Clerk keys were placeholder values (`pk_test_your_real_publishable_key_here`), making the login page non-functional. Added local JWT auth as a fully working fallback.
**How to apply:** When working on auth or protected routes, check if Clerk is actually configured (real keys) vs local JWT mode. The `clerkFrontendEnabled`/`clerkBackendEnabled` flags in `apps/dashboard/src/lib/clerk.ts` detect this.
+528 -557
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -11,6 +11,7 @@
"dev": "turbo dev",
"lint": "turbo lint",
"type-check": "turbo type-check",
"clerk:keys": "bash scripts/setup-clerk-keys.sh",
"db:generate": "turbo db:generate",
"db:deploy": "sh -c 'if [ -n \"$(find packages/database/prisma/migrations -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)\" ]; then npm run db:migrate:deploy --workspace @rentaldrivego/database; else npm run db:push --workspace @rentaldrivego/database; fi'",
"db:push": "turbo db:push",
@@ -0,0 +1,2 @@
-- Empty placeholder migration (no schema changes)
SELECT 1;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "passwordHash" TEXT;
@@ -0,0 +1,7 @@
-- AlterTable employees
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "passwordResetToken" TEXT UNIQUE;
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "passwordResetExpiresAt" TIMESTAMPTZ;
-- AlterTable admin_users
ALTER TABLE "admin_users" ADD COLUMN IF NOT EXISTS "passwordResetToken" TEXT UNIQUE;
ALTER TABLE "admin_users" ADD COLUMN IF NOT EXISTS "passwordResetExpiresAt" TIMESTAMPTZ;
@@ -0,0 +1 @@
provider = "postgresql"
+26 -21
View File
@@ -435,16 +435,19 @@ model BrandSettings {
}
model Employee {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
clerkUserId String @unique
firstName String
lastName String
email String
phone String?
role EmployeeRole @default(AGENT)
isActive Boolean @default(true)
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
clerkUserId String @unique
firstName String
lastName String
email String
phone String?
passwordHash String?
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
role EmployeeRole @default(AGENT)
isActive Boolean @default(true)
notifications Notification[] @relation("EmployeeNotifications")
notificationPreferences NotificationPreference[]
@@ -1027,17 +1030,19 @@ model AccountingSettings {
// ═══════════════════════════════════════════════════════════════
model AdminUser {
id String @id @default(cuid())
email String @unique
firstName String
lastName String
passwordHash String
role AdminRole @default(SUPPORT)
isActive Boolean @default(true)
totpSecret String?
totpEnabled Boolean @default(false)
lastLoginAt DateTime?
lastLoginIp String?
id String @id @default(cuid())
email String @unique
firstName String
lastName String
passwordHash String
role AdminRole @default(SUPPORT)
isActive Boolean @default(true)
totpSecret String?
totpEnabled Boolean @default(false)
lastLoginAt DateTime?
lastLoginIp String?
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
auditLogs AuditLog[]
permissions AdminPermission[]
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ROOT_DIR}/.env.local"
SECRET_KEY="${CLERK_SECRET_KEY:-}"
PUBLISHABLE_KEY="${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:-${CLERK_PUBLISHABLE_KEY:-}}"
usage() {
cat <<'EOF'
Usage:
scripts/setup-clerk-keys.sh --secret-key sk_test_xxx --publishable-key pk_test_xxx
Options:
--secret-key VALUE Clerk secret key to store as CLERK_SECRET_KEY
--publishable-key VALUE Clerk publishable key to store as NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
--env-file PATH Target env file (default: .env.local)
--help Show this message
Notes:
- Real Clerk keys are created by Clerk, not generated offline by this script.
- Find them in the Clerk Dashboard > API Keys.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--secret-key)
SECRET_KEY="${2:-}"
shift 2
;;
--publishable-key)
PUBLISHABLE_KEY="${2:-}"
shift 2
;;
--env-file)
ENV_FILE="${2:-}"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
prompt_if_missing() {
local var_name="$1"
local label="$2"
local current_value="$3"
if [[ -n "$current_value" ]]; then
printf '%s' "$current_value"
return 0
fi
read -r -p "${label}: " current_value
printf '%s' "$current_value"
}
SECRET_KEY="$(prompt_if_missing "CLERK_SECRET_KEY" "Enter your Clerk secret key (sk_test_... or sk_live_...)" "$SECRET_KEY")"
PUBLISHABLE_KEY="$(prompt_if_missing "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" "Enter your Clerk publishable key (pk_test_... or pk_live_...)" "$PUBLISHABLE_KEY")"
validate_prefix() {
local value="$1"
local name="$2"
local prefix_a="$3"
local prefix_b="$4"
if [[ -z "$value" ]]; then
echo "${name} is required." >&2
exit 1
fi
if [[ "$value" != "${prefix_a}"* && "$value" != "${prefix_b}"* ]]; then
echo "${name} must start with ${prefix_a} or ${prefix_b}." >&2
exit 1
fi
}
validate_prefix "$SECRET_KEY" "CLERK_SECRET_KEY" "sk_test_" "sk_live_"
validate_prefix "$PUBLISHABLE_KEY" "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" "pk_test_" "pk_live_"
mkdir -p "$(dirname "$ENV_FILE")"
touch "$ENV_FILE"
upsert_env_var() {
local file="$1"
local key="$2"
local value="$3"
local escaped_value
escaped_value="$(printf '%s' "$value" | sed 's/[&|]/\\&/g')"
if grep -q "^${key}=" "$file"; then
sed -i "s|^${key}=.*|${key}=${escaped_value}|" "$file"
else
printf '\n%s=%s\n' "$key" "$value" >> "$file"
fi
}
upsert_env_var "$ENV_FILE" "CLERK_SECRET_KEY" "$SECRET_KEY"
upsert_env_var "$ENV_FILE" "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" "$PUBLISHABLE_KEY"
echo "Updated ${ENV_FILE}"
echo " CLERK_SECRET_KEY=${SECRET_KEY:0:10}..."
echo " NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${PUBLISHABLE_KEY:0:10}..."