fixing platform admin
This commit is contained in:
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,365 @@
|
||||
'use client'
|
||||
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
heroKicker: 'RentalDriveGo',
|
||||
heroTitle: 'Marketplace discovery with a sharper front door.',
|
||||
heroBody:
|
||||
'Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the company’s own branded checkout.',
|
||||
startTrial: 'Start free trial',
|
||||
exploreVehicles: 'Explore vehicles',
|
||||
surfaceLabel: 'Marketplace surface',
|
||||
surfaceTitle: 'Designed for two audiences at once.',
|
||||
surfaceBody:
|
||||
'Operators need control, renters need confidence. The marketplace should show both without feeling like a template.',
|
||||
liveLabel: 'Live network',
|
||||
trustedFleets: 'Trusted fleets',
|
||||
brandedFlows: 'Branded booking flows',
|
||||
multiTenant: 'Multi-tenant operations',
|
||||
companyKicker: 'Operator workflow',
|
||||
companyTitle: 'Control inventory, offers, billing, and staff from one command layer.',
|
||||
companyBody:
|
||||
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
|
||||
renterKicker: 'Renter experience',
|
||||
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
|
||||
renterBody:
|
||||
'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.',
|
||||
pillars: [
|
||||
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'],
|
||||
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'],
|
||||
['Direct revenue path', 'Bookings move into the company’s own payment flow, so customer ownership and cash collection stay with the business.'],
|
||||
],
|
||||
metrics: [
|
||||
['01', 'Shared marketplace visibility'],
|
||||
['02', 'Direct company checkout'],
|
||||
['03', 'Company-owned payments'],
|
||||
],
|
||||
featureLabel: 'What the product covers',
|
||||
features: [
|
||||
'Fleet management with multi-photo uploads',
|
||||
'Marketplace offers and redirect booking flow',
|
||||
'Branded public booking site per company',
|
||||
'Customer CRM, analytics, and billing controls',
|
||||
],
|
||||
stepsTitle: 'How companies launch',
|
||||
steps: [
|
||||
['1', 'Create your company workspace', 'Pick a plan, launch your 14-day trial, and verify the owner account.'],
|
||||
['2', 'Publish vehicles and offers', 'Upload photos once and control what appears on the marketplace and branded site.'],
|
||||
['3', 'Accept bookings on your own site', 'Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow.'],
|
||||
],
|
||||
stepLabel: 'Step',
|
||||
readyKicker: 'Ready to launch',
|
||||
readyTitle: 'A marketplace homepage should sell the system in seconds.',
|
||||
readyBody:
|
||||
'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.',
|
||||
viewPricing: 'View pricing',
|
||||
createWorkspace: 'Create workspace',
|
||||
},
|
||||
fr: {
|
||||
heroKicker: 'RentalDriveGo',
|
||||
heroTitle: 'Une vitrine marketplace plus nette et plus forte.',
|
||||
heroBody:
|
||||
'Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune, et chaque réservation se termine sur le paiement de marque de la société.',
|
||||
startTrial: 'Commencer l’essai gratuit',
|
||||
exploreVehicles: 'Explorer les véhicules',
|
||||
surfaceLabel: 'Surface marketplace',
|
||||
surfaceTitle: 'Pensée pour deux audiences à la fois.',
|
||||
surfaceBody:
|
||||
'Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.',
|
||||
liveLabel: 'Réseau actif',
|
||||
trustedFleets: 'Flottes fiables',
|
||||
brandedFlows: 'Parcours de marque',
|
||||
multiTenant: 'Opérations multi-tenant',
|
||||
companyKicker: 'Flux opérateur',
|
||||
companyTitle: 'Pilotez inventaire, offres, facturation et équipe depuis une seule couche de commande.',
|
||||
companyBody:
|
||||
'Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.',
|
||||
renterKicker: 'Expérience client',
|
||||
renterTitle: 'Laissez les clients comparer rapidement puis dirigez-les vers le bon site entreprise.',
|
||||
renterBody:
|
||||
'La marketplace agit comme moteur de découverte, pas comme agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.',
|
||||
pillars: [
|
||||
['Publication unifiée', 'Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque.'],
|
||||
['Découverte qualifiée', 'Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement.'],
|
||||
['Revenus en direct', 'Les réservations basculent vers le paiement propre à l’entreprise, pour garder la relation client et l’encaissement.'],
|
||||
],
|
||||
metrics: [
|
||||
['01', 'Visibilité marketplace partagée'],
|
||||
['02', 'Paiement direct entreprise'],
|
||||
['03', 'Paiements détenus par la société'],
|
||||
],
|
||||
featureLabel: 'Ce que couvre le produit',
|
||||
features: [
|
||||
'Gestion de flotte avec téléversement multi-photos',
|
||||
'Offres marketplace et redirection vers la réservation',
|
||||
'Site public de réservation par entreprise',
|
||||
'CRM client, analytics et contrôle de facturation',
|
||||
],
|
||||
stepsTitle: 'Comment les entreprises démarrent',
|
||||
steps: [
|
||||
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire.'],
|
||||
['2', 'Publiez véhicules et offres', 'Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque.'],
|
||||
['3', 'Acceptez les réservations sur votre site', 'Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation.'],
|
||||
],
|
||||
stepLabel: 'Étape',
|
||||
readyKicker: 'Prêt à démarrer',
|
||||
readyTitle: 'Une homepage marketplace doit vendre le système en quelques secondes.',
|
||||
readyBody:
|
||||
'Utilisez la marketplace pour capter la demande, puis faites passer les clients vers une expérience de marque qui garde prix, paiements et confiance liés à votre entreprise.',
|
||||
viewPricing: 'Voir les tarifs',
|
||||
createWorkspace: 'Créer l’espace',
|
||||
},
|
||||
ar: {
|
||||
heroKicker: 'RentalDriveGo',
|
||||
heroTitle: 'واجهة سوق أوضح وأقوى.',
|
||||
heroBody:
|
||||
'شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.',
|
||||
startTrial: 'ابدأ التجربة المجانية',
|
||||
exploreVehicles: 'استكشف السيارات',
|
||||
surfaceLabel: 'واجهة السوق',
|
||||
surfaceTitle: 'مصممة لجمهورين في الوقت نفسه.',
|
||||
surfaceBody:
|
||||
'المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.',
|
||||
liveLabel: 'شبكة نشطة',
|
||||
trustedFleets: 'أساطيل موثوقة',
|
||||
brandedFlows: 'مسارات حجز مخصصة',
|
||||
multiTenant: 'عمليات متعددة الشركات',
|
||||
companyKicker: 'تدفق المشغل',
|
||||
companyTitle: 'تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.',
|
||||
companyBody:
|
||||
'انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.',
|
||||
renterKicker: 'تجربة المستأجر',
|
||||
renterTitle: 'دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.',
|
||||
renterBody:
|
||||
'السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.',
|
||||
pillars: [
|
||||
['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'],
|
||||
['اكتشاف مؤهل', 'العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة.'],
|
||||
['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه.'],
|
||||
],
|
||||
metrics: [
|
||||
['01', 'ظهور مشترك في السوق'],
|
||||
['02', 'دفع مباشر للشركة'],
|
||||
['03', 'مدفوعات مملوكة للشركة'],
|
||||
],
|
||||
featureLabel: 'ما الذي يغطيه المنتج',
|
||||
features: [
|
||||
'إدارة الأسطول مع رفع عدة صور',
|
||||
'عروض السوق والتحويل إلى مسار الحجز',
|
||||
'موقع حجز عام مخصص لكل شركة',
|
||||
'إدارة العملاء والتحليلات والفوترة',
|
||||
],
|
||||
stepsTitle: 'كيف تبدأ الشركات',
|
||||
steps: [
|
||||
['1', 'أنشئ مساحة شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك.'],
|
||||
['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'],
|
||||
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
|
||||
],
|
||||
stepLabel: 'الخطوة',
|
||||
readyKicker: 'جاهز للانطلاق',
|
||||
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
|
||||
readyBody:
|
||||
'استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.',
|
||||
viewPricing: 'عرض الأسعار',
|
||||
createWorkspace: 'إنشاء المساحة',
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function HomeContent() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY¤cy=MAD`
|
||||
|
||||
return (
|
||||
<main className="min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f8f5ef_0%,#f4efe6_28%,#fffdf8_58%,#ffffff_100%)] dark:bg-[linear-gradient(180deg,#14110f_0%,#17120d_35%,#0c0a09_100%)]">
|
||||
<section className="relative">
|
||||
<div className="absolute inset-x-0 top-0 h-[38rem] bg-[radial-gradient(circle_at_top_left,rgba(217,119,6,0.24),transparent_34%),radial-gradient(circle_at_top_right,rgba(15,118,110,0.18),transparent_26%)] dark:bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_34%),radial-gradient(circle_at_top_right,rgba(45,212,191,0.14),transparent_26%)]" />
|
||||
<div className="shell relative py-10 sm:py-14 lg:py-20">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_24rem] lg:items-stretch xl:grid-cols-[minmax(0,1.2fr)_28rem]">
|
||||
<div className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-stone-800 dark:bg-stone-900/70">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.32em] text-amber-700 dark:text-amber-300">{dict.heroKicker}</p>
|
||||
<h1 className="mt-5 max-w-4xl text-5xl font-black leading-none tracking-[-0.04em] text-stone-950 dark:text-stone-50 sm:text-6xl lg:text-7xl">
|
||||
{dict.heroTitle}
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-stone-600 dark:text-stone-300 sm:text-lg">
|
||||
{dict.heroBody}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<a
|
||||
href={starterSignupHref}
|
||||
className="rounded-full bg-stone-950 px-6 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
|
||||
>
|
||||
{dict.startTrial}
|
||||
</a>
|
||||
<a
|
||||
href="/explore"
|
||||
className="rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-950 hover:text-stone-950 dark:border-stone-700 dark:bg-stone-950/30 dark:text-stone-200 dark:hover:border-stone-200 dark:hover:text-white"
|
||||
>
|
||||
{dict.exploreVehicles}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-3 sm:grid-cols-3">
|
||||
{dict.metrics.map(([value, label]) => (
|
||||
<div key={value} className="rounded-[1.5rem] border border-stone-200/80 bg-stone-50/90 p-4 dark:border-stone-800 dark:bg-stone-950/50">
|
||||
<p className="text-xs font-bold tracking-[0.28em] text-stone-400 dark:text-stone-500">{value}</p>
|
||||
<p className="mt-3 text-sm font-semibold leading-6 text-stone-900 dark:text-stone-100">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-[2rem] border border-stone-200/80 bg-stone-950 p-6 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] dark:border-stone-700">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(245,158,11,0.34),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(20,184,166,0.25),transparent_32%)]" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
|
||||
{dict.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
{dict.liveLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-6 text-3xl font-black leading-tight tracking-[-0.03em]">{dict.surfaceTitle}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-300">{dict.surfaceBody}</p>
|
||||
|
||||
<div className="mt-8 space-y-3">
|
||||
{[
|
||||
['01', dict.trustedFleets],
|
||||
['02', dict.brandedFlows],
|
||||
['03', dict.multiTenant],
|
||||
].map(([step, label]) => (
|
||||
<div key={step} className="flex items-center justify-between rounded-[1.25rem] border border-white/10 bg-white/5 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-xs font-bold text-stone-950">
|
||||
{step}
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-white">{label}</p>
|
||||
</div>
|
||||
<span className="text-lg text-amber-300">+</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<article className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 backdrop-blur dark:border-stone-800 dark:bg-stone-900/60">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.companyKicker}</p>
|
||||
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-stone-950 dark:text-stone-50 sm:text-3xl">{dict.companyTitle}</h2>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-stone-600 dark:text-stone-300">{dict.companyBody}</p>
|
||||
</article>
|
||||
<article className="rounded-[2rem] border border-stone-200/80 bg-[#f7efe2] p-7 dark:border-stone-800 dark:bg-[#1d1712]">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-300">{dict.renterKicker}</p>
|
||||
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-stone-950 dark:text-stone-50 sm:text-3xl">{dict.renterTitle}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-700 dark:text-stone-300">{dict.renterBody}</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shell pb-10">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{dict.pillars.map(([title, body], index) => (
|
||||
<article
|
||||
key={title}
|
||||
className={`rounded-[2rem] border p-7 shadow-sm ${
|
||||
index === 1
|
||||
? 'border-stone-900 bg-stone-950 text-white dark:border-amber-300 dark:bg-amber-300 dark:text-stone-950'
|
||||
: 'border-stone-200 bg-white dark:border-stone-800 dark:bg-stone-900'
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-xs font-bold uppercase tracking-[0.26em] ${
|
||||
index === 1 ? 'text-stone-300 dark:text-stone-700' : 'text-stone-400 dark:text-stone-500'
|
||||
}`}
|
||||
>
|
||||
0{index + 1}
|
||||
</p>
|
||||
<h3 className="mt-4 text-2xl font-black tracking-[-0.03em]">{title}</h3>
|
||||
<p
|
||||
className={`mt-4 text-sm leading-7 ${
|
||||
index === 1 ? 'text-stone-200 dark:text-stone-800' : 'text-stone-600 dark:text-stone-300'
|
||||
}`}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shell py-10">
|
||||
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-[linear-gradient(160deg,#fffdf8_0%,#f1ebe1_100%)] p-8 dark:border-stone-800 dark:bg-[linear-gradient(160deg,#17120d_0%,#221a14_100%)]">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.featureLabel}</p>
|
||||
<div className="mt-6 space-y-3">
|
||||
{dict.features.map((item, index) => (
|
||||
<div key={item} className="flex items-start gap-4 rounded-[1.25rem] border border-stone-200/80 bg-white/85 px-4 py-4 dark:border-stone-700 dark:bg-stone-950/40">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-stone-950 text-xs font-bold text-white dark:bg-amber-300 dark:text-stone-950">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm font-semibold leading-6 text-stone-800 dark:text-stone-100">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm dark:border-stone-800 dark:bg-stone-900">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-300">{dict.readyKicker}</p>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.03em] text-stone-950 dark:text-stone-50">{dict.stepsTitle}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{dict.steps.map(([step, title, body]) => (
|
||||
<article key={step} className="rounded-[1.5rem] border border-stone-200 bg-stone-50 p-5 dark:border-stone-800 dark:bg-stone-950">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">
|
||||
{dict.stepLabel} {step}
|
||||
</p>
|
||||
<h3 className="mt-3 text-lg font-black text-stone-950 dark:text-stone-50">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600 dark:text-stone-300">{body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shell pb-16 pt-6 sm:pb-20">
|
||||
<div className="overflow-hidden rounded-[2rem] border border-stone-200 bg-stone-950 px-8 py-10 text-white dark:border-stone-700">
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-amber-300">{dict.readyKicker}</p>
|
||||
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight tracking-[-0.04em] sm:text-5xl">{dict.readyTitle}</h2>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-8 text-stone-300 sm:text-base">{dict.readyBody}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||
<a
|
||||
href="/pricing"
|
||||
className="rounded-full border border-white/20 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white hover:text-stone-950"
|
||||
>
|
||||
{dict.viewPricing}
|
||||
</a>
|
||||
<a
|
||||
href={starterSignupHref}
|
||||
className="rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-stone-950 transition hover:bg-amber-200"
|
||||
>
|
||||
{dict.createWorkspace}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function BrandedWebsitePage() {
|
||||
redirect('/explore')
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import WorkspaceFrame from '@/components/WorkspaceFrame'
|
||||
|
||||
export default function CompanyWorkspacePage() {
|
||||
return <WorkspaceFrame target="dashboard" />
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import Link from 'next/link'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
|
||||
interface CompanyProfile {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
brand: {
|
||||
displayName: string
|
||||
logoUrl: string | null
|
||||
publicCity: string | null
|
||||
publicCountry: string | null
|
||||
publicPhone: string | null
|
||||
whatsappNumber: string | null
|
||||
marketplaceRating: number | null
|
||||
} | null
|
||||
vehicles: Array<{ id: string; make: string; model: string; dailyRate: number; photos: string[]; category: string }>
|
||||
offers: Array<{ id: string; title: string; discountValue: number; validUntil: string }>
|
||||
}
|
||||
|
||||
export default async function CompanyProfilePage({ params }: { params: { slug: string } }) {
|
||||
const language = getMarketplaceLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
unavailable: 'Marketplace unavailable',
|
||||
unavailableTitle: 'Company details are temporarily unavailable.',
|
||||
unavailableBody: 'The marketplace API is running, but the database is not reachable in this local environment.',
|
||||
backToExplore: 'Back to explore',
|
||||
partner: 'Marketplace partner',
|
||||
vehicles: 'vehicles',
|
||||
offers: 'active offers',
|
||||
rating: 'Rating',
|
||||
new: 'New',
|
||||
currentOffers: 'Current offers',
|
||||
validUntil: 'Valid until',
|
||||
noOffers: 'No active offers right now.',
|
||||
fleet: 'Fleet',
|
||||
viewVehicle: 'View vehicle',
|
||||
},
|
||||
fr: {
|
||||
unavailable: 'Marketplace indisponible',
|
||||
unavailableTitle: 'Les détails de l’entreprise sont temporairement indisponibles.',
|
||||
unavailableBody: 'L’API marketplace fonctionne, mais la base de données n’est pas accessible dans cet environnement local.',
|
||||
backToExplore: 'Retour à explorer',
|
||||
partner: 'Partenaire marketplace',
|
||||
vehicles: 'véhicules',
|
||||
offers: 'offres actives',
|
||||
rating: 'Note',
|
||||
new: 'Nouveau',
|
||||
currentOffers: 'Offres actuelles',
|
||||
validUntil: 'Valable jusqu’au',
|
||||
noOffers: 'Aucune offre active pour le moment.',
|
||||
fleet: 'Flotte',
|
||||
viewVehicle: 'Voir le véhicule',
|
||||
},
|
||||
ar: {
|
||||
unavailable: 'السوق غير متاح',
|
||||
unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.',
|
||||
unavailableBody: 'واجهة marketplace تعمل، لكن قاعدة البيانات غير متاحة في هذا البيئة المحلية.',
|
||||
backToExplore: 'العودة إلى الاستكشاف',
|
||||
partner: 'شريك في السوق',
|
||||
vehicles: 'سيارات',
|
||||
offers: 'عروض نشطة',
|
||||
rating: 'التقييم',
|
||||
new: 'جديد',
|
||||
currentOffers: 'العروض الحالية',
|
||||
validUntil: 'صالح حتى',
|
||||
noOffers: 'لا توجد عروض نشطة حالياً.',
|
||||
fleet: 'الأسطول',
|
||||
viewVehicle: 'عرض السيارة',
|
||||
},
|
||||
}[language]
|
||||
const company = await marketplaceFetchOrDefault<CompanyProfile | null>(`/marketplace/${params.slug}`, null)
|
||||
|
||||
if (!company) {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell">
|
||||
<section className="card p-8">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-amber-700">{dict.unavailable}</p>
|
||||
<h1 className="mt-3 text-3xl font-black text-stone-900">{dict.unavailableTitle}</h1>
|
||||
<p className="mt-3 text-stone-600">{dict.unavailableBody}</p>
|
||||
<div className="mt-6">
|
||||
<Link href="/explore" className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.backToExplore}</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell space-y-8">
|
||||
<section className="card p-8">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-amber-700">{company.brand?.publicCity ?? dict.partner}</p>
|
||||
<h1 className="mt-3 text-4xl font-black text-stone-900">{company.brand?.displayName ?? company.name}</h1>
|
||||
<p className="mt-3 text-stone-600">{company.brand?.publicCountry ?? ''}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-stone-500">
|
||||
<span>{company.vehicles.length} {dict.vehicles}</span>
|
||||
<span>{company.offers.length} {dict.offers}</span>
|
||||
<span>{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-stone-900">{dict.currentOffers}</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{company.offers.map((offer) => (
|
||||
<div key={offer.id} className="card p-5">
|
||||
<p className="text-sm font-semibold text-stone-900">{offer.title}</p>
|
||||
<p className="mt-2 text-3xl font-black text-amber-700">{offer.discountValue}%</p>
|
||||
<p className="mt-2 text-sm text-stone-500">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
|
||||
</div>
|
||||
))}
|
||||
{company.offers.length === 0 && <div className="card p-6 text-sm text-stone-500">{dict.noOffers}</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-stone-900">{dict.fleet}</h2>
|
||||
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{company.vehicles.map((vehicle) => (
|
||||
<article key={vehicle.id} className="card overflow-hidden">
|
||||
<div className="aspect-[16/10] bg-stone-100">
|
||||
{vehicle.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<h3 className="text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">{vehicle.category}</p>
|
||||
<div className="mt-4 flex items-end justify-between">
|
||||
<p className="text-xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
|
||||
<Link href={`/explore/${params.slug}/vehicles/${vehicle.id}`} className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.viewVehicle}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Link from 'next/link'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
|
||||
interface VehicleDetail {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
seats: number
|
||||
transmission: string
|
||||
fuelType: string
|
||||
features: string[]
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
company: {
|
||||
brand: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
publicPhone: string | null
|
||||
whatsappNumber: string | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
export default async function VehicleDetailPage({ params }: { params: { slug: string; id: string } }) {
|
||||
const language = getMarketplaceLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
unavailable: 'Marketplace unavailable',
|
||||
unavailableTitle: 'Vehicle details are temporarily unavailable.',
|
||||
unavailableBody: 'The marketplace frontend is running, but the backing database is not reachable right now.',
|
||||
backToFleet: 'Back to company fleet',
|
||||
noPhotos: 'No photos available.',
|
||||
rentalCompany: 'Rental company',
|
||||
perDay: '/ day',
|
||||
continueTo: 'Continue to',
|
||||
bookingSite: 'booking site',
|
||||
},
|
||||
fr: {
|
||||
unavailable: 'Marketplace indisponible',
|
||||
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
|
||||
unavailableBody: 'Le frontend marketplace fonctionne, mais la base de données n’est pas accessible pour le moment.',
|
||||
backToFleet: 'Retour à la flotte',
|
||||
noPhotos: 'Aucune photo disponible.',
|
||||
rentalCompany: 'Société de location',
|
||||
perDay: '/ jour',
|
||||
continueTo: 'Continuer vers le site de réservation de',
|
||||
bookingSite: '',
|
||||
},
|
||||
ar: {
|
||||
unavailable: 'السوق غير متاح',
|
||||
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
|
||||
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.',
|
||||
backToFleet: 'العودة إلى أسطول الشركة',
|
||||
noPhotos: 'لا توجد صور متاحة.',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
perDay: '/ يوم',
|
||||
continueTo: 'الانتقال إلى موقع حجز',
|
||||
bookingSite: '',
|
||||
},
|
||||
}[language]
|
||||
const vehicle = await marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${params.slug}/vehicles/${params.id}`, null)
|
||||
|
||||
if (!vehicle) {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell">
|
||||
<section className="card p-8">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-amber-700">{dict.unavailable}</p>
|
||||
<h1 className="mt-3 text-3xl font-black text-stone-900">{dict.unavailableTitle}</h1>
|
||||
<p className="mt-3 text-stone-600">{dict.unavailableBody}</p>
|
||||
<div className="mt-6">
|
||||
<Link href={`/explore/${params.slug}`} className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.backToFleet}</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const destination = `https://${params.slug}.RentalDriveGo.com/book?vehicleId=${vehicle.id}&ref=marketplace`
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<section className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{vehicle.photos.map((photo, index) => (
|
||||
<div key={`${photo}-${index}`} className="card overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 1}`} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
{vehicle.photos.length === 0 && <div className="card p-8 text-sm text-stone-500">{dict.noPhotos}</div>}
|
||||
</div>
|
||||
</section>
|
||||
<aside className="card p-6 h-fit">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h1 className="mt-3 text-4xl font-black text-stone-900">{vehicle.make} {vehicle.model}</h1>
|
||||
<p className="mt-3 text-stone-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
|
||||
<p className="mt-6 text-3xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-stone-500"> {dict.perDay}</span></p>
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{vehicle.features.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-700">{feature}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 space-y-3">
|
||||
<Link href={destination} className="block rounded-full bg-stone-900 px-6 py-3 text-center text-sm font-semibold text-white">{dict.continueTo} {vehicle.company.brand?.displayName ?? dict.rentalCompany} {dict.bookingSite}</Link>
|
||||
<Link href={`/explore/${params.slug}`} className="block rounded-full border border-stone-300 px-6 py-3 text-center text-sm font-semibold text-stone-700">{dict.backToFleet}</Link>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import Link from 'next/link'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
|
||||
interface Vehicle {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
availability?: boolean | 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: 'Every listing links you to the company’s own booking and payment flow.',
|
||||
city: 'City or location',
|
||||
allCategories: 'All categories',
|
||||
anyTransmission: 'Any transmission',
|
||||
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',
|
||||
from: 'From',
|
||||
bookNow: 'Book now',
|
||||
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'],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Découverte uniquement',
|
||||
title: 'Trouvez votre prochaine location auprès d’entreprises locales fiables.',
|
||||
body: 'Chaque annonce vous redirige vers le parcours de réservation et de paiement propre à l’entreprise.',
|
||||
city: 'Ville ou emplacement',
|
||||
allCategories: 'Toutes les catégories',
|
||||
anyTransmission: 'Toute transmission',
|
||||
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',
|
||||
from: 'À partir de',
|
||||
bookNow: 'Réserver',
|
||||
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'],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'للاستكشاف فقط',
|
||||
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
|
||||
body: 'كل إعلان يوجّهك إلى مسار الحجز والدفع الخاص بالشركة نفسها.',
|
||||
city: 'المدينة أو الموقع',
|
||||
allCategories: 'كل الفئات',
|
||||
anyTransmission: 'أي ناقل حركة',
|
||||
search: 'بحث',
|
||||
currentDeals: 'العروض الحالية',
|
||||
featuredOffers: 'عروض السوق المميزة',
|
||||
company: 'شركة',
|
||||
validUntil: 'صالح حتى',
|
||||
offersUnavailable: 'عروض السوق غير متاحة حالياً.',
|
||||
availableVehicles: 'السيارات المتاحة',
|
||||
listings: 'إعلانات',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
checkAvailability: 'تحقق من التوفر',
|
||||
available: 'متاح',
|
||||
from: 'ابتداءً من',
|
||||
bookNow: 'احجز الآن',
|
||||
details: 'التفاصيل',
|
||||
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
||||
browseByCategory: 'تصفح حسب الفئة',
|
||||
browseByCompany: 'تصفح حسب الشركة',
|
||||
marketplacePartners: 'شركاء السوق',
|
||||
marketplacePartner: 'شريك في السوق',
|
||||
rating: 'التقييم',
|
||||
new: 'جديد',
|
||||
publishedVehicles: 'سيارات منشورة',
|
||||
viewFleet: 'عرض الأسطول',
|
||||
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
|
||||
},
|
||||
}[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 query = new URLSearchParams()
|
||||
if (city) query.set('city', city)
|
||||
if (category) query.set('category', category)
|
||||
if (transmission) query.set('transmission', transmission)
|
||||
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-4">
|
||||
<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" />
|
||||
<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', '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>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900">{dict.availableVehicles}</h2>
|
||||
<p className="text-sm text-stone-500">{vehicles.length} {dict.listings}</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{vehicles.map((vehicle) => (
|
||||
<article key={vehicle.id} className="card overflow-hidden">
|
||||
<div className="aspect-[16/10] bg-stone-100">
|
||||
{vehicle.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">{vehicle.year} · {vehicle.category}</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'}`}>
|
||||
{vehicle.availability === false ? dict.checkAvailability : dict.available}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-5 flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-500">{dict.from}</p>
|
||||
<p className="text-2xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href={`https://${vehicle.company.brand?.subdomain ?? vehicle.company.slug}.RentalDriveGo.com/book?vehicleId=${vehicle.id}&ref=marketplace`}
|
||||
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white"
|
||||
>
|
||||
{dict.bookNow}
|
||||
</a>
|
||||
<Link href={`/explore/${vehicle.company.slug}/vehicles/${vehicle.id}`} className="rounded-full border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-700">
|
||||
{dict.details}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{vehicles.length === 0 && <div className="card p-6 text-sm text-stone-500 md:col-span-2 xl:col-span-3">{dict.vehicleUnavailable}</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
kicker: 'Features',
|
||||
title: 'Operations, marketplace discovery, and branded booking.',
|
||||
body: 'RentalDriveGo combines company-only operations with a public marketplace that sends renters to each company’s own payment flow.',
|
||||
cards: [
|
||||
['Private dashboard', 'Fleet, reservations, CRM, team permissions, billing, and reports stay isolated per company.'],
|
||||
['Marketplace discovery', 'Published vehicles and public offers appear on `/explore` for cross-company browsing.'],
|
||||
['Branded booking site', 'Each company gets its own subdomain where renters complete booking and payment directly.'],
|
||||
['Payment flexibility', 'Use AmanPay or PayPal for subscriptions and for company-side renter payments.'],
|
||||
['Advanced rental ops', 'Insurance, additional drivers, pricing rules, and structured fuel policies live in dashboard settings.'],
|
||||
['Notification controls', 'Both company staff and renters can manage event-by-channel notification preferences.'],
|
||||
],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Fonctionnalités',
|
||||
title: 'Opérations, découverte marketplace et réservation de marque.',
|
||||
body: 'RentalDriveGo combine des opérations privées pour l’entreprise avec une marketplace publique qui envoie les clients vers le flux de paiement propre à chaque société.',
|
||||
cards: [
|
||||
['Tableau de bord privé', 'Flotte, réservations, CRM, permissions d’équipe, facturation et rapports restent isolés par entreprise.'],
|
||||
['Découverte marketplace', 'Les véhicules publiés et les offres publiques apparaissent sur `/explore` pour une navigation multi-entreprises.'],
|
||||
['Site de réservation de marque', 'Chaque entreprise dispose de son propre sous-domaine où les clients terminent réservation et paiement.'],
|
||||
['Flexibilité de paiement', 'Utilisez AmanPay ou PayPal pour les abonnements et pour les paiements côté entreprise.'],
|
||||
['Opérations avancées', 'Assurance, conducteurs supplémentaires, règles tarifaires et politiques carburant structurées vivent dans le dashboard.'],
|
||||
['Contrôle des notifications', 'Le personnel comme les clients peuvent gérer leurs préférences de notification par événement et canal.'],
|
||||
],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'المزايا',
|
||||
title: 'العمليات، اكتشاف السوق، والحجز المخصص.',
|
||||
body: 'يجمع RentalDriveGo بين عمليات الشركة الخاصة وسوق عام يوجّه المستأجرين إلى مسار الدفع الخاص بكل شركة.',
|
||||
cards: [
|
||||
['لوحة تحكم خاصة', 'الأسطول والحجوزات وCRM وصلاحيات الفريق والفوترة والتقارير تبقى معزولة لكل شركة.'],
|
||||
['اكتشاف عبر السوق', 'تظهر السيارات المنشورة والعروض العامة في `/explore` للتصفح بين الشركات.'],
|
||||
['موقع حجز مخصص', 'تحصل كل شركة على نطاق فرعي خاص بها لإتمام الحجز والدفع مباشرة.'],
|
||||
['مرونة الدفع', 'استخدم AmanPay أو PayPal للاشتراكات ومدفوعات المستأجرين من جهة الشركة.'],
|
||||
['عمليات متقدمة', 'التأمين والسائقون الإضافيون وقواعد التسعير وسياسات الوقود المنظمة موجودة في إعدادات اللوحة.'],
|
||||
['التحكم في الإشعارات', 'يمكن للموظفين والمستأجرين إدارة تفضيلات الإشعارات حسب الحدث والقناة.'],
|
||||
],
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function FeaturesPage() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50">
|
||||
<div className="shell space-y-12 py-16">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">{dict.kicker}</p>
|
||||
<h1 className="mt-4 text-5xl font-black tracking-tight text-stone-900">{dict.title}</h1>
|
||||
<p className="mt-5 text-lg leading-8 text-stone-600">{dict.body}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{dict.cards.map(([title, body]) => (
|
||||
<article key={title} className="card p-8">
|
||||
<h2 className="text-xl font-black text-stone-900">{title}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-600">{body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-stone-50 text-stone-900 antialiased transition-colors dark:bg-stone-950 dark:text-stone-100;
|
||||
}
|
||||
|
||||
.shell {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-2xl border border-stone-200 bg-white shadow-sm transition-colors dark:border-stone-800 dark:bg-stone-900;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next'
|
||||
import MarketplaceShell from '@/components/MarketplaceShell'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Marketplace',
|
||||
description: 'Discover vehicles from trusted rental companies.',
|
||||
icons: {
|
||||
icon: '/rentalcardrive.png',
|
||||
shortcut: '/favicon.ico',
|
||||
apple: '/rentalcardrive.png',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body suppressHydrationWarning>
|
||||
<MarketplaceShell>{children}</MarketplaceShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import HomeContent from './HomeContent'
|
||||
|
||||
export default function HomePage() {
|
||||
return <HomeContent />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import WorkspaceFrame from '@/components/WorkspaceFrame'
|
||||
|
||||
export default function PlatformOperationsPage() {
|
||||
return <WorkspaceFrame target="admin" />
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
|
||||
type Currency = 'MAD' | 'USD' | 'EUR'
|
||||
type Billing = 'monthly' | 'annual'
|
||||
|
||||
const SYMBOL: Record<Currency, string> = { MAD: 'MAD', USD: '$', EUR: '€' }
|
||||
|
||||
const PRICES: Record<string, Record<Currency, { monthly: number; annual: number }>> = {
|
||||
STARTER: {
|
||||
MAD: { monthly: 299, annual: 2990 },
|
||||
USD: { monthly: 29, annual: 290 },
|
||||
EUR: { monthly: 27, annual: 270 },
|
||||
},
|
||||
GROWTH: {
|
||||
MAD: { monthly: 599, annual: 5990 },
|
||||
USD: { monthly: 59, annual: 590 },
|
||||
EUR: { monthly: 55, annual: 550 },
|
||||
},
|
||||
PRO: {
|
||||
MAD: { monthly: 999, annual: 9990 },
|
||||
USD: { monthly: 99, annual: 990 },
|
||||
EUR: { monthly: 92, annual: 920 },
|
||||
},
|
||||
}
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
key: 'STARTER',
|
||||
name: 'Starter',
|
||||
tagline: 'Launch your fleet online',
|
||||
features: [
|
||||
'Up to 10 vehicles',
|
||||
'1 user account',
|
||||
'Basic analytics',
|
||||
'Marketplace listing',
|
||||
],
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
key: 'GROWTH',
|
||||
name: 'Growth',
|
||||
tagline: 'Scale with confidence',
|
||||
features: [
|
||||
'Up to 50 vehicles',
|
||||
'5 user accounts',
|
||||
'Full analytics',
|
||||
'Priority marketplace placement',
|
||||
'Custom branding',
|
||||
],
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
key: 'PRO',
|
||||
name: 'Pro',
|
||||
tagline: 'Enterprise-grade power',
|
||||
features: [
|
||||
'Unlimited vehicles',
|
||||
'Unlimited user accounts',
|
||||
'Advanced reports',
|
||||
'API access',
|
||||
'Dedicated support',
|
||||
],
|
||||
highlight: false,
|
||||
},
|
||||
]
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
monthly: 'Monthly',
|
||||
annual: 'Annual',
|
||||
yearly: '/ year',
|
||||
youSave: 'You save',
|
||||
withAnnual: 'with annual billing — billed as one payment per year.',
|
||||
mostPopular: 'Most popular',
|
||||
perMonth: '/mo',
|
||||
billedAs: 'Billed as',
|
||||
getStarted: 'Get started',
|
||||
footer: 'All plans include a 14-day free trial. No credit card required.',
|
||||
},
|
||||
fr: {
|
||||
monthly: 'Mensuel',
|
||||
annual: 'Annuel',
|
||||
yearly: '/ an',
|
||||
youSave: 'Vous économisez',
|
||||
withAnnual: 'avec la facturation annuelle — prélevée en un seul paiement par an.',
|
||||
mostPopular: 'Le plus populaire',
|
||||
perMonth: '/mois',
|
||||
billedAs: 'Facturé à',
|
||||
getStarted: 'Commencer',
|
||||
footer: 'Tous les plans incluent un essai gratuit de 14 jours. Aucune carte bancaire requise.',
|
||||
},
|
||||
ar: {
|
||||
monthly: 'شهري',
|
||||
annual: 'سنوي',
|
||||
yearly: '/ سنة',
|
||||
youSave: 'توفر',
|
||||
withAnnual: 'مع الفوترة السنوية — دفعة واحدة كل سنة.',
|
||||
mostPopular: 'الأكثر شيوعاً',
|
||||
perMonth: '/شهر',
|
||||
billedAs: 'يتم الفوترة بمبلغ',
|
||||
getStarted: 'ابدأ الآن',
|
||||
footer: 'جميع الخطط تشمل تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.',
|
||||
},
|
||||
} as const
|
||||
|
||||
function fmt(value: number, currency: Currency, billing: Billing): string {
|
||||
const sym = SYMBOL[currency]
|
||||
const amount = billing === 'annual' ? Math.round(value / 12) : value
|
||||
return currency === 'MAD' ? `${amount} ${sym}` : `${sym}${amount}`
|
||||
}
|
||||
|
||||
function annualSavingsPct(plan: string, currency: Currency): number {
|
||||
const { monthly, annual } = PRICES[plan][currency]
|
||||
const wouldPay = monthly * 12
|
||||
return Math.round(((wouldPay - annual) / wouldPay) * 100)
|
||||
}
|
||||
|
||||
export default function PricingClient() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
const [currency, setCurrency] = useState<Currency>('MAD')
|
||||
const [billing, setBilling] = useState<Billing>('monthly')
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
|
||||
const savings = annualSavingsPct('STARTER', currency) // same % for all plans
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Toggles */}
|
||||
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
|
||||
{/* Billing toggle */}
|
||||
<div className="flex items-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm">
|
||||
{(['monthly', 'annual'] as Billing[]).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setBilling(b)}
|
||||
className={`relative rounded-full px-5 py-2 text-sm font-semibold transition-colors ${
|
||||
billing === b
|
||||
? 'bg-stone-900 text-white'
|
||||
: 'text-stone-600 hover:text-stone-900'
|
||||
}`}
|
||||
>
|
||||
{b === 'monthly' ? dict.monthly : dict.annual}
|
||||
{b === 'annual' && billing !== 'annual' && (
|
||||
<span className="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-700">
|
||||
-{savings}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Currency toggle */}
|
||||
<div className="flex items-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm">
|
||||
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCurrency(c)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
currency === c
|
||||
? 'bg-amber-500 text-white'
|
||||
: 'text-stone-600 hover:text-stone-900'
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{billing === 'annual' && (
|
||||
<p className="text-center text-sm text-amber-700 font-medium">
|
||||
{dict.youSave} {savings}% {dict.withAnnual}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{PLANS.map((plan) => {
|
||||
const price = PRICES[plan.key][currency]
|
||||
const displayPrice = billing === 'annual'
|
||||
? Math.round(price.annual / 12)
|
||||
: price.monthly
|
||||
const sym = SYMBOL[currency]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.key}
|
||||
className={`card relative flex flex-col overflow-hidden ${
|
||||
plan.highlight
|
||||
? 'border-amber-400 ring-2 ring-amber-400 ring-offset-2'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{plan.highlight && (
|
||||
<div className="bg-amber-500 py-1.5 text-center text-xs font-bold uppercase tracking-widest text-white">
|
||||
{dict.mostPopular}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-amber-700">
|
||||
{plan.name}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-500">{plan.tagline}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-end gap-1">
|
||||
<span className="text-4xl font-black text-stone-900">
|
||||
{currency === 'MAD' ? displayPrice : `${sym}${displayPrice}`}
|
||||
</span>
|
||||
{currency === 'MAD' && (
|
||||
<span className="mb-1 text-lg font-semibold text-stone-500">{sym}</span>
|
||||
)}
|
||||
<span className="mb-1 text-sm text-stone-400">{dict.perMonth}</span>
|
||||
</div>
|
||||
{billing === 'annual' && (
|
||||
<p className="mt-1 text-xs text-stone-400">
|
||||
{dict.billedAs}{' '}
|
||||
{currency === 'MAD'
|
||||
? `${price.annual} ${sym}`
|
||||
: `${sym}${price.annual}`}{' '}
|
||||
{dict.yearly}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 flex-1 space-y-3">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2.5 text-sm text-stone-700">
|
||||
<svg
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-amber-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}¤cy=${currency}`}
|
||||
className={`mt-10 block rounded-full px-6 py-3 text-center text-sm font-semibold transition-colors ${
|
||||
plan.highlight
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-stone-900 text-white hover:bg-stone-700'
|
||||
}`}
|
||||
>
|
||||
{dict.getStarted}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<p className="text-center text-sm text-stone-400">{dict.footer}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import PricingClient from './PricingClient'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
kicker: 'Pricing',
|
||||
title: 'One platform, every fleet size.',
|
||||
body: 'Start free for 14 days, then choose the plan that grows with your business. Switch plans or cancel at any time.',
|
||||
faq: 'Frequently asked',
|
||||
items: [
|
||||
['Can I change plans later?', 'Yes. Upgrade or downgrade at any time — changes take effect on your next billing cycle.'],
|
||||
['What counts as a vehicle?', 'Any vehicle record added to your fleet dashboard, active or not.'],
|
||||
['Is there a setup fee?', 'No setup fee, ever. You only pay the monthly or annual subscription.'],
|
||||
['How does the marketplace listing work?', 'All paid plans include a public marketplace listing on RentalDriveGo. Renters can discover your fleet and are redirected to your branded booking site.'],
|
||||
],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Tarifs',
|
||||
title: 'Une seule plateforme pour toutes les tailles de flotte.',
|
||||
body: 'Commencez gratuitement pendant 14 jours puis choisissez le plan qui accompagne votre activité. Changez ou annulez à tout moment.',
|
||||
faq: 'Questions fréquentes',
|
||||
items: [
|
||||
['Puis-je changer de plan plus tard ?', 'Oui. Vous pouvez monter ou descendre à tout moment — les changements prennent effet au prochain cycle de facturation.'],
|
||||
['Qu’est-ce qui compte comme véhicule ?', 'Tout véhicule ajouté à votre tableau de bord flotte, actif ou non.'],
|
||||
['Y a-t-il des frais d’installation ?', 'Aucun frais d’installation. Vous payez uniquement l’abonnement mensuel ou annuel.'],
|
||||
['Comment fonctionne la visibilité marketplace ?', 'Tous les plans payants incluent une présence publique sur RentalDriveGo. Les clients découvrent votre flotte puis sont redirigés vers votre site de réservation.'],
|
||||
],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'الأسعار',
|
||||
title: 'منصة واحدة لكل أحجام الأساطيل.',
|
||||
body: 'ابدأ مجاناً لمدة 14 يوماً ثم اختر الخطة التي تنمو مع نشاطك. يمكنك تغيير الخطة أو الإلغاء في أي وقت.',
|
||||
faq: 'الأسئلة الشائعة',
|
||||
items: [
|
||||
['هل يمكنني تغيير الخطة لاحقاً؟', 'نعم. يمكنك الترقية أو التخفيض في أي وقت — وتدخل التغييرات حيز التنفيذ في دورة الفوترة التالية.'],
|
||||
['ما الذي يُحتسب كسيارة؟', 'أي سجل سيارة تتم إضافته إلى لوحة الأسطول سواء كان نشطاً أم لا.'],
|
||||
['هل توجد رسوم إعداد؟', 'لا توجد أي رسوم إعداد. أنت تدفع فقط الاشتراك الشهري أو السنوي.'],
|
||||
['كيف تعمل قائمة السوق؟', 'كل الخطط المدفوعة تشمل الظهور العام على RentalDriveGo. يكتشف المستأجرون أسطولك ثم يتم تحويلهم إلى موقع الحجز الخاص بك.'],
|
||||
],
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function PricingPageContent() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,#fef3c7,transparent_30%),linear-gradient(180deg,#fafaf9,white)]">
|
||||
<div className="shell py-20">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">{dict.kicker}</p>
|
||||
<h1 className="mt-4 text-5xl font-black tracking-tight text-stone-900">{dict.title}</h1>
|
||||
<p className="mt-5 text-lg leading-8 text-stone-600">{dict.body}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16">
|
||||
<PricingClient />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-24 max-w-3xl divide-y divide-stone-200">
|
||||
<h2 className="pb-8 text-2xl font-bold text-stone-900">{dict.faq}</h2>
|
||||
{dict.items.map(([q, a]) => (
|
||||
<div key={q} className="py-6">
|
||||
<p className="font-semibold text-stone-900">{q}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-600">{a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
import PricingPageContent from './PricingPageContent'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pricing — RentalDriveGo',
|
||||
description: 'Simple, transparent pricing for every fleet size.',
|
||||
}
|
||||
|
||||
export default function PricingPage() {
|
||||
return <PricingPageContent />
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
interface SavedCompany {
|
||||
id: string
|
||||
brand?: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
logoUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface RenterProfile {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
savedCompanies?: SavedCompany[]
|
||||
}
|
||||
|
||||
type Status = 'loading' | 'ready' | 'error'
|
||||
|
||||
export default function RenterDashboardPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
loadProfile: 'Could not load profile.',
|
||||
reachServer: 'Could not reach the server.',
|
||||
backToExplore: 'Back to explore',
|
||||
renterDashboard: 'Renter Dashboard',
|
||||
welcome: 'Welcome back,',
|
||||
exploreVehicles: 'Explore vehicles',
|
||||
logout: 'Log out',
|
||||
cards: [
|
||||
['Profile', 'Review your account details and preferences.'],
|
||||
['Saved companies', 'Jump back into companies you bookmarked.'],
|
||||
['Notifications', 'See booking updates and in-app alerts.'],
|
||||
],
|
||||
yourProfile: 'Your profile',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
savedCompanies: 'Saved companies',
|
||||
noneSaved: 'None saved yet',
|
||||
saved: 'saved',
|
||||
saveCompaniesPrompt: 'Save companies you like while browsing the marketplace.',
|
||||
startExploring: 'Start exploring',
|
||||
rentalCompany: 'Rental company',
|
||||
},
|
||||
fr: {
|
||||
loadProfile: 'Impossible de charger le profil.',
|
||||
reachServer: 'Impossible de joindre le serveur.',
|
||||
backToExplore: 'Retour à explorer',
|
||||
renterDashboard: 'Espace client',
|
||||
welcome: 'Bon retour,',
|
||||
exploreVehicles: 'Explorer les véhicules',
|
||||
logout: 'Déconnexion',
|
||||
cards: [
|
||||
['Profil', 'Consultez vos informations et préférences.'],
|
||||
['Entreprises sauvegardées', 'Revenez vers les entreprises que vous avez enregistrées.'],
|
||||
['Notifications', 'Consultez les mises à jour de réservation et les alertes.'],
|
||||
],
|
||||
yourProfile: 'Votre profil',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'Email',
|
||||
phone: 'Téléphone',
|
||||
savedCompanies: 'Entreprises sauvegardées',
|
||||
noneSaved: 'Aucune pour le moment',
|
||||
saved: 'sauvegardées',
|
||||
saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la marketplace.',
|
||||
startExploring: 'Commencer à explorer',
|
||||
rentalCompany: 'Société de location',
|
||||
},
|
||||
ar: {
|
||||
loadProfile: 'تعذر تحميل الملف الشخصي.',
|
||||
reachServer: 'تعذر الوصول إلى الخادم.',
|
||||
backToExplore: 'العودة إلى الاستكشاف',
|
||||
renterDashboard: 'لوحة المستأجر',
|
||||
welcome: 'مرحباً بعودتك،',
|
||||
exploreVehicles: 'استكشف السيارات',
|
||||
logout: 'تسجيل الخروج',
|
||||
cards: [
|
||||
['الملف الشخصي', 'راجع بيانات حسابك وتفضيلاتك.'],
|
||||
['الشركات المحفوظة', 'ارجع بسرعة إلى الشركات التي حفظتها.'],
|
||||
['الإشعارات', 'اطلع على تحديثات الحجز والتنبيهات داخل التطبيق.'],
|
||||
],
|
||||
yourProfile: 'ملفك الشخصي',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
savedCompanies: 'الشركات المحفوظة',
|
||||
noneSaved: 'لا يوجد شيء محفوظ بعد',
|
||||
saved: 'محفوظة',
|
||||
saveCompaniesPrompt: 'احفظ الشركات التي تعجبك أثناء تصفح السوق.',
|
||||
startExploring: 'ابدأ الاستكشاف',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [profile, setProfile] = useState<RenterProfile | null>(null)
|
||||
const [status, setStatus] = useState<Status>('loading')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('renter_token')
|
||||
if (!token) {
|
||||
router.replace('/renter/sign-in')
|
||||
return
|
||||
}
|
||||
|
||||
fetch(`${API_BASE}/auth/renter/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(async (res) => {
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('renter_token')
|
||||
router.replace('/renter/sign-in')
|
||||
} else {
|
||||
setErrorMsg(json?.message ?? dict.loadProfile)
|
||||
setStatus('error')
|
||||
}
|
||||
return
|
||||
}
|
||||
setProfile(json?.data ?? json)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorMsg(dict.reachServer)
|
||||
setStatus('error')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('renter_token')
|
||||
router.push('/explore')
|
||||
}
|
||||
|
||||
// Loading skeleton
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 w-48 rounded-xl bg-stone-200" />
|
||||
<div className="h-40 rounded-2xl bg-stone-200" />
|
||||
<div className="h-40 rounded-2xl bg-stone-200" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell">
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-sm text-rose-600">{errorMsg}</p>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-4 rounded-full bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white"
|
||||
>
|
||||
{dict.backToExplore}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const savedCompanies = profile?.savedCompanies ?? []
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">
|
||||
{dict.renterDashboard}
|
||||
</p>
|
||||
<h1 className="mt-1 text-3xl font-black tracking-tight text-stone-900">
|
||||
{dict.welcome} {profile?.firstName}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/explore"
|
||||
className="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}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white hover:bg-stone-700"
|
||||
>
|
||||
{dict.logout}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ href: '/renter/profile', label: dict.cards[0][0], copy: dict.cards[0][1] },
|
||||
{ href: '/renter/saved-companies', label: dict.cards[1][0], copy: dict.cards[1][1] },
|
||||
{ href: '/renter/notifications', label: dict.cards[2][0], copy: dict.cards[2][1] },
|
||||
].map((item) => (
|
||||
<Link key={item.href} href={item.href} className="card p-5 hover:border-amber-300 transition-colors">
|
||||
<p className="text-sm font-semibold text-stone-900">{item.label}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-500">{item.copy}</p>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Profile card */}
|
||||
<section className="card p-8">
|
||||
<h2 className="text-lg font-bold text-stone-900">{dict.yourProfile}</h2>
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{ label: dict.firstName, value: profile?.firstName },
|
||||
{ label: dict.lastName, value: profile?.lastName },
|
||||
{ label: dict.email, value: profile?.email },
|
||||
{ label: dict.phone, value: profile?.phone ?? '—' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1.5 text-sm font-medium text-stone-900 break-all">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Saved companies */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900">{dict.savedCompanies}</h2>
|
||||
<p className="text-sm text-stone-500">
|
||||
{savedCompanies.length === 0
|
||||
? dict.noneSaved
|
||||
: `${savedCompanies.length} ${dict.saved}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{savedCompanies.length === 0 ? (
|
||||
<div className="card mt-4 flex flex-col items-center gap-4 py-16 text-center">
|
||||
<svg
|
||||
className="h-10 w-10 text-stone-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-stone-500">
|
||||
{dict.saveCompaniesPrompt}
|
||||
</p>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="rounded-full bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white hover:bg-stone-700"
|
||||
>
|
||||
{dict.startExploring}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{savedCompanies.map((company) => (
|
||||
<Link
|
||||
key={company.id}
|
||||
href={`/explore/${company.brand?.subdomain ?? company.id}`}
|
||||
className="card flex items-center gap-4 p-5 hover:border-amber-300 transition-colors"
|
||||
>
|
||||
{company.brand?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={company.brand.logoUrl}
|
||||
alt={company.brand.displayName}
|
||||
className="h-10 w-10 rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100 text-amber-700 font-bold text-sm">
|
||||
{company.brand?.displayName?.charAt(0) ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-stone-900">
|
||||
{company.brand?.displayName ?? dict.rentalCompany}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-stone-400">
|
||||
{company.brand?.subdomain ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
<svg
|
||||
className="ml-auto h-4 w-4 shrink-0 text-stone-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
RenterAuthError,
|
||||
loadRenterNotifications,
|
||||
markAllRenterNotificationsRead,
|
||||
markRenterNotificationRead,
|
||||
type RenterNotification,
|
||||
} from '@/lib/renter'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
|
||||
export default function RenterNotificationsPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
load: 'Could not load notifications.',
|
||||
updateOne: 'Could not update the notification.',
|
||||
updateAll: 'Could not update notifications.',
|
||||
renter: 'Renter',
|
||||
title: 'Notifications',
|
||||
markAll: 'Mark all read',
|
||||
back: 'Back to dashboard',
|
||||
loading: 'Loading notifications…',
|
||||
empty: 'No renter notifications yet.',
|
||||
read: 'Read',
|
||||
markRead: 'Mark read',
|
||||
},
|
||||
fr: {
|
||||
load: 'Impossible de charger les notifications.',
|
||||
updateOne: 'Impossible de mettre à jour la notification.',
|
||||
updateAll: 'Impossible de mettre à jour les notifications.',
|
||||
renter: 'Client',
|
||||
title: 'Notifications',
|
||||
markAll: 'Tout marquer comme lu',
|
||||
back: 'Retour au dashboard',
|
||||
loading: 'Chargement des notifications…',
|
||||
empty: 'Aucune notification client pour le moment.',
|
||||
read: 'Lu',
|
||||
markRead: 'Marquer comme lu',
|
||||
},
|
||||
ar: {
|
||||
load: 'تعذر تحميل الإشعارات.',
|
||||
updateOne: 'تعذر تحديث الإشعار.',
|
||||
updateAll: 'تعذر تحديث الإشعارات.',
|
||||
renter: 'المستأجر',
|
||||
title: 'الإشعارات',
|
||||
markAll: 'تحديد الكل كمقروء',
|
||||
back: 'العودة إلى اللوحة',
|
||||
loading: 'جارٍ تحميل الإشعارات…',
|
||||
empty: 'لا توجد إشعارات للمستأجر حالياً.',
|
||||
read: 'مقروء',
|
||||
markRead: 'تحديد كمقروء',
|
||||
},
|
||||
}[language]
|
||||
const [notifications, setNotifications] = useState<RenterNotification[]>([])
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadRenterNotifications()
|
||||
.then((items) => setNotifications(items))
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/renter/sign-in')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.load)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
async function markRead(id: string) {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await markRenterNotificationRead(id)
|
||||
setNotifications((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, readAt: item.readAt ?? new Date().toISOString() } : item)),
|
||||
)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.updateOne)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await markAllRenterNotificationsRead()
|
||||
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.updateAll)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">{dict.renter}</p>
|
||||
<h1 className="mt-1 text-3xl font-black tracking-tight text-stone-900">{dict.title}</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllRead}
|
||||
disabled={loading || updating}
|
||||
className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700"
|
||||
>
|
||||
{dict.markAll}
|
||||
</button>
|
||||
<Link href="/renter/dashboard" className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700">
|
||||
{dict.back}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
|
||||
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
|
||||
|
||||
{!loading && !errorMsg && notifications.length === 0 ? (
|
||||
<div className="card p-10 text-center text-sm text-stone-500">{dict.empty}</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !errorMsg && notifications.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification) => (
|
||||
<article key={notification.id} className="card p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-700">{notification.type.replaceAll('_', ' ')}</p>
|
||||
<h2 className="mt-2 text-lg font-bold text-stone-900">{notification.title}</h2>
|
||||
</div>
|
||||
{notification.readAt ? (
|
||||
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-600">{dict.read}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markRead(notification.id)}
|
||||
disabled={updating}
|
||||
className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800"
|
||||
>
|
||||
{dict.markRead}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600">{notification.body}</p>
|
||||
<p className="mt-4 text-xs text-stone-400">{new Date(notification.createdAt).toLocaleString()}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
RenterAuthError,
|
||||
clearRenterSession,
|
||||
loadRenterPreferences,
|
||||
loadRenterProfile,
|
||||
updateRenterPreferences,
|
||||
updateRenterProfile,
|
||||
type RenterPreference,
|
||||
type RenterProfile,
|
||||
} from '@/lib/renter'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
|
||||
type Status = 'loading' | 'ready' | 'error'
|
||||
const RENTER_EVENTS = ['BOOKING_CONFIRMED', 'BOOKING_REMINDER', 'RETURN_REMINDER', 'REFUND_ISSUED', 'NEW_OFFER']
|
||||
const RENTER_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'PUSH', 'IN_APP']
|
||||
|
||||
export default function RenterProfilePage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
profile: 'Profile',
|
||||
loading: 'Loading profile…',
|
||||
loadFailed: 'Could not load profile.',
|
||||
yourProfile: 'Your profile',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
locale: 'Locale',
|
||||
currency: 'Currency',
|
||||
saving: 'Saving…',
|
||||
saveProfile: 'Save profile',
|
||||
verification: 'Verification',
|
||||
verified: 'Email verified',
|
||||
pending: 'Email verification is still pending.',
|
||||
notificationPrefs: 'Notification preferences',
|
||||
notificationBody: 'Choose how reminders, offers, and booking updates reach you.',
|
||||
savePreferences: 'Save preferences',
|
||||
event: 'Event',
|
||||
back: 'Back to dashboard',
|
||||
savedCompanies: 'Saved companies',
|
||||
notifications: 'Notifications',
|
||||
logout: 'Log out',
|
||||
renter: 'Renter',
|
||||
saveFailed: 'Could not save profile.',
|
||||
prefFailed: 'Could not save preferences.',
|
||||
},
|
||||
fr: {
|
||||
profile: 'Profil',
|
||||
loading: 'Chargement du profil…',
|
||||
loadFailed: 'Impossible de charger le profil.',
|
||||
yourProfile: 'Votre profil',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'Email',
|
||||
phone: 'Téléphone',
|
||||
locale: 'Langue',
|
||||
currency: 'Devise',
|
||||
saving: 'Enregistrement…',
|
||||
saveProfile: 'Enregistrer le profil',
|
||||
verification: 'Vérification',
|
||||
verified: 'Email vérifié',
|
||||
pending: 'La vérification de l’email est encore en attente.',
|
||||
notificationPrefs: 'Préférences de notification',
|
||||
notificationBody: 'Choisissez comment les rappels, offres et mises à jour vous parviennent.',
|
||||
savePreferences: 'Enregistrer les préférences',
|
||||
event: 'Événement',
|
||||
back: 'Retour au dashboard',
|
||||
savedCompanies: 'Entreprises sauvegardées',
|
||||
notifications: 'Notifications',
|
||||
logout: 'Déconnexion',
|
||||
renter: 'Client',
|
||||
saveFailed: 'Impossible d’enregistrer le profil.',
|
||||
prefFailed: 'Impossible d’enregistrer les préférences.',
|
||||
},
|
||||
ar: {
|
||||
profile: 'الملف الشخصي',
|
||||
loading: 'جارٍ تحميل الملف الشخصي…',
|
||||
loadFailed: 'تعذر تحميل الملف الشخصي.',
|
||||
yourProfile: 'ملفك الشخصي',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
locale: 'اللغة',
|
||||
currency: 'العملة',
|
||||
saving: 'جارٍ الحفظ…',
|
||||
saveProfile: 'حفظ الملف الشخصي',
|
||||
verification: 'التحقق',
|
||||
verified: 'تم التحقق من البريد الإلكتروني',
|
||||
pending: 'التحقق من البريد الإلكتروني ما زال قيد الانتظار.',
|
||||
notificationPrefs: 'تفضيلات الإشعارات',
|
||||
notificationBody: 'اختر كيف تصلك التذكيرات والعروض وتحديثات الحجز.',
|
||||
savePreferences: 'حفظ التفضيلات',
|
||||
event: 'الحدث',
|
||||
back: 'العودة إلى اللوحة',
|
||||
savedCompanies: 'الشركات المحفوظة',
|
||||
notifications: 'الإشعارات',
|
||||
logout: 'تسجيل الخروج',
|
||||
renter: 'المستأجر',
|
||||
saveFailed: 'تعذر حفظ الملف الشخصي.',
|
||||
prefFailed: 'تعذر حفظ التفضيلات.',
|
||||
},
|
||||
}[language]
|
||||
const [profile, setProfile] = useState<RenterProfile | null>(null)
|
||||
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
|
||||
const [status, setStatus] = useState<Status>('loading')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadRenterProfile(), loadRenterPreferences()])
|
||||
.then(([profileData, preferenceData]) => {
|
||||
setProfile(profileData)
|
||||
setPreferences(
|
||||
Object.fromEntries(
|
||||
preferenceData.map((item) => [`${item.notificationType}:${item.channel}`, item.enabled]),
|
||||
),
|
||||
)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/renter/sign-in')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.loadFailed)
|
||||
setStatus('error')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
if (status === 'loading') {
|
||||
return <RenterShell title={dict.profile} dict={dict}><div className="card p-8 text-sm text-stone-500">{dict.loading}</div></RenterShell>
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return <RenterShell title={dict.profile} dict={dict}><ErrorCard message={errorMsg} /></RenterShell>
|
||||
}
|
||||
|
||||
return (
|
||||
<RenterShell title={dict.profile} dict={dict}>
|
||||
<div className="card p-8">
|
||||
<h1 className="text-3xl font-black tracking-tight text-stone-900">{dict.yourProfile}</h1>
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EditableField label={dict.firstName} value={profile?.firstName ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, firstName: value } : current)} />
|
||||
<EditableField label={dict.lastName} value={profile?.lastName ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, lastName: value } : current)} />
|
||||
<Field label={dict.email} value={profile?.email} />
|
||||
<EditableField label={dict.phone} value={profile?.phone ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, phone: value } : current)} />
|
||||
<SelectField
|
||||
label={dict.locale}
|
||||
value={profile?.preferredLocale || 'en'}
|
||||
options={['en', 'fr', 'ar']}
|
||||
onChange={(value) => setProfile((current) => current ? { ...current, preferredLocale: value } : current)}
|
||||
/>
|
||||
<SelectField
|
||||
label={dict.currency}
|
||||
value={profile?.preferredCurrency || 'MAD'}
|
||||
options={['MAD', 'USD', 'EUR']}
|
||||
onChange={(value) => setProfile((current) => current ? { ...current, preferredCurrency: value } : current)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!profile) return
|
||||
setSaving(true)
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const updated = await updateRenterProfile({
|
||||
firstName: profile.firstName,
|
||||
lastName: profile.lastName,
|
||||
phone: profile.phone,
|
||||
preferredLocale: profile.preferredLocale,
|
||||
preferredCurrency: profile.preferredCurrency,
|
||||
})
|
||||
setProfile(updated)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.saveFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
className="rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
{saving ? dict.saving : dict.saveProfile}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8 rounded-2xl border border-stone-200 bg-stone-50 p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-500">{dict.verification}</p>
|
||||
<p className="mt-2 text-sm text-stone-700">
|
||||
{profile?.emailVerified ? dict.verified : dict.pending}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-stone-900">{dict.notificationPrefs}</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">{dict.notificationBody}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setSaving(true)
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const payload: RenterPreference[] = Object.entries(preferences).map(([key, enabled]) => {
|
||||
const [notificationType, channel] = key.split(':')
|
||||
return { notificationType, channel, enabled }
|
||||
})
|
||||
await updateRenterPreferences(payload)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.prefFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
className="rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
{saving ? dict.saving : dict.savePreferences}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-stone-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-stone-500">{dict.event}</th>
|
||||
{RENTER_CHANNELS.map((channel) => (
|
||||
<th key={channel} className="px-4 py-3 text-center font-semibold text-stone-500">
|
||||
{channel}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{RENTER_EVENTS.map((eventName) => (
|
||||
<tr key={eventName} className="border-t border-stone-100">
|
||||
<td className="px-4 py-3 font-medium text-stone-900">{eventName.replaceAll('_', ' ')}</td>
|
||||
{RENTER_CHANNELS.map((channel) => (
|
||||
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences[`${eventName}:${channel}`] ?? true}
|
||||
onChange={(event) =>
|
||||
setPreferences((current) => ({
|
||||
...current,
|
||||
[`${eventName}:${channel}`]: event.target.checked,
|
||||
}))
|
||||
}
|
||||
className="h-4 w-4 rounded border-stone-300 text-amber-600"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</RenterShell>
|
||||
)
|
||||
}
|
||||
|
||||
function RenterShell({ title, children, dict }: { title: string; children: ReactNode; dict: Record<string, string> }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link href="/renter/dashboard" className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
|
||||
{dict.back}
|
||||
</Link>
|
||||
<Link href="/renter/saved-companies" className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
|
||||
{dict.savedCompanies}
|
||||
</Link>
|
||||
<Link href="/renter/notifications" className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
|
||||
{dict.notifications}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearRenterSession()
|
||||
window.location.href = '/renter/sign-in'
|
||||
}}
|
||||
className="rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
{dict.logout}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">{dict.renter} {title}</p>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<p className="mt-2 text-sm font-medium text-stone-900 break-all">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditableField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-900"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-900"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorCard({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-sm text-rose-600">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { RenterAuthError, loadRenterProfile, type SavedCompany } from '@/lib/renter'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
|
||||
export default function RenterSavedCompaniesPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
load: 'Could not load saved companies.',
|
||||
renter: 'Renter',
|
||||
title: 'Saved companies',
|
||||
back: 'Back to dashboard',
|
||||
loading: 'Loading saved companies…',
|
||||
empty: 'You have not saved any companies yet.',
|
||||
browse: 'Browse the marketplace',
|
||||
rentalCompany: 'Rental company',
|
||||
},
|
||||
fr: {
|
||||
load: 'Impossible de charger les entreprises sauvegardées.',
|
||||
renter: 'Client',
|
||||
title: 'Entreprises sauvegardées',
|
||||
back: 'Retour au dashboard',
|
||||
loading: 'Chargement des entreprises sauvegardées…',
|
||||
empty: 'Vous n’avez encore enregistré aucune entreprise.',
|
||||
browse: 'Parcourir la marketplace',
|
||||
rentalCompany: 'Société de location',
|
||||
},
|
||||
ar: {
|
||||
load: 'تعذر تحميل الشركات المحفوظة.',
|
||||
renter: 'المستأجر',
|
||||
title: 'الشركات المحفوظة',
|
||||
back: 'العودة إلى اللوحة',
|
||||
loading: 'جارٍ تحميل الشركات المحفوظة…',
|
||||
empty: 'لم تقم بحفظ أي شركة بعد.',
|
||||
browse: 'تصفح السوق',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
},
|
||||
}[language]
|
||||
const [companies, setCompanies] = useState<SavedCompany[]>([])
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadRenterProfile()
|
||||
.then((profile) => setCompanies(profile.savedCompanies ?? []))
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/renter/sign-in')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.load)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">{dict.renter}</p>
|
||||
<h1 className="mt-1 text-3xl font-black tracking-tight text-stone-900">{dict.title}</h1>
|
||||
</div>
|
||||
<Link href="/renter/dashboard" className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700">
|
||||
{dict.back}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
|
||||
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
|
||||
|
||||
{!loading && !errorMsg && companies.length === 0 ? (
|
||||
<div className="card p-10 text-center">
|
||||
<p className="text-sm text-stone-500">{dict.empty}</p>
|
||||
<Link href="/explore" className="mt-5 inline-flex rounded-full bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white">
|
||||
{dict.browse}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !errorMsg && companies.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{companies.map((company) => (
|
||||
<Link
|
||||
key={company.id}
|
||||
href={`/explore/${company.brand?.subdomain ?? company.id}`}
|
||||
className="card flex items-center gap-4 p-5 hover:border-amber-300 transition-colors"
|
||||
>
|
||||
{company.brand?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={company.brand.logoUrl} alt={company.brand.displayName} className="h-11 w-11 rounded-xl object-contain" />
|
||||
) : (
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-100 text-sm font-bold text-amber-700">
|
||||
{company.brand?.displayName?.charAt(0) ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-stone-900">{company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<p className="mt-1 truncate text-xs text-stone-400">{company.brand?.subdomain ?? company.id}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function RenterSignInPage() {
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
redirect(`${dashboardUrl}/sign-in`)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function RenterSignUpPage() {
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
redirect(`${dashboardUrl}/sign-in`)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
type Dictionary = {
|
||||
marketplace: string
|
||||
companyWorkspace: string
|
||||
platformOperations: string
|
||||
explore: string
|
||||
ownerSignIn: string
|
||||
language: string
|
||||
theme: string
|
||||
light: string
|
||||
dark: string
|
||||
preferences: string
|
||||
}
|
||||
|
||||
const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
|
||||
en: {
|
||||
marketplace: 'Marketplace',
|
||||
companyWorkspace: 'Company Workspace',
|
||||
platformOperations: 'Platform Operations',
|
||||
explore: 'Explore',
|
||||
ownerSignIn: 'Owner sign in',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
preferences: 'Marketplace preferences',
|
||||
},
|
||||
fr: {
|
||||
marketplace: 'Marketplace',
|
||||
companyWorkspace: 'Espace entreprise',
|
||||
platformOperations: 'Opérations plateforme',
|
||||
explore: 'Explorer',
|
||||
ownerSignIn: 'Connexion proprietaire',
|
||||
language: 'Langue',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
preferences: 'Préférences marketplace',
|
||||
},
|
||||
ar: {
|
||||
marketplace: 'السوق',
|
||||
companyWorkspace: 'مساحة الشركة',
|
||||
platformOperations: 'عمليات المنصة',
|
||||
explore: 'استكشاف',
|
||||
ownerSignIn: 'دخول المالك',
|
||||
language: 'اللغة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
dark: 'داكن',
|
||||
preferences: 'تفضيلات السوق',
|
||||
},
|
||||
}
|
||||
|
||||
type PreferencesContextValue = {
|
||||
language: MarketplaceLanguage
|
||||
theme: Theme
|
||||
dict: Dictionary
|
||||
setLanguage: (language: MarketplaceLanguage) => void
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: T
|
||||
options: { value: T; label: string }[]
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-stone-200/70 bg-white/90 px-2 py-1 shadow-sm dark:border-stone-700 dark:bg-stone-900/80">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{options.map((option) => {
|
||||
const active = option.value === value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active
|
||||
? 'bg-stone-900 text-white dark:bg-amber-400 dark:text-stone-950'
|
||||
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function useMarketplacePreferences() {
|
||||
const context = useContext(PreferencesContext)
|
||||
if (!context) throw new Error('useMarketplacePreferences must be used within MarketplaceShell')
|
||||
return context
|
||||
}
|
||||
|
||||
export default function MarketplaceShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
const [language, setLanguage] = useState<MarketplaceLanguage>('en')
|
||||
const [theme, setTheme] = useState<Theme>('light')
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const previousLanguage = useRef<MarketplaceLanguage>('en')
|
||||
|
||||
useEffect(() => {
|
||||
const storedLanguage = window.localStorage.getItem('marketplace-language')
|
||||
const storedTheme = window.localStorage.getItem('marketplace-theme') as Theme | null
|
||||
|
||||
if (isMarketplaceLanguage(storedLanguage)) {
|
||||
setLanguage(storedLanguage)
|
||||
}
|
||||
|
||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||
setTheme(storedTheme)
|
||||
}
|
||||
|
||||
setHydrated(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
document.body.dataset.theme = theme
|
||||
window.localStorage.setItem('marketplace-language', language)
|
||||
window.localStorage.setItem('marketplace-theme', theme)
|
||||
document.cookie = `${MARKETPLACE_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; SameSite=Lax`
|
||||
|
||||
if (hydrated && previousLanguage.current !== language) {
|
||||
router.refresh()
|
||||
}
|
||||
previousLanguage.current = language
|
||||
}, [hydrated, language, router, theme])
|
||||
|
||||
const dict = dictionaries[language]
|
||||
const isEmbeddedWorkspace =
|
||||
pathname === '/company-workspace' ||
|
||||
pathname === '/platform-operations' ||
|
||||
pathname === '/branded-website'
|
||||
const hideHeader = pathname === '/company-workspace'
|
||||
const value = useMemo(
|
||||
() => ({ language, theme, dict, setLanguage, setTheme }),
|
||||
[dict, language, theme],
|
||||
)
|
||||
|
||||
return (
|
||||
<PreferencesContext.Provider value={value}>
|
||||
<div className="min-h-screen bg-stone-50 text-stone-900 transition-colors dark:bg-stone-950 dark:text-stone-100">
|
||||
{!hideHeader ? (
|
||||
<header className="sticky top-0 z-50 border-b border-stone-200 bg-white/80 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-stone-950/80">
|
||||
<div className="shell flex min-h-14 flex-col gap-3 py-3 lg:flex-row lg:items-center lg:justify-between lg:py-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Link href="/" className="flex items-center gap-3 text-sm font-bold uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100">
|
||||
<Image
|
||||
src="/rentalcardrive.png"
|
||||
alt="RentalDriveGo"
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 rounded-md object-contain"
|
||||
/>
|
||||
<span>RentalDriveGo</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<nav className="flex items-center gap-1 overflow-x-auto">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
|
||||
>
|
||||
{dict.marketplace}
|
||||
</Link>
|
||||
<Link
|
||||
href="/company-workspace"
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
|
||||
>
|
||||
{dict.companyWorkspace}
|
||||
</Link>
|
||||
<Link
|
||||
href="/platform-operations"
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
|
||||
>
|
||||
{dict.platformOperations}
|
||||
</Link>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
|
||||
>
|
||||
{dict.explore}
|
||||
</Link>
|
||||
<Link
|
||||
href={`${dashboardUrl}/sign-in`}
|
||||
className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
|
||||
>
|
||||
{dict.ownerSignIn}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-[calc(100vh-57px)] flex-col">
|
||||
<div className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{!isEmbeddedWorkspace ? (
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-stone-950/90">
|
||||
<div className="shell flex flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">
|
||||
{dict.preferences}
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<SegmentedControl
|
||||
label={dict.language}
|
||||
value={language}
|
||||
options={[
|
||||
{ value: 'en', label: 'EN' },
|
||||
{ value: 'fr', label: 'FR' },
|
||||
{ value: 'ar', label: 'AR' },
|
||||
]}
|
||||
onChange={setLanguage}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label={dict.theme}
|
||||
value={theme}
|
||||
options={[
|
||||
{ value: 'light', label: dict.light },
|
||||
{ value: 'dark', label: dict.dark },
|
||||
]}
|
||||
onChange={setTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useMarketplacePreferences } from './MarketplaceShell'
|
||||
|
||||
type FrameId = 'dashboard' | 'admin' | 'public-site'
|
||||
|
||||
const frameConfig: Record<FrameId, { label: string; port: number; path: string }> = {
|
||||
dashboard: {
|
||||
label: 'Company Workspace',
|
||||
port: 3001,
|
||||
path: '/',
|
||||
},
|
||||
admin: {
|
||||
label: 'Platform Operations',
|
||||
port: 3002,
|
||||
path: '/',
|
||||
},
|
||||
'public-site': {
|
||||
label: 'Branded Website',
|
||||
port: 3003,
|
||||
path: '/',
|
||||
},
|
||||
}
|
||||
|
||||
function withPort(port: number, path: string) {
|
||||
if (typeof window === 'undefined') return path
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
url.port = String(port)
|
||||
url.pathname = path
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export default function WorkspaceFrame({ target }: { target: FrameId }) {
|
||||
const pathname = usePathname()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const [src, setSrc] = useState('')
|
||||
const config = frameConfig[target]
|
||||
const fillViewport = pathname === '/company-workspace'
|
||||
const frameHeight = fillViewport ? '100vh' : 'calc(100vh - 56px)'
|
||||
const loadingLabel = {
|
||||
en: 'Loading',
|
||||
fr: 'Chargement de',
|
||||
ar: 'جارٍ تحميل',
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
setSrc(withPort(config.port, config.path))
|
||||
}, [config.path, config.port])
|
||||
|
||||
return (
|
||||
<main className="bg-stone-950" style={{ minHeight: frameHeight }}>
|
||||
{src ? (
|
||||
<iframe
|
||||
title={config.label}
|
||||
src={src}
|
||||
className="w-full border-0 bg-white"
|
||||
style={{ height: frameHeight }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center text-sm text-stone-300" style={{ height: frameHeight }}>
|
||||
{loadingLabel} {config.label}…
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client'
|
||||
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function WorkspaceTabs() {
|
||||
const { language, theme } = useMarketplacePreferences()
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
badge: 'Unified Workspace',
|
||||
title: 'One launch point on port 3000 for marketplace discovery, company operations, and platform admin work.',
|
||||
intro:
|
||||
'The apps still run as separate Next.js surfaces internally, but the navbar now takes you straight to each workspace area without hunting for ports.',
|
||||
sectionBadge: 'RentalDriveGo Marketplace',
|
||||
sectionTitle: 'Browse local rental fleets without losing the company behind the wheel.',
|
||||
sectionBodyA: 'Discovery happens here. Booking and payment happen on each rental company\'s own branded site.',
|
||||
sectionBodyB: 'Customers only reach that branded booking site after selecting a vehicle from a company\'s fleet.',
|
||||
exploreVehicles: 'Explore vehicles',
|
||||
viewPricing: 'View pricing',
|
||||
nowServing: 'Now serving',
|
||||
promoTitle: 'Discovery, pricing, owner sign-in, and booking entry all stay anchored on the marketplace surface.',
|
||||
promoBody:
|
||||
'Company Workspace and Platform Operations stay available from the navbar, while branded booking starts after a renter picks a vehicle.',
|
||||
customerPaths: 'Customer Paths',
|
||||
ownerSignIn: 'Owner sign in',
|
||||
pricing: 'Pricing',
|
||||
workspaceAreas: 'Workspace Areas',
|
||||
workspaceA: 'Company Workspace for fleet operations, team, billing, and analytics.',
|
||||
workspaceB: 'Platform Operations for platform-level support and tenant oversight.',
|
||||
workspaceC: 'Branded booking handoff begins only after a renter chooses a specific vehicle.',
|
||||
},
|
||||
fr: {
|
||||
badge: 'Espace unifie',
|
||||
title: 'Un point d\'entree sur le port 3000 pour la decouverte marketplace, les operations entreprise et l\'administration plateforme.',
|
||||
intro:
|
||||
'Les applications restent des surfaces Next.js separees en interne, mais la barre de navigation mene directement vers chaque espace sans changer de port manuellement.',
|
||||
sectionBadge: 'Marketplace RentalDriveGo',
|
||||
sectionTitle: 'Parcourez les flottes locales sans perdre l\'identite de l\'entreprise qui gere la location.',
|
||||
sectionBodyA: 'La decouverte commence ici. La reservation et le paiement se font sur le site de reservation propre a chaque entreprise.',
|
||||
sectionBodyB: 'Les clients accedent a ce site de reservation seulement apres avoir choisi un vehicule dans la flotte d\'une entreprise.',
|
||||
exploreVehicles: 'Explorer les vehicules',
|
||||
viewPricing: 'Voir les tarifs',
|
||||
nowServing: 'Disponible',
|
||||
promoTitle: 'La decouverte, les tarifs, la connexion proprietaire et l\'entree vers la reservation restent ancrees dans la marketplace.',
|
||||
promoBody:
|
||||
'Espace entreprise et Operations plateforme restent accessibles depuis la navigation, tandis que la reservation de marque commence apres le choix d\'un vehicule.',
|
||||
customerPaths: 'Parcours client',
|
||||
ownerSignIn: 'Connexion proprietaire',
|
||||
pricing: 'Tarifs',
|
||||
workspaceAreas: 'Espaces de travail',
|
||||
workspaceA: 'Espace entreprise pour la flotte, l\'equipe, la facturation et l\'analyse.',
|
||||
workspaceB: 'Operations plateforme pour le support global et la supervision des tenants.',
|
||||
workspaceC: 'Le transfert vers la reservation de marque commence seulement apres le choix d\'un vehicule precis.',
|
||||
},
|
||||
ar: {
|
||||
badge: 'مساحة موحدة',
|
||||
title: 'نقطة دخول واحدة على المنفذ 3000 لاكتشاف السوق وعمليات الشركات وإدارة المنصة.',
|
||||
intro:
|
||||
'ما زالت التطبيقات تعمل كأسطح Next.js منفصلة داخلياً، لكن شريط التنقل ينقلك مباشرة إلى كل مساحة عمل بدون البحث عن المنافذ.',
|
||||
sectionBadge: 'سوق RentalDriveGo',
|
||||
sectionTitle: 'تصفح أساطيل التأجير المحلية من دون فقدان هوية الشركة التي تدير الحجز.',
|
||||
sectionBodyA: 'الاكتشاف يبدأ هنا. الحجز والدفع يتمان على موقع الحجز الخاص بكل شركة.',
|
||||
sectionBodyB: 'لا يصل العميل إلى موقع الحجز الخاص بالشركة إلا بعد اختيار سيارة من أسطول تلك الشركة.',
|
||||
exploreVehicles: 'استكشف السيارات',
|
||||
viewPricing: 'عرض الأسعار',
|
||||
nowServing: 'المتاح الآن',
|
||||
promoTitle: 'الاكتشاف والأسعار وتسجيل دخول المالك وبداية الحجز تبقى كلها داخل واجهة السوق.',
|
||||
promoBody:
|
||||
'تبقى مساحة الشركة وعمليات المنصة متاحتين من شريط التنقل، بينما يبدأ الحجز المرتبط بالعلامة التجارية بعد اختيار المستأجر لسيارة.',
|
||||
customerPaths: 'مسارات العملاء',
|
||||
ownerSignIn: 'دخول المالك',
|
||||
pricing: 'الأسعار',
|
||||
workspaceAreas: 'مساحات العمل',
|
||||
workspaceA: 'مساحة الشركة لإدارة الأسطول والفريق والفوترة والتحليلات.',
|
||||
workspaceB: 'عمليات المنصة للدعم والإشراف على المستأجرين على مستوى المنصة.',
|
||||
workspaceC: 'الانتقال إلى موقع الحجز الخاص بالشركة يبدأ فقط بعد اختيار سيارة محددة.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<main className={`min-h-screen transition-colors ${
|
||||
theme === 'dark'
|
||||
? 'bg-[radial-gradient(circle_at_top,#451a03,transparent_28%),linear-gradient(180deg,#0c0a09,#111827)]'
|
||||
: 'bg-[radial-gradient(circle_at_top,#fef3c7,transparent_28%),linear-gradient(180deg,#fafaf9,white)]'
|
||||
}`}>
|
||||
<div className="shell py-16 sm:py-20">
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/" aria-label="RentalDriveGo home">
|
||||
<Image
|
||||
src="/rentalcardrive.png"
|
||||
alt="RentalDriveGo"
|
||||
width={72}
|
||||
height={72}
|
||||
className={`h-16 w-16 rounded-2xl object-contain p-1 shadow-sm ${
|
||||
theme === 'dark' ? 'border border-amber-500/40 bg-stone-900' : 'border border-amber-200 bg-white'
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<p className={`text-sm font-semibold uppercase tracking-[0.24em] ${
|
||||
theme === 'dark' ? 'text-amber-300' : 'text-amber-700'
|
||||
}`}>
|
||||
{copy.badge}
|
||||
</p>
|
||||
</div>
|
||||
<h1 className={`mt-5 text-4xl font-black tracking-tight sm:text-6xl ${
|
||||
theme === 'dark' ? 'text-stone-100' : 'text-stone-900'
|
||||
}`}>
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p className={`mt-6 max-w-3xl text-lg leading-8 ${
|
||||
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
|
||||
}`}>
|
||||
{copy.intro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className={`mt-10 overflow-hidden rounded-[2rem] border shadow-xl transition-colors ${
|
||||
theme === 'dark'
|
||||
? 'border-stone-800 bg-stone-900 shadow-black/20'
|
||||
: 'border-stone-200 bg-white shadow-stone-200/40'
|
||||
}`}>
|
||||
<div className={`px-6 py-6 ${theme === 'dark' ? 'border-b border-stone-800' : 'border-b border-stone-200'}`}>
|
||||
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
|
||||
theme === 'dark' ? 'text-amber-300' : 'text-amber-700'
|
||||
}`}>
|
||||
{copy.sectionBadge}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<h2 className={`text-2xl font-black tracking-tight ${
|
||||
theme === 'dark' ? 'text-stone-100' : 'text-stone-900'
|
||||
}`}>
|
||||
{copy.sectionTitle}
|
||||
</h2>
|
||||
<p className={`mt-3 text-sm leading-7 ${
|
||||
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
|
||||
}`}>
|
||||
{copy.sectionBodyA}
|
||||
</p>
|
||||
<p className={`mt-3 text-sm leading-7 ${
|
||||
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
|
||||
}`}>
|
||||
{copy.sectionBodyB}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/explore"
|
||||
className={`rounded-full px-5 py-2.5 text-sm font-semibold transition ${
|
||||
theme === 'dark'
|
||||
? 'bg-amber-400 text-stone-950 hover:bg-amber-300'
|
||||
: 'bg-stone-900 text-white hover:bg-stone-700'
|
||||
}`}
|
||||
>
|
||||
{copy.exploreVehicles}
|
||||
</Link>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className={`rounded-full border px-5 py-2.5 text-sm font-semibold transition ${
|
||||
theme === 'dark'
|
||||
? 'border-stone-700 text-stone-200 hover:border-stone-500 hover:bg-stone-800'
|
||||
: 'border-stone-300 text-stone-700 hover:border-stone-400 hover:bg-stone-50'
|
||||
}`}
|
||||
>
|
||||
{copy.viewPricing}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-6 px-6 py-8 lg:grid-cols-[1.4fr_0.9fr] ${
|
||||
theme === 'dark' ? 'bg-stone-950' : 'bg-stone-50'
|
||||
}`}>
|
||||
<div className="rounded-[1.5rem] bg-[linear-gradient(135deg,#1c1917,#44403c)] p-8 text-white">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-amber-300">{copy.nowServing}</p>
|
||||
<h3 className="mt-4 text-3xl font-black tracking-tight">
|
||||
{copy.promoTitle}
|
||||
</h3>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-stone-200">
|
||||
{copy.promoBody}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className={`rounded-[1.5rem] border p-5 ${
|
||||
theme === 'dark' ? 'border-stone-800 bg-stone-900' : 'border-stone-200 bg-white'
|
||||
}`}>
|
||||
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
|
||||
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
|
||||
}`}>{copy.customerPaths}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href="/explore" className="rounded-full bg-amber-500 px-4 py-2 text-sm font-semibold text-white">
|
||||
{copy.exploreVehicles}
|
||||
</Link>
|
||||
<Link className={`rounded-full border px-4 py-2 text-sm font-semibold ${
|
||||
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
|
||||
}`} href={`${dashboardUrl}/sign-in`}>
|
||||
{copy.ownerSignIn}
|
||||
</Link>
|
||||
<Link className={`rounded-full border px-4 py-2 text-sm font-semibold ${
|
||||
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
|
||||
}`} href="/pricing">
|
||||
{copy.pricing}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`rounded-[1.5rem] border p-5 ${
|
||||
theme === 'dark' ? 'border-stone-800 bg-stone-900' : 'border-stone-200 bg-white'
|
||||
}`}>
|
||||
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
|
||||
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
|
||||
}`}>{copy.workspaceAreas}</p>
|
||||
<ul className={`mt-4 space-y-3 text-sm ${
|
||||
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
|
||||
}`}>
|
||||
<li>{copy.workspaceA}</li>
|
||||
<li>{copy.workspaceB}</li>
|
||||
<li>{copy.workspaceC}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
const API_BASE =
|
||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : process.env.NEXT_PUBLIC_API_URL)
|
||||
?? process.env.NEXT_PUBLIC_API_URL
|
||||
?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export class MarketplaceApiError extends Error {
|
||||
status: number
|
||||
code?: string
|
||||
|
||||
constructor(message: string, status: number, code?: string) {
|
||||
super(message)
|
||||
this.name = 'MarketplaceApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export async function marketplaceFetch<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error)
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
export async function marketplaceFetchOrDefault<T>(path: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
return await marketplaceFetch<T>(path)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from './i18n'
|
||||
|
||||
export function getMarketplaceLanguage(): MarketplaceLanguage {
|
||||
const cookieValue = cookies().get(MARKETPLACE_LANGUAGE_COOKIE)?.value
|
||||
return isMarketplaceLanguage(cookieValue) ? cookieValue : 'en'
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type MarketplaceLanguage = 'en' | 'fr' | 'ar'
|
||||
|
||||
export const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
|
||||
|
||||
export function isMarketplaceLanguage(value: string | null | undefined): value is MarketplaceLanguage {
|
||||
return value === 'en' || value === 'fr' || value === 'ar'
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export interface SavedCompany {
|
||||
id: string
|
||||
brand?: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
logoUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface RenterProfile {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
preferredLocale?: string
|
||||
preferredCurrency?: string
|
||||
emailVerified?: boolean
|
||||
savedCompanies?: SavedCompany[]
|
||||
}
|
||||
|
||||
export interface RenterPreference {
|
||||
notificationType: string
|
||||
channel: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface RenterNotification {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
type: string
|
||||
status: string
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export class RenterAuthError extends Error {
|
||||
constructor(message = 'Authentication required') {
|
||||
super(message)
|
||||
this.name = 'RenterAuthError'
|
||||
}
|
||||
}
|
||||
|
||||
function getRenterToken() {
|
||||
return window.localStorage.getItem('renter_token')
|
||||
}
|
||||
|
||||
async function renterFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getRenterToken()
|
||||
if (!token) throw new RenterAuthError()
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
|
||||
const json = await res.json().catch(() => null)
|
||||
if (res.status === 401) {
|
||||
window.localStorage.removeItem('renter_token')
|
||||
throw new RenterAuthError()
|
||||
}
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
return (json?.data ?? json) as T
|
||||
}
|
||||
|
||||
export function clearRenterSession() {
|
||||
window.localStorage.removeItem('renter_token')
|
||||
}
|
||||
|
||||
export function loadRenterProfile() {
|
||||
return renterFetch<RenterProfile>('/auth/renter/me')
|
||||
}
|
||||
|
||||
export function updateRenterProfile(body: Partial<RenterProfile>) {
|
||||
return renterFetch<RenterProfile>('/auth/renter/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function loadRenterNotifications() {
|
||||
return renterFetch<RenterNotification[]>('/notifications/renter')
|
||||
}
|
||||
|
||||
export function markRenterNotificationRead(id: string) {
|
||||
return renterFetch<{ success: boolean }>(`/notifications/renter/${id}/read`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function markAllRenterNotificationsRead() {
|
||||
return renterFetch<{ success: boolean }>('/notifications/renter/read-all', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function loadRenterPreferences() {
|
||||
return renterFetch<RenterPreference[]>('/notifications/renter/preferences')
|
||||
}
|
||||
|
||||
export function updateRenterPreferences(body: RenterPreference[]) {
|
||||
return renterFetch<{ success: boolean }>('/notifications/renter/preferences', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user