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
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:
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
+5
-5
@@ -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>
|
||||||
+8
-8
@@ -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 à l’exploration',
|
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
-4
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
+2
-2
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 à l’exploration',
|
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: 'اللغة',
|
||||||
|
|||||||
@@ -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.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user