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 */}
- {dict.backToExplore}
+ {dict.backToStorefront}
/
- {vehicle.company.brand?.displayName ?? dict.rentalCompany}
+ {vehicle.company.brand?.displayName ?? dict.rentalCompany}
/
{vehicle.make} {vehicle.model}
@@ -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