diff --git a/apps/storefront/next.config.js b/apps/storefront/next.config.js index 20d26b7..bd56cb2 100644 --- a/apps/storefront/next.config.js +++ b/apps/storefront/next.config.js @@ -8,10 +8,16 @@ const dashboardAssetSource = normalizeAssetPrefix( process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined), '/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({ - assetSources: [dashboardAssetSource, process.env.ADMIN_ASSET_PREFIX], + assetSources: [storefrontAssetPrefix, dashboardAssetSource, process.env.ADMIN_ASSET_PREFIX], }) const nextConfig = { + ...(storefrontAssetPrefix ? { assetPrefix: storefrontAssetPrefix } : {}), images: { remotePatterns: [ { diff --git a/apps/storefront/src/app/(public)/explore/[slug]/page.tsx b/apps/storefront/src/app/(public)/[slug]/page.tsx similarity index 91% rename from apps/storefront/src/app/(public)/explore/[slug]/page.tsx rename to apps/storefront/src/app/(public)/[slug]/page.tsx index a314bbf..4402aa9 100644 --- a/apps/storefront/src/app/(public)/explore/[slug]/page.tsx +++ b/apps/storefront/src/app/(public)/[slug]/page.tsx @@ -27,7 +27,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s unavailable: 'Storefront unavailable', unavailableTitle: 'Company details are temporarily unavailable.', 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', vehicles: 'vehicles', offers: 'active offers', @@ -45,7 +45,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s unavailable: 'Storefront indisponible', 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.", - backToExplore: "Retour à l'exploration", + backToStorefront: "Retour à la storefront", partner: 'Partenaire storefront', vehicles: 'véhicules', offers: 'offres actives', @@ -63,7 +63,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s unavailable: 'السوق غير متاح', unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.', unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات غير متاحة في هذه البيئة المحلية.', - backToExplore: 'العودة إلى الاستكشاف', + backToStorefront: 'العودة إلى السوق', partner: 'شريك في السوق', vehicles: 'مركبات', offers: 'عروض نشطة', @@ -89,7 +89,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s

{dict.unavailableTitle}

{dict.unavailableBody}

- {dict.backToExplore} + {dict.backToStorefront}
@@ -146,7 +146,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s )}

{formatCurrency(vehicle.dailyRate, 'MAD', language)}

- {dict.viewVehicle} + {dict.viewVehicle}
diff --git a/apps/storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx b/apps/storefront/src/app/(public)/[slug]/vehicles/[id]/page.tsx similarity index 92% rename from apps/storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx rename to apps/storefront/src/app/(public)/[slug]/vehicles/[id]/page.tsx index a5a446c..425ec67 100644 --- a/apps/storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx +++ b/apps/storefront/src/app/(public)/[slug]/vehicles/[id]/page.tsx @@ -40,7 +40,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st unavailableTitle: 'Vehicle details are temporarily unavailable.', unavailableBody: 'The storefront frontend is running, but the backing database is not reachable right now.', backToFleet: 'Back to fleet', - backToExplore: 'Back to explore', + backToStorefront: 'Back to storefront', noPhotos: 'No photos available.', rentalCompany: 'Rental company', 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.', unavailableBody: "Le frontend storefront fonctionne, mais la base de données n'est pas accessible pour le moment.", backToFleet: 'Retour à la flotte', - backToExplore: 'Retour à l’exploration', + backToStorefront: 'Retour à la storefront', noPhotos: 'Aucune photo disponible.', rentalCompany: 'Société de location', perDay: '/ jour', @@ -86,7 +86,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.', unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات في الخلفية غير متاحة حالياً.', backToFleet: 'العودة إلى الأسطول', - backToExplore: 'العودة إلى الاستكشاف', + backToStorefront: 'العودة إلى السوق', noPhotos: 'لا توجد صور متاحة.', rentalCompany: 'شركة تأجير', perDay: '/ يوم', @@ -117,8 +117,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st

{dict.unavailableTitle}

{dict.unavailableBody}

- {dict.backToFleet} - {dict.backToExplore} + {dict.backToFleet} + {dict.backToStorefront}
@@ -138,9 +138,9 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st {/* Breadcrumb */} @@ -269,7 +269,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st )} {dict.backToFleet} diff --git a/apps/storefront/src/app/(public)/explore/ExploreSearchForm.tsx b/apps/storefront/src/app/(public)/_components/StorefrontSearchForm.tsx similarity index 98% rename from apps/storefront/src/app/(public)/explore/ExploreSearchForm.tsx rename to apps/storefront/src/app/(public)/_components/StorefrontSearchForm.tsx index 408cd02..9f424f4 100644 --- a/apps/storefront/src/app/(public)/explore/ExploreSearchForm.tsx +++ b/apps/storefront/src/app/(public)/_components/StorefrontSearchForm.tsx @@ -4,7 +4,7 @@ import { CalendarDays, Clock3, Info, MapPin, TicketPercent } from 'lucide-react' import { useRouter } from 'next/navigation' import { useState } from 'react' -type ExploreSearchFormDict = { +type StorefrontSearchFormDict = { searchTitle: string pickupLocation: 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 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, dropoffLocation, dropoffMode, @@ -52,7 +52,7 @@ export default function ExploreSearchForm({ promoCode: string driverAge: string cities: string[] - dict: ExploreSearchFormDict + dict: StorefrontSearchFormDict }) { const router = useRouter() const cityOptions = Array.isArray(cities) ? cities : [] @@ -104,7 +104,7 @@ export default function ExploreSearchForm({ if (startDateTime) query.set('startDate', startDateTime) 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) } diff --git a/apps/storefront/src/app/(public)/explore/ExploreVehicleGrid.tsx b/apps/storefront/src/app/(public)/_components/StorefrontVehicleGrid.tsx similarity index 95% rename from apps/storefront/src/app/(public)/explore/ExploreVehicleGrid.tsx rename to apps/storefront/src/app/(public)/_components/StorefrontVehicleGrid.tsx index 52b8586..588ff14 100644 --- a/apps/storefront/src/app/(public)/explore/ExploreVehicleGrid.tsx +++ b/apps/storefront/src/app/(public)/_components/StorefrontVehicleGrid.tsx @@ -56,7 +56,7 @@ function getStatusCopy(vehicle: Vehicle, dict: Dict) { 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 (
@@ -94,7 +94,7 @@ export default function ExploreVehicleGrid({ vehicles, dict, language }: { vehic

{formatCents(vehicle.dailyRate, language)}

{dict.viewVehicle} diff --git a/apps/storefront/src/app/(public)/explore/page.tsx b/apps/storefront/src/app/(public)/explore/page.tsx deleted file mode 100644 index 7251068..0000000 --- a/apps/storefront/src/app/(public)/explore/page.tsx +++ /dev/null @@ -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> }) { - 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('/storefront/offers', []), - storefrontFetchOrDefault(`/storefront/search?${query.toString()}`, []), - storefrontFetchOrDefault('/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 ( -
-
-
-

{dict.kicker}

-

{dict.title}

-

{dict.body}

- -
- -
-
-

{dict.currentDeals}

-

{dict.featuredOffers}

-
-
- {offers.map((offer) => ( - -

{offer.company.brand?.displayName ?? dict.company}

-

{offer.title}

-

{offer.discountValue}%

-

{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}

- - ))} - {offers.length === 0 &&
{dict.offersUnavailable}
} -
-
- - - -
-

{dict.browseByCategory}

-
- {dict.categories.map((categoryName, index) => ( - - {categoryName} - - ))} -
-
- -
-
-

{dict.browseByCompany}

-

{dict.storefrontPartners}

-
-
- {companies.map((company) => ( - -

{company.brand?.publicCity ?? dict.storefrontPartner}

-

{company.brand?.displayName ?? dict.rentalCompany}

-

{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}

-

{company._count.vehicles} {dict.publishedVehicles}

- {dict.viewFleet} - - ))} -
-
-
-
- ) -} diff --git a/apps/storefront/src/app/(public)/page.tsx b/apps/storefront/src/app/(public)/page.tsx index b227514..7ee7ed5 100644 --- a/apps/storefront/src/app/(public)/page.tsx +++ b/apps/storefront/src/app/(public)/page.tsx @@ -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() { - redirect('/explore') +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 StorefrontPage({ searchParams }: { searchParams?: Promise> }) { + 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('/storefront/offers', []), + storefrontFetchOrDefault(`/storefront/search?${query.toString()}`, []), + storefrontFetchOrDefault('/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 ( +
+
+
+

{dict.kicker}

+

{dict.title}

+

{dict.body}

+ +
+ +
+
+

{dict.currentDeals}

+

{dict.featuredOffers}

+
+
+ {offers.map((offer) => ( + +

{offer.company.brand?.displayName ?? dict.company}

+

{offer.title}

+

{offer.discountValue}%

+

{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}

+ + ))} + {offers.length === 0 &&
{dict.offersUnavailable}
} +
+
+ + + +
+

{dict.browseByCategory}

+
+ {dict.categories.map((categoryName, index) => ( + + {categoryName} + + ))} +
+
+ +
+
+

{dict.browseByCompany}

+

{dict.storefrontPartners}

+
+
+ {companies.map((company) => ( + +

{company.brand?.publicCity ?? dict.storefrontPartner}

+

{company.brand?.displayName ?? dict.rentalCompany}

+

{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}

+

{company._count.vehicles} {dict.publishedVehicles}

+ {dict.viewFleet} + + ))} +
+
+
+
+ ) } diff --git a/apps/storefront/src/app/renter/dashboard/page.tsx b/apps/storefront/src/app/renter/dashboard/page.tsx index badf423..3be7123 100644 --- a/apps/storefront/src/app/renter/dashboard/page.tsx +++ b/apps/storefront/src/app/renter/dashboard/page.tsx @@ -34,10 +34,10 @@ export default function RenterDashboardPage() { en: { loadProfile: 'Could not load profile.', reachServer: 'Could not reach the server.', - backToExplore: 'Back to explore', + backToStorefront: 'Back to storefront', renterDashboard: 'Renter Dashboard', welcome: 'Welcome back,', - exploreVehicles: 'Explore vehicles', + browseVehicles: 'Browse vehicles', logout: 'Log out', cards: [ ['Profile', 'Review your account details and preferences.'], @@ -53,16 +53,16 @@ export default function RenterDashboardPage() { noneSaved: 'None saved yet', saved: 'saved', saveCompaniesPrompt: 'Save companies you like while browsing the storefront.', - startExploring: 'Start exploring', + startBrowsing: 'Start browsing', rentalCompany: 'Rental company', }, fr: { loadProfile: 'Impossible de charger le profil.', reachServer: 'Impossible de joindre le serveur.', - backToExplore: 'Retour à l’exploration', + backToStorefront: 'Retour à la storefront', renterDashboard: 'Espace client', welcome: 'Bon retour,', - exploreVehicles: 'Explorer les véhicules', + browseVehicles: 'Parcourir les véhicules', logout: 'Déconnexion', cards: [ ['Profil', 'Consultez vos informations et préférences.'], @@ -78,16 +78,16 @@ export default function RenterDashboardPage() { noneSaved: 'Aucune pour le moment', saved: 'sauvegardées', saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la storefront.', - startExploring: 'Commencer à explorer', + startBrowsing: 'Commencer à parcourir', rentalCompany: 'Société de location', }, ar: { loadProfile: 'تعذر تحميل الملف الشخصي.', reachServer: 'تعذر الوصول إلى الخادم.', - backToExplore: 'العودة إلى الاستكشاف', + backToStorefront: 'العودة إلى السوق', renterDashboard: 'بوابة المستأجر', welcome: 'مرحباً بعودتك،', - exploreVehicles: 'استكشف السيارات', + browseVehicles: 'تصفح السيارات', logout: 'تسجيل الخروج', cards: [ ['الملف الشخصي', 'راجع بيانات حسابك وتفضيلاتك.'], @@ -103,7 +103,7 @@ export default function RenterDashboardPage() { noneSaved: 'لا يوجد شيء محفوظ بعد', saved: 'محفوظة', saveCompaniesPrompt: 'احفظ الشركات التي تعجبك أثناء تصفح السوق.', - startExploring: 'ابدأ الاستكشاف', + startBrowsing: 'ابدأ التصفح', rentalCompany: 'شركة تأجير', }, }[language] @@ -155,10 +155,10 @@ export default function RenterDashboardPage() {

{errorMsg}

- {dict.backToExplore} + {dict.backToStorefront}
@@ -179,10 +179,10 @@ export default function RenterDashboardPage() { - {dict.exploreVehicles} + {dict.browseVehicles} @@ -251,10 +251,10 @@ export default function RenterDashboardPage() { {dict.saveCompaniesPrompt}

- {dict.startExploring} + {dict.startBrowsing} ) : ( @@ -262,7 +262,7 @@ export default function RenterDashboardPage() { {savedCompanies.map((company) => ( {company.brand?.logoUrl ? ( diff --git a/apps/storefront/src/app/renter/saved-companies/page.tsx b/apps/storefront/src/app/renter/saved-companies/page.tsx index d9b9da7..d995a25 100644 --- a/apps/storefront/src/app/renter/saved-companies/page.tsx +++ b/apps/storefront/src/app/renter/saved-companies/page.tsx @@ -68,7 +68,7 @@ export default function RenterSavedCompaniesPage() { {!loading && !errorMsg && companies.length === 0 ? (

{dict.empty}

- + {dict.browse}
@@ -79,7 +79,7 @@ export default function RenterSavedCompaniesPage() { {companies.map((company) => ( {company.brand?.logoUrl ? ( diff --git a/apps/storefront/src/components/StorefrontHeader.tsx b/apps/storefront/src/components/StorefrontHeader.tsx index 5d48ec8..b9f0dc4 100644 --- a/apps/storefront/src/components/StorefrontHeader.tsx +++ b/apps/storefront/src/components/StorefrontHeader.tsx @@ -9,8 +9,8 @@ export type Theme = 'light' | 'dark' export type Language = 'en' | 'fr' | 'ar' type Dictionary = { + home: string storefront: string - explore: string signIn: string ownerSignIn: string theme: string @@ -124,13 +124,13 @@ export default function StorefrontHeader({ 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.storefront} + {dict.home} - {dict.explore} + {dict.storefront} = { en: { - storefront: 'Home', - explore: 'Storefront', + home: 'Home', + storefront: 'Storefront', signIn: 'Sign in', ownerSignIn: 'Create Agency Space', language: 'Language', @@ -33,8 +33,8 @@ const dictionaries: Record = { preferences: 'Storefront preferences', }, fr: { - storefront: 'Accueil', - explore: 'Storefront', + home: 'Accueil', + storefront: 'Storefront', signIn: 'Connexion', ownerSignIn: "Créer un espace d'agence", language: 'Langue', @@ -44,8 +44,8 @@ const dictionaries: Record = { preferences: 'Préférences storefront', }, ar: { - storefront: 'الرئيسية', - explore: 'السوق', + home: 'الرئيسية', + storefront: 'السوق', signIn: 'تسجيل الدخول', ownerSignIn: 'إنشاء مساحة الوكالة', language: 'اللغة', diff --git a/apps/storefront/src/lib/footerContent.ts b/apps/storefront/src/lib/footerContent.ts index 6cf85ad..91611ec 100644 --- a/apps/storefront/src/lib/footerContent.ts +++ b/apps/storefront/src/lib/footerContent.ts @@ -197,7 +197,7 @@ const footerContent: Record { kind: 'redirect', url: url.toString(), status, + cookies: { + set: vi.fn(), + }, })) const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({ kind: 'response', @@ -125,6 +128,26 @@ describe('storefront proxy language bootstrap', () => { expect(response.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'fr', expect.any(Object)) 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', () => { @@ -133,7 +156,7 @@ describe('storefront middleware dashboard path canonicalization', () => { 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 () => { @@ -141,6 +164,6 @@ describe('storefront middleware dashboard path canonicalization', () => { 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 }) }) }) diff --git a/apps/storefront/src/proxy.ts b/apps/storefront/src/proxy.ts index 3e4a556..37740dd 100644 --- a/apps/storefront/src/proxy.ts +++ b/apps/storefront/src/proxy.ts @@ -33,6 +33,23 @@ function deduplicateDashboardPath(pathname: string): string | null { 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) { const rejected = rejectInternalSubrequest(request) if (rejected) return rejected @@ -44,6 +61,13 @@ export function proxy(request: NextRequest) { 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 if (isValidLanguage(sharedLang)) return NextResponse.next() @@ -60,11 +84,7 @@ export function proxy(request: NextRequest) { const response = NextResponse.next({ request: { headers: requestHeaders } }) - response.cookies.set(SHARED_LANGUAGE_COOKIE, resolved, { - maxAge: 365 * 24 * 60 * 60, - path: '/', - sameSite: 'lax', - }) + setLanguageCookie(response, resolved) return response } diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 5dd4326..798be3d 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -215,14 +215,15 @@ services: restart: unless-stopped labels: - 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.tls=true - traefik.http.routers.storefront.tls.certresolver=letsencrypt - traefik.http.routers.storefront.service=storefront-svc - traefik.http.routers.storefront.priority=20 - 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: <<: *node-runtime-hardening