redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s

This commit is contained in:
root
2026-06-26 16:27:21 -04:00
parent 256ff0814e
commit d7fb7b7a7b
1030 changed files with 107374 additions and 2657 deletions
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { calculateBookingTotalDays, normalizeOptionalText, resolveReturnLocation } from './BookingForm'
describe('BookingForm helpers', () => {
it('calculates booking nights from date-only inputs', () => {
expect(calculateBookingTotalDays('2026-06-09', '2026-06-10')).toBe(1)
expect(calculateBookingTotalDays('2026-06-09', '2026-06-16')).toBe(7)
})
it('never returns a negative total for reversed or incomplete dates', () => {
expect(calculateBookingTotalDays('2026-06-16', '2026-06-09')).toBe(0)
expect(calculateBookingTotalDays('', '2026-06-09')).toBe(0)
expect(calculateBookingTotalDays('2026-06-09', '')).toBe(0)
})
it('uses pickup location for same-location returns', () => {
expect(resolveReturnLocation({ dropoffMode: 'same', pickupLocation: 'Marrakesh Airport', returnLocation: 'Casablanca' })).toBe('Marrakesh Airport')
})
it('uses the explicit return location for different dropoffs and preserves an unset optional value', () => {
expect(resolveReturnLocation({ dropoffMode: 'different', pickupLocation: 'Marrakesh', returnLocation: 'Casablanca' })).toBe('Casablanca')
expect(resolveReturnLocation({ dropoffMode: 'different', pickupLocation: 'Marrakesh', returnLocation: '' })).toBeUndefined()
})
it('normalizes optional free-text fields without leaking empty strings into payloads', () => {
expect(normalizeOptionalText(' flight lands at 9 ')).toBe('flight lands at 9')
expect(normalizeOptionalText(' ')).toBeUndefined()
})
})
+721
View File
@@ -0,0 +1,721 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { MarketplaceApiError, marketplaceFetch, marketplacePost } from '@/lib/api'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
type Props = {
vehicleId: string
companySlug: string
dailyRate: number
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
}
type DropoffMode = 'same' | 'different'
type BookingFields = {
firstName: string
lastName: string
email: string
phone: string
pickupLocation: string
dropoffMode: DropoffMode
returnLocation: string
startDate: string
endDate: string
notes: string
dateOfBirth: string
nationality: string
identityDocumentNumber: string
fullAddress: string
driverLicense: string
licenseIssuedAt: string
licenseExpiry: string
licenseCountry: string
licenseCategory: string
internationalLicenseNumber: string
}
type AvailabilityState = {
status: 'idle' | 'checking' | 'available' | 'unavailable' | 'error'
nextAvailableAt: string | null
}
type AvailabilityResponse = {
available: boolean
availabilityStatus: string
nextAvailableAt: string | null
blockingReason: string | null
}
type BookingResponse = {
reservationId: string
bookingReference?: string | null
companyName?: string
vehicleName?: string
startDate?: string
endDate?: string
status?: string
message?: string
}
type FunnelEventName =
| 'booking_form_viewed'
| 'trip_dates_selected'
| 'contact_details_started'
| 'driver_details_expanded'
| 'booking_request_submitted'
| 'booking_request_failed'
| 'booking_request_success'
const copy = {
en: {
title: 'Request this car in 1 minute',
subtitle: 'Send the essentials now. Driver documents can be added later if the company needs them.',
tripDetails: 'Trip details',
contactDetails: 'Contact details',
optionalDetails: 'Driver details, optional now',
optionalDetailsHint: 'Skip this unless you want to speed up final approval. The company can collect it after confirming availability.',
showDriverDetails: 'Add driver details now',
hideDriverDetails: 'Hide driver details',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
dateOfBirth: 'Date of birth',
nationality: 'Nationality',
identityDocumentNumber: 'CIN / Passport number',
fullAddress: 'Full address',
driverLicense: 'License number',
licenseIssuedAt: 'Issue date',
licenseExpiry: 'Expiry date',
licenseCountry: 'Country of issue',
licenseCategory: 'License category',
internationalLicenseNumber: 'International permit number',
pickupLocation: 'Pick-up location',
returnLocation: 'Drop-off location',
sameDropoff: 'Same drop-off',
differentDropoff: 'Different drop-off',
sameAsPickup: 'Return to the same location',
startDate: 'Pick-up date',
endDate: 'Return date',
notes: 'Anything the company should know? Optional.',
submit: 'Send booking request',
submitting: 'Sending…',
successTitle: 'Request sent.',
successBody: 'This first step is a request, not an instant booking. Here is what happens next.',
bookingReference: 'Booking reference',
pendingStatus: 'Pending',
invalidDates: 'Return date must be after pick-up date.',
required: 'Add your trip dates, name, email, and phone.',
perDay: '/ day',
estimatedTotal: 'Estimated total',
days: 'day(s)',
genericError: 'Something went wrong. Please try again.',
locationRequired: 'Select the required pick-up and drop-off locations.',
checkingAvailability: 'Checking availability for these dates…',
likelyAvailable: 'Likely available for these dates. Final confirmation still depends on the rental company.',
unavailableWithDate: 'Unavailable for these dates. Next likely availability: {date}.',
unavailableWithoutDate: 'Unavailable for these dates. Choose different trip dates to continue.',
availabilityError: 'Availability could not be checked right now. You can still try again in a moment.',
nextStepRequestSent: 'Request sent',
nextStepRequestSentBody: 'The company receives your dates and contact details first.',
nextStepAvailability: 'Company confirms availability',
nextStepAvailabilityBody: 'They review the request and accept it if the car is still open.',
nextStepDocuments: 'Driver documents if needed',
nextStepDocumentsBody: 'License and identity details can be collected after the company accepts the request.',
nextStepPayment: 'Payment or deposit',
nextStepPaymentBody: 'Any required payment is handled only after availability is confirmed.',
nextStepConfirmed: 'Booking confirmed',
nextStepConfirmedBody: 'The booking is final once availability, documents, and payment are all cleared.',
},
fr: {
title: 'Demander cette voiture en 1 minute',
subtitle: 'Envoyez lessentiel maintenant. Les documents conducteur peuvent être ajoutés plus tard si lagence les demande.',
tripDetails: 'Détails du trajet',
contactDetails: 'Coordonnées',
optionalDetails: 'Détails conducteur, optionnels maintenant',
optionalDetailsHint: 'Ignorez cette partie sauf si vous voulez accélérer la validation finale. Lagence pourra les demander après confirmation de disponibilité.',
showDriverDetails: 'Ajouter les détails conducteur',
hideDriverDetails: 'Masquer les détails conducteur',
firstName: 'Prénom',
lastName: 'Nom',
email: 'E-mail',
phone: 'Téléphone',
dateOfBirth: 'Date de naissance',
nationality: 'Nationalité',
identityDocumentNumber: 'N° CIN / passeport',
fullAddress: 'Adresse complète',
driverLicense: 'Numéro du permis',
licenseIssuedAt: 'Date de délivrance',
licenseExpiry: 'Date dexpiration',
licenseCountry: 'Pays de délivrance',
licenseCategory: 'Catégorie du permis',
internationalLicenseNumber: 'N° de permis international',
pickupLocation: 'Lieu de départ',
returnLocation: 'Lieu de retour',
sameDropoff: 'Même retour',
differentDropoff: 'Retour différent',
sameAsPickup: 'Retour au même lieu',
startDate: 'Date de départ',
endDate: 'Date de retour',
notes: 'Une information utile pour lagence ? Optionnel.',
submit: 'Envoyer la demande',
submitting: 'Envoi…',
successTitle: 'Demande envoyée.',
successBody: 'Cette première étape reste une demande, pas une réservation instantanée. Voici la suite.',
bookingReference: 'Référence de réservation',
pendingStatus: 'En attente',
invalidDates: 'La date de retour doit être après la date de départ.',
required: 'Ajoutez vos dates, votre nom, votre e-mail et votre téléphone.',
perDay: '/ jour',
estimatedTotal: 'Total estimé',
days: 'jour(s)',
genericError: 'Une erreur est survenue. Veuillez réessayer.',
locationRequired: 'Sélectionnez les lieux de départ et de retour requis.',
checkingAvailability: 'Vérification de la disponibilité pour ces dates…',
likelyAvailable: 'Probablement disponible pour ces dates. La confirmation finale dépend encore de lagence.',
unavailableWithDate: 'Indisponible pour ces dates. Prochaine disponibilité probable : {date}.',
unavailableWithoutDate: 'Indisponible pour ces dates. Choisissez dautres dates pour continuer.',
availabilityError: 'La disponibilité ne peut pas être vérifiée pour le moment. Réessayez dans un instant.',
nextStepRequestSent: 'Demande envoyée',
nextStepRequestSentBody: 'Lagence reçoit dabord vos dates et vos coordonnées.',
nextStepAvailability: 'Confirmation de disponibilité',
nextStepAvailabilityBody: 'Lagence vérifie la demande et laccepte si la voiture est toujours libre.',
nextStepDocuments: 'Documents conducteur si nécessaires',
nextStepDocumentsBody: 'Le permis et les pièces didentité peuvent être demandés après lacceptation de la demande.',
nextStepPayment: 'Paiement ou dépôt',
nextStepPaymentBody: 'Le paiement demandé intervient seulement après la confirmation de disponibilité.',
nextStepConfirmed: 'Réservation confirmée',
nextStepConfirmedBody: 'La réservation devient finale une fois la disponibilité, les documents et le paiement validés.',
},
ar: {
title: 'اطلب هذه السيارة خلال دقيقة',
subtitle: 'أرسل المعلومات الأساسية الآن. يمكن إضافة وثائق السائق لاحقاً إذا احتاجت الشركة إليها.',
tripDetails: 'تفاصيل الرحلة',
contactDetails: 'بيانات التواصل',
optionalDetails: 'بيانات السائق، اختيارية الآن',
optionalDetailsHint: 'تجاوز هذا الجزء إلا إذا أردت تسريع الموافقة النهائية. يمكن للشركة طلبه بعد تأكيد التوفر.',
showDriverDetails: 'إضافة بيانات السائق الآن',
hideDriverDetails: 'إخفاء بيانات السائق',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
dateOfBirth: 'تاريخ الميلاد',
nationality: 'الجنسية',
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
fullAddress: 'العنوان الكامل',
driverLicense: 'رقم الرخصة',
licenseIssuedAt: 'تاريخ الإصدار',
licenseExpiry: 'تاريخ الانتهاء',
licenseCountry: 'بلد الإصدار',
licenseCategory: 'فئة الرخصة',
internationalLicenseNumber: 'رقم الرخصة الدولية',
pickupLocation: 'موقع الاستلام',
returnLocation: 'موقع الإرجاع',
sameDropoff: 'نفس موقع الإرجاع',
differentDropoff: 'موقع إرجاع مختلف',
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
startDate: 'تاريخ الاستلام',
endDate: 'تاريخ الإرجاع',
notes: 'أي معلومة تريد إبلاغ الشركة بها؟ اختياري.',
submit: 'إرسال طلب الحجز',
submitting: 'جارٍ الإرسال…',
successTitle: 'تم إرسال الطلب.',
successBody: 'هذه الخطوة الأولى عبارة عن طلب وليست حجزاً فورياً. هذه هي الخطوات التالية.',
bookingReference: 'مرجع الحجز',
pendingStatus: 'قيد المراجعة',
invalidDates: 'يجب أن يكون تاريخ الإرجاع بعد تاريخ الاستلام.',
required: 'أضف تواريخ الرحلة والاسم والبريد والهاتف.',
perDay: '/ يوم',
estimatedTotal: 'الإجمالي التقديري',
days: 'يوم',
genericError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
locationRequired: 'اختر مواقع الاستلام والإرجاع المطلوبة.',
checkingAvailability: 'جارٍ التحقق من التوفر لهذه التواريخ…',
likelyAvailable: 'السيارة متاحة غالباً لهذه التواريخ، لكن التأكيد النهائي يعود إلى شركة التأجير.',
unavailableWithDate: 'غير متاحة لهذه التواريخ. أقرب توفر متوقع: {date}.',
unavailableWithoutDate: 'غير متاحة لهذه التواريخ. اختر تواريخ مختلفة للمتابعة.',
availabilityError: 'تعذر التحقق من التوفر الآن. حاول مرة أخرى بعد قليل.',
nextStepRequestSent: 'تم إرسال الطلب',
nextStepRequestSentBody: 'تتلقى الشركة أولاً التواريخ وبيانات التواصل.',
nextStepAvailability: 'تأكيد التوفر',
nextStepAvailabilityBody: 'تراجع الشركة الطلب وتقبله إذا كانت السيارة ما زالت متاحة.',
nextStepDocuments: 'وثائق السائق عند الحاجة',
nextStepDocumentsBody: 'يمكن طلب الرخصة ووثائق الهوية بعد قبول الطلب.',
nextStepPayment: 'الدفع أو العربون',
nextStepPaymentBody: 'أي دفعة مطلوبة تتم فقط بعد تأكيد التوفر.',
nextStepConfirmed: 'تأكيد الحجز',
nextStepConfirmedBody: 'يصبح الحجز نهائياً بعد اعتماد التوفر والوثائق والدفع.',
},
} as const
const emptyFields: BookingFields = {
firstName: '',
lastName: '',
email: '',
phone: '',
pickupLocation: '',
dropoffMode: 'same',
returnLocation: '',
startDate: '',
endDate: '',
notes: '',
dateOfBirth: '',
nationality: '',
identityDocumentNumber: '',
fullAddress: '',
driverLicense: '',
licenseIssuedAt: '',
licenseExpiry: '',
licenseCountry: '',
licenseCategory: '',
internationalLicenseNumber: '',
}
export function calculateBookingTotalDays(startDate: string, endDate: string): number {
if (!startDate || !endDate) return 0
return Math.max(0, Math.ceil((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86_400_000))
}
export function resolveReturnLocation({
dropoffMode,
returnLocation,
pickupLocation,
}: {
dropoffMode: DropoffMode
returnLocation: string
pickupLocation: string
}): string | undefined {
if (dropoffMode === 'different') return returnLocation || undefined
return pickupLocation || undefined
}
export function normalizeOptionalText(value: string): string | undefined {
const trimmed = value.trim()
return trimmed || undefined
}
function interpolate(template: string, replacements: Record<string, string>) {
return Object.entries(replacements).reduce(
(current, [key, value]) => current.replace(`{${key}}`, value),
template,
)
}
function formatLocalizedDate(value: string, language: 'en' | 'fr' | 'ar') {
return new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(new Date(value))
}
function getBookingSessionId() {
if (typeof window === 'undefined') return 'server'
const existing = window.sessionStorage.getItem('marketplace-booking-session-id')
if (existing) return existing
const created =
typeof window.crypto?.randomUUID === 'function'
? window.crypto.randomUUID()
: `booking-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
window.sessionStorage.setItem('marketplace-booking-session-id', created)
return created
}
function RequiredMark() {
return <span className="text-red-500">*</span>
}
type FieldProps = {
label: string
value: string
onChange: (value: string) => void
required?: boolean
type?: string
textarea?: boolean
}
function Field({ label, value, onChange, required = false, type = 'text', textarea = false }: FieldProps) {
const className = 'w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500'
return (
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">
{label} {required ? <RequiredMark /> : null}
</label>
{textarea ? (
<textarea value={value} onChange={(e) => onChange(e.target.value)} rows={3} className={className} />
) : (
<input required={required} type={type} value={value} onChange={(e) => onChange(e.target.value)} className={className} />
)}
</div>
)
}
export default function BookingForm({
vehicleId,
companySlug,
dailyRate,
pickupLocations,
allowDifferentDropoff,
dropoffLocations,
}: Props) {
const { language } = useMarketplacePreferences()
const t = copy[language]
const pickupOptions = Array.isArray(pickupLocations) ? pickupLocations : []
const dropoffOptions = Array.isArray(dropoffLocations) ? dropoffLocations : []
const [fields, setFields] = useState<BookingFields>({
...emptyFields,
pickupLocation: pickupOptions[0] ?? '',
returnLocation: dropoffOptions[0] ?? '',
})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [successBooking, setSuccessBooking] = useState<BookingResponse | null>(null)
const [availability, setAvailability] = useState<AvailabilityState>({ status: 'idle', nextAvailableAt: null })
const totalDays = calculateBookingTotalDays(fields.startDate, fields.endDate)
const sessionIdRef = useRef<string>('')
const startedAtRef = useRef<number>(Date.now())
const trackedRef = useRef({
viewed: false,
tripDates: false,
contactStarted: false,
})
if (!sessionIdRef.current) {
sessionIdRef.current = getBookingSessionId()
}
function buildUnavailableMessage(nextAvailableAt: string | null) {
return nextAvailableAt
? interpolate(t.unavailableWithDate, { date: formatLocalizedDate(nextAvailableAt, language) })
: t.unavailableWithoutDate
}
async function trackEvent(eventName: FunnelEventName, metadata?: Record<string, string | number | boolean | null>) {
await marketplacePost('/marketplace/events', {
eventName,
companySlug,
vehicleId,
sessionId: sessionIdRef.current,
path: typeof window === 'undefined' ? undefined : window.location.pathname,
metadata,
}).catch(() => null)
}
async function checkAvailability(startDate: string, endDate: string, signal?: AbortSignal) {
const start = new Date(startDate)
const end = new Date(endDate)
const result = await marketplaceFetch<AvailabilityResponse>(
`/marketplace/${companySlug}/vehicles/${vehicleId}/availability?startDate=${encodeURIComponent(start.toISOString())}&endDate=${encodeURIComponent(end.toISOString())}`,
signal ? { signal } : undefined,
)
setAvailability({
status: result.available ? 'available' : 'unavailable',
nextAvailableAt: result.nextAvailableAt,
})
return result
}
function update<K extends keyof BookingFields>(key: K, value: BookingFields[K]) {
setFields((current) => ({ ...current, [key]: value }))
}
useEffect(() => {
if (trackedRef.current.viewed) return
trackedRef.current.viewed = true
void trackEvent('booking_form_viewed', {
allowDifferentDropoff,
hasPickupLocations: pickupOptions.length > 0,
hasDropoffLocations: dropoffOptions.length > 0,
})
}, [allowDifferentDropoff, dropoffOptions.length, pickupOptions.length])
useEffect(() => {
if (trackedRef.current.contactStarted) return
if (!fields.firstName.trim() && !fields.lastName.trim() && !fields.email.trim() && !fields.phone.trim()) return
trackedRef.current.contactStarted = true
void trackEvent('contact_details_started')
}, [fields.email, fields.firstName, fields.lastName, fields.phone])
useEffect(() => {
if (!fields.startDate || !fields.endDate) {
setAvailability({ status: 'idle', nextAvailableAt: null })
return
}
const start = new Date(fields.startDate)
const end = new Date(fields.endDate)
if (end <= start) {
setAvailability({ status: 'idle', nextAvailableAt: null })
return
}
if (!trackedRef.current.tripDates) {
trackedRef.current.tripDates = true
void trackEvent('trip_dates_selected', { totalDays: calculateBookingTotalDays(fields.startDate, fields.endDate) })
}
const controller = new AbortController()
setAvailability((current) => ({ ...current, status: 'checking' }))
void checkAvailability(fields.startDate, fields.endDate, controller.signal).catch((err) => {
if (controller.signal.aborted) return
if (err instanceof MarketplaceApiError && err.code === 'invalid_dates') {
setAvailability({ status: 'idle', nextAvailableAt: null })
return
}
setAvailability({ status: 'error', nextAvailableAt: null })
})
return () => controller.abort()
}, [companySlug, fields.endDate, fields.startDate, vehicleId])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (!fields.firstName.trim() || !fields.lastName.trim() || !fields.email.trim() || !fields.phone.trim() || !fields.startDate || !fields.endDate) {
setError(t.required)
return
}
if ((pickupOptions.length > 0 && !fields.pickupLocation) || (fields.dropoffMode === 'different' && dropoffOptions.length > 0 && !fields.returnLocation)) {
setError(t.locationRequired)
return
}
const start = new Date(fields.startDate)
const end = new Date(fields.endDate)
if (end <= start) {
setError(t.invalidDates)
return
}
setSubmitting(true)
try {
void trackEvent('booking_request_submitted', {
totalDays,
})
const availabilityResult = await checkAvailability(fields.startDate, fields.endDate)
if (!availabilityResult.available) {
const message = buildUnavailableMessage(availabilityResult.nextAvailableAt)
setError(message)
void trackEvent('booking_request_failed', {
reason: 'unavailable',
nextAvailableAt: availabilityResult.nextAvailableAt,
})
return
}
const booking = await marketplacePost<BookingResponse>('/marketplace/reservations', {
vehicleId,
companySlug,
firstName: fields.firstName.trim(),
lastName: fields.lastName.trim(),
email: fields.email.trim(),
phone: fields.phone.trim(),
startDate: start.toISOString(),
endDate: end.toISOString(),
pickupLocation: fields.pickupLocation || undefined,
returnLocation: resolveReturnLocation({
dropoffMode: fields.dropoffMode,
returnLocation: fields.returnLocation,
pickupLocation: fields.pickupLocation,
}),
notes: normalizeOptionalText(fields.notes),
language,
})
setSuccessBooking(booking)
setSuccess(true)
void trackEvent('booking_request_success', {
totalDays,
elapsedMs: Date.now() - startedAtRef.current,
})
} catch (err: unknown) {
if (err instanceof MarketplaceApiError && err.code === 'unavailable') {
setAvailability({ status: 'unavailable', nextAvailableAt: err.nextAvailableAt ?? null })
setError(buildUnavailableMessage(err.nextAvailableAt ?? null))
} else {
setError(err instanceof Error ? err.message : t.genericError)
}
void trackEvent('booking_request_failed', {
reason: err instanceof MarketplaceApiError ? err.code ?? 'request_failed' : 'request_failed',
})
} finally {
setSubmitting(false)
}
}
if (success) {
return (
<div className="rounded-2xl border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-950/40">
<p className="text-base font-semibold text-green-800 dark:text-green-300">{t.successTitle}</p>
<p className="mt-2 text-sm text-green-700 dark:text-green-400">{t.successBody}</p>
{successBooking?.bookingReference ? (
<div className="mt-4 rounded-2xl border border-green-200 bg-white/70 px-4 py-3 text-sm text-green-900 dark:border-green-800 dark:bg-green-950/20 dark:text-green-200">
<p className="font-semibold">{t.bookingReference}: {successBooking.bookingReference}</p>
<p className="mt-1 text-xs text-green-700 dark:text-green-400">
{successBooking.status ?? t.pendingStatus}
</p>
</div>
) : null}
<div className="mt-4 space-y-3">
{[
{ title: t.nextStepRequestSent, body: t.nextStepRequestSentBody, active: true },
{ title: t.nextStepAvailability, body: t.nextStepAvailabilityBody },
{ title: t.nextStepDocuments, body: t.nextStepDocumentsBody },
{ title: t.nextStepPayment, body: t.nextStepPaymentBody },
{ title: t.nextStepConfirmed, body: t.nextStepConfirmedBody },
].map((step, index) => (
<div
key={step.title}
className={`rounded-2xl border px-4 py-3 ${step.active ? 'border-green-300 bg-white/80 dark:border-green-700 dark:bg-green-950/20' : 'border-green-100 bg-white/50 dark:border-green-900/60 dark:bg-transparent'}`}
>
<p className="text-sm font-semibold text-green-900 dark:text-green-200">{index + 1}. {step.title}</p>
<p className="mt-1 text-xs leading-5 text-green-700 dark:text-green-400">{step.body}</p>
</div>
))}
</div>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.title}</p>
<p className="mt-1 text-xs leading-5 text-stone-500 dark:text-stone-400">{t.subtitle}</p>
</div>
{error ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-400">
{error}
</p>
) : null}
<section className="space-y-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-blue-950">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.tripDetails}</p>
{pickupOptions.length > 0 ? (
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.pickupLocation} <RequiredMark /></label>
<select
value={fields.pickupLocation}
onChange={(e) => update('pickupLocation', e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500"
>
{pickupOptions.map((location) => <option key={location} value={location}>{location}</option>)}
</select>
</div>
) : null}
{allowDifferentDropoff ? (
<div className="space-y-3">
<div className="inline-flex rounded-full border border-stone-200 bg-white p-1 dark:border-blue-800 dark:bg-blue-900/30">
<button
type="button"
onClick={() => update('dropoffMode', 'same')}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${fields.dropoffMode === 'same' ? 'bg-blue-900 text-white dark:bg-orange-400' : 'text-stone-500 dark:text-stone-400'}`}
>
{t.sameDropoff}
</button>
<button
type="button"
onClick={() => update('dropoffMode', 'different')}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${fields.dropoffMode === 'different' ? 'bg-blue-900 text-white dark:bg-orange-400' : 'text-stone-500 dark:text-stone-400'}`}
>
{t.differentDropoff}
</button>
</div>
{fields.dropoffMode === 'different' && dropoffOptions.length > 0 ? (
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.returnLocation} <RequiredMark /></label>
<select
value={fields.returnLocation}
onChange={(e) => update('returnLocation', e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500"
>
{dropoffOptions.map((location) => <option key={location} value={location}>{location}</option>)}
</select>
</div>
) : (
<p className="text-sm text-stone-500 dark:text-stone-400">{fields.pickupLocation || t.sameAsPickup}</p>
)}
</div>
) : null}
<div className="grid grid-cols-2 gap-3">
<Field label={t.startDate} required type="date" value={fields.startDate} onChange={(value) => update('startDate', value)} />
<Field label={t.endDate} required type="date" value={fields.endDate} onChange={(value) => update('endDate', value)} />
</div>
{totalDays > 0 ? (
<p className="text-sm text-stone-600 dark:text-stone-400">
{t.estimatedTotal}:{' '}
<strong className="text-stone-900 dark:text-stone-100">{formatCurrency(dailyRate * totalDays, 'MAD', language)}</strong>{' '}
({totalDays} {t.days})
</p>
) : null}
{availability.status === 'checking' ? (
<p className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-300">
{t.checkingAvailability}
</p>
) : null}
{availability.status === 'available' ? (
<p className="rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700 dark:border-green-800 dark:bg-green-950/40 dark:text-green-300">
{t.likelyAvailable}
</p>
) : null}
{availability.status === 'unavailable' ? (
<p className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800 dark:border-orange-800 dark:bg-orange-950/40 dark:text-orange-300">
{buildUnavailableMessage(availability.nextAvailableAt)}
</p>
) : null}
{availability.status === 'error' ? (
<p className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-300">
{t.availabilityError}
</p>
) : null}
</section>
<section className="space-y-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-blue-950">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.contactDetails}</p>
<div className="grid grid-cols-2 gap-3">
<Field label={t.firstName} required value={fields.firstName} onChange={(value) => update('firstName', value)} />
<Field label={t.lastName} required value={fields.lastName} onChange={(value) => update('lastName', value)} />
</div>
<Field label={t.email} required type="email" value={fields.email} onChange={(value) => update('email', value)} />
<Field label={t.phone} required type="tel" value={fields.phone} onChange={(value) => update('phone', value)} />
<Field label={t.notes} textarea value={fields.notes} onChange={(value) => update('notes', value)} />
</section>
<button
type="submit"
disabled={submitting || availability.status === 'checking'}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-center text-sm font-semibold text-white transition hover:bg-orange-700 disabled:opacity-60 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
>
{submitting ? t.submitting : t.submit}
</button>
</form>
)
}
@@ -0,0 +1,64 @@
import React, { isValidElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
vi.mock('@/components/MarketplaceShell', () => ({
useMarketplacePreferences: () => ({ language: preferenceState.language, theme: 'light' }),
}))
import FooterContentPage from './FooterContentPage'
function collectText(node: unknown): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
if (Array.isArray(node)) return node.flatMap(collectText)
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
return []
}
function collectElements(node: unknown): React.ReactElement[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (Array.isArray(node)) return node.flatMap(collectElements)
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
return [node, ...collectElements(node.props.children)]
}
describe('FooterContentPage', () => {
beforeEach(() => {
preferenceState.language = 'en'
})
it('uses the current marketplace language when no forced language is provided', () => {
preferenceState.language = 'fr'
const page = FooterContentPage({ slug: 'contact-sales' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).toContain('Besoin de plus de détails')
})
it('lets static app policy pages force their own locale', () => {
preferenceState.language = 'en'
const page = FooterContentPage({ slug: 'terms-of-service', forcedLanguage: 'fr' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).not.toContain('Footer information')
})
it('marks Arabic content as right-aligned', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'ar' })
const elements = collectElements(page)
expect(elements.some((element) => String(element.props.className ?? '').includes('text-right'))).toBe(true)
})
it('turns plain URLs embedded in policy paragraphs into safe anchors', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'en' })
const links = collectElements(page).filter((element) => element.type === 'a')
expect(links.some((link) => String(link.props.href).startsWith('https://'))).toBe(true)
expect(links.every((link) => link.props.href === collectText(link).join(''))).toBe(true)
})
})
@@ -0,0 +1,107 @@
'use client'
import React from 'react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
import { type MarketplaceLanguage } from '@/lib/i18n'
const pageMeta = {
en: {
kicker: 'Footer information',
cta: 'Need more details?',
support: 'Contact the RentalDriveGo team through our official channels for business, support, or partnership requests.',
},
fr: {
kicker: 'Informations du pied de page',
cta: 'Besoin de plus de détails ?',
support: "Contactez l'équipe RentalDriveGo via nos canaux officiels pour toute demande commerciale, support ou partenariat.",
},
ar: {
kicker: 'معلومات التذييل',
cta: 'هل تحتاج إلى مزيد من التفاصيل؟',
support: 'تواصل مع فريق RentalDriveGo عبر القنوات الرسمية للاستفسارات التجارية أو الدعم أو الشراكات.',
},
} as const
const urlPattern = /(https?:\/\/[^\s]+)/g
function renderParagraphText(paragraph: string) {
const parts = paragraph.split(urlPattern)
return parts.map((part, index) => {
if (urlPattern.test(part)) {
urlPattern.lastIndex = 0
const match = part.match(/^(https?:\/\/[^\s]+?)([.,!?;:]*)$/)
const href = match?.[1] ?? part
const trailing = match?.[2] ?? ''
return (
<span key={`${part}-${index}`}>
<a
href={href}
className="text-orange-700 underline decoration-orange-300 underline-offset-4 transition hover:text-blue-900 dark:text-orange-300 dark:hover:text-stone-100"
>
{href}
</a>
{trailing}
</span>
)
}
urlPattern.lastIndex = 0
return <span key={`${part}-${index}`}>{part}</span>
})
}
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: MarketplaceLanguage }) {
const { language } = useMarketplacePreferences()
const activeLanguage = forcedLanguage ?? language
const content = getFooterPageContent(activeLanguage, slug)
const meta = pageMeta[activeLanguage]
const isArabic = activeLanguage === 'ar'
return (
<main className="site-page">
<div className="site-section mx-auto max-w-4xl">
<section className="site-panel overflow-hidden p-0">
<div className="site-panel-muted rounded-none border-0 border-b border-stone-100/80 px-6 py-10 sm:px-10 sm:py-14 dark:border-blue-900">
<p className="site-kicker">
{meta.kicker}
</p>
<h1 className="site-title">
{content.title}
</h1>
</div>
<div className={`space-y-6 px-6 py-10 sm:px-10 sm:py-12 ${isArabic ? 'text-right' : 'text-left'}`}>
{content.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
{content.sections?.map((section) => (
<section key={section.heading} className="space-y-4">
<h2 className="text-xl font-semibold text-stone-900 dark:text-stone-100">
{section.heading}
</h2>
{section.paragraphs.map((paragraph) => (
<p key={`${section.heading}-${paragraph}`} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
</section>
))}
<div className="rounded-3xl border border-orange-200 bg-orange-50 px-6 py-5 dark:border-orange-800 dark:bg-orange-950/30">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-orange-800 dark:text-orange-400">
{meta.cta}
</p>
<p className="mt-2 text-base leading-7 text-stone-700 dark:text-stone-300">{meta.support}</p>
</div>
</div>
</section>
</div>
</main>
)
}
@@ -0,0 +1,123 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
type LocaleOption = {
value: 'en' | 'fr' | 'ar'
label: string
flag: string
}
type FooterItem = {
label: string
href?: string
}
export default function MarketplaceFooter({
primaryItems,
secondaryItems,
localeLabel,
rightsLabel,
localeOptions,
currentLocale,
onSelectLanguage,
}: {
primaryItems: FooterItem[]
secondaryItems: FooterItem[]
localeLabel: string
rightsLabel: string
localeOptions: LocaleOption[]
currentLocale: LocaleOption
onSelectLanguage: (language: LocaleOption['value']) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
return (
<footer className="border-t border-stone-200/80 bg-white/72 px-4 py-8 text-stone-600 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-blue-950/72 dark:text-stone-300">
<div className="shell flex flex-col items-center gap-5 text-center">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{primaryItems.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < primaryItems.length - 1 ? (
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
) : null}
</div>
))}
</nav>
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{secondaryItems.map((item) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
</div>
))}
<div ref={localeMenuRef} className="relative px-3">
<button
type="button"
onClick={() => setLocaleMenuOpen((open) => !open)}
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true" className="text-base leading-none">{currentLocale.flag}</span>
<span>{localeLabel}</span>
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span aria-hidden="true" className="text-base leading-none">{option.flag}</span>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {rightsLabel}
</p>
</div>
</footer>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-blue-900 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<Link href={item.href} className={className}>
{item.label}
</Link>
)
}
return <span className={className}>{item.label}</span>
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
buildDashboardSignInHref,
companyInitial,
localeMenuPositionClass,
ownerWorkspaceHref,
} from './MarketplaceHeader'
describe('MarketplaceHeader helpers', () => {
it('builds dashboard sign-in links with language and theme query parameters', () => {
expect(buildDashboardSignInHref('https://app.example.com/dashboard', 'fr', 'dark')).toBe(
'https://app.example.com/dashboard/sign-in?lang=fr&theme=dark'
)
expect(buildDashboardSignInHref('/dashboard', 'ar', 'light')).toBe('/dashboard/sign-in?lang=ar&theme=light')
})
it('keeps the locale dropdown anchored to the readable side for RTL and LTR languages', () => {
expect(localeMenuPositionClass('ar')).toBe('right-0 sm:right-0')
expect(localeMenuPositionClass('en')).toBe('left-0 sm:left-auto sm:right-0')
expect(localeMenuPositionClass('fr')).toBe('left-0 sm:left-auto sm:right-0')
})
it('routes existing companies to their workspace and new owners to sign-up', () => {
expect(ownerWorkspaceHref('https://dashboard.example.com', 'Atlas Cars')).toBe('https://dashboard.example.com')
expect(ownerWorkspaceHref('https://dashboard.example.com', null)).toBe('https://dashboard.example.com/sign-up')
})
it('normalizes company initials for the owner workspace pill', () => {
expect(companyInitial('atlas cars')).toBe('A')
expect(companyInitial(' زاكورة كار ')).toBe('ز')
})
})
@@ -0,0 +1,230 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
export type Theme = 'light' | 'dark'
export type Language = 'en' | 'fr' | 'ar'
type Dictionary = {
marketplace: string
explore: string
signIn: string
ownerSignIn: string
theme: string
light: string
dark: string
}
type LanguageMeta = {
value: Language
flag: string
shortLabel: string
}
export function buildDashboardSignInHref(dashboardUrl: string, language: Language, theme: Theme): string {
const signInParams = new URLSearchParams()
signInParams.set('lang', language)
signInParams.set('theme', theme)
return `${dashboardUrl}/sign-in?${signInParams.toString()}`
}
export function localeMenuPositionClass(language: Language): string {
return language === 'ar' ? 'right-0 sm:right-0' : 'left-0 sm:left-auto sm:right-0'
}
export function ownerWorkspaceHref(dashboardUrl: string, companyName: string | null): string {
return companyName ? dashboardUrl : `${dashboardUrl}/sign-up`
}
export function companyInitial(companyName: string): string {
return companyName.trim().charAt(0).toUpperCase()
}
export default function MarketplaceHeader({
dict,
theme,
setTheme,
companyName,
dashboardUrl,
currentLanguage,
localeOptions,
onSelectLanguage,
}: {
dict: Dictionary
theme: Theme
setTheme: (theme: Theme) => void
companyName: string | null
dashboardUrl: string
currentLanguage: LanguageMeta
localeOptions: LanguageMeta[]
onSelectLanguage: (language: Language) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const themeMenuRef = useRef<HTMLDivElement | null>(null)
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
const themeOptions = [
{ value: 'light' as const, label: dict.light },
{ value: 'dark' as const, label: dict.dark },
]
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
const menuPositionClass = localeMenuPositionClass(currentLanguage.value)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
if (!themeMenuRef.current?.contains(event.target as Node)) {
setThemeMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
function toggleLocaleMenu() {
setThemeMenuOpen(false)
setLocaleMenuOpen((open) => !open)
}
function toggleThemeMenu() {
setLocaleMenuOpen(false)
setThemeMenuOpen((open) => !open)
}
const signInHref = buildDashboardSignInHref(dashboardUrl, currentLanguage.value, theme)
return (
<header className="sticky top-0 z-50 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.32)]">
<div className="shell flex min-h-14 flex-col gap-1.5 py-1.5 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:py-2">
<div className="flex items-center justify-between gap-3">
<Link href="/" className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>RentalDriveGo</span>
</Link>
</div>
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
<nav className="flex items-center gap-0.5 overflow-x-auto">
<Link
href="/"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.marketplace}
</Link>
<Link
href="/explore"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.explore}
</Link>
<a
href={signInHref}
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.signIn}
</a>
{companyName ? (
<a
href={dashboardUrl}
className="ml-1 flex items-center gap-1.5 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:gap-2 sm:px-4 sm:py-2 sm:text-sm"
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-blue-950/20">
{companyInitial(companyName)}
</span>
{companyName}
</a>
) : (
<a
href={ownerWorkspaceHref(dashboardUrl, companyName)}
className="ml-1 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm"
>
{dict.ownerSignIn}
</a>
)}
</nav>
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1">
<div ref={localeMenuRef} className="relative">
<button
type="button"
onClick={toggleLocaleMenu}
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-blue-900/40 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true">{currentLanguage.flag}</span>
<span>{currentLanguage.shortLabel}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span aria-hidden="true">{option.flag}</span>
<span>{option.shortLabel}</span>
</button>
))}
</div>
) : null}
</div>
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
<div ref={themeMenuRef} className="relative">
<button
type="button"
onClick={toggleThemeMenu}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 transition hover:bg-stone-100 dark:hover:bg-blue-900/40 sm:px-2.5 sm:py-1.5"
aria-expanded={themeMenuOpen}
aria-haspopup="menu"
>
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-orange-400 dark:text-white sm:px-3.5 sm:py-1.5 sm:text-xs">
{currentTheme.label}
</span>
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{themeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
{themeOptions
.filter((option) => option.value !== theme)
.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setTheme(option.value)
setThemeMenuOpen(false)
}}
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
</header>
)
}
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './MarketplaceShell'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('MarketplaceShell auth helpers', () => {
it('does not report an employee profile when the browser cache is empty', () => {
vi.stubGlobal('window', {
localStorage: {
getItem: vi.fn(() => null),
},
})
expect(hasCachedEmployeeProfile()).toBe(false)
})
it('detects the cached employee profile marker written by dashboard sign-in', () => {
vi.stubGlobal('window', {
localStorage: {
getItem: vi.fn((key: string) => (key === 'employee_profile' ? '{"id":"emp_1"}' : null)),
},
})
expect(hasCachedEmployeeProfile()).toBe(true)
})
it('clears the cached employee profile marker', () => {
const removeItem = vi.fn()
vi.stubGlobal('window', {
localStorage: {
removeItem,
},
})
clearCachedEmployeeProfile()
expect(removeItem).toHaveBeenCalledWith('employee_profile')
})
})
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { getFooterContent, localeOptions } from './MarketplaceShell'
describe('MarketplaceShell footer content registry', () => {
it('exposes exactly the supported marketplace locales in selector order', () => {
expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr'])
expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true)
})
it('returns English footer links with app privacy pointing at the English mobile policy', () => {
const content = getFooterContent('en')
expect(content.localeLabel).toBe('Global (English)')
expect(content.rightsLabel).toBe('All rights reserved.')
expect(content.primary).toContainEqual({ label: 'Privacy Policy', href: '/app-privacy-en' })
expect(content.secondary).toContainEqual({ label: 'Contact Sales', href: '/footer/contact-sales' })
})
it('returns French labels while preserving canonical footer slugs', () => {
const content = getFooterContent('fr')
expect(content.localeLabel).toBe('Europe (French)')
expect(content.primary).toContainEqual({ label: "Conditions d'utilisation", href: '/footer/terms-of-service' })
expect(content.secondary).toContainEqual({ label: 'Conditions générales', href: '/footer/general-conditions' })
})
it('returns Arabic labels and app privacy href without falling back to English copy', () => {
const content = getFooterContent('ar')
expect(content.localeLabel).toBe('North Africa (Arabic)')
expect(content.rightsLabel).toBe('جميع الحقوق محفوظة.')
expect(content.primary).toContainEqual({ label: 'سياسة الخصوصية', href: '/app-privacy-ar' })
expect(content.primary.map((item) => item.label)).not.toContain('Privacy Policy')
})
})
@@ -0,0 +1,364 @@
'use client'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { appPrivacyHref, footerPageHref } from '@/lib/footerContent'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
type Theme = 'light' | 'dark'
type Dictionary = {
marketplace: string
explore: string
signIn: string
ownerSignIn: string
language: string
theme: string
light: string
dark: string
preferences: string
}
const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
en: {
marketplace: 'Home',
explore: 'Marketplace',
signIn: 'Sign in',
ownerSignIn: 'Create Agency Space',
language: 'Language',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
preferences: 'Marketplace preferences',
},
fr: {
marketplace: 'Accueil',
explore: 'Marketplace',
signIn: 'Connexion',
ownerSignIn: "Créer un espace d'agence",
language: 'Langue',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
preferences: 'Préférences marketplace',
},
ar: {
marketplace: 'الرئيسية',
explore: 'السوق',
signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء مساحة الوكالة',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
preferences: 'تفضيلات السوق',
},
}
const EMPLOYEE_PROFILE_KEY = 'employee_profile'
export const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
]
export function getFooterContent(language: MarketplaceLanguage): {
primary: Array<{ label: string; href?: string }>
secondary: Array<{ label: string; href?: string }>
localeLabel: string
rightsLabel: string
} {
switch (language) {
case 'fr':
return {
primary: [
{ label: 'À propos de nous', href: footerPageHref['about-us'] },
{ label: "Conditions d'utilisation", href: footerPageHref['terms-of-service'] },
{ label: 'Sécurité', href: footerPageHref.security },
{ label: 'Conformité', href: footerPageHref.compliance },
{ label: 'Politique de confidentialité', href: appPrivacyHref.fr },
{ label: 'Politique relative aux cookies', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'Newsletter', href: footerPageHref.newsletter },
{ label: 'Contacter les ventes', href: footerPageHref['contact-sales'] },
{ label: 'Conditions générales', href: footerPageHref['general-conditions'] },
],
localeLabel: 'Europe (French)',
rightsLabel: 'Tous droits réservés.',
}
case 'ar':
return {
primary: [
{ label: 'من نحن', href: footerPageHref['about-us'] },
{ label: 'شروط الاستخدام', href: footerPageHref['terms-of-service'] },
{ label: 'الأمان', href: footerPageHref.security },
{ label: 'الامتثال', href: footerPageHref.compliance },
{ label: 'سياسة الخصوصية', href: appPrivacyHref.ar },
{ label: 'سياسة ملفات تعريف الارتباط', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'النشرة الإخبارية', href: footerPageHref.newsletter },
{ label: 'تواصل مع المبيعات', href: footerPageHref['contact-sales'] },
{ label: 'الشروط العامة', href: footerPageHref['general-conditions'] },
],
localeLabel: 'North Africa (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
}
case 'en':
default:
return {
primary: [
{ label: 'About Us', href: footerPageHref['about-us'] },
{ label: 'Terms of Service', href: footerPageHref['terms-of-service'] },
{ label: 'Security', href: footerPageHref.security },
{ label: 'Compliance', href: footerPageHref.compliance },
{ label: 'Privacy Policy', href: appPrivacyHref.en },
{ label: 'Cookie Policy', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'Newsletter', href: footerPageHref.newsletter },
{ label: 'Contact Sales', href: footerPageHref['contact-sales'] },
{ label: 'General Conditions', href: footerPageHref['general-conditions'] },
],
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
}
}
}
export function hasCachedEmployeeProfile(): boolean {
if (typeof window === 'undefined') return false
try {
return Boolean(window.localStorage.getItem(EMPLOYEE_PROFILE_KEY))
} catch {
return false
}
}
export function clearCachedEmployeeProfile() {
if (typeof window === 'undefined') return
try {
window.localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
} catch {}
}
function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null
const langs = Array.from(navigator.languages ?? [navigator.language])
for (const lang of langs) {
const code = lang.split('-')[0].toLowerCase()
if (code === 'fr' || code === 'ar' || code === 'en') return code
}
return null
}
type PreferencesContextValue = {
language: MarketplaceLanguage
theme: Theme
dict: Dictionary
companyName: string | null
setLanguage: (language: MarketplaceLanguage) => void
setTheme: (theme: Theme) => void
}
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
export function useMarketplacePreferences() {
const context = useContext(PreferencesContext)
if (!context) throw new Error('useMarketplacePreferences must be used within MarketplaceShell')
return context
}
export default function MarketplaceShell({
children,
initialLanguage = 'en',
initialTheme = 'light',
}: {
children: React.ReactNode
initialLanguage?: MarketplaceLanguage
initialTheme?: Theme
}) {
const router = useRouter()
const pathname = usePathname()
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const [language, setLanguageState] = useState<MarketplaceLanguage>(initialLanguage)
const [theme, setThemeState] = useState<Theme>(initialTheme)
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
const [companyName, setCompanyName] = useState<string | null>(null)
function applyLanguage(nextLanguage: MarketplaceLanguage) {
if (nextLanguage === language) return
if (typeof window !== 'undefined') {
document.documentElement.lang = nextLanguage
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
try {
sessionStorage.setItem('marketplace-language', nextLanguage)
} catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['marketplace-language'])
}
setLanguageState(nextLanguage)
}
function applyTheme(nextTheme: Theme) {
if (nextTheme === theme) return
if (typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
document.body.dataset.theme = nextTheme
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['marketplace-theme'])
}
setThemeState(nextTheme)
}
async function syncCompanyBrand() {
if (!hasCachedEmployeeProfile()) {
setCompanyName(null)
return
}
try {
const response = await fetch(`${apiUrl}/companies/me/brand`, {
credentials: 'include',
})
if (!response.ok) {
if (response.status === 401) {
clearCachedEmployeeProfile()
}
setCompanyName(null)
return
}
const json = await response.json()
const name = json?.data?.displayName ?? json?.displayName ?? null
setCompanyName(name)
} catch {
setCompanyName(null)
}
}
function readSessionLanguage(): MarketplaceLanguage | null {
try {
const val = sessionStorage.getItem('marketplace-language')
return isMarketplaceLanguage(val) ? val : null
} catch {
return null
}
}
function syncScopedPreferencesForSession() {
const sessionLang = readSessionLanguage()
if (sessionLang) {
if (sessionLang !== language) setLanguageState(sessionLang)
} else {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguageState(scopedLanguage)
}
}
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if ((scopedTheme === 'light' || scopedTheme === 'dark') && scopedTheme !== theme) {
setThemeState(scopedTheme)
} else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
}
}
useEffect(() => {
const sessionLang = readSessionLanguage()
if (sessionLang) {
setLanguageState(sessionLang)
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['marketplace-language'])
} else {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
if (isMarketplaceLanguage(storedLanguage)) {
setLanguageState(storedLanguage)
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['marketplace-language'])
} else {
const detected = detectBrowserLanguage()
if (isMarketplaceLanguage(detected)) {
setLanguageState(detected)
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['marketplace-language'])
}
}
}
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
if (storedTheme === 'light' || storedTheme === 'dark') {
setThemeState(storedTheme)
}
setHydrated(true)
void syncCompanyBrand()
}, [])
useEffect(() => {
function handleWindowFocus() {
syncScopedPreferencesForSession()
void syncCompanyBrand()
}
function handleAuthMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return
const message = event.data as { type?: string }
if (message.type === 'rentaldrivego:employee-login' || message.type === 'rentaldrivego:employee-logout') {
syncScopedPreferencesForSession()
void syncCompanyBrand()
}
}
window.addEventListener('focus', handleWindowFocus)
window.addEventListener('message', handleAuthMessage)
document.addEventListener('visibilitychange', handleWindowFocus)
return () => {
window.removeEventListener('focus', handleWindowFocus)
window.removeEventListener('message', handleAuthMessage)
document.removeEventListener('visibilitychange', handleWindowFocus)
}
}, [language, theme])
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
if (!hydrated) return
try { sessionStorage.setItem('marketplace-language', language) } catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
if (previousLanguage.current !== language && pathname !== '/sign-in') {
router.refresh()
}
previousLanguage.current = language
}, [hydrated, language, pathname, router])
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark')
document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light'
document.body.dataset.theme = theme
if (hydrated) {
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
}
}, [theme, hydrated])
const value = useMemo(
() => ({ language, theme, dict: dictionaries[language], companyName, setLanguage: applyLanguage, setTheme: applyTheme }),
[language, theme, companyName],
)
return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>
}
+223
View File
@@ -0,0 +1,223 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import {
LayoutDashboard,
User,
Bookmark,
Bell,
LogOut,
ChevronRight,
ChevronLeft,
Car,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { clearRenterSession, loadRenterProfile } from '@/lib/renter'
const NAV_ITEMS = [
{ href: '/renter/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
{ href: '/renter/profile', key: 'profile', icon: User },
{ href: '/renter/saved-companies', key: 'savedCompanies', icon: Bookmark },
{ href: '/renter/notifications', key: 'notifications', icon: Bell },
] as const
const dicts = {
en: {
dashboard: 'Dashboard',
profile: 'Profile',
savedCompanies: 'Saved companies',
notifications: 'Notifications',
signOut: 'Sign out',
renterPortal: 'Renter Portal',
rights: 'All rights reserved.',
},
fr: {
dashboard: 'Tableau de bord',
profile: 'Profil',
savedCompanies: 'Entreprises sauvegardées',
notifications: 'Notifications',
signOut: 'Déconnexion',
renterPortal: 'Espace client',
rights: 'Tous droits réservés.',
},
ar: {
dashboard: 'لوحة التحكم',
profile: 'الملف الشخصي',
savedCompanies: 'الشركات المحفوظة',
notifications: 'الإشعارات',
signOut: 'تسجيل الخروج',
renterPortal: 'بوابة المستأجر',
rights: 'جميع الحقوق محفوظة.',
},
} as const
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'R'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
export default function RenterShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const { language } = useMarketplacePreferences()
const dict = dicts[language]
const isRtl = language === 'ar'
const [open, setOpen] = useState(false)
const [userInitials, setUserInitials] = useState('R')
const [userName, setUserName] = useState<string | null>(null)
const [userEmail, setUserEmail] = useState<string | null>(null)
useEffect(() => {
loadRenterProfile()
.then((profile) => {
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
setUserName(fullName || profile.email.split('@')[0])
setUserEmail(profile.email)
setUserInitials(computeInitials(fullName || profile.email))
})
.catch(() => {})
}, [])
useEffect(() => {
setOpen(false)
}, [pathname])
function signOut() {
clearRenterSession()
router.push('/')
}
const isActive = (item: (typeof NAV_ITEMS)[number]) => {
if ('exact' in item && item.exact) return pathname === item.href
return pathname.startsWith(item.href)
}
const activeNav = NAV_ITEMS.find((item) => isActive(item))
const pageTitle = activeNav ? (dict[activeNav.key as keyof typeof dict] as string) : dict.dashboard
return (
<div className="site-page flex min-h-screen">
{open && (
<div
className="fixed inset-0 z-30 bg-blue-950/50 lg:hidden"
onClick={() => setOpen(false)}
/>
)}
{!open && (
<button
onClick={() => setOpen(true)}
aria-label="Open sidebar"
className={[
'fixed top-1/2 z-50 -translate-y-1/2 flex items-center justify-center w-6 h-14 bg-blue-950 text-white shadow-lg lg:hidden',
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
)}
<aside
className={[
'fixed inset-y-0 z-40 flex w-64 flex-col bg-blue-950 transition-transform duration-300',
isRtl ? 'right-0' : 'left-0',
'lg:translate-x-0',
open ? 'translate-x-0' : isRtl ? 'translate-x-full' : '-translate-x-full',
].join(' ')}
>
<div className="flex items-center gap-3 border-b border-blue-900/50 px-6 py-5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-orange-500 overflow-hidden">
<Car className="h-4 w-4 text-white" />
</div>
<span className="truncate text-sm font-bold tracking-wide text-white">
{dict.renterPortal}
</span>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
href={item.href}
className={[
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
active
? 'bg-orange-500 text-white'
: 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
].join(' ')}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{dict[item.key as keyof typeof dict] as string}
</Link>
)
})}
</nav>
<div className="border-t border-stone-800 px-3 py-4">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-500 text-xs font-semibold text-white">
{userInitials}
</div>
<div className="min-w-0 flex-1">
{userName && (
<p className="truncate text-sm font-medium text-white">{userName}</p>
)}
{userEmail && (
<p className="truncate text-xs text-stone-400">{userEmail}</p>
)}
</div>
</div>
<button
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-blue-900/40 hover:text-white"
>
<LogOut className="h-4 w-4" />
{dict.signOut}
</button>
</div>
<button
onClick={() => setOpen(false)}
aria-label="Close sidebar"
className={[
'absolute top-1/2 z-10 -translate-y-1/2 flex items-center justify-center w-5 h-14 bg-blue-950 text-white shadow-lg lg:hidden',
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
</button>
</aside>
<div
className={[
'flex min-h-screen min-w-0 flex-1 flex-col',
isRtl ? 'lg:mr-64' : 'lg:ml-64',
].join(' ')}
>
<header className="flex h-16 items-center justify-between border-b border-stone-200/80 bg-white/80 px-6 backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/70">
<h1 className="text-lg font-semibold text-stone-900 dark:text-stone-100">{pageTitle}</h1>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500 text-xs font-semibold text-white">
{userInitials}
</div>
</header>
<main className="flex-1 overflow-y-auto py-8">
{children}
</main>
<footer className="border-t border-stone-200/80 bg-white/80 px-6 py-4 backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/70">
<p className="text-center text-xs text-stone-400 dark:text-stone-500">
&copy; {new Date().getFullYear()} RentalDriveGo. {dict.rights}
</p>
</footer>
</div>
</div>
)
}
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { buildFrameUrl, FRAME_HEIGHT, getDefaultFramePath } from './WorkspaceFrame'
afterEach(() => {
vi.unstubAllGlobals()
})
function stubBrowser({ cookie = '', origin = 'https://market.example' }: { cookie?: string; origin?: string } = {}) {
const parsed = new URL(origin)
vi.stubGlobal('window', {
location: { origin, hostname: parsed.hostname, protocol: parsed.protocol, replace: vi.fn() },
})
vi.stubGlobal('document', { cookie })
}
describe('WorkspaceFrame helpers', () => {
it('uses the dashboard sign-in path during server rendering', () => {
vi.stubGlobal('window', undefined)
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
expect(buildFrameUrl('https://dashboard.example/dashboard', '/dashboard/sign-in')).toBe('/dashboard/sign-in')
})
it('keeps users with an employee token on the embedded sign-in path until the dashboard confirms login', () => {
stubBrowser({ cookie: 'other=1; employee_session=token_123' })
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
})
it('keeps unauthenticated visitors on the embedded sign-in path', () => {
stubBrowser({ cookie: 'marketplace-language=fr' })
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
})
it('rewrites a configured app base to the requested embedded path and query', () => {
stubBrowser({ origin: 'https://market.example' })
expect(buildFrameUrl('https://dashboard.example/dashboard', '/dashboard/reset-password?token=abc')).toBe(
'https://market.example/dashboard/reset-password?token=abc',
)
})
it('documents the iframe height contract used by embedded dashboard surfaces', () => {
expect(FRAME_HEIGHT).toBe('calc(100vh - 56px)')
})
})
@@ -0,0 +1,111 @@
'use client'
import { useEffect, useState } from 'react'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { useMarketplacePreferences } from './MarketplaceShell'
export type FrameId = 'sign-in'
const frameConfig: Record<FrameId, { label: string; appUrl: string; path: string }> = {
'sign-in': {
label: 'Sign in',
appUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
path: '/dashboard/sign-in',
},
}
export function getDefaultFramePath(target: FrameId) {
if (typeof window === 'undefined') {
return frameConfig[target].path
}
return frameConfig[target].path
}
export function buildFrameUrl(appUrl: string, path: string) {
if (typeof window === 'undefined') return path
const resolvedAppUrl = resolveBrowserAppUrl(appUrl)
const appBase = new URL(resolvedAppUrl, window.location.origin)
const target = new URL(path, window.location.origin)
appBase.pathname = target.pathname
appBase.search = target.search
appBase.hash = ''
return appBase.toString()
}
export const FRAME_HEIGHT = 'calc(100vh - 56px)'
export default function WorkspaceFrame({ target }: { target: FrameId }) {
const { language, theme } = useMarketplacePreferences()
const [src, setSrc] = useState('')
const [framePath, setFramePath] = useState(() => getDefaultFramePath(target))
const config = frameConfig[target]
const loadingLabel = {
en: 'Loading',
fr: 'Chargement',
ar: 'جارٍ تحميل',
}[language]
useEffect(() => {
setFramePath(getDefaultFramePath(target))
}, [target])
useEffect(() => {
function handleFrameMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return
const message = event.data as { type?: string; path?: string }
if (target === 'sign-in' && message.type === 'rentaldrivego:employee-login' && typeof message.path === 'string') {
window.location.replace(message.path)
return
}
if ((message.type === 'rentaldrivego:embedded-path' || message.type === 'rentaldrivego:employee-login') && typeof message.path === 'string') {
setFramePath(message.path)
}
}
window.addEventListener('message', handleFrameMessage)
return () => window.removeEventListener('message', handleFrameMessage)
}, [target])
useEffect(() => {
if (target === 'sign-in' && src) return
const nextUrl = new URL(buildFrameUrl(config.appUrl, framePath))
nextUrl.searchParams.set('theme', theme)
nextUrl.searchParams.set('lang', language)
nextUrl.searchParams.set('embedded', '1')
setSrc(nextUrl.toString())
}, [config.appUrl, framePath, language, src, target, theme])
const content = src ? (
<iframe
title={config.label}
src={src}
className="block w-full border-0 bg-white dark:bg-blue-950"
style={{ height: FRAME_HEIGHT }}
/>
) : (
<div
className="flex items-center justify-center text-sm text-stone-500 dark:text-stone-300"
style={{ height: FRAME_HEIGHT }}
>
{loadingLabel} {config.label}
</div>
)
if (target === 'sign-in') {
return <main className="min-h-[calc(100vh-56px)]">{content}</main>
}
return (
<main className="site-page">
<div className="site-section py-6 sm:py-8 lg:py-10">
<section className="site-panel overflow-hidden p-0">{content}</section>
</div>
</main>
)
}
@@ -0,0 +1,4 @@
'use client'
// Public marketing footer used by the homepage and other storefront pages.
export { default } from '@/components/MarketplaceFooter'
@@ -0,0 +1,4 @@
'use client'
// Public marketing navbar used by the homepage and other storefront pages.
export { default } from '@/components/MarketplaceHeader'
@@ -0,0 +1,15 @@
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
describe('storefront public page layout', () => {
it('keeps the navbar and footer in reusable component modules', () => {
const layoutPath = fileURLToPath(new URL('./SitePageLayout.tsx', import.meta.url))
const source = fs.readFileSync(layoutPath, 'utf8')
expect(source).toContain("import SiteNavbar from './SiteNavbar'")
expect(source).toContain("import SiteFooter from './SiteFooter'")
expect(source).toContain('<SiteNavbar')
expect(source).toContain('<SiteFooter')
})
})
@@ -0,0 +1,57 @@
'use client'
import type { ReactNode } from 'react'
import SiteFooter from './SiteFooter'
import SiteNavbar from './SiteNavbar'
import {
getFooterContent,
localeOptions,
useMarketplacePreferences,
} from '@/components/MarketplaceShell'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
export default function SitePageLayout({ children }: { children: ReactNode }) {
const { language, theme, dict, companyName, setLanguage, setTheme } = useMarketplacePreferences()
const dashboardUrl = resolveBrowserAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
const footerContent = getFooterContent(language)
const available = localeOptions.filter((option) => option.value !== language)
const current = localeOptions.find((option) => option.value === language) ?? localeOptions[0]
return (
<div className="site-page flex min-h-screen flex-col" data-site-page-layout="true">
<SiteNavbar
dict={dict}
theme={theme}
setTheme={setTheme}
companyName={companyName}
dashboardUrl={dashboardUrl}
currentLanguage={{
value: current.value,
flag: current.flag,
shortLabel: current.value.toUpperCase(),
}}
localeOptions={available.map((option) => ({
value: option.value,
flag: option.flag,
shortLabel: option.value.toUpperCase(),
}))}
onSelectLanguage={setLanguage}
/>
<div className="flex flex-1 flex-col">
<div className="flex-1">{children}</div>
<SiteFooter
primaryItems={footerContent.primary}
secondaryItems={footerContent.secondary}
localeLabel={footerContent.localeLabel}
rightsLabel={footerContent.rightsLabel}
localeOptions={available}
currentLocale={current}
onSelectLanguage={setLanguage}
/>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
'use client'
export { default as SiteFooter } from './SiteFooter'
export { default as SiteNavbar } from './SiteNavbar'
export { default as SitePageLayout } from './SitePageLayout'