6913c298ad
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
330 lines
16 KiB
TypeScript
330 lines
16 KiB
TypeScript
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'
|
|
|
|
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<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>
|
|
)
|
|
}
|