fix first online resevation
This commit is contained in:
@@ -21,3 +21,17 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
|||||||
CLERK_SECRET_KEY=
|
CLERK_SECRET_KEY=
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003
|
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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Three Docker environments are available:
|
|||||||
- `Dockerfile.dev` with `docker-compose.dev.yml`
|
- `Dockerfile.dev` with `docker-compose.dev.yml`
|
||||||
- `Dockerfile.test` with `docker-compose.test.yml`
|
- `Dockerfile.test` with `docker-compose.test.yml`
|
||||||
- `Dockerfile.production` with `docker-compose.production.yml`
|
- `Dockerfile.production` with `docker-compose.production.yml`
|
||||||
|
- `docker-compose.pgmanage.yml` for a standalone pgManage container
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
@@ -75,6 +76,17 @@ pgAdmin Postgres connection:
|
|||||||
- username: `postgres`
|
- username: `postgres`
|
||||||
- password: `password`
|
- 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
|
### Test
|
||||||
|
|
||||||
Use the test stack to run repeatable containerized verification:
|
Use the test stack to run repeatable containerized verification:
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||||
|
const apiUrl = new URL(apiOrigin)
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
images: {
|
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'],
|
transpilePackages: ['@rentaldrivego/types'],
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname, useRouter } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
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 = [
|
const navLinks = [
|
||||||
{ href: '/dashboard', key: 'overview', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
{ href: '/dashboard', 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/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/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/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 }) {
|
export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { dict } = useAdminI18n()
|
const { dict } = useAdminI18n()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('admin_token')
|
const token = localStorage.getItem('admin_token')
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.replace('/login')
|
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||||
} else {
|
} else {
|
||||||
setReady(true)
|
setReady(true)
|
||||||
}
|
}
|
||||||
}, [router])
|
}, [pathname])
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
localStorage.removeItem('admin_token')
|
localStorage.removeItem('admin_token')
|
||||||
router.push('/login')
|
window.location.href = buildUnifiedLoginUrl('/dashboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
@@ -43,8 +57,8 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<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">
|
<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">
|
<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="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>
|
<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}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||||
active
|
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
|
||||||
? '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}>
|
<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">
|
<div className="px-3 py-4">
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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}>
|
<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" />
|
<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>
|
</svg>
|
||||||
{dict.logout}
|
{dict.logout}
|
||||||
</button>
|
</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 />
|
<AdminLanguageSwitcher />
|
||||||
|
<AdminThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,8 +2,16 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html.light {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-zinc-950 text-zinc-50 antialiased;
|
@apply bg-zinc-950 text-zinc-50 antialiased transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
@@ -13,3 +21,55 @@ body {
|
|||||||
.panel {
|
.panel {
|
||||||
@apply rounded-2xl border border-zinc-800 bg-zinc-900 shadow-sm;
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,16 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="dark" suppressHydrationWarning>
|
||||||
<body><AdminI18nProvider>{children}</AdminI18nProvider></body>
|
<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>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,212 +1,7 @@
|
|||||||
'use client'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
import Link from 'next/link'
|
const DASHBOARD_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||||
import { useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useAdminI18n } from '@/components/I18nProvider'
|
|
||||||
import PublicShell from '@/components/PublicShell'
|
|
||||||
|
|
||||||
const API_BASE = '/api/v1'
|
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const { language } = useAdminI18n()
|
redirect(`${DASHBOARD_URL}/sign-in?portal=admin&next=/dashboard`)
|
||||||
const dict = {
|
|
||||||
en: {
|
|
||||||
loginFailed: 'Login failed',
|
|
||||||
verifyFailed: '2FA verification failed',
|
|
||||||
brand: 'Admin console',
|
|
||||||
access: 'Platform operations access',
|
|
||||||
email: 'Email',
|
|
||||||
password: 'Password',
|
|
||||||
signIn: 'Sign in',
|
|
||||||
signingIn: 'Signing in…',
|
|
||||||
enterCode: 'Enter the 6-digit code from your authenticator app',
|
|
||||||
authCode: 'Authentication code',
|
|
||||||
verify: 'Verify',
|
|
||||||
verifying: 'Verifying…',
|
|
||||||
back: 'Back to login',
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
loginFailed: 'Échec de connexion',
|
|
||||||
verifyFailed: 'Échec de la vérification 2FA',
|
|
||||||
brand: 'Console admin',
|
|
||||||
access: 'Accès opérations plateforme',
|
|
||||||
email: 'Email',
|
|
||||||
password: 'Mot de passe',
|
|
||||||
signIn: 'Connexion',
|
|
||||||
signingIn: 'Connexion…',
|
|
||||||
enterCode: 'Entrez le code à 6 chiffres de votre application d’authentification',
|
|
||||||
authCode: 'Code d’authentification',
|
|
||||||
verify: 'Vérifier',
|
|
||||||
verifying: 'Vérification…',
|
|
||||||
back: 'Retour à la connexion',
|
|
||||||
},
|
|
||||||
ar: {
|
|
||||||
loginFailed: 'فشل تسجيل الدخول',
|
|
||||||
verifyFailed: 'فشل التحقق الثنائي',
|
|
||||||
brand: 'لوحة الإدارة',
|
|
||||||
access: 'وصول عمليات المنصة',
|
|
||||||
email: 'البريد الإلكتروني',
|
|
||||||
password: 'كلمة المرور',
|
|
||||||
signIn: 'تسجيل الدخول',
|
|
||||||
signingIn: 'جارٍ تسجيل الدخول…',
|
|
||||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة',
|
|
||||||
authCode: 'رمز المصادقة',
|
|
||||||
verify: 'تحقق',
|
|
||||||
verifying: 'جارٍ التحقق…',
|
|
||||||
back: 'العودة إلى تسجيل الدخول',
|
|
||||||
},
|
|
||||||
}[language]
|
|
||||||
const router = useRouter()
|
|
||||||
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [totp, setTotp] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
async function handleCredentials(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/admin/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
})
|
|
||||||
const json = await res.json()
|
|
||||||
if (res.status === 401 && json?.error === 'totp_required') {
|
|
||||||
setStep('totp')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error(json?.message ?? dict.loginFailed)
|
|
||||||
if (json.data?.token) {
|
|
||||||
localStorage.setItem('admin_token', json.data.token)
|
|
||||||
router.push('/dashboard')
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTotp(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/admin/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email, password, totpCode: totp }),
|
|
||||||
})
|
|
||||||
const json = await res.json()
|
|
||||||
if (!res.ok) throw new Error(json?.message ?? dict.verifyFailed)
|
|
||||||
if (json.data?.token) {
|
|
||||||
localStorage.setItem('admin_token', json.data.token)
|
|
||||||
router.push('/dashboard')
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PublicShell>
|
|
||||||
<main className="flex flex-1 items-center justify-center px-4">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="mb-8 text-center">
|
|
||||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
|
|
||||||
<h1 className="mt-3 text-3xl font-black tracking-tight">{dict.brand}</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-400">{dict.access}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="panel p-8">
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 p-3 rounded-xl border border-red-900/50 bg-red-950/50 text-sm text-red-400">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'credentials' ? (
|
|
||||||
<form onSubmit={handleCredentials} className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.email}</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
|
||||||
placeholder="admin@rentaldrivego.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.password}</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? dict.signingIn : dict.signIn}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleTotp} className="space-y-5">
|
|
||||||
<div className="text-center mb-2">
|
|
||||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900/40 mb-3">
|
|
||||||
<svg className="h-6 w-6 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-zinc-300">{dict.enterCode}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.authCode}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]{6}"
|
|
||||||
maxLength={6}
|
|
||||||
required
|
|
||||||
value={totp}
|
|
||||||
onChange={(e) => setTotp(e.target.value.replace(/\D/g, ''))}
|
|
||||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-center text-2xl tracking-[0.5em] placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
|
||||||
placeholder="000000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? dict.verifying : dict.verify}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setStep('credentials'); setTotp(''); setError(null) }}
|
|
||||||
className="w-full text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
||||||
>
|
|
||||||
{dict.back}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</PublicShell>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,11 +3,15 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
export type AdminLanguage = 'en' | 'fr' | 'ar'
|
export type AdminLanguage = 'en' | 'fr' | 'ar'
|
||||||
|
export type AdminTheme = 'light' | 'dark'
|
||||||
|
|
||||||
type AdminDictionary = {
|
type AdminDictionary = {
|
||||||
nav: Record<string, string>
|
nav: Record<string, string>
|
||||||
logout: string
|
logout: string
|
||||||
language: string
|
language: string
|
||||||
|
theme: string
|
||||||
|
light: string
|
||||||
|
dark: string
|
||||||
overview: string
|
overview: string
|
||||||
admin: string
|
admin: string
|
||||||
platform: string
|
platform: string
|
||||||
@@ -23,9 +27,13 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
|||||||
renters: 'Renters',
|
renters: 'Renters',
|
||||||
auditLogs: 'Audit Logs',
|
auditLogs: 'Audit Logs',
|
||||||
adminUsers: 'Admin Users',
|
adminUsers: 'Admin Users',
|
||||||
|
billing: 'Billing',
|
||||||
},
|
},
|
||||||
logout: 'Logout',
|
logout: 'Logout',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
|
theme: 'Theme',
|
||||||
|
light: 'Light',
|
||||||
|
dark: 'Dark',
|
||||||
overview: 'Platform overview',
|
overview: 'Platform overview',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
platform: 'Platform',
|
platform: 'Platform',
|
||||||
@@ -33,15 +41,19 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
|||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
nav: {
|
nav: {
|
||||||
overview: 'Vue d’ensemble',
|
overview: "Vue d'ensemble",
|
||||||
companies: 'Entreprises',
|
companies: 'Entreprises',
|
||||||
siteConfig: 'Config site',
|
siteConfig: 'Config site',
|
||||||
renters: 'Locataires',
|
renters: 'Locataires',
|
||||||
auditLogs: 'Journaux d’audit',
|
auditLogs: "Journaux d'audit",
|
||||||
adminUsers: 'Utilisateurs admin',
|
adminUsers: 'Utilisateurs admin',
|
||||||
|
billing: 'Facturation',
|
||||||
},
|
},
|
||||||
logout: 'Déconnexion',
|
logout: 'Déconnexion',
|
||||||
language: 'Langue',
|
language: 'Langue',
|
||||||
|
theme: 'Mode',
|
||||||
|
light: 'Clair',
|
||||||
|
dark: 'Sombre',
|
||||||
overview: 'Vue plateforme',
|
overview: 'Vue plateforme',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
platform: 'Plateforme',
|
platform: 'Plateforme',
|
||||||
@@ -55,9 +67,13 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
|||||||
renters: 'المستأجرون',
|
renters: 'المستأجرون',
|
||||||
auditLogs: 'سجلات التدقيق',
|
auditLogs: 'سجلات التدقيق',
|
||||||
adminUsers: 'مستخدمو الإدارة',
|
adminUsers: 'مستخدمو الإدارة',
|
||||||
|
billing: 'الفوترة',
|
||||||
},
|
},
|
||||||
logout: 'تسجيل الخروج',
|
logout: 'تسجيل الخروج',
|
||||||
language: 'اللغة',
|
language: 'اللغة',
|
||||||
|
theme: 'الوضع',
|
||||||
|
light: 'فاتح',
|
||||||
|
dark: 'داكن',
|
||||||
overview: 'نظرة عامة على المنصة',
|
overview: 'نظرة عامة على المنصة',
|
||||||
admin: 'الإدارة',
|
admin: 'الإدارة',
|
||||||
platform: 'المنصة',
|
platform: 'المنصة',
|
||||||
@@ -68,6 +84,8 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
|||||||
type AdminI18nContext = {
|
type AdminI18nContext = {
|
||||||
language: AdminLanguage
|
language: AdminLanguage
|
||||||
setLanguage: (value: AdminLanguage) => void
|
setLanguage: (value: AdminLanguage) => void
|
||||||
|
theme: AdminTheme
|
||||||
|
setTheme: (value: AdminTheme) => void
|
||||||
dict: AdminDictionary
|
dict: AdminDictionary
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,12 +93,23 @@ const Context = createContext<AdminI18nContext | null>(null)
|
|||||||
|
|
||||||
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [language, setLanguage] = useState<AdminLanguage>('en')
|
const [language, setLanguage] = useState<AdminLanguage>('en')
|
||||||
|
const [theme, setTheme] = useState<AdminTheme>('dark')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = window.localStorage.getItem('admin-language')
|
const stored = window.localStorage.getItem('admin-language')
|
||||||
|
const storedTheme = window.localStorage.getItem('admin-theme')
|
||||||
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||||
setLanguage(stored)
|
setLanguage(stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||||
|
setTheme(storedTheme)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
setTheme('light')
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,7 +118,18 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
|||||||
window.localStorage.setItem('admin-language', language)
|
window.localStorage.setItem('admin-language', 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>
|
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +150,7 @@ export function AdminLanguageSwitcher() {
|
|||||||
if (embedded) return null
|
if (embedded) return null
|
||||||
|
|
||||||
return (
|
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>
|
<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) => {
|
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
|
||||||
const active = value === language
|
const active = value === language
|
||||||
@@ -130,3 +170,37 @@ export function AdminLanguageSwitcher() {
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
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 }) {
|
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||||
const { language } = useAdminI18n()
|
const { language } = useAdminI18n()
|
||||||
@@ -24,8 +28,8 @@ export default function PublicShell({ children }: { children: React.ReactNode })
|
|||||||
}[language]
|
}[language]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
<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">
|
<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">
|
<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">
|
<Link href="/" className="flex items-center gap-3">
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-400">RentalDriveGo</span>
|
<span className="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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div>{children}</div>
|
<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">
|
<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}
|
{dict.preferences}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||||
<AdminLanguageSwitcher />
|
<AdminLanguageSwitcher />
|
||||||
|
<AdminThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -3,18 +3,16 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node --env-file=../../.env.local dist/index.js",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/clerk-sdk-node": "^5.0.0",
|
|
||||||
"@react-pdf/renderer": "^3.4.3",
|
"@react-pdf/renderer": "^3.4.3",
|
||||||
"@rentaldrivego/database": "*",
|
"@rentaldrivego/database": "*",
|
||||||
"@rentaldrivego/types": "*",
|
"@rentaldrivego/types": "*",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cloudinary": "^2.2.0",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
@@ -33,7 +31,6 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"resend": "^3.2.0",
|
"resend": "^3.2.0",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"svix": "^1.20.0",
|
|
||||||
"twilio": "^5.1.0",
|
"twilio": "^5.1.0",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middlewa
|
|||||||
// ─── Routes ───────────────────────────────────────────────────
|
// ─── Routes ───────────────────────────────────────────────────
|
||||||
import webhookRouter from './routes/webhooks'
|
import webhookRouter from './routes/webhooks'
|
||||||
import companyAuthRouter from './routes/auth.company'
|
import companyAuthRouter from './routes/auth.company'
|
||||||
|
import employeeAuthRouter from './routes/auth.employee'
|
||||||
import renterAuthRouter from './routes/auth.renter'
|
import renterAuthRouter from './routes/auth.renter'
|
||||||
import vehiclesRouter from './routes/vehicles'
|
import vehiclesRouter from './routes/vehicles'
|
||||||
import reservationsRouter from './routes/reservations'
|
import reservationsRouter from './routes/reservations'
|
||||||
@@ -114,6 +115,9 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ─── Middleware ────────────────────────────────────────────────
|
// ─── Middleware ────────────────────────────────────────────────
|
||||||
|
// Serve local file storage
|
||||||
|
app.use('/storage', express.static(require('path').resolve(__dirname, 'lib/storage')))
|
||||||
|
|
||||||
// Webhook must use raw body BEFORE express.json()
|
// Webhook must use raw body BEFORE express.json()
|
||||||
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
|
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
|
||||||
|
|
||||||
@@ -126,6 +130,7 @@ app.use(express.json({ limit: '10mb' }))
|
|||||||
// Auth routes: strict brute-force protection
|
// Auth routes: strict brute-force protection
|
||||||
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
|
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
|
||||||
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
||||||
|
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
|
||||||
|
|
||||||
// Admin routes: dedicated limit
|
// Admin routes: dedicated limit
|
||||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 418 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
@@ -1,5 +1,5 @@
|
|||||||
import { Request, Response, NextFunction } from 'express'
|
import { Request, Response, NextFunction } from 'express'
|
||||||
import { clerkClient } from '@clerk/clerk-sdk-node'
|
import jwt from 'jsonwebtoken'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
|
||||||
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
|
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
@@ -15,11 +15,10 @@ export async function requireCompanyAuth(req: Request, res: Response, next: Next
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await clerkClient.sessions.verifySession(sessionToken, sessionToken)
|
const payload = jwt.verify(sessionToken, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||||
const clerkUserId = session.userId
|
if (payload.type === 'employee') {
|
||||||
|
|
||||||
const employee = await prisma.employee.findUnique({
|
const employee = await prisma.employee.findUnique({
|
||||||
where: { clerkUserId },
|
where: { id: payload.sub },
|
||||||
include: { company: true },
|
include: { company: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -34,7 +33,8 @@ export async function requireCompanyAuth(req: Request, res: Response, next: Next
|
|||||||
req.employee = employee
|
req.employee = employee
|
||||||
req.company = employee.company
|
req.company = employee.company
|
||||||
req.companyId = employee.companyId
|
req.companyId = employee.companyId
|
||||||
next()
|
return next()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'invalid_token',
|
error: 'invalid_token',
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { Router } from 'express'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
|
import crypto from 'crypto'
|
||||||
import { authenticator } from 'otplib'
|
import { authenticator } from 'otplib'
|
||||||
import qrcode from 'qrcode'
|
import qrcode from 'qrcode'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAuth'
|
import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAuth'
|
||||||
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../services/platformContentService'
|
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'
|
import type { MarketplaceHomepageContent, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep } from '@rentaldrivego/types'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -311,6 +314,70 @@ router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} 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 ────────────────────────────────────────────────
|
// ─── Companies ────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/companies', requireAdminAuth, async (req, res, next) => {
|
router.get('/companies', requireAdminAuth, async (req, res, next) => {
|
||||||
@@ -665,4 +732,125 @@ router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPE
|
|||||||
} catch (err) { next(err) }
|
} 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
|
export default router
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { clerkClient } from '@clerk/clerk-sdk-node'
|
import bcrypt from 'bcryptjs'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { sendNotification } from '../services/notificationService'
|
import { sendNotification } from '../services/notificationService'
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ const signupSchema = z.object({
|
|||||||
firstName: z.string().min(1).max(80),
|
firstName: z.string().min(1).max(80),
|
||||||
lastName: z.string().min(1).max(80),
|
lastName: z.string().min(1).max(80),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8).max(128),
|
||||||
companyName: z.string().min(2).max(120),
|
companyName: z.string().min(2).max(120),
|
||||||
companyPhone: z.string().optional(),
|
companyPhone: z.string().optional(),
|
||||||
country: z.string().optional(),
|
country: z.string().optional(),
|
||||||
@@ -20,24 +21,11 @@ const signupSchema = z.object({
|
|||||||
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
|
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ownerWorkspaceSchema = z.object({
|
type EmailDeliveryResult = {
|
||||||
companyName: z.string().min(2).max(120),
|
attempted: boolean
|
||||||
companyPhone: z.string().nullish(),
|
success: boolean
|
||||||
country: z.string().nullish(),
|
error: string | null
|
||||||
city: z.string().nullish(),
|
}
|
||||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
|
||||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
|
||||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
|
||||||
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
|
|
||||||
})
|
|
||||||
|
|
||||||
const completeSignupMetadataSchema = ownerWorkspaceSchema.extend({
|
|
||||||
signupFlow: z.literal('company-owner'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const verifySchema = z.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
})
|
|
||||||
|
|
||||||
function slugifyCompanyName(value: string) {
|
function slugifyCompanyName(value: string) {
|
||||||
return value
|
return value
|
||||||
@@ -69,207 +57,42 @@ function buildSignupConfirmationEmail(opts: {
|
|||||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||||
currency: 'MAD' | 'USD' | 'EUR'
|
currency: 'MAD' | 'USD' | 'EUR'
|
||||||
paymentProvider?: 'AMANPAY' | 'PAYPAL'
|
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
||||||
trialEndAt?: Date
|
trialEndAt: Date
|
||||||
isResend?: boolean
|
|
||||||
usesInvitation?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const greetingName = opts.firstName.trim() || 'there'
|
const greetingName = opts.firstName.trim() || 'there'
|
||||||
const lines = [
|
|
||||||
|
return [
|
||||||
`Hi ${greetingName},`,
|
`Hi ${greetingName},`,
|
||||||
'',
|
'',
|
||||||
opts.isResend
|
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
|
||||||
? `We sent a fresh owner invitation for ${opts.companyName}.`
|
|
||||||
: `Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
|
|
||||||
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
|
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
|
||||||
`Currency: ${opts.currency}`,
|
`Currency: ${opts.currency}`,
|
||||||
]
|
`Primary payment provider: ${opts.paymentProvider}`,
|
||||||
|
|
||||||
if (opts.paymentProvider) {
|
|
||||||
lines.push(`Primary payment provider: ${opts.paymentProvider}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.trialEndAt) {
|
|
||||||
lines.push(
|
|
||||||
`Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', {
|
`Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})}.`
|
})}.`,
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(
|
|
||||||
'',
|
'',
|
||||||
opts.usesInvitation === false
|
'Your workspace is ready and you can sign in with the email address and password you chose during signup.',
|
||||||
? 'Your workspace is ready and the owner record has been created for this environment.'
|
|
||||||
: 'Open the invitation email from Clerk to confirm your account, finish onboarding, and enter the dashboard.',
|
|
||||||
'',
|
'',
|
||||||
'RentalDriveGo'
|
'RentalDriveGo',
|
||||||
)
|
].join('\n')
|
||||||
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOwnerInvitation(email: string, companyId: string, companyName: string) {
|
async function sendSignupEmailNotification(opts: Parameters<typeof sendNotification>[0]): Promise<EmailDeliveryResult> {
|
||||||
const dashboardUrl = process.env.DASHBOARD_URL
|
const result = await sendNotification(opts)
|
||||||
if (!process.env.CLERK_SECRET_KEY || !dashboardUrl) {
|
const emailResult = result.find((entry) => entry.channel === 'EMAIL')
|
||||||
throw Object.assign(new Error('Clerk signup is not configured on this environment'), {
|
|
||||||
statusCode: 503,
|
if (!emailResult) {
|
||||||
code: 'clerk_not_configured',
|
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 {
|
return {
|
||||||
company,
|
attempted: true,
|
||||||
employeeId: employee.id,
|
success: emailResult.success,
|
||||||
trialEndAt,
|
error: emailResult.error ?? null,
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +121,7 @@ router.post('/signup', async (req, res, next) => {
|
|||||||
const slug = await generateUniqueSlug(body.companyName)
|
const slug = await generateUniqueSlug(body.companyName)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const trialEndAt = addDays(now, 14)
|
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 result = await prisma.$transaction(async (tx) => {
|
||||||
const company = await tx.company.create({
|
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: {
|
data: {
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
plan: body.plan,
|
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({
|
const employee = await tx.employee.create({
|
||||||
data: {
|
data: {
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
clerkUserId: invitation ? `pending_${invitation.id}` : `local_owner_${company.id}`,
|
clerkUserId: `local_owner_${company.id}`,
|
||||||
firstName: body.firstName,
|
firstName: body.firstName,
|
||||||
lastName: body.lastName,
|
lastName: body.lastName,
|
||||||
email: body.email,
|
email: body.email,
|
||||||
phone: body.companyPhone || null,
|
phone: body.companyPhone || null,
|
||||||
|
passwordHash,
|
||||||
role: 'OWNER',
|
role: 'OWNER',
|
||||||
isActive: !invitation,
|
isActive: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
company,
|
company,
|
||||||
subscription,
|
|
||||||
employeeId: employee.id,
|
employeeId: employee.id,
|
||||||
invitationId: invitation?.id ?? null,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
await sendNotification({
|
const emailDelivery = await sendSignupEmailNotification({
|
||||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||||
title: 'Your workspace is ready',
|
title: 'Your workspace is ready',
|
||||||
body: buildSignupConfirmationEmail({
|
body: buildSignupConfirmationEmail({
|
||||||
@@ -374,22 +192,22 @@ router.post('/signup', async (req, res, next) => {
|
|||||||
currency: body.currency,
|
currency: body.currency,
|
||||||
paymentProvider: body.paymentProvider,
|
paymentProvider: body.paymentProvider,
|
||||||
trialEndAt,
|
trialEndAt,
|
||||||
usesInvitation,
|
|
||||||
}),
|
}),
|
||||||
companyId: result.company.id,
|
companyId: result.company.id,
|
||||||
employeeId: result.employeeId,
|
employeeId: result.employeeId,
|
||||||
email: body.email,
|
email: body.email,
|
||||||
channels: ['EMAIL', 'IN_APP'],
|
channels: ['EMAIL', 'IN_APP'],
|
||||||
}).catch(() => null)
|
})
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
data: {
|
data: {
|
||||||
companyId: result.company.id,
|
companyId: result.company.id,
|
||||||
companyName: result.company.name,
|
companyName: result.company.name,
|
||||||
slug: result.company.slug,
|
slug: result.company.slug,
|
||||||
invitationId: result.invitationId,
|
invitationId: null,
|
||||||
trialEndsAt: trialEndAt.toISOString(),
|
trialEndsAt: trialEndAt.toISOString(),
|
||||||
nextStep: usesInvitation ? 'check_email_for_invitation' : 'workspace_created',
|
nextStep: 'workspace_created',
|
||||||
|
emailDelivery,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -397,144 +215,20 @@ router.post('/signup', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/complete-signup', async (req, res, next) => {
|
router.post('/complete-signup', (_req, res) => {
|
||||||
try {
|
res.status(410).json({
|
||||||
const authHeader = req.headers.authorization
|
error: 'disabled',
|
||||||
const sessionToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
message: 'Clerk-based signup has been removed. Use /auth/company/signup instead.',
|
||||||
|
statusCode: 410,
|
||||||
if (!sessionToken) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: 'unauthenticated',
|
|
||||||
message: 'Authentication required',
|
|
||||||
statusCode: 401,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const session = await verifyClerkSessionToken(sessionToken)
|
|
||||||
const user = await clerkClient.users.getUser(session.userId)
|
|
||||||
const primaryEmail = getPrimaryEmail(user)
|
|
||||||
|
|
||||||
if (!primaryEmail?.emailAddress) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'email_missing',
|
|
||||||
message: 'The signed-in Clerk user does not have a primary email address',
|
|
||||||
statusCode: 400,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (primaryEmail.verification?.status !== 'verified') {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'email_not_verified',
|
|
||||||
message: 'Verify the owner email address before creating the workspace',
|
|
||||||
statusCode: 400,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = completeSignupMetadataSchema.parse(user.unsafeMetadata ?? {})
|
|
||||||
const existingEmployee = await prisma.employee.findUnique({
|
|
||||||
where: { clerkUserId: user.id },
|
|
||||||
include: { company: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existingEmployee) {
|
|
||||||
return res.json({
|
|
||||||
data: {
|
|
||||||
companyId: existingEmployee.companyId,
|
|
||||||
companyName: existingEmployee.company.name,
|
|
||||||
slug: existingEmployee.company.slug,
|
|
||||||
trialEndsAt: null,
|
|
||||||
nextStep: 'enter_dashboard',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await createCompanyWorkspaceForOwner({
|
|
||||||
clerkUserId: user.id,
|
|
||||||
firstName: user.firstName?.trim() || 'Owner',
|
|
||||||
lastName: user.lastName?.trim() || 'Account',
|
|
||||||
email: primaryEmail.emailAddress,
|
|
||||||
companyName: metadata.companyName,
|
|
||||||
companyPhone: metadata.companyPhone ?? null,
|
|
||||||
country: metadata.country ?? null,
|
|
||||||
city: metadata.city ?? null,
|
|
||||||
plan: metadata.plan,
|
|
||||||
billingPeriod: metadata.billingPeriod,
|
|
||||||
currency: metadata.currency,
|
|
||||||
paymentProvider: metadata.paymentProvider,
|
|
||||||
})
|
|
||||||
|
|
||||||
res.status(result.created ? 201 : 200).json({
|
|
||||||
data: {
|
|
||||||
companyId: result.company.id,
|
|
||||||
companyName: result.company.name,
|
|
||||||
slug: result.company.slug,
|
|
||||||
trialEndsAt: result.trialEndAt?.toISOString() ?? null,
|
|
||||||
nextStep: 'enter_dashboard',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
next(err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/verify-email', async (req, res, next) => {
|
router.post('/verify-email', (_req, res) => {
|
||||||
try {
|
res.status(410).json({
|
||||||
const { email } = verifySchema.parse(req.body)
|
error: 'disabled',
|
||||||
const pendingEmployee = await prisma.employee.findFirst({
|
message: 'Email verification resend via Clerk has been removed from this project.',
|
||||||
where: {
|
statusCode: 410,
|
||||||
email,
|
|
||||||
role: 'OWNER',
|
|
||||||
clerkUserId: { startsWith: 'pending_' },
|
|
||||||
},
|
|
||||||
include: { company: true },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!pendingEmployee) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'pending_signup_not_found',
|
|
||||||
message: 'No pending company signup was found for this email',
|
|
||||||
statusCode: 404,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const newInvitation = await createOwnerInvitation(email, pendingEmployee.companyId, pendingEmployee.company.name)
|
|
||||||
await prisma.employee.update({
|
|
||||||
where: { id: pendingEmployee.id },
|
|
||||||
data: { clerkUserId: `pending_${newInvitation.id}` },
|
|
||||||
})
|
|
||||||
|
|
||||||
const subscription = await prisma.subscription.findUnique({
|
|
||||||
where: { companyId: pendingEmployee.companyId },
|
|
||||||
})
|
|
||||||
|
|
||||||
await sendNotification({
|
|
||||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
|
||||||
title: 'Your owner invitation has been resent',
|
|
||||||
body: buildSignupConfirmationEmail({
|
|
||||||
firstName: pendingEmployee.firstName,
|
|
||||||
companyName: pendingEmployee.company.name,
|
|
||||||
plan: subscription?.plan ?? 'STARTER',
|
|
||||||
billingPeriod: subscription?.billingPeriod ?? 'MONTHLY',
|
|
||||||
currency: subscription?.currency === 'USD' || subscription?.currency === 'EUR' ? subscription.currency : 'MAD',
|
|
||||||
trialEndAt: subscription?.trialEndAt ?? undefined,
|
|
||||||
isResend: true,
|
|
||||||
}),
|
|
||||||
companyId: pendingEmployee.companyId,
|
|
||||||
employeeId: pendingEmployee.id,
|
|
||||||
email,
|
|
||||||
channels: ['EMAIL', 'IN_APP'],
|
|
||||||
}).catch(() => null)
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
data: {
|
|
||||||
success: true,
|
|
||||||
invitationId: newInvitation.id,
|
|
||||||
message: 'A fresh invitation link has been sent to your email address',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
next(err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,7 +2,7 @@ import { Router } from 'express'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { uploadImage } from '../lib/cloudinary'
|
import { uploadImage } from '../lib/storage'
|
||||||
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
||||||
import { requireTenant } from '../middleware/requireTenant'
|
import { requireTenant } from '../middleware/requireTenant'
|
||||||
import { requireSubscription } from '../middleware/requireSubscription'
|
import { requireSubscription } from '../middleware/requireSubscription'
|
||||||
@@ -261,7 +261,7 @@ router.post('/me/brand/logo', upload.single('file'), async (req, res, next) => {
|
|||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'missing_file', message: 'A logo file is required', statusCode: 400 })
|
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({
|
const brand = await prisma.brandSettings.upsert({
|
||||||
where: { companyId: req.companyId },
|
where: { companyId: req.companyId },
|
||||||
update: { logoUrl: url },
|
update: { logoUrl: url },
|
||||||
@@ -281,7 +281,7 @@ router.post('/me/brand/hero', upload.single('file'), async (req, res, next) => {
|
|||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'missing_file', message: 'A hero image file is required', statusCode: 400 })
|
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({
|
const brand = await prisma.brandSettings.upsert({
|
||||||
where: { companyId: req.companyId },
|
where: { companyId: req.companyId },
|
||||||
update: { heroImageUrl: url },
|
update: { heroImageUrl: url },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
|
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
|
||||||
|
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
router.use(optionalRenterAuth)
|
router.use(optionalRenterAuth)
|
||||||
@@ -38,7 +39,7 @@ router.get('/companies', async (req, res, next) => {
|
|||||||
const { city, hasOffer } = req.query as Record<string, string>
|
const { city, hasOffer } = req.query as Record<string, string>
|
||||||
const { page, pageSize } = paginationSchema.parse(req.query)
|
const { page, pageSize } = paginationSchema.parse(req.query)
|
||||||
const safeCity = city ? city.trim().slice(0, 100) : undefined
|
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 (safeCity) where.brand = { ...where.brand, publicCity: { contains: safeCity, mode: 'insensitive' } }
|
||||||
if (hasOffer === 'true') {
|
if (hasOffer === 'true') {
|
||||||
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
|
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(),
|
category: z.string().trim().max(50).optional(),
|
||||||
maxPrice: z.coerce.number().int().min(0).max(1_000_000).optional(),
|
maxPrice: z.coerce.number().int().min(0).max(1_000_000).optional(),
|
||||||
transmission: z.string().trim().max(20).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 { 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 (category) where.category = category
|
||||||
if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice }
|
if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice }
|
||||||
if (transmission) where.transmission = transmission
|
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' } } }
|
if (city) where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: city, mode: 'insensitive' } } }
|
||||||
|
|
||||||
const vehicles = await prisma.vehicle.findMany({
|
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) => {
|
router.get('/:slug', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const company = await prisma.company.findFirst({
|
const company = await prisma.company.findFirst({
|
||||||
where: { slug: req.params.slug, status: 'ACTIVE' },
|
where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||||
include: {
|
include: {
|
||||||
brand: true,
|
brand: true,
|
||||||
vehicles: { where: { isPublished: true }, orderBy: { createdAt: 'desc' } },
|
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) => {
|
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||||
try {
|
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({
|
const vehicles = await prisma.vehicle.findMany({
|
||||||
where: { companyId: company.id, isPublished: true },
|
where: { companyId: company.id, isPublished: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -167,7 +289,7 @@ router.get('/:slug/vehicles', async (req, res, next) => {
|
|||||||
|
|
||||||
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||||
try {
|
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({
|
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||||
where: { id: req.params.id, companyId: company.id, isPublished: true },
|
where: { id: req.params.id, companyId: company.id, isPublished: true },
|
||||||
include: {
|
include: {
|
||||||
@@ -189,7 +311,7 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
|||||||
|
|
||||||
router.get('/:slug/offers', async (req, res, next) => {
|
router.get('/:slug/offers', async (req, res, next) => {
|
||||||
try {
|
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({
|
const offers = await prisma.offer.findMany({
|
||||||
where: {
|
where: {
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router } from 'express'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { uploadImage } from '../lib/cloudinary'
|
import { uploadImage } from '../lib/storage'
|
||||||
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
||||||
import { requireTenant } from '../middleware/requireTenant'
|
import { requireTenant } from '../middleware/requireTenant'
|
||||||
import { requireSubscription } from '../middleware/requireSubscription'
|
import { requireSubscription } from '../middleware/requireSubscription'
|
||||||
@@ -16,7 +16,7 @@ const vehicleSchema = z.object({
|
|||||||
make: z.string().min(1),
|
make: z.string().min(1),
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
year: z.number().int().min(1990).max(new Date().getFullYear() + 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),
|
licensePlate: z.string().min(1),
|
||||||
vin: z.string().optional(),
|
vin: z.string().optional(),
|
||||||
category: z.enum(['ECONOMY','COMPACT','MIDSIZE','FULLSIZE','SUV','LUXURY','VAN','TRUCK']),
|
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 {
|
try {
|
||||||
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||||
const files = req.files as Express.Multer.File[]
|
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] } })
|
const updated = await prisma.vehicle.update({ where: { id: vehicle.id }, data: { photos: [...vehicle.photos, ...urls] } })
|
||||||
res.json({ data: updated })
|
res.json({ data: updated })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
|
|||||||
@@ -1,39 +1,13 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { Webhook } from 'svix'
|
|
||||||
import { EmployeeRole } from '@rentaldrivego/database'
|
|
||||||
import { handleInvitationAccepted } from '../services/teamService'
|
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
router.post('/clerk', async (req, res) => {
|
router.post('/clerk', (_req, res) => {
|
||||||
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET
|
res.status(410).json({
|
||||||
if (!webhookSecret) return res.status(500).json({ error: 'Webhook secret not configured' })
|
error: 'disabled',
|
||||||
|
message: 'Clerk webhooks are disabled because Clerk has been removed from this project.',
|
||||||
const wh = new Webhook(webhookSecret)
|
statusCode: 410,
|
||||||
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 })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
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')
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
import { EmployeeRole } from '@rentaldrivego/database'
|
import { EmployeeRole } from '@rentaldrivego/database'
|
||||||
import { clerkClient } from '@clerk/clerk-sdk-node'
|
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { sendTransactionalEmail } from './notificationService'
|
||||||
|
|
||||||
|
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
|
||||||
|
|
||||||
export interface InvitePayload {
|
export interface InvitePayload {
|
||||||
firstName: string
|
firstName: string
|
||||||
@@ -24,24 +27,33 @@ export interface TeamMember {
|
|||||||
invitationStatus?: 'accepted' | 'pending' | 'revoked'
|
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[]> {
|
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
|
||||||
const employees = await prisma.employee.findMany({
|
const employees = await prisma.employee.findMany({
|
||||||
where: { companyId },
|
where: { companyId },
|
||||||
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
|
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
|
||||||
})
|
})
|
||||||
|
|
||||||
const hydrated = await Promise.allSettled(
|
return employees.map((employee) => ({
|
||||||
employees.map(async (emp: (typeof employees)[number]) => {
|
...employee,
|
||||||
try {
|
lastActiveAt: null,
|
||||||
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
|
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
|
||||||
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] : []))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) {
|
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' })
|
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({
|
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
|
||||||
where: { id: companyId },
|
const rawToken = crypto.randomBytes(32).toString('hex')
|
||||||
include: { brand: { select: { subdomain: true, displayName: true } } },
|
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
|
||||||
})
|
|
||||||
|
|
||||||
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 employee = await prisma.employee.create({
|
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 }) {
|
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 } })
|
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 (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 (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 (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.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 } })
|
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 } })
|
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.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 } })
|
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
|
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 })
|
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 } })
|
await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
|
||||||
|
|
||||||
if (!target.clerkUserId.startsWith('pending_')) {
|
|
||||||
try { await clerkClient.users.unbanUser(target.clerkUserId) } catch { /* non-fatal */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } })
|
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 } })
|
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.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 } })
|
await prisma.employee.delete({ where: { id: employeeId } })
|
||||||
return { success: true }
|
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 },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @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 = {
|
const nextConfig = {
|
||||||
images: {
|
images: {
|
||||||
domains: ['res.cloudinary.com'],
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'res.cloudinary.com',
|
||||||
|
},
|
||||||
|
...storagePatterns,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
transpilePackages: ['@rentaldrivego/types'],
|
transpilePackages: ['@rentaldrivego/types'],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,18 +10,18 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rentaldrivego/types": "*",
|
"@rentaldrivego/types": "*",
|
||||||
"@clerk/nextjs": "^5.0.0",
|
"autoprefixer": "^10.4.19",
|
||||||
|
"dayjs": "^1.11.11",
|
||||||
|
"lucide-react": "^0.376.0",
|
||||||
"next": "14.2.3",
|
"next": "14.2.3",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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",
|
"recharts": "^2.12.7",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
"lucide-react": "^0.376.0"
|
"tailwindcss": "^3.4.3",
|
||||||
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.12.0",
|
"@types/node": "^20.12.0",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { ApiPaginated } from '@rentaldrivego/types'
|
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
|
||||||
interface CustomerRow {
|
interface CustomerRow {
|
||||||
@@ -19,8 +18,8 @@ export default function CustomersPage() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<ApiPaginated<CustomerRow>>('/customers?pageSize=100')
|
apiFetch<CustomerRow[]>('/customers?pageSize=100')
|
||||||
.then((result) => setRows(result.data))
|
.then((result) => setRows(result ?? []))
|
||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import { ArrowLeft, Edit, X, Check, Upload, Trash2 } from 'lucide-react'
|
||||||
import { formatCurrency } from '@rentaldrivego/types'
|
import { formatCurrency } from '@rentaldrivego/types'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
|
||||||
@@ -18,15 +19,78 @@ interface VehicleDetail {
|
|||||||
transmission: string
|
transmission: string
|
||||||
fuelType: string
|
fuelType: string
|
||||||
seats: number
|
seats: number
|
||||||
|
mileage: number | null
|
||||||
licensePlate: string
|
licensePlate: string
|
||||||
photos: string[]
|
photos: string[]
|
||||||
notes: string | null
|
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() {
|
export default function FleetDetailPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
|
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
|
||||||
const [error, setError] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
apiFetch<VehicleDetail>(`/vehicles/${params.id}`)
|
apiFetch<VehicleDetail>(`/vehicles/${params.id}`)
|
||||||
@@ -34,38 +98,400 @@ export default function FleetDetailPage() {
|
|||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
}, [params.id])
|
}, [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 (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>
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* 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>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
|
<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>
|
<p className="text-sm text-slate-500 mt-0.5">{vehicle.year} · {vehicle.licensePlate} · {vehicle.status}</p>
|
||||||
</div>
|
</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]">
|
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
|
||||||
|
{/* Photos */}
|
||||||
<div className="card p-6">
|
<div className="card p-6">
|
||||||
<h3 className="text-base font-semibold text-slate-900 mb-4">Photos</h3>
|
<h3 className="text-base font-semibold text-slate-900 mb-4">Photos</h3>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{/* Existing photos */}
|
||||||
{vehicle.photos.map((photo, index) => (
|
{vehicle.photos.map((photo, index) => (
|
||||||
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100">
|
<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" />
|
<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>
|
</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>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{vehicle.photos.length === 0 && !editing && (
|
||||||
|
<div className="text-sm text-slate-400">No photos uploaded yet.</div>
|
||||||
|
)}
|
||||||
</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">
|
<div className="card p-6">
|
||||||
<h3 className="text-base font-semibold text-slate-900 mb-4">Vehicle details</h3>
|
<h3 className="text-base font-semibold text-slate-900 mb-4">Vehicle details</h3>
|
||||||
|
|
||||||
|
{!editing || !form ? (
|
||||||
<dl className="space-y-3 text-sm">
|
<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">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">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">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">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">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">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>
|
<div><dt className="text-slate-500">Notes</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
|
||||||
</dl>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Plus, Edit, Eye, ChevronDown, Search } from 'lucide-react'
|
import { Plus, Edit, Eye, Search, Upload, X } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { formatCurrency } from '@rentaldrivego/types'
|
import { formatCurrency } from '@rentaldrivego/types'
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import type { ApiPaginated } from '@rentaldrivego/types'
|
|
||||||
|
|
||||||
interface Vehicle {
|
interface Vehicle {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,32 +33,106 @@ const STATUS_BADGE: Record<Vehicle['status'], string> = {
|
|||||||
OUT_OF_SERVICE: 'badge-red',
|
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) {
|
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
|
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 [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
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)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await apiFetch('/vehicles', {
|
const vehicle = await apiFetch<{ id: string }>('/vehicles', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...form,
|
...form,
|
||||||
dailyRate: Math.round(parseFloat(form.dailyRate) * 100),
|
dailyRate: Math.round(parseFloat(form.dailyRate) * 100),
|
||||||
year: Number(form.year),
|
year: Number(form.year),
|
||||||
seats: Number(form.seats),
|
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()
|
onSaved()
|
||||||
onClose()
|
handleClose()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message ?? 'Failed to add vehicle. Please check all fields and try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -68,21 +140,66 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
|||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
|
const presetModels = form.make && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
<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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Make & Model */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Year & Category */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Daily Rate & License Plate */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label>
|
<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 })} />
|
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Color, Seats, Transmission */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
|
||||||
@@ -124,6 +260,9 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fuel Type & Odometer */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label>
|
<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 })}>
|
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
|
||||||
@@ -132,11 +271,62 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
<div>
|
||||||
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</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>
|
||||||
|
|
||||||
|
{/* 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">
|
<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">
|
<button type="submit" disabled={loading} className="btn-primary flex-1">
|
||||||
{loading ? 'Adding…' : 'Add Vehicle'}
|
{loading ? 'Adding…' : 'Add Vehicle'}
|
||||||
</button>
|
</button>
|
||||||
@@ -159,8 +349,8 @@ export default function FleetPage() {
|
|||||||
|
|
||||||
const fetchVehicles = () => {
|
const fetchVehicles = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
apiFetch<ApiPaginated<Vehicle>>('/vehicles?pageSize=100')
|
apiFetch<Vehicle[]>('/vehicles?pageSize=100')
|
||||||
.then((result) => setVehicles(result.data))
|
.then((result) => setVehicles(result ?? []))
|
||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}
|
}
|
||||||
@@ -209,31 +399,19 @@ export default function FleetPage() {
|
|||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select className="input-field w-36" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||||
className="input-field w-36"
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
|
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
|
||||||
<option key={s} value={s}>{s}</option>
|
<option key={s} value={s}>{s}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select className="input-field w-36" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
|
||||||
className="input-field w-36"
|
|
||||||
value={categoryFilter}
|
|
||||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">All categories</option>
|
<option value="">All categories</option>
|
||||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
|
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
|
||||||
<option key={c} value={c}>{c}</option>
|
<option key={c} value={c}>{c}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select className="input-field w-36" value={publishedFilter} onChange={(e) => setPublishedFilter(e.target.value)}>
|
||||||
className="input-field w-36"
|
|
||||||
value={publishedFilter}
|
|
||||||
onChange={(e) => setPublishedFilter(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">All visibility</option>
|
<option value="">All visibility</option>
|
||||||
<option value="published">Published</option>
|
<option value="published">Published</option>
|
||||||
<option value="unpublished">Unpublished</option>
|
<option value="unpublished">Unpublished</option>
|
||||||
@@ -270,7 +448,7 @@ export default function FleetPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-12 h-10 rounded-lg bg-slate-100 overflow-hidden flex-shrink-0">
|
<div className="w-12 h-10 rounded-lg bg-slate-100 overflow-hidden flex-shrink-0">
|
||||||
{vehicle.primaryPhoto ? (
|
{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>
|
<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 { useEffect, useState } from 'react'
|
||||||
import { formatCurrency } from '@rentaldrivego/types'
|
import { formatCurrency } from '@rentaldrivego/types'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||||
|
|
||||||
interface ReportRow {
|
interface ReportRow {
|
||||||
reservationId: string
|
reservationId: string
|
||||||
@@ -55,7 +55,7 @@ export default function ReportsPage() {
|
|||||||
setExporting(true)
|
setExporting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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`, {
|
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function ReservationsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<ApiPaginated<ReservationRow>>('/reservations?pageSize=100')
|
apiFetch<ApiPaginated<ReservationRow>>('/reservations?pageSize=100')
|
||||||
.then((result) => setRows(result.data))
|
.then((result) => setRows(result.data ?? []))
|
||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useUser } from '@clerk/nextjs'
|
|
||||||
import { useTeam, TeamMember } from '@/hooks/useTeam'
|
import { useTeam, TeamMember } from '@/hooks/useTeam'
|
||||||
import InviteModal from '@/components/team/InviteModal'
|
import InviteModal from '@/components/team/InviteModal'
|
||||||
import EditMemberModal from '@/components/team/EditMemberModal'
|
import EditMemberModal from '@/components/team/EditMemberModal'
|
||||||
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
|
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
|
||||||
|
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||||
|
|
||||||
function initials(m: TeamMember) {
|
function initials(m: TeamMember) {
|
||||||
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
|
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
|
||||||
@@ -73,8 +73,8 @@ function StatusBadge({ member }: { member: TeamMember }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamPage() {
|
export default function TeamPage() {
|
||||||
const { user } = useUser()
|
|
||||||
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
|
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
|
||||||
|
const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null)
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [roleFilter, setRoleFilter] = useState<string>('')
|
const [roleFilter, setRoleFilter] = useState<string>('')
|
||||||
@@ -83,7 +83,22 @@ export default function TeamPage() {
|
|||||||
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
|
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
|
||||||
const [toast, setToast] = useState<string | 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'
|
const isOwner = currentEmployee?.role === 'OWNER'
|
||||||
|
|
||||||
function showToast(msg: string) {
|
function showToast(msg: string) {
|
||||||
@@ -247,7 +262,7 @@ export default function TeamPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-zinc-900 dark:text-white">
|
<div className="text-sm font-medium text-zinc-900 dark:text-white">
|
||||||
{member.firstName} {member.lastName}
|
{member.firstName} {member.lastName}
|
||||||
{member.email === user?.primaryEmailAddress?.emailAddress && (
|
{member.id === currentEmployeeId && (
|
||||||
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
|
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import Sidebar from '@/components/layout/Sidebar'
|
import Sidebar from '@/components/layout/Sidebar'
|
||||||
import TopBar from '@/components/layout/TopBar'
|
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 }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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 />
|
<Sidebar />
|
||||||
<div className="ml-64 flex min-h-screen min-w-0 flex-1 flex-col">
|
<div className="ml-64 flex min-h-screen min-w-0 flex-1 flex-col">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||||
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors">
|
<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">
|
<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
|
Workspace preferences
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||||
<DashboardLanguageSwitcher />
|
<DashboardLanguageSwitcher />
|
||||||
|
<DashboardThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html.light {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary: #2563eb;
|
--primary: #2563eb;
|
||||||
--primary-hover: #1d4ed8;
|
--primary-hover: #1d4ed8;
|
||||||
@@ -17,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
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 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
@@ -31,7 +39,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.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 {
|
.btn-danger {
|
||||||
@@ -39,11 +47,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input-field {
|
.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 {
|
.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 {
|
.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;
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { ClerkProvider } from '@clerk/nextjs'
|
|
||||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
|
||||||
import { DashboardI18nProvider } from '@/components/I18nProvider'
|
import { DashboardI18nProvider } from '@/components/I18nProvider'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
@@ -10,13 +8,19 @@ export const metadata: Metadata = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const content = (
|
return (
|
||||||
<html lang="en">
|
<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">
|
<body className="font-sans">
|
||||||
<DashboardI18nProvider>{children}</DashboardI18nProvider>
|
<DashboardI18nProvider>{children}</DashboardI18nProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { SignIn } from '@clerk/nextjs'
|
import { useState } from 'react'
|
||||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||||
import { marketplaceUrl } from '@/lib/urls'
|
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() {
|
export default function SignInPage() {
|
||||||
const { language } = useDashboardI18n()
|
const { language } = useDashboardI18n()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const portal = searchParams.get('portal') === 'admin' ? 'admin' : 'workspace'
|
||||||
const dict = {
|
const dict = {
|
||||||
en: {
|
en: {
|
||||||
workspace: 'Company workspace',
|
workspace: 'Sign in',
|
||||||
access: 'Workspace access',
|
admin: 'Admin sign in',
|
||||||
signIn: 'Sign in',
|
subtitle: 'Enter your credentials to access your workspace.',
|
||||||
useAccount: 'Use your owner or employee account to access the company workspace.',
|
adminSubtitle: 'Use the same sign-in page for platform admin access.',
|
||||||
inactive: 'Enter your workspace credentials to continue.',
|
|
||||||
newAccount: 'New company account?',
|
|
||||||
createWorkspace: 'Create your workspace',
|
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
password: 'Password',
|
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: {
|
fr: {
|
||||||
workspace: 'Espace entreprise',
|
workspace: 'Connexion',
|
||||||
access: 'Accès workspace',
|
admin: 'Connexion admin',
|
||||||
signIn: 'Connexion',
|
subtitle: 'Saisissez vos identifiants pour accéder à votre espace.',
|
||||||
useAccount: 'Utilisez votre compte propriétaire ou employé pour accéder à l’espace entreprise.',
|
adminSubtitle: 'Utilisez cette page unique pour accéder à la plateforme admin.',
|
||||||
inactive: 'Saisissez les identifiants de votre espace pour continuer.',
|
|
||||||
newAccount: 'Nouveau compte entreprise ?',
|
|
||||||
createWorkspace: 'Créer votre espace',
|
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
password: 'Mot de passe',
|
password: 'Mot de passe',
|
||||||
|
signIn: 'Connexion',
|
||||||
|
signingIn: 'Connexion…',
|
||||||
|
verify: 'Vérifier le code',
|
||||||
|
verifying: 'Vérification…',
|
||||||
|
authCode: 'Code d’authentification',
|
||||||
|
enterCode: 'Entrez le code à 6 chiffres de votre application d’authentification.',
|
||||||
|
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: {
|
ar: {
|
||||||
workspace: 'مساحة الشركة',
|
workspace: 'تسجيل الدخول',
|
||||||
access: 'الوصول إلى المساحة',
|
admin: 'دخول الإدارة',
|
||||||
signIn: 'تسجيل الدخول',
|
subtitle: 'أدخل بياناتك للوصول إلى مساحة العمل.',
|
||||||
useAccount: 'استخدم حساب المالك أو الموظف للوصول إلى مساحة الشركة.',
|
adminSubtitle: 'استخدم صفحة الدخول نفسها للوصول إلى لوحة الإدارة.',
|
||||||
inactive: 'أدخل بيانات مساحة العمل للمتابعة.',
|
|
||||||
newAccount: 'حساب شركة جديد؟',
|
|
||||||
createWorkspace: 'أنشئ مساحتك',
|
|
||||||
email: 'البريد الإلكتروني',
|
email: 'البريد الإلكتروني',
|
||||||
password: 'كلمة المرور',
|
password: 'كلمة المرور',
|
||||||
|
signIn: 'تسجيل الدخول',
|
||||||
|
signingIn: 'جارٍ تسجيل الدخول…',
|
||||||
|
verify: 'تحقق من الرمز',
|
||||||
|
verifying: 'جارٍ التحقق…',
|
||||||
|
authCode: 'رمز المصادقة',
|
||||||
|
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||||
|
back: 'العودة إلى بيانات الدخول',
|
||||||
|
forgotPassword: 'نسيت كلمة المرور؟',
|
||||||
|
newAccount: 'حساب شركة جديد؟',
|
||||||
|
createWorkspace: 'أنشئ مساحتك',
|
||||||
|
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
|
||||||
|
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
|
||||||
|
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||||
},
|
},
|
||||||
}[language]
|
}[language]
|
||||||
|
|
||||||
@@ -51,61 +87,258 @@ export default function SignInPage() {
|
|||||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">
|
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">
|
||||||
RentalDriveGo
|
RentalDriveGo
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.workspace}</h1>
|
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">
|
||||||
<p className="mt-2 text-sm text-slate-500">{dict.useAccount}</p>
|
{portal === 'admin' ? dict.admin : dict.workspace}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
{portal === 'admin' ? dict.adminSubtitle : dict.subtitle}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
<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>
|
<LocalSignInForm dict={dict} portal={portal} />
|
||||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.signIn}</h2>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
{clerkFrontendEnabled ? <ConfiguredSignIn /> : <FallbackSignInCard dict={dict} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{portal !== 'admin' ? (
|
||||||
<div className="mt-6 border-t border-slate-200 pt-6 text-sm text-slate-500">
|
<div className="mt-6 border-t border-slate-200 pt-6 text-sm text-slate-500">
|
||||||
{dict.newAccount}
|
{dict.newAccount}
|
||||||
<Link href="/sign-up" className="ml-1 font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
<Link href="/sign-up" className="ml-1 font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||||
{dict.createWorkspace}
|
{dict.createWorkspace}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfiguredSignIn() {
|
function LocalSignInForm({ dict, portal }: {
|
||||||
return (
|
dict: {
|
||||||
<SignIn
|
email: string
|
||||||
routing="path"
|
password: string
|
||||||
path="/sign-in"
|
signIn: string
|
||||||
signUpUrl="/sign-up"
|
signingIn: string
|
||||||
fallbackRedirectUrl="/dashboard"
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function FallbackSignInCard({ dict }: { dict: { inactive: string; email: string; password: string; signIn: string } }) {
|
|
||||||
return (
|
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">
|
{error ? (
|
||||||
{dict.inactive}
|
<div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 'credentials' ? (
|
||||||
|
<form onSubmit={handleCredentials} className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
|
<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" />
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="owner@company.com"
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
|
<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 className="relative">
|
||||||
</div>
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
<button type="button" disabled className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60">
|
required
|
||||||
{dict.signIn}
|
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>
|
</button>
|
||||||
</div>
|
</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 Link from 'next/link'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useSignUp } from '@clerk/nextjs'
|
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
|
||||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
|
||||||
import PublicShell from '@/components/layout/PublicShell'
|
import PublicShell from '@/components/layout/PublicShell'
|
||||||
import { marketplaceUrl } from '@/lib/urls'
|
import { marketplaceUrl } from '@/lib/urls'
|
||||||
|
|
||||||
@@ -29,325 +26,23 @@ type SignupForm = {
|
|||||||
type CompletedSignup = {
|
type CompletedSignup = {
|
||||||
companyName: string
|
companyName: string
|
||||||
email: 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() {
|
export default function SignUpPage() {
|
||||||
return clerkFrontendEnabled ? <ConfiguredSignUpPage /> : <LocalSignUpPage />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfiguredSignUpPage() {
|
|
||||||
const { language } = useDashboardI18n()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const dict = {
|
|
||||||
en: {
|
|
||||||
loading: 'Authentication is still loading. Try again in a moment.',
|
|
||||||
passwordShort: 'Choose a password with at least 8 characters.',
|
|
||||||
passwordMismatch: 'Passwords do not match.',
|
|
||||||
startFailed: 'Could not start account creation',
|
|
||||||
resendFailed: 'Could not resend the verification code',
|
|
||||||
completeFailed: 'Could not verify your email and finish setup',
|
|
||||||
working: 'Working…',
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
loading: 'L’authentification est en cours de chargement. Réessayez dans un instant.',
|
|
||||||
passwordShort: 'Choisissez un mot de passe d’au moins 8 caractères.',
|
|
||||||
passwordMismatch: 'Les mots de passe ne correspondent pas.',
|
|
||||||
startFailed: 'Impossible de démarrer la création du compte',
|
|
||||||
resendFailed: 'Impossible de renvoyer le code de vérification',
|
|
||||||
completeFailed: 'Impossible de vérifier votre email et finaliser la configuration',
|
|
||||||
working: 'Traitement…',
|
|
||||||
},
|
|
||||||
ar: {
|
|
||||||
loading: 'المصادقة ما زالت قيد التحميل. حاول بعد لحظة.',
|
|
||||||
passwordShort: 'اختر كلمة مرور من 8 أحرف على الأقل.',
|
|
||||||
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
|
|
||||||
startFailed: 'تعذر بدء إنشاء الحساب',
|
|
||||||
resendFailed: 'تعذر إعادة إرسال رمز التحقق',
|
|
||||||
completeFailed: 'تعذر التحقق من البريد وإكمال الإعداد',
|
|
||||||
working: 'جارٍ التنفيذ…',
|
|
||||||
},
|
|
||||||
}[language]
|
|
||||||
const { isLoaded, signUp, setActive } = useSignUp()
|
|
||||||
const initialPlan = normalizePlan(searchParams.get('plan'))
|
|
||||||
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
|
|
||||||
const initialCurrency = normalizeCurrency(searchParams.get('currency'))
|
|
||||||
const [step, setStep] = useState(1)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [verificationCode, setVerificationCode] = useState('')
|
|
||||||
const [awaitingVerification, setAwaitingVerification] = useState(false)
|
|
||||||
const [completedSignup, setCompletedSignup] = useState<CompletedSignup | null>(null)
|
|
||||||
const [form, setForm] = useState<SignupForm>({
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
companyName: '',
|
|
||||||
companyPhone: '',
|
|
||||||
city: '',
|
|
||||||
country: '',
|
|
||||||
plan: initialPlan,
|
|
||||||
billingPeriod: initialBillingPeriod,
|
|
||||||
currency: initialCurrency,
|
|
||||||
paymentProvider: 'AMANPAY',
|
|
||||||
})
|
|
||||||
|
|
||||||
async function submitSignup() {
|
|
||||||
if (form.password.length < 8) {
|
|
||||||
setError(dict.passwordShort)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.password !== form.confirmPassword) {
|
|
||||||
setError(dict.passwordMismatch)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isLoaded || !signUp) {
|
|
||||||
setError(dict.loading)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await signUp.create({
|
|
||||||
emailAddress: form.email,
|
|
||||||
password: form.password,
|
|
||||||
firstName: form.firstName,
|
|
||||||
lastName: form.lastName,
|
|
||||||
unsafeMetadata: {
|
|
||||||
signupFlow: 'company-owner',
|
|
||||||
companyName: form.companyName,
|
|
||||||
companyPhone: form.companyPhone || null,
|
|
||||||
city: form.city || null,
|
|
||||||
country: form.country || null,
|
|
||||||
plan: form.plan,
|
|
||||||
billingPeriod: form.billingPeriod,
|
|
||||||
currency: form.currency,
|
|
||||||
paymentProvider: form.paymentProvider,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
|
|
||||||
setAwaitingVerification(true)
|
|
||||||
} catch (err) {
|
|
||||||
setError(getClerkErrorMessage(err, dict.startFailed))
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resendVerificationCode() {
|
|
||||||
if (!signUp) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
|
|
||||||
} catch (err) {
|
|
||||||
setError(getClerkErrorMessage(err, dict.resendFailed))
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function completeSignup() {
|
|
||||||
if (!signUp || !setActive) {
|
|
||||||
setError(dict.loading)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await signUp.attemptEmailAddressVerification({ code: verificationCode })
|
|
||||||
|
|
||||||
if (result.status !== 'complete' || !result.createdSessionId) {
|
|
||||||
throw new Error('Email verification is not complete yet.')
|
|
||||||
}
|
|
||||||
|
|
||||||
await setActive({ session: result.createdSessionId })
|
|
||||||
await waitForActiveSession()
|
|
||||||
|
|
||||||
await apiFetch('/auth/company/complete-signup', {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
|
|
||||||
setCompletedSignup({
|
|
||||||
companyName: form.companyName,
|
|
||||||
email: form.email,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
setError(getClerkErrorMessage(err, dict.completeFailed))
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completedSignup) {
|
|
||||||
return (
|
|
||||||
<PublicShell>
|
|
||||||
<main className="flex-1 px-4 py-16">
|
|
||||||
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
|
|
||||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
|
|
||||||
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">Workspace ready</h1>
|
|
||||||
<p className="mt-4 text-base leading-7 text-slate-600">
|
|
||||||
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> is now active and the owner email
|
|
||||||
<span className="font-semibold text-slate-900"> {completedSignup.email}</span> has been registered.
|
|
||||||
</p>
|
|
||||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
|
||||||
<Link href="/dashboard" className="btn-primary justify-center">
|
|
||||||
Enter dashboard
|
|
||||||
</Link>
|
|
||||||
<Link href="/sign-in" className="btn-secondary justify-center">
|
|
||||||
Sign in on another device
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</PublicShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (awaitingVerification) {
|
|
||||||
return (
|
|
||||||
<PublicShell>
|
|
||||||
<main className="flex-1 px-4 py-16">
|
|
||||||
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
|
|
||||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
|
|
||||||
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">Confirm your email</h1>
|
|
||||||
<p className="mt-4 text-base leading-7 text-slate-600">
|
|
||||||
We sent a verification code to <span className="font-semibold text-slate-900">{form.email}</span>. Enter it here to finish creating your workspace.
|
|
||||||
</p>
|
|
||||||
<div className="mt-8 space-y-5">
|
|
||||||
<LabeledInput label="Verification code" value={verificationCode} onChange={setVerificationCode} />
|
|
||||||
{error ? <div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<button type="button" onClick={() => setAwaitingVerification(false)} className="btn-secondary">
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
|
||||||
<button type="button" onClick={resendVerificationCode} disabled={loading} className="btn-secondary disabled:cursor-not-allowed disabled:opacity-60">
|
|
||||||
{loading ? 'Sending…' : 'Resend code'}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={completeSignup} disabled={loading || !verificationCode.trim()} className="btn-primary disabled:cursor-not-allowed disabled:opacity-60">
|
|
||||||
{loading ? 'Verifying…' : 'Verify and create workspace'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</PublicShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PublicShell>
|
|
||||||
<main className="px-4 py-12">
|
|
||||||
<div className="mx-auto max-w-3xl space-y-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
|
|
||||||
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">Launch your rental workspace</h1>
|
|
||||||
<p className="mt-4 text-base leading-7 text-slate-600">Create the owner account, verify your email, and start your 14-day free trial.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-4">
|
|
||||||
{['Account', 'Company', 'Plan', 'Verify'].map((label, index) => (
|
|
||||||
<div key={label} className={`rounded-full px-4 py-2 text-center text-sm font-semibold ${index + 1 <= step ? 'bg-slate-900 text-white' : 'border border-slate-200 bg-white text-slate-500'}`}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
|
||||||
{error ? <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
|
||||||
|
|
||||||
{step === 1 ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<SectionIntro title="Owner account" body="This becomes the primary owner account for the company workspace." />
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<LabeledInput label="First name" value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
|
|
||||||
<LabeledInput label="Last name" value={form.lastName} onChange={(value) => setForm((current) => ({ ...current, lastName: value }))} />
|
|
||||||
</div>
|
|
||||||
<LabeledInput label="Email" type="email" value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: value }))} />
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<LabeledInput label="Password" type="password" value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
|
|
||||||
<LabeledInput label="Confirm password" type="password" value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500">Use at least 8 characters so the owner account can sign in directly after verification.</p>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button type="button" onClick={() => setStep(2)} className="btn-primary">Continue</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{step === 2 ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<SectionIntro title="Company details" body="We’ll use these details for your trial workspace and public marketplace profile." />
|
|
||||||
<LabeledInput label="Company name" value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<LabeledInput label="Phone" value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
|
|
||||||
<LabeledInput label="Country" value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
|
|
||||||
</div>
|
|
||||||
<LabeledInput label="City" value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
|
|
||||||
<NavActions onBack={() => setStep(1)} onNext={() => setStep(3)} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{step === 3 ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<SectionIntro title="Choose your plan" body="Your workspace starts immediately on a 14-day free trial in your preferred billing currency." />
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
|
||||||
{['STARTER', 'GROWTH', 'PRO'].map((plan) => (
|
|
||||||
<button
|
|
||||||
key={plan}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setForm((current) => ({ ...current, plan: plan as SignupForm['plan'] }))}
|
|
||||||
className={`rounded-3xl border p-5 text-left ${form.plan === plan ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-900'}`}
|
|
||||||
>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em]">{plan}</p>
|
|
||||||
<p className="mt-3 text-sm opacity-80">{plan === 'STARTER' ? 'Core fleet, offers, and bookings.' : plan === 'GROWTH' ? 'Featured marketplace placement and more room to scale.' : 'Full white-label and premium controls.'}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
|
||||||
<LabeledSelect label="Billing period" value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
|
|
||||||
<LabeledSelect label="Currency" value={form.currency} options={['MAD', 'USD', 'EUR']} onChange={(value) => setForm((current) => ({ ...current, currency: value as SignupForm['currency'] }))} />
|
|
||||||
<LabeledSelect label="Primary provider" value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
|
|
||||||
</div>
|
|
||||||
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{step === 4 ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<SectionIntro title="Verify and launch" body="We’ll create the owner account in Clerk first, email you a verification code, then provision the workspace after that email is confirmed." />
|
|
||||||
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
|
|
||||||
<p><span className="font-semibold text-slate-900">Workspace:</span> {form.companyName || 'Your company'}</p>
|
|
||||||
<p className="mt-2"><span className="font-semibold text-slate-900">Plan:</span> {form.plan} · {form.billingPeriod} · {form.currency}</p>
|
|
||||||
<p className="mt-2"><span className="font-semibold text-slate-900">Owner email:</span> {form.email || '—'}</p>
|
|
||||||
</div>
|
|
||||||
<NavActions
|
|
||||||
onBack={() => setStep(3)}
|
|
||||||
onNext={submitSignup}
|
|
||||||
loading={loading}
|
|
||||||
nextLabel="Send verification code"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</PublicShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LocalSignUpPage() {
|
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const initialPlan = normalizePlan(searchParams.get('plan'))
|
const initialPlan = normalizePlan(searchParams.get('plan'))
|
||||||
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
|
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
|
||||||
@@ -387,12 +82,13 @@ function LocalSignUpPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiFetch('/auth/company/signup', {
|
const response = await apiFetch<SignupResponse>('/auth/company/signup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
firstName: form.firstName,
|
firstName: form.firstName,
|
||||||
lastName: form.lastName,
|
lastName: form.lastName,
|
||||||
email: form.email,
|
email: form.email,
|
||||||
|
password: form.password,
|
||||||
companyName: form.companyName,
|
companyName: form.companyName,
|
||||||
companyPhone: form.companyPhone || undefined,
|
companyPhone: form.companyPhone || undefined,
|
||||||
city: form.city || undefined,
|
city: form.city || undefined,
|
||||||
@@ -407,9 +103,10 @@ function LocalSignUpPage() {
|
|||||||
setCompletedSignup({
|
setCompletedSignup({
|
||||||
companyName: form.companyName,
|
companyName: form.companyName,
|
||||||
email: form.email,
|
email: form.email,
|
||||||
|
emailWarning: getEmailWarning(response.emailDelivery),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getClerkErrorMessage(err, 'Could not create workspace'))
|
setError(getErrorMessage(err, 'Could not create workspace'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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.companyName}</span> is now active and the owner email
|
||||||
<span className="font-semibold text-slate-900"> {completedSignup.email}</span> has been registered.
|
<span className="font-semibold text-slate-900"> {completedSignup.email}</span> has been registered.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8">
|
{completedSignup.emailWarning ? (
|
||||||
<Link href="/sign-up" className="btn-primary justify-center">
|
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
Create another workspace
|
{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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -517,7 +222,7 @@ function LocalSignUpPage() {
|
|||||||
|
|
||||||
{step === 4 ? (
|
{step === 4 ? (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<SectionIntro title="Verify and launch" body="We’ll create the company workspace immediately and start the 14-day trial with the plan you selected." />
|
<SectionIntro title="Review and launch" body="We’ll create the company workspace immediately and start the 14-day trial with the plan you selected." />
|
||||||
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
|
<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><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">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 }) {
|
function LabeledInput({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (value: string) => void; type?: string }) {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<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>
|
<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)}>
|
<select className="input-field" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>{option}</option>
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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 (
|
return (
|
||||||
<div className="flex justify-between gap-3">
|
<div className="flex gap-3">
|
||||||
{onBack ? <button type="button" onClick={onBack} className="btn-secondary">Back</button> : <span />}
|
{onBack ? (
|
||||||
<button type="button" onClick={onNext} disabled={loading || disableNext} className="btn-primary disabled:cursor-not-allowed disabled:opacity-60">
|
<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}
|
{loading ? 'Working…' : nextLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -583,50 +304,15 @@ function NavActions({ onBack, onNext, loading = false, nextLabel = 'Continue', d
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizePlan(value: string | null): SignupForm['plan'] {
|
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'] {
|
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'] {
|
function normalizeCurrency(value: string | null): SignupForm['currency'] {
|
||||||
return value === 'USD' || value === 'EUR' ? value : 'MAD'
|
if (value === 'USD' || value === 'EUR' || value === 'MAD') return value
|
||||||
}
|
return '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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
export type DashboardLanguage = 'en' | 'fr' | 'ar'
|
export type DashboardLanguage = 'en' | 'fr' | 'ar'
|
||||||
|
export type DashboardTheme = 'light' | 'dark'
|
||||||
|
|
||||||
type DashboardDictionary = {
|
type DashboardDictionary = {
|
||||||
nav: Record<string, string>
|
nav: Record<string, string>
|
||||||
@@ -11,9 +12,10 @@ type DashboardDictionary = {
|
|||||||
noNewNotifications: string
|
noNewNotifications: string
|
||||||
unreadNotifications: (count: number) => string
|
unreadNotifications: (count: number) => string
|
||||||
signOut: string
|
signOut: string
|
||||||
demoUser: string
|
workspaceUser: string
|
||||||
clerkDisabled: string
|
localAuth: string
|
||||||
language: string
|
language: string
|
||||||
|
theme: string
|
||||||
light: string
|
light: string
|
||||||
dark: string
|
dark: string
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
fleet: 'Fleet',
|
fleet: 'Fleet',
|
||||||
reservations: 'Reservations',
|
reservations: 'Reservations',
|
||||||
|
onlineReservations: 'Online Reservations',
|
||||||
customers: 'Customers',
|
customers: 'Customers',
|
||||||
offers: 'Offers',
|
offers: 'Offers',
|
||||||
team: 'Team',
|
team: 'Team',
|
||||||
@@ -36,6 +39,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
'/dashboard': 'Dashboard',
|
'/dashboard': 'Dashboard',
|
||||||
'/dashboard/fleet': 'Fleet Management',
|
'/dashboard/fleet': 'Fleet Management',
|
||||||
'/dashboard/reservations': 'Reservations',
|
'/dashboard/reservations': 'Reservations',
|
||||||
|
'/dashboard/online-reservations': 'Online Reservations',
|
||||||
'/dashboard/customers': 'Customers',
|
'/dashboard/customers': 'Customers',
|
||||||
'/dashboard/offers': 'Offers',
|
'/dashboard/offers': 'Offers',
|
||||||
'/dashboard/team': 'Team',
|
'/dashboard/team': 'Team',
|
||||||
@@ -47,9 +51,10 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
noNewNotifications: 'No new notifications',
|
noNewNotifications: 'No new notifications',
|
||||||
unreadNotifications: (count) => `${count} unread notification${count > 1 ? 's' : ''}`,
|
unreadNotifications: (count) => `${count} unread notification${count > 1 ? 's' : ''}`,
|
||||||
signOut: 'Sign out',
|
signOut: 'Sign out',
|
||||||
demoUser: 'Demo User',
|
workspaceUser: 'Workspace user',
|
||||||
clerkDisabled: 'Clerk disabled in local dev',
|
localAuth: 'Local authentication',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
|
theme: 'Theme',
|
||||||
light: 'Light',
|
light: 'Light',
|
||||||
dark: 'Dark',
|
dark: 'Dark',
|
||||||
},
|
},
|
||||||
@@ -58,6 +63,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
dashboard: 'Tableau de bord',
|
dashboard: 'Tableau de bord',
|
||||||
fleet: 'Flotte',
|
fleet: 'Flotte',
|
||||||
reservations: 'Réservations',
|
reservations: 'Réservations',
|
||||||
|
onlineReservations: 'Réservations en ligne',
|
||||||
customers: 'Clients',
|
customers: 'Clients',
|
||||||
offers: 'Offres',
|
offers: 'Offres',
|
||||||
team: 'Équipe',
|
team: 'Équipe',
|
||||||
@@ -70,6 +76,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
'/dashboard': 'Tableau de bord',
|
'/dashboard': 'Tableau de bord',
|
||||||
'/dashboard/fleet': 'Gestion de flotte',
|
'/dashboard/fleet': 'Gestion de flotte',
|
||||||
'/dashboard/reservations': 'Réservations',
|
'/dashboard/reservations': 'Réservations',
|
||||||
|
'/dashboard/online-reservations': 'Réservations en ligne',
|
||||||
'/dashboard/customers': 'Clients',
|
'/dashboard/customers': 'Clients',
|
||||||
'/dashboard/offers': 'Offres',
|
'/dashboard/offers': 'Offres',
|
||||||
'/dashboard/team': 'Équipe',
|
'/dashboard/team': 'Équipe',
|
||||||
@@ -81,9 +88,10 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
noNewNotifications: 'Aucune nouvelle notification',
|
noNewNotifications: 'Aucune nouvelle notification',
|
||||||
unreadNotifications: (count) => `${count} notification${count > 1 ? 's' : ''} non lue${count > 1 ? 's' : ''}`,
|
unreadNotifications: (count) => `${count} notification${count > 1 ? 's' : ''} non lue${count > 1 ? 's' : ''}`,
|
||||||
signOut: 'Déconnexion',
|
signOut: 'Déconnexion',
|
||||||
demoUser: 'Utilisateur démo',
|
workspaceUser: 'Utilisateur espace',
|
||||||
clerkDisabled: 'Clerk désactivé en local',
|
localAuth: 'Authentification locale',
|
||||||
language: 'Langue',
|
language: 'Langue',
|
||||||
|
theme: 'Mode',
|
||||||
light: 'Clair',
|
light: 'Clair',
|
||||||
dark: 'Sombre',
|
dark: 'Sombre',
|
||||||
},
|
},
|
||||||
@@ -92,6 +100,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
dashboard: 'لوحة التحكم',
|
dashboard: 'لوحة التحكم',
|
||||||
fleet: 'الأسطول',
|
fleet: 'الأسطول',
|
||||||
reservations: 'الحجوزات',
|
reservations: 'الحجوزات',
|
||||||
|
onlineReservations: 'الحجوزات الإلكترونية',
|
||||||
customers: 'العملاء',
|
customers: 'العملاء',
|
||||||
offers: 'العروض',
|
offers: 'العروض',
|
||||||
team: 'الفريق',
|
team: 'الفريق',
|
||||||
@@ -104,6 +113,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
'/dashboard': 'لوحة التحكم',
|
'/dashboard': 'لوحة التحكم',
|
||||||
'/dashboard/fleet': 'إدارة الأسطول',
|
'/dashboard/fleet': 'إدارة الأسطول',
|
||||||
'/dashboard/reservations': 'الحجوزات',
|
'/dashboard/reservations': 'الحجوزات',
|
||||||
|
'/dashboard/online-reservations': 'الحجوزات الإلكترونية',
|
||||||
'/dashboard/customers': 'العملاء',
|
'/dashboard/customers': 'العملاء',
|
||||||
'/dashboard/offers': 'العروض',
|
'/dashboard/offers': 'العروض',
|
||||||
'/dashboard/team': 'الفريق',
|
'/dashboard/team': 'الفريق',
|
||||||
@@ -115,9 +125,10 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
noNewNotifications: 'لا توجد إشعارات جديدة',
|
noNewNotifications: 'لا توجد إشعارات جديدة',
|
||||||
unreadNotifications: (count) => `${count} إشعار غير مقروء`,
|
unreadNotifications: (count) => `${count} إشعار غير مقروء`,
|
||||||
signOut: 'تسجيل الخروج',
|
signOut: 'تسجيل الخروج',
|
||||||
demoUser: 'مستخدم تجريبي',
|
workspaceUser: 'مستخدم مساحة العمل',
|
||||||
clerkDisabled: 'Clerk غير مفعّل محلياً',
|
localAuth: 'مصادقة محلية',
|
||||||
language: 'اللغة',
|
language: 'اللغة',
|
||||||
|
theme: 'الوضع',
|
||||||
light: 'فاتح',
|
light: 'فاتح',
|
||||||
dark: 'داكن',
|
dark: 'داكن',
|
||||||
},
|
},
|
||||||
@@ -126,6 +137,8 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
type I18nContextValue = {
|
type I18nContextValue = {
|
||||||
language: DashboardLanguage
|
language: DashboardLanguage
|
||||||
setLanguage: (value: DashboardLanguage) => void
|
setLanguage: (value: DashboardLanguage) => void
|
||||||
|
theme: DashboardTheme
|
||||||
|
setTheme: (value: DashboardTheme) => void
|
||||||
dict: DashboardDictionary
|
dict: DashboardDictionary
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,12 +146,24 @@ const I18nContext = createContext<I18nContextValue | null>(null)
|
|||||||
|
|
||||||
export function DashboardI18nProvider({ children }: { children: React.ReactNode }) {
|
export function DashboardI18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [language, setLanguage] = useState<DashboardLanguage>('en')
|
const [language, setLanguage] = useState<DashboardLanguage>('en')
|
||||||
|
const [theme, setTheme] = useState<DashboardTheme>('light')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = window.localStorage.getItem('dashboard-language')
|
const stored = window.localStorage.getItem('dashboard-language')
|
||||||
|
const storedTheme = window.localStorage.getItem('dashboard-theme')
|
||||||
|
|
||||||
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||||
setLanguage(stored)
|
setLanguage(stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||||
|
setTheme(storedTheme)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
setTheme('dark')
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -147,7 +172,18 @@ export function DashboardI18nProvider({ children }: { children: React.ReactNode
|
|||||||
window.localStorage.setItem('dashboard-language', language)
|
window.localStorage.setItem('dashboard-language', 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>
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +204,7 @@ export function DashboardLanguageSwitcher() {
|
|||||||
if (embedded) return null
|
if (embedded) return null
|
||||||
|
|
||||||
return (
|
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">
|
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||||
{dict.language}
|
{dict.language}
|
||||||
</span>
|
</span>
|
||||||
@@ -180,7 +216,9 @@ export function DashboardLanguageSwitcher() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setLanguage(value)}
|
onClick={() => setLanguage(value)}
|
||||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
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()}
|
{value.toUpperCase()}
|
||||||
@@ -190,3 +228,39 @@ export function DashboardLanguageSwitcher() {
|
|||||||
</div>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { DashboardLanguageSwitcher, useDashboardI18n } from '@/components/I18nProvider'
|
import {
|
||||||
|
DashboardLanguageSwitcher,
|
||||||
|
DashboardThemeSwitcher,
|
||||||
|
useDashboardI18n,
|
||||||
|
} from '@/components/I18nProvider'
|
||||||
import { marketplaceUrl } from '@/lib/urls'
|
import { marketplaceUrl } from '@/lib/urls'
|
||||||
|
|
||||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||||
@@ -28,31 +32,32 @@ export default function PublicShell({ children }: { children: React.ReactNode })
|
|||||||
}[language]
|
}[language]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)]">
|
<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">
|
<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">
|
<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="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>
|
</Link>
|
||||||
<nav className="flex items-center gap-2">
|
<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}
|
{dict.signIn}
|
||||||
</Link>
|
</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}
|
{dict.createWorkspace}
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div>{children}</div>
|
<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">
|
<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}
|
{dict.preferences}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||||
<DashboardLanguageSwitcher />
|
<DashboardLanguageSwitcher />
|
||||||
|
<DashboardThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Car,
|
Car,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Globe,
|
||||||
Users,
|
Users,
|
||||||
Tag,
|
Tag,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
@@ -15,15 +16,15 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useClerk, useUser } from '@clerk/nextjs'
|
|
||||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
|
||||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||||
import { marketplaceUrl } from '@/lib/urls'
|
import { marketplaceUrl } from '@/lib/urls'
|
||||||
|
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
|
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
|
||||||
{ href: '/dashboard/fleet', key: 'fleet', icon: Car },
|
{ href: '/dashboard/fleet', key: 'fleet', icon: Car },
|
||||||
{ href: '/dashboard/reservations', key: 'reservations', icon: Calendar },
|
{ href: '/dashboard/reservations', key: 'reservations', icon: Calendar },
|
||||||
|
{ href: '/dashboard/online-reservations', key: 'onlineReservations', icon: Globe },
|
||||||
{ href: '/dashboard/customers', key: 'customers', icon: Users },
|
{ href: '/dashboard/customers', key: 'customers', icon: Users },
|
||||||
{ href: '/dashboard/offers', key: 'offers', icon: Tag },
|
{ href: '/dashboard/offers', key: 'offers', icon: Tag },
|
||||||
{ href: '/dashboard/team', key: 'team', icon: UserPlus },
|
{ href: '/dashboard/team', key: 'team', icon: UserPlus },
|
||||||
@@ -33,29 +34,32 @@ const NAV_ITEMS = [
|
|||||||
{ href: '/dashboard/settings', key: 'settings', icon: Settings },
|
{ href: '/dashboard/settings', key: 'settings', icon: Settings },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function SidebarWithClerk() {
|
export default function Sidebar() {
|
||||||
const { dict } = useDashboardI18n()
|
const { dict } = useDashboardI18n()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { signOut } = useClerk()
|
const router = useRouter()
|
||||||
const { user } = useUser()
|
|
||||||
|
|
||||||
const isActive = (item: typeof NAV_ITEMS[0]) => {
|
const isActive = (item: typeof NAV_ITEMS[0]) => {
|
||||||
if (item.exact) return pathname === item.href
|
if (item.exact) return pathname === item.href
|
||||||
return pathname.startsWith(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 (
|
return (
|
||||||
<aside className="fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40">
|
<aside className="fixed inset-y-0 left-0 z-40 flex w-64 flex-col bg-slate-900">
|
||||||
{/* Logo */}
|
<Link href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
|
||||||
<Link href={marketplaceUrl} className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-500">
|
||||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
<Car className="h-4 w-4 text-white" />
|
||||||
<Car className="w-4 h-4 text-white" />
|
|
||||||
</div>
|
</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>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation */}
|
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
const active = isActive(item)
|
const active = isActive(item)
|
||||||
@@ -64,101 +68,35 @@ function SidebarWithClerk() {
|
|||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={[
|
className={[
|
||||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||||
active
|
active ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white',
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-800',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||||
{dict.nav[item.key]}
|
{dict.nav[item.key]}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User menu */}
|
<div className="border-t border-slate-800 px-3 py-4">
|
||||||
<div className="px-3 py-4 border-t border-slate-800">
|
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
|
||||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg">
|
<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">
|
||||||
<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">
|
W
|
||||||
{user?.firstName?.[0]?.toUpperCase() ?? 'U'}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-white truncate">
|
<p className="truncate text-sm font-medium text-white">{dict.workspaceUser}</p>
|
||||||
{user?.firstName} {user?.lastName}
|
<p className="truncate text-xs text-slate-400">{dict.localAuth}</p>
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-400 truncate">
|
|
||||||
{user?.primaryEmailAddress?.emailAddress}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut()}
|
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"
|
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}
|
{dict.signOut}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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 />
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,113 +4,54 @@ import { Bell } from 'lucide-react'
|
|||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { useUser } from '@clerk/nextjs'
|
|
||||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
|
||||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
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() {
|
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>
|
||||||
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
|
<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 === '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."}
|
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -3,25 +3,19 @@ const API_BASE =
|
|||||||
?? process.env.NEXT_PUBLIC_API_URL
|
?? process.env.NEXT_PUBLIC_API_URL
|
||||||
?? 'http://localhost:4000/api/v1'
|
?? '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 {
|
try {
|
||||||
// In Next.js App Router, the Clerk session token is available via the Clerk SDK
|
return localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||||
// 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
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
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 isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,21 +1,26 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { clerkMiddlewareEnabled } from '@/lib/clerk'
|
|
||||||
|
|
||||||
const isProtectedRoute = createRouteMatcher([
|
function isProtectedRoute(req: NextRequest) {
|
||||||
'/dashboard(.*)',
|
return req.nextUrl.pathname === '/dashboard' || req.nextUrl.pathname.startsWith('/dashboard/')
|
||||||
'/onboarding(.*)',
|
}
|
||||||
])
|
|
||||||
|
|
||||||
export default clerkMiddlewareEnabled
|
function localJwtMiddleware(req: NextRequest): NextResponse {
|
||||||
? clerkMiddleware((auth, req) => {
|
if (!isProtectedRoute(req)) return NextResponse.next()
|
||||||
if (isProtectedRoute(req)) {
|
|
||||||
auth().protect()
|
// 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)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
: function middleware() {
|
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function middleware(req: NextRequest) {
|
||||||
|
return localJwtMiddleware(req)
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||||
import { formatCurrency } from '@rentaldrivego/types'
|
import ExploreVehicleGrid from './ExploreVehicleGrid'
|
||||||
|
|
||||||
interface Vehicle {
|
interface Vehicle {
|
||||||
id: string
|
id: string
|
||||||
@@ -55,10 +55,12 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
|||||||
en: {
|
en: {
|
||||||
kicker: 'Discovery only',
|
kicker: 'Discovery only',
|
||||||
title: 'Find your next rental from trusted local companies.',
|
title: 'Find your next rental from trusted local companies.',
|
||||||
body: 'Every listing links you to the company’s own booking and payment flow.',
|
body: 'Browse, filter, and reserve — the company confirms and contacts you directly.',
|
||||||
city: 'City or location',
|
city: 'City or location',
|
||||||
allCategories: 'All categories',
|
allCategories: 'All categories',
|
||||||
anyTransmission: 'Any transmission',
|
anyTransmission: 'Any transmission',
|
||||||
|
makePlaceholder: 'Make (e.g. Toyota)',
|
||||||
|
modelPlaceholder: 'Model (e.g. Corolla)',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
currentDeals: 'Current deals',
|
currentDeals: 'Current deals',
|
||||||
featuredOffers: 'Featured marketplace offers',
|
featuredOffers: 'Featured marketplace offers',
|
||||||
@@ -71,7 +73,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
|||||||
checkAvailability: 'Check availability',
|
checkAvailability: 'Check availability',
|
||||||
available: 'Available',
|
available: 'Available',
|
||||||
from: 'From',
|
from: 'From',
|
||||||
bookNow: 'Book now',
|
bookNow: 'Reserve',
|
||||||
details: 'Details',
|
details: 'Details',
|
||||||
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
|
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
|
||||||
browseByCategory: 'Browse by category',
|
browseByCategory: 'Browse by category',
|
||||||
@@ -83,19 +85,38 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
|||||||
publishedVehicles: 'published vehicles',
|
publishedVehicles: 'published vehicles',
|
||||||
viewFleet: 'View fleet',
|
viewFleet: 'View fleet',
|
||||||
categories: ['Economy', 'Compact', 'SUV', 'Luxury', 'Van', 'Truck', 'Electric'],
|
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: {
|
fr: {
|
||||||
kicker: 'Découverte uniquement',
|
kicker: 'Découverte uniquement',
|
||||||
title: 'Trouvez votre prochaine location auprès d’entreprises locales fiables.',
|
title: "Trouvez votre prochaine location auprès d'entreprises locales fiables.",
|
||||||
body: 'Chaque annonce vous redirige vers le parcours de réservation et de paiement propre à l’entreprise.',
|
body: "Parcourez, filtrez et réservez — l'entreprise confirme et vous contacte directement.",
|
||||||
city: 'Ville ou emplacement',
|
city: 'Ville ou emplacement',
|
||||||
allCategories: 'Toutes les catégories',
|
allCategories: 'Toutes les catégories',
|
||||||
anyTransmission: 'Toute transmission',
|
anyTransmission: 'Toute transmission',
|
||||||
|
makePlaceholder: 'Marque (ex. Toyota)',
|
||||||
|
modelPlaceholder: 'Modèle (ex. Corolla)',
|
||||||
search: 'Rechercher',
|
search: 'Rechercher',
|
||||||
currentDeals: 'Offres du moment',
|
currentDeals: 'Offres du moment',
|
||||||
featuredOffers: 'Offres marketplace mises en avant',
|
featuredOffers: 'Offres marketplace mises en avant',
|
||||||
company: 'Entreprise',
|
company: 'Entreprise',
|
||||||
validUntil: 'Valable jusqu’au',
|
validUntil: "Valable jusqu'au",
|
||||||
offersUnavailable: 'Les offres marketplace sont indisponibles pour le moment.',
|
offersUnavailable: 'Les offres marketplace sont indisponibles pour le moment.',
|
||||||
availableVehicles: 'Véhicules disponibles',
|
availableVehicles: 'Véhicules disponibles',
|
||||||
listings: 'annonces',
|
listings: 'annonces',
|
||||||
@@ -115,14 +136,32 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
|||||||
publishedVehicles: 'véhicules publiés',
|
publishedVehicles: 'véhicules publiés',
|
||||||
viewFleet: 'Voir la flotte',
|
viewFleet: 'Voir la flotte',
|
||||||
categories: ['Économie', 'Compacte', 'SUV', 'Luxe', 'Van', 'Camion', 'Électrique'],
|
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: {
|
ar: {
|
||||||
kicker: 'للاستكشاف فقط',
|
kicker: 'للاستكشاف فقط',
|
||||||
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
|
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
|
||||||
body: 'كل إعلان يوجّهك إلى مسار الحجز والدفع الخاص بالشركة نفسها.',
|
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
|
||||||
city: 'المدينة أو الموقع',
|
city: 'المدينة أو الموقع',
|
||||||
allCategories: 'كل الفئات',
|
allCategories: 'كل الفئات',
|
||||||
anyTransmission: 'أي ناقل حركة',
|
anyTransmission: 'أي ناقل حركة',
|
||||||
|
makePlaceholder: 'الماركة (مثل Toyota)',
|
||||||
|
modelPlaceholder: 'الموديل (مثل Corolla)',
|
||||||
search: 'بحث',
|
search: 'بحث',
|
||||||
currentDeals: 'العروض الحالية',
|
currentDeals: 'العروض الحالية',
|
||||||
featuredOffers: 'عروض السوق المميزة',
|
featuredOffers: 'عروض السوق المميزة',
|
||||||
@@ -135,7 +174,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
|||||||
checkAvailability: 'تحقق من التوفر',
|
checkAvailability: 'تحقق من التوفر',
|
||||||
available: 'متاح',
|
available: 'متاح',
|
||||||
from: 'ابتداءً من',
|
from: 'ابتداءً من',
|
||||||
bookNow: 'احجز الآن',
|
bookNow: 'احجز',
|
||||||
details: 'التفاصيل',
|
details: 'التفاصيل',
|
||||||
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
||||||
browseByCategory: 'تصفح حسب الفئة',
|
browseByCategory: 'تصفح حسب الفئة',
|
||||||
@@ -147,15 +186,37 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
|||||||
publishedVehicles: 'سيارات منشورة',
|
publishedVehicles: 'سيارات منشورة',
|
||||||
viewFleet: 'عرض الأسطول',
|
viewFleet: 'عرض الأسطول',
|
||||||
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
|
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
|
||||||
|
reserveVehicle: 'احجز هذه السيارة',
|
||||||
|
pickupDate: 'تاريخ الاستلام',
|
||||||
|
returnDate: 'تاريخ الإرجاع',
|
||||||
|
firstName: 'الاسم الأول',
|
||||||
|
lastName: 'اسم العائلة',
|
||||||
|
email: 'البريد الإلكتروني',
|
||||||
|
phone: 'رقم الهاتف (اختياري)',
|
||||||
|
notes: 'ملاحظات إضافية (اختياري)',
|
||||||
|
cancel: 'إلغاء',
|
||||||
|
submitRequest: 'إرسال طلب الحجز',
|
||||||
|
submitting: 'جارٍ الإرسال…',
|
||||||
|
successTitle: 'تم إرسال الطلب!',
|
||||||
|
successBody: 'تم استلام طلب الحجز. تحقق من بريدك الإلكتروني — ستتواصل معك الشركة قريباً.',
|
||||||
|
close: 'إغلاق',
|
||||||
|
errorUnavailable: 'السيارة غير متاحة في التواريخ المحددة. يرجى اختيار تواريخ أخرى.',
|
||||||
|
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||||
},
|
},
|
||||||
}[language]
|
}[language]
|
||||||
|
|
||||||
const city = typeof searchParams?.city === 'string' ? searchParams.city : ''
|
const city = typeof searchParams?.city === 'string' ? searchParams.city : ''
|
||||||
const category = typeof searchParams?.category === 'string' ? searchParams.category : ''
|
const category = typeof searchParams?.category === 'string' ? searchParams.category : ''
|
||||||
const transmission = typeof searchParams?.transmission === 'string' ? searchParams.transmission : ''
|
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()
|
const query = new URLSearchParams()
|
||||||
if (city) query.set('city', city)
|
if (city) query.set('city', city)
|
||||||
if (category) query.set('category', category)
|
if (category) query.set('category', category)
|
||||||
if (transmission) query.set('transmission', transmission)
|
if (transmission) query.set('transmission', transmission)
|
||||||
|
if (make) query.set('make', make)
|
||||||
|
if (model) query.set('model', model)
|
||||||
query.set('pageSize', '24')
|
query.set('pageSize', '24')
|
||||||
|
|
||||||
const [offers, vehicles, companies] = await Promise.all([
|
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>
|
<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>
|
<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>
|
<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="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">
|
<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>
|
<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>
|
||||||
<select name="transmission" defaultValue={transmission} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900">
|
<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>
|
<option value="">{dict.anyTransmission}</option>
|
||||||
@@ -203,54 +268,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<ExploreVehicleGrid vehicles={vehicles} dict={dict} />
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-2xl font-bold text-stone-900">{dict.availableVehicles}</h2>
|
|
||||||
<p className="text-sm text-stone-500">{vehicles.length} {dict.listings}</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{vehicles.map((vehicle) => (
|
|
||||||
<article key={vehicle.id} className="card overflow-hidden">
|
|
||||||
<div className="aspect-[16/10] bg-stone-100">
|
|
||||||
{vehicle.photos[0] ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="p-5">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
|
||||||
<h3 className="mt-2 text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
|
|
||||||
<p className="mt-1 text-sm text-stone-500">{vehicle.year} · {vehicle.category}</p>
|
|
||||||
</div>
|
|
||||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'}`}>
|
|
||||||
{vehicle.availability === false ? dict.checkAvailability : dict.available}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 flex items-end justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-stone-500">{dict.from}</p>
|
|
||||||
<p className="text-2xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<a
|
|
||||||
href={`https://${vehicle.company.brand?.subdomain ?? vehicle.company.slug}.RentalDriveGo.com/book?vehicleId=${vehicle.id}&ref=marketplace`}
|
|
||||||
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white"
|
|
||||||
>
|
|
||||||
{dict.bookNow}
|
|
||||||
</a>
|
|
||||||
<Link href={`/explore/${vehicle.company.slug}/vehicles/${vehicle.id}`} className="rounded-full border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-700">
|
|
||||||
{dict.details}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
{vehicles.length === 0 && <div className="card p-6 text-sm text-stone-500 md:col-span-2 xl:col-span-3">{dict.vehicleUnavailable}</div>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-2xl font-bold text-stone-900">{dict.browseByCategory}</h2>
|
<h2 className="text-2xl font-bold text-stone-900">{dict.browseByCategory}</h2>
|
||||||
|
|||||||
@@ -29,3 +29,15 @@ export async function marketplaceFetchOrDefault<T>(path: string, fallback: T): P
|
|||||||
return fallback
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html.light {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-white text-slate-900 antialiased;
|
@apply bg-white text-slate-900 antialiased;
|
||||||
}
|
}
|
||||||
@@ -13,3 +21,78 @@ body {
|
|||||||
.card {
|
.card {
|
||||||
@apply rounded-2xl border border-slate-200 bg-white shadow-sm;
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
|
|||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
import { getPublicSiteDictionary } from '@/lib/i18n'
|
||||||
import PublicLanguageSwitcher from '@/components/PublicLanguageSwitcher'
|
import PublicLanguageSwitcher from '@/components/PublicLanguageSwitcher'
|
||||||
|
import PublicThemeSwitcher from '@/components/PublicThemeSwitcher'
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
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)
|
const bookCarLabel = resolveText(menuConfig?.bookCarLabel, dict.nav.bookCar)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={dict.lang} dir={dict.dir}>
|
<html lang={dict.lang} dir={dict.dir} className="light" suppressHydrationWarning>
|
||||||
<body>
|
<head>
|
||||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur-sm">
|
<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">
|
<div className="shell flex h-14 items-center justify-between gap-6">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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">
|
<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()}
|
{companyName.charAt(0).toUpperCase()}
|
||||||
@@ -91,9 +100,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<PublicLanguageSwitcher currentLanguage={language} />
|
<PublicLanguageSwitcher currentLanguage={language} />
|
||||||
|
<PublicThemeSwitcher currentLanguage={language} />
|
||||||
<Link
|
<Link
|
||||||
href="/vehicles"
|
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}
|
{bookCarLabel}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -103,20 +113,20 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
|
|
||||||
{children}
|
{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">
|
<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}
|
{footerLabel}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
<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) => (
|
{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}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<p className="text-sm text-stone-500">
|
<p className="text-sm text-stone-500 dark:text-stone-400">
|
||||||
© {new Date().getFullYear()} {companyName}
|
© {new Date().getFullYear()} {companyName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +141,7 @@ function NavLink({ href, children }: { href: string; children: React.ReactNode }
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
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}
|
{children}
|
||||||
</Link>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
Generated
+528
-557
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
|||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"type-check": "turbo type-check",
|
"type-check": "turbo type-check",
|
||||||
|
"clerk:keys": "bash scripts/setup-clerk-keys.sh",
|
||||||
"db:generate": "turbo db:generate",
|
"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: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",
|
"db:push": "turbo db:push",
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- Empty placeholder migration (no schema changes)
|
||||||
|
SELECT 1;
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "passwordHash" TEXT;
|
||||||
+7
@@ -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"
|
||||||
@@ -443,6 +443,9 @@ model Employee {
|
|||||||
lastName String
|
lastName String
|
||||||
email String
|
email String
|
||||||
phone String?
|
phone String?
|
||||||
|
passwordHash String?
|
||||||
|
passwordResetToken String? @unique
|
||||||
|
passwordResetExpiresAt DateTime?
|
||||||
role EmployeeRole @default(AGENT)
|
role EmployeeRole @default(AGENT)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
@@ -1038,6 +1041,8 @@ model AdminUser {
|
|||||||
totpEnabled Boolean @default(false)
|
totpEnabled Boolean @default(false)
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
lastLoginIp String?
|
lastLoginIp String?
|
||||||
|
passwordResetToken String? @unique
|
||||||
|
passwordResetExpiresAt DateTime?
|
||||||
|
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
permissions AdminPermission[]
|
permissions AdminPermission[]
|
||||||
|
|||||||
Executable
+112
@@ -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}..."
|
||||||
Reference in New Issue
Block a user