Rename legacy storefront app and references to carplace
Build & Push / Build & Push Docker Image (push) Failing after 10m39s
Build & Push / Build & Push Docker Image (push) Failing after 10m39s
Replace storefront naming across source, tests, docs, config, and production scripts. Rename the legacy top-level app directory and Carplace component files, remove duplicate storefront startup scripts, and refresh the lockfile.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import AppPrivacyEnPage from './app-privacy-en/page'
|
||||
import AppPrivacyFrPage from './app-privacy-fr/page'
|
||||
import AppPrivacyArPage from './app-privacy-ar/page'
|
||||
import AppTermsEnPage from './app-tc-en/page'
|
||||
import AppTermsFrPage from './app-tc-fr/page'
|
||||
import AppTermsArPage from './app-tc-ar/page'
|
||||
|
||||
function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string) {
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
const page = element as React.ReactElement
|
||||
expect(page.type).toBe(FooterContentPage)
|
||||
expect(page.props.slug).toBe(slug)
|
||||
expect(page.props.forcedLanguage).toBe(forcedLanguage)
|
||||
}
|
||||
|
||||
describe('carplace static app policy pages', () => {
|
||||
it('binds app privacy pages to explicit locales', () => {
|
||||
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
|
||||
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
|
||||
expectPolicyPage(AppPrivacyArPage(), 'privacy-policy', 'ar')
|
||||
})
|
||||
|
||||
it('binds app terms pages to explicit locales and the app terms content slug', () => {
|
||||
expectPolicyPage(AppTermsEnPage(), 'general-conditions', 'en')
|
||||
expectPolicyPage(AppTermsFrPage(), 'general-conditions', 'fr')
|
||||
expectPolicyPage(AppTermsArPage(), 'general-conditions', 'ar')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyArPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyEnPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="en" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyFrPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsArPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsEnPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="en" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsFrPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CompanyWorkspacePage() {
|
||||
redirect('/')
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
'use client'
|
||||
|
||||
import { CalendarDays, Clock3, Info, MapPin, TicketPercent } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
type ExploreSearchFormDict = {
|
||||
searchTitle: string
|
||||
pickupLocation: string
|
||||
dropoffLocation: string
|
||||
sameDropoff: string
|
||||
differentDropoff: string
|
||||
sameAsPickup: string
|
||||
pickupDate: string
|
||||
returnDate: string
|
||||
hour: string
|
||||
promoCode: string
|
||||
myAge: string
|
||||
ageDescription: string
|
||||
search: string
|
||||
ageOptions: string[]
|
||||
}
|
||||
|
||||
function toUtcDateTime(date: string, time: string) {
|
||||
if (!date) return ''
|
||||
return `${date}T${time || '10:00'}:00.000Z`
|
||||
}
|
||||
|
||||
const inputBase = 'w-full border-0 bg-white dark:bg-blue-900/30 px-4 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none placeholder:text-stone-400 dark:placeholder:text-stone-500'
|
||||
const selectBase = 'w-full border-0 bg-white dark:bg-blue-900/30 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none'
|
||||
|
||||
export default function ExploreSearchForm({
|
||||
pickupLocation,
|
||||
dropoffLocation,
|
||||
dropoffMode,
|
||||
pickupDate,
|
||||
pickupTime,
|
||||
returnDate,
|
||||
returnTime,
|
||||
promoCode,
|
||||
driverAge,
|
||||
cities,
|
||||
dict,
|
||||
}: {
|
||||
pickupLocation: string
|
||||
dropoffLocation: string
|
||||
dropoffMode: 'same' | 'different'
|
||||
pickupDate: string
|
||||
pickupTime: string
|
||||
returnDate: string
|
||||
returnTime: string
|
||||
promoCode: string
|
||||
driverAge: string
|
||||
cities: string[]
|
||||
dict: ExploreSearchFormDict
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const cityOptions = Array.isArray(cities) ? cities : []
|
||||
const ageOptions = Array.isArray(dict.ageOptions) ? dict.ageOptions : []
|
||||
const [filters, setFilters] = useState({
|
||||
pickupLocation,
|
||||
dropoffLocation,
|
||||
dropoffMode,
|
||||
pickupDate,
|
||||
pickupTime: pickupTime || '10:00',
|
||||
returnDate,
|
||||
returnTime: returnTime || '10:00',
|
||||
promoCode,
|
||||
driverAge: driverAge || '25+',
|
||||
})
|
||||
const [ageHelpOpen, setAgeHelpOpen] = useState(false)
|
||||
|
||||
function updateFilter(name: keyof typeof filters, value: string) {
|
||||
setFilters((current) => ({ ...current, [name]: value }))
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const query = new URLSearchParams()
|
||||
const normalizedPickup = filters.pickupLocation.trim()
|
||||
const normalizedDropoff = filters.dropoffMode === 'different'
|
||||
? filters.dropoffLocation.trim()
|
||||
: normalizedPickup
|
||||
const normalizedPromo = filters.promoCode.trim()
|
||||
|
||||
if (normalizedPickup) {
|
||||
query.set('pickupLocation', normalizedPickup)
|
||||
query.set('city', normalizedPickup)
|
||||
}
|
||||
query.set('dropoffMode', filters.dropoffMode)
|
||||
if (filters.dropoffMode === 'different' && normalizedDropoff) {
|
||||
query.set('dropoffLocation', normalizedDropoff)
|
||||
}
|
||||
if (filters.pickupDate) query.set('pickupDate', filters.pickupDate)
|
||||
if (filters.pickupTime) query.set('pickupTime', filters.pickupTime)
|
||||
if (filters.returnDate) query.set('returnDate', filters.returnDate)
|
||||
if (filters.returnTime) query.set('returnTime', filters.returnTime)
|
||||
if (normalizedPromo) query.set('promoCode', normalizedPromo)
|
||||
if (filters.driverAge) query.set('driverAge', filters.driverAge)
|
||||
|
||||
const startDateTime = toUtcDateTime(filters.pickupDate, filters.pickupTime)
|
||||
const endDateTime = toUtcDateTime(filters.returnDate, filters.returnTime)
|
||||
if (startDateTime) query.set('startDate', startDateTime)
|
||||
if (endDateTime) query.set('endDate', endDateTime)
|
||||
|
||||
const target = query.toString() ? `/explore?${query.toString()}` : '/explore'
|
||||
router.push(target)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 rounded-[1.75rem] bg-white dark:bg-blue-950 p-5 text-stone-900 dark:text-stone-100 shadow-[0_24px_80px_rgba(0,0,0,0.16)]">
|
||||
<h2 className="text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{dict.searchTitle}</h2>
|
||||
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
|
||||
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 md:grid-cols-2">
|
||||
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
||||
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
||||
<select
|
||||
name="pickupLocation"
|
||||
value={filters.pickupLocation}
|
||||
onChange={(event) => updateFilter('pickupLocation', event.target.value)}
|
||||
className={selectBase}
|
||||
>
|
||||
<option value="">{dict.pickupLocation}</option>
|
||||
{cityOptions.map((city) => (
|
||||
<option key={`pickup-${city}`} value={city}>{city}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="bg-white dark:bg-blue-900/30 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.dropoffLocation}</span>
|
||||
<div className="inline-flex rounded-full border border-stone-200 dark:border-blue-800 bg-stone-100 dark:bg-blue-950 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((current) => ({ ...current, dropoffMode: 'same', dropoffLocation: '' }))}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
filters.dropoffMode === 'same'
|
||||
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
||||
: 'text-stone-500 dark:text-stone-400'
|
||||
}`}
|
||||
>
|
||||
{dict.sameDropoff}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((current) => ({ ...current, dropoffMode: 'different' }))}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
filters.dropoffMode === 'different'
|
||||
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
||||
: 'text-stone-500 dark:text-stone-400'
|
||||
}`}
|
||||
>
|
||||
{dict.differentDropoff}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{filters.dropoffMode === 'different' ? (
|
||||
<label className="mt-3 flex items-center gap-3">
|
||||
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
||||
<select
|
||||
name="dropoffLocation"
|
||||
value={filters.dropoffLocation}
|
||||
onChange={(event) => updateFilter('dropoffLocation', event.target.value)}
|
||||
className={selectBase}
|
||||
>
|
||||
<option value="">{dict.dropoffLocation}</option>
|
||||
{cityOptions.map((city) => (
|
||||
<option key={`dropoff-${city}`} value={city}>{city}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<div className="mt-3 flex items-center gap-3 rounded-2xl bg-stone-50 px-4 py-4 text-sm font-medium text-stone-500 dark:bg-blue-950 dark:text-stone-300">
|
||||
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
||||
<span>{filters.pickupLocation || dict.sameAsPickup}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 md:grid-cols-4">
|
||||
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
||||
<CalendarDays className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.pickupDate}</span>
|
||||
<input
|
||||
name="pickupDate"
|
||||
type="date"
|
||||
value={filters.pickupDate}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
onChange={(event) => updateFilter('pickupDate', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
||||
<Clock3 className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.hour}</span>
|
||||
<input
|
||||
name="pickupTime"
|
||||
type="time"
|
||||
value={filters.pickupTime}
|
||||
onChange={(event) => updateFilter('pickupTime', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
||||
<CalendarDays className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.returnDate}</span>
|
||||
<input
|
||||
name="returnDate"
|
||||
type="date"
|
||||
value={filters.returnDate}
|
||||
min={filters.pickupDate || new Date().toISOString().slice(0, 10)}
|
||||
onChange={(event) => updateFilter('returnDate', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
||||
<Clock3 className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.hour}</span>
|
||||
<input
|
||||
name="returnTime"
|
||||
type="time"
|
||||
value={filters.returnTime}
|
||||
onChange={(event) => updateFilter('returnTime', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 grid gap-px overflow-visible rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 lg:grid-cols-[1.1fr_1.1fr_1.35fr]">
|
||||
<label className="flex items-center gap-3 rounded-t-[1.35rem] bg-white dark:bg-blue-900/30 px-4 lg:rounded-l-[1.35rem] lg:rounded-tr-none">
|
||||
<TicketPercent className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
||||
<input
|
||||
name="promoCode"
|
||||
value={filters.promoCode}
|
||||
onChange={(event) => updateFilter('promoCode', event.target.value)}
|
||||
placeholder={dict.promoCode}
|
||||
className={`${inputBase} pl-2`}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-3 bg-white dark:bg-blue-900/30 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="text-sm font-semibold text-stone-600 dark:text-stone-300">{dict.myAge}</span>
|
||||
<select
|
||||
name="driverAge"
|
||||
value={filters.driverAge}
|
||||
onChange={(event) => updateFilter('driverAge', event.target.value)}
|
||||
className="border-0 bg-transparent text-sm font-semibold text-stone-900 dark:text-stone-100 outline-none"
|
||||
>
|
||||
{ageOptions.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
className="relative shrink-0"
|
||||
onMouseEnter={() => setAgeHelpOpen(true)}
|
||||
onMouseLeave={() => setAgeHelpOpen(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={dict.ageDescription}
|
||||
onClick={() => setAgeHelpOpen((open) => !open)}
|
||||
className="text-stone-400 dark:text-stone-500 transition hover:text-stone-600 dark:hover:text-stone-300"
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</button>
|
||||
{ageHelpOpen ? (
|
||||
<div className="absolute bottom-[calc(100%+0.85rem)] right-0 z-30 w-72 rounded-2xl border border-orange-300 dark:border-orange-600 bg-white dark:bg-blue-950 px-4 py-3 text-left text-sm leading-6 text-stone-700 dark:text-stone-200 shadow-[0_16px_40px_rgba(0,0,0,0.14)]">
|
||||
{dict.ageDescription}
|
||||
<span className="absolute right-6 top-full h-3 w-3 -translate-y-1/2 rotate-45 border-b border-r border-orange-300 dark:border-orange-600 bg-white dark:bg-blue-950" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="min-h-[76px] rounded-b-[1.35rem] bg-orange-600 px-6 text-sm font-black uppercase tracking-[0.08em] text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300 lg:rounded-b-none lg:rounded-r-[1.35rem]"
|
||||
>
|
||||
{dict.search}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency, type Locale } from '@rentaldrivego/types'
|
||||
|
||||
interface Vehicle {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
availability?: boolean | null
|
||||
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
nextAvailableAt?: string | null
|
||||
company: {
|
||||
slug: string
|
||||
brand: {
|
||||
displayName: string
|
||||
logoUrl: string | null
|
||||
subdomain: string
|
||||
marketplaceRating: number | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
interface Dict {
|
||||
rentalCompany: string
|
||||
checkAvailability: string
|
||||
available: string
|
||||
reserved: string
|
||||
maintenance: string
|
||||
unavailableStatus: string
|
||||
from: string
|
||||
viewVehicle: string
|
||||
availableFrom: string
|
||||
unavailableNoDate: string
|
||||
vehicleUnavailable: string
|
||||
listings: string
|
||||
availableVehicles: string
|
||||
}
|
||||
|
||||
function formatCents(cents: number, language: Locale) {
|
||||
return formatCurrency(cents, 'MAD', language)
|
||||
}
|
||||
|
||||
function formatAvailabilityDate(value?: string | null) {
|
||||
if (!value) return null
|
||||
return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
function getStatusCopy(vehicle: Vehicle, dict: Dict) {
|
||||
if (vehicle.availabilityStatus === 'RESERVED') return dict.reserved
|
||||
if (vehicle.availabilityStatus === 'MAINTENANCE') return dict.maintenance
|
||||
if (vehicle.availabilityStatus === 'UNAVAILABLE') return dict.unavailableStatus
|
||||
if (vehicle.availability === false) return dict.checkAvailability
|
||||
return dict.available
|
||||
}
|
||||
|
||||
export default function ExploreVehicleGrid({ vehicles, dict, language }: { vehicles: Vehicle[]; dict: Dict; language: Locale }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.availableVehicles}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{vehicles.length} {dict.listings}</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{vehicles.map((vehicle) => (
|
||||
<article key={vehicle.id} className="card">
|
||||
<div className="aspect-[16/10] overflow-hidden rounded-t-[2rem] bg-stone-100 dark:bg-blue-900/30">
|
||||
{vehicle.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h3 className="mt-2 truncate text-lg font-bold text-stone-900 dark:text-stone-100">{vehicle.make} {vehicle.model}</h3>
|
||||
<p className="mt-1 truncate text-sm text-stone-500 dark:text-stone-400">{vehicle.year} · {vehicle.category}</p>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700 dark:bg-rose-950/50 dark:text-rose-400' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400'}`}>
|
||||
{getStatusCopy(vehicle, dict)}
|
||||
</span>
|
||||
</div>
|
||||
{vehicle.availability === false && (
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">
|
||||
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.from}</p>
|
||||
<p className="truncate text-xl font-black text-stone-900 dark:text-stone-100">{formatCents(vehicle.dailyRate, language)}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/explore/${vehicle.company.slug}/vehicles/${vehicle.id}`}
|
||||
className="shrink-0 rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
|
||||
>
|
||||
{dict.viewVehicle}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{vehicles.length === 0 && (
|
||||
<div className="card p-6 text-sm text-stone-500 dark:text-stone-400 md:col-span-2 xl:col-span-3">{dict.vehicleUnavailable}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import Link from 'next/link'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
|
||||
interface CompanyProfile {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
brand: {
|
||||
displayName: string
|
||||
logoUrl: string | null
|
||||
publicCity: string | null
|
||||
publicCountry: string | null
|
||||
publicPhone: string | null
|
||||
whatsappNumber: string | null
|
||||
marketplaceRating: number | null
|
||||
} | null
|
||||
vehicles: Array<{ id: string; make: string; model: string; dailyRate: number; photos: string[]; category: string; availability: boolean; nextAvailableAt: string | null }>
|
||||
offers: Array<{ id: string; title: string; discountValue: number; validUntil: string }>
|
||||
}
|
||||
|
||||
export default async function CompanyProfilePage({ params }: { params: { slug: string } }) {
|
||||
const language = await getCarplaceLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
unavailable: 'Carplace unavailable',
|
||||
unavailableTitle: 'Company details are temporarily unavailable.',
|
||||
unavailableBody: 'The carplace API is running, but the database is not reachable in this local environment.',
|
||||
backToExplore: 'Back to explore',
|
||||
partner: 'Carplace partner',
|
||||
vehicles: 'vehicles',
|
||||
offers: 'active offers',
|
||||
rating: 'Rating',
|
||||
new: 'New',
|
||||
currentOffers: 'Current offers',
|
||||
validUntil: 'Valid until',
|
||||
noOffers: 'No active offers right now.',
|
||||
fleet: 'Fleet',
|
||||
viewVehicle: 'View vehicle',
|
||||
availableFrom: 'Available from',
|
||||
unavailableNoDate: 'Temporarily unavailable for new reservations.',
|
||||
},
|
||||
fr: {
|
||||
unavailable: 'Carplace indisponible',
|
||||
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
|
||||
unavailableBody: "L'API carplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
|
||||
backToExplore: "Retour à l'exploration",
|
||||
partner: 'Partenaire carplace',
|
||||
vehicles: 'véhicules',
|
||||
offers: 'offres actives',
|
||||
rating: 'Note',
|
||||
new: 'Nouveau',
|
||||
currentOffers: 'Offres actuelles',
|
||||
validUntil: "Valable jusqu'au",
|
||||
noOffers: 'Aucune offre active pour le moment.',
|
||||
fleet: 'Flotte',
|
||||
viewVehicle: 'Voir le véhicule',
|
||||
availableFrom: 'Disponible à partir du',
|
||||
unavailableNoDate: 'Temporairement indisponible pour les nouvelles réservations.',
|
||||
},
|
||||
ar: {
|
||||
unavailable: 'السوق غير متاح',
|
||||
unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.',
|
||||
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات غير متاحة في هذه البيئة المحلية.',
|
||||
backToExplore: 'العودة إلى الاستكشاف',
|
||||
partner: 'شريك في السوق',
|
||||
vehicles: 'مركبات',
|
||||
offers: 'عروض نشطة',
|
||||
rating: 'التقييم',
|
||||
new: 'جديد',
|
||||
currentOffers: 'العروض الحالية',
|
||||
validUntil: 'صالح حتى',
|
||||
noOffers: 'لا توجد عروض نشطة حالياً.',
|
||||
fleet: 'الأسطول',
|
||||
viewVehicle: 'عرض السيارة',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
unavailableNoDate: 'غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
},
|
||||
}[language]
|
||||
const company = await carplaceFetchOrDefault<CompanyProfile | null>(`/carplace/${params.slug}`, null)
|
||||
|
||||
if (!company) {
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section">
|
||||
<section className="card p-8">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{dict.unavailable}</p>
|
||||
<h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1>
|
||||
<p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p>
|
||||
<div className="mt-6">
|
||||
<Link href="/explore" className="rounded-full bg-blue-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.backToExplore}</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section space-y-8">
|
||||
<section className="card p-8">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{company.brand?.publicCity ?? dict.partner}</p>
|
||||
<h1 className="mt-3 text-4xl font-black text-stone-900 dark:text-stone-100">{company.brand?.displayName ?? company.name}</h1>
|
||||
<p className="mt-3 text-stone-600 dark:text-stone-400">{company.brand?.publicCountry ?? ''}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-stone-500 dark:text-stone-400">
|
||||
<span>{company.vehicles.length} {dict.vehicles}</span>
|
||||
<span>{company.offers.length} {dict.offers}</span>
|
||||
<span>{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.currentOffers}</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{company.offers.map((offer) => (
|
||||
<div key={offer.id} className="card p-5">
|
||||
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{offer.title}</p>
|
||||
<p className="mt-2 text-3xl font-black text-orange-700 dark:text-orange-400">{offer.discountValue}%</p>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
|
||||
</div>
|
||||
))}
|
||||
{company.offers.length === 0 && <div className="card p-6 text-sm text-stone-500 dark:text-stone-400">{dict.noOffers}</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.fleet}</h2>
|
||||
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{company.vehicles.map((vehicle) => (
|
||||
<article key={vehicle.id} className="card">
|
||||
<div className="aspect-[16/10] overflow-hidden rounded-t-[2rem] bg-stone-100 dark:bg-blue-900/30">
|
||||
{vehicle.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="truncate text-lg font-bold text-stone-900 dark:text-stone-100">{vehicle.make} {vehicle.model}</h3>
|
||||
<p className="mt-1 truncate text-sm text-stone-500 dark:text-stone-400">{vehicle.category}</p>
|
||||
{!vehicle.availability && (
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}` : dict.unavailableNoDate}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex items-end justify-between gap-3">
|
||||
<p className="min-w-0 truncate text-xl font-black text-stone-900 dark:text-stone-100">{formatCurrency(vehicle.dailyRate, 'MAD', language)}</p>
|
||||
<Link href={`/explore/${params.slug}/vehicles/${vehicle.id}`} className="shrink-0 rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.viewVehicle}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import Link from 'next/link'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import BookingForm from '@/components/BookingForm'
|
||||
|
||||
interface VehicleDetail {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
seats: number
|
||||
transmission: string
|
||||
fuelType: string
|
||||
features: string[]
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
pickupLocations: string[]
|
||||
allowDifferentDropoff: boolean
|
||||
dropoffLocations: string[]
|
||||
availability: boolean
|
||||
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
nextAvailableAt: string | null
|
||||
company: {
|
||||
brand: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
publicPhone: string | null
|
||||
whatsappNumber: string | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
export default async function VehicleDetailPage({ params }: { params: { slug: string; id: string } }) {
|
||||
const language = await getCarplaceLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
unavailable: 'Carplace unavailable',
|
||||
unavailableTitle: 'Vehicle details are temporarily unavailable.',
|
||||
unavailableBody: 'The carplace frontend is running, but the backing database is not reachable right now.',
|
||||
backToFleet: 'Back to fleet',
|
||||
backToExplore: 'Back to explore',
|
||||
noPhotos: 'No photos available.',
|
||||
rentalCompany: 'Rental company',
|
||||
perDay: '/ day',
|
||||
availableFrom: 'Available from',
|
||||
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||
specs: 'Specifications',
|
||||
year: 'Year',
|
||||
category: 'Category',
|
||||
seats: 'Seats',
|
||||
transmission: 'Transmission',
|
||||
fuel: 'Fuel type',
|
||||
features: 'Features & extras',
|
||||
contact: 'Contact company',
|
||||
phone: 'Phone',
|
||||
whatsapp: 'WhatsApp',
|
||||
photos: 'Photos',
|
||||
},
|
||||
fr: {
|
||||
unavailable: 'Carplace indisponible',
|
||||
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
|
||||
unavailableBody: "Le frontend carplace fonctionne, mais la base de données n'est pas accessible pour le moment.",
|
||||
backToFleet: 'Retour à la flotte',
|
||||
backToExplore: 'Retour à l’exploration',
|
||||
noPhotos: 'Aucune photo disponible.',
|
||||
rentalCompany: 'Société de location',
|
||||
perDay: '/ jour',
|
||||
availableFrom: 'Disponible à partir du',
|
||||
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
||||
specs: 'Caractéristiques',
|
||||
year: 'Année',
|
||||
category: 'Catégorie',
|
||||
seats: 'Places',
|
||||
transmission: 'Transmission',
|
||||
fuel: 'Carburant',
|
||||
features: 'Équipements & extras',
|
||||
contact: 'Contacter la société',
|
||||
phone: 'Téléphone',
|
||||
whatsapp: 'WhatsApp',
|
||||
photos: 'Photos',
|
||||
},
|
||||
ar: {
|
||||
unavailable: 'السوق غير متاح',
|
||||
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
|
||||
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات في الخلفية غير متاحة حالياً.',
|
||||
backToFleet: 'العودة إلى الأسطول',
|
||||
backToExplore: 'العودة إلى الاستكشاف',
|
||||
noPhotos: 'لا توجد صور متاحة.',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
perDay: '/ يوم',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
specs: 'المواصفات',
|
||||
year: 'السنة',
|
||||
category: 'الفئة',
|
||||
seats: 'المقاعد',
|
||||
transmission: 'ناقل الحركة',
|
||||
fuel: 'نوع الوقود',
|
||||
features: 'المميزات والإضافات',
|
||||
contact: 'تواصل مع الشركة',
|
||||
phone: 'الهاتف',
|
||||
whatsapp: 'واتساب',
|
||||
photos: 'الصور',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const vehicle = await carplaceFetchOrDefault<VehicleDetail | null>(`/carplace/${params.slug}/vehicles/${params.id}`, null)
|
||||
|
||||
if (!vehicle) {
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section">
|
||||
<section className="card p-8">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{dict.unavailable}</p>
|
||||
<h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1>
|
||||
<p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Link href={`/explore/${params.slug}`} className="rounded-full bg-blue-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.backToFleet}</Link>
|
||||
<Link href="/explore" className="rounded-full border border-stone-200 px-5 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-stone-400 dark:border-blue-800 dark:text-stone-300 dark:hover:border-stone-500">{dict.backToExplore}</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const availabilityDate = vehicle.nextAvailableAt
|
||||
? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: null
|
||||
|
||||
const [heroPhoto, ...galleryPhotos] = vehicle.photos
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section space-y-8">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
<Link href="/explore" className="hover:text-stone-900 dark:hover:text-stone-100 transition">{dict.backToExplore}</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/explore/${params.slug}`} className="hover:text-stone-900 dark:hover:text-stone-100 transition">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</Link>
|
||||
<span>/</span>
|
||||
<span className="text-stone-900 dark:text-stone-100 font-medium">{vehicle.make} {vehicle.model}</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
|
||||
|
||||
{/* Left — photos + specs */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Hero photo */}
|
||||
{heroPhoto ? (
|
||||
<div className="overflow-hidden rounded-3xl bg-stone-100 dark:bg-blue-900/30 aspect-[16/9]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={heroPhoto} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-3xl bg-stone-100 dark:bg-blue-900/30 aspect-[16/9] flex items-center justify-center text-sm text-stone-400">{dict.noPhotos}</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{galleryPhotos.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">{dict.photos}</p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{galleryPhotos.map((photo, index) => (
|
||||
<div key={`${photo}-${index}`} className="overflow-hidden rounded-2xl bg-stone-100 dark:bg-blue-900/30 aspect-[4/3]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 2}`} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Specs */}
|
||||
<div className="card p-6 space-y-4">
|
||||
<h2 className="text-lg font-bold text-stone-900 dark:text-stone-100">{dict.specs}</h2>
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.year}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.year}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.category}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.category}</dd>
|
||||
</div>
|
||||
{vehicle.seats > 0 && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.seats}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.seats}</dd>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.transmission && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.transmission}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.transmission}</dd>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.fuelType && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.fuel}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.fuelType}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{vehicle.features.length > 0 && (
|
||||
<div className="pt-2 border-t border-stone-100 dark:border-blue-900">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500 mb-3">{dict.features}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{vehicle.features.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-stone-100 dark:bg-blue-900/30 px-3 py-1 text-xs font-medium text-stone-700 dark:text-stone-300">{feature}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
{(vehicle.company.brand?.publicPhone || vehicle.company.brand?.whatsappNumber) && (
|
||||
<div className="card p-6 space-y-3">
|
||||
<h2 className="text-lg font-bold text-stone-900 dark:text-stone-100">{dict.contact}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{vehicle.company.brand?.publicPhone && (
|
||||
<a href={`tel:${vehicle.company.brand.publicPhone}`} className="inline-flex items-center gap-2 rounded-full border border-stone-300 dark:border-blue-800 px-4 py-2 text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-blue-900/40 transition">
|
||||
{dict.phone}: {vehicle.company.brand.publicPhone}
|
||||
</a>
|
||||
)}
|
||||
{vehicle.company.brand?.whatsappNumber && (
|
||||
<a href={`https://wa.me/${vehicle.company.brand.whatsappNumber.replace(/\D/g, '')}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 rounded-full bg-orange-600 px-4 py-2 text-sm font-semibold text-white hover:bg-orange-700 transition">
|
||||
{dict.whatsapp}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right — sticky booking panel */}
|
||||
<aside className="space-y-4">
|
||||
<div className="card p-6 space-y-4 lg:sticky lg:top-20">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h1 className="text-3xl font-black text-stone-900 dark:text-stone-100">{vehicle.make} {vehicle.model}</h1>
|
||||
<p className="text-stone-500 dark:text-stone-400">{vehicle.year} · {vehicle.category}</p>
|
||||
<p className="text-3xl font-black text-stone-900 dark:text-stone-100">
|
||||
{formatCurrency(vehicle.dailyRate, 'MAD', language)}
|
||||
<span className="text-base font-medium text-stone-500 dark:text-stone-400"> {dict.perDay}</span>
|
||||
</p>
|
||||
|
||||
{!vehicle.availability && (
|
||||
<div className="rounded-2xl border border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/40 px-4 py-3 text-sm text-orange-800 dark:text-orange-300">
|
||||
{availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vehicle.availability && (
|
||||
<BookingForm
|
||||
vehicleId={vehicle.id}
|
||||
companySlug={params.slug}
|
||||
dailyRate={vehicle.dailyRate}
|
||||
pickupLocations={vehicle.pickupLocations}
|
||||
allowDifferentDropoff={vehicle.allowDifferentDropoff}
|
||||
dropoffLocations={vehicle.dropoffLocations}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/explore/${params.slug}`}
|
||||
className="block rounded-full border border-stone-300 dark:border-blue-800 px-6 py-3 text-center text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-blue-900/40 transition"
|
||||
>
|
||||
{dict.backToFleet}
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import Link from 'next/link'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
import ExploreSearchForm from './ExploreSearchForm'
|
||||
import ExploreVehicleGrid from './ExploreVehicleGrid'
|
||||
|
||||
interface Vehicle {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
pickupLocations: string[]
|
||||
allowDifferentDropoff: boolean
|
||||
dropoffLocations: string[]
|
||||
availability?: boolean | null
|
||||
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
nextAvailableAt?: string | null
|
||||
company: {
|
||||
slug: string
|
||||
brand: {
|
||||
displayName: string
|
||||
logoUrl: string | null
|
||||
subdomain: string
|
||||
marketplaceRating: number | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
interface Offer {
|
||||
id: string
|
||||
title: string
|
||||
discountValue: number
|
||||
validUntil: string
|
||||
company: {
|
||||
brand: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
interface CompanyCard {
|
||||
id: string
|
||||
brand: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
publicCity: string | null
|
||||
marketplaceRating: number | null
|
||||
} | null
|
||||
_count: {
|
||||
vehicles: number
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORY_VALUES = [
|
||||
'ECONOMY',
|
||||
'COMPACT',
|
||||
'MIDSIZE',
|
||||
'FULLSIZE',
|
||||
'SUV',
|
||||
'LUXURY',
|
||||
'VAN',
|
||||
'TRUCK',
|
||||
] as const
|
||||
|
||||
export default async function ExplorePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const resolvedSearchParams = await searchParams
|
||||
const language = await getCarplaceLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
kicker: 'Discovery only',
|
||||
title: 'Find your next rental from trusted local companies.',
|
||||
body: 'Browse, filter, and reserve — the company confirms and contacts you directly.',
|
||||
searchTitle: 'Find a vehicle',
|
||||
pickupLocation: 'Pick-up location',
|
||||
dropoffLocation: 'Drop-off location',
|
||||
sameDropoff: 'Same drop-off',
|
||||
differentDropoff: 'Different drop-off',
|
||||
sameAsPickup: 'Return to the same location',
|
||||
pickupDate: 'Start date',
|
||||
returnDate: 'Return date',
|
||||
hour: 'Hour',
|
||||
promoCode: 'I have a promo code',
|
||||
myAge: 'My age',
|
||||
ageDescription: 'Driver age can affect availability, deposit amount, and final pricing.',
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'Search',
|
||||
currentDeals: 'Current deals',
|
||||
featuredOffers: 'Featured carplace offers',
|
||||
company: 'Company',
|
||||
validUntil: 'Valid until',
|
||||
offersUnavailable: 'Carplace offers are unavailable right now.',
|
||||
availableVehicles: 'Available vehicles',
|
||||
listings: 'listings',
|
||||
rentalCompany: 'Rental company',
|
||||
checkAvailability: 'Check availability',
|
||||
available: 'Available',
|
||||
reserved: 'Reserved',
|
||||
maintenance: 'In maintenance',
|
||||
unavailableStatus: 'Unavailable',
|
||||
from: 'From',
|
||||
viewVehicle: 'View vehicle',
|
||||
availableFrom: 'Available from',
|
||||
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
|
||||
browseByCategory: 'Browse by category',
|
||||
browseByCompany: 'Browse by company',
|
||||
carplacePartners: 'Carplace partners',
|
||||
carplacePartner: 'Carplace partner',
|
||||
rating: 'Rating',
|
||||
new: 'New',
|
||||
publishedVehicles: 'published vehicles',
|
||||
viewFleet: 'View fleet',
|
||||
categories: ['Economy', 'Compact', 'Midsize', 'Full-size', 'SUV', 'Luxury', 'Van', 'Truck'],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Découverte uniquement',
|
||||
title: "Trouvez votre prochaine location auprès d'entreprises locales fiables.",
|
||||
body: "Parcourez, filtrez et réservez — l'entreprise confirme et vous contacte directement.",
|
||||
searchTitle: 'Trouver un véhicule',
|
||||
pickupLocation: 'Lieu de départ',
|
||||
dropoffLocation: "Lieu de retour",
|
||||
sameDropoff: 'Même retour',
|
||||
differentDropoff: 'Retour différent',
|
||||
sameAsPickup: 'Retour au même lieu',
|
||||
pickupDate: 'Date de départ',
|
||||
returnDate: 'Date de retour',
|
||||
hour: 'Heure',
|
||||
promoCode: "J'ai un code promo",
|
||||
myAge: 'Mon âge',
|
||||
ageDescription: "L'âge du conducteur peut influencer la disponibilité, le dépôt de garantie et le prix final.",
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'Rechercher',
|
||||
currentDeals: 'Offres du moment',
|
||||
featuredOffers: 'Offres carplace mises en avant',
|
||||
company: 'Entreprise',
|
||||
validUntil: "Valable jusqu'au",
|
||||
offersUnavailable: 'Les offres carplace sont indisponibles pour le moment.',
|
||||
availableVehicles: 'Véhicules disponibles',
|
||||
listings: 'annonces',
|
||||
rentalCompany: 'Société de location',
|
||||
checkAvailability: 'Vérifier la disponibilité',
|
||||
available: 'Disponible',
|
||||
reserved: 'Réservé',
|
||||
maintenance: 'En maintenance',
|
||||
unavailableStatus: 'Indisponible',
|
||||
from: 'À partir de',
|
||||
viewVehicle: 'Voir le véhicule',
|
||||
availableFrom: 'Disponible à partir du',
|
||||
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
||||
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
|
||||
browseByCategory: 'Parcourir par catégorie',
|
||||
browseByCompany: 'Parcourir par entreprise',
|
||||
carplacePartners: 'Partenaires carplace',
|
||||
carplacePartner: 'Partenaire carplace',
|
||||
rating: 'Note',
|
||||
new: 'Nouveau',
|
||||
publishedVehicles: 'véhicules publiés',
|
||||
viewFleet: 'Voir la flotte',
|
||||
categories: ['Économie', 'Compacte', 'Intermédiaire', 'Grande berline', 'SUV', 'Luxe', 'Van', 'Camion'],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'للاستكشاف فقط',
|
||||
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
|
||||
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
|
||||
searchTitle: 'احجز سيارة',
|
||||
pickupLocation: 'موقع الاستلام',
|
||||
dropoffLocation: 'موقع الإرجاع',
|
||||
sameDropoff: 'نفس موقع الإرجاع',
|
||||
differentDropoff: 'موقع إرجاع مختلف',
|
||||
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
|
||||
pickupDate: 'تاريخ البدء',
|
||||
returnDate: 'تاريخ الإرجاع',
|
||||
hour: 'الساعة',
|
||||
promoCode: 'لدي رمز ترويجي',
|
||||
myAge: 'عمري',
|
||||
ageDescription: 'قد يؤثر عمر السائق على التوفر ومبلغ التأمين والسعر النهائي.',
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'بحث',
|
||||
currentDeals: 'العروض الحالية',
|
||||
featuredOffers: 'عروض السوق المميزة',
|
||||
company: 'شركة',
|
||||
validUntil: 'صالح حتى',
|
||||
offersUnavailable: 'عروض السوق غير متاحة حالياً.',
|
||||
availableVehicles: 'السيارات المتاحة',
|
||||
listings: 'إعلانات',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
checkAvailability: 'تحقق من التوفر',
|
||||
available: 'متاح',
|
||||
reserved: 'محجوز',
|
||||
maintenance: 'قيد الصيانة',
|
||||
unavailableStatus: 'غير متاح',
|
||||
from: 'ابتداءً من',
|
||||
viewVehicle: 'عرض السيارة',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
||||
browseByCategory: 'تصفح حسب الفئة',
|
||||
browseByCompany: 'تصفح حسب الشركة',
|
||||
carplacePartners: 'شركاء السوق',
|
||||
carplacePartner: 'شريك في السوق',
|
||||
rating: 'التقييم',
|
||||
new: 'جديد',
|
||||
publishedVehicles: 'سيارات منشورة',
|
||||
viewFleet: 'عرض الأسطول',
|
||||
categories: ['اقتصادية', 'مدمجة', 'متوسطة', 'كبيرة', 'SUV', 'فاخرة', 'فان', 'شاحنة'],
|
||||
},
|
||||
}[language]
|
||||
|
||||
const city = typeof resolvedSearchParams?.city === 'string' ? resolvedSearchParams.city : ''
|
||||
const pickupLocation = typeof resolvedSearchParams?.pickupLocation === 'string' ? resolvedSearchParams.pickupLocation : city
|
||||
const dropoffLocation = typeof resolvedSearchParams?.dropoffLocation === 'string' ? resolvedSearchParams.dropoffLocation : ''
|
||||
const dropoffMode = resolvedSearchParams?.dropoffMode === 'different' ? 'different' : 'same'
|
||||
const pickupDate = typeof resolvedSearchParams?.pickupDate === 'string' ? resolvedSearchParams.pickupDate : ''
|
||||
const pickupTime = typeof resolvedSearchParams?.pickupTime === 'string' ? resolvedSearchParams.pickupTime : '10:00'
|
||||
const returnDate = typeof resolvedSearchParams?.returnDate === 'string' ? resolvedSearchParams.returnDate : ''
|
||||
const returnTime = typeof resolvedSearchParams?.returnTime === 'string' ? resolvedSearchParams.returnTime : '10:00'
|
||||
const promoCode = typeof resolvedSearchParams?.promoCode === 'string' ? resolvedSearchParams.promoCode : ''
|
||||
const driverAge = typeof resolvedSearchParams?.driverAge === 'string' ? resolvedSearchParams.driverAge : '23+'
|
||||
const category = typeof resolvedSearchParams?.category === 'string' ? resolvedSearchParams.category : ''
|
||||
const transmission = typeof resolvedSearchParams?.transmission === 'string' ? resolvedSearchParams.transmission : ''
|
||||
const make = typeof resolvedSearchParams?.make === 'string' ? resolvedSearchParams.make : ''
|
||||
const model = typeof resolvedSearchParams?.model === 'string' ? resolvedSearchParams.model : ''
|
||||
const startDate = typeof resolvedSearchParams?.startDate === 'string' ? resolvedSearchParams.startDate : ''
|
||||
const endDate = typeof resolvedSearchParams?.endDate === 'string' ? resolvedSearchParams.endDate : ''
|
||||
|
||||
const query = new URLSearchParams()
|
||||
if (pickupLocation) query.set('city', pickupLocation)
|
||||
if (pickupLocation) query.set('pickupLocation', pickupLocation)
|
||||
query.set('dropoffMode', dropoffMode)
|
||||
if (dropoffMode === 'different' && dropoffLocation) query.set('dropoffLocation', dropoffLocation)
|
||||
if (startDate) query.set('startDate', startDate)
|
||||
if (endDate) query.set('endDate', endDate)
|
||||
if (category) query.set('category', category)
|
||||
if (transmission) query.set('transmission', transmission)
|
||||
if (make) query.set('make', make)
|
||||
if (model) query.set('model', model)
|
||||
query.set('pageSize', '24')
|
||||
|
||||
const [offers, vehicles, companies] = await Promise.all([
|
||||
carplaceFetchOrDefault<Offer[]>('/carplace/offers', []),
|
||||
carplaceFetchOrDefault<Vehicle[]>(`/carplace/search?${query.toString()}`, []),
|
||||
carplaceFetchOrDefault<CompanyCard[]>('/carplace/companies?pageSize=8', []),
|
||||
])
|
||||
const cities = Array.from(
|
||||
new Set(
|
||||
companies
|
||||
.map((company) => company.brand?.publicCity?.trim())
|
||||
.filter((city): city is string => Boolean(city))
|
||||
)
|
||||
).sort((left, right) => left.localeCompare(right))
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section space-y-10">
|
||||
<section className="site-panel-contrast bg-[linear-gradient(135deg,#0f2460,#1a3a8f)] dark:bg-[linear-gradient(135deg,#0a1735,#112d6e)] px-8 py-12">
|
||||
<p className="site-kicker text-orange-300 dark:text-orange-300">{dict.kicker}</p>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight">{dict.title}</h1>
|
||||
<p className="mt-4 max-w-2xl text-blue-100 dark:text-blue-100">{dict.body}</p>
|
||||
<ExploreSearchForm
|
||||
pickupLocation={pickupLocation}
|
||||
dropoffLocation={dropoffLocation}
|
||||
dropoffMode={dropoffMode}
|
||||
pickupDate={pickupDate}
|
||||
pickupTime={pickupTime}
|
||||
returnDate={returnDate}
|
||||
returnTime={returnTime}
|
||||
promoCode={promoCode}
|
||||
driverAge={driverAge}
|
||||
cities={cities}
|
||||
dict={dict}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.currentDeals}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.featuredOffers}</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{offers.map((offer) => (
|
||||
<Link key={offer.id} href={`/explore/${offer.company.brand?.subdomain ?? ''}#offers`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
|
||||
<p className="truncate text-xs uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{offer.company.brand?.displayName ?? dict.company}</p>
|
||||
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{offer.title}</h3>
|
||||
<p className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{offer.discountValue}%</p>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
|
||||
</Link>
|
||||
))}
|
||||
{offers.length === 0 && <div className="card p-6 text-sm text-stone-500 dark:text-stone-400 md:col-span-2 xl:col-span-4">{dict.offersUnavailable}</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ExploreVehicleGrid vehicles={vehicles} dict={dict} language={language} />
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCategory}</h2>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{dict.categories.map((categoryName, index) => (
|
||||
<Link key={CATEGORY_VALUES[index] ?? categoryName} href={`/explore?category=${CATEGORY_VALUES[index]}`} className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-stone-400 dark:border-blue-800 dark:bg-blue-950 dark:text-stone-300 dark:hover:border-stone-500">
|
||||
{categoryName}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCompany}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.carplacePartners}</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{companies.map((company) => (
|
||||
<Link key={company.id} href={`/explore/${company.brand?.subdomain ?? ''}`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
|
||||
<p className="truncate text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{company.brand?.publicCity ?? dict.carplacePartner}</p>
|
||||
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{company.brand?.displayName ?? dict.rentalCompany}</h3>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</p>
|
||||
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{company._count.vehicles} {dict.publishedVehicles}</p>
|
||||
<span className="mt-4 inline-flex self-start rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white dark:bg-orange-500 dark:text-white">{dict.viewFleet}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error('NEXT_NOT_FOUND')
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => navigation)
|
||||
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import FooterPage, { generateStaticParams } from './page'
|
||||
import { footerPageSlugs } from '@/lib/footerContent'
|
||||
|
||||
describe('carplace footer route', () => {
|
||||
it('generates one static route per registered footer slug', () => {
|
||||
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
|
||||
})
|
||||
|
||||
it('passes valid slugs through to FooterContentPage', () => {
|
||||
const element = FooterPage({ params: { slug: 'privacy-policy' } })
|
||||
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
expect(element.type).toBe(FooterContentPage)
|
||||
expect(element.props.slug).toBe('privacy-policy')
|
||||
expect(element.props.forcedLanguage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects unknown slugs instead of rendering arbitrary policy pages', () => {
|
||||
expect(() => FooterPage({ params: { slug: 'definitely-not-a-policy' } })).toThrow('NEXT_NOT_FOUND')
|
||||
expect(navigation.notFound).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import { footerPageSlugs, isFooterPageSlug } from '@/lib/footerContent'
|
||||
|
||||
export function generateStaticParams() {
|
||||
return footerPageSlugs.map((slug) => ({ slug }))
|
||||
}
|
||||
|
||||
export default function FooterPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string }
|
||||
}) {
|
||||
const { slug } = params
|
||||
|
||||
if (!isFooterPageSlug(slug)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <FooterContentPage slug={slug} />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import SitePageLayout from '@/components/public/SitePageLayout'
|
||||
|
||||
export default function PublicLayout({ children }: { children: ReactNode }) {
|
||||
return <SitePageLayout>{children}</SitePageLayout>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CarplaceHomePage() {
|
||||
redirect('/explore')
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
@import "../styles/phase16-tokens.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-white text-blue-950 antialiased transition-colors dark:text-slate-100;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-[2rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .card {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
}
|
||||
|
||||
.site-page {
|
||||
@apply min-h-screen overflow-hidden text-blue-950;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-page {
|
||||
@apply text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%);
|
||||
}
|
||||
|
||||
.site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(234, 88, 12, 0.24), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
html.dark .site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(251, 146, 60, 0.22), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(96, 165, 250, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
.site-section {
|
||||
@apply shell py-10 sm:py-14 lg:py-16;
|
||||
}
|
||||
|
||||
.site-panel {
|
||||
@apply rounded-[2rem] border p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .site-panel {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.site-panel-muted {
|
||||
@apply rounded-[2rem] border p-7 transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-image: linear-gradient(160deg, #f5f8ff 0%, #edf2ff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-panel-muted {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-image: linear-gradient(160deg, #0c1830 0%, #10203e 100%);
|
||||
}
|
||||
|
||||
.site-panel-contrast {
|
||||
@apply rounded-[2rem] border p-7 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] transition-colors sm:p-8;
|
||||
border-color: rgb(14 40 90 / 0.8);
|
||||
background-color: rgb(10 25 75);
|
||||
}
|
||||
|
||||
html.dark .site-panel-contrast {
|
||||
border-color: rgb(30 64 175);
|
||||
}
|
||||
|
||||
.site-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.28em] text-orange-600 dark:text-orange-400;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
@apply mt-4 text-4xl font-black tracking-[-0.04em] text-blue-950 dark:text-white sm:text-5xl;
|
||||
}
|
||||
|
||||
.site-lead {
|
||||
@apply mt-5 text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg;
|
||||
}
|
||||
|
||||
.site-link-primary {
|
||||
@apply rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400;
|
||||
}
|
||||
|
||||
.site-link-secondary {
|
||||
@apply rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100 dark:hover:border-blue-400 dark:hover:text-white;
|
||||
}
|
||||
|
||||
/* Phase 16 operational application layer */
|
||||
@layer base {
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--rdg-surface-page);
|
||||
color: var(--rdg-text-primary);
|
||||
scroll-behavior: smooth;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
background: var(--rdg-surface-page);
|
||||
color: var(--rdg-text-primary);
|
||||
font-family: var(--rdg-font-latin);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--rdg-text-primary);
|
||||
line-height: 1.18;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.shell {
|
||||
width: min(100%, var(--rdg-container-max));
|
||||
}
|
||||
|
||||
.panel,
|
||||
.card,
|
||||
.glass-card,
|
||||
.site-panel {
|
||||
border-color: var(--rdg-border);
|
||||
border-radius: var(--rdg-radius-lg);
|
||||
background: color-mix(in srgb, var(--rdg-surface-elevated) 92%, transparent);
|
||||
box-shadow: var(--rdg-shadow-card);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.site-panel-muted {
|
||||
border-color: var(--rdg-border);
|
||||
border-radius: var(--rdg-radius-lg);
|
||||
background: var(--rdg-surface-muted);
|
||||
}
|
||||
|
||||
.site-panel-contrast {
|
||||
border-color: color-mix(in srgb, var(--rdg-blue-700) 68%, var(--rdg-border));
|
||||
border-radius: var(--rdg-radius-lg);
|
||||
background:
|
||||
radial-gradient(circle at 90% 10%, color-mix(in srgb, var(--rdg-blue-500) 30%, transparent), transparent 40%),
|
||||
linear-gradient(135deg, var(--rdg-navy-900), var(--rdg-blue-800));
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.site-link-primary {
|
||||
min-height: var(--rdg-touch-min);
|
||||
border-radius: var(--rdg-radius-sm);
|
||||
background: var(--rdg-action-conversion);
|
||||
color: #ffffff;
|
||||
box-shadow: var(--rdg-shadow-subtle);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.site-link-primary:hover {
|
||||
background: var(--rdg-action-conversion-hover);
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.site-link-secondary {
|
||||
min-height: var(--rdg-touch-min);
|
||||
border-color: var(--rdg-border-strong);
|
||||
border-radius: var(--rdg-radius-sm);
|
||||
background: var(--rdg-surface-primary);
|
||||
color: var(--rdg-action-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.site-link-secondary:hover {
|
||||
border-color: var(--rdg-action-primary);
|
||||
background: var(--rdg-soft-blue);
|
||||
color: var(--rdg-action-primary-hover);
|
||||
}
|
||||
|
||||
.input-field,
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='range']),
|
||||
select,
|
||||
textarea {
|
||||
min-height: var(--rdg-touch-min);
|
||||
border-color: var(--rdg-border);
|
||||
border-radius: var(--rdg-radius-sm);
|
||||
background: var(--rdg-surface-primary);
|
||||
color: var(--rdg-text-primary);
|
||||
}
|
||||
|
||||
.input-field::placeholder,
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--rdg-text-subdued);
|
||||
}
|
||||
|
||||
table {
|
||||
border-color: var(--rdg-border);
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--rdg-surface-muted);
|
||||
color: var(--rdg-text-secondary);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-color: var(--rdg-border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.panel,
|
||||
.card,
|
||||
.glass-card,
|
||||
.site-panel,
|
||||
.site-panel-muted,
|
||||
.site-panel-contrast {
|
||||
border: 1px solid CanvasText;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark body,
|
||||
html[data-theme='dark'] body,
|
||||
.site-page,
|
||||
html.dark .site-page,
|
||||
html[data-theme='dark'] .site-page {
|
||||
background:
|
||||
radial-gradient(circle at 12% 0%, color-mix(in srgb, var(--rdg-soft-blue) 78%, transparent), transparent 30%),
|
||||
var(--rdg-surface-page);
|
||||
color: var(--rdg-text-primary);
|
||||
}
|
||||
|
||||
.site-glow,
|
||||
html.dark .site-glow,
|
||||
html[data-theme='dark'] .site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, color-mix(in srgb, var(--rdg-soft-orange) 72%, transparent), transparent 34%),
|
||||
radial-gradient(circle at top right, color-mix(in srgb, var(--rdg-soft-blue) 72%, transparent), transparent 28%);
|
||||
}
|
||||
|
||||
html.dark .card,
|
||||
html.dark .site-panel,
|
||||
html.dark .site-panel-muted,
|
||||
html[data-theme='dark'] .card,
|
||||
html[data-theme='dark'] .site-panel,
|
||||
html[data-theme='dark'] .site-panel-muted {
|
||||
border-color: var(--rdg-border);
|
||||
background: color-mix(in srgb, var(--rdg-surface-elevated) 92%, transparent);
|
||||
box-shadow: var(--rdg-shadow-card);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import CarplaceShell from '@/components/CarplaceShell'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Carplace',
|
||||
description: 'Discover vehicles from trusted rental companies.',
|
||||
icons: {
|
||||
icon: '/rentaldrivego.png',
|
||||
shortcut: '/favicon.ico',
|
||||
apple: '/rentaldrivego.png',
|
||||
},
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const language = await getCarplaceLanguage()
|
||||
const cookieStore = await cookies()
|
||||
const rawTheme =
|
||||
cookieStore.get('rentaldrivego-theme')?.value ??
|
||||
cookieStore.get('carplace-theme')?.value
|
||||
const theme = rawTheme === 'dark' ? 'dark' : 'light'
|
||||
|
||||
return (
|
||||
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Runs before hydration to prevent flash of wrong theme */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('carplace-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<CarplaceShell initialLanguage={language} initialTheme={theme}>{children}</CarplaceShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
interface SavedCompany {
|
||||
id: string
|
||||
brand?: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
logoUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface RenterProfile {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
savedCompanies?: SavedCompany[]
|
||||
}
|
||||
|
||||
type Status = 'loading' | 'ready' | 'error'
|
||||
|
||||
export default function RenterDashboardPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useCarplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
loadProfile: 'Could not load profile.',
|
||||
reachServer: 'Could not reach the server.',
|
||||
backToExplore: 'Back to explore',
|
||||
renterDashboard: 'Renter Dashboard',
|
||||
welcome: 'Welcome back,',
|
||||
exploreVehicles: 'Explore vehicles',
|
||||
logout: 'Log out',
|
||||
cards: [
|
||||
['Profile', 'Review your account details and preferences.'],
|
||||
['Saved companies', 'Jump back into companies you bookmarked.'],
|
||||
['Notifications', 'See booking updates and in-app alerts.'],
|
||||
],
|
||||
yourProfile: 'Your profile',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
savedCompanies: 'Saved companies',
|
||||
noneSaved: 'None saved yet',
|
||||
saved: 'saved',
|
||||
saveCompaniesPrompt: 'Save companies you like while browsing the carplace.',
|
||||
startExploring: 'Start exploring',
|
||||
rentalCompany: 'Rental company',
|
||||
},
|
||||
fr: {
|
||||
loadProfile: 'Impossible de charger le profil.',
|
||||
reachServer: 'Impossible de joindre le serveur.',
|
||||
backToExplore: 'Retour à l’exploration',
|
||||
renterDashboard: 'Espace client',
|
||||
welcome: 'Bon retour,',
|
||||
exploreVehicles: 'Explorer les véhicules',
|
||||
logout: 'Déconnexion',
|
||||
cards: [
|
||||
['Profil', 'Consultez vos informations et préférences.'],
|
||||
['Entreprises sauvegardées', 'Revenez vers les entreprises que vous avez enregistrées.'],
|
||||
['Notifications', 'Consultez les mises à jour de réservation et les alertes.'],
|
||||
],
|
||||
yourProfile: 'Votre profil',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'E-mail',
|
||||
phone: 'Téléphone',
|
||||
savedCompanies: 'Entreprises sauvegardées',
|
||||
noneSaved: 'Aucune pour le moment',
|
||||
saved: 'sauvegardées',
|
||||
saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la carplace.',
|
||||
startExploring: 'Commencer à explorer',
|
||||
rentalCompany: 'Société de location',
|
||||
},
|
||||
ar: {
|
||||
loadProfile: 'تعذر تحميل الملف الشخصي.',
|
||||
reachServer: 'تعذر الوصول إلى الخادم.',
|
||||
backToExplore: 'العودة إلى الاستكشاف',
|
||||
renterDashboard: 'بوابة المستأجر',
|
||||
welcome: 'مرحباً بعودتك،',
|
||||
exploreVehicles: 'استكشف السيارات',
|
||||
logout: 'تسجيل الخروج',
|
||||
cards: [
|
||||
['الملف الشخصي', 'راجع بيانات حسابك وتفضيلاتك.'],
|
||||
['الشركات المحفوظة', 'ارجع بسرعة إلى الشركات التي حفظتها.'],
|
||||
['الإشعارات', 'اطلع على تحديثات الحجز والتنبيهات داخل التطبيق.'],
|
||||
],
|
||||
yourProfile: 'ملفك الشخصي',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
savedCompanies: 'الشركات المحفوظة',
|
||||
noneSaved: 'لا يوجد شيء محفوظ بعد',
|
||||
saved: 'محفوظة',
|
||||
saveCompaniesPrompt: 'احفظ الشركات التي تعجبك أثناء تصفح السوق.',
|
||||
startExploring: 'ابدأ الاستكشاف',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [profile, setProfile] = useState<RenterProfile | null>(null)
|
||||
const [status, setStatus] = useState<Status>('loading')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/auth/renter/me`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(async (res) => {
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
void fetch(`${API_BASE}/auth/renter/logout`, { method: 'POST', credentials: 'include' })
|
||||
router.replace('/')
|
||||
} else {
|
||||
setErrorMsg(json?.message ?? dict.loadProfile)
|
||||
setStatus('error')
|
||||
}
|
||||
return
|
||||
}
|
||||
setProfile(json?.data ?? json)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorMsg(dict.reachServer)
|
||||
setStatus('error')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="shell">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 w-48 rounded-xl bg-stone-200" />
|
||||
<div className="h-40 rounded-2xl bg-stone-200" />
|
||||
<div className="h-40 rounded-2xl bg-stone-200" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="shell">
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-sm text-rose-600">{errorMsg}</p>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="mt-4 inline-flex rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white"
|
||||
>
|
||||
{dict.backToExplore}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const savedCompanies = profile?.savedCompanies ?? []
|
||||
|
||||
return (
|
||||
<div className="shell space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700">
|
||||
{dict.renterDashboard}
|
||||
</p>
|
||||
<h1 className="mt-1 text-3xl font-black tracking-tight text-blue-900">
|
||||
{dict.welcome} {profile?.firstName}
|
||||
</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="self-start rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 shadow-sm hover:border-stone-300"
|
||||
>
|
||||
{dict.exploreVehicles}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ href: '/renter/profile', label: dict.cards[0][0], copy: dict.cards[0][1] },
|
||||
{ href: '/renter/saved-companies', label: dict.cards[1][0], copy: dict.cards[1][1] },
|
||||
{ href: '/renter/notifications', label: dict.cards[2][0], copy: dict.cards[2][1] },
|
||||
].map((item) => (
|
||||
<Link key={item.href} href={item.href} className="card p-5 hover:border-orange-300 transition-colors">
|
||||
<p className="text-sm font-semibold text-blue-900">{item.label}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-500">{item.copy}</p>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Profile card */}
|
||||
<section className="card p-8">
|
||||
<h2 className="text-lg font-bold text-blue-900">{dict.yourProfile}</h2>
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{ label: dict.firstName, value: profile?.firstName },
|
||||
{ label: dict.lastName, value: profile?.lastName },
|
||||
{ label: dict.email, value: profile?.email },
|
||||
{ label: dict.phone, value: profile?.phone ?? '—' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1.5 text-sm font-medium text-blue-900 break-all">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Saved companies */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-blue-900">{dict.savedCompanies}</h2>
|
||||
<p className="text-sm text-stone-500">
|
||||
{savedCompanies.length === 0
|
||||
? dict.noneSaved
|
||||
: `${savedCompanies.length} ${dict.saved}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{savedCompanies.length === 0 ? (
|
||||
<div className="card mt-4 flex flex-col items-center gap-4 py-16 text-center">
|
||||
<svg
|
||||
className="h-10 w-10 text-stone-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-stone-500">
|
||||
{dict.saveCompaniesPrompt}
|
||||
</p>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-orange-700"
|
||||
>
|
||||
{dict.startExploring}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{savedCompanies.map((company) => (
|
||||
<Link
|
||||
key={company.id}
|
||||
href={`/explore/${company.brand?.subdomain ?? company.id}`}
|
||||
className="card flex items-center gap-4 p-5 hover:border-orange-300 transition-colors"
|
||||
>
|
||||
{company.brand?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={company.brand.logoUrl}
|
||||
alt={company.brand.displayName}
|
||||
className="h-10 w-10 rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 text-orange-700 font-bold text-sm">
|
||||
{company.brand?.displayName?.charAt(0) ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-blue-900">
|
||||
{company.brand?.displayName ?? dict.rentalCompany}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-stone-400">
|
||||
{company.brand?.subdomain ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
<svg
|
||||
className="ml-auto h-4 w-4 shrink-0 text-stone-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import RenterShell from '@/components/RenterShell'
|
||||
|
||||
export default function RenterLayout({ children }: { children: React.ReactNode }) {
|
||||
return <RenterShell>{children}</RenterShell>
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
RenterAuthError,
|
||||
loadRenterNotifications,
|
||||
markAllRenterNotificationsRead,
|
||||
markRenterNotificationRead,
|
||||
type RenterNotification,
|
||||
} from '@/lib/renter'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
|
||||
export default function RenterNotificationsPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useCarplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
load: 'Could not load notifications.',
|
||||
updateOne: 'Could not update the notification.',
|
||||
updateAll: 'Could not update notifications.',
|
||||
renter: 'Renter',
|
||||
title: 'Notifications',
|
||||
markAll: 'Mark all read',
|
||||
back: 'Back to dashboard',
|
||||
loading: 'Loading notifications…',
|
||||
empty: 'No renter notifications yet.',
|
||||
read: 'Read',
|
||||
markRead: 'Mark read',
|
||||
},
|
||||
fr: {
|
||||
load: 'Impossible de charger les notifications.',
|
||||
updateOne: 'Impossible de mettre à jour la notification.',
|
||||
updateAll: 'Impossible de mettre à jour les notifications.',
|
||||
renter: 'Client',
|
||||
title: 'Notifications',
|
||||
markAll: 'Tout marquer comme lu',
|
||||
back: 'Retour au tableau de bord',
|
||||
loading: 'Chargement des notifications…',
|
||||
empty: 'Aucune notification client pour le moment.',
|
||||
read: 'Lu',
|
||||
markRead: 'Marquer comme lu',
|
||||
},
|
||||
ar: {
|
||||
load: 'تعذر تحميل الإشعارات.',
|
||||
updateOne: 'تعذر تحديث الإشعار.',
|
||||
updateAll: 'تعذر تحديث الإشعارات.',
|
||||
renter: 'المستأجر',
|
||||
title: 'الإشعارات',
|
||||
markAll: 'تعيين الكل كمقروء',
|
||||
back: 'العودة إلى بوابة المستأجر',
|
||||
loading: 'جارٍ تحميل الإشعارات…',
|
||||
empty: 'لا توجد إشعارات للمستأجر حالياً.',
|
||||
read: 'مقروء',
|
||||
markRead: 'تعيين كمقروء',
|
||||
},
|
||||
}[language]
|
||||
const [notifications, setNotifications] = useState<RenterNotification[]>([])
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadRenterNotifications()
|
||||
.then((items) => setNotifications(items))
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.load)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
async function markRead(id: string) {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await markRenterNotificationRead(id)
|
||||
setNotifications((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, readAt: item.readAt ?? new Date().toISOString() } : item)),
|
||||
)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.updateOne)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await markAllRenterNotificationsRead()
|
||||
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.updateAll)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-3xl font-black tracking-tight text-blue-900">{dict.title}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllRead}
|
||||
disabled={loading || updating}
|
||||
className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700"
|
||||
>
|
||||
{dict.markAll}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
|
||||
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
|
||||
|
||||
{!loading && !errorMsg && notifications.length === 0 ? (
|
||||
<div className="card p-10 text-center text-sm text-stone-500">{dict.empty}</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !errorMsg && notifications.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification) => (
|
||||
<article key={notification.id} className="card p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-orange-700">{notification.type.replaceAll('_', ' ')}</p>
|
||||
<h2 className="mt-2 text-lg font-bold text-blue-900">{notification.title}</h2>
|
||||
</div>
|
||||
{notification.readAt ? (
|
||||
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-600">{dict.read}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markRead(notification.id)}
|
||||
disabled={updating}
|
||||
className="rounded-full bg-orange-100 px-3 py-1 text-xs font-semibold text-orange-800"
|
||||
>
|
||||
{dict.markRead}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600">{notification.body}</p>
|
||||
<p className="mt-4 text-xs text-stone-400">{new Date(notification.createdAt).toLocaleString()}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
RenterAuthError,
|
||||
clearRenterSession,
|
||||
loadRenterPreferences,
|
||||
loadRenterProfile,
|
||||
updateRenterPreferences,
|
||||
updateRenterProfile,
|
||||
type RenterPreference,
|
||||
type RenterProfile,
|
||||
} from '@/lib/renter'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
|
||||
type Status = 'loading' | 'ready' | 'error'
|
||||
const RENTER_EVENTS = ['BOOKING_CONFIRMED', 'BOOKING_REMINDER', 'RETURN_REMINDER', 'REFUND_ISSUED', 'NEW_OFFER']
|
||||
const RENTER_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'PUSH', 'IN_APP']
|
||||
|
||||
export default function RenterProfilePage() {
|
||||
const router = useRouter()
|
||||
const { language } = useCarplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
profile: 'Profile',
|
||||
loading: 'Loading profile…',
|
||||
loadFailed: 'Could not load profile.',
|
||||
yourProfile: 'Your profile',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
locale: 'Locale',
|
||||
currency: 'Currency',
|
||||
saving: 'Saving…',
|
||||
saveProfile: 'Save profile',
|
||||
verification: 'Verification',
|
||||
verified: 'Email verified',
|
||||
pending: 'Email verification is still pending.',
|
||||
notificationPrefs: 'Notification preferences',
|
||||
notificationBody: 'Choose how reminders, offers, and booking updates reach you.',
|
||||
savePreferences: 'Save preferences',
|
||||
event: 'Event',
|
||||
back: 'Back to dashboard',
|
||||
savedCompanies: 'Saved companies',
|
||||
notifications: 'Notifications',
|
||||
logout: 'Log out',
|
||||
renter: 'Renter',
|
||||
saveFailed: 'Could not save profile.',
|
||||
prefFailed: 'Could not save preferences.',
|
||||
},
|
||||
fr: {
|
||||
profile: 'Profil',
|
||||
loading: 'Chargement du profil…',
|
||||
loadFailed: 'Impossible de charger le profil.',
|
||||
yourProfile: 'Votre profil',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'Email',
|
||||
phone: 'Téléphone',
|
||||
locale: 'Langue',
|
||||
currency: 'Devise',
|
||||
saving: 'Enregistrement…',
|
||||
saveProfile: 'Enregistrer le profil',
|
||||
verification: 'Vérification',
|
||||
verified: 'Email vérifié',
|
||||
pending: 'La vérification de l’email est encore en attente.',
|
||||
notificationPrefs: 'Préférences de notification',
|
||||
notificationBody: 'Choisissez comment les rappels, offres et mises à jour vous parviennent.',
|
||||
savePreferences: 'Enregistrer les préférences',
|
||||
event: 'Événement',
|
||||
back: 'Retour au dashboard',
|
||||
savedCompanies: 'Entreprises sauvegardées',
|
||||
notifications: 'Notifications',
|
||||
logout: 'Déconnexion',
|
||||
renter: 'Client',
|
||||
saveFailed: 'Impossible d’enregistrer le profil.',
|
||||
prefFailed: 'Impossible d’enregistrer les préférences.',
|
||||
},
|
||||
ar: {
|
||||
profile: 'الملف الشخصي',
|
||||
loading: 'جارٍ تحميل الملف الشخصي…',
|
||||
loadFailed: 'تعذر تحميل الملف الشخصي.',
|
||||
yourProfile: 'ملفك الشخصي',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
locale: 'اللغة',
|
||||
currency: 'العملة',
|
||||
saving: 'جارٍ الحفظ…',
|
||||
saveProfile: 'حفظ الملف الشخصي',
|
||||
verification: 'التحقق',
|
||||
verified: 'تم التحقق من البريد الإلكتروني',
|
||||
pending: 'التحقق من البريد الإلكتروني ما زال قيد الانتظار.',
|
||||
notificationPrefs: 'تفضيلات الإشعارات',
|
||||
notificationBody: 'اختر كيف تصلك التذكيرات والعروض وتحديثات الحجز.',
|
||||
savePreferences: 'حفظ التفضيلات',
|
||||
event: 'الحدث',
|
||||
back: 'العودة إلى اللوحة',
|
||||
savedCompanies: 'الشركات المحفوظة',
|
||||
notifications: 'الإشعارات',
|
||||
logout: 'تسجيل الخروج',
|
||||
renter: 'المستأجر',
|
||||
saveFailed: 'تعذر حفظ الملف الشخصي.',
|
||||
prefFailed: 'تعذر حفظ التفضيلات.',
|
||||
},
|
||||
}[language]
|
||||
const [profile, setProfile] = useState<RenterProfile | null>(null)
|
||||
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
|
||||
const [status, setStatus] = useState<Status>('loading')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadRenterProfile(), loadRenterPreferences()])
|
||||
.then(([profileData, preferenceData]) => {
|
||||
setProfile(profileData)
|
||||
setPreferences(
|
||||
Object.fromEntries(
|
||||
preferenceData.map((item) => [`${item.notificationType}:${item.channel}`, item.enabled]),
|
||||
),
|
||||
)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.loadFailed)
|
||||
setStatus('error')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div className="shell"><div className="card p-8 text-sm text-stone-500">{dict.loading}</div></div>
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return <div className="shell"><ErrorCard message={errorMsg} /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell space-y-6">
|
||||
<div className="card p-8">
|
||||
<h1 className="text-3xl font-black tracking-tight text-blue-900">{dict.yourProfile}</h1>
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EditableField label={dict.firstName} value={profile?.firstName ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, firstName: value } : current)} />
|
||||
<EditableField label={dict.lastName} value={profile?.lastName ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, lastName: value } : current)} />
|
||||
<Field label={dict.email} value={profile?.email} />
|
||||
<EditableField label={dict.phone} value={profile?.phone ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, phone: value } : current)} />
|
||||
<SelectField
|
||||
label={dict.locale}
|
||||
value={profile?.preferredLocale || 'en'}
|
||||
options={['en', 'fr', 'ar']}
|
||||
onChange={(value) => setProfile((current) => current ? { ...current, preferredLocale: value } : current)}
|
||||
/>
|
||||
<SelectField
|
||||
label={dict.currency}
|
||||
value="MAD"
|
||||
options={['MAD']}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!profile) return
|
||||
setSaving(true)
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const updated = await updateRenterProfile({
|
||||
firstName: profile.firstName,
|
||||
lastName: profile.lastName,
|
||||
phone: profile.phone,
|
||||
preferredLocale: profile.preferredLocale,
|
||||
preferredCurrency: profile.preferredCurrency,
|
||||
})
|
||||
setProfile(updated)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.saveFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
{saving ? dict.saving : dict.saveProfile}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8 rounded-2xl border border-stone-200 bg-stone-50 p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-500">{dict.verification}</p>
|
||||
<p className="mt-2 text-sm text-stone-700">
|
||||
{profile?.emailVerified ? dict.verified : dict.pending}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-blue-900">{dict.notificationPrefs}</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">{dict.notificationBody}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setSaving(true)
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const payload: RenterPreference[] = Object.entries(preferences).map(([key, enabled]) => {
|
||||
const [notificationType, channel] = key.split(':')
|
||||
return { notificationType, channel, enabled }
|
||||
})
|
||||
await updateRenterPreferences(payload)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.prefFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
{saving ? dict.saving : dict.savePreferences}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-stone-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-stone-500">{dict.event}</th>
|
||||
{RENTER_CHANNELS.map((channel) => (
|
||||
<th key={channel} className="px-4 py-3 text-center font-semibold text-stone-500">
|
||||
{channel}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{RENTER_EVENTS.map((eventName) => (
|
||||
<tr key={eventName} className="border-t border-stone-100">
|
||||
<td className="px-4 py-3 font-medium text-blue-900">{eventName.replaceAll('_', ' ')}</td>
|
||||
{RENTER_CHANNELS.map((channel) => (
|
||||
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences[`${eventName}:${channel}`] ?? true}
|
||||
onChange={(event) =>
|
||||
setPreferences((current) => ({
|
||||
...current,
|
||||
[`${eventName}:${channel}`]: event.target.checked,
|
||||
}))
|
||||
}
|
||||
className="h-4 w-4 rounded border-stone-300 text-orange-600"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<p className="mt-2 text-sm font-medium text-blue-900 break-all">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditableField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-blue-900"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-blue-900"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorCard({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-sm text-rose-600">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { RenterAuthError, loadRenterProfile, type SavedCompany } from '@/lib/renter'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
|
||||
export default function RenterSavedCompaniesPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useCarplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
load: 'Could not load saved companies.',
|
||||
renter: 'Renter',
|
||||
title: 'Saved companies',
|
||||
back: 'Back to dashboard',
|
||||
loading: 'Loading saved companies…',
|
||||
empty: 'You have not saved any companies yet.',
|
||||
browse: 'Browse the carplace',
|
||||
rentalCompany: 'Rental company',
|
||||
},
|
||||
fr: {
|
||||
load: 'Impossible de charger les entreprises sauvegardées.',
|
||||
renter: 'Client',
|
||||
title: 'Entreprises sauvegardées',
|
||||
back: 'Retour au tableau de bord',
|
||||
loading: 'Chargement des entreprises sauvegardées…',
|
||||
empty: 'Vous n’avez encore enregistré aucune entreprise.',
|
||||
browse: 'Parcourir la carplace',
|
||||
rentalCompany: 'Société de location',
|
||||
},
|
||||
ar: {
|
||||
load: 'تعذر تحميل الشركات المحفوظة.',
|
||||
renter: 'المستأجر',
|
||||
title: 'الشركات المحفوظة',
|
||||
back: 'العودة إلى بوابة المستأجر',
|
||||
loading: 'جارٍ تحميل الشركات المحفوظة…',
|
||||
empty: 'لم تقم بحفظ أي شركة بعد.',
|
||||
browse: 'تصفح السوق',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
},
|
||||
}[language]
|
||||
const [companies, setCompanies] = useState<SavedCompany[]>([])
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadRenterProfile()
|
||||
.then((profile) => setCompanies(profile.savedCompanies ?? []))
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.load)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="shell space-y-6">
|
||||
<h1 className="text-3xl font-black tracking-tight text-blue-900">{dict.title}</h1>
|
||||
|
||||
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
|
||||
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
|
||||
|
||||
{!loading && !errorMsg && companies.length === 0 ? (
|
||||
<div className="card p-10 text-center">
|
||||
<p className="text-sm text-stone-500">{dict.empty}</p>
|
||||
<Link href="/explore" className="mt-5 inline-flex rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white">
|
||||
{dict.browse}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !errorMsg && companies.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{companies.map((company) => (
|
||||
<Link
|
||||
key={company.id}
|
||||
href={`/explore/${company.brand?.subdomain ?? company.id}`}
|
||||
className="card flex items-center gap-4 p-5 hover:border-orange-300 transition-colors"
|
||||
>
|
||||
{company.brand?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={company.brand.logoUrl} alt={company.brand.displayName} className="h-11 w-11 rounded-xl object-contain" />
|
||||
) : (
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-orange-100 text-sm font-bold text-orange-700">
|
||||
{company.brand?.displayName?.charAt(0) ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-blue-900">{company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<p className="mt-1 truncate text-xs text-stone-400">{company.brand?.subdomain ?? company.id}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
function buildSearch(params: SearchParams): string {
|
||||
const nextParams = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === 'string') {
|
||||
nextParams.set(key, value)
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) nextParams.append(key, entry)
|
||||
}
|
||||
}
|
||||
|
||||
const search = nextParams.toString()
|
||||
return search ? `?${search}` : ''
|
||||
}
|
||||
|
||||
export default async function RenterSignInPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const requestHeaders = await headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_HOMEPAGE_URL ?? 'http://localhost:3000',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
const params = await searchParams
|
||||
|
||||
redirect(`${dashboardUrl}/sign-in${buildSearch(params)}`)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
function buildSearch(params: SearchParams): string {
|
||||
const nextParams = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === 'string') {
|
||||
nextParams.set(key, value)
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) nextParams.append(key, entry)
|
||||
}
|
||||
}
|
||||
|
||||
const search = nextParams.toString()
|
||||
return search ? `?${search}` : ''
|
||||
}
|
||||
|
||||
export default async function RenterSignUpPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const requestHeaders = await headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
const params = await searchParams
|
||||
|
||||
redirect(`${dashboardUrl}/sign-up${buildSearch(params)}`)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
function buildSearch(params: SearchParams): string {
|
||||
|
||||
const nextParams = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === 'string') {
|
||||
nextParams.set(key, value)
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) nextParams.append(key, entry)
|
||||
}
|
||||
}
|
||||
|
||||
const search = nextParams.toString()
|
||||
return search ? `?${search}` : ''
|
||||
}
|
||||
|
||||
export default async function SignInPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const requestHeaders = await headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_HOMEPAGE_URL ?? 'http://localhost:3000',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
const params = await searchParams
|
||||
|
||||
redirect(`${dashboardUrl}/sign-in${buildSearch(params)}`)
|
||||
}
|
||||
Reference in New Issue
Block a user