fix first online resevation
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ApiPaginated } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface CustomerRow {
|
||||
@@ -19,8 +18,8 @@ export default function CustomersPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ApiPaginated<CustomerRow>>('/customers?pageSize=100')
|
||||
.then((result) => setRows(result.data))
|
||||
apiFetch<CustomerRow[]>('/customers?pageSize=100')
|
||||
.then((result) => setRows(result ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import { ArrowLeft, Edit, X, Check, Upload, Trash2 } from 'lucide-react'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
@@ -18,15 +19,78 @@ interface VehicleDetail {
|
||||
transmission: string
|
||||
fuelType: string
|
||||
seats: number
|
||||
mileage: number | null
|
||||
licensePlate: string
|
||||
photos: string[]
|
||||
notes: string | null
|
||||
isPublished: boolean
|
||||
}
|
||||
|
||||
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
|
||||
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
|
||||
const MODELS_BY_MAKE: Record<string, string[]> = {
|
||||
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
|
||||
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
|
||||
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
|
||||
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
|
||||
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
|
||||
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
|
||||
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
|
||||
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
|
||||
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
|
||||
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
|
||||
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
|
||||
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
|
||||
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
|
||||
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
|
||||
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
|
||||
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
|
||||
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
|
||||
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
|
||||
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
|
||||
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
|
||||
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
|
||||
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
|
||||
}
|
||||
|
||||
function initMakeSelect(make: string) {
|
||||
return PRESET_MAKES.includes(make) ? make : (make ? 'custom' : '')
|
||||
}
|
||||
function initModelSelect(make: string, model: string) {
|
||||
const presets = MODELS_BY_MAKE[make]
|
||||
if (!presets) return model ? 'custom' : ''
|
||||
return presets.includes(model) ? model : (model ? 'custom' : '')
|
||||
}
|
||||
function initColorSelect(color: string) {
|
||||
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
|
||||
}
|
||||
|
||||
export default function FleetDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
// edit form state
|
||||
const [form, setForm] = useState<{
|
||||
make: string; model: string; year: number; category: string
|
||||
dailyRate: string; licensePlate: string; color: string
|
||||
seats: number; transmission: string; fuelType: string
|
||||
status: string; notes: string; mileage: string
|
||||
} | null>(null)
|
||||
const [makeSelect, setMakeSelect] = useState('')
|
||||
const [modelSelect, setModelSelect] = useState('')
|
||||
const [colorSelect, setColorSelect] = useState('')
|
||||
|
||||
// photo management
|
||||
const [deletingPhotoIdx, setDeletingPhotoIdx] = useState<number | null>(null)
|
||||
const [newPhotoFiles, setNewPhotoFiles] = useState<File[]>([])
|
||||
const [newPhotoPreviews, setNewPhotoPreviews] = useState<string[]>([])
|
||||
const [uploadingPhotos, setUploadingPhotos] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<VehicleDetail>(`/vehicles/${params.id}`)
|
||||
@@ -34,38 +98,400 @@ export default function FleetDetailPage() {
|
||||
.catch((err) => setError(err.message))
|
||||
}, [params.id])
|
||||
|
||||
const startEdit = () => {
|
||||
if (!vehicle) return
|
||||
setForm({
|
||||
make: vehicle.make,
|
||||
model: vehicle.model,
|
||||
year: vehicle.year,
|
||||
category: vehicle.category,
|
||||
dailyRate: (vehicle.dailyRate / 100).toFixed(2),
|
||||
licensePlate: vehicle.licensePlate,
|
||||
color: vehicle.color ?? '',
|
||||
seats: vehicle.seats,
|
||||
transmission: vehicle.transmission,
|
||||
fuelType: vehicle.fuelType,
|
||||
status: vehicle.status,
|
||||
notes: vehicle.notes ?? '',
|
||||
mileage: vehicle.mileage != null ? String(vehicle.mileage) : '',
|
||||
})
|
||||
setMakeSelect(initMakeSelect(vehicle.make))
|
||||
setModelSelect(initModelSelect(vehicle.make, vehicle.model))
|
||||
setColorSelect(initColorSelect(vehicle.color ?? ''))
|
||||
setSaveError(null)
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
|
||||
setNewPhotoFiles([])
|
||||
setNewPhotoPreviews([])
|
||||
setEditing(false)
|
||||
setForm(null)
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form || !vehicle) return
|
||||
if (!form.make) { setSaveError('Make is required.'); return }
|
||||
if (!form.model) { setSaveError('Model is required.'); return }
|
||||
if (!form.licensePlate) { setSaveError('License plate is required.'); return }
|
||||
const rate = parseFloat(form.dailyRate)
|
||||
if (isNaN(rate) || rate < 0) { setSaveError('Please enter a valid daily rate.'); return }
|
||||
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
make: form.make,
|
||||
model: form.model,
|
||||
year: Number(form.year),
|
||||
category: form.category,
|
||||
dailyRate: Math.round(rate * 100),
|
||||
licensePlate: form.licensePlate,
|
||||
color: form.color,
|
||||
seats: Number(form.seats),
|
||||
transmission: form.transmission,
|
||||
fuelType: form.fuelType,
|
||||
status: form.status,
|
||||
notes: form.notes || undefined,
|
||||
mileage: form.mileage ? Number(form.mileage) : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
let finalVehicle = updated
|
||||
if (newPhotoFiles.length > 0) {
|
||||
setUploadingPhotos(true)
|
||||
const fd = new FormData()
|
||||
newPhotoFiles.forEach((f) => fd.append('photos', f))
|
||||
finalVehicle = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
|
||||
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
|
||||
setNewPhotoFiles([])
|
||||
setNewPhotoPreviews([])
|
||||
setUploadingPhotos(false)
|
||||
}
|
||||
|
||||
setVehicle(finalVehicle)
|
||||
setEditing(false)
|
||||
setForm(null)
|
||||
} catch (err: any) {
|
||||
setSaveError(err.message ?? 'Failed to save changes.')
|
||||
setUploadingPhotos(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePhoto = async (idx: number) => {
|
||||
if (!vehicle) return
|
||||
setDeletingPhotoIdx(idx)
|
||||
try {
|
||||
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos/${idx}`, { method: 'DELETE' })
|
||||
setVehicle(updated)
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
} finally {
|
||||
setDeletingPhotoIdx(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
if (!files.length) return
|
||||
const totalExisting = (vehicle?.photos.length ?? 0)
|
||||
const combined = [...newPhotoFiles, ...files].slice(0, 10 - totalExisting)
|
||||
setNewPhotoFiles(combined)
|
||||
setNewPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const removeNewPhoto = (i: number) => {
|
||||
URL.revokeObjectURL(newPhotoPreviews[i])
|
||||
setNewPhotoFiles(newPhotoFiles.filter((_, idx) => idx !== i))
|
||||
setNewPhotoPreviews(newPhotoPreviews.filter((_, idx) => idx !== i))
|
||||
}
|
||||
|
||||
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
|
||||
if (!vehicle) return <div className="card p-6 text-sm text-slate-500">Loading vehicle…</div>
|
||||
|
||||
const presetModels = form && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
|
||||
const totalPhotos = vehicle.photos.length + newPhotoFiles.length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">{vehicle.year} · {vehicle.licensePlate} · {vehicle.status}</p>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => router.back()} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{vehicle.year} · {vehicle.licensePlate} · {vehicle.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<button onClick={startEdit} className="btn-secondary flex items-center gap-2">
|
||||
<Edit className="w-4 h-4" /> Edit
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={cancelEdit} className="btn-secondary flex items-center gap-2">
|
||||
<X className="w-4 h-4" /> Cancel
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
|
||||
<Check className="w-4 h-4" />
|
||||
{saving ? (uploadingPhotos ? 'Uploading photos…' : 'Saving…') : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{saveError}</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
|
||||
{/* Photos */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-4">Photos</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Existing photos */}
|
||||
{vehicle.photos.map((photo, index) => (
|
||||
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100">
|
||||
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" />
|
||||
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group">
|
||||
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" unoptimized={!photo.includes('res.cloudinary.com')} priority={index === 0} />
|
||||
{editing && (
|
||||
<button
|
||||
onClick={() => handleDeletePhoto(index)}
|
||||
disabled={deletingPhotoIdx === index}
|
||||
className="absolute top-2 right-2 p-1.5 bg-red-600 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-60"
|
||||
>
|
||||
{deletingPhotoIdx === index
|
||||
? <div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
: <Trash2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{vehicle.photos.length === 0 && <div className="text-sm text-slate-400">No photos uploaded yet.</div>}
|
||||
|
||||
{/* New photo previews (edit mode) */}
|
||||
{editing && newPhotoPreviews.map((src, i) => (
|
||||
<div key={`new-${i}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group ring-2 ring-blue-400">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt={`new photo ${i + 1}`} className="object-cover w-full h-full" />
|
||||
<button
|
||||
onClick={() => removeNewPhoto(i)}
|
||||
className="absolute top-2 right-2 p-1.5 bg-black/60 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="absolute bottom-2 left-2 text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full">New</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{vehicle.photos.length === 0 && !editing && (
|
||||
<div className="text-sm text-slate-400">No photos uploaded yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload new photos (edit mode) */}
|
||||
{editing && totalPhotos < 10 && (
|
||||
<div className="mt-4">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleNewPhotoChange} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{totalPhotos === 0 ? 'Click to add photos' : `Add more photos (${totalPhotos}/10)`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{editing && totalPhotos >= 10 && (
|
||||
<p className="mt-3 text-xs text-slate-400 text-center">Maximum 10 photos reached.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details / Edit form */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-4">Vehicle details</h3>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div><dt className="text-slate-500">Category</dt><dd className="text-slate-900">{vehicle.category}</dd></div>
|
||||
<div><dt className="text-slate-500">Daily rate</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Seats</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
|
||||
<div><dt className="text-slate-500">Transmission</dt><dd className="text-slate-900">{vehicle.transmission}</dd></div>
|
||||
<div><dt className="text-slate-500">Fuel type</dt><dd className="text-slate-900">{vehicle.fuelType}</dd></div>
|
||||
<div><dt className="text-slate-500">Color</dt><dd className="text-slate-900">{vehicle.color}</dd></div>
|
||||
<div><dt className="text-slate-500">Notes</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
|
||||
</dl>
|
||||
|
||||
{!editing || !form ? (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div><dt className="text-slate-500">Category</dt><dd className="text-slate-900">{vehicle.category}</dd></div>
|
||||
<div><dt className="text-slate-500">Daily rate</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Status</dt><dd className="text-slate-900">{vehicle.status}</dd></div>
|
||||
<div><dt className="text-slate-500">Seats</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
|
||||
<div><dt className="text-slate-500">Transmission</dt><dd className="text-slate-900">{vehicle.transmission}</dd></div>
|
||||
<div><dt className="text-slate-500">Fuel type</dt><dd className="text-slate-900">{vehicle.fuelType}</dd></div>
|
||||
<div><dt className="text-slate-500">Color</dt><dd className="text-slate-900">{vehicle.color || '—'}</dd></div>
|
||||
<div><dt className="text-slate-500">Odometer</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div>
|
||||
<div><dt className="text-slate-500">Notes</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
|
||||
</dl>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
{/* Make */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={makeSelect}
|
||||
onChange={(e) => {
|
||||
setMakeSelect(e.target.value)
|
||||
setModelSelect('')
|
||||
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
|
||||
else setForm({ ...form, make: '', model: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select make…</option>
|
||||
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
</select>
|
||||
{makeSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type make…" value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label>
|
||||
{!presetModels ? (
|
||||
<input className="input-field" placeholder="Model" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
className="input-field"
|
||||
value={modelSelect}
|
||||
onChange={(e) => {
|
||||
setModelSelect(e.target.value)
|
||||
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
|
||||
else setForm({ ...form, model: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select model…</option>
|
||||
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
</select>
|
||||
{modelSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type model…" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Year & Category */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label>
|
||||
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Category</label>
|
||||
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Rate & License Plate */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label>
|
||||
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">License Plate *</label>
|
||||
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Status</label>
|
||||
<select className="input-field" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
|
||||
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Color & Seats */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={colorSelect}
|
||||
onChange={(e) => {
|
||||
setColorSelect(e.target.value)
|
||||
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
|
||||
else setForm({ ...form, color: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select color…</option>
|
||||
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
</select>
|
||||
{colorSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type color…" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
|
||||
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transmission & Fuel */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Transmission</label>
|
||||
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
|
||||
<option value="MANUAL">Manual</option>
|
||||
<option value="AUTOMATIC">Automatic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label>
|
||||
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
|
||||
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'].map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Odometer */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Odometer (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
placeholder="e.g. 45000"
|
||||
min="0"
|
||||
value={form.mileage}
|
||||
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Notes</label>
|
||||
<textarea
|
||||
className="input-field resize-none"
|
||||
rows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="Optional notes…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Edit, Eye, ChevronDown, Search } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Plus, Edit, Eye, Search, Upload, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import dayjs from 'dayjs'
|
||||
import type { ApiPaginated } from '@rentaldrivego/types'
|
||||
|
||||
interface Vehicle {
|
||||
id: string
|
||||
@@ -35,32 +33,106 @@ const STATUS_BADGE: Record<Vehicle['status'], string> = {
|
||||
OUT_OF_SERVICE: 'badge-red',
|
||||
}
|
||||
|
||||
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
|
||||
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
|
||||
const MODELS_BY_MAKE: Record<string, string[]> = {
|
||||
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
|
||||
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
|
||||
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
|
||||
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
|
||||
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
|
||||
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
|
||||
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
|
||||
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
|
||||
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
|
||||
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
|
||||
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
|
||||
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
|
||||
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
|
||||
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
|
||||
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
|
||||
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
|
||||
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
|
||||
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
|
||||
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
|
||||
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
|
||||
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
|
||||
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
|
||||
}
|
||||
|
||||
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
const [form, setForm] = useState({
|
||||
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
|
||||
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL', fuelType: 'GASOLINE',
|
||||
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL',
|
||||
fuelType: 'GASOLINE', mileage: '',
|
||||
})
|
||||
const [colorSelect, setColorSelect] = useState('')
|
||||
const [makeSelect, setMakeSelect] = useState('')
|
||||
const [modelSelect, setModelSelect] = useState('')
|
||||
const [photoFiles, setPhotoFiles] = useState<File[]>([])
|
||||
const [photoPreviews, setPhotoPreviews] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
if (!files.length) return
|
||||
const combined = [...photoFiles, ...files].slice(0, 10)
|
||||
setPhotoFiles(combined)
|
||||
setPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const removePhoto = (index: number) => {
|
||||
URL.revokeObjectURL(photoPreviews[index])
|
||||
const files = photoFiles.filter((_, i) => i !== index)
|
||||
const previews = photoPreviews.filter((_, i) => i !== index)
|
||||
setPhotoFiles(files)
|
||||
setPhotoPreviews(previews)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setForm({ make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY', dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL', fuelType: 'GASOLINE', mileage: '' })
|
||||
setColorSelect('')
|
||||
setMakeSelect('')
|
||||
setModelSelect('')
|
||||
photoPreviews.forEach((p) => URL.revokeObjectURL(p))
|
||||
setPhotoFiles([])
|
||||
setPhotoPreviews([])
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleClose = () => { reset(); onClose() }
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.make) { setError('Please select or enter a make.'); return }
|
||||
if (!form.model) { setError('Please select or enter a model.'); return }
|
||||
if (!form.licensePlate) { setError('Please enter a license plate.'); return }
|
||||
if (!form.dailyRate || isNaN(parseFloat(form.dailyRate))) { setError('Please enter a valid daily rate.'); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/vehicles', {
|
||||
const vehicle = await apiFetch<{ id: string }>('/vehicles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
dailyRate: Math.round(parseFloat(form.dailyRate) * 100),
|
||||
year: Number(form.year),
|
||||
seats: Number(form.seats),
|
||||
mileage: form.mileage ? Number(form.mileage) : undefined,
|
||||
}),
|
||||
})
|
||||
if (photoFiles.length > 0) {
|
||||
const fd = new FormData()
|
||||
photoFiles.forEach((f) => fd.append('photos', f))
|
||||
await apiFetch(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
|
||||
}
|
||||
onSaved()
|
||||
onClose()
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setError(err.message ?? 'Failed to add vehicle. Please check all fields and try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -68,21 +140,66 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const presetModels = form.make && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-5">Add New Vehicle</h2>
|
||||
{error && (
|
||||
<div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Make & Model */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label>
|
||||
<input className="input-field" placeholder="Toyota" required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
|
||||
<select
|
||||
className="input-field"
|
||||
value={makeSelect}
|
||||
onChange={(e) => {
|
||||
setMakeSelect(e.target.value)
|
||||
setModelSelect('')
|
||||
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
|
||||
else setForm({ ...form, make: '', model: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select make…</option>
|
||||
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
</select>
|
||||
{makeSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type make…" required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} autoFocus />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label>
|
||||
<input className="input-field" placeholder="Corolla" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||
{!presetModels ? (
|
||||
<input className="input-field" placeholder="Model" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
className="input-field"
|
||||
value={modelSelect}
|
||||
onChange={(e) => {
|
||||
setModelSelect(e.target.value)
|
||||
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
|
||||
else setForm({ ...form, model: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select model…</option>
|
||||
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
</select>
|
||||
{modelSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type model…" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} autoFocus />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Year & Category */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label>
|
||||
@@ -97,6 +214,8 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Rate & License Plate */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label>
|
||||
@@ -107,10 +226,27 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color, Seats, Transmission */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label>
|
||||
<input className="input-field" placeholder="White" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
|
||||
<select
|
||||
className="input-field"
|
||||
value={colorSelect}
|
||||
onChange={(e) => {
|
||||
setColorSelect(e.target.value)
|
||||
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
|
||||
else setForm({ ...form, color: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
</select>
|
||||
{colorSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type color…" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} autoFocus />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
|
||||
@@ -124,19 +260,73 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label>
|
||||
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
|
||||
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'].map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Fuel Type & Odometer */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label>
|
||||
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
|
||||
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'].map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Odometer (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
placeholder="e.g. 45000"
|
||||
min="0"
|
||||
value={form.mileage}
|
||||
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Photos */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Photos (up to 10)</label>
|
||||
{photoPreviews.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{photoPreviews.map((src, i) => (
|
||||
<div key={i} className="relative h-24 rounded-lg overflow-hidden bg-slate-100 group">
|
||||
<Image src={src} alt={`preview ${i + 1}`} fill className="object-cover" unoptimized />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePhoto(i)}
|
||||
className="absolute top-1 right-1 p-0.5 bg-black/60 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{photoFiles.length < 10 && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handlePhotoChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{photoFiles.length === 0 ? 'Click to add photos' : 'Add more photos'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="btn-secondary flex-1">Cancel</button>
|
||||
<button type="button" onClick={handleClose} className="btn-secondary flex-1">Cancel</button>
|
||||
<button type="submit" disabled={loading} className="btn-primary flex-1">
|
||||
{loading ? 'Adding…' : 'Add Vehicle'}
|
||||
</button>
|
||||
@@ -159,8 +349,8 @@ export default function FleetPage() {
|
||||
|
||||
const fetchVehicles = () => {
|
||||
setLoading(true)
|
||||
apiFetch<ApiPaginated<Vehicle>>('/vehicles?pageSize=100')
|
||||
.then((result) => setVehicles(result.data))
|
||||
apiFetch<Vehicle[]>('/vehicles?pageSize=100')
|
||||
.then((result) => setVehicles(result ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
@@ -209,31 +399,19 @@ export default function FleetPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="input-field w-36"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<select className="input-field w-36" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input-field w-36"
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
<select className="input-field w-36" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
|
||||
<option value="">All categories</option>
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input-field w-36"
|
||||
value={publishedFilter}
|
||||
onChange={(e) => setPublishedFilter(e.target.value)}
|
||||
>
|
||||
<select className="input-field w-36" value={publishedFilter} onChange={(e) => setPublishedFilter(e.target.value)}>
|
||||
<option value="">All visibility</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="unpublished">Unpublished</option>
|
||||
@@ -270,7 +448,7 @@ export default function FleetPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-10 rounded-lg bg-slate-100 overflow-hidden flex-shrink-0">
|
||||
{vehicle.primaryPhoto ? (
|
||||
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" />
|
||||
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" unoptimized={!vehicle.primaryPhoto.includes('res.cloudinary.com')} />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">No photo</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react'
|
||||
|
||||
interface OnlineReservation {
|
||||
id: string
|
||||
status: 'DRAFT' | 'CONFIRMED' | 'CANCELLED' | 'ACTIVE' | 'COMPLETED' | 'NO_SHOW'
|
||||
source: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
totalAmount: number
|
||||
totalDays: number
|
||||
dailyRate: number
|
||||
notes: string | null
|
||||
cancelReason: string | null
|
||||
createdAt: string
|
||||
vehicle: { make: string; model: string; year: number; licensePlate: string }
|
||||
customer: { firstName: string; lastName: string; email: string; phone: string | null }
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
DRAFT: 'bg-amber-100 text-amber-800',
|
||||
CONFIRMED: 'bg-emerald-100 text-emerald-700',
|
||||
CANCELLED: 'bg-rose-100 text-rose-700',
|
||||
ACTIVE: 'bg-blue-100 text-blue-700',
|
||||
COMPLETED: 'bg-slate-100 text-slate-600',
|
||||
NO_SHOW: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: 'Pending',
|
||||
CONFIRMED: 'Confirmed',
|
||||
CANCELLED: 'Declined',
|
||||
ACTIVE: 'Active',
|
||||
COMPLETED: 'Completed',
|
||||
NO_SHOW: 'No show',
|
||||
}
|
||||
|
||||
function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: string) => void; onCancel: () => void; loading: boolean }) {
|
||||
const [reason, setReason] = useState('')
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onCancel}>
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-base font-semibold text-slate-900">Decline reservation</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Optionally provide a reason — it will be visible in the reservation record.</p>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. Vehicle not available for these dates"
|
||||
className="mt-4 w-full rounded-xl border border-slate-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900 resize-none"
|
||||
/>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button onClick={onCancel} className="flex-1 rounded-full border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700">Cancel</button>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={() => onConfirm(reason)}
|
||||
className="flex-1 rounded-full bg-rose-600 px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? 'Declining…' : 'Decline'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OnlineReservationsPage() {
|
||||
const [rows, setRows] = useState<OnlineReservation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [acting, setActing] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [declineTarget, setDeclineTarget] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await apiFetch<ApiPaginated<OnlineReservation>>('/reservations?source=MARKETPLACE&pageSize=100')
|
||||
setRows(
|
||||
(result.data ?? []).sort((a, b) => {
|
||||
// DRAFT first, then by createdAt desc
|
||||
if (a.status === 'DRAFT' && b.status !== 'DRAFT') return -1
|
||||
if (a.status !== 'DRAFT' && b.status === 'DRAFT') return 1
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
})
|
||||
)
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load reservations')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function confirm(id: string) {
|
||||
setActing(id)
|
||||
setActionError(null)
|
||||
try {
|
||||
await apiFetch(`/reservations/${id}/confirm`, { method: 'POST', body: JSON.stringify({}) })
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setActionError(err.message ?? 'Failed to confirm reservation')
|
||||
} finally {
|
||||
setActing(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function decline(id: string, reason: string) {
|
||||
setActing(id)
|
||||
setActionError(null)
|
||||
try {
|
||||
await apiFetch(`/reservations/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason: reason || undefined }) })
|
||||
setDeclineTarget(null)
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setActionError(err.message ?? 'Failed to decline reservation')
|
||||
} finally {
|
||||
setActing(null)
|
||||
}
|
||||
}
|
||||
|
||||
const pending = rows.filter((r) => r.status === 'DRAFT')
|
||||
const history = rows.filter((r) => r.status !== 'DRAFT')
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{declineTarget && (
|
||||
<DeclineModal
|
||||
loading={acting === declineTarget}
|
||||
onConfirm={(reason) => decline(declineTarget, reason)}
|
||||
onCancel={() => setDeclineTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold text-slate-900">Online Reservations</h2>
|
||||
{pending.length > 0 && (
|
||||
<span className="rounded-full bg-amber-500 px-2.5 py-0.5 text-xs font-bold text-white">{pending.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">Requests submitted by customers through the marketplace. Confirm or decline each one.</p>
|
||||
</div>
|
||||
<button onClick={load} className="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{actionError}</div>
|
||||
)}
|
||||
|
||||
{/* Pending section */}
|
||||
<section>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
<Clock className="h-4 w-4" /> Pending approval
|
||||
{pending.length > 0 && <span className="ml-1 text-amber-600">({pending.length})</span>}
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="card p-8 text-center text-sm text-slate-400">Loading…</div>
|
||||
) : pending.length === 0 ? (
|
||||
<div className="card flex flex-col items-center gap-3 p-10 text-center">
|
||||
<CheckCircle className="h-10 w-10 text-emerald-400" />
|
||||
<p className="text-sm font-medium text-slate-600">All caught up — no pending requests.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{pending.map((r) => (
|
||||
<ReservationCard
|
||||
key={r.id}
|
||||
r={r}
|
||||
acting={acting === r.id}
|
||||
onConfirm={() => confirm(r.id)}
|
||||
onDecline={() => setDeclineTarget(r.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* History section */}
|
||||
{!loading && history.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
History
|
||||
</h3>
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50">
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Customer</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Vehicle</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Dates</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Status</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">Total</th>
|
||||
<th className="px-5 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{history.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-slate-50">
|
||||
<td className="px-5 py-3.5">
|
||||
<p className="text-sm font-medium text-slate-900">{r.customer.firstName} {r.customer.lastName}</p>
|
||||
<p className="text-xs text-slate-500">{r.customer.email}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-sm text-slate-700">{r.vehicle.year} {r.vehicle.make} {r.vehicle.model}</td>
|
||||
<td className="px-5 py-3.5 text-sm text-slate-500">
|
||||
{dayjs(r.startDate).format('MMM D')} – {dayjs(r.endDate).format('MMM D, YYYY')}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_STYLE[r.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{STATUS_LABEL[r.status] ?? r.status}
|
||||
</span>
|
||||
{r.cancelReason && (
|
||||
<p className="mt-0.5 text-xs text-slate-400 truncate max-w-[160px]" title={r.cancelReason}>{r.cancelReason}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right text-sm font-semibold text-slate-900">
|
||||
{formatCurrency(r.totalAmount, 'MAD')}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<Link href={`/dashboard/reservations/${r.id}`} className="text-slate-400 hover:text-slate-700">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReservationCard({ r, acting, onConfirm, onDecline }: {
|
||||
r: OnlineReservation
|
||||
acting: boolean
|
||||
onConfirm: () => void
|
||||
onDecline: () => void
|
||||
}) {
|
||||
const days = r.totalDays
|
||||
return (
|
||||
<div className="card overflow-hidden border-l-4 border-l-amber-400">
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
{/* Left: customer + vehicle + dates */}
|
||||
<div className="space-y-3 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 flex-shrink-0 text-slate-400" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{r.customer.firstName} {r.customer.lastName}</p>
|
||||
<p className="text-xs text-slate-500">{r.customer.email}{r.customer.phone ? ` · ${r.customer.phone}` : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Car className="h-4 w-4 flex-shrink-0 text-slate-400" />
|
||||
<p className="text-sm text-slate-700">{r.vehicle.year} {r.vehicle.make} {r.vehicle.model} <span className="text-slate-400">· {r.vehicle.licensePlate}</span></p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays className="h-4 w-4 flex-shrink-0 text-slate-400" />
|
||||
<p className="text-sm text-slate-700">
|
||||
{dayjs(r.startDate).format('MMM D, YYYY')} → {dayjs(r.endDate).format('MMM D, YYYY')}
|
||||
<span className="ml-2 text-slate-400">{days} day{days > 1 ? 's' : ''}</span>
|
||||
</p>
|
||||
</div>
|
||||
{r.notes && (
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageSquare className="h-4 w-4 flex-shrink-0 text-slate-400 mt-0.5" />
|
||||
<p className="text-sm text-slate-600 italic">"{r.notes}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: total + actions */}
|
||||
<div className="flex flex-col items-end gap-3 flex-shrink-0">
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-400">Estimated total</p>
|
||||
<p className="text-xl font-bold text-slate-900">{formatCurrency(r.totalAmount, 'MAD')}</p>
|
||||
<p className="text-xs text-slate-400">{formatCurrency(r.dailyRate, 'MAD')}/day</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onDecline}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-1.5 rounded-full border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Decline
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-1.5 rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
{acting ? 'Confirming…' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">Received {dayjs(r.createdAt).format('MMM D [at] HH:mm')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 bg-slate-50 px-5 py-2.5 flex items-center justify-between">
|
||||
<p className="text-xs text-slate-400">Ref: {r.id.slice(-10).toUpperCase()}</p>
|
||||
<Link href={`/dashboard/reservations/${r.id}`} className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-700">
|
||||
View full detail <ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
|
||||
interface ReportRow {
|
||||
reservationId: string
|
||||
@@ -55,7 +55,7 @@ export default function ReportsPage() {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const token = (window as any).__clerk?.session ? await (window as any).__clerk.session.getToken() : null
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ReservationsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ApiPaginated<ReservationRow>>('/reservations?pageSize=100')
|
||||
.then((result) => setRows(result.data))
|
||||
.then((result) => setRows(result.data ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useUser } from '@clerk/nextjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTeam, TeamMember } from '@/hooks/useTeam'
|
||||
import InviteModal from '@/components/team/InviteModal'
|
||||
import EditMemberModal from '@/components/team/EditMemberModal'
|
||||
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
|
||||
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
|
||||
function initials(m: TeamMember) {
|
||||
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
|
||||
@@ -73,8 +73,8 @@ function StatusBadge({ member }: { member: TeamMember }) {
|
||||
}
|
||||
|
||||
export default function TeamPage() {
|
||||
const { user } = useUser()
|
||||
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
|
||||
const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState<string>('')
|
||||
@@ -83,7 +83,22 @@ export default function TeamPage() {
|
||||
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
const currentEmployee = members.find((member) => member.email === user?.primaryEmailAddress?.emailAddress)
|
||||
useEffect(() => {
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const encoded = token.split('.')[1] ?? ''
|
||||
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const payload = JSON.parse(atob(padded))
|
||||
setCurrentEmployeeId(typeof payload?.sub === 'string' ? payload.sub : null)
|
||||
} catch {
|
||||
setCurrentEmployeeId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const currentEmployee = members.find((member) => member.id === currentEmployeeId)
|
||||
const isOwner = currentEmployee?.role === 'OWNER'
|
||||
|
||||
function showToast(msg: string) {
|
||||
@@ -247,7 +262,7 @@ export default function TeamPage() {
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-900 dark:text-white">
|
||||
{member.firstName} {member.lastName}
|
||||
{member.email === user?.primaryEmailAddress?.emailAddress && (
|
||||
{member.id === currentEmployeeId && (
|
||||
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import Sidebar from '@/components/layout/Sidebar'
|
||||
import TopBar from '@/components/layout/TopBar'
|
||||
import { DashboardLanguageSwitcher } from '@/components/I18nProvider'
|
||||
import { DashboardLanguageSwitcher, DashboardThemeSwitcher } from '@/components/I18nProvider'
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-50">
|
||||
<div className="flex min-h-screen bg-slate-50 transition-colors dark:bg-slate-950">
|
||||
<Sidebar />
|
||||
<div className="ml-64 flex min-h-screen min-w-0 flex-1 flex-col">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors">
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90">
|
||||
<div className="flex flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-slate-500">
|
||||
Workspace preferences
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<DashboardLanguageSwitcher />
|
||||
<DashboardThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Forgot your password?',
|
||||
subtitle: "Enter your work email and we'll send you a reset link.",
|
||||
email: 'Email',
|
||||
submit: 'Send reset link',
|
||||
submitting: 'Sending…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Check your email',
|
||||
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
|
||||
error: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Mot de passe oublié ?',
|
||||
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
|
||||
email: 'Email',
|
||||
submit: 'Envoyer le lien',
|
||||
submitting: 'Envoi…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Vérifiez votre email',
|
||||
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
|
||||
error: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
title: 'نسيت كلمة المرور؟',
|
||||
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
|
||||
email: 'البريد الإلكتروني',
|
||||
submit: 'إرسال رابط الإعادة',
|
||||
submitting: 'جارٍ الإرسال…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تحقق من بريدك الإلكتروني',
|
||||
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
|
||||
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/employee/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
setSent(true)
|
||||
} catch {
|
||||
setError(dict.error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">
|
||||
RentalDriveGo
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.title}</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">{dict.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{sent ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-slate-500">{dict.successBody}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="owner@company.com"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
|
||||
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,14 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html.light {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
@@ -17,7 +25,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 antialiased;
|
||||
@apply bg-slate-50 text-slate-900 antialiased transition-colors dark:bg-slate-950 dark:text-slate-100;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@@ -31,7 +39,7 @@
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 bg-white hover:bg-slate-50 text-slate-700 text-sm font-medium rounded-lg border border-slate-200 transition-colors;
|
||||
@apply inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@@ -39,11 +47,11 @@
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-3 py-2 text-sm border border-slate-200 rounded-lg bg-white text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors;
|
||||
@apply w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 transition-colors placeholder:text-slate-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white border border-slate-200 rounded-xl shadow-sm;
|
||||
@apply rounded-xl border border-slate-200 bg-white shadow-sm transition-colors dark:border-slate-800 dark:bg-slate-900;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
@@ -70,3 +78,49 @@
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.dark .bg-slate-50 {
|
||||
background-color: rgb(2 6 23);
|
||||
}
|
||||
|
||||
.dark .bg-white {
|
||||
background-color: rgb(15 23 42);
|
||||
}
|
||||
|
||||
.dark .bg-white\/90 {
|
||||
background-color: rgb(15 23 42 / 0.9);
|
||||
}
|
||||
|
||||
.dark .border-slate-200,
|
||||
.dark .border-stone-200 {
|
||||
border-color: rgb(30 41 59);
|
||||
}
|
||||
|
||||
.dark .text-slate-900 {
|
||||
color: rgb(241 245 249);
|
||||
}
|
||||
|
||||
.dark .text-slate-700 {
|
||||
color: rgb(203 213 225);
|
||||
}
|
||||
|
||||
.dark .text-slate-600,
|
||||
.dark .text-stone-500 {
|
||||
color: rgb(148 163 184);
|
||||
}
|
||||
|
||||
.dark .text-slate-500,
|
||||
.dark .text-slate-400,
|
||||
.dark .text-stone-400 {
|
||||
color: rgb(100 116 139);
|
||||
}
|
||||
|
||||
.dark .hover\:bg-slate-100:hover {
|
||||
background-color: rgb(30 41 59);
|
||||
}
|
||||
|
||||
.dark .hover\:text-slate-900:hover {
|
||||
color: rgb(248 250 252);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { ClerkProvider } from '@clerk/nextjs'
|
||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
||||
import { DashboardI18nProvider } from '@/components/I18nProvider'
|
||||
import './globals.css'
|
||||
|
||||
@@ -10,13 +8,19 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const content = (
|
||||
<html lang="en">
|
||||
return (
|
||||
<html lang="en" className="light" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var theme=localStorage.getItem('dashboard-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="font-sans">
|
||||
<DashboardI18nProvider>{children}</DashboardI18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
return clerkFrontendEnabled ? <ClerkProvider>{content}</ClerkProvider> : content
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Set new password',
|
||||
subtitle: 'Choose a strong password for your workspace account.',
|
||||
newPassword: 'New password',
|
||||
confirmPassword: 'Confirm password',
|
||||
submit: 'Reset password',
|
||||
submitting: 'Resetting…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Password updated',
|
||||
successBody: 'Your password has been reset. You can now sign in with your new password.',
|
||||
signIn: 'Sign in',
|
||||
errorMismatch: 'Passwords do not match.',
|
||||
errorShort: 'Password must be at least 8 characters.',
|
||||
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
|
||||
errorGeneric: 'Something went wrong. Please try again.',
|
||||
noToken: 'No reset token found. Please request a new password reset.',
|
||||
requestNew: 'Request new reset link',
|
||||
},
|
||||
fr: {
|
||||
title: 'Nouveau mot de passe',
|
||||
subtitle: "Choisissez un mot de passe fort pour votre compte.",
|
||||
newPassword: 'Nouveau mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
submit: 'Réinitialiser',
|
||||
submitting: 'Traitement…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Mot de passe mis à jour',
|
||||
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
|
||||
signIn: 'Se connecter',
|
||||
errorMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
|
||||
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
|
||||
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
noToken: 'Aucun jeton trouvé. Veuillez demander une réinitialisation.',
|
||||
requestNew: 'Demander un nouveau lien',
|
||||
},
|
||||
ar: {
|
||||
title: 'تعيين كلمة مرور جديدة',
|
||||
subtitle: 'اختر كلمة مرور قوية لحساب مساحة العمل.',
|
||||
newPassword: 'كلمة المرور الجديدة',
|
||||
confirmPassword: 'تأكيد كلمة المرور',
|
||||
submit: 'إعادة تعيين',
|
||||
submitting: 'جارٍ المعالجة…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تم تحديث كلمة المرور',
|
||||
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
|
||||
signIn: 'تسجيل الدخول',
|
||||
errorMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
|
||||
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
|
||||
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
noToken: 'لم يتم العثور على رمز. يرجى طلب إعادة تعيين.',
|
||||
requestNew: 'طلب رابط جديد',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (password.length < 8) { setError(dict.errorShort); return }
|
||||
if (password !== confirm) { setError(dict.errorMismatch); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken)
|
||||
throw new Error(dict.errorGeneric)
|
||||
}
|
||||
setDone(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? dict.errorGeneric)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<ResetShell title={dict.title}>
|
||||
<p className="text-sm text-slate-500 text-center">{dict.noToken}</p>
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/forgot-password" className="btn-primary inline-flex">{dict.requestNew}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<ResetShell title={dict.title}>
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-slate-500">{dict.successBody}</p>
|
||||
<Link href="/sign-in" className="btn-primary inline-flex justify-center">{dict.signIn}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResetShell title={dict.title} subtitle={dict.subtitle}>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.newPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.confirmPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
|
||||
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetShell({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</span>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{title}</h1>
|
||||
{subtitle && <p className="mt-2 text-sm text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { SignIn } from '@clerk/nextjs'
|
||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
const ADMIN_URL = process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002'
|
||||
|
||||
export default function SignInPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const portal = searchParams.get('portal') === 'admin' ? 'admin' : 'workspace'
|
||||
const dict = {
|
||||
en: {
|
||||
workspace: 'Company workspace',
|
||||
access: 'Workspace access',
|
||||
signIn: 'Sign in',
|
||||
useAccount: 'Use your owner or employee account to access the company workspace.',
|
||||
inactive: 'Enter your workspace credentials to continue.',
|
||||
newAccount: 'New company account?',
|
||||
createWorkspace: 'Create your workspace',
|
||||
workspace: 'Sign in',
|
||||
admin: 'Admin sign in',
|
||||
subtitle: 'Enter your credentials to access your workspace.',
|
||||
adminSubtitle: 'Use the same sign-in page for platform admin access.',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
signIn: 'Sign in',
|
||||
signingIn: 'Signing in…',
|
||||
verify: 'Verify code',
|
||||
verifying: 'Verifying…',
|
||||
authCode: 'Authentication code',
|
||||
enterCode: 'Enter the 6-digit code from your authenticator app.',
|
||||
back: 'Back to credentials',
|
||||
forgotPassword: 'Forgot your password?',
|
||||
newAccount: 'New company account?',
|
||||
createWorkspace: 'Create your workspace',
|
||||
invalidCredentials: 'Invalid email or password.',
|
||||
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
|
||||
unexpectedError: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
workspace: 'Espace entreprise',
|
||||
access: 'Accès workspace',
|
||||
signIn: 'Connexion',
|
||||
useAccount: 'Utilisez votre compte propriétaire ou employé pour accéder à l’espace entreprise.',
|
||||
inactive: 'Saisissez les identifiants de votre espace pour continuer.',
|
||||
newAccount: 'Nouveau compte entreprise ?',
|
||||
createWorkspace: 'Créer votre espace',
|
||||
workspace: 'Connexion',
|
||||
admin: 'Connexion admin',
|
||||
subtitle: 'Saisissez vos identifiants pour accéder à votre espace.',
|
||||
adminSubtitle: 'Utilisez cette page unique pour accéder à la plateforme admin.',
|
||||
email: 'Email',
|
||||
password: 'Mot de passe',
|
||||
signIn: 'Connexion',
|
||||
signingIn: 'Connexion…',
|
||||
verify: 'Vérifier le code',
|
||||
verifying: 'Vérification…',
|
||||
authCode: 'Code d’authentification',
|
||||
enterCode: 'Entrez le code à 6 chiffres de votre application d’authentification.',
|
||||
back: 'Retour aux identifiants',
|
||||
forgotPassword: 'Mot de passe oublié ?',
|
||||
newAccount: 'Nouveau compte entreprise ?',
|
||||
createWorkspace: 'Créer votre espace',
|
||||
invalidCredentials: 'Email ou mot de passe invalide.',
|
||||
passwordNotSet: 'Aucun mot de passe défini. Vérifiez votre e-mail d\'invitation ou utilisez « Mot de passe oublié ? »',
|
||||
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
workspace: 'مساحة الشركة',
|
||||
access: 'الوصول إلى المساحة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
useAccount: 'استخدم حساب المالك أو الموظف للوصول إلى مساحة الشركة.',
|
||||
inactive: 'أدخل بيانات مساحة العمل للمتابعة.',
|
||||
newAccount: 'حساب شركة جديد؟',
|
||||
createWorkspace: 'أنشئ مساحتك',
|
||||
workspace: 'تسجيل الدخول',
|
||||
admin: 'دخول الإدارة',
|
||||
subtitle: 'أدخل بياناتك للوصول إلى مساحة العمل.',
|
||||
adminSubtitle: 'استخدم صفحة الدخول نفسها للوصول إلى لوحة الإدارة.',
|
||||
email: 'البريد الإلكتروني',
|
||||
password: 'كلمة المرور',
|
||||
signIn: 'تسجيل الدخول',
|
||||
signingIn: 'جارٍ تسجيل الدخول…',
|
||||
verify: 'تحقق من الرمز',
|
||||
verifying: 'جارٍ التحقق…',
|
||||
authCode: 'رمز المصادقة',
|
||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||
back: 'العودة إلى بيانات الدخول',
|
||||
forgotPassword: 'نسيت كلمة المرور؟',
|
||||
newAccount: 'حساب شركة جديد؟',
|
||||
createWorkspace: 'أنشئ مساحتك',
|
||||
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
|
||||
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
|
||||
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
@@ -51,61 +87,258 @@ export default function SignInPage() {
|
||||
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">
|
||||
RentalDriveGo
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.workspace}</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">{dict.useAccount}</p>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">
|
||||
{portal === 'admin' ? dict.admin : dict.workspace}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{portal === 'admin' ? dict.adminSubtitle : dict.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{dict.access}</p>
|
||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.signIn}</h2>
|
||||
<LocalSignInForm dict={dict} portal={portal} />
|
||||
|
||||
<div className="mt-8">
|
||||
{clerkFrontendEnabled ? <ConfiguredSignIn /> : <FallbackSignInCard dict={dict} />}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-sm text-slate-500">
|
||||
{dict.newAccount}
|
||||
<Link href="/sign-up" className="ml-1 font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.createWorkspace}
|
||||
</Link>
|
||||
</div>
|
||||
{portal !== 'admin' ? (
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-sm text-slate-500">
|
||||
{dict.newAccount}
|
||||
<Link href="/sign-up" className="ml-1 font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.createWorkspace}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfiguredSignIn() {
|
||||
function LocalSignInForm({ dict, portal }: {
|
||||
dict: {
|
||||
email: string
|
||||
password: string
|
||||
signIn: string
|
||||
signingIn: string
|
||||
verify: string
|
||||
verifying: string
|
||||
authCode: string
|
||||
enterCode: string
|
||||
back: string
|
||||
forgotPassword: string
|
||||
invalidCredentials: string
|
||||
passwordNotSet: string
|
||||
unexpectedError: string
|
||||
}
|
||||
portal: 'admin' | 'workspace'
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const adminNext = searchParams.get('next') || '/dashboard'
|
||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
|
||||
const forgotPasswordHref = portal === 'admin' ? `${ADMIN_URL}/forgot-password` : '/forgot-password'
|
||||
|
||||
function redirectAdmin(token: string) {
|
||||
const hash = new URLSearchParams({ token, next: adminNext }).toString()
|
||||
window.location.href = `${ADMIN_URL}/auth-redirect#${hash}`
|
||||
}
|
||||
|
||||
async function handleCredentials(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
if (portal === 'admin') {
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.token) {
|
||||
redirectAdmin(adminJson.data.token)
|
||||
return
|
||||
}
|
||||
|
||||
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
|
||||
setStep('totp')
|
||||
return
|
||||
}
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
return
|
||||
}
|
||||
|
||||
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const empJson = await empRes.json()
|
||||
|
||||
if (empRes.ok && empJson?.data?.token) {
|
||||
const token = empJson.data.token
|
||||
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
|
||||
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
|
||||
router.push(employeeRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (empJson?.error === 'password_not_set') {
|
||||
setError(dict.passwordNotSet)
|
||||
return
|
||||
}
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
} catch {
|
||||
setError(dict.unexpectedError)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotp(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, totpCode }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.token) {
|
||||
redirectAdmin(adminJson.data.token)
|
||||
return
|
||||
}
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
} catch {
|
||||
setError(dict.unexpectedError)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SignIn
|
||||
routing="path"
|
||||
path="/sign-in"
|
||||
signUpUrl="/sign-up"
|
||||
fallbackRedirectUrl="/dashboard"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FallbackSignInCard({ dict }: { dict: { inactive: string; email: string; password: string; signIn: string } }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
{dict.inactive}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
|
||||
<input type="email" disabled placeholder="owner@company.com" className="input-field cursor-not-allowed opacity-70" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
|
||||
<input type="password" disabled placeholder="••••••••" className="input-field cursor-not-allowed opacity-70" />
|
||||
</div>
|
||||
|
||||
<button type="button" disabled className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{dict.signIn}
|
||||
</button>
|
||||
</div>
|
||||
<>
|
||||
{error ? (
|
||||
<div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'credentials' ? (
|
||||
<form onSubmit={handleCredentials} className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="owner@company.com"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-slate-400 hover:text-slate-600"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.signingIn : dict.signIn}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href={forgotPasswordHref} className="text-sm text-slate-500 hover:text-slate-700 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleTotp} className="space-y-5">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-center text-sm text-slate-600">
|
||||
{dict.enterCode}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.authCode}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
required
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="000000"
|
||||
className="input-field text-center text-xl tracking-[0.45em]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.verifying : dict.verify}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('credentials')
|
||||
setTotpCode('')
|
||||
setError(null)
|
||||
}}
|
||||
className="w-full text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
{dict.back}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useSignUp } from '@clerk/nextjs'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { clerkFrontendEnabled } from '@/lib/clerk'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
@@ -29,325 +26,23 @@ type SignupForm = {
|
||||
type CompletedSignup = {
|
||||
companyName: string
|
||||
email: string
|
||||
emailWarning?: string | null
|
||||
}
|
||||
|
||||
type SignupResponse = {
|
||||
companyId: string
|
||||
companyName: string
|
||||
slug: string
|
||||
trialEndsAt: string | null
|
||||
nextStep: string
|
||||
emailDelivery?: {
|
||||
attempted: boolean
|
||||
success: boolean
|
||||
error: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export default function SignUpPage() {
|
||||
return clerkFrontendEnabled ? <ConfiguredSignUpPage /> : <LocalSignUpPage />
|
||||
}
|
||||
|
||||
function ConfiguredSignUpPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const dict = {
|
||||
en: {
|
||||
loading: 'Authentication is still loading. Try again in a moment.',
|
||||
passwordShort: 'Choose a password with at least 8 characters.',
|
||||
passwordMismatch: 'Passwords do not match.',
|
||||
startFailed: 'Could not start account creation',
|
||||
resendFailed: 'Could not resend the verification code',
|
||||
completeFailed: 'Could not verify your email and finish setup',
|
||||
working: 'Working…',
|
||||
},
|
||||
fr: {
|
||||
loading: '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'))
|
||||
@@ -387,12 +82,13 @@ function LocalSignUpPage() {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await apiFetch('/auth/company/signup', {
|
||||
const response = await apiFetch<SignupResponse>('/auth/company/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
companyName: form.companyName,
|
||||
companyPhone: form.companyPhone || undefined,
|
||||
city: form.city || undefined,
|
||||
@@ -407,9 +103,10 @@ function LocalSignUpPage() {
|
||||
setCompletedSignup({
|
||||
companyName: form.companyName,
|
||||
email: form.email,
|
||||
emailWarning: getEmailWarning(response.emailDelivery),
|
||||
})
|
||||
} catch (err) {
|
||||
setError(getClerkErrorMessage(err, 'Could not create workspace'))
|
||||
setError(getErrorMessage(err, 'Could not create workspace'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -426,9 +123,17 @@ function LocalSignUpPage() {
|
||||
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> is now active and the owner email
|
||||
<span className="font-semibold text-slate-900"> {completedSignup.email}</span> has been registered.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Link href="/sign-up" className="btn-primary justify-center">
|
||||
Create another workspace
|
||||
{completedSignup.emailWarning ? (
|
||||
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{completedSignup.emailWarning}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Link href="/dashboard" className="btn-primary justify-center">
|
||||
Enter dashboard
|
||||
</Link>
|
||||
<Link href="/sign-in" className="btn-secondary justify-center">
|
||||
Sign in on another device
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -517,7 +222,7 @@ function LocalSignUpPage() {
|
||||
|
||||
{step === 4 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title="Verify and launch" body="We’ll create the company workspace immediately and start the 14-day trial with the plan you selected." />
|
||||
<SectionIntro title="Review and launch" body="We’ll create the company workspace immediately and start the 14-day trial with the plan you selected." />
|
||||
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
|
||||
<p><span className="font-semibold text-slate-900">Workspace:</span> {form.companyName || 'Your company'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">Plan:</span> {form.plan} · {form.billingPeriod} · {form.currency}</p>
|
||||
@@ -547,6 +252,20 @@ function SectionIntro({ title, body }: { title: string; body: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function getEmailWarning(emailDelivery: SignupResponse['emailDelivery']) {
|
||||
if (!emailDelivery) return null
|
||||
if (emailDelivery.success) return null
|
||||
if (emailDelivery.attempted) {
|
||||
return `The account was created, but the confirmation email could not be delivered${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
|
||||
}
|
||||
return 'The account was created, but email sending is not configured on this environment yet.'
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return fallback
|
||||
}
|
||||
|
||||
function LabeledInput({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (value: string) => void; type?: string }) {
|
||||
return (
|
||||
<label className="block">
|
||||
@@ -562,20 +281,22 @@ function LabeledSelect({ label, value, options, onChange }: { label: string; val
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
|
||||
<select className="input-field" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function NavActions({ onBack, onNext, loading = false, nextLabel = 'Continue', disableNext = false }: { onBack?: () => void; onNext: () => void; loading?: boolean; nextLabel?: string; disableNext?: boolean }) {
|
||||
function NavActions({ onBack, onNext, loading = false, nextLabel = 'Continue' }: { onBack?: () => void; onNext: () => void; loading?: boolean; nextLabel?: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
{onBack ? <button type="button" onClick={onBack} className="btn-secondary">Back</button> : <span />}
|
||||
<button type="button" onClick={onNext} disabled={loading || disableNext} className="btn-primary disabled:cursor-not-allowed disabled:opacity-60">
|
||||
<div className="flex gap-3">
|
||||
{onBack ? (
|
||||
<button type="button" onClick={onBack} className="btn-secondary flex-1 justify-center">
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={onNext} disabled={loading} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{loading ? 'Working…' : nextLabel}
|
||||
</button>
|
||||
</div>
|
||||
@@ -583,50 +304,15 @@ function NavActions({ onBack, onNext, loading = false, nextLabel = 'Continue', d
|
||||
}
|
||||
|
||||
function normalizePlan(value: string | null): SignupForm['plan'] {
|
||||
return value === 'GROWTH' || value === 'PRO' ? value : 'STARTER'
|
||||
if (value === 'GROWTH' || value === 'PRO' || value === 'STARTER') return value
|
||||
return 'STARTER'
|
||||
}
|
||||
|
||||
function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod'] {
|
||||
return value === 'ANNUAL' || value === 'annual' ? 'ANNUAL' : 'MONTHLY'
|
||||
return value === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY'
|
||||
}
|
||||
|
||||
function normalizeCurrency(value: string | null): SignupForm['currency'] {
|
||||
return value === 'USD' || value === 'EUR' ? value : 'MAD'
|
||||
}
|
||||
|
||||
function getClerkErrorMessage(error: unknown, fallback: string) {
|
||||
if (error && typeof error === 'object' && 'errors' in error && Array.isArray((error as { errors?: Array<{ longMessage?: string; message?: string }> }).errors)) {
|
||||
const first = (error as { errors: Array<{ longMessage?: string; message?: string }> }).errors[0]
|
||||
if (first?.longMessage) return first.longMessage
|
||||
if (first?.message) return first.message
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function waitForActiveSession() {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const token = await readActiveSessionToken()
|
||||
if (token) return token
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
throw new Error('Signed in successfully, but could not read the active session token.')
|
||||
}
|
||||
|
||||
async function readActiveSessionToken() {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const clerk = (window as Window & { __clerk?: { session?: { getToken: () => Promise<string | null> } } }).__clerk
|
||||
if (!clerk?.session?.getToken) return null
|
||||
|
||||
try {
|
||||
return await clerk.session.getToken()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (value === 'USD' || value === 'EUR' || value === 'MAD') return value
|
||||
return 'MAD'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user