fixing platform admin
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
|
||||
type Currency = 'MAD' | 'USD' | 'EUR'
|
||||
|
||||
interface Subscription {
|
||||
id: string
|
||||
plan: Plan
|
||||
billingPeriod: BillingPeriod
|
||||
status: string
|
||||
currency: Currency
|
||||
trialEndAt: string | null
|
||||
currentPeriodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string
|
||||
amount: number
|
||||
currency: Currency
|
||||
status: string
|
||||
paymentProvider: string
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
TRIALING: 'bg-sky-100 text-sky-700',
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
PAST_DUE: 'bg-amber-100 text-amber-700',
|
||||
CANCELLED: 'bg-slate-100 text-slate-600',
|
||||
UNPAID: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const INVOICE_STATUS: Record<string, string> = {
|
||||
PAID: 'bg-green-100 text-green-700',
|
||||
PENDING: 'bg-amber-100 text-amber-700',
|
||||
FAILED: 'bg-red-100 text-red-700',
|
||||
REFUNDED: 'bg-slate-100 text-slate-600',
|
||||
}
|
||||
|
||||
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||
const PLAN_FEATURES: Record<Plan, string[]> = {
|
||||
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
|
||||
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
|
||||
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
|
||||
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
|
||||
const [currency, setCurrency] = useState<Currency>('MAD')
|
||||
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
|
||||
const [paying, setPaying] = useState(false)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
apiFetch<Subscription | null>('/subscriptions/me'),
|
||||
apiFetch<Invoice[]>('/subscriptions/invoices'),
|
||||
])
|
||||
.then(([sub, inv]) => {
|
||||
if (sub) {
|
||||
setSubscription(sub)
|
||||
setSelectedPlan(sub.plan)
|
||||
setBillingPeriod(sub.billingPeriod)
|
||||
setCurrency(sub.currency as Currency)
|
||||
}
|
||||
setInvoices(inv ?? [])
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function handleCheckout() {
|
||||
setPaying(true)
|
||||
setError(null)
|
||||
try {
|
||||
const base = window.location.origin
|
||||
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
plan: selectedPlan,
|
||||
billingPeriod,
|
||||
currency,
|
||||
provider,
|
||||
successUrl: `${base}/dashboard/billing?payment=success`,
|
||||
failureUrl: `${base}/dashboard/billing?payment=failed`,
|
||||
}),
|
||||
})
|
||||
window.location.href = result.checkoutUrl
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setPaying(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
setCancelling(true)
|
||||
setError(null)
|
||||
try {
|
||||
const sub = await apiFetch<Subscription>('/subscriptions/cancel', { method: 'POST' })
|
||||
setSubscription(sub)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResume() {
|
||||
setCancelling(true)
|
||||
setError(null)
|
||||
try {
|
||||
const sub = await apiFetch<Subscription>('/subscriptions/resume', { method: 'POST' })
|
||||
setSubscription(sub)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const planPrice = PLAN_PRICES[selectedPlan]?.[billingPeriod]?.[currency]
|
||||
const daysLeft = subscription?.trialEndAt
|
||||
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Billing</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Manage your plan, payment provider, and invoice history.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="card p-4 border-red-200 bg-red-50 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Trial banner */}
|
||||
{subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (
|
||||
<div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-sky-800">
|
||||
Free trial — <strong>{daysLeft} days</strong> remaining. Subscribe before it ends to keep access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current plan */}
|
||||
{subscription && (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Current plan</p>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{subscription.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{subscription.billingPeriod} · {subscription.currency}
|
||||
{subscription.currentPeriodEnd && ` · renews ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
|
||||
</p>
|
||||
{subscription.cancelAtPeriodEnd && (
|
||||
<p className="mt-2 text-sm font-medium text-amber-700">
|
||||
Cancellation scheduled at end of billing period.{' '}
|
||||
<button onClick={handleResume} disabled={cancelling} className="underline">Undo</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!subscription.cancelAtPeriodEnd && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={cancelling}
|
||||
className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm"
|
||||
>
|
||||
{cancelling ? 'Cancelling…' : 'Cancel plan'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan selector + checkout */}
|
||||
<div className="card p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">
|
||||
{subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe'}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Select a plan and payment provider to proceed.</p>
|
||||
</div>
|
||||
|
||||
{/* Billing period toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(['MONTHLY', 'ANNUAL'] as BillingPeriod[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setBillingPeriod(p)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
billingPeriod === p ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{p === 'MONTHLY' ? 'Monthly' : 'Annual (save ~17%)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Currency selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCurrency(c)}
|
||||
className={`px-3 py-1 rounded-lg text-sm font-medium border transition-colors ${
|
||||
currency === c ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{PLANS.map((plan) => {
|
||||
const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency]
|
||||
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
|
||||
return (
|
||||
<button
|
||||
key={plan}
|
||||
onClick={() => setSelectedPlan(plan)}
|
||||
className={`text-left p-5 rounded-xl border-2 transition-all ${
|
||||
selectedPlan === plan
|
||||
? 'border-blue-500 bg-blue-50/50'
|
||||
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||||
} ${isActive ? 'ring-2 ring-green-200' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold text-slate-900">{plan}</p>
|
||||
{isActive && <span className="badge-green">Active</span>}
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-black text-slate-900">
|
||||
{price ? formatCurrency(price, currency) : '—'}
|
||||
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? 'mo' : 'yr'}</span>
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{PLAN_FEATURES[plan].map((f) => (
|
||||
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
|
||||
<span className="text-green-500">✓</span> {f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Provider selector */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 mb-2">Payment provider</p>
|
||||
<div className="flex gap-3">
|
||||
{(['AMANPAY', 'PAYPAL'] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setProvider(p)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
provider === p ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{p === 'AMANPAY' ? '🏦 AmanPay' : '🔵 PayPal'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkout CTA */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Total</p>
|
||||
<p className="text-xl font-black text-slate-900">
|
||||
{planPrice ? formatCurrency(planPrice, currency) : '—'}
|
||||
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? 'month' : 'year'}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
disabled={paying || loading}
|
||||
className="btn-primary px-8 py-3"
|
||||
>
|
||||
{paying ? 'Redirecting…' : subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice history */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-base font-semibold text-slate-900">Invoice history</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Date</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Provider</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Paid</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">Loading…</td></tr>
|
||||
) : invoices.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">No invoices yet.</td></tr>
|
||||
) : invoices.map((inv) => (
|
||||
<tr key={inv.id}>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{inv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">
|
||||
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
|
||||
{formatCurrency(inv.amount, inv.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ApiPaginated } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface CustomerRow {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
flagged: boolean
|
||||
licenseValidationStatus: string
|
||||
}
|
||||
|
||||
export default function CustomersPage() {
|
||||
const [rows, setRows] = useState<CustomerRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ApiPaginated<CustomerRow>>('/customers?pageSize=100')
|
||||
.then((result) => setRows(result.data))
|
||||
.catch((err) => setError(err.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Customers</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Company-scoped CRM with license validation and risk flags.</p>
|
||||
</div>
|
||||
<div className="card overflow-hidden">
|
||||
{error ? (
|
||||
<div className="p-8 text-sm text-red-600">{error}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Contact</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">License</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="px-6 py-4 text-sm font-semibold text-slate-900">{row.firstName} {row.lastName}</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm text-slate-700">{row.email}</p>
|
||||
<p className="text-xs text-slate-500">{row.phone ?? 'No phone'}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4"><span className="badge-gray">{row.licenseValidationStatus}</span></td>
|
||||
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">Flagged</span> : <span className="badge-green">Clear</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">No customers yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface VehicleDetail {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
dailyRate: number
|
||||
status: string
|
||||
color: string
|
||||
transmission: string
|
||||
fuelType: string
|
||||
seats: number
|
||||
licensePlate: string
|
||||
photos: string[]
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export default function FleetDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<VehicleDetail>(`/vehicles/${params.id}`)
|
||||
.then(setVehicle)
|
||||
.catch((err) => setError(err.message))
|
||||
}, [params.id])
|
||||
|
||||
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
|
||||
if (!vehicle) return <div className="card p-6 text-sm text-slate-500">Loading vehicle…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">{vehicle.year} · {vehicle.licensePlate} · {vehicle.status}</p>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-4">Photos</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{vehicle.photos.map((photo, index) => (
|
||||
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100">
|
||||
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" />
|
||||
</div>
|
||||
))}
|
||||
{vehicle.photos.length === 0 && <div className="text-sm text-slate-400">No photos uploaded yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-4">Vehicle details</h3>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div><dt className="text-slate-500">Category</dt><dd className="text-slate-900">{vehicle.category}</dd></div>
|
||||
<div><dt className="text-slate-500">Daily rate</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Seats</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
|
||||
<div><dt className="text-slate-500">Transmission</dt><dd className="text-slate-900">{vehicle.transmission}</dd></div>
|
||||
<div><dt className="text-slate-500">Fuel type</dt><dd className="text-slate-900">{vehicle.fuelType}</dd></div>
|
||||
<div><dt className="text-slate-500">Color</dt><dd className="text-slate-900">{vehicle.color}</dd></div>
|
||||
<div><dt className="text-slate-500">Notes</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import Link from 'next/link'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import dayjs from 'dayjs'
|
||||
import type { ApiPaginated } from '@rentaldrivego/types'
|
||||
|
||||
interface Vehicle {
|
||||
id: string
|
||||
@@ -90,7 +91,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Category</label>
|
||||
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'CONVERTIBLE', 'PICKUP'].map((c) => (
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -126,7 +127,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label>
|
||||
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
|
||||
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID', 'LPG'].map((f) => (
|
||||
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'].map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -158,8 +159,8 @@ export default function FleetPage() {
|
||||
|
||||
const fetchVehicles = () => {
|
||||
setLoading(true)
|
||||
apiFetch<Vehicle[]>('/vehicles')
|
||||
.then(setVehicles)
|
||||
apiFetch<ApiPaginated<Vehicle>>('/vehicles?pageSize=100')
|
||||
.then((result) => setVehicles(result.data))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
@@ -224,7 +225,7 @@ export default function FleetPage() {
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'CONVERTIBLE', 'PICKUP'].map((c) => (
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
type NotificationItem = {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
body: string
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type PreferenceItem = {
|
||||
notificationType: string
|
||||
channel: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const COMPANY_EVENTS = [
|
||||
'NEW_RESERVATION',
|
||||
'RESERVATION_CANCELLED',
|
||||
'PAYMENT_RECEIVED',
|
||||
'SUBSCRIPTION_TRIAL_ENDING',
|
||||
'MAINTENANCE_DUE',
|
||||
'OFFER_EXPIRING',
|
||||
]
|
||||
|
||||
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||
|
||||
export default function DashboardNotificationsPage() {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([])
|
||||
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
|
||||
const [activeTab, setActiveTab] = useState<'inbox' | 'preferences'>('inbox')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [notificationData, preferenceData] = await Promise.all([
|
||||
apiFetch<NotificationItem[]>('/notifications/company'),
|
||||
apiFetch<PreferenceItem[]>('/notifications/company/preferences'),
|
||||
])
|
||||
setNotifications(notificationData)
|
||||
const mapped = Object.fromEntries(
|
||||
preferenceData.map((item) => [`${item.notificationType}:${item.channel}`, item.enabled]),
|
||||
)
|
||||
setPreferences(mapped)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load notifications')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function markRead(id: string) {
|
||||
await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' })
|
||||
setNotifications((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
await apiFetch('/notifications/company/read-all', { method: 'POST' })
|
||||
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
|
||||
}
|
||||
|
||||
async function savePreferences() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const body = Object.entries(preferences).map(([key, enabled]) => {
|
||||
const [notificationType, channel] = key.split(':')
|
||||
return { notificationType, channel, enabled }
|
||||
})
|
||||
await apiFetch('/notifications/company/preferences', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save preferences')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function preferenceValue(notificationType: string, channel: string) {
|
||||
return preferences[`${notificationType}:${channel}`] ?? true
|
||||
}
|
||||
|
||||
function setPreference(notificationType: string, channel: string, enabled: boolean) {
|
||||
setPreferences((current) => ({
|
||||
...current,
|
||||
[`${notificationType}:${channel}`]: enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-slate-900">Notifications</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Track in-app alerts and control delivery preferences for your team account.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('inbox')}
|
||||
className={activeTab === 'inbox' ? 'btn-primary' : 'btn-secondary'}
|
||||
>
|
||||
Inbox
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('preferences')}
|
||||
className={activeTab === 'preferences' ? 'btn-primary' : 'btn-secondary'}
|
||||
>
|
||||
Preferences
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="card p-4 text-sm text-red-600">{error}</div> : null}
|
||||
|
||||
{activeTab === 'inbox' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={markAllRead} className="btn-secondary">
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
{loading ? <div className="card p-6 text-sm text-slate-500">Loading notifications…</div> : null}
|
||||
{!loading && notifications.length === 0 ? <div className="card p-6 text-sm text-slate-500">No notifications yet.</div> : null}
|
||||
{!loading
|
||||
? notifications.map((notification) => (
|
||||
<article key={notification.id} className="card p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
{notification.type.replaceAll('_', ' ')}
|
||||
</p>
|
||||
<h2 className="mt-2 text-lg font-semibold text-slate-900">{notification.title}</h2>
|
||||
</div>
|
||||
{notification.readAt ? (
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Read</span>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
|
||||
Mark as read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600">{notification.body}</p>
|
||||
<p className="mt-4 text-xs text-slate-400">{new Date(notification.createdAt).toLocaleString()}</p>
|
||||
</article>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-600">Event</th>
|
||||
{COMPANY_CHANNELS.map((channel) => (
|
||||
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600">
|
||||
{channel.replace('_', ' ')}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPANY_EVENTS.map((eventName) => (
|
||||
<tr key={eventName} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-medium text-slate-900">{eventName.replaceAll('_', ' ')}</td>
|
||||
{COMPANY_CHANNELS.map((channel) => (
|
||||
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferenceValue(eventName, channel)}
|
||||
onChange={(event) => setPreference(eventName, channel, event.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-100 p-4">
|
||||
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save preferences'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface OfferRow {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
discountValue: number
|
||||
isActive: boolean
|
||||
isPublic: boolean
|
||||
isFeatured: boolean
|
||||
validUntil: string
|
||||
promoCode: string | null
|
||||
}
|
||||
|
||||
export default function OffersPage() {
|
||||
const [rows, setRows] = useState<OfferRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<OfferRow[]>('/offers')
|
||||
.then(setRows)
|
||||
.catch((err) => setError(err.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Offers</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Promotions shown on your site and optionally on the marketplace.</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((offer) => (
|
||||
<div key={offer.id} className="card p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">{offer.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">{offer.type} · ends {dayjs(offer.validUntil).format('MMM D, YYYY')}</p>
|
||||
</div>
|
||||
<span className={offer.isActive ? 'badge-green' : 'badge-gray'}>{offer.isActive ? 'Active' : 'Inactive'}</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{offer.isPublic && <span className="badge-blue">Marketplace</span>}
|
||||
{offer.isFeatured && <span className="badge-purple">Featured</span>}
|
||||
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
|
||||
</div>
|
||||
<p className="mt-4 text-2xl font-bold text-slate-900">
|
||||
{offer.type === 'PERCENTAGE' ? `${offer.discountValue}%` : formatCurrency(offer.discountValue, 'MAD')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && !error && (
|
||||
<div className="card p-8 text-sm text-slate-400">No offers created yet.</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface ReportRow {
|
||||
reservationId: string
|
||||
customerName: string
|
||||
vehicle: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
totalAmount: number
|
||||
source: string
|
||||
paymentStatus: string
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
summary: {
|
||||
totalReservations: number
|
||||
totalRentalRevenue: number
|
||||
totalDiscounts: number
|
||||
totalInsurance: number
|
||||
totalAdditionalDrivers: number
|
||||
totalCollected: number
|
||||
totalPricingRulesAdj?: number
|
||||
}
|
||||
rows: ReportRow[]
|
||||
}
|
||||
|
||||
type ReportPeriod = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'ANNUAL'
|
||||
|
||||
const periodLabels: Record<ReportPeriod, string> = {
|
||||
WEEKLY: 'Weekly',
|
||||
MONTHLY: 'Monthly',
|
||||
QUARTERLY: 'Quarterly',
|
||||
ANNUAL: 'Annual',
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ReportData>(`/analytics/report?period=${period}`)
|
||||
.then(setReport)
|
||||
.catch((err) => setError(err.message))
|
||||
}, [period])
|
||||
|
||||
async function exportCsv() {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const token = (window as any).__clerk?.session ? await (window as any).__clerk.session.getToken() : null
|
||||
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null)
|
||||
throw new Error(json?.message ?? 'Export failed')
|
||||
}
|
||||
const csv = await res.text()
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `rentaldrivego-${period.toLowerCase()}-report.csv`
|
||||
anchor.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Export failed')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Reports</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">Accountant-ready exports with insurance, additional-driver, and pricing-rule totals.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(periodLabels) as ReportPeriod[]).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setPeriod(option)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold ${period === option ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-700'}`}
|
||||
>
|
||||
{periodLabels[option]}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={exportCsv} disabled={exporting} className="rounded-full border border-slate-900 px-4 py-2 text-sm font-semibold text-slate-900">
|
||||
{exporting ? 'Exporting…' : 'Export CSV'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
|
||||
|
||||
{report && (
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Bookings</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Rental</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Insurance</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Drivers</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Discounts</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Collected</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Reservation</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Vehicle</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Period</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Source</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Payment</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{(report?.rows ?? []).map((row) => (
|
||||
<tr key={row.reservationId}>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.customerName}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{row.startDate} - {row.endDate}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
|
||||
<td className="px-6 py-4"><span className="badge-gray">{row.paymentStatus}</span></td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
|
||||
</tr>
|
||||
))}
|
||||
{(report?.rows.length ?? 0) === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">No report rows available for this period.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import DamageInspectionCard, { DamageInspection } from '@/components/reservations/DamageInspectionCard'
|
||||
|
||||
interface ReservationDetail {
|
||||
id: string
|
||||
status: string
|
||||
source: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
totalAmount: number
|
||||
discountAmount: number
|
||||
insuranceTotal: number
|
||||
additionalDriverTotal: number
|
||||
pricingRulesTotal: number
|
||||
paymentStatus: string
|
||||
pricingRulesApplied: { name: string; amount: number; type: string }[] | null
|
||||
customer: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
driverLicense: string | null
|
||||
licenseValidationStatus: string
|
||||
flagged: boolean
|
||||
}
|
||||
vehicle: {
|
||||
make: string
|
||||
model: string
|
||||
licensePlate: string
|
||||
}
|
||||
insurances: { id: string; policyName: string; totalCharge: number }[]
|
||||
additionalDrivers: {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
driverLicense: string
|
||||
totalCharge: number
|
||||
requiresApproval: boolean
|
||||
approvedAt: string | null
|
||||
approvalNote: string | null
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function ReservationDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const [reservation, setReservation] = useState<ReservationDetail | null>(null)
|
||||
const [inspections, setInspections] = useState<DamageInspection[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [acting, setActing] = useState(false)
|
||||
|
||||
async function loadReservation() {
|
||||
try {
|
||||
const [reservationData, inspectionData] = await Promise.all([
|
||||
apiFetch<ReservationDetail>(`/reservations/${params.id}`),
|
||||
apiFetch<DamageInspection[]>(`/reservations/${params.id}/inspections`),
|
||||
])
|
||||
setReservation(reservationData)
|
||||
setInspections(inspectionData)
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load reservation')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadReservation()
|
||||
}, [params.id])
|
||||
|
||||
async function runAction(action: 'confirm' | 'checkin' | 'checkout') {
|
||||
setActing(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
await apiFetch(`/reservations/${params.id}/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
await loadReservation()
|
||||
} catch (err: any) {
|
||||
setActionError(err.message ?? `Failed to ${action} reservation`)
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function approveDriver(driverId: string) {
|
||||
setActing(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
await apiFetch(`/reservations/${params.id}/additional-drivers/${driverId}/approval`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ approved: true }),
|
||||
})
|
||||
await loadReservation()
|
||||
} catch (err: any) {
|
||||
setActionError(err.message ?? 'Failed to approve additional driver')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
|
||||
if (!reservation) return <div className="card p-6 text-sm text-slate-500">Loading reservation…</div>
|
||||
|
||||
const checkinInspection = inspections.find((inspection) => inspection.type === 'CHECKIN')
|
||||
const checkoutInspection = inspections.find((inspection) => inspection.type === 'CHECKOUT')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Reservation {reservation.id.slice(-8).toUpperCase()}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">{reservation.source} · {reservation.status} · {reservation.paymentStatus}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{reservation.status === 'DRAFT' && (
|
||||
<button disabled={acting} onClick={() => runAction('confirm')} className="btn-primary">
|
||||
{acting ? 'Working…' : 'Confirm reservation'}
|
||||
</button>
|
||||
)}
|
||||
{reservation.status === 'CONFIRMED' && (
|
||||
<button disabled={acting} onClick={() => runAction('checkin')} className="btn-primary">
|
||||
{acting ? 'Working…' : 'Check in vehicle'}
|
||||
</button>
|
||||
)}
|
||||
{reservation.status === 'ACTIVE' && (
|
||||
<button disabled={acting} onClick={() => runAction('checkout')} className="btn-primary">
|
||||
{acting ? 'Working…' : 'Check out vehicle'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionError && <div className="card p-4 text-sm text-red-600">{actionError}</div>}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">Customer</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium text-slate-900">{reservation.customer.firstName} {reservation.customer.lastName}</p>
|
||||
<p className="text-slate-600">{reservation.customer.email}</p>
|
||||
<p className="text-slate-600">{reservation.customer.phone ?? 'No phone provided'}</p>
|
||||
<p className="text-slate-600">License: {reservation.customer.driverLicense ?? 'Not captured'}</p>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
|
||||
{reservation.customer.flagged && <span className="badge-red">Flagged</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">Vehicle</h3>
|
||||
<div className="space-y-2 text-sm text-slate-600">
|
||||
<p className="font-medium text-slate-900">{reservation.vehicle.make} {reservation.vehicle.model}</p>
|
||||
<p>{reservation.vehicle.licensePlate}</p>
|
||||
<p>{dayjs(reservation.startDate).format('MMM D, YYYY')} - {dayjs(reservation.endDate).format('MMM D, YYYY')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">Charges</h3>
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-slate-500">Discount</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Insurance</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Additional drivers</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Pricing adjustments</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Grand total</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
|
||||
</dl>
|
||||
|
||||
{reservation.insurances.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-sm font-semibold text-slate-900">Applied insurance</p>
|
||||
<div className="space-y-2">
|
||||
{reservation.insurances.map((insurance) => (
|
||||
<div key={insurance.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
|
||||
<span className="text-slate-700">{insurance.policyName}</span>
|
||||
<span className="font-semibold text-slate-900">{formatCurrency(insurance.totalCharge, 'MAD')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-sm font-semibold text-slate-900">Pricing rules applied</p>
|
||||
<div className="space-y-2">
|
||||
{reservation.pricingRulesApplied.map((rule) => (
|
||||
<div key={`${rule.name}-${rule.amount}`} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
|
||||
<span className="text-slate-700">{rule.name}</span>
|
||||
<span className={rule.amount < 0 ? 'font-semibold text-emerald-700' : 'font-semibold text-amber-700'}>
|
||||
{rule.amount < 0 ? '-' : '+'}{formatCurrency(Math.abs(rule.amount), 'MAD')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DamageInspectionCard
|
||||
reservationId={reservation.id}
|
||||
type="CHECKIN"
|
||||
initialInspection={checkinInspection}
|
||||
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
|
||||
/>
|
||||
<DamageInspectionCard
|
||||
reservationId={reservation.id}
|
||||
type="CHECKOUT"
|
||||
initialInspection={checkoutInspection}
|
||||
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">Additional drivers</h3>
|
||||
<div className="space-y-3">
|
||||
{reservation.additionalDrivers.map((driver) => (
|
||||
<div key={driver.id} className="rounded-2xl border border-slate-200 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{driver.firstName} {driver.lastName}</p>
|
||||
<p className="text-sm text-slate-500">License: {driver.driverLicense}</p>
|
||||
<p className="mt-1 text-sm text-slate-600">Charge: {formatCurrency(driver.totalCharge, 'MAD')}</p>
|
||||
</div>
|
||||
{driver.requiresApproval && !driver.approvedAt ? (
|
||||
<button onClick={() => approveDriver(driver.id)} disabled={acting} className="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold text-white">
|
||||
Approve
|
||||
</button>
|
||||
) : (
|
||||
<span className={driver.approvedAt ? 'badge-green' : 'badge-gray'}>
|
||||
{driver.approvedAt ? 'Approved' : 'No approval needed'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{driver.approvalNote && <p className="mt-2 text-xs text-amber-700">{driver.approvalNote}</p>}
|
||||
</div>
|
||||
))}
|
||||
{reservation.additionalDrivers.length === 0 && (
|
||||
<div className="text-sm text-slate-400">No additional drivers were added to this reservation.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">Inspection summary</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<span className="text-slate-600">Check-in inspection</span>
|
||||
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? 'Saved' : 'Pending'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<span className="text-slate-600">Check-out inspection</span>
|
||||
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? 'Saved' : 'Pending'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface ReservationRow {
|
||||
id: string
|
||||
status: string
|
||||
source: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
totalAmount: number
|
||||
totalDays: number
|
||||
vehicle: { make: string; model: string }
|
||||
customer: { firstName: string; lastName: string; email: string }
|
||||
}
|
||||
|
||||
export default function ReservationsPage() {
|
||||
const [rows, setRows] = useState<ReservationRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ApiPaginated<ReservationRow>>('/reservations?pageSize=100')
|
||||
.then((result) => setRows(result.data))
|
||||
.catch((err) => setError(err.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Reservations</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">All booking sources, including dashboard, public site, and marketplace.</p>
|
||||
</div>
|
||||
<div className="card overflow-hidden">
|
||||
{error ? (
|
||||
<div className="p-8 text-sm text-red-600">{error}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Dates</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Source</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="px-6 py-4">
|
||||
<Link href={`/dashboard/reservations/${row.id}`} className="text-sm font-semibold text-slate-900 hover:text-blue-700">
|
||||
{row.customer.firstName} {row.customer.lastName}
|
||||
</Link>
|
||||
<p className="text-xs text-slate-500">{row.customer.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle.make} {row.vehicle.model}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{dayjs(row.startDate).format('MMM D')} - {dayjs(row.endDate).format('MMM D, YYYY')}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
|
||||
<td className="px-6 py-4"><span className="badge-blue">{row.status}</span></td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">No reservations yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface BrandSettings {
|
||||
displayName: string
|
||||
tagline: string | null
|
||||
primaryColor: string
|
||||
accentColor?: string | null
|
||||
publicEmail: string | null
|
||||
publicPhone: string | null
|
||||
publicAddress?: string | null
|
||||
publicCity: string | null
|
||||
publicCountry?: string | null
|
||||
subdomain: string
|
||||
logoUrl?: string | null
|
||||
heroImageUrl?: string | null
|
||||
websiteUrl?: string | null
|
||||
whatsappNumber?: string | null
|
||||
paypalEmail: string | null
|
||||
amanpayMerchantId: string | null
|
||||
amanpaySecretKey?: string | null
|
||||
paypalMerchantId?: string | null
|
||||
customDomain?: string | null
|
||||
customDomainVerified?: boolean
|
||||
defaultCurrency?: string
|
||||
isListedOnMarketplace: boolean
|
||||
}
|
||||
|
||||
interface ContractSettings {
|
||||
fuelPolicyType: string
|
||||
fuelPolicyNote: string | null
|
||||
additionalDriverCharge: 'FREE' | 'PER_DAY' | 'FLAT'
|
||||
additionalDriverDailyRate: number
|
||||
additionalDriverFlatRate: number
|
||||
damagePolicy: string
|
||||
}
|
||||
|
||||
interface InsurancePolicy {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
chargeType: string
|
||||
chargeValue: number
|
||||
isRequired: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface PricingRule {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
condition: string
|
||||
conditionValue: number
|
||||
adjustmentType: string
|
||||
adjustmentValue: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface AccountingSettings {
|
||||
reportingPeriod: string
|
||||
accountantEmail: string | null
|
||||
accountantName: string | null
|
||||
autoSendReport: boolean
|
||||
reportFormat: string
|
||||
}
|
||||
|
||||
const emptyInsurance = { name: '', type: 'BASIC', chargeType: 'PER_DAY', chargeValue: 0, isRequired: false, isActive: true }
|
||||
const emptyRule = { name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 0, isActive: true }
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [brand, setBrand] = useState<BrandSettings | null>(null)
|
||||
const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null)
|
||||
const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([])
|
||||
const [pricingRules, setPricingRules] = useState<PricingRule[]>([])
|
||||
const [accountingSettings, setAccountingSettings] = useState<AccountingSettings | null>(null)
|
||||
const [newInsurance, setNewInsurance] = useState(emptyInsurance)
|
||||
const [newRule, setNewRule] = useState(emptyRule)
|
||||
const [customDomain, setCustomDomain] = useState('')
|
||||
const [uploadingAsset, setUploadingAsset] = useState<'logo' | 'hero' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [brandData, contractData, insuranceData, ruleData, accountingData] = await Promise.all([
|
||||
apiFetch<BrandSettings | null>('/companies/me/brand'),
|
||||
apiFetch<ContractSettings | null>('/companies/me/contract-settings'),
|
||||
apiFetch<InsurancePolicy[]>('/companies/me/insurance-policies'),
|
||||
apiFetch<PricingRule[]>('/companies/me/pricing-rules'),
|
||||
apiFetch<AccountingSettings | null>('/companies/me/accounting-settings'),
|
||||
])
|
||||
setBrand(brandData)
|
||||
setCustomDomain(brandData?.customDomain ?? '')
|
||||
setContractSettings(contractData ?? {
|
||||
fuelPolicyType: 'FULL_TO_FULL',
|
||||
fuelPolicyNote: '',
|
||||
additionalDriverCharge: 'FREE',
|
||||
additionalDriverDailyRate: 0,
|
||||
additionalDriverFlatRate: 0,
|
||||
damagePolicy: '',
|
||||
})
|
||||
setInsurancePolicies(insuranceData)
|
||||
setPricingRules(ruleData)
|
||||
setAccountingSettings(accountingData ?? {
|
||||
reportingPeriod: 'MONTHLY',
|
||||
accountantEmail: '',
|
||||
accountantName: '',
|
||||
autoSendReport: false,
|
||||
reportFormat: 'CSV',
|
||||
})
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load settings')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBrandSettings() {
|
||||
if (!brand) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const updated = await apiFetch<BrandSettings>('/companies/me/brand', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
displayName: brand.displayName,
|
||||
tagline: brand.tagline || undefined,
|
||||
primaryColor: brand.primaryColor,
|
||||
accentColor: brand.accentColor || undefined,
|
||||
publicEmail: brand.publicEmail || undefined,
|
||||
publicPhone: brand.publicPhone || undefined,
|
||||
publicAddress: brand.publicAddress || undefined,
|
||||
publicCity: brand.publicCity || undefined,
|
||||
publicCountry: brand.publicCountry || undefined,
|
||||
websiteUrl: brand.websiteUrl || undefined,
|
||||
whatsappNumber: brand.whatsappNumber || undefined,
|
||||
paypalEmail: brand.paypalEmail || undefined,
|
||||
amanpayMerchantId: brand.amanpayMerchantId || undefined,
|
||||
amanpaySecretKey: brand.amanpaySecretKey || undefined,
|
||||
paypalMerchantId: brand.paypalMerchantId || undefined,
|
||||
defaultCurrency: brand.defaultCurrency || undefined,
|
||||
isListedOnMarketplace: brand.isListedOnMarketplace,
|
||||
}),
|
||||
})
|
||||
setBrand(updated)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save brand settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBrandAsset(kind: 'logo' | 'hero', file: File | null) {
|
||||
if (!file) return
|
||||
setUploadingAsset(kind)
|
||||
setError(null)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const updated = await apiFetch<BrandSettings>(`/companies/me/brand/${kind}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
setBrand(updated)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? `Failed to upload ${kind}`)
|
||||
} finally {
|
||||
setUploadingAsset(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCustomDomain() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/brand/custom-domain', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ customDomain }),
|
||||
})
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save custom domain')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCustomDomain() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/brand/custom-domain', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
setCustomDomain('')
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to remove custom domain')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function saveContractSettings() {
|
||||
if (!contractSettings) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/contract-settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(contractSettings),
|
||||
})
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save contract settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAccountingSettings() {
|
||||
if (!accountingSettings) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/accounting-settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(accountingSettings),
|
||||
})
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save accounting settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function createInsurance() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/insurance-policies', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newInsurance),
|
||||
})
|
||||
setNewInsurance(emptyInsurance)
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to create insurance policy')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleInsurance(policy: InsurancePolicy) {
|
||||
try {
|
||||
await apiFetch(`/companies/me/insurance-policies/${policy.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !policy.isActive }),
|
||||
})
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to update insurance policy')
|
||||
}
|
||||
}
|
||||
|
||||
async function createRule() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/pricing-rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newRule),
|
||||
})
|
||||
setNewRule(emptyRule)
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to create pricing rule')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRule(rule: PricingRule) {
|
||||
try {
|
||||
await apiFetch(`/companies/me/pricing-rules/${rule.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !rule.isActive }),
|
||||
})
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to update pricing rule')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Advanced settings</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
|
||||
|
||||
{brand && (
|
||||
<div className="grid gap-6">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">Brand and public profile</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Control how your company appears on the marketplace and public booking site.</p>
|
||||
</div>
|
||||
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save brand'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Display name</label>
|
||||
<input className="input-field" value={brand.displayName} onChange={(event) => setBrand((current) => current ? { ...current, displayName: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Tagline</label>
|
||||
<input className="input-field" value={brand.tagline ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, tagline: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Public email</label>
|
||||
<input className="input-field" type="email" value={brand.publicEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicEmail: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Public phone</label>
|
||||
<input className="input-field" value={brand.publicPhone ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicPhone: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">City</label>
|
||||
<input className="input-field" value={brand.publicCity ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCity: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Country</label>
|
||||
<input className="input-field" value={brand.publicCountry ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCountry: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Website URL</label>
|
||||
<input className="input-field" value={brand.websiteUrl ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, websiteUrl: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">WhatsApp number</label>
|
||||
<input className="input-field" value={brand.whatsappNumber ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, whatsappNumber: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Brand color</label>
|
||||
<input className="input-field" value={brand.primaryColor} onChange={(event) => setBrand((current) => current ? { ...current, primaryColor: event.target.value } : current)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} />
|
||||
Listed on marketplace
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">Logo upload</span>
|
||||
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('logo', event.target.files?.[0] ?? null)} className="input-field" />
|
||||
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? 'Uploading…' : brand.logoUrl ? 'Logo uploaded' : 'No logo uploaded yet'}</p>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">Hero image upload</span>
|
||||
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('hero', event.target.files?.[0] ?? null)} className="input-field" />
|
||||
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? 'Uploading…' : brand.heroImageUrl ? 'Hero image uploaded' : 'No hero image uploaded yet'}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">Rental payment methods</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Configure how renters pay your company on the public booking site.</p>
|
||||
</div>
|
||||
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save payments'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">AmanPay merchant ID</label>
|
||||
<input className="input-field" value={brand.amanpayMerchantId ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpayMerchantId: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">AmanPay secret key</label>
|
||||
<input className="input-field" type="password" value={brand.amanpaySecretKey ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpaySecretKey: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">PayPal business email</label>
|
||||
<input className="input-field" type="email" value={brand.paypalEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, paypalEmail: event.target.value } : current)} />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
|
||||
If no payment method is configured, renters can still submit reservation requests and pay on pickup.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">Custom domain</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Point your own domain to the branded booking site.</p>
|
||||
</div>
|
||||
<button onClick={saveCustomDomain} disabled={saving || !customDomain.trim()} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save domain'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Subdomain</label>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Custom domain</label>
|
||||
<input className="input-field" placeholder="cars.example.com" value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
|
||||
Status: {brand.customDomain ? (brand.customDomainVerified ? 'Verified' : 'Pending DNS verification') : 'Not configured'}
|
||||
</div>
|
||||
{brand.customDomain ? (
|
||||
<button onClick={removeCustomDomain} disabled={saving} className="btn-secondary">
|
||||
Remove custom domain
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contractSettings && (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">Contract and driver policies</h3>
|
||||
<button onClick={saveContractSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save policies'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel policy type</label>
|
||||
<select className="input-field" value={contractSettings.fuelPolicyType} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyType: event.target.value } : current)}>
|
||||
{['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Additional driver charge</label>
|
||||
<select className="input-field" value={contractSettings.additionalDriverCharge} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverCharge: event.target.value as ContractSettings['additionalDriverCharge'] } : current)}>
|
||||
{['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Daily driver rate</label>
|
||||
<input type="number" className="input-field" value={contractSettings.additionalDriverDailyRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverDailyRate: Number(event.target.value) } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Flat driver rate</label>
|
||||
<input type="number" className="input-field" value={contractSettings.additionalDriverFlatRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverFlatRate: Number(event.target.value) } : current)} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel policy note</label>
|
||||
<textarea className="input-field min-h-24" value={contractSettings.fuelPolicyNote ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyNote: event.target.value } : current)} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Damage policy</label>
|
||||
<textarea className="input-field min-h-24" value={contractSettings.damagePolicy ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, damagePolicy: event.target.value } : current)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900">Insurance policies</h3>
|
||||
<div className="mt-5 space-y-3">
|
||||
{insurancePolicies.map((policy) => (
|
||||
<div key={policy.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{policy.name}</p>
|
||||
<p className="text-slate-500">{policy.type} · {policy.chargeType} · {policy.chargeValue}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleInsurance(policy)} className={policy.isActive ? 'badge-green' : 'badge-gray'}>
|
||||
{policy.isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{insurancePolicies.length === 0 && <div className="text-sm text-slate-400">No insurance policies configured yet.</div>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<input className="input-field" placeholder="Policy name" value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} />
|
||||
<select className="input-field" value={newInsurance.type} onChange={(event) => setNewInsurance((current) => ({ ...current, type: event.target.value }))}>
|
||||
{['BASIC', 'FULL', 'CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'ROADSIDE', 'PERSONAL', 'CUSTOM'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<select className="input-field" value={newInsurance.chargeType} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeType: event.target.value }))}>
|
||||
{['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<input type="number" className="input-field" placeholder="Charge value" value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-4 text-sm">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> Required</label>
|
||||
<button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? 'Saving…' : 'Add policy'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900">Pricing rules</h3>
|
||||
<div className="mt-5 space-y-3">
|
||||
{pricingRules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{rule.name}</p>
|
||||
<p className="text-slate-500">{rule.type} · {rule.condition} {rule.conditionValue} · {rule.adjustmentType} {rule.adjustmentValue}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleRule(rule)} className={rule.isActive ? 'badge-green' : 'badge-gray'}>
|
||||
{rule.isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pricingRules.length === 0 && <div className="text-sm text-slate-400">No pricing rules configured yet.</div>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<input className="input-field" placeholder="Rule name" value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
|
||||
<select className="input-field" value={newRule.type} onChange={(event) => setNewRule((current) => ({ ...current, type: event.target.value }))}>
|
||||
{['SURCHARGE', 'DISCOUNT'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<select className="input-field" value={newRule.condition} onChange={(event) => setNewRule((current) => ({ ...current, condition: event.target.value }))}>
|
||||
{['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<input type="number" className="input-field" value={newRule.conditionValue} onChange={(event) => setNewRule((current) => ({ ...current, conditionValue: Number(event.target.value) }))} />
|
||||
<select className="input-field" value={newRule.adjustmentType} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentType: event.target.value }))}>
|
||||
{['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<input type="number" className="input-field" value={newRule.adjustmentValue} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentValue: Number(event.target.value) }))} />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<button onClick={createRule} disabled={saving} className="btn-primary">{saving ? 'Saving…' : 'Add rule'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accountingSettings && (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">Accounting and exports</h3>
|
||||
<button onClick={saveAccountingSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save accounting'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Reporting period</label>
|
||||
<select className="input-field" value={accountingSettings.reportingPeriod} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportingPeriod: event.target.value } : current)}>
|
||||
{['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Report format</label>
|
||||
<select className="input-field" value={accountingSettings.reportFormat} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportFormat: event.target.value } : current)}>
|
||||
{['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Accountant name</label>
|
||||
<input className="input-field" value={accountingSettings.accountantName ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantName: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Accountant email</label>
|
||||
<input className="input-field" value={accountingSettings.accountantEmail ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantEmail: event.target.value } : current)} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={accountingSettings.autoSendReport} onChange={(event) => setAccountingSettings((current) => current ? { ...current, autoSendReport: event.target.checked } : current)} />
|
||||
Auto-send reports to the accountant
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useUser } from '@clerk/nextjs'
|
||||
import { useTeam, TeamMember } from '@/hooks/useTeam'
|
||||
import InviteModal from '@/components/team/InviteModal'
|
||||
import EditMemberModal from '@/components/team/EditMemberModal'
|
||||
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
|
||||
|
||||
function initials(m: TeamMember) {
|
||||
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string | null) {
|
||||
if (!dateStr) return 'Never'
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 2) return 'Just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
return new Date(dateStr).toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const AVATAR_BG: string[] = [
|
||||
'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300',
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
|
||||
'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300',
|
||||
'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300',
|
||||
'bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300',
|
||||
]
|
||||
|
||||
function avatarColor(id: string) {
|
||||
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
|
||||
return AVATAR_BG[hash % AVATAR_BG.length]
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
OWNER: 'bg-violet-50 dark:bg-violet-950/40 text-violet-700 dark:text-violet-300 border-violet-100 dark:border-violet-900/50',
|
||||
MANAGER: 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border-blue-100 dark:border-blue-900/50',
|
||||
AGENT: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-700',
|
||||
}
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${styles[role] ?? styles.AGENT}`}>
|
||||
{role.charAt(0) + role.slice(1).toLowerCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ member }: { member: TeamMember }) {
|
||||
if (member.invitationStatus === 'pending') {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border-amber-100 dark:border-amber-900/50">
|
||||
Pending invite
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (!member.isActive) {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
|
||||
Deactivated
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
|
||||
Active
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TeamPage() {
|
||||
const { user } = useUser()
|
||||
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState<string>('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [inviteOpen, setInviteOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
const currentEmployee = members.find((member) => member.email === user?.primaryEmailAddress?.emailAddress)
|
||||
const isOwner = currentEmployee?.role === 'OWNER'
|
||||
|
||||
function showToast(msg: string) {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return members.filter((m) => {
|
||||
const q = search.toLowerCase()
|
||||
const matchQ = !q ||
|
||||
`${m.firstName} ${m.lastName}`.toLowerCase().includes(q) ||
|
||||
m.email.toLowerCase().includes(q)
|
||||
const matchRole = !roleFilter || m.role === roleFilter
|
||||
const matchStatus =
|
||||
!statusFilter ||
|
||||
(statusFilter === 'active' && m.isActive && m.invitationStatus === 'accepted') ||
|
||||
(statusFilter === 'pending' && m.invitationStatus === 'pending') ||
|
||||
(statusFilter === 'inactive' && !m.isActive && m.invitationStatus === 'accepted')
|
||||
return matchQ && matchRole && matchStatus
|
||||
})
|
||||
}, [members, search, roleFilter, statusFilter])
|
||||
|
||||
const handleInvite: typeof invite = async (payload) => {
|
||||
await invite(payload)
|
||||
showToast(`Invite sent to ${payload.email}`)
|
||||
}
|
||||
|
||||
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
|
||||
await updateRole(id, role)
|
||||
showToast('Role updated')
|
||||
}
|
||||
|
||||
const handleDeactivate = async (id: string) => {
|
||||
await deactivate(id)
|
||||
showToast('Member deactivated')
|
||||
}
|
||||
|
||||
const handleReactivate = async (id: string) => {
|
||||
await reactivate(id)
|
||||
showToast('Member reactivated')
|
||||
}
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
await remove(id)
|
||||
showToast('Member removed')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">Team</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Manage your employees and their access levels
|
||||
</p>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => setInviteOpen(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
|
||||
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
Invite member
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
{[
|
||||
{ label: 'Total members', value: stats.total },
|
||||
{ label: 'Active', value: stats.active },
|
||||
{ label: 'Pending invite', value: stats.pending },
|
||||
{ label: 'Deactivated', value: stats.inactive },
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 flex flex-col gap-1"
|
||||
>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">{s.label}</span>
|
||||
<span className="text-2xl font-medium text-zinc-900 dark:text-white">{s.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 14 14">
|
||||
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path d="M10 10L13 13" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
|
||||
>
|
||||
<option value="">All roles</option>
|
||||
<option value="OWNER">Owner</option>
|
||||
<option value="MANAGER">Manager</option>
|
||||
<option value="AGENT">Agent</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="inactive">Deactivated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-10">
|
||||
{loading ? (
|
||||
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
|
||||
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
|
||||
<span className="text-sm">Loading team…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-12 text-center text-sm text-red-500">{error}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">No members match your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Member</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Role</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Status</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">Last active</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((member) => (
|
||||
<tr
|
||||
key={member.id}
|
||||
className={[
|
||||
'border-b border-zinc-100 dark:border-zinc-800 last:border-0',
|
||||
'hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors',
|
||||
].join(' ')}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${avatarColor(member.id)}`}>
|
||||
{initials(member)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-900 dark:text-white">
|
||||
{member.firstName} {member.lastName}
|
||||
{member.email === user?.primaryEmailAddress?.emailAddress && (
|
||||
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<RoleBadge role={member.role} />
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge member={member} />
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<span className="text-xs text-zinc-400 dark:text-zinc-500">
|
||||
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isOwner && member.role !== 'OWNER' && (
|
||||
<button
|
||||
onClick={() => setEditTarget(member)}
|
||||
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PermissionsMatrix />
|
||||
|
||||
<InviteModal
|
||||
open={inviteOpen}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvite={handleInvite}
|
||||
/>
|
||||
<EditMemberModal
|
||||
member={editTarget}
|
||||
open={!!editTarget}
|
||||
onClose={() => setEditTarget(null)}
|
||||
onUpdateRole={handleUpdateRole}
|
||||
onDeactivate={handleDeactivate}
|
||||
onReactivate={handleReactivate}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 duration-200">
|
||||
<svg className="w-4 h-4 text-green-400 dark:text-green-600" fill="none" viewBox="0 0 16 16">
|
||||
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,24 @@
|
||||
import Sidebar from '@/components/layout/Sidebar'
|
||||
import TopBar from '@/components/layout/TopBar'
|
||||
import { DashboardLanguageSwitcher } from '@/components/I18nProvider'
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-50">
|
||||
<div className="flex min-h-screen bg-slate-50">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col ml-64 min-w-0">
|
||||
<div className="ml-64 flex min-h-screen min-w-0 flex-1 flex-col">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors">
|
||||
<div className="flex flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">
|
||||
Workspace preferences
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<DashboardLanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
|
||||
export const size = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
}
|
||||
|
||||
export const contentType = 'image/png'
|
||||
|
||||
export default function Icon() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#0f172a',
|
||||
color: '#ffffff',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
R
|
||||
</div>
|
||||
),
|
||||
size,
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,22 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { ClerkProvider } from '@clerk/nextjs'
|
||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
||||
import { DashboardI18nProvider } from '@/components/I18nProvider'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Dashboard',
|
||||
description: 'Manage your rental car business',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ClerkProvider>
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
const content = (
|
||||
<html lang="en">
|
||||
<body className="font-sans">
|
||||
<DashboardI18nProvider>{children}</DashboardI18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
return clerkFrontendEnabled ? <ClerkProvider>{content}</ClerkProvider> : content
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import Link from 'next/link'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
|
||||
export default function AcceptInvitePage() {
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex flex-col items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-md card p-10 text-center">
|
||||
<div className="mx-auto mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Invitation accepted</h1>
|
||||
<p className="mt-3 text-sm text-slate-500">
|
||||
Your invitation has been processed. You can now sign in to access the team dashboard.
|
||||
</p>
|
||||
<Link href="/sign-in" className="mt-8 btn-primary justify-center w-full">
|
||||
Sign in to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
type Step = 1 | 2 | 3
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<Step>(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [company, setCompany] = useState({ name: '', slug: '' })
|
||||
const [brand, setBrand] = useState({
|
||||
displayName: '',
|
||||
tagline: '',
|
||||
primaryColor: '#2563eb',
|
||||
publicCity: '',
|
||||
publicCountry: '',
|
||||
})
|
||||
const [payments, setPayments] = useState({
|
||||
amanpayMerchantId: '',
|
||||
amanpaySecretKey: '',
|
||||
paypalEmail: '',
|
||||
isListedOnMarketplace: true,
|
||||
})
|
||||
|
||||
async function handleStep1() {
|
||||
if (!company.name.trim()) return setError('Company name is required')
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name: company.name }),
|
||||
})
|
||||
setStep(2)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep2() {
|
||||
if (!brand.displayName.trim()) return setError('Display name is required')
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/brand', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
displayName: brand.displayName,
|
||||
tagline: brand.tagline || undefined,
|
||||
primaryColor: brand.primaryColor,
|
||||
publicCity: brand.publicCity || undefined,
|
||||
publicCountry: brand.publicCountry || undefined,
|
||||
}),
|
||||
})
|
||||
setStep(3)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep3() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/brand', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
amanpayMerchantId: payments.amanpayMerchantId || undefined,
|
||||
amanpaySecretKey: payments.amanpaySecretKey || undefined,
|
||||
paypalEmail: payments.paypalEmail || undefined,
|
||||
isListedOnMarketplace: payments.isListedOnMarketplace,
|
||||
}),
|
||||
})
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">RentalDriveGo</Link>
|
||||
<h1 className="mt-2 text-3xl font-bold text-slate-900">Set up your account</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Step {step} of 3</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-8">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`flex-1 h-1.5 rounded-full transition-colors ${
|
||||
s <= step ? 'bg-blue-600' : 'bg-slate-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card p-8">
|
||||
{error && (
|
||||
<div className="mb-6 p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Your company</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">Tell us the basics about your rental company.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Company name</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="Casablanca Car Rentals"
|
||||
value={company.name}
|
||||
onChange={(e) => setCompany({ ...company, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleStep1} disabled={loading} className="btn-primary w-full justify-center">
|
||||
{loading ? 'Saving…' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Brand & identity</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">Customize how your company appears to renters.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Display name</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="Casa Rentals"
|
||||
value={brand.displayName}
|
||||
onChange={(e) => setBrand({ ...brand, displayName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Tagline <span className="text-slate-400">(optional)</span></label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="The best fleet in the city"
|
||||
value={brand.tagline}
|
||||
onChange={(e) => setBrand({ ...brand, tagline: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">City</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="Casablanca"
|
||||
value={brand.publicCity}
|
||||
onChange={(e) => setBrand({ ...brand, publicCity: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Country</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="Morocco"
|
||||
value={brand.publicCountry}
|
||||
onChange={(e) => setBrand({ ...brand, publicCountry: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Brand color</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
className="h-10 w-14 rounded border border-slate-200 cursor-pointer"
|
||||
value={brand.primaryColor}
|
||||
onChange={(e) => setBrand({ ...brand, primaryColor: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input-field"
|
||||
value={brand.primaryColor}
|
||||
onChange={(e) => setBrand({ ...brand, primaryColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setStep(1)} className="btn-secondary flex-1 justify-center">Back</button>
|
||||
<button onClick={handleStep2} disabled={loading} className="btn-primary flex-1 justify-center">
|
||||
{loading ? 'Saving…' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Payment setup</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">Connect payment providers so renters can book online. You can skip this for now.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Merchant ID <span className="text-slate-400">(optional)</span></label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="your-merchant-id"
|
||||
value={payments.amanpayMerchantId}
|
||||
onChange={(e) => setPayments({ ...payments, amanpayMerchantId: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Secret Key <span className="text-slate-400">(optional)</span></label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field"
|
||||
placeholder="your-secret-key"
|
||||
value={payments.amanpaySecretKey}
|
||||
onChange={(e) => setPayments({ ...payments, amanpaySecretKey: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">PayPal email <span className="text-slate-400">(optional)</span></label>
|
||||
<input
|
||||
type="email"
|
||||
className="input-field"
|
||||
placeholder="payments@yourcompany.com"
|
||||
value={payments.paypalEmail}
|
||||
onChange={(e) => setPayments({ ...payments, paypalEmail: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||
checked={payments.isListedOnMarketplace}
|
||||
onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">List my company on the RentalDriveGo marketplace</span>
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button>
|
||||
<button onClick={handleStep3} disabled={loading} className="btn-primary flex-1 justify-center">
|
||||
{loading ? 'Finishing…' : 'Go to dashboard'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function DashboardRootPage() {
|
||||
redirect('/sign-in')
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { SignIn } from '@clerk/nextjs'
|
||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
export default function SignInPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
workspace: 'Company workspace',
|
||||
body: 'Sign in to manage fleet operations, reservations, billing, team access, and performance reporting from one workspace.',
|
||||
features: [
|
||||
['Reservations', 'Track bookings, handoffs, availability, and customer activity without leaving the dashboard.'],
|
||||
['Billing', 'Review plan status, trial timing, payment setup, and subscription changes in one place.'],
|
||||
['Team access', 'Owners and staff keep separate access with role-based controls and invited employee accounts.'],
|
||||
['Analytics', 'Monitor fleet usage, revenue movement, and offer performance from the operations view.'],
|
||||
],
|
||||
access: 'Workspace Access',
|
||||
signIn: 'Sign in',
|
||||
useAccount: 'Use your owner or employee account to access the company workspace.',
|
||||
inactive: 'Enter your workspace credentials to continue.',
|
||||
newAccount: 'New company account?',
|
||||
createWorkspace: 'Create your workspace',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
},
|
||||
fr: {
|
||||
workspace: 'Espace entreprise',
|
||||
body: 'Connectez-vous pour gérer la flotte, les réservations, la facturation, l’accès équipe et le reporting depuis un seul espace.',
|
||||
features: [
|
||||
['Réservations', 'Suivez les réservations, remises, disponibilités et activité client sans quitter le dashboard.'],
|
||||
['Facturation', 'Consultez le plan, la période d’essai, la configuration des paiements et les changements d’abonnement.'],
|
||||
['Accès équipe', 'Les propriétaires et collaborateurs ont des accès séparés avec rôles et invitations.'],
|
||||
['Analytique', 'Surveillez l’usage de la flotte, les revenus et la performance des offres.'],
|
||||
],
|
||||
access: 'Accès workspace',
|
||||
signIn: 'Connexion',
|
||||
useAccount: 'Utilisez votre compte propriétaire ou employé pour accéder à l’espace entreprise.',
|
||||
inactive: 'Saisissez les identifiants de votre espace pour continuer.',
|
||||
newAccount: 'Nouveau compte entreprise ?',
|
||||
createWorkspace: 'Créer votre espace',
|
||||
email: 'Email',
|
||||
password: 'Mot de passe',
|
||||
},
|
||||
ar: {
|
||||
workspace: 'مساحة الشركة',
|
||||
body: 'سجّل الدخول لإدارة الأسطول والحجوزات والفوترة ووصول الفريق والتقارير من مساحة واحدة.',
|
||||
features: [
|
||||
['الحجوزات', 'تابع الحجوزات والتسليمات والتوفر ونشاط العملاء من داخل اللوحة.'],
|
||||
['الفوترة', 'راجع الخطة وفترة التجربة وإعدادات الدفع وتغييرات الاشتراك في مكان واحد.'],
|
||||
['وصول الفريق', 'يحصل المالكون والموظفون على وصول منفصل مع أدوار ودعوات منظمة.'],
|
||||
['التحليلات', 'راقب استخدام الأسطول وحركة الإيرادات وأداء العروض.'],
|
||||
],
|
||||
access: 'الوصول إلى المساحة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
useAccount: 'استخدم حساب المالك أو الموظف للوصول إلى مساحة الشركة.',
|
||||
inactive: 'أدخل بيانات مساحة العمل للمتابعة.',
|
||||
newAccount: 'حساب شركة جديد؟',
|
||||
createWorkspace: 'أنشئ مساحتك',
|
||||
email: 'البريد الإلكتروني',
|
||||
password: 'كلمة المرور',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="px-4 py-12">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-11rem)] max-w-6xl items-center">
|
||||
<div className="grid w-full gap-10 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<section className="space-y-8">
|
||||
<div>
|
||||
<Link href={marketplaceUrl} className="inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
|
||||
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{dict.workspace}</h1>
|
||||
<p className="mt-4 max-w-xl text-base leading-7 text-slate-600">{dict.body}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{dict.features.map(([title, body]) => (
|
||||
<FeatureCard key={title} title={title} body={body} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{dict.access}</p>
|
||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.signIn}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-500">{dict.useAccount}</p>
|
||||
|
||||
<div className="mt-8">
|
||||
{clerkFrontendEnabled ? <ConfiguredSignIn /> : <FallbackSignInCard dict={dict} />}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-sm text-slate-500">
|
||||
{dict.newAccount}
|
||||
<Link href="/sign-up" className="ml-1 font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.createWorkspace}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfiguredSignIn() {
|
||||
return (
|
||||
<SignIn
|
||||
routing="path"
|
||||
path="/sign-in"
|
||||
signUpUrl="/sign-up"
|
||||
fallbackRedirectUrl="/dashboard"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FallbackSignInCard({ dict }: { dict: { inactive: string; email: string; password: string; signIn: string } }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
{dict.inactive}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
|
||||
<input type="email" disabled placeholder="owner@company.com" className="input-field cursor-not-allowed opacity-70" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
|
||||
<input type="password" disabled placeholder="••••••••" className="input-field cursor-not-allowed opacity-70" />
|
||||
</div>
|
||||
|
||||
<button type="button" disabled className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{dict.signIn}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureCard({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="rounded-[1.5rem] border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-slate-900">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">{body}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useSignUp } from '@clerk/nextjs'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
type SignupForm = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
companyName: string
|
||||
companyPhone: string
|
||||
city: string
|
||||
country: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD' | 'USD' | 'EUR'
|
||||
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
||||
}
|
||||
|
||||
type CompletedSignup = {
|
||||
companyName: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function SignUpPage() {
|
||||
return clerkFrontendEnabled ? <ConfiguredSignUpPage /> : <LocalSignUpPage />
|
||||
}
|
||||
|
||||
function ConfiguredSignUpPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const dict = {
|
||||
en: {
|
||||
loading: 'Authentication is still loading. Try again in a moment.',
|
||||
passwordShort: 'Choose a password with at least 8 characters.',
|
||||
passwordMismatch: 'Passwords do not match.',
|
||||
startFailed: 'Could not start account creation',
|
||||
resendFailed: 'Could not resend the verification code',
|
||||
completeFailed: 'Could not verify your email and finish setup',
|
||||
working: 'Working…',
|
||||
},
|
||||
fr: {
|
||||
loading: 'L’authentification est en cours de chargement. Réessayez dans un instant.',
|
||||
passwordShort: 'Choisissez un mot de passe d’au moins 8 caractères.',
|
||||
passwordMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
startFailed: 'Impossible de démarrer la création du compte',
|
||||
resendFailed: 'Impossible de renvoyer le code de vérification',
|
||||
completeFailed: 'Impossible de vérifier votre email et finaliser la configuration',
|
||||
working: 'Traitement…',
|
||||
},
|
||||
ar: {
|
||||
loading: 'المصادقة ما زالت قيد التحميل. حاول بعد لحظة.',
|
||||
passwordShort: 'اختر كلمة مرور من 8 أحرف على الأقل.',
|
||||
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
startFailed: 'تعذر بدء إنشاء الحساب',
|
||||
resendFailed: 'تعذر إعادة إرسال رمز التحقق',
|
||||
completeFailed: 'تعذر التحقق من البريد وإكمال الإعداد',
|
||||
working: 'جارٍ التنفيذ…',
|
||||
},
|
||||
}[language]
|
||||
const { isLoaded, signUp, setActive } = useSignUp()
|
||||
const initialPlan = normalizePlan(searchParams.get('plan'))
|
||||
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
|
||||
const initialCurrency = normalizeCurrency(searchParams.get('currency'))
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [verificationCode, setVerificationCode] = useState('')
|
||||
const [awaitingVerification, setAwaitingVerification] = useState(false)
|
||||
const [completedSignup, setCompletedSignup] = useState<CompletedSignup | null>(null)
|
||||
const [form, setForm] = useState<SignupForm>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
companyName: '',
|
||||
companyPhone: '',
|
||||
city: '',
|
||||
country: '',
|
||||
plan: initialPlan,
|
||||
billingPeriod: initialBillingPeriod,
|
||||
currency: initialCurrency,
|
||||
paymentProvider: 'AMANPAY',
|
||||
})
|
||||
|
||||
async function submitSignup() {
|
||||
if (form.password.length < 8) {
|
||||
setError(dict.passwordShort)
|
||||
return
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError(dict.passwordMismatch)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
if (!isLoaded || !signUp) {
|
||||
setError(dict.loading)
|
||||
return
|
||||
}
|
||||
|
||||
await signUp.create({
|
||||
emailAddress: form.email,
|
||||
password: form.password,
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
unsafeMetadata: {
|
||||
signupFlow: 'company-owner',
|
||||
companyName: form.companyName,
|
||||
companyPhone: form.companyPhone || null,
|
||||
city: form.city || null,
|
||||
country: form.country || null,
|
||||
plan: form.plan,
|
||||
billingPeriod: form.billingPeriod,
|
||||
currency: form.currency,
|
||||
paymentProvider: form.paymentProvider,
|
||||
},
|
||||
})
|
||||
|
||||
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
|
||||
setAwaitingVerification(true)
|
||||
} catch (err) {
|
||||
setError(getClerkErrorMessage(err, dict.startFailed))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function resendVerificationCode() {
|
||||
if (!signUp) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
|
||||
} catch (err) {
|
||||
setError(getClerkErrorMessage(err, dict.resendFailed))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function completeSignup() {
|
||||
if (!signUp || !setActive) {
|
||||
setError(dict.loading)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await signUp.attemptEmailAddressVerification({ code: verificationCode })
|
||||
|
||||
if (result.status !== 'complete' || !result.createdSessionId) {
|
||||
throw new Error('Email verification is not complete yet.')
|
||||
}
|
||||
|
||||
await setActive({ session: result.createdSessionId })
|
||||
await waitForActiveSession()
|
||||
|
||||
await apiFetch('/auth/company/complete-signup', {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
setCompletedSignup({
|
||||
companyName: form.companyName,
|
||||
email: form.email,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(getClerkErrorMessage(err, dict.completeFailed))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (completedSignup) {
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex-1 px-4 py-16">
|
||||
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">Workspace ready</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">
|
||||
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> is now active and the owner email
|
||||
<span className="font-semibold text-slate-900"> {completedSignup.email}</span> has been registered.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Link href="/dashboard" className="btn-primary justify-center">
|
||||
Enter dashboard
|
||||
</Link>
|
||||
<Link href="/sign-in" className="btn-secondary justify-center">
|
||||
Sign in on another device
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (awaitingVerification) {
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex-1 px-4 py-16">
|
||||
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">Confirm your email</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">
|
||||
We sent a verification code to <span className="font-semibold text-slate-900">{form.email}</span>. Enter it here to finish creating your workspace.
|
||||
</p>
|
||||
<div className="mt-8 space-y-5">
|
||||
<LabeledInput label="Verification code" value={verificationCode} onChange={setVerificationCode} />
|
||||
{error ? <div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button type="button" onClick={() => setAwaitingVerification(false)} className="btn-secondary">
|
||||
Back
|
||||
</button>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<button type="button" onClick={resendVerificationCode} disabled={loading} className="btn-secondary disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{loading ? 'Sending…' : 'Resend code'}
|
||||
</button>
|
||||
<button type="button" onClick={completeSignup} disabled={loading || !verificationCode.trim()} className="btn-primary disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{loading ? 'Verifying…' : 'Verify and create workspace'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="px-4 py-12">
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
<div className="text-center">
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">Launch your rental workspace</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">Create the owner account, verify your email, and start your 14-day free trial.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{['Account', 'Company', 'Plan', 'Verify'].map((label, index) => (
|
||||
<div key={label} className={`rounded-full px-4 py-2 text-center text-sm font-semibold ${index + 1 <= step ? 'bg-slate-900 text-white' : 'border border-slate-200 bg-white text-slate-500'}`}>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{error ? <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Owner account" body="This becomes the primary owner account for the company workspace." />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label="First name" value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
|
||||
<LabeledInput label="Last name" value={form.lastName} onChange={(value) => setForm((current) => ({ ...current, lastName: value }))} />
|
||||
</div>
|
||||
<LabeledInput label="Email" type="email" value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: value }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label="Password" type="password" value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
|
||||
<LabeledInput label="Confirm password" type="password" value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Use at least 8 characters so the owner account can sign in directly after verification.</p>
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => setStep(2)} className="btn-primary">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Company details" body="We’ll use these details for your trial workspace and public marketplace profile." />
|
||||
<LabeledInput label="Company name" value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label="Phone" value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
|
||||
<LabeledInput label="Country" value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
|
||||
</div>
|
||||
<LabeledInput label="City" value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
|
||||
<NavActions onBack={() => setStep(1)} onNext={() => setStep(3)} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Choose your plan" body="Your workspace starts immediately on a 14-day free trial in your preferred billing currency." />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{['STARTER', 'GROWTH', 'PRO'].map((plan) => (
|
||||
<button
|
||||
key={plan}
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, plan: plan as SignupForm['plan'] }))}
|
||||
className={`rounded-3xl border p-5 text-left ${form.plan === plan ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-900'}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em]">{plan}</p>
|
||||
<p className="mt-3 text-sm opacity-80">{plan === 'STARTER' ? 'Core fleet, offers, and bookings.' : plan === 'GROWTH' ? 'Featured marketplace placement and more room to scale.' : 'Full white-label and premium controls.'}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<LabeledSelect label="Billing period" value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
|
||||
<LabeledSelect label="Currency" value={form.currency} options={['MAD', 'USD', 'EUR']} onChange={(value) => setForm((current) => ({ ...current, currency: value as SignupForm['currency'] }))} />
|
||||
<LabeledSelect label="Primary provider" value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
|
||||
</div>
|
||||
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 4 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Verify and launch" body="We’ll create the owner account in Clerk first, email you a verification code, then provision the workspace after that email is confirmed." />
|
||||
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
|
||||
<p><span className="font-semibold text-slate-900">Workspace:</span> {form.companyName || 'Your company'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">Plan:</span> {form.plan} · {form.billingPeriod} · {form.currency}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">Owner email:</span> {form.email || '—'}</p>
|
||||
</div>
|
||||
<NavActions
|
||||
onBack={() => setStep(3)}
|
||||
onNext={submitSignup}
|
||||
loading={loading}
|
||||
nextLabel="Send verification code"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalSignUpPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const initialPlan = normalizePlan(searchParams.get('plan'))
|
||||
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
|
||||
const initialCurrency = normalizeCurrency(searchParams.get('currency'))
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [completedSignup, setCompletedSignup] = useState<CompletedSignup | null>(null)
|
||||
const [form, setForm] = useState<SignupForm>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
companyName: '',
|
||||
companyPhone: '',
|
||||
city: '',
|
||||
country: '',
|
||||
plan: initialPlan,
|
||||
billingPeriod: initialBillingPeriod,
|
||||
currency: initialCurrency,
|
||||
paymentProvider: 'AMANPAY',
|
||||
})
|
||||
|
||||
async function submitSignup() {
|
||||
if (form.password.length < 8) {
|
||||
setError('Choose a password with at least 8 characters.')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError('Passwords do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await apiFetch('/auth/company/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
companyName: form.companyName,
|
||||
companyPhone: form.companyPhone || undefined,
|
||||
city: form.city || undefined,
|
||||
country: form.country || undefined,
|
||||
plan: form.plan,
|
||||
billingPeriod: form.billingPeriod,
|
||||
currency: form.currency,
|
||||
paymentProvider: form.paymentProvider,
|
||||
}),
|
||||
})
|
||||
|
||||
setCompletedSignup({
|
||||
companyName: form.companyName,
|
||||
email: form.email,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(getClerkErrorMessage(err, 'Could not create workspace'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (completedSignup) {
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex-1 px-4 py-16">
|
||||
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">Workspace ready</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">
|
||||
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> is now active and the owner email
|
||||
<span className="font-semibold text-slate-900"> {completedSignup.email}</span> has been registered.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Link href="/sign-up" className="btn-primary justify-center">
|
||||
Create another workspace
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="px-4 py-12">
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
<div className="text-center">
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">Launch your rental workspace</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">Create the owner account and start your 14-day free trial.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{['Account', 'Company', 'Plan', 'Verify'].map((label, index) => (
|
||||
<div key={label} className={`rounded-full px-4 py-2 text-center text-sm font-semibold ${index + 1 <= step ? 'bg-slate-900 text-white' : 'border border-slate-200 bg-white text-slate-500'}`}>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{error ? <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Owner account" body="This becomes the primary owner account for the company workspace." />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label="First name" value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
|
||||
<LabeledInput label="Last name" value={form.lastName} onChange={(value) => setForm((current) => ({ ...current, lastName: value }))} />
|
||||
</div>
|
||||
<LabeledInput label="Email" type="email" value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: value }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label="Password" type="password" value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
|
||||
<LabeledInput label="Confirm password" type="password" value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Use at least 8 characters for your company owner account.</p>
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => setStep(2)} className="btn-primary">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Company details" body="We’ll use these details for your trial workspace and public marketplace profile." />
|
||||
<LabeledInput label="Company name" value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label="Phone" value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
|
||||
<LabeledInput label="Country" value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
|
||||
</div>
|
||||
<LabeledInput label="City" value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
|
||||
<NavActions onBack={() => setStep(1)} onNext={() => setStep(3)} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Choose your plan" body="Your workspace starts immediately on a 14-day free trial in your preferred billing currency." />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{['STARTER', 'GROWTH', 'PRO'].map((plan) => (
|
||||
<button
|
||||
key={plan}
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, plan: plan as SignupForm['plan'] }))}
|
||||
className={`rounded-3xl border p-5 text-left ${form.plan === plan ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-900'}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em]">{plan}</p>
|
||||
<p className="mt-3 text-sm opacity-80">{plan === 'STARTER' ? 'Core fleet, offers, and bookings.' : plan === 'GROWTH' ? 'Featured marketplace placement and more room to scale.' : 'Full white-label and premium controls.'}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<LabeledSelect label="Billing period" value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
|
||||
<LabeledSelect label="Currency" value={form.currency} options={['MAD', 'USD', 'EUR']} onChange={(value) => setForm((current) => ({ ...current, currency: value as SignupForm['currency'] }))} />
|
||||
<LabeledSelect label="Primary provider" value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
|
||||
</div>
|
||||
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 4 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Verify and launch" body="We’ll create the company workspace immediately and start the 14-day trial with the plan you selected." />
|
||||
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
|
||||
<p><span className="font-semibold text-slate-900">Workspace:</span> {form.companyName || 'Your company'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">Plan:</span> {form.plan} · {form.billingPeriod} · {form.currency}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">Owner email:</span> {form.email || '—'}</p>
|
||||
</div>
|
||||
<NavActions
|
||||
onBack={() => setStep(3)}
|
||||
onNext={submitSignup}
|
||||
loading={loading}
|
||||
nextLabel="Create workspace"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionIntro({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{title}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-500">{body}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LabeledInput({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (value: string) => void; type?: string }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
|
||||
<input type={type} className="input-field" value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function LabeledSelect({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
|
||||
<select className="input-field" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function NavActions({ onBack, onNext, loading = false, nextLabel = 'Continue', disableNext = false }: { onBack?: () => void; onNext: () => void; loading?: boolean; nextLabel?: string; disableNext?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
{onBack ? <button type="button" onClick={onBack} className="btn-secondary">Back</button> : <span />}
|
||||
<button type="button" onClick={onNext} disabled={loading || disableNext} className="btn-primary disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{loading ? 'Working…' : nextLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePlan(value: string | null): SignupForm['plan'] {
|
||||
return value === 'GROWTH' || value === 'PRO' ? value : 'STARTER'
|
||||
}
|
||||
|
||||
function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod'] {
|
||||
return value === 'ANNUAL' || value === 'annual' ? 'ANNUAL' : 'MONTHLY'
|
||||
}
|
||||
|
||||
function normalizeCurrency(value: string | null): SignupForm['currency'] {
|
||||
return value === 'USD' || value === 'EUR' ? value : 'MAD'
|
||||
}
|
||||
|
||||
function getClerkErrorMessage(error: unknown, fallback: string) {
|
||||
if (error && typeof error === 'object' && 'errors' in error && Array.isArray((error as { errors?: Array<{ longMessage?: string; message?: string }> }).errors)) {
|
||||
const first = (error as { errors: Array<{ longMessage?: string; message?: string }> }).errors[0]
|
||||
if (first?.longMessage) return first.longMessage
|
||||
if (first?.message) return first.message
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function waitForActiveSession() {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const token = await readActiveSessionToken()
|
||||
if (token) return token
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
throw new Error('Signed in successfully, but could not read the active session token.')
|
||||
}
|
||||
|
||||
async function readActiveSessionToken() {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const clerk = (window as Window & { __clerk?: { session?: { getToken: () => Promise<string | null> } } }).__clerk
|
||||
if (!clerk?.session?.getToken) return null
|
||||
|
||||
try {
|
||||
return await clerk.session.getToken()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export type DashboardLanguage = 'en' | 'fr' | 'ar'
|
||||
|
||||
type DashboardDictionary = {
|
||||
nav: Record<string, string>
|
||||
titles: Record<string, string>
|
||||
notifications: string
|
||||
noNewNotifications: string
|
||||
unreadNotifications: (count: number) => string
|
||||
signOut: string
|
||||
demoUser: string
|
||||
clerkDisabled: string
|
||||
language: string
|
||||
light: string
|
||||
dark: string
|
||||
}
|
||||
|
||||
const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
en: {
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
fleet: 'Fleet',
|
||||
reservations: 'Reservations',
|
||||
customers: 'Customers',
|
||||
offers: 'Offers',
|
||||
team: 'Team',
|
||||
reports: 'Reports',
|
||||
billing: 'Billing',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Settings',
|
||||
},
|
||||
titles: {
|
||||
'/dashboard': 'Dashboard',
|
||||
'/dashboard/fleet': 'Fleet Management',
|
||||
'/dashboard/reservations': 'Reservations',
|
||||
'/dashboard/customers': 'Customers',
|
||||
'/dashboard/offers': 'Offers',
|
||||
'/dashboard/team': 'Team',
|
||||
'/dashboard/reports': 'Reports',
|
||||
'/dashboard/billing': 'Billing',
|
||||
'/dashboard/settings': 'Settings',
|
||||
},
|
||||
notifications: 'Notifications',
|
||||
noNewNotifications: 'No new notifications',
|
||||
unreadNotifications: (count) => `${count} unread notification${count > 1 ? 's' : ''}`,
|
||||
signOut: 'Sign out',
|
||||
demoUser: 'Demo User',
|
||||
clerkDisabled: 'Clerk disabled in local dev',
|
||||
language: 'Language',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
},
|
||||
fr: {
|
||||
nav: {
|
||||
dashboard: 'Tableau de bord',
|
||||
fleet: 'Flotte',
|
||||
reservations: 'Réservations',
|
||||
customers: 'Clients',
|
||||
offers: 'Offres',
|
||||
team: 'Équipe',
|
||||
reports: 'Rapports',
|
||||
billing: 'Facturation',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Paramètres',
|
||||
},
|
||||
titles: {
|
||||
'/dashboard': 'Tableau de bord',
|
||||
'/dashboard/fleet': 'Gestion de flotte',
|
||||
'/dashboard/reservations': 'Réservations',
|
||||
'/dashboard/customers': 'Clients',
|
||||
'/dashboard/offers': 'Offres',
|
||||
'/dashboard/team': 'Équipe',
|
||||
'/dashboard/reports': 'Rapports',
|
||||
'/dashboard/billing': 'Facturation',
|
||||
'/dashboard/settings': 'Paramètres',
|
||||
},
|
||||
notifications: 'Notifications',
|
||||
noNewNotifications: 'Aucune nouvelle notification',
|
||||
unreadNotifications: (count) => `${count} notification${count > 1 ? 's' : ''} non lue${count > 1 ? 's' : ''}`,
|
||||
signOut: 'Déconnexion',
|
||||
demoUser: 'Utilisateur démo',
|
||||
clerkDisabled: 'Clerk désactivé en local',
|
||||
language: 'Langue',
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
},
|
||||
ar: {
|
||||
nav: {
|
||||
dashboard: 'لوحة التحكم',
|
||||
fleet: 'الأسطول',
|
||||
reservations: 'الحجوزات',
|
||||
customers: 'العملاء',
|
||||
offers: 'العروض',
|
||||
team: 'الفريق',
|
||||
reports: 'التقارير',
|
||||
billing: 'الفوترة',
|
||||
notifications: 'الإشعارات',
|
||||
settings: 'الإعدادات',
|
||||
},
|
||||
titles: {
|
||||
'/dashboard': 'لوحة التحكم',
|
||||
'/dashboard/fleet': 'إدارة الأسطول',
|
||||
'/dashboard/reservations': 'الحجوزات',
|
||||
'/dashboard/customers': 'العملاء',
|
||||
'/dashboard/offers': 'العروض',
|
||||
'/dashboard/team': 'الفريق',
|
||||
'/dashboard/reports': 'التقارير',
|
||||
'/dashboard/billing': 'الفوترة',
|
||||
'/dashboard/settings': 'الإعدادات',
|
||||
},
|
||||
notifications: 'الإشعارات',
|
||||
noNewNotifications: 'لا توجد إشعارات جديدة',
|
||||
unreadNotifications: (count) => `${count} إشعار غير مقروء`,
|
||||
signOut: 'تسجيل الخروج',
|
||||
demoUser: 'مستخدم تجريبي',
|
||||
clerkDisabled: 'Clerk غير مفعّل محلياً',
|
||||
language: 'اللغة',
|
||||
light: 'فاتح',
|
||||
dark: 'داكن',
|
||||
},
|
||||
}
|
||||
|
||||
type I18nContextValue = {
|
||||
language: DashboardLanguage
|
||||
setLanguage: (value: DashboardLanguage) => void
|
||||
dict: DashboardDictionary
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null)
|
||||
|
||||
export function DashboardI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<DashboardLanguage>('en')
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('dashboard-language')
|
||||
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||
setLanguage(stored)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
window.localStorage.setItem('dashboard-language', language)
|
||||
}, [language])
|
||||
|
||||
const value = useMemo(() => ({ language, setLanguage, dict: dictionaries[language] }), [language])
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useDashboardI18n() {
|
||||
const context = useContext(I18nContext)
|
||||
if (!context) throw new Error('useDashboardI18n must be used within DashboardI18nProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
export function DashboardLanguageSwitcher() {
|
||||
const { language, setLanguage, dict } = useDashboardI18n()
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-1 shadow-sm">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
{dict.language}
|
||||
</span>
|
||||
{(['en', 'fr', 'ar'] as DashboardLanguage[]).map((value) => {
|
||||
const active = value === language
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setLanguage(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { DashboardLanguageSwitcher, useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
workspace: 'Company Workspace',
|
||||
signIn: 'Sign in',
|
||||
createWorkspace: 'Create workspace',
|
||||
preferences: 'Workspace preferences',
|
||||
},
|
||||
fr: {
|
||||
workspace: 'Espace entreprise',
|
||||
signIn: 'Connexion',
|
||||
createWorkspace: 'Créer un espace',
|
||||
preferences: 'Preferences espace',
|
||||
},
|
||||
ar: {
|
||||
workspace: 'مساحة الشركة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
createWorkspace: 'إنشاء مساحة',
|
||||
preferences: 'تفضيلات المساحة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)]">
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/85 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
|
||||
<Link href={marketplaceUrl} className="flex items-center gap-3 text-slate-900">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</span>
|
||||
<span className="hidden text-sm font-semibold text-slate-500 sm:inline">{dict.workspace}</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<Link href="/sign-in" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-900">
|
||||
{dict.signIn}
|
||||
</Link>
|
||||
<Link href="/sign-up" className="rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700">
|
||||
{dict.createWorkspace}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">
|
||||
{dict.preferences}
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<DashboardLanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,24 +11,30 @@ import {
|
||||
UserPlus,
|
||||
BarChart2,
|
||||
CreditCard,
|
||||
Bell,
|
||||
Settings,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import { useClerk, useUser } from '@clerk/nextjs'
|
||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/dashboard/fleet', label: 'Fleet', icon: Car },
|
||||
{ href: '/dashboard/reservations', label: 'Reservations', icon: Calendar },
|
||||
{ href: '/dashboard/customers', label: 'Customers', icon: Users },
|
||||
{ href: '/dashboard/offers', label: 'Offers', icon: Tag },
|
||||
{ href: '/dashboard/team', label: 'Team', icon: UserPlus },
|
||||
{ href: '/dashboard/reports', label: 'Reports', icon: BarChart2 },
|
||||
{ href: '/dashboard/billing', label: 'Billing', icon: CreditCard },
|
||||
{ href: '/dashboard/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/dashboard/fleet', key: 'fleet', icon: Car },
|
||||
{ href: '/dashboard/reservations', key: 'reservations', icon: Calendar },
|
||||
{ href: '/dashboard/customers', key: 'customers', icon: Users },
|
||||
{ href: '/dashboard/offers', key: 'offers', icon: Tag },
|
||||
{ href: '/dashboard/team', key: 'team', icon: UserPlus },
|
||||
{ href: '/dashboard/reports', key: 'reports', icon: BarChart2 },
|
||||
{ href: '/dashboard/billing', key: 'billing', icon: CreditCard },
|
||||
{ href: '/dashboard/notifications', key: 'notifications', icon: Bell },
|
||||
{ href: '/dashboard/settings', key: 'settings', icon: Settings },
|
||||
] as const
|
||||
|
||||
export default function Sidebar() {
|
||||
function SidebarWithClerk() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
const { signOut } = useClerk()
|
||||
const { user } = useUser()
|
||||
@@ -41,12 +47,12 @@ export default function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
|
||||
<Link href={marketplaceUrl} className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Car className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-white text-sm tracking-wide">RentalDriveGo</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
@@ -65,7 +71,7 @@ export default function Sidebar() {
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{item.label}
|
||||
{dict.nav[item.key]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@@ -91,9 +97,68 @@ export default function Sidebar() {
|
||||
className="mt-2 w-full flex items-center gap-3 px-3 py-2 text-sm text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign out
|
||||
{dict.signOut}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarWithoutClerk() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
|
||||
const isActive = (item: typeof NAV_ITEMS[0]) => {
|
||||
if (item.exact) return pathname === item.href
|
||||
return pathname.startsWith(item.href)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40">
|
||||
<Link href={marketplaceUrl} className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Car className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-white text-sm tracking-wide">RentalDriveGo</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={[
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{dict.nav[item.key]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="px-3 py-4 border-t border-slate-800">
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
|
||||
D
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{dict.demoUser}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{dict.clerkDisabled}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
return clerkFrontendEnabled ? <SidebarWithClerk /> : <SidebarWithoutClerk />
|
||||
}
|
||||
|
||||
@@ -5,26 +5,17 @@ import { usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useUser } from '@clerk/nextjs'
|
||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
'/dashboard': 'Dashboard',
|
||||
'/dashboard/fleet': 'Fleet Management',
|
||||
'/dashboard/reservations': 'Reservations',
|
||||
'/dashboard/customers': 'Customers',
|
||||
'/dashboard/offers': 'Offers',
|
||||
'/dashboard/team': 'Team',
|
||||
'/dashboard/reports': 'Reports',
|
||||
'/dashboard/billing': 'Billing',
|
||||
'/dashboard/settings': 'Settings',
|
||||
}
|
||||
|
||||
export default function TopBar() {
|
||||
function TopBarWithClerk() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
const { user } = useUser()
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [showNotifs, setShowNotifs] = useState(false)
|
||||
|
||||
const title = PAGE_TITLES[pathname] ?? 'Dashboard'
|
||||
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
@@ -53,9 +44,9 @@ export default function TopBar() {
|
||||
|
||||
{showNotifs && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-slate-200 rounded-xl shadow-lg z-50 p-4">
|
||||
<p className="text-sm font-medium text-slate-900 mb-2">Notifications</p>
|
||||
<p className="text-sm font-medium text-slate-900 mb-2">{dict.notifications}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{unreadCount === 0 ? 'No new notifications' : `${unreadCount} unread notification${unreadCount > 1 ? 's' : ''}`}
|
||||
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -69,3 +60,57 @@ export default function TopBar() {
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function TopBarWithoutClerk() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [showNotifs, setShowNotifs] = useState(false)
|
||||
|
||||
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
.then((data) => setUnreadCount(data.unread))
|
||||
.catch(() => {})
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6">
|
||||
<h1 className="text-lg font-semibold text-slate-900">{title}</h1>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifs(!showNotifs)}
|
||||
className="relative p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 min-w-[16px] h-4 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showNotifs && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-slate-200 rounded-xl shadow-lg z-50 p-4">
|
||||
<p className="text-sm font-medium text-slate-900 mb-2">{dict.notifications}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-semibold">
|
||||
D
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TopBar() {
|
||||
return clerkFrontendEnabled ? <TopBarWithClerk /> : <TopBarWithoutClerk />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
type InspectionType = 'CHECKIN' | 'CHECKOUT'
|
||||
type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY'
|
||||
type DamageType = 'SCRATCH' | 'DENT' | 'CRACK' | 'CHIP' | 'MISSING' | 'STAIN' | 'OTHER'
|
||||
type DamageSeverity = 'MINOR' | 'MODERATE' | 'MAJOR'
|
||||
|
||||
export interface DamagePoint {
|
||||
id?: string
|
||||
viewType: 'TOP' | 'FRONT' | 'REAR' | 'LEFT' | 'RIGHT'
|
||||
x: number
|
||||
y: number
|
||||
damageType: DamageType
|
||||
severity: DamageSeverity
|
||||
description?: string | null
|
||||
isPreExisting: boolean
|
||||
}
|
||||
|
||||
export interface DamageInspection {
|
||||
id: string
|
||||
type: InspectionType
|
||||
mileage: number | null
|
||||
fuelLevel: FuelLevel
|
||||
fuelCharge: number | null
|
||||
generalCondition: string | null
|
||||
employeeNotes: string | null
|
||||
customerAgreed: boolean
|
||||
damagePoints: DamagePoint[]
|
||||
}
|
||||
|
||||
const fuelLevels: FuelLevel[] = ['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']
|
||||
const damageTypes: DamageType[] = ['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']
|
||||
const severityColors: Record<DamageSeverity, string> = {
|
||||
MINOR: '#f59e0b',
|
||||
MODERATE: '#f97316',
|
||||
MAJOR: '#dc2626',
|
||||
}
|
||||
|
||||
export default function DamageInspectionCard({
|
||||
reservationId,
|
||||
type,
|
||||
initialInspection,
|
||||
onSaved,
|
||||
}: {
|
||||
reservationId: string
|
||||
type: InspectionType
|
||||
initialInspection?: DamageInspection | null
|
||||
onSaved: (inspection: DamageInspection) => void
|
||||
}) {
|
||||
const [inspection, setInspection] = useState<DamageInspection>({
|
||||
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
|
||||
type,
|
||||
mileage: initialInspection?.mileage ?? null,
|
||||
fuelLevel: initialInspection?.fuelLevel ?? 'FULL',
|
||||
fuelCharge: initialInspection?.fuelCharge ?? null,
|
||||
generalCondition: initialInspection?.generalCondition ?? '',
|
||||
employeeNotes: initialInspection?.employeeNotes ?? '',
|
||||
customerAgreed: initialInspection?.customerAgreed ?? false,
|
||||
damagePoints: initialInspection?.damagePoints ?? [],
|
||||
})
|
||||
const [damageType, setDamageType] = useState<DamageType>('SCRATCH')
|
||||
const [severity, setSeverity] = useState<DamageSeverity>('MINOR')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function saveInspection() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
mileage: inspection.mileage,
|
||||
fuelLevel: inspection.fuelLevel,
|
||||
fuelCharge: inspection.fuelCharge,
|
||||
generalCondition: inspection.generalCondition,
|
||||
employeeNotes: inspection.employeeNotes,
|
||||
customerAgreed: inspection.customerAgreed,
|
||||
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
|
||||
viewType,
|
||||
x,
|
||||
y,
|
||||
damageType,
|
||||
severity,
|
||||
description,
|
||||
isPreExisting,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
setInspection(result)
|
||||
onSaved(result)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save inspection')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function addDamagePoint(event: React.MouseEvent<SVGSVGElement>) {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const x = ((event.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((event.clientY - rect.top) / rect.height) * 180
|
||||
setInspection((current) => ({
|
||||
...current,
|
||||
damagePoints: [
|
||||
...current.damagePoints,
|
||||
{
|
||||
viewType: 'TOP',
|
||||
x,
|
||||
y,
|
||||
damageType,
|
||||
severity,
|
||||
description: '',
|
||||
isPreExisting: type === 'CHECKIN',
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function removePoint(index: number) {
|
||||
setInspection((current) => ({
|
||||
...current,
|
||||
damagePoints: current.damagePoints.filter((_, pointIndex) => pointIndex !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">{type === 'CHECKIN' ? 'Check-in inspection' : 'Check-out inspection'}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Mark existing and newly discovered damage directly on the vehicle diagram.</p>
|
||||
</div>
|
||||
<button onClick={saveInspection} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save inspection'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
|
||||
|
||||
<div className="mt-5 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
{damageTypes.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setDamageType(option)}
|
||||
className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-slate-900 text-white' : 'bg-white text-slate-600'}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
|
||||
{(Object.keys(severityColors) as DamageSeverity[]).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setSeverity(option)}
|
||||
className="rounded-full px-3 py-1.5 font-semibold text-white"
|
||||
style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<svg viewBox="0 0 100 180" className="w-full cursor-crosshair rounded-2xl border border-slate-200 bg-white" onClick={addDamagePoint}>
|
||||
<rect x="30" y="12" width="40" height="156" rx="18" fill="#e2e8f0" stroke="#94a3b8" />
|
||||
<rect x="36" y="22" width="28" height="24" rx="8" fill="#cbd5e1" />
|
||||
<rect x="36" y="54" width="28" height="70" rx="8" fill="#f8fafc" stroke="#cbd5e1" />
|
||||
<rect x="36" y="132" width="28" height="20" rx="8" fill="#cbd5e1" />
|
||||
<rect x="18" y="38" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="70" y="38" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="18" y="114" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
<rect x="70" y="114" width="12" height="28" rx="5" fill="#1e293b" />
|
||||
{inspection.damagePoints.map((point, index) => (
|
||||
<g key={`${point.x}-${point.y}-${index}`}>
|
||||
<circle cx={point.x} cy={point.y} r="3.8" fill={severityColors[point.severity]} stroke="white" strokeWidth="1.5" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<p className="mt-2 text-xs text-slate-500">Click anywhere on the diagram to add a damage marker with the selected type and severity.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Mileage</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
value={inspection.mileage ?? ''}
|
||||
onChange={(event) => setInspection((current) => ({ ...current, mileage: event.target.value ? Number(event.target.value) : null }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel level</label>
|
||||
<select className="input-field" value={inspection.fuelLevel} onChange={(event) => setInspection((current) => ({ ...current, fuelLevel: event.target.value as FuelLevel }))}>
|
||||
{fuelLevels.map((fuelLevel) => <option key={fuelLevel} value={fuelLevel}>{fuelLevel}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">General condition</label>
|
||||
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(event) => setInspection((current) => ({ ...current, generalCondition: event.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Employee notes</label>
|
||||
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(event) => setInspection((current) => ({ ...current, employeeNotes: event.target.value }))} />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={inspection.customerAgreed} onChange={(event) => setInspection((current) => ({ ...current, customerAgreed: event.target.checked }))} />
|
||||
Customer acknowledged this inspection
|
||||
</label>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200">
|
||||
<div className="border-b border-slate-200 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-slate-900">Damage markers</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{inspection.damagePoints.map((point, index) => (
|
||||
<div key={`${point.x}-${point.y}-${index}`} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{point.damageType} · {point.severity}</p>
|
||||
<p className="text-xs text-slate-500">x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => removePoint(index)} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{inspection.damagePoints.length === 0 && (
|
||||
<div className="px-4 py-6 text-sm text-slate-400">No damage markers saved yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { TeamMember } from '@/hooks/useTeam'
|
||||
|
||||
interface Props {
|
||||
member: TeamMember | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onUpdateRole: (memberId: string, role: 'MANAGER' | 'AGENT') => Promise<void>
|
||||
onDeactivate: (memberId: string) => Promise<void>
|
||||
onReactivate: (memberId: string) => Promise<void>
|
||||
onRemove: (memberId: string) => Promise<void>
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
|
||||
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
|
||||
]
|
||||
|
||||
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
|
||||
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
|
||||
|
||||
export default function EditMemberModal({
|
||||
member,
|
||||
open,
|
||||
onClose,
|
||||
onUpdateRole,
|
||||
onDeactivate,
|
||||
onReactivate,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
|
||||
const [actionState, setActionState] = useState<ActionState>('idle')
|
||||
const [confirm, setConfirm] = useState<ConfirmAction>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (member && open) {
|
||||
setRole(member.role === 'OWNER' ? 'MANAGER' : (member.role as 'MANAGER' | 'AGENT'))
|
||||
setActionState('idle')
|
||||
setConfirm(null)
|
||||
setError(null)
|
||||
}
|
||||
}, [member, open])
|
||||
|
||||
if (!open || !member) return null
|
||||
|
||||
const isPending = member.invitationStatus === 'pending'
|
||||
const isActive = member.isActive
|
||||
|
||||
const handleSave = async () => {
|
||||
if (role === member.role) { onClose(); return }
|
||||
setActionState('saving')
|
||||
setError(null)
|
||||
try {
|
||||
await onUpdateRole(member.id, role)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
setActionState('deactivating')
|
||||
setError(null)
|
||||
try {
|
||||
await onDeactivate(member.id)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
setConfirm(null)
|
||||
}
|
||||
|
||||
const handleReactivate = async () => {
|
||||
setActionState('reactivating')
|
||||
setError(null)
|
||||
try {
|
||||
await onReactivate(member.id)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
setConfirm(null)
|
||||
}
|
||||
|
||||
const handleRemove = async () => {
|
||||
setActionState('removing')
|
||||
setError(null)
|
||||
try {
|
||||
await onRemove(member.id)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
setConfirm(null)
|
||||
}
|
||||
|
||||
const busy = actionState !== 'idle'
|
||||
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
|
||||
>
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
|
||||
{initials}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-zinc-900 dark:text-white text-sm">
|
||||
{member.firstName} {member.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
{isPending && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border border-amber-100 dark:border-amber-900/50">
|
||||
Pending invite
|
||||
</span>
|
||||
)}
|
||||
{!isPending && !isActive && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
|
||||
Deactivated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirm && (
|
||||
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
|
||||
{confirm === 'deactivate' && 'Deactivate this member?'}
|
||||
{confirm === 'remove' && 'Permanently remove this member?'}
|
||||
{confirm === 'reactivate' && 'Reactivate this member?'}
|
||||
</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
|
||||
{confirm === 'deactivate' && "They'll immediately lose dashboard access. You can reactivate anytime."}
|
||||
{confirm === 'remove' && "This will delete the employee record and revoke their Clerk access. This cannot be undone."}
|
||||
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setConfirm(null)}
|
||||
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={
|
||||
confirm === 'deactivate' ? handleDeactivate :
|
||||
confirm === 'reactivate' ? handleReactivate :
|
||||
handleRemove
|
||||
}
|
||||
disabled={busy}
|
||||
className={[
|
||||
'flex-1 text-xs px-3 py-1.5 rounded-lg font-medium disabled:opacity-50',
|
||||
confirm === 'reactivate'
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'bg-red-600 text-white hover:bg-red-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? 'Working…' : (
|
||||
confirm === 'deactivate' ? 'Deactivate' :
|
||||
confirm === 'reactivate' ? 'Reactivate' :
|
||||
'Remove permanently'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPending && isActive && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
|
||||
Role
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROLE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setRole(option.value)}
|
||||
className={[
|
||||
'text-left px-3 py-2.5 rounded-xl border transition-all',
|
||||
role === option.value
|
||||
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="text-sm font-medium text-zinc-900 dark:text-white">{option.label}</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{option.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<div className="flex gap-2 mr-auto">
|
||||
{isActive && !isPending && (
|
||||
<button
|
||||
onClick={() => setConfirm('deactivate')}
|
||||
disabled={busy}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
)}
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={() => setConfirm('reactivate')}
|
||||
disabled={busy}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Reactivate
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setConfirm('remove')}
|
||||
disabled={busy}
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{isActive && !isPending && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionState === 'saving' ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { InvitePayload } from '@/hooks/useTeam'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onInvite: (payload: InvitePayload) => Promise<void>
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{
|
||||
value: 'MANAGER' as const,
|
||||
label: 'Manager',
|
||||
description: 'Full fleet ops — no billing or team settings',
|
||||
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
|
||||
},
|
||||
{
|
||||
value: 'AGENT' as const,
|
||||
label: 'Agent',
|
||||
description: 'Day-to-day operations only',
|
||||
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
|
||||
},
|
||||
]
|
||||
|
||||
export default function InviteModal({ open, onClose, onInvite }: Props) {
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setEmail('')
|
||||
setRole('AGENT')
|
||||
setError(null)
|
||||
}, 200)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to send invitation')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">Invite a team member</h2>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
They'll receive an email invitation to join your dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
|
||||
First name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder="Youssef"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
|
||||
Last name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder="Benali"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="youssef@yourcompany.com"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
|
||||
Role
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROLE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setRole(option.value)}
|
||||
className={[
|
||||
'text-left px-3 py-3 rounded-xl border transition-all',
|
||||
role === option.value
|
||||
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-zinc-900 dark:text-white">
|
||||
{option.label}
|
||||
</span>
|
||||
{role === option.value && (
|
||||
<span className="w-4 h-4 rounded-full bg-zinc-900 dark:bg-white flex items-center justify-center">
|
||||
<svg className="w-2.5 h-2.5 text-white dark:text-zinc-900" fill="currentColor" viewBox="0 0 12 12">
|
||||
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
|
||||
{option.description}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{option.permissions.slice(0, 3).map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{option.permissions.length > 3 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
|
||||
+{option.permissions.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Sending…' : 'Send invite →'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
type Access = 'full' | 'partial' | 'none'
|
||||
|
||||
interface Feature {
|
||||
label: string
|
||||
manager: Access
|
||||
managerNote?: string
|
||||
agent: Access
|
||||
agentNote?: string
|
||||
}
|
||||
|
||||
const FEATURES: Feature[] = [
|
||||
{ label: 'View fleet & vehicles', manager: 'full', agent: 'full' },
|
||||
{ label: 'Add / edit vehicles', manager: 'full', agent: 'none' },
|
||||
{ label: 'Publish / unpublish vehicle', manager: 'full', agent: 'none' },
|
||||
{ label: 'Upload vehicle photos', manager: 'full', agent: 'none' },
|
||||
{ label: 'View reservations', manager: 'full', agent: 'full' },
|
||||
{ label: 'Create reservations', manager: 'full', agent: 'full' },
|
||||
{ label: 'Cancel reservations', manager: 'full', agent: 'partial', agentNote: 'Own only' },
|
||||
{ label: 'Check-in / check-out', manager: 'full', agent: 'full' },
|
||||
{ label: 'Damage inspection', manager: 'full', agent: 'full' },
|
||||
{ label: 'Customer CRM — view', manager: 'full', agent: 'full' },
|
||||
{ label: 'Customer CRM — edit', manager: 'full', agent: 'partial', agentNote: 'Notes only' },
|
||||
{ label: 'Approve driver licenses', manager: 'full', agent: 'none' },
|
||||
{ label: 'Flag / blacklist customer', manager: 'full', agent: 'none' },
|
||||
{ label: 'Offers & promotions', manager: 'full', agent: 'none' },
|
||||
{ label: 'Analytics & reports', manager: 'full', agent: 'none' },
|
||||
{ label: 'Contract & invoice PDF', manager: 'full', agent: 'full' },
|
||||
{ label: 'Billing & subscription', manager: 'none', agent: 'none' },
|
||||
{ label: 'Invite / remove staff', manager: 'none', agent: 'none' },
|
||||
{ label: 'Brand & site settings', manager: 'none', agent: 'none' },
|
||||
{ label: 'Payment settings', manager: 'none', agent: 'none' },
|
||||
]
|
||||
|
||||
function AccessCell({ access, note }: { access: Access; note?: string }) {
|
||||
if (access === 'full') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
|
||||
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="text-xs text-zinc-700 dark:text-zinc-300">Full</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (access === 'partial') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
|
||||
<div className="w-2.5 h-0.5 rounded bg-amber-500" />
|
||||
</div>
|
||||
<span className="text-xs text-amber-700 dark:text-amber-400">{note ?? 'Limited'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
|
||||
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<span className="text-xs text-zinc-400 dark:text-zinc-600">No access</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PermissionsMatrix() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-base font-medium text-zinc-900 dark:text-white">Role permissions</h2>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
What each role can do across the dashboard. Owners have full access to everything.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
|
||||
Feature
|
||||
</div>
|
||||
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
|
||||
Manager
|
||||
</div>
|
||||
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
|
||||
Agent
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{FEATURES.map((feature, i) => (
|
||||
<div
|
||||
key={feature.label}
|
||||
className={[
|
||||
'grid grid-cols-[1fr_120px_120px]',
|
||||
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
|
||||
i % 2 === 0 ? '' : 'bg-zinc-50/50 dark:bg-zinc-800/20',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
{feature.label}
|
||||
</div>
|
||||
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
|
||||
<AccessCell access={feature.manager} note={feature.managerNote} />
|
||||
</div>
|
||||
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
|
||||
<AccessCell access={feature.agent} note={feature.agentNote} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
||||
export type InvitationStatus = 'accepted' | 'pending' | 'revoked'
|
||||
|
||||
export interface TeamMember {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
role: EmployeeRole
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastActiveAt: string | null
|
||||
invitationStatus: InvitationStatus
|
||||
}
|
||||
|
||||
export interface TeamStats {
|
||||
total: number
|
||||
active: number
|
||||
pending: number
|
||||
inactive: number
|
||||
}
|
||||
|
||||
export interface InvitePayload {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
role: 'MANAGER' | 'AGENT'
|
||||
}
|
||||
|
||||
export function useTeam() {
|
||||
const [members, setMembers] = useState<TeamMember[]>([])
|
||||
const [stats, setStats] = useState<TeamStats>({ total: 0, active: 0, pending: 0, inactive: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [membersData, statsData] = await Promise.all([
|
||||
apiFetch<TeamMember[]>('/team'),
|
||||
apiFetch<TeamStats>('/team/stats'),
|
||||
])
|
||||
setMembers(membersData)
|
||||
setStats(statsData)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refetch() }, [refetch])
|
||||
|
||||
const invite = useCallback(async (payload: InvitePayload) => {
|
||||
const result = await apiFetch<{ employee: TeamMember; invitationId: string }>(
|
||||
'/team/invite',
|
||||
{ method: 'POST', body: JSON.stringify(payload) }
|
||||
)
|
||||
setMembers((prev) => [...prev, result.employee])
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
total: prev.total + 1,
|
||||
pending: prev.pending + 1,
|
||||
}))
|
||||
return result
|
||||
}, [])
|
||||
|
||||
const updateRole = useCallback(async (memberId: string, role: 'MANAGER' | 'AGENT') => {
|
||||
const updated = await apiFetch<TeamMember>(`/team/${memberId}/role`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ role }),
|
||||
})
|
||||
setMembers((prev) =>
|
||||
prev.map((m) => (m.id === memberId ? { ...m, role: updated.role } : m))
|
||||
)
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
const deactivate = useCallback(async (memberId: string) => {
|
||||
const updated = await apiFetch<TeamMember>(`/team/${memberId}/deactivate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setMembers((prev) =>
|
||||
prev.map((m) => (m.id === memberId ? { ...m, isActive: false } : m))
|
||||
)
|
||||
setStats((prev) => ({ ...prev, active: prev.active - 1, inactive: prev.inactive + 1 }))
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
const reactivate = useCallback(async (memberId: string) => {
|
||||
const updated = await apiFetch<TeamMember>(`/team/${memberId}/reactivate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setMembers((prev) =>
|
||||
prev.map((m) => (m.id === memberId ? { ...m, isActive: true } : m))
|
||||
)
|
||||
setStats((prev) => ({ ...prev, active: prev.active + 1, inactive: prev.inactive - 1 }))
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
const remove = useCallback(async (memberId: string) => {
|
||||
await apiFetch<{ success: boolean }>(`/team/${memberId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const target = members.find((m) => m.id === memberId)
|
||||
setMembers((prev) => prev.filter((m) => m.id !== memberId))
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
total: prev.total - 1,
|
||||
active: target?.isActive ? prev.active - 1 : prev.active,
|
||||
pending: target?.invitationStatus === 'pending' ? prev.pending - 1 : prev.pending,
|
||||
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
|
||||
? prev.inactive - 1
|
||||
: prev.inactive,
|
||||
}))
|
||||
}, [members])
|
||||
|
||||
return {
|
||||
members,
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
invite,
|
||||
updateRole,
|
||||
deactivate,
|
||||
reactivate,
|
||||
remove,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
const API_BASE =
|
||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : process.env.NEXT_PUBLIC_API_URL)
|
||||
?? process.env.NEXT_PUBLIC_API_URL
|
||||
?? 'http://localhost:4000/api/v1'
|
||||
|
||||
async function getClerkToken(): Promise<string | null> {
|
||||
try {
|
||||
@@ -19,12 +22,16 @@ async function getClerkToken(): Promise<string | null> {
|
||||
|
||||
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = await getClerkToken()
|
||||
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers as Record<string, string> ?? {}),
|
||||
}
|
||||
|
||||
if (!isFormData) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
@@ -53,10 +60,11 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
|
||||
}
|
||||
|
||||
export async function apiFetchServer<T>(path: string, token: string, options?: RequestInit): Promise<T> {
|
||||
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...(options?.headers as Record<string, string> ?? {}),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const clerkFrontendEnabled = Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY)
|
||||
|
||||
export const clerkBackendEnabled = Boolean(process.env.CLERK_SECRET_KEY)
|
||||
|
||||
export const clerkMiddlewareEnabled = clerkFrontendEnabled && clerkBackendEnabled
|
||||
@@ -0,0 +1 @@
|
||||
export const marketplaceUrl = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
|
||||
@@ -1,15 +1,21 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
|
||||
import { clerkMiddlewareEnabled } from '@/lib/clerk'
|
||||
|
||||
const isProtectedRoute = createRouteMatcher([
|
||||
'/dashboard(.*)',
|
||||
'/onboarding(.*)',
|
||||
])
|
||||
|
||||
export default clerkMiddleware((auth, req) => {
|
||||
if (isProtectedRoute(req)) {
|
||||
auth().protect()
|
||||
export default clerkMiddlewareEnabled
|
||||
? clerkMiddleware((auth, req) => {
|
||||
if (isProtectedRoute(req)) {
|
||||
auth().protect()
|
||||
}
|
||||
})
|
||||
: function middleware() {
|
||||
return NextResponse.next()
|
||||
}
|
||||
})
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
|
||||
Reference in New Issue
Block a user