325 lines
15 KiB
TypeScript
325 lines
15 KiB
TypeScript
import Link from 'next/link'
|
|
import { marketplaceFetchOrDefault } from '@/lib/api'
|
|
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
|
import ExploreVehicleGrid from './ExploreVehicleGrid'
|
|
|
|
interface Vehicle {
|
|
id: string
|
|
make: string
|
|
model: string
|
|
year: number
|
|
category: string
|
|
dailyRate: number
|
|
photos: 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
|
|
}
|
|
}
|
|
|
|
export default async function ExplorePage({ searchParams }: { searchParams?: Record<string, string | string[] | undefined> }) {
|
|
const language = getMarketplaceLanguage()
|
|
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.',
|
|
city: 'City or location',
|
|
allCategories: 'All categories',
|
|
anyTransmission: 'Any transmission',
|
|
makePlaceholder: 'Make (e.g. Toyota)',
|
|
modelPlaceholder: 'Model (e.g. Corolla)',
|
|
search: 'Search',
|
|
currentDeals: 'Current deals',
|
|
featuredOffers: 'Featured marketplace offers',
|
|
company: 'Company',
|
|
validUntil: 'Valid until',
|
|
offersUnavailable: 'Marketplace 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',
|
|
bookNow: 'Reserve',
|
|
availableFrom: 'Available from',
|
|
reserveAfterDate: 'This vehicle can only be reserved starting',
|
|
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
|
details: 'Details',
|
|
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
|
|
browseByCategory: 'Browse by category',
|
|
browseByCompany: 'Browse by company',
|
|
marketplacePartners: 'Marketplace partners',
|
|
marketplacePartner: 'Marketplace partner',
|
|
rating: 'Rating',
|
|
new: 'New',
|
|
publishedVehicles: 'published vehicles',
|
|
viewFleet: 'View fleet',
|
|
categories: ['Economy', 'Compact', 'SUV', 'Luxury', 'Van', 'Truck', 'Electric'],
|
|
// modal
|
|
reserveVehicle: 'Reserve this vehicle',
|
|
pickupDate: 'Pick-up date',
|
|
returnDate: 'Return date',
|
|
firstName: 'First name',
|
|
lastName: 'Last name',
|
|
email: 'Email address',
|
|
phone: 'Phone number (optional)',
|
|
notes: 'Additional notes (optional)',
|
|
cancel: 'Cancel',
|
|
submitRequest: 'Send reservation request',
|
|
submitting: 'Sending…',
|
|
successTitle: 'Request sent!',
|
|
successBody: 'Your reservation request has been received. Check your inbox for a confirmation email — the company will contact you shortly.',
|
|
close: 'Close',
|
|
errorUnavailable: 'This vehicle is no longer available for the selected dates. Please choose different dates.',
|
|
errorGeneric: 'Something went wrong. Please try again.',
|
|
},
|
|
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.",
|
|
city: 'Ville ou emplacement',
|
|
allCategories: 'Toutes les catégories',
|
|
anyTransmission: 'Toute transmission',
|
|
makePlaceholder: 'Marque (ex. Toyota)',
|
|
modelPlaceholder: 'Modèle (ex. Corolla)',
|
|
search: 'Rechercher',
|
|
currentDeals: 'Offres du moment',
|
|
featuredOffers: 'Offres marketplace mises en avant',
|
|
company: 'Entreprise',
|
|
validUntil: "Valable jusqu'au",
|
|
offersUnavailable: 'Les offres marketplace 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',
|
|
bookNow: 'Réserver',
|
|
availableFrom: 'Disponible à partir du',
|
|
reserveAfterDate: 'Ce véhicule ne peut être réservé qu’à partir du',
|
|
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
|
details: 'Détails',
|
|
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
|
|
browseByCategory: 'Parcourir par catégorie',
|
|
browseByCompany: 'Parcourir par entreprise',
|
|
marketplacePartners: 'Partenaires marketplace',
|
|
marketplacePartner: 'Partenaire marketplace',
|
|
rating: 'Note',
|
|
new: 'Nouveau',
|
|
publishedVehicles: 'véhicules publiés',
|
|
viewFleet: 'Voir la flotte',
|
|
categories: ['Économie', 'Compacte', 'SUV', 'Luxe', 'Van', 'Camion', 'Électrique'],
|
|
reserveVehicle: 'Réserver ce véhicule',
|
|
pickupDate: 'Date de prise en charge',
|
|
returnDate: 'Date de retour',
|
|
firstName: 'Prénom',
|
|
lastName: 'Nom',
|
|
email: 'Adresse e-mail',
|
|
phone: 'Numéro de téléphone (optionnel)',
|
|
notes: 'Notes supplémentaires (optionnel)',
|
|
cancel: 'Annuler',
|
|
submitRequest: 'Envoyer la demande',
|
|
submitting: 'Envoi…',
|
|
successTitle: 'Demande envoyée !',
|
|
successBody: "Votre demande de réservation a été reçue. Vérifiez votre boîte mail — l'entreprise vous contactera prochainement.",
|
|
close: 'Fermer',
|
|
errorUnavailable: "Ce véhicule n'est plus disponible pour les dates sélectionnées.",
|
|
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
|
|
},
|
|
ar: {
|
|
kicker: 'للاستكشاف فقط',
|
|
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
|
|
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
|
|
city: 'المدينة أو الموقع',
|
|
allCategories: 'كل الفئات',
|
|
anyTransmission: 'أي ناقل حركة',
|
|
makePlaceholder: 'الماركة (مثل Toyota)',
|
|
modelPlaceholder: 'الموديل (مثل Corolla)',
|
|
search: 'بحث',
|
|
currentDeals: 'العروض الحالية',
|
|
featuredOffers: 'عروض السوق المميزة',
|
|
company: 'شركة',
|
|
validUntil: 'صالح حتى',
|
|
offersUnavailable: 'عروض السوق غير متاحة حالياً.',
|
|
availableVehicles: 'السيارات المتاحة',
|
|
listings: 'إعلانات',
|
|
rentalCompany: 'شركة تأجير',
|
|
checkAvailability: 'تحقق من التوفر',
|
|
available: 'متاح',
|
|
reserved: 'محجوز',
|
|
maintenance: 'قيد الصيانة',
|
|
unavailableStatus: 'غير متاح',
|
|
from: 'ابتداءً من',
|
|
bookNow: 'احجز',
|
|
availableFrom: 'متاح ابتداءً من',
|
|
reserveAfterDate: 'يمكن حجز هذه السيارة ابتداءً من',
|
|
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
|
details: 'التفاصيل',
|
|
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
|
browseByCategory: 'تصفح حسب الفئة',
|
|
browseByCompany: 'تصفح حسب الشركة',
|
|
marketplacePartners: 'شركاء السوق',
|
|
marketplacePartner: 'شريك في السوق',
|
|
rating: 'التقييم',
|
|
new: 'جديد',
|
|
publishedVehicles: 'سيارات منشورة',
|
|
viewFleet: 'عرض الأسطول',
|
|
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
|
|
reserveVehicle: 'احجز هذه السيارة',
|
|
pickupDate: 'تاريخ الاستلام',
|
|
returnDate: 'تاريخ الإرجاع',
|
|
firstName: 'الاسم الأول',
|
|
lastName: 'اسم العائلة',
|
|
email: 'البريد الإلكتروني',
|
|
phone: 'رقم الهاتف (اختياري)',
|
|
notes: 'ملاحظات إضافية (اختياري)',
|
|
cancel: 'إلغاء',
|
|
submitRequest: 'إرسال طلب الحجز',
|
|
submitting: 'جارٍ الإرسال…',
|
|
successTitle: 'تم إرسال الطلب!',
|
|
successBody: 'تم استلام طلب الحجز. تحقق من بريدك الإلكتروني — ستتواصل معك الشركة قريباً.',
|
|
close: 'إغلاق',
|
|
errorUnavailable: 'السيارة غير متاحة في التواريخ المحددة. يرجى اختيار تواريخ أخرى.',
|
|
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
|
},
|
|
}[language]
|
|
|
|
const city = typeof searchParams?.city === 'string' ? searchParams.city : ''
|
|
const category = typeof searchParams?.category === 'string' ? searchParams.category : ''
|
|
const transmission = typeof searchParams?.transmission === 'string' ? searchParams.transmission : ''
|
|
const make = typeof searchParams?.make === 'string' ? searchParams.make : ''
|
|
const model = typeof searchParams?.model === 'string' ? searchParams.model : ''
|
|
|
|
const query = new URLSearchParams()
|
|
if (city) query.set('city', city)
|
|
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([
|
|
marketplaceFetchOrDefault<Offer[]>('/marketplace/offers', []),
|
|
marketplaceFetchOrDefault<Vehicle[]>(`/marketplace/search?${query.toString()}`, []),
|
|
marketplaceFetchOrDefault<CompanyCard[]>('/marketplace/companies?pageSize=8', []),
|
|
])
|
|
|
|
return (
|
|
<main className="min-h-screen bg-stone-50 py-10">
|
|
<div className="shell space-y-10">
|
|
<section className="rounded-[2rem] bg-[linear-gradient(135deg,#1c1917,#57534e)] px-8 py-12 text-white">
|
|
<p className="text-sm uppercase tracking-[0.2em] text-amber-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-stone-300">{dict.body}</p>
|
|
<form className="mt-8 grid gap-3 rounded-[1.5rem] bg-white/10 p-4 md:grid-cols-3 xl:grid-cols-6">
|
|
<input name="city" defaultValue={city} placeholder={dict.city} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900" />
|
|
<input name="make" defaultValue={make} placeholder={dict.makePlaceholder} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900" />
|
|
<input name="model" defaultValue={model} placeholder={dict.modelPlaceholder} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900" />
|
|
<select name="category" defaultValue={category} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900">
|
|
<option value="">{dict.allCategories}</option>
|
|
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((option) => (
|
|
<option key={option} value={option}>{option}</option>
|
|
))}
|
|
</select>
|
|
<select name="transmission" defaultValue={transmission} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900">
|
|
<option value="">{dict.anyTransmission}</option>
|
|
{['AUTOMATIC', 'MANUAL'].map((option) => <option key={option} value={option}>{option}</option>)}
|
|
</select>
|
|
<button className="rounded-2xl bg-amber-400 px-4 py-3 text-sm font-semibold text-stone-950">{dict.search}</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section>
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-2xl font-bold text-stone-900">{dict.currentDeals}</h2>
|
|
<p className="text-sm text-stone-500">{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 p-5 hover:border-amber-300 transition-colors">
|
|
<p className="text-xs uppercase tracking-[0.18em] text-amber-700">{offer.company.brand?.displayName ?? dict.company}</p>
|
|
<h3 className="mt-3 text-lg font-semibold text-stone-900">{offer.title}</h3>
|
|
<p className="mt-3 text-3xl font-black text-stone-900">{offer.discountValue}%</p>
|
|
<p className="mt-2 text-sm text-stone-500">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
|
|
</Link>
|
|
))}
|
|
{offers.length === 0 && <div className="card p-6 text-sm text-stone-500 md:col-span-2 xl:col-span-4">{dict.offersUnavailable}</div>}
|
|
</div>
|
|
</section>
|
|
|
|
<ExploreVehicleGrid vehicles={vehicles} dict={dict} />
|
|
|
|
<section>
|
|
<h2 className="text-2xl font-bold text-stone-900">{dict.browseByCategory}</h2>
|
|
<div className="mt-4 flex flex-wrap gap-3">
|
|
{dict.categories.map((categoryName, index) => (
|
|
<Link key={categoryName} href={`/explore?category=${['ECONOMY', 'COMPACT', 'SUV', 'LUXURY', 'VAN', 'TRUCK', 'ELECTRIC'][index]}`} className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
|
|
{categoryName}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-2xl font-bold text-stone-900">{dict.browseByCompany}</h2>
|
|
<p className="text-sm text-stone-500">{dict.marketplacePartners}</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 p-5 hover:border-amber-300 transition-colors">
|
|
<p className="text-xs uppercase tracking-[0.18em] text-stone-500">{company.brand?.publicCity ?? dict.marketplacePartner}</p>
|
|
<h3 className="mt-3 text-lg font-semibold text-stone-900">{company.brand?.displayName ?? dict.rentalCompany}</h3>
|
|
<p className="mt-2 text-sm text-stone-500">{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</p>
|
|
<p className="mt-1 text-sm text-stone-500">{company._count.vehicles} {dict.publishedVehicles}</p>
|
|
<span className="mt-4 inline-flex rounded-full bg-stone-900 px-4 py-2 text-sm font-semibold text-white">{dict.viewFleet}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|