Route storefront marketplace under /storefront
Build & Deploy / Build & Push Docker Image (push) Successful in 2m57s
Test / Type Check (all packages) (push) Successful in 57s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m4s

Replace the legacy /explore marketplace route with /storefront across the
storefront app and production routing.

- move the marketplace index page to the storefront root route
- move company and vehicle detail pages from /explore/* to /* internally
- update marketplace links, renter dashboard links, saved company links, and
  header navigation to target /storefront
- remove remaining explore route references and wording from storefront code
- configure production Traefik to route /storefront and strip the prefix before
  forwarding to the storefront service
- set the storefront production asset prefix to /storefront so Next chunks load
  from /storefront/_next instead of the shared root /_next path
- redirect locale entry paths such as /storefront/fr back to /storefront while
  persisting the selected language cookie
- update middleware tests for the locale redirect and adjusted redirect mock

Verification:
- npm run test --workspace @rentaldrivego/storefront
- npm run build --workspace @rentaldrivego/storefront
This commit is contained in:
root
2026-07-02 14:47:50 -04:00
parent b45f84d62b
commit 6913c298ad
15 changed files with 436 additions and 391 deletions
+7 -1
View File
@@ -8,10 +8,16 @@ const dashboardAssetSource = normalizeAssetPrefix(
process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined), process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined),
'/dashboard', '/dashboard',
) )
const STOREFRONT_ASSET_PREFIX = '/storefront'
const storefrontAssetPrefix = normalizeAssetPrefix(
process.env.STOREFRONT_ASSET_PREFIX ?? (process.env.NODE_ENV === 'production' ? STOREFRONT_ASSET_PREFIX : undefined),
STOREFRONT_ASSET_PREFIX,
)
const securityHeaders = buildSecurityHeaders({ const securityHeaders = buildSecurityHeaders({
assetSources: [dashboardAssetSource, process.env.ADMIN_ASSET_PREFIX], assetSources: [storefrontAssetPrefix, dashboardAssetSource, process.env.ADMIN_ASSET_PREFIX],
}) })
const nextConfig = { const nextConfig = {
...(storefrontAssetPrefix ? { assetPrefix: storefrontAssetPrefix } : {}),
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
@@ -27,7 +27,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
unavailable: 'Storefront unavailable', unavailable: 'Storefront unavailable',
unavailableTitle: 'Company details are temporarily unavailable.', unavailableTitle: 'Company details are temporarily unavailable.',
unavailableBody: 'The storefront API is running, but the database is not reachable in this local environment.', unavailableBody: 'The storefront API is running, but the database is not reachable in this local environment.',
backToExplore: 'Back to explore', backToStorefront: 'Back to storefront',
partner: 'Storefront partner', partner: 'Storefront partner',
vehicles: 'vehicles', vehicles: 'vehicles',
offers: 'active offers', offers: 'active offers',
@@ -45,7 +45,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
unavailable: 'Storefront indisponible', unavailable: 'Storefront indisponible',
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.", unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
unavailableBody: "L'API storefront fonctionne, mais la base de données n'est pas accessible dans cet environnement local.", unavailableBody: "L'API storefront fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
backToExplore: "Retour à l'exploration", backToStorefront: "Retour à la storefront",
partner: 'Partenaire storefront', partner: 'Partenaire storefront',
vehicles: 'véhicules', vehicles: 'véhicules',
offers: 'offres actives', offers: 'offres actives',
@@ -63,7 +63,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
unavailable: 'السوق غير متاح', unavailable: 'السوق غير متاح',
unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.', unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.',
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات غير متاحة في هذه البيئة المحلية.', unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات غير متاحة في هذه البيئة المحلية.',
backToExplore: 'العودة إلى الاستكشاف', backToStorefront: 'العودة إلى السوق',
partner: 'شريك في السوق', partner: 'شريك في السوق',
vehicles: 'مركبات', vehicles: 'مركبات',
offers: 'عروض نشطة', offers: 'عروض نشطة',
@@ -89,7 +89,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
<h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1> <h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1>
<p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p> <p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p>
<div className="mt-6"> <div className="mt-6">
<Link href="/explore" className="rounded-full bg-blue-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.backToExplore}</Link> <Link href="/storefront" className="rounded-full bg-blue-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.backToStorefront}</Link>
</div> </div>
</section> </section>
</div> </div>
@@ -146,7 +146,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
)} )}
<div className="mt-4 flex items-end justify-between gap-3"> <div className="mt-4 flex items-end justify-between gap-3">
<p className="min-w-0 truncate text-xl font-black text-stone-900 dark:text-stone-100">{formatCurrency(vehicle.dailyRate, 'MAD', language)}</p> <p className="min-w-0 truncate text-xl font-black text-stone-900 dark:text-stone-100">{formatCurrency(vehicle.dailyRate, 'MAD', language)}</p>
<Link href={`/explore/${params.slug}/vehicles/${vehicle.id}`} className="shrink-0 rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.viewVehicle}</Link> <Link href={`/storefront/${params.slug}/vehicles/${vehicle.id}`} className="shrink-0 rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.viewVehicle}</Link>
</div> </div>
</div> </div>
</article> </article>
@@ -40,7 +40,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
unavailableTitle: 'Vehicle details are temporarily unavailable.', unavailableTitle: 'Vehicle details are temporarily unavailable.',
unavailableBody: 'The storefront frontend is running, but the backing database is not reachable right now.', unavailableBody: 'The storefront frontend is running, but the backing database is not reachable right now.',
backToFleet: 'Back to fleet', backToFleet: 'Back to fleet',
backToExplore: 'Back to explore', backToStorefront: 'Back to storefront',
noPhotos: 'No photos available.', noPhotos: 'No photos available.',
rentalCompany: 'Rental company', rentalCompany: 'Rental company',
perDay: '/ day', perDay: '/ day',
@@ -63,7 +63,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.', unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
unavailableBody: "Le frontend storefront fonctionne, mais la base de données n'est pas accessible pour le moment.", unavailableBody: "Le frontend storefront fonctionne, mais la base de données n'est pas accessible pour le moment.",
backToFleet: 'Retour à la flotte', backToFleet: 'Retour à la flotte',
backToExplore: 'Retour à lexploration', backToStorefront: 'Retour à la storefront',
noPhotos: 'Aucune photo disponible.', noPhotos: 'Aucune photo disponible.',
rentalCompany: 'Société de location', rentalCompany: 'Société de location',
perDay: '/ jour', perDay: '/ jour',
@@ -86,7 +86,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.', unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات في الخلفية غير متاحة حالياً.', unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات في الخلفية غير متاحة حالياً.',
backToFleet: 'العودة إلى الأسطول', backToFleet: 'العودة إلى الأسطول',
backToExplore: 'العودة إلى الاستكشاف', backToStorefront: 'العودة إلى السوق',
noPhotos: 'لا توجد صور متاحة.', noPhotos: 'لا توجد صور متاحة.',
rentalCompany: 'شركة تأجير', rentalCompany: 'شركة تأجير',
perDay: '/ يوم', perDay: '/ يوم',
@@ -117,8 +117,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
<h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1> <h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1>
<p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p> <p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p>
<div className="mt-6 flex gap-3"> <div className="mt-6 flex gap-3">
<Link href={`/explore/${params.slug}`} className="rounded-full bg-blue-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.backToFleet}</Link> <Link href={`/storefront/${params.slug}`} className="rounded-full bg-blue-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.backToFleet}</Link>
<Link href="/explore" className="rounded-full border border-stone-200 px-5 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-stone-400 dark:border-blue-800 dark:text-stone-300 dark:hover:border-stone-500">{dict.backToExplore}</Link> <Link href="/storefront" className="rounded-full border border-stone-200 px-5 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-stone-400 dark:border-blue-800 dark:text-stone-300 dark:hover:border-stone-500">{dict.backToStorefront}</Link>
</div> </div>
</section> </section>
</div> </div>
@@ -138,9 +138,9 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-stone-500 dark:text-stone-400"> <nav className="flex items-center gap-2 text-sm text-stone-500 dark:text-stone-400">
<Link href="/explore" className="hover:text-stone-900 dark:hover:text-stone-100 transition">{dict.backToExplore}</Link> <Link href="/storefront" className="hover:text-stone-900 dark:hover:text-stone-100 transition">{dict.backToStorefront}</Link>
<span>/</span> <span>/</span>
<Link href={`/explore/${params.slug}`} className="hover:text-stone-900 dark:hover:text-stone-100 transition">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</Link> <Link href={`/storefront/${params.slug}`} className="hover:text-stone-900 dark:hover:text-stone-100 transition">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</Link>
<span>/</span> <span>/</span>
<span className="text-stone-900 dark:text-stone-100 font-medium">{vehicle.make} {vehicle.model}</span> <span className="text-stone-900 dark:text-stone-100 font-medium">{vehicle.make} {vehicle.model}</span>
</nav> </nav>
@@ -269,7 +269,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
)} )}
<Link <Link
href={`/explore/${params.slug}`} href={`/storefront/${params.slug}`}
className="block rounded-full border border-stone-300 dark:border-blue-800 px-6 py-3 text-center text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-blue-900/40 transition" className="block rounded-full border border-stone-300 dark:border-blue-800 px-6 py-3 text-center text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-blue-900/40 transition"
> >
{dict.backToFleet} {dict.backToFleet}
@@ -4,7 +4,7 @@ import { CalendarDays, Clock3, Info, MapPin, TicketPercent } from 'lucide-react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
type ExploreSearchFormDict = { type StorefrontSearchFormDict = {
searchTitle: string searchTitle: string
pickupLocation: string pickupLocation: string
dropoffLocation: string dropoffLocation: string
@@ -29,7 +29,7 @@ function toUtcDateTime(date: string, time: string) {
const inputBase = 'w-full border-0 bg-white dark:bg-blue-900/30 px-4 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none placeholder:text-stone-400 dark:placeholder:text-stone-500' const inputBase = 'w-full border-0 bg-white dark:bg-blue-900/30 px-4 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none placeholder:text-stone-400 dark:placeholder:text-stone-500'
const selectBase = 'w-full border-0 bg-white dark:bg-blue-900/30 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none' const selectBase = 'w-full border-0 bg-white dark:bg-blue-900/30 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none'
export default function ExploreSearchForm({ export default function StorefrontSearchForm({
pickupLocation, pickupLocation,
dropoffLocation, dropoffLocation,
dropoffMode, dropoffMode,
@@ -52,7 +52,7 @@ export default function ExploreSearchForm({
promoCode: string promoCode: string
driverAge: string driverAge: string
cities: string[] cities: string[]
dict: ExploreSearchFormDict dict: StorefrontSearchFormDict
}) { }) {
const router = useRouter() const router = useRouter()
const cityOptions = Array.isArray(cities) ? cities : [] const cityOptions = Array.isArray(cities) ? cities : []
@@ -104,7 +104,7 @@ export default function ExploreSearchForm({
if (startDateTime) query.set('startDate', startDateTime) if (startDateTime) query.set('startDate', startDateTime)
if (endDateTime) query.set('endDate', endDateTime) if (endDateTime) query.set('endDate', endDateTime)
const target = query.toString() ? `/explore?${query.toString()}` : '/explore' const target = query.toString() ? `/storefront?${query.toString()}` : '/storefront'
router.push(target) router.push(target)
} }
@@ -56,7 +56,7 @@ function getStatusCopy(vehicle: Vehicle, dict: Dict) {
return dict.available return dict.available
} }
export default function ExploreVehicleGrid({ vehicles, dict, language }: { vehicles: Vehicle[]; dict: Dict; language: Locale }) { export default function StorefrontVehicleGrid({ vehicles, dict, language }: { vehicles: Vehicle[]; dict: Dict; language: Locale }) {
return ( return (
<section> <section>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -94,7 +94,7 @@ export default function ExploreVehicleGrid({ vehicles, dict, language }: { vehic
<p className="truncate text-xl font-black text-stone-900 dark:text-stone-100">{formatCents(vehicle.dailyRate, language)}</p> <p className="truncate text-xl font-black text-stone-900 dark:text-stone-100">{formatCents(vehicle.dailyRate, language)}</p>
</div> </div>
<Link <Link
href={`/explore/${vehicle.company.slug}/vehicles/${vehicle.id}`} href={`/storefront/${vehicle.company.slug}/vehicles/${vehicle.id}`}
className="shrink-0 rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400" className="shrink-0 rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
> >
{dict.viewVehicle} {dict.viewVehicle}
@@ -1,329 +0,0 @@
import Link from 'next/link'
import { storefrontFetchOrDefault } from '@/lib/api'
import { getStorefrontLanguage } from '@/lib/i18n.server'
import ExploreSearchForm from './ExploreSearchForm'
import ExploreVehicleGrid from './ExploreVehicleGrid'
interface Vehicle {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
photos: string[]
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
availability?: boolean | null
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt?: string | null
company: {
slug: string
brand: {
displayName: string
logoUrl: string | null
subdomain: string
marketplaceRating: number | null
} | null
}
}
interface Offer {
id: string
title: string
discountValue: number
validUntil: string
company: {
brand: {
displayName: string
subdomain: string
} | null
}
}
interface CompanyCard {
id: string
brand: {
displayName: string
subdomain: string
publicCity: string | null
marketplaceRating: number | null
} | null
_count: {
vehicles: number
}
}
const CATEGORY_VALUES = [
'ECONOMY',
'COMPACT',
'MIDSIZE',
'FULLSIZE',
'SUV',
'LUXURY',
'VAN',
'TRUCK',
] as const
export default async function ExplorePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
const resolvedSearchParams = await searchParams
const language = await getStorefrontLanguage()
const dict = {
en: {
kicker: 'Discovery only',
title: 'Find your next rental from trusted local companies.',
body: 'Browse, filter, and reserve — the company confirms and contacts you directly.',
searchTitle: 'Find a vehicle',
pickupLocation: 'Pick-up location',
dropoffLocation: 'Drop-off location',
sameDropoff: 'Same drop-off',
differentDropoff: 'Different drop-off',
sameAsPickup: 'Return to the same location',
pickupDate: 'Start date',
returnDate: 'Return date',
hour: 'Hour',
promoCode: 'I have a promo code',
myAge: 'My age',
ageDescription: 'Driver age can affect availability, deposit amount, and final pricing.',
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Search',
currentDeals: 'Current deals',
featuredOffers: 'Featured storefront offers',
company: 'Company',
validUntil: 'Valid until',
offersUnavailable: 'Storefront offers are unavailable right now.',
availableVehicles: 'Available vehicles',
listings: 'listings',
rentalCompany: 'Rental company',
checkAvailability: 'Check availability',
available: 'Available',
reserved: 'Reserved',
maintenance: 'In maintenance',
unavailableStatus: 'Unavailable',
from: 'From',
viewVehicle: 'View vehicle',
availableFrom: 'Available from',
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
browseByCategory: 'Browse by category',
browseByCompany: 'Browse by company',
storefrontPartners: 'Storefront partners',
storefrontPartner: 'Storefront partner',
rating: 'Rating',
new: 'New',
publishedVehicles: 'published vehicles',
viewFleet: 'View fleet',
categories: ['Economy', 'Compact', 'Midsize', 'Full-size', 'SUV', 'Luxury', 'Van', 'Truck'],
},
fr: {
kicker: 'Découverte uniquement',
title: "Trouvez votre prochaine location auprès d'entreprises locales fiables.",
body: "Parcourez, filtrez et réservez — l'entreprise confirme et vous contacte directement.",
searchTitle: 'Trouver un véhicule',
pickupLocation: 'Lieu de départ',
dropoffLocation: "Lieu de retour",
sameDropoff: 'Même retour',
differentDropoff: 'Retour différent',
sameAsPickup: 'Retour au même lieu',
pickupDate: 'Date de départ',
returnDate: 'Date de retour',
hour: 'Heure',
promoCode: "J'ai un code promo",
myAge: 'Mon âge',
ageDescription: "L'âge du conducteur peut influencer la disponibilité, le dépôt de garantie et le prix final.",
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Rechercher',
currentDeals: 'Offres du moment',
featuredOffers: 'Offres storefront mises en avant',
company: 'Entreprise',
validUntil: "Valable jusqu'au",
offersUnavailable: 'Les offres storefront sont indisponibles pour le moment.',
availableVehicles: 'Véhicules disponibles',
listings: 'annonces',
rentalCompany: 'Société de location',
checkAvailability: 'Vérifier la disponibilité',
available: 'Disponible',
reserved: 'Réservé',
maintenance: 'En maintenance',
unavailableStatus: 'Indisponible',
from: 'À partir de',
viewVehicle: 'Voir le véhicule',
availableFrom: 'Disponible à partir du',
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
browseByCategory: 'Parcourir par catégorie',
browseByCompany: 'Parcourir par entreprise',
storefrontPartners: 'Partenaires storefront',
storefrontPartner: 'Partenaire storefront',
rating: 'Note',
new: 'Nouveau',
publishedVehicles: 'véhicules publiés',
viewFleet: 'Voir la flotte',
categories: ['Économie', 'Compacte', 'Intermédiaire', 'Grande berline', 'SUV', 'Luxe', 'Van', 'Camion'],
},
ar: {
kicker: 'للاستكشاف فقط',
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
searchTitle: 'احجز سيارة',
pickupLocation: 'موقع الاستلام',
dropoffLocation: 'موقع الإرجاع',
sameDropoff: 'نفس موقع الإرجاع',
differentDropoff: 'موقع إرجاع مختلف',
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
pickupDate: 'تاريخ البدء',
returnDate: 'تاريخ الإرجاع',
hour: 'الساعة',
promoCode: 'لدي رمز ترويجي',
myAge: 'عمري',
ageDescription: 'قد يؤثر عمر السائق على التوفر ومبلغ التأمين والسعر النهائي.',
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'بحث',
currentDeals: 'العروض الحالية',
featuredOffers: 'عروض السوق المميزة',
company: 'شركة',
validUntil: 'صالح حتى',
offersUnavailable: 'عروض السوق غير متاحة حالياً.',
availableVehicles: 'السيارات المتاحة',
listings: 'إعلانات',
rentalCompany: 'شركة تأجير',
checkAvailability: 'تحقق من التوفر',
available: 'متاح',
reserved: 'محجوز',
maintenance: 'قيد الصيانة',
unavailableStatus: 'غير متاح',
from: 'ابتداءً من',
viewVehicle: 'عرض السيارة',
availableFrom: 'متاح ابتداءً من',
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
browseByCategory: 'تصفح حسب الفئة',
browseByCompany: 'تصفح حسب الشركة',
storefrontPartners: 'شركاء السوق',
storefrontPartner: 'شريك في السوق',
rating: 'التقييم',
new: 'جديد',
publishedVehicles: 'سيارات منشورة',
viewFleet: 'عرض الأسطول',
categories: ['اقتصادية', 'مدمجة', 'متوسطة', 'كبيرة', 'SUV', 'فاخرة', 'فان', 'شاحنة'],
},
}[language]
const city = typeof resolvedSearchParams?.city === 'string' ? resolvedSearchParams.city : ''
const pickupLocation = typeof resolvedSearchParams?.pickupLocation === 'string' ? resolvedSearchParams.pickupLocation : city
const dropoffLocation = typeof resolvedSearchParams?.dropoffLocation === 'string' ? resolvedSearchParams.dropoffLocation : ''
const dropoffMode = resolvedSearchParams?.dropoffMode === 'different' ? 'different' : 'same'
const pickupDate = typeof resolvedSearchParams?.pickupDate === 'string' ? resolvedSearchParams.pickupDate : ''
const pickupTime = typeof resolvedSearchParams?.pickupTime === 'string' ? resolvedSearchParams.pickupTime : '10:00'
const returnDate = typeof resolvedSearchParams?.returnDate === 'string' ? resolvedSearchParams.returnDate : ''
const returnTime = typeof resolvedSearchParams?.returnTime === 'string' ? resolvedSearchParams.returnTime : '10:00'
const promoCode = typeof resolvedSearchParams?.promoCode === 'string' ? resolvedSearchParams.promoCode : ''
const driverAge = typeof resolvedSearchParams?.driverAge === 'string' ? resolvedSearchParams.driverAge : '23+'
const category = typeof resolvedSearchParams?.category === 'string' ? resolvedSearchParams.category : ''
const transmission = typeof resolvedSearchParams?.transmission === 'string' ? resolvedSearchParams.transmission : ''
const make = typeof resolvedSearchParams?.make === 'string' ? resolvedSearchParams.make : ''
const model = typeof resolvedSearchParams?.model === 'string' ? resolvedSearchParams.model : ''
const startDate = typeof resolvedSearchParams?.startDate === 'string' ? resolvedSearchParams.startDate : ''
const endDate = typeof resolvedSearchParams?.endDate === 'string' ? resolvedSearchParams.endDate : ''
const query = new URLSearchParams()
if (pickupLocation) query.set('city', pickupLocation)
if (pickupLocation) query.set('pickupLocation', pickupLocation)
query.set('dropoffMode', dropoffMode)
if (dropoffMode === 'different' && dropoffLocation) query.set('dropoffLocation', dropoffLocation)
if (startDate) query.set('startDate', startDate)
if (endDate) query.set('endDate', endDate)
if (category) query.set('category', category)
if (transmission) query.set('transmission', transmission)
if (make) query.set('make', make)
if (model) query.set('model', model)
query.set('pageSize', '24')
const [offers, vehicles, companies] = await Promise.all([
storefrontFetchOrDefault<Offer[]>('/storefront/offers', []),
storefrontFetchOrDefault<Vehicle[]>(`/storefront/search?${query.toString()}`, []),
storefrontFetchOrDefault<CompanyCard[]>('/storefront/companies?pageSize=8', []),
])
const cities = Array.from(
new Set(
companies
.map((company) => company.brand?.publicCity?.trim())
.filter((city): city is string => Boolean(city))
)
).sort((left, right) => left.localeCompare(right))
return (
<main className="site-page">
<div className="site-section space-y-10">
<section className="site-panel-contrast bg-[linear-gradient(135deg,#0f2460,#1a3a8f)] dark:bg-[linear-gradient(135deg,#0a1735,#112d6e)] px-8 py-12">
<p className="site-kicker text-orange-300 dark:text-orange-300">{dict.kicker}</p>
<h1 className="mt-4 text-4xl font-black tracking-tight">{dict.title}</h1>
<p className="mt-4 max-w-2xl text-blue-100 dark:text-blue-100">{dict.body}</p>
<ExploreSearchForm
pickupLocation={pickupLocation}
dropoffLocation={dropoffLocation}
dropoffMode={dropoffMode}
pickupDate={pickupDate}
pickupTime={pickupTime}
returnDate={returnDate}
returnTime={returnTime}
promoCode={promoCode}
driverAge={driverAge}
cities={cities}
dict={dict}
/>
</section>
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.currentDeals}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.featuredOffers}</p>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{offers.map((offer) => (
<Link key={offer.id} href={`/explore/${offer.company.brand?.subdomain ?? ''}#offers`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
<p className="truncate text-xs uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{offer.company.brand?.displayName ?? dict.company}</p>
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{offer.title}</h3>
<p className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{offer.discountValue}%</p>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
</Link>
))}
{offers.length === 0 && <div className="card p-6 text-sm text-stone-500 dark:text-stone-400 md:col-span-2 xl:col-span-4">{dict.offersUnavailable}</div>}
</div>
</section>
<ExploreVehicleGrid vehicles={vehicles} dict={dict} language={language} />
<section>
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCategory}</h2>
<div className="mt-4 flex flex-wrap gap-3">
{dict.categories.map((categoryName, index) => (
<Link key={CATEGORY_VALUES[index] ?? categoryName} href={`/explore?category=${CATEGORY_VALUES[index]}`} className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-stone-400 dark:border-blue-800 dark:bg-blue-950 dark:text-stone-300 dark:hover:border-stone-500">
{categoryName}
</Link>
))}
</div>
</section>
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCompany}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.storefrontPartners}</p>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{companies.map((company) => (
<Link key={company.id} href={`/explore/${company.brand?.subdomain ?? ''}`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
<p className="truncate text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{company.brand?.publicCity ?? dict.storefrontPartner}</p>
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{company.brand?.displayName ?? dict.rentalCompany}</h3>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</p>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{company._count.vehicles} {dict.publishedVehicles}</p>
<span className="mt-4 inline-flex self-start rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white dark:bg-orange-500 dark:text-white">{dict.viewFleet}</span>
</Link>
))}
</div>
</section>
</div>
</main>
)
}
+327 -3
View File
@@ -1,5 +1,329 @@
import { redirect } from 'next/navigation' import Link from 'next/link'
import { storefrontFetchOrDefault } from '@/lib/api'
import { getStorefrontLanguage } from '@/lib/i18n.server'
import StorefrontSearchForm from './_components/StorefrontSearchForm'
import StorefrontVehicleGrid from './_components/StorefrontVehicleGrid'
export default function StorefrontHomePage() { interface Vehicle {
redirect('/explore') id: string
make: string
model: string
year: number
category: string
dailyRate: number
photos: string[]
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
availability?: boolean | null
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt?: string | null
company: {
slug: string
brand: {
displayName: string
logoUrl: string | null
subdomain: string
marketplaceRating: number | null
} | null
}
}
interface Offer {
id: string
title: string
discountValue: number
validUntil: string
company: {
brand: {
displayName: string
subdomain: string
} | null
}
}
interface CompanyCard {
id: string
brand: {
displayName: string
subdomain: string
publicCity: string | null
marketplaceRating: number | null
} | null
_count: {
vehicles: number
}
}
const CATEGORY_VALUES = [
'ECONOMY',
'COMPACT',
'MIDSIZE',
'FULLSIZE',
'SUV',
'LUXURY',
'VAN',
'TRUCK',
] as const
export default async function StorefrontPage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
const resolvedSearchParams = await searchParams
const language = await getStorefrontLanguage()
const dict = {
en: {
kicker: 'Discovery only',
title: 'Find your next rental from trusted local companies.',
body: 'Browse, filter, and reserve — the company confirms and contacts you directly.',
searchTitle: 'Find a vehicle',
pickupLocation: 'Pick-up location',
dropoffLocation: 'Drop-off location',
sameDropoff: 'Same drop-off',
differentDropoff: 'Different drop-off',
sameAsPickup: 'Return to the same location',
pickupDate: 'Start date',
returnDate: 'Return date',
hour: 'Hour',
promoCode: 'I have a promo code',
myAge: 'My age',
ageDescription: 'Driver age can affect availability, deposit amount, and final pricing.',
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Search',
currentDeals: 'Current deals',
featuredOffers: 'Featured storefront offers',
company: 'Company',
validUntil: 'Valid until',
offersUnavailable: 'Storefront offers are unavailable right now.',
availableVehicles: 'Available vehicles',
listings: 'listings',
rentalCompany: 'Rental company',
checkAvailability: 'Check availability',
available: 'Available',
reserved: 'Reserved',
maintenance: 'In maintenance',
unavailableStatus: 'Unavailable',
from: 'From',
viewVehicle: 'View vehicle',
availableFrom: 'Available from',
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
browseByCategory: 'Browse by category',
browseByCompany: 'Browse by company',
storefrontPartners: 'Storefront partners',
storefrontPartner: 'Storefront partner',
rating: 'Rating',
new: 'New',
publishedVehicles: 'published vehicles',
viewFleet: 'View fleet',
categories: ['Economy', 'Compact', 'Midsize', 'Full-size', 'SUV', 'Luxury', 'Van', 'Truck'],
},
fr: {
kicker: 'Découverte uniquement',
title: "Trouvez votre prochaine location auprès d'entreprises locales fiables.",
body: "Parcourez, filtrez et réservez — l'entreprise confirme et vous contacte directement.",
searchTitle: 'Trouver un véhicule',
pickupLocation: 'Lieu de départ',
dropoffLocation: "Lieu de retour",
sameDropoff: 'Même retour',
differentDropoff: 'Retour différent',
sameAsPickup: 'Retour au même lieu',
pickupDate: 'Date de départ',
returnDate: 'Date de retour',
hour: 'Heure',
promoCode: "J'ai un code promo",
myAge: 'Mon âge',
ageDescription: "L'âge du conducteur peut influencer la disponibilité, le dépôt de garantie et le prix final.",
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Rechercher',
currentDeals: 'Offres du moment',
featuredOffers: 'Offres storefront mises en avant',
company: 'Entreprise',
validUntil: "Valable jusqu'au",
offersUnavailable: 'Les offres storefront sont indisponibles pour le moment.',
availableVehicles: 'Véhicules disponibles',
listings: 'annonces',
rentalCompany: 'Société de location',
checkAvailability: 'Vérifier la disponibilité',
available: 'Disponible',
reserved: 'Réservé',
maintenance: 'En maintenance',
unavailableStatus: 'Indisponible',
from: 'À partir de',
viewVehicle: 'Voir le véhicule',
availableFrom: 'Disponible à partir du',
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
browseByCategory: 'Parcourir par catégorie',
browseByCompany: 'Parcourir par entreprise',
storefrontPartners: 'Partenaires storefront',
storefrontPartner: 'Partenaire storefront',
rating: 'Note',
new: 'Nouveau',
publishedVehicles: 'véhicules publiés',
viewFleet: 'Voir la flotte',
categories: ['Économie', 'Compacte', 'Intermédiaire', 'Grande berline', 'SUV', 'Luxe', 'Van', 'Camion'],
},
ar: {
kicker: 'للاستكشاف فقط',
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
searchTitle: 'احجز سيارة',
pickupLocation: 'موقع الاستلام',
dropoffLocation: 'موقع الإرجاع',
sameDropoff: 'نفس موقع الإرجاع',
differentDropoff: 'موقع إرجاع مختلف',
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
pickupDate: 'تاريخ البدء',
returnDate: 'تاريخ الإرجاع',
hour: 'الساعة',
promoCode: 'لدي رمز ترويجي',
myAge: 'عمري',
ageDescription: 'قد يؤثر عمر السائق على التوفر ومبلغ التأمين والسعر النهائي.',
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'بحث',
currentDeals: 'العروض الحالية',
featuredOffers: 'عروض السوق المميزة',
company: 'شركة',
validUntil: 'صالح حتى',
offersUnavailable: 'عروض السوق غير متاحة حالياً.',
availableVehicles: 'السيارات المتاحة',
listings: 'إعلانات',
rentalCompany: 'شركة تأجير',
checkAvailability: 'تحقق من التوفر',
available: 'متاح',
reserved: 'محجوز',
maintenance: 'قيد الصيانة',
unavailableStatus: 'غير متاح',
from: 'ابتداءً من',
viewVehicle: 'عرض السيارة',
availableFrom: 'متاح ابتداءً من',
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
browseByCategory: 'تصفح حسب الفئة',
browseByCompany: 'تصفح حسب الشركة',
storefrontPartners: 'شركاء السوق',
storefrontPartner: 'شريك في السوق',
rating: 'التقييم',
new: 'جديد',
publishedVehicles: 'سيارات منشورة',
viewFleet: 'عرض الأسطول',
categories: ['اقتصادية', 'مدمجة', 'متوسطة', 'كبيرة', 'SUV', 'فاخرة', 'فان', 'شاحنة'],
},
}[language]
const city = typeof resolvedSearchParams?.city === 'string' ? resolvedSearchParams.city : ''
const pickupLocation = typeof resolvedSearchParams?.pickupLocation === 'string' ? resolvedSearchParams.pickupLocation : city
const dropoffLocation = typeof resolvedSearchParams?.dropoffLocation === 'string' ? resolvedSearchParams.dropoffLocation : ''
const dropoffMode = resolvedSearchParams?.dropoffMode === 'different' ? 'different' : 'same'
const pickupDate = typeof resolvedSearchParams?.pickupDate === 'string' ? resolvedSearchParams.pickupDate : ''
const pickupTime = typeof resolvedSearchParams?.pickupTime === 'string' ? resolvedSearchParams.pickupTime : '10:00'
const returnDate = typeof resolvedSearchParams?.returnDate === 'string' ? resolvedSearchParams.returnDate : ''
const returnTime = typeof resolvedSearchParams?.returnTime === 'string' ? resolvedSearchParams.returnTime : '10:00'
const promoCode = typeof resolvedSearchParams?.promoCode === 'string' ? resolvedSearchParams.promoCode : ''
const driverAge = typeof resolvedSearchParams?.driverAge === 'string' ? resolvedSearchParams.driverAge : '23+'
const category = typeof resolvedSearchParams?.category === 'string' ? resolvedSearchParams.category : ''
const transmission = typeof resolvedSearchParams?.transmission === 'string' ? resolvedSearchParams.transmission : ''
const make = typeof resolvedSearchParams?.make === 'string' ? resolvedSearchParams.make : ''
const model = typeof resolvedSearchParams?.model === 'string' ? resolvedSearchParams.model : ''
const startDate = typeof resolvedSearchParams?.startDate === 'string' ? resolvedSearchParams.startDate : ''
const endDate = typeof resolvedSearchParams?.endDate === 'string' ? resolvedSearchParams.endDate : ''
const query = new URLSearchParams()
if (pickupLocation) query.set('city', pickupLocation)
if (pickupLocation) query.set('pickupLocation', pickupLocation)
query.set('dropoffMode', dropoffMode)
if (dropoffMode === 'different' && dropoffLocation) query.set('dropoffLocation', dropoffLocation)
if (startDate) query.set('startDate', startDate)
if (endDate) query.set('endDate', endDate)
if (category) query.set('category', category)
if (transmission) query.set('transmission', transmission)
if (make) query.set('make', make)
if (model) query.set('model', model)
query.set('pageSize', '24')
const [offers, vehicles, companies] = await Promise.all([
storefrontFetchOrDefault<Offer[]>('/storefront/offers', []),
storefrontFetchOrDefault<Vehicle[]>(`/storefront/search?${query.toString()}`, []),
storefrontFetchOrDefault<CompanyCard[]>('/storefront/companies?pageSize=8', []),
])
const cities = Array.from(
new Set(
companies
.map((company) => company.brand?.publicCity?.trim())
.filter((city): city is string => Boolean(city))
)
).sort((left, right) => left.localeCompare(right))
return (
<main className="site-page">
<div className="site-section space-y-10">
<section className="site-panel-contrast bg-[linear-gradient(135deg,#0f2460,#1a3a8f)] dark:bg-[linear-gradient(135deg,#0a1735,#112d6e)] px-8 py-12">
<p className="site-kicker text-orange-300 dark:text-orange-300">{dict.kicker}</p>
<h1 className="mt-4 text-4xl font-black tracking-tight">{dict.title}</h1>
<p className="mt-4 max-w-2xl text-blue-100 dark:text-blue-100">{dict.body}</p>
<StorefrontSearchForm
pickupLocation={pickupLocation}
dropoffLocation={dropoffLocation}
dropoffMode={dropoffMode}
pickupDate={pickupDate}
pickupTime={pickupTime}
returnDate={returnDate}
returnTime={returnTime}
promoCode={promoCode}
driverAge={driverAge}
cities={cities}
dict={dict}
/>
</section>
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.currentDeals}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.featuredOffers}</p>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{offers.map((offer) => (
<Link key={offer.id} href={`/storefront/${offer.company.brand?.subdomain ?? ''}#offers`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
<p className="truncate text-xs uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{offer.company.brand?.displayName ?? dict.company}</p>
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{offer.title}</h3>
<p className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{offer.discountValue}%</p>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
</Link>
))}
{offers.length === 0 && <div className="card p-6 text-sm text-stone-500 dark:text-stone-400 md:col-span-2 xl:col-span-4">{dict.offersUnavailable}</div>}
</div>
</section>
<StorefrontVehicleGrid vehicles={vehicles} dict={dict} language={language} />
<section>
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCategory}</h2>
<div className="mt-4 flex flex-wrap gap-3">
{dict.categories.map((categoryName, index) => (
<Link key={CATEGORY_VALUES[index] ?? categoryName} href={`/storefront?category=${CATEGORY_VALUES[index]}`} className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-stone-400 dark:border-blue-800 dark:bg-blue-950 dark:text-stone-300 dark:hover:border-stone-500">
{categoryName}
</Link>
))}
</div>
</section>
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCompany}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.storefrontPartners}</p>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{companies.map((company) => (
<Link key={company.id} href={`/storefront/${company.brand?.subdomain ?? ''}`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
<p className="truncate text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{company.brand?.publicCity ?? dict.storefrontPartner}</p>
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{company.brand?.displayName ?? dict.rentalCompany}</h3>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</p>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{company._count.vehicles} {dict.publishedVehicles}</p>
<span className="mt-4 inline-flex self-start rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white dark:bg-orange-500 dark:text-white">{dict.viewFleet}</span>
</Link>
))}
</div>
</section>
</div>
</main>
)
} }
@@ -34,10 +34,10 @@ export default function RenterDashboardPage() {
en: { en: {
loadProfile: 'Could not load profile.', loadProfile: 'Could not load profile.',
reachServer: 'Could not reach the server.', reachServer: 'Could not reach the server.',
backToExplore: 'Back to explore', backToStorefront: 'Back to storefront',
renterDashboard: 'Renter Dashboard', renterDashboard: 'Renter Dashboard',
welcome: 'Welcome back,', welcome: 'Welcome back,',
exploreVehicles: 'Explore vehicles', browseVehicles: 'Browse vehicles',
logout: 'Log out', logout: 'Log out',
cards: [ cards: [
['Profile', 'Review your account details and preferences.'], ['Profile', 'Review your account details and preferences.'],
@@ -53,16 +53,16 @@ export default function RenterDashboardPage() {
noneSaved: 'None saved yet', noneSaved: 'None saved yet',
saved: 'saved', saved: 'saved',
saveCompaniesPrompt: 'Save companies you like while browsing the storefront.', saveCompaniesPrompt: 'Save companies you like while browsing the storefront.',
startExploring: 'Start exploring', startBrowsing: 'Start browsing',
rentalCompany: 'Rental company', rentalCompany: 'Rental company',
}, },
fr: { fr: {
loadProfile: 'Impossible de charger le profil.', loadProfile: 'Impossible de charger le profil.',
reachServer: 'Impossible de joindre le serveur.', reachServer: 'Impossible de joindre le serveur.',
backToExplore: 'Retour à lexploration', backToStorefront: 'Retour à la storefront',
renterDashboard: 'Espace client', renterDashboard: 'Espace client',
welcome: 'Bon retour,', welcome: 'Bon retour,',
exploreVehicles: 'Explorer les véhicules', browseVehicles: 'Parcourir les véhicules',
logout: 'Déconnexion', logout: 'Déconnexion',
cards: [ cards: [
['Profil', 'Consultez vos informations et préférences.'], ['Profil', 'Consultez vos informations et préférences.'],
@@ -78,16 +78,16 @@ export default function RenterDashboardPage() {
noneSaved: 'Aucune pour le moment', noneSaved: 'Aucune pour le moment',
saved: 'sauvegardées', saved: 'sauvegardées',
saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la storefront.', saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la storefront.',
startExploring: 'Commencer à explorer', startBrowsing: 'Commencer à parcourir',
rentalCompany: 'Société de location', rentalCompany: 'Société de location',
}, },
ar: { ar: {
loadProfile: 'تعذر تحميل الملف الشخصي.', loadProfile: 'تعذر تحميل الملف الشخصي.',
reachServer: 'تعذر الوصول إلى الخادم.', reachServer: 'تعذر الوصول إلى الخادم.',
backToExplore: 'العودة إلى الاستكشاف', backToStorefront: 'العودة إلى السوق',
renterDashboard: 'بوابة المستأجر', renterDashboard: 'بوابة المستأجر',
welcome: 'مرحباً بعودتك،', welcome: 'مرحباً بعودتك،',
exploreVehicles: 'استكشف السيارات', browseVehicles: 'تصفح السيارات',
logout: 'تسجيل الخروج', logout: 'تسجيل الخروج',
cards: [ cards: [
['الملف الشخصي', 'راجع بيانات حسابك وتفضيلاتك.'], ['الملف الشخصي', 'راجع بيانات حسابك وتفضيلاتك.'],
@@ -103,7 +103,7 @@ export default function RenterDashboardPage() {
noneSaved: 'لا يوجد شيء محفوظ بعد', noneSaved: 'لا يوجد شيء محفوظ بعد',
saved: 'محفوظة', saved: 'محفوظة',
saveCompaniesPrompt: 'احفظ الشركات التي تعجبك أثناء تصفح السوق.', saveCompaniesPrompt: 'احفظ الشركات التي تعجبك أثناء تصفح السوق.',
startExploring: 'ابدأ الاستكشاف', startBrowsing: 'ابدأ التصفح',
rentalCompany: 'شركة تأجير', rentalCompany: 'شركة تأجير',
}, },
}[language] }[language]
@@ -155,10 +155,10 @@ export default function RenterDashboardPage() {
<div className="card p-8 text-center"> <div className="card p-8 text-center">
<p className="text-sm text-rose-600">{errorMsg}</p> <p className="text-sm text-rose-600">{errorMsg}</p>
<Link <Link
href="/explore" href="/storefront"
className="mt-4 inline-flex rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white" className="mt-4 inline-flex rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white"
> >
{dict.backToExplore} {dict.backToStorefront}
</Link> </Link>
</div> </div>
</div> </div>
@@ -179,10 +179,10 @@ export default function RenterDashboardPage() {
</h1> </h1>
</div> </div>
<Link <Link
href="/explore" href="/storefront"
className="self-start rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 shadow-sm hover:border-stone-300" className="self-start rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 shadow-sm hover:border-stone-300"
> >
{dict.exploreVehicles} {dict.browseVehicles}
</Link> </Link>
</div> </div>
@@ -251,10 +251,10 @@ export default function RenterDashboardPage() {
{dict.saveCompaniesPrompt} {dict.saveCompaniesPrompt}
</p> </p>
<Link <Link
href="/explore" href="/storefront"
className="rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-orange-700" className="rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-orange-700"
> >
{dict.startExploring} {dict.startBrowsing}
</Link> </Link>
</div> </div>
) : ( ) : (
@@ -262,7 +262,7 @@ export default function RenterDashboardPage() {
{savedCompanies.map((company) => ( {savedCompanies.map((company) => (
<Link <Link
key={company.id} key={company.id}
href={`/explore/${company.brand?.subdomain ?? company.id}`} href={`/storefront/${company.brand?.subdomain ?? company.id}`}
className="card flex items-center gap-4 p-5 hover:border-orange-300 transition-colors" className="card flex items-center gap-4 p-5 hover:border-orange-300 transition-colors"
> >
{company.brand?.logoUrl ? ( {company.brand?.logoUrl ? (
@@ -68,7 +68,7 @@ export default function RenterSavedCompaniesPage() {
{!loading && !errorMsg && companies.length === 0 ? ( {!loading && !errorMsg && companies.length === 0 ? (
<div className="card p-10 text-center"> <div className="card p-10 text-center">
<p className="text-sm text-stone-500">{dict.empty}</p> <p className="text-sm text-stone-500">{dict.empty}</p>
<Link href="/explore" className="mt-5 inline-flex rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white"> <Link href="/storefront" className="mt-5 inline-flex rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white">
{dict.browse} {dict.browse}
</Link> </Link>
</div> </div>
@@ -79,7 +79,7 @@ export default function RenterSavedCompaniesPage() {
{companies.map((company) => ( {companies.map((company) => (
<Link <Link
key={company.id} key={company.id}
href={`/explore/${company.brand?.subdomain ?? company.id}`} href={`/storefront/${company.brand?.subdomain ?? company.id}`}
className="card flex items-center gap-4 p-5 hover:border-orange-300 transition-colors" className="card flex items-center gap-4 p-5 hover:border-orange-300 transition-colors"
> >
{company.brand?.logoUrl ? ( {company.brand?.logoUrl ? (
@@ -9,8 +9,8 @@ export type Theme = 'light' | 'dark'
export type Language = 'en' | 'fr' | 'ar' export type Language = 'en' | 'fr' | 'ar'
type Dictionary = { type Dictionary = {
home: string
storefront: string storefront: string
explore: string
signIn: string signIn: string
ownerSignIn: string ownerSignIn: string
theme: string theme: string
@@ -124,13 +124,13 @@ export default function StorefrontHeader({
href="/" 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" 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.storefront} {dict.home}
</Link> </Link>
<Link <Link
href="/explore" href="/storefront"
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" 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} {dict.storefront}
</Link> </Link>
<a <a
href={signInHref} href={signInHref}
@@ -9,8 +9,8 @@ import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference,
type Theme = 'light' | 'dark' type Theme = 'light' | 'dark'
type Dictionary = { type Dictionary = {
home: string
storefront: string storefront: string
explore: string
signIn: string signIn: string
ownerSignIn: string ownerSignIn: string
language: string language: string
@@ -22,8 +22,8 @@ type Dictionary = {
const dictionaries: Record<StorefrontLanguage, Dictionary> = { const dictionaries: Record<StorefrontLanguage, Dictionary> = {
en: { en: {
storefront: 'Home', home: 'Home',
explore: 'Storefront', storefront: 'Storefront',
signIn: 'Sign in', signIn: 'Sign in',
ownerSignIn: 'Create Agency Space', ownerSignIn: 'Create Agency Space',
language: 'Language', language: 'Language',
@@ -33,8 +33,8 @@ const dictionaries: Record<StorefrontLanguage, Dictionary> = {
preferences: 'Storefront preferences', preferences: 'Storefront preferences',
}, },
fr: { fr: {
storefront: 'Accueil', home: 'Accueil',
explore: 'Storefront', storefront: 'Storefront',
signIn: 'Connexion', signIn: 'Connexion',
ownerSignIn: "Créer un espace d'agence", ownerSignIn: "Créer un espace d'agence",
language: 'Langue', language: 'Langue',
@@ -44,8 +44,8 @@ const dictionaries: Record<StorefrontLanguage, Dictionary> = {
preferences: 'Préférences storefront', preferences: 'Préférences storefront',
}, },
ar: { ar: {
storefront: 'الرئيسية', home: 'الرئيسية',
explore: 'السوق', storefront: 'السوق',
signIn: 'تسجيل الدخول', signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء مساحة الوكالة', ownerSignIn: 'إنشاء مساحة الوكالة',
language: 'اللغة', language: 'اللغة',
+1 -1
View File
@@ -197,7 +197,7 @@ const footerContent: Record<StorefrontLanguage, Record<FooterPageSlug, FooterPag
'contact-sales': { 'contact-sales': {
title: 'Contact Sales', title: 'Contact Sales',
paragraphs: [ paragraphs: [
'Want to see how RentalDriveGo can transform your rental operations? Our sales team is ready to help you explore the right solution for your business.', 'Want to see how RentalDriveGo can transform your rental operations? Our sales team is ready to help you find the right solution for your business.',
'Contact us to schedule a demo, discuss pricing, or learn more about our fleet management and reservation platform.', 'Contact us to schedule a demo, discuss pricing, or learn more about our fleet management and reservation platform.',
], ],
}, },
+25 -2
View File
@@ -12,6 +12,9 @@ const nextServer = vi.hoisted(() => {
kind: 'redirect', kind: 'redirect',
url: url.toString(), url: url.toString(),
status, status,
cookies: {
set: vi.fn(),
},
})) }))
const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({ const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({
kind: 'response', kind: 'response',
@@ -125,6 +128,26 @@ describe('storefront proxy language bootstrap', () => {
expect(response.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'fr', expect.any(Object)) expect(response.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'fr', expect.any(Object))
expect(response.init?.request?.headers?.get('cookie')).toBe('rentaldrivego-language=fr') expect(response.init?.request?.headers?.get('cookie')).toBe('rentaldrivego-language=fr')
}) })
it('redirects locale entry paths to the storefront mount and stores the language', async () => {
const { proxy } = await import('./proxy')
const input = middlewareRequest('http://localhost:3004/fr')
input.headers.set('host', 'rentaldrivego.ma')
input.headers.set('x-forwarded-proto', 'https')
input.headers.set('x-forwarded-host', 'rentaldrivego.ma')
const response = proxy(input as never) as any
expect(response).toMatchObject({
kind: 'redirect',
url: 'https://rentaldrivego.ma/storefront',
status: 307,
})
expect(response.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'fr', expect.objectContaining({
path: '/',
sameSite: 'lax',
}))
})
}) })
describe('storefront middleware dashboard path canonicalization', () => { describe('storefront middleware dashboard path canonicalization', () => {
@@ -133,7 +156,7 @@ describe('storefront middleware dashboard path canonicalization', () => {
const response = proxy(middlewareRequest('http://localhost:3000/dashboard/dashboard') as never) const response = proxy(middlewareRequest('http://localhost:3000/dashboard/dashboard') as never)
expect(response).toEqual({ kind: 'redirect', url: 'http://localhost:3000/dashboard', status: 307 }) expect(response).toMatchObject({ kind: 'redirect', url: 'http://localhost:3000/dashboard', status: 307 })
}) })
it('preserves nested dashboard paths while removing the duplicate prefix', async () => { it('preserves nested dashboard paths while removing the duplicate prefix', async () => {
@@ -141,6 +164,6 @@ describe('storefront middleware dashboard path canonicalization', () => {
const response = proxy(middlewareRequest('http://localhost:3000/dashboard/dashboard/fleet?tab=active') as never) const response = proxy(middlewareRequest('http://localhost:3000/dashboard/dashboard/fleet?tab=active') as never)
expect(response).toEqual({ kind: 'redirect', url: 'http://localhost:3000/dashboard/fleet?tab=active', status: 307 }) expect(response).toMatchObject({ kind: 'redirect', url: 'http://localhost:3000/dashboard/fleet?tab=active', status: 307 })
}) })
}) })
+25 -5
View File
@@ -33,6 +33,23 @@ function deduplicateDashboardPath(pathname: string): string | null {
return normalized || DASHBOARD_BASE_PATH return normalized || DASHBOARD_BASE_PATH
} }
function storefrontRedirectUrl(request: NextRequest): URL {
const forwardedProto = request.headers.get('x-forwarded-proto')
const forwardedHost = request.headers.get('x-forwarded-host')
const host = forwardedHost ?? request.headers.get('host') ?? request.nextUrl.host
const protocol = forwardedProto ?? request.nextUrl.protocol.replace(/:$/, '')
return new URL('/storefront', `${protocol}://${host}`)
}
function setLanguageCookie(response: NextResponse, language: 'en' | 'fr' | 'ar'): void {
response.cookies.set(SHARED_LANGUAGE_COOKIE, language, {
maxAge: 365 * 24 * 60 * 60,
path: '/',
sameSite: 'lax',
})
}
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const rejected = rejectInternalSubrequest(request) const rejected = rejectInternalSubrequest(request)
if (rejected) return rejected if (rejected) return rejected
@@ -44,6 +61,13 @@ export function proxy(request: NextRequest) {
return NextResponse.redirect(url, 307) return NextResponse.redirect(url, 307)
} }
const localeEntry = request.nextUrl.pathname.split('/').filter(Boolean)[0]
if (isValidLanguage(localeEntry)) {
const response = NextResponse.redirect(storefrontRedirectUrl(request), 307)
setLanguageCookie(response, localeEntry)
return response
}
const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
if (isValidLanguage(sharedLang)) return NextResponse.next() if (isValidLanguage(sharedLang)) return NextResponse.next()
@@ -60,11 +84,7 @@ export function proxy(request: NextRequest) {
const response = NextResponse.next({ request: { headers: requestHeaders } }) const response = NextResponse.next({ request: { headers: requestHeaders } })
response.cookies.set(SHARED_LANGUAGE_COOKIE, resolved, { setLanguageCookie(response, resolved)
maxAge: 365 * 24 * 60 * 60,
path: '/',
sameSite: 'lax',
})
return response return response
} }
+3 -2
View File
@@ -215,14 +215,15 @@ services:
restart: unless-stopped restart: unless-stopped
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.storefront.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/explore`, `/renter`, `/sign-in`, `/footer`, `/app-privacy`, `/app-tc`, `/company-workspace`) - traefik.http.routers.storefront.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/storefront`, `/renter`, `/sign-in`, `/footer`, `/app-privacy`, `/app-tc`, `/company-workspace`)
- traefik.http.routers.storefront.entrypoints=websecure - traefik.http.routers.storefront.entrypoints=websecure
- traefik.http.routers.storefront.tls=true - traefik.http.routers.storefront.tls=true
- traefik.http.routers.storefront.tls.certresolver=letsencrypt - traefik.http.routers.storefront.tls.certresolver=letsencrypt
- traefik.http.routers.storefront.service=storefront-svc - traefik.http.routers.storefront.service=storefront-svc
- traefik.http.routers.storefront.priority=20 - traefik.http.routers.storefront.priority=20
- traefik.http.services.storefront-svc.loadbalancer.server.port=3004 - traefik.http.services.storefront-svc.loadbalancer.server.port=3004
- traefik.http.routers.storefront.middlewares=rdg-security-headers@docker - traefik.http.middlewares.storefront-strip-prefix.stripprefix.prefixes=/storefront
- traefik.http.routers.storefront.middlewares=storefront-strip-prefix,rdg-security-headers@docker
dashboard: dashboard:
<<: *node-runtime-hardening <<: *node-runtime-hardening