fixing platform admin

This commit is contained in:
root
2026-05-06 22:58:23 -04:00
parent 695a7f7cc7
commit 750ae56a29
175 changed files with 31249 additions and 328 deletions
+365
View File
@@ -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 companys 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 companys 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 lessai 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 à lentreprise, pour garder la relation client et lencaissement.'],
],
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 lespace',
},
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&currency=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 lentreprise sont temporairement indisponibles.',
unavailableBody: 'LAPI marketplace fonctionne, mais la base de données nest 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 jusquau',
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 nest 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>
)
}
+286
View File
@@ -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 companys 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 dentreprises locales fiables.',
body: 'Chaque annonce vous redirige vers le parcours de réservation et de paiement propre à lentreprise.',
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 jusquau',
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 companys 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 lentreprise 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>
)
}
+15
View File
@@ -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;
}
+23
View File
@@ -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>
)
}
+5
View File
@@ -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()}&currency=${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.'],
['Quest-ce qui compte comme véhicule ?', 'Tout véhicule ajouté à votre tableau de bord flotte, actif ou non.'],
['Y a-t-il des frais dinstallation ?', 'Aucun frais dinstallation. Vous payez uniquement labonnement 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>
)
}
+11
View File
@@ -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 lemail 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 denregistrer le profil.',
prefFailed: 'Impossible denregistrer 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 navez 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>
)
}
+31
View File
@@ -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
}
}
+7
View File
@@ -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'
}
+7
View File
@@ -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'
}
+115
View File
@@ -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),
})
}