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
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 l’essentiel maintenant. Les documents conducteur peuvent être ajoutés plus tard si l’agence 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. L’agence 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 d’expiration',
|
||||
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 l’agence ? 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 l’agence.',
|
||||
unavailableWithDate: 'Indisponible pour ces dates. Prochaine disponibilité probable : {date}.',
|
||||
unavailableWithoutDate: 'Indisponible pour ces dates. Choisissez d’autres 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: 'L’agence reçoit d’abord vos dates et vos coordonnées.',
|
||||
nextStepAvailability: 'Confirmation de disponibilité',
|
||||
nextStepAvailabilityBody: 'L’agence vérifie la demande et l’accepte si la voiture est toujours libre.',
|
||||
nextStepDocuments: 'Documents conducteur si nécessaires',
|
||||
nextStepDocumentsBody: 'Le permis et les pièces d’identité peuvent être demandés après l’acceptation 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">
|
||||
© {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>
|
||||
}
|
||||
@@ -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">
|
||||
© {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'
|
||||
Reference in New Issue
Block a user