refactor: rename marketplace to storefront across the entire monorepo
Build & Deploy / Build & Push Docker Image (push) Successful in 1m2s
Test / Type Check (all packages) (push) Failing after 28s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Deploy / Deploy to VPS (push) Successful in 3s
Build & Deploy / Build & Push Docker Image (push) Successful in 1m2s
Test / Type Check (all packages) (push) Failing after 28s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Deploy / Deploy to VPS (push) Successful in 3s
Comprehensive rename of all marketplace references to storefront: - API module: apps/api/src/modules/marketplace/ → storefront/ - Components: MarketplaceHeader → StorefrontHeader, MarketplaceShell → StorefrontShell, MarketplaceFooter → StorefrontFooter - Types: marketplace-homepage.ts → storefront-homepage.ts - Test files: employee-marketplace-* → employee-storefront-* - All source code identifiers, imports, route paths, and strings - Documentation (docs/), CI config (.gitlab-ci.yml), scripts - Dashboard, admin, storefront workspace references - Prisma field names preserved (isListedOnMarketplace, marketplaceRating, marketplaceFunnelEvent) as they map to database schema Validation: - API type-check: 0 errors - Storefront type-check: 0 errors - Dashboard type-check: 0 errors - Full monorepo type-check: only pre-existing admin TS18046
This commit is contained in:
@@ -5,7 +5,7 @@ const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http:
|
||||
const apiUrl = new URL(apiOrigin)
|
||||
const ADMIN_BASE_PATH = '/admin'
|
||||
|
||||
// In Docker dev the admin app runs on port 3002 while the marketplace proxy
|
||||
// In Docker dev the admin app runs on port 3002 while the storefront proxy
|
||||
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
|
||||
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
|
||||
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
|
||||
|
||||
@@ -724,7 +724,7 @@ export default function AdminCompanyDetailPage() {
|
||||
</label>
|
||||
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
|
||||
<input type="checkbox" checked={form.brand.isListedOnMarketplace} onChange={(e) => updateSection('brand', { isListedOnMarketplace: e.target.checked })} />
|
||||
Listed on marketplace
|
||||
Listed on storefront
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function AdminDashboardPage() {
|
||||
brand: 'RentalDriveGo',
|
||||
eyebrow: 'Operations command',
|
||||
platformOverview: 'RentalDriveGo admin dashboard',
|
||||
subtitle: 'Monitor marketplace health, subscription coverage, renter activity, and operator actions from one control surface.',
|
||||
subtitle: 'Monitor storefront health, subscription coverage, renter activity, and operator actions from one control surface.',
|
||||
liveSignal: 'Live platform signal',
|
||||
readiness: 'Operational readiness',
|
||||
readinessBody: 'Core admin workflows are connected and ready for platform support.',
|
||||
@@ -36,14 +36,14 @@ export default function AdminDashboardPage() {
|
||||
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
|
||||
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'],
|
||||
],
|
||||
health: ['Tenant accounts', 'Marketplace identity', 'Admin audit trail'],
|
||||
health: ['Tenant accounts', 'Storefront identity', 'Admin audit trail'],
|
||||
},
|
||||
fr: {
|
||||
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
||||
brand: 'RentalDriveGo',
|
||||
eyebrow: 'Centre des opérations',
|
||||
platformOverview: 'Tableau de bord admin RentalDriveGo',
|
||||
subtitle: 'Suivez la santé de la marketplace, les abonnements, l’activité des locataires et les actions opérateur depuis une même interface.',
|
||||
subtitle: 'Suivez la santé de la storefront, les abonnements, l’activité des locataires et les actions opérateur depuis une même interface.',
|
||||
liveSignal: 'Signal plateforme en direct',
|
||||
readiness: 'Disponibilité opérationnelle',
|
||||
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.',
|
||||
@@ -55,7 +55,7 @@ export default function AdminDashboardPage() {
|
||||
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
|
||||
['Journaux d’audit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
|
||||
],
|
||||
health: ['Comptes locataires', 'Identité marketplace', 'Piste d’audit admin'],
|
||||
health: ['Comptes locataires', 'Identité storefront', 'Piste d’audit admin'],
|
||||
},
|
||||
ar: {
|
||||
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
cloneMarketplaceHomepageContent,
|
||||
resolveMarketplaceHomepageSections,
|
||||
type MarketplaceHomepageConfig,
|
||||
type MarketplaceHomepageContent,
|
||||
type MarketplaceHomepageSectionType,
|
||||
type MarketplaceLanguage,
|
||||
cloneStorefrontHomepageContent,
|
||||
resolveStorefrontHomepageSections,
|
||||
type StorefrontHomepageConfig,
|
||||
type StorefrontHomepageContent,
|
||||
type StorefrontHomepageSectionType,
|
||||
type StorefrontLanguage,
|
||||
} from '@rentaldrivego/types'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
@@ -35,15 +35,15 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500'
|
||||
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
|
||||
|
||||
function cloneHomepageContent(content: MarketplaceHomepageConfig) {
|
||||
const cloned = JSON.parse(JSON.stringify(content)) as MarketplaceHomepageConfig
|
||||
;(['en', 'fr', 'ar'] as MarketplaceLanguage[]).forEach((language) => {
|
||||
cloned[language].sections = resolveMarketplaceHomepageSections(cloned[language].sections)
|
||||
function cloneHomepageContent(content: StorefrontHomepageConfig) {
|
||||
const cloned = JSON.parse(JSON.stringify(content)) as StorefrontHomepageConfig
|
||||
;(['en', 'fr', 'ar'] as StorefrontLanguage[]).forEach((language) => {
|
||||
cloned[language].sections = resolveStorefrontHomepageSections(cloned[language].sections)
|
||||
})
|
||||
return cloned
|
||||
}
|
||||
|
||||
const HOMEPAGE_SECTIONS: MarketplaceHomepageSectionType[] = [
|
||||
const HOMEPAGE_SECTIONS: StorefrontHomepageSectionType[] = [
|
||||
'hero',
|
||||
'surface',
|
||||
'pillars',
|
||||
@@ -58,12 +58,12 @@ export default function AdminSiteConfigPage() {
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Site configuration',
|
||||
description: 'Edit the main marketplace homepage here, or jump into a company to manage its branded public homepage and menu.',
|
||||
description: 'Edit the main storefront homepage here, or jump into a company to manage its branded public homepage and menu.',
|
||||
homepageTitle: 'Main website homepage',
|
||||
homepageDescription: 'This controls the marketplace homepage shown on the main RentalDriveGo website.',
|
||||
homepageDescription: 'This controls the storefront homepage shown on the main RentalDriveGo website.',
|
||||
saveHomepage: 'Save homepage',
|
||||
savingHomepage: 'Saving…',
|
||||
homepageSaved: 'Marketplace homepage saved.',
|
||||
homepageSaved: 'Storefront homepage saved.',
|
||||
search: 'Search by company or slug…',
|
||||
loading: 'Loading…',
|
||||
empty: 'No companies found',
|
||||
@@ -86,7 +86,7 @@ export default function AdminSiteConfigPage() {
|
||||
previewTitle: 'Live preview',
|
||||
previewDescription: 'Draft changes appear here before you save them.',
|
||||
homepageSections: 'Homepage sections',
|
||||
homepageSectionsDescription: 'Add or remove blocks from the main marketplace homepage.',
|
||||
homepageSectionsDescription: 'Add or remove blocks from the main storefront homepage.',
|
||||
addSection: 'Add',
|
||||
removeSection: 'Remove',
|
||||
expand: 'Expand',
|
||||
@@ -94,12 +94,12 @@ export default function AdminSiteConfigPage() {
|
||||
},
|
||||
fr: {
|
||||
title: 'Configuration du site',
|
||||
description: 'Modifiez ici la page d’accueil principale de la marketplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||||
description: 'Modifiez ici la page d’accueil principale de la storefront, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||||
homepageTitle: 'Page d’accueil du site principal',
|
||||
homepageDescription: 'Cette section contrôle la page d’accueil marketplace affichée sur le site principal de RentalDriveGo.',
|
||||
homepageDescription: 'Cette section contrôle la page d’accueil storefront affichée sur le site principal de RentalDriveGo.',
|
||||
saveHomepage: 'Enregistrer la page d’accueil',
|
||||
savingHomepage: 'Enregistrement…',
|
||||
homepageSaved: 'Page d’accueil marketplace enregistrée.',
|
||||
homepageSaved: 'Page d’accueil storefront enregistrée.',
|
||||
search: 'Rechercher par entreprise ou slug…',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucune entreprise trouvée',
|
||||
@@ -112,7 +112,7 @@ export default function AdminSiteConfigPage() {
|
||||
companiesTitle: 'Sites de marque des entreprises',
|
||||
languageLabel: 'Langue',
|
||||
hero: 'Bloc principal',
|
||||
surface: 'Bloc marketplace',
|
||||
surface: 'Bloc storefront',
|
||||
companySection: 'Bloc opérateur',
|
||||
renterSection: 'Bloc client',
|
||||
valueSection: 'Blocs de valeur',
|
||||
@@ -122,7 +122,7 @@ export default function AdminSiteConfigPage() {
|
||||
previewTitle: 'Aperçu en direct',
|
||||
previewDescription: 'Les brouillons apparaissent ici avant l’enregistrement.',
|
||||
homepageSections: 'Sections de la page d’accueil',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil marketplace principale.',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil storefront principale.',
|
||||
addSection: 'Ajouter',
|
||||
removeSection: 'Retirer',
|
||||
expand: 'Ouvrir',
|
||||
@@ -171,8 +171,8 @@ export default function AdminSiteConfigPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [homepage, setHomepage] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||
const [homepageLanguage, setHomepageLanguage] = useState<MarketplaceLanguage>(language)
|
||||
const [homepage, setHomepage] = useState<StorefrontHomepageConfig>(cloneStorefrontHomepageContent())
|
||||
const [homepageLanguage, setHomepageLanguage] = useState<StorefrontLanguage>(language)
|
||||
const [homepageSaving, setHomepageSaving] = useState(false)
|
||||
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
|
||||
const [homepageExpanded, setHomepageExpanded] = useState(false)
|
||||
@@ -189,7 +189,7 @@ export default function AdminSiteConfigPage() {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
fetch(`${ADMIN_API_BASE}/admin/site-config/storefront-homepage`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
@@ -202,7 +202,7 @@ export default function AdminSiteConfigPage() {
|
||||
|
||||
const homepageJson = await homepageRes.json()
|
||||
if (homepageRes.ok && homepageJson?.data) {
|
||||
setHomepage(cloneHomepageContent(homepageJson.data as MarketplaceHomepageConfig))
|
||||
setHomepage(cloneHomepageContent(homepageJson.data as StorefrontHomepageConfig))
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
@@ -225,7 +225,7 @@ export default function AdminSiteConfigPage() {
|
||||
)
|
||||
}, [search, companies])
|
||||
|
||||
function updateHomepageContent(patch: Partial<MarketplaceHomepageContent>) {
|
||||
function updateHomepageContent(patch: Partial<StorefrontHomepageContent>) {
|
||||
setHomepage((current) => ({
|
||||
...current,
|
||||
[homepageLanguage]: {
|
||||
@@ -257,16 +257,16 @@ export default function AdminSiteConfigPage() {
|
||||
}
|
||||
|
||||
function getActiveSections() {
|
||||
return resolveMarketplaceHomepageSections(activeContent.sections)
|
||||
return resolveStorefrontHomepageSections(activeContent.sections)
|
||||
}
|
||||
|
||||
function addHomepageSection(section: MarketplaceHomepageSectionType) {
|
||||
function addHomepageSection(section: StorefrontHomepageSectionType) {
|
||||
const sections = getActiveSections()
|
||||
if (sections.includes(section)) return
|
||||
updateHomepageContent({ sections: [...sections, section] })
|
||||
}
|
||||
|
||||
function removeHomepageSection(section: MarketplaceHomepageSectionType) {
|
||||
function removeHomepageSection(section: StorefrontHomepageSectionType) {
|
||||
const sections = getActiveSections().filter((item) => item !== section)
|
||||
if (sections.length === 0) return
|
||||
updateHomepageContent({ sections })
|
||||
@@ -276,7 +276,7 @@ export default function AdminSiteConfigPage() {
|
||||
setHomepageSaving(true)
|
||||
setHomepageMessage(null)
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/storefront-homepage`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -286,7 +286,7 @@ export default function AdminSiteConfigPage() {
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
|
||||
setHomepage(cloneHomepageContent(json.data as MarketplaceHomepageConfig))
|
||||
setHomepage(cloneHomepageContent(json.data as StorefrontHomepageConfig))
|
||||
setHomepageMessage(copy.homepageSaved)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
@@ -298,7 +298,7 @@ export default function AdminSiteConfigPage() {
|
||||
const activeContent = homepage[homepageLanguage]
|
||||
const activeSections = getActiveSections()
|
||||
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
|
||||
const sectionLabels: Record<MarketplaceHomepageSectionType, string> = {
|
||||
const sectionLabels: Record<StorefrontHomepageSectionType, string> = {
|
||||
hero: copy.hero,
|
||||
surface: copy.surface,
|
||||
pillars: copy.valueSection,
|
||||
@@ -337,7 +337,7 @@ export default function AdminSiteConfigPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
|
||||
{(['en', 'fr', 'ar'] as MarketplaceLanguage[]).map((value) => (
|
||||
{(['en', 'fr', 'ar'] as StorefrontLanguage[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
|
||||
+4
-4
@@ -28,7 +28,7 @@ import customersRouter from './modules/customers/customer.routes'
|
||||
import vehiclesRouter from './modules/vehicles/vehicle.routes'
|
||||
import companiesRouter from './modules/companies/company.routes'
|
||||
import reservationsRouter from './modules/reservations/reservation.routes'
|
||||
import marketplaceRouter from './modules/marketplace/marketplace.routes'
|
||||
import storefrontRouter from './modules/storefront/storefront.routes'
|
||||
import siteRouter from './modules/site/site.routes'
|
||||
import reviewsRouter from './modules/reviews/review.routes'
|
||||
import complaintsRouter from './modules/complaints/complaint.routes'
|
||||
@@ -95,8 +95,8 @@ const routeDocs = [
|
||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/cities`, description: 'Marketplace cities' },
|
||||
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
|
||||
{ method: 'GET', path: `${v1}/storefront/cities`, description: 'Storefront cities' },
|
||||
{ method: 'GET', path: `${v1}/storefront/search`, description: 'Storefront search' },
|
||||
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
|
||||
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
|
||||
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
|
||||
@@ -187,7 +187,7 @@ export function createApp() {
|
||||
app.use(`${v1}/admin/auth`, authLimiter)
|
||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||
|
||||
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
|
||||
app.use(`${v1}/storefront`, publicLimiter, storefrontRouter)
|
||||
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
||||
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
|
||||
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"marketplaceHomepage": {
|
||||
"storefrontHomepage": {
|
||||
"en": {
|
||||
"sections": [
|
||||
"hero",
|
||||
@@ -13,13 +13,13 @@
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Marketplace discovery with a sharper front door.",
|
||||
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the company’s own branded checkout.",
|
||||
"heroTitle": "Storefront discovery with a sharper front door.",
|
||||
"heroBody": "Rental companies run private operations, renters browse a shared storefront, and every booking ends on the company’s own branded checkout.",
|
||||
"startTrial": "Start free trial",
|
||||
"exploreVehicles": "Explore vehicles",
|
||||
"surfaceLabel": "Marketplace surface",
|
||||
"surfaceLabel": "Storefront 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.",
|
||||
"surfaceBody": "Operators need control, renters need confidence. The storefront should show both without feeling like a template.",
|
||||
"liveLabel": "Live network",
|
||||
"trustedFleets": "Trusted fleets",
|
||||
"brandedFlows": "Branded booking flows",
|
||||
@@ -29,11 +29,11 @@
|
||||
"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, and pay direct.",
|
||||
"renterBody": "The storefront works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Unified publishing",
|
||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages."
|
||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into storefront discovery and branded booking pages."
|
||||
},
|
||||
{
|
||||
"title": "Qualified discovery",
|
||||
@@ -47,7 +47,7 @@
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Shared marketplace visibility"
|
||||
"label": "Shared storefront visibility"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
@@ -61,7 +61,7 @@
|
||||
"featureLabel": "What the product covers",
|
||||
"features": [
|
||||
"Fleet management with multi-photo uploads",
|
||||
"Marketplace offers and redirect booking flow",
|
||||
"Storefront offers and redirect booking flow",
|
||||
"Branded public booking site per company",
|
||||
"Customer CRM, analytics, and billing controls"
|
||||
],
|
||||
@@ -76,7 +76,7 @@
|
||||
{
|
||||
"number": "2",
|
||||
"title": "Add Your Fleet",
|
||||
"description": "Upload vehicle photos, set prices, and create offers visible on the marketplace."
|
||||
"description": "Upload vehicle photos, set prices, and create offers visible on the storefront."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
@@ -94,7 +94,7 @@
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publish vehicles and offers",
|
||||
"body": "Upload photos once and control what appears on the marketplace and branded site."
|
||||
"body": "Upload photos once and control what appears on the storefront and branded site."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
@@ -104,8 +104,8 @@
|
||||
],
|
||||
"stepLabel": "Step",
|
||||
"readyKicker": "Ready to launch",
|
||||
"readyTitle": "The marketplace homepage should explain the product in seconds.",
|
||||
"readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
|
||||
"readyTitle": "The storefront homepage should explain the product in seconds.",
|
||||
"readyBody": "Use the storefront to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
|
||||
"viewPricing": "View pricing",
|
||||
"createWorkspace": "Create workspace"
|
||||
},
|
||||
@@ -122,13 +122,13 @@
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Une vitrine marketplace plus claire et plus percutante.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
|
||||
"heroTitle": "Une vitrine storefront plus claire et plus percutante.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une storefront commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
|
||||
"startTrial": "Commencer l’essai gratuit",
|
||||
"exploreVehicles": "Explorer les véhicules",
|
||||
"surfaceLabel": "Vitrine marketplace",
|
||||
"surfaceLabel": "Vitrine storefront",
|
||||
"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.",
|
||||
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La storefront doit montrer les deux sans ressembler à un modèle générique.",
|
||||
"liveLabel": "Réseau actif",
|
||||
"trustedFleets": "Flottes fiables",
|
||||
"brandedFlows": "Parcours de marque",
|
||||
@@ -138,11 +138,11 @@
|
||||
"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 redirigez-les vers le bon site d’entreprise.",
|
||||
"renterBody": "La marketplace sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"renterBody": "La storefront sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Publication unifiée",
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux d’administration vers la découverte marketplace et les pages de réservation de marque."
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux d’administration vers la découverte storefront et les pages de réservation de marque."
|
||||
},
|
||||
{
|
||||
"title": "Découverte qualifiée",
|
||||
@@ -156,7 +156,7 @@
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Visibilité marketplace partagée"
|
||||
"label": "Visibilité storefront partagée"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
@@ -170,7 +170,7 @@
|
||||
"featureLabel": "Ce que couvre le produit",
|
||||
"features": [
|
||||
"Gestion de flotte avec téléversement de plusieurs photos",
|
||||
"Offres marketplace et redirection vers la réservation",
|
||||
"Offres storefront et redirection vers la réservation",
|
||||
"Site public de réservation par entreprise",
|
||||
"CRM client, analytics et contrôle de facturation"
|
||||
],
|
||||
@@ -185,7 +185,7 @@
|
||||
{
|
||||
"number": "2",
|
||||
"title": "Ajouter votre flotte",
|
||||
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la marketplace."
|
||||
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la storefront."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
@@ -203,7 +203,7 @@
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publiez véhicules et offres",
|
||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque."
|
||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la storefront et le site de marque."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
@@ -213,8 +213,8 @@
|
||||
],
|
||||
"stepLabel": "Étape",
|
||||
"readyKicker": "Prêt à démarrer",
|
||||
"readyTitle": "La page d’accueil marketplace doit présenter le produit en quelques secondes.",
|
||||
"readyBody": "Utilisez la marketplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
|
||||
"readyTitle": "La page d’accueil storefront doit présenter le produit en quelques secondes.",
|
||||
"readyBody": "Utilisez la storefront pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
|
||||
"viewPricing": "Voir les tarifs",
|
||||
"createWorkspace": "Créer l’espace"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatDate,
|
||||
marketplaceReservationEmail,
|
||||
storefrontReservationEmail,
|
||||
resetPasswordEmail,
|
||||
signupEmail,
|
||||
} from './emailTranslations'
|
||||
@@ -40,8 +40,8 @@ describe('emailTranslations', () => {
|
||||
expect(html).toContain('45')
|
||||
})
|
||||
|
||||
it('includes optional contact phone in marketplace reservation HTML when present', () => {
|
||||
const html = marketplaceReservationEmail.html({
|
||||
it('includes optional contact phone in storefront reservation HTML when present', () => {
|
||||
const html = storefrontReservationEmail.html({
|
||||
firstName: 'Yassine',
|
||||
vehicleYear: 2024,
|
||||
vehicleMake: 'Dacia',
|
||||
|
||||
@@ -117,9 +117,9 @@ export const resetPasswordEmail = {
|
||||
}, lang),
|
||||
}
|
||||
|
||||
// ─── Marketplace reservation request ─────────────────────────────────────────
|
||||
// ─── Storefront reservation request ─────────────────────────────────────────
|
||||
|
||||
export const marketplaceReservationEmail = {
|
||||
export const storefrontReservationEmail = {
|
||||
subject: (vehicleName: string, lang: Lang) => t({
|
||||
en: `Reservation Request Received — ${vehicleName}`,
|
||||
fr: `Demande de réservation reçue — ${vehicleName}`,
|
||||
|
||||
@@ -82,7 +82,7 @@ export const apiLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Limiter for public marketplace and site endpoints (no auth)
|
||||
// Limiter for public storefront and site endpoints (no auth)
|
||||
export const publicLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
|
||||
@@ -559,16 +559,16 @@ router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAd
|
||||
|
||||
// ─── Site config ───────────────────────────────────────────────
|
||||
|
||||
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
router.get('/site-config/storefront-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.getMarketplaceHomepage())
|
||||
ok(res, await service.getStorefrontHomepage())
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
router.patch('/site-config/storefront-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { homepage } = parseBody(homepageUpdateSchema, req)
|
||||
ok(res, await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip))
|
||||
ok(res, await service.updateStorefrontHomepage(homepage, req.admin.id, req.ip))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import type { MarketplaceHomepageContent, MarketplaceHomepageHowItWorksStep, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep, MarketplaceHomepageTestimonial } from '@rentaldrivego/types'
|
||||
import type { StorefrontHomepageContent, StorefrontHomepageHowItWorksStep, StorefrontHomepageMetric, StorefrontHomepagePillar, StorefrontHomepageSectionType, StorefrontHomepageStep, StorefrontHomepageTestimonial } from '@rentaldrivego/types'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
@@ -175,35 +175,35 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
|
||||
const marketplacePillarSchema: z.ZodType<MarketplaceHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
|
||||
const marketplaceStepSchema: z.ZodType<MarketplaceHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
|
||||
const marketplaceHowItWorksStepSchema: z.ZodType<MarketplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
|
||||
const marketplaceTestimonialSchema: z.ZodType<MarketplaceHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
|
||||
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
|
||||
const storefrontMetricSchema: z.ZodType<StorefrontHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
|
||||
const storefrontPillarSchema: z.ZodType<StorefrontHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
|
||||
const storefrontStepSchema: z.ZodType<StorefrontHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
|
||||
const storefrontHowItWorksStepSchema: z.ZodType<StorefrontHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
|
||||
const storefrontTestimonialSchema: z.ZodType<StorefrontHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
|
||||
const storefrontSectionSchema: z.ZodType<StorefrontHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
|
||||
|
||||
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
|
||||
sections: z.array(marketplaceSectionSchema).min(1),
|
||||
const storefrontHomepageContentSchema: z.ZodType<StorefrontHomepageContent> = z.object({
|
||||
sections: z.array(storefrontSectionSchema).min(1),
|
||||
heroKicker: z.string().min(1), heroTitle: z.string().min(1), heroBody: z.string().min(1),
|
||||
startTrial: z.string().min(1), exploreVehicles: z.string().min(1),
|
||||
surfaceLabel: z.string().min(1), surfaceTitle: z.string().min(1), surfaceBody: z.string().min(1),
|
||||
liveLabel: z.string().min(1), trustedFleets: z.string().min(1), brandedFlows: z.string().min(1), multiTenant: z.string().min(1),
|
||||
companyKicker: z.string().min(1), companyTitle: z.string().min(1), companyBody: z.string().min(1),
|
||||
renterKicker: z.string().min(1), renterTitle: z.string().min(1), renterBody: z.string().min(1),
|
||||
pillars: z.array(marketplacePillarSchema).length(3),
|
||||
metrics: z.array(marketplaceMetricSchema).length(3),
|
||||
pillars: z.array(storefrontPillarSchema).length(3),
|
||||
metrics: z.array(storefrontMetricSchema).length(3),
|
||||
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1),
|
||||
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(marketplaceHowItWorksStepSchema).min(1),
|
||||
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().min(1),
|
||||
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(marketplaceTestimonialSchema).min(1),
|
||||
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(storefrontHowItWorksStepSchema).min(1),
|
||||
stepsTitle: z.string().min(1), steps: z.array(storefrontStepSchema).length(3), stepLabel: z.string().min(1),
|
||||
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(storefrontTestimonialSchema).min(1),
|
||||
readyKicker: z.string().min(1), readyTitle: z.string().min(1), readyBody: z.string().min(1),
|
||||
viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
|
||||
})
|
||||
|
||||
export const marketplaceHomepageConfigSchema = z.object({
|
||||
en: marketplaceHomepageContentSchema,
|
||||
fr: marketplaceHomepageContentSchema,
|
||||
ar: marketplaceHomepageContentSchema,
|
||||
export const storefrontHomepageConfigSchema = z.object({
|
||||
en: storefrontHomepageContentSchema,
|
||||
fr: storefrontHomepageContentSchema,
|
||||
ar: storefrontHomepageContentSchema,
|
||||
})
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
@@ -300,7 +300,7 @@ export const billingRefundSchema = z.object({
|
||||
})
|
||||
|
||||
export const homepageUpdateSchema = z.object({
|
||||
homepage: marketplaceHomepageConfigSchema,
|
||||
homepage: storefrontHomepageConfigSchema,
|
||||
})
|
||||
|
||||
export const pricingUpdateSchema = z.object({
|
||||
|
||||
@@ -3,7 +3,7 @@ import crypto from 'crypto'
|
||||
import { authenticator } from 'otplib'
|
||||
import { signActorToken } from '../../security/tokens'
|
||||
import qrcode from 'qrcode'
|
||||
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
|
||||
import { getStorefrontHomepageContent, saveStorefrontHomepageContent } from '../../services/platformContentService'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import * as presenter from './admin.presenter'
|
||||
import * as repo from './admin.repo'
|
||||
@@ -402,16 +402,16 @@ export function issueBillingRefund(
|
||||
return billingService.issueRefund(invoiceId, data, adminId, ip)
|
||||
}
|
||||
|
||||
export function getMarketplaceHomepage() {
|
||||
return getMarketplaceHomepageContent()
|
||||
export function getStorefrontHomepage() {
|
||||
return getStorefrontHomepageContent()
|
||||
}
|
||||
|
||||
export async function updateMarketplaceHomepage(homepage: any, adminId: string, ip?: string) {
|
||||
const saved = await saveMarketplaceHomepageContent(homepage)
|
||||
export async function updateStorefrontHomepage(homepage: any, adminId: string, ip?: string) {
|
||||
const saved = await saveStorefrontHomepageContent(homepage)
|
||||
await repo.createAuditLog({
|
||||
adminUserId: adminId,
|
||||
action: 'UPDATE',
|
||||
resource: 'MarketplaceHomepage',
|
||||
resource: 'StorefrontHomepage',
|
||||
after: toAuditJson(saved),
|
||||
ipAddress: ip,
|
||||
userAgent: undefined,
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('../../lib/prisma', () => ({
|
||||
planFeature: { findMany: vi.fn() },
|
||||
},
|
||||
}))
|
||||
vi.mock('../../services/platformContentService', () => ({ getMarketplaceHomepageContent: vi.fn() }))
|
||||
vi.mock('../../services/platformContentService', () => ({ getStorefrontHomepageContent: vi.fn() }))
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
||||
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
|
||||
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AppError, NotFoundError } from '../../http/errors'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { applyPricingRules } from '../../services/pricingRuleService'
|
||||
import { validateLicense } from '../../services/licenseValidationService'
|
||||
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
|
||||
import { getStorefrontHomepageContent } from '../../services/platformContentService'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
@@ -53,7 +53,7 @@ function generateBookingReference() {
|
||||
}
|
||||
|
||||
export async function getPlatformHomepage() {
|
||||
return getMarketplaceHomepageContent()
|
||||
return getStorefrontHomepageContent()
|
||||
}
|
||||
|
||||
export async function getPlatformPricing() {
|
||||
|
||||
@@ -66,7 +66,7 @@ vi.mock('../../services/paypalService', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../services/platformContentService', () => ({
|
||||
getMarketplaceHomepageContent: vi.fn(),
|
||||
getStorefrontHomepageContent: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as repo from './site.repo'
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presentVehicleWithAvailability } from './marketplace.presenter'
|
||||
import { presentVehicleWithAvailability } from './storefront.presenter'
|
||||
|
||||
describe('marketplace.presenter', () => {
|
||||
describe('storefront.presenter', () => {
|
||||
it('adds public availability fields without mutating the original vehicle shape', () => {
|
||||
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
|
||||
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
|
||||
+8
-8
@@ -11,9 +11,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
import * as repo from './marketplace.repo'
|
||||
import * as repo from './storefront.repo'
|
||||
|
||||
describe('marketplace.repo public query and write boundaries', () => {
|
||||
describe('storefront.repo public query and write boundaries', () => {
|
||||
it('finds public offers using active/public/current-window filters and featured ordering', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
|
||||
@@ -33,7 +33,7 @@ describe('marketplace.repo public query and write boundaries', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('lists marketplace cities only from active listed companies with a public city', async () => {
|
||||
it('lists storefront cities only from active listed companies with a public city', async () => {
|
||||
await repo.findCitiesFromCompanies()
|
||||
|
||||
expect(prismaMock.company.findMany).toHaveBeenCalledWith({
|
||||
@@ -45,8 +45,8 @@ describe('marketplace.repo public query and write boundaries', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('requires published vehicles and an active company when loading marketplace vehicle details', async () => {
|
||||
await repo.findVehicleForMarketplace('vehicle_1', 'atlas')
|
||||
it('requires published vehicles and an active company when loading storefront vehicle details', async () => {
|
||||
await repo.findVehicleForStorefront('vehicle_1', 'atlas')
|
||||
|
||||
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
@@ -58,7 +58,7 @@ describe('marketplace.repo public query and write boundaries', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('patches marketplace customer address fields without deleting existing address metadata', async () => {
|
||||
it('patches storefront customer address fields without deleting existing address metadata', async () => {
|
||||
prismaMock.customer.findUnique.mockResolvedValueOnce({
|
||||
id: 'customer_1',
|
||||
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' },
|
||||
@@ -86,11 +86,11 @@ describe('marketplace.repo public query and write boundaries', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('creates marketplace reservations as draft marketplace-sourced records', async () => {
|
||||
it('creates storefront reservations as draft storefront-sourced records', async () => {
|
||||
const startDate = new Date('2026-07-01T10:00:00.000Z')
|
||||
const endDate = new Date('2026-07-05T10:00:00.000Z')
|
||||
|
||||
await repo.createMarketplaceReservation({
|
||||
await repo.createStorefrontReservation({
|
||||
companyId: 'company_1',
|
||||
vehicleId: 'vehicle_1',
|
||||
customerId: 'customer_1',
|
||||
+4
-4
@@ -41,14 +41,14 @@ export async function findPublishedVehicles(where: any) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleForMarketplace(vehicleId: string, companySlug: string) {
|
||||
export async function findVehicleForStorefront(vehicleId: string, companySlug: string) {
|
||||
return prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
|
||||
include: { company: { include: { brand: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findVehicleForMarketplaceById(vehicleId: string) {
|
||||
export async function findVehicleForStorefrontById(vehicleId: string) {
|
||||
return prisma.vehicle.findFirst({
|
||||
where: {
|
||||
id: vehicleId,
|
||||
@@ -115,7 +115,7 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
|
||||
return prisma.customer.update({ where: { id: existing.id }, data: payload })
|
||||
}
|
||||
|
||||
export async function createMarketplaceReservation(data: {
|
||||
export async function createStorefrontReservation(data: {
|
||||
companyId: string; vehicleId: string; customerId: string
|
||||
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
|
||||
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
|
||||
@@ -125,7 +125,7 @@ export async function createMarketplaceReservation(data: {
|
||||
})
|
||||
}
|
||||
|
||||
export async function createMarketplaceFunnelEvent(data: {
|
||||
export async function createStorefrontFunnelEvent(data: {
|
||||
eventName: string
|
||||
companySlug: string
|
||||
vehicleId: string
|
||||
+8
-8
@@ -3,12 +3,12 @@ import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
|
||||
import * as service from './marketplace.service'
|
||||
import * as service from './storefront.service'
|
||||
import {
|
||||
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema,
|
||||
paginationSchema, searchSchema, companiesQuerySchema, storefrontReservationSchema,
|
||||
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
|
||||
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
|
||||
} from './marketplace.schemas'
|
||||
} from './storefront.schemas'
|
||||
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
@@ -55,8 +55,8 @@ router.get('/search', async (req, res, next) => {
|
||||
|
||||
router.post('/reservations', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(marketplaceReservationSchema, req)
|
||||
const result = await service.createMarketplaceReservation(body)
|
||||
const body = parseBody(storefrontReservationSchema, req)
|
||||
const result = await service.createStorefrontReservation(body)
|
||||
created(res, result)
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
@@ -69,7 +69,7 @@ router.post('/reservations', async (req, res, next) => {
|
||||
router.post('/events', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(marketplaceFunnelEventSchema, req)
|
||||
ok(res, await service.trackMarketplaceFunnelEvent(body, req.renterId))
|
||||
ok(res, await service.trackStorefrontFunnelEvent(body, req.renterId))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Analytics events are temporarily unavailable', statusCode: 503 })
|
||||
@@ -112,7 +112,7 @@ router.get('/:slug', async (req, res, next) => {
|
||||
ok(res, await service.getCompanyPage(slug))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 })
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Storefront data is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(vehicleParamSchema, req)
|
||||
const { startDate, endDate } = parseQuery(vehicleAvailabilityQuerySchema, req)
|
||||
ok(res, await service.getMarketplaceVehicleAvailability(slug, id, { startDate, endDate }))
|
||||
ok(res, await service.getStorefrontVehicleAvailability(slug, id, { startDate, endDate }))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { marketplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './marketplace.schemas'
|
||||
import { storefrontReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './storefront.schemas'
|
||||
|
||||
describe('marketplace.schemas', () => {
|
||||
describe('storefront.schemas', () => {
|
||||
it('coerces pagination and caps untrusted public page sizes', () => {
|
||||
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
|
||||
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
|
||||
@@ -18,8 +18,8 @@ describe('marketplace.schemas', () => {
|
||||
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
|
||||
})
|
||||
|
||||
it('defaults marketplace reservation language while keeping date and email validation strict', () => {
|
||||
const parsed = marketplaceReservationSchema.parse({
|
||||
it('defaults storefront reservation language while keeping date and email validation strict', () => {
|
||||
const parsed = storefrontReservationSchema.parse({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
companySlug: 'atlas-cars',
|
||||
firstName: 'Aya',
|
||||
@@ -30,8 +30,8 @@ describe('marketplace.schemas', () => {
|
||||
})
|
||||
|
||||
expect(parsed.language).toBe('fr')
|
||||
expect(() => marketplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
|
||||
expect(() => marketplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
|
||||
expect(() => storefrontReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
|
||||
expect(() => storefrontReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
|
||||
})
|
||||
|
||||
it('validates review ratings as bounded integers', () => {
|
||||
+1
-1
@@ -29,7 +29,7 @@ export const vehicleAvailabilityQuerySchema = z.object({
|
||||
endDate: z.string().datetime(),
|
||||
})
|
||||
|
||||
export const marketplaceReservationSchema = z.object({
|
||||
export const storefrontReservationSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
companySlug: z.string().trim().max(100).optional(),
|
||||
firstName: z.string().min(1).max(100),
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./marketplace.repo', () => ({
|
||||
vi.mock('./storefront.repo', () => ({
|
||||
findCompanyPage: vi.fn(),
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyReviews: vi.fn(),
|
||||
@@ -12,16 +12,16 @@ vi.mock('./marketplace.repo', () => ({
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
||||
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
|
||||
vi.mock('../../lib/emailTranslations', () => ({
|
||||
marketplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
|
||||
storefrontReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
|
||||
}))
|
||||
|
||||
import * as repo from './marketplace.repo'
|
||||
import * as repo from './storefront.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './marketplace.service'
|
||||
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './storefront.service'
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('marketplace.service public page edges', () => {
|
||||
describe('storefront.service public page edges', () => {
|
||||
it('enriches company page vehicles with availability without changing company metadata', async () => {
|
||||
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
|
||||
vi.mocked(repo.findCompanyPage).mockResolvedValue({
|
||||
+13
-13
@@ -1,8 +1,8 @@
|
||||
import { AppError, NotFoundError, ConflictError } from '../../http/errors'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { marketplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import * as repo from './marketplace.repo'
|
||||
import { storefrontReservationEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import * as repo from './storefront.repo'
|
||||
|
||||
function normalizeLocation(value?: string | null) {
|
||||
const normalized = value?.trim()
|
||||
@@ -120,12 +120,12 @@ export async function searchVehicles(params: {
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getMarketplaceVehicleAvailability(
|
||||
export async function getStorefrontVehicleAvailability(
|
||||
slug: string,
|
||||
vehicleId: string,
|
||||
params: { startDate: string; endDate: string },
|
||||
) {
|
||||
const vehicle = await repo.findVehicleForMarketplace(vehicleId, slug)
|
||||
const vehicle = await repo.findVehicleForStorefront(vehicleId, slug)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const startDate = new Date(params.startDate)
|
||||
@@ -154,7 +154,7 @@ function generateBookingReference() {
|
||||
return `BK-${year}-${random}`
|
||||
}
|
||||
|
||||
export async function createMarketplaceReservation(body: {
|
||||
export async function createStorefrontReservation(body: {
|
||||
vehicleId: string; companySlug?: string; firstName: string; lastName: string
|
||||
email: string; phone?: string; dateOfBirth?: string; nationality?: string
|
||||
identityDocumentNumber?: string; fullAddress?: string
|
||||
@@ -170,7 +170,7 @@ export async function createMarketplaceReservation(body: {
|
||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
}
|
||||
|
||||
const vehicle = await repo.findVehicleForMarketplaceById(body.vehicleId)
|
||||
const vehicle = await repo.findVehicleForStorefrontById(body.vehicleId)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
const pickupLocation = normalizeLocation(body.pickupLocation)
|
||||
@@ -218,7 +218,7 @@ export async function createMarketplaceReservation(body: {
|
||||
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||
const totalAmount = vehicle.dailyRate * totalDays
|
||||
|
||||
const reservation = await repo.createMarketplaceReservation({
|
||||
const reservation = await repo.createStorefrontReservation({
|
||||
companyId: vehicle.companyId,
|
||||
vehicleId: body.vehicleId,
|
||||
customerId: customer.id,
|
||||
@@ -245,14 +245,14 @@ export async function createMarketplaceReservation(body: {
|
||||
|
||||
sendTransactionalEmail({
|
||||
to: body.email,
|
||||
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||
html: marketplaceReservationEmail.html(emailOpts, lang),
|
||||
text: marketplaceReservationEmail.text(emailOpts, lang),
|
||||
subject: storefrontReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||
html: storefrontReservationEmail.html(emailOpts, lang),
|
||||
text: storefrontReservationEmail.text(emailOpts, lang),
|
||||
}).catch(() => null)
|
||||
|
||||
sendNotification({
|
||||
type: 'NEW_BOOKING',
|
||||
title: 'New Marketplace Reservation Request',
|
||||
title: 'New Storefront Reservation Request',
|
||||
body: `${body.firstName} ${body.lastName} requested ${vehicle.make} ${vehicle.model} from ${startDate.toISOString().slice(0, 10)} to ${endDate.toISOString().slice(0, 10)}.`,
|
||||
companyId: vehicle.companyId,
|
||||
channels: ['IN_APP'],
|
||||
@@ -270,7 +270,7 @@ export async function createMarketplaceReservation(body: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function trackMarketplaceFunnelEvent(body: {
|
||||
export async function trackStorefrontFunnelEvent(body: {
|
||||
eventName: string
|
||||
companySlug: string
|
||||
vehicleId: string
|
||||
@@ -278,7 +278,7 @@ export async function trackMarketplaceFunnelEvent(body: {
|
||||
path?: string
|
||||
metadata?: Record<string, string | number | boolean | null>
|
||||
}, renterId?: string) {
|
||||
await repo.createMarketplaceFunnelEvent({
|
||||
await repo.createStorefrontFunnelEvent({
|
||||
...body,
|
||||
renterId: renterId ?? null,
|
||||
})
|
||||
+20
-20
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
|
||||
|
||||
vi.mock('./marketplace.repo', () => ({
|
||||
vi.mock('./storefront.repo', () => ({
|
||||
findPublicOffers: vi.fn(),
|
||||
findCitiesFromCompanies: vi.fn(),
|
||||
findListedCompanies: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
findVehicleForMarketplace: vi.fn(),
|
||||
findVehicleForMarketplaceById: vi.fn(),
|
||||
findVehicleForStorefront: vi.fn(),
|
||||
findVehicleForStorefrontById: vi.fn(),
|
||||
upsertMarketplaceCustomer: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
createStorefrontReservation: vi.fn(),
|
||||
findCompanyPage: vi.fn(),
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findCompanyReviews: vi.fn(),
|
||||
@@ -33,19 +33,19 @@ vi.mock('../../services/notificationService', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/emailTranslations', () => ({
|
||||
marketplaceReservationEmail: {
|
||||
storefrontReservationEmail: {
|
||||
subject: vi.fn(() => 'Subject'),
|
||||
html: vi.fn(() => '<html>'),
|
||||
text: vi.fn(() => 'text'),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as repo from './marketplace.repo'
|
||||
import * as repo from './storefront.repo'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import {
|
||||
getCities, searchVehicles, createMarketplaceReservation,
|
||||
getCities, searchVehicles, createStorefrontReservation,
|
||||
getReviewContext, submitReview, validateOfferCode,
|
||||
} from './marketplace.service'
|
||||
} from './storefront.service'
|
||||
|
||||
const SLUG = 'test-company'
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('searchVehicles', () => {
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('createMarketplaceReservation', () => {
|
||||
describe('createStorefrontReservation', () => {
|
||||
const baseBody = {
|
||||
vehicleId: 'v-1', companySlug: SLUG,
|
||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||
@@ -137,12 +137,12 @@ describe('createMarketplaceReservation', () => {
|
||||
}
|
||||
|
||||
it('creates reservation and returns reservationId', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
|
||||
vi.mocked(repo.createStorefrontReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
|
||||
|
||||
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||
const result = await createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||
expect(result.reservationId).toBe('r-1')
|
||||
expect(result.bookingReference).toBe('BK-2026-ABC123')
|
||||
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
||||
@@ -150,36 +150,36 @@ describe('createMarketplaceReservation', () => {
|
||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||
driverLicense: 'DL-123456',
|
||||
}))
|
||||
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(repo.createStorefrontReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
pickupLocation: 'Casablanca',
|
||||
returnLocation: 'Casablanca',
|
||||
}))
|
||||
})
|
||||
|
||||
it('throws NotFoundError when vehicle not found', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(null)
|
||||
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(null)
|
||||
|
||||
await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
||||
await expect(createStorefrontReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
it('throws AppError when vehicle is unavailable', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||
|
||||
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||
await expect(createStorefrontReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||
})
|
||||
|
||||
it('rejects different return locations when the vehicle requires same drop-off', async () => {
|
||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
|
||||
|
||||
await expect(
|
||||
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||
createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||
).rejects.toMatchObject({ error: 'same_dropoff_required' })
|
||||
})
|
||||
|
||||
it('throws AppError for invalid date range', async () => {
|
||||
await expect(
|
||||
createMarketplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
|
||||
createStorefrontReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
|
||||
).rejects.toMatchObject({ error: 'invalid_dates' })
|
||||
})
|
||||
})
|
||||
@@ -27,15 +27,15 @@ describe('platformContentService', () => {
|
||||
it('returns cloned default homepage content when the file does not exist or cannot be parsed', async () => {
|
||||
const service = await importFreshService()
|
||||
|
||||
const first = await service.getMarketplaceHomepageContent()
|
||||
const first = await service.getStorefrontHomepageContent()
|
||||
first.en.heroTitle = 'Mutated by test'
|
||||
|
||||
const second = await service.getMarketplaceHomepageContent()
|
||||
const second = await service.getStorefrontHomepageContent()
|
||||
expect(second.en.sections).toContain('hero')
|
||||
expect(second.en.heroTitle).not.toBe('Mutated by test')
|
||||
})
|
||||
|
||||
it('saves only the marketplace homepage field while preserving unrelated platform content fields', async () => {
|
||||
it('saves only the storefront homepage field while preserving unrelated platform content fields', async () => {
|
||||
const dataPath = path.join(tempDir, 'apps/api/src/data/platform-content.json')
|
||||
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => {
|
||||
await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true }))
|
||||
@@ -43,13 +43,13 @@ describe('platformContentService', () => {
|
||||
})
|
||||
|
||||
const service = await importFreshService()
|
||||
const homepage = await service.getMarketplaceHomepageContent()
|
||||
homepage.en.heroTitle = 'A better marketplace title'
|
||||
const homepage = await service.getStorefrontHomepageContent()
|
||||
homepage.en.heroTitle = 'A better storefront title'
|
||||
|
||||
await expect(service.saveMarketplaceHomepageContent(homepage)).resolves.toBe(homepage)
|
||||
await expect(service.saveStorefrontHomepageContent(homepage)).resolves.toBe(homepage)
|
||||
|
||||
const raw = JSON.parse(await readFile(dataPath, 'utf8'))
|
||||
expect(raw.otherSetting).toEqual({ keep: true })
|
||||
expect(raw.marketplaceHomepage.en.heroTitle).toBe('A better marketplace title')
|
||||
expect(raw.storefrontHomepage.en.heroTitle).toBe('A better storefront title')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { cloneMarketplaceHomepageContent, type MarketplaceHomepageConfig } from '@rentaldrivego/types'
|
||||
import { cloneStorefrontHomepageContent, type StorefrontHomepageConfig } from '@rentaldrivego/types'
|
||||
|
||||
function resolveContentPath() {
|
||||
const cwd = process.cwd()
|
||||
@@ -12,7 +12,7 @@ function resolveContentPath() {
|
||||
const platformContentPath = resolveContentPath()
|
||||
|
||||
type PlatformContentFile = {
|
||||
marketplaceHomepage?: MarketplaceHomepageConfig
|
||||
storefrontHomepage?: StorefrontHomepageConfig
|
||||
}
|
||||
|
||||
async function readPlatformContentFile(): Promise<PlatformContentFile> {
|
||||
@@ -29,16 +29,16 @@ async function writePlatformContentFile(content: PlatformContentFile) {
|
||||
await writeFile(platformContentPath, JSON.stringify(content, null, 2))
|
||||
}
|
||||
|
||||
export async function getMarketplaceHomepageContent() {
|
||||
export async function getStorefrontHomepageContent() {
|
||||
const content = await readPlatformContentFile()
|
||||
return content.marketplaceHomepage ?? cloneMarketplaceHomepageContent()
|
||||
return content.storefrontHomepage ?? cloneStorefrontHomepageContent()
|
||||
}
|
||||
|
||||
export async function saveMarketplaceHomepageContent(homepage: MarketplaceHomepageConfig) {
|
||||
export async function saveStorefrontHomepageContent(homepage: StorefrontHomepageConfig) {
|
||||
const content = await readPlatformContentFile()
|
||||
await writePlatformContentFile({
|
||||
...content,
|
||||
marketplaceHomepage: homepage,
|
||||
storefrontHomepage: homepage,
|
||||
})
|
||||
return homepage
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('OpenAPI document boundary contract', () => {
|
||||
}),
|
||||
}))
|
||||
expect(openApiDocument.tags).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'Marketplace' }),
|
||||
expect.objectContaining({ name: 'Storefront' }),
|
||||
expect.objectContaining({ name: 'Subscriptions' }),
|
||||
expect.objectContaining({ name: 'Admin' }),
|
||||
]))
|
||||
|
||||
@@ -119,7 +119,7 @@ export const openApiDocument: JsonObject = {
|
||||
{ name: 'Companies', description: 'Company profile & settings' },
|
||||
{ name: 'Team', description: 'Employee management' },
|
||||
{ name: 'Subscriptions', description: 'Plan management & billing' },
|
||||
{ name: 'Marketplace', description: 'Public booking portal' },
|
||||
{ name: 'Storefront', description: 'Public booking portal' },
|
||||
{ name: 'Site', description: 'White-label site API' },
|
||||
{ name: 'Admin', description: 'Platform administration' },
|
||||
],
|
||||
@@ -998,18 +998,18 @@ export const openApiDocument: JsonObject = {
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// MARKETPLACE (public — no auth required)
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
'/marketplace/cities': {
|
||||
get: { tags: ['Marketplace'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
|
||||
'/storefront/cities': {
|
||||
get: { tags: ['Storefront'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
|
||||
},
|
||||
'/marketplace/companies': {
|
||||
get: { tags: ['Marketplace'], summary: 'List companies', security: [], responses: { '200': ok } },
|
||||
'/storefront/companies': {
|
||||
get: { tags: ['Storefront'], summary: 'List companies', security: [], responses: { '200': ok } },
|
||||
},
|
||||
'/marketplace/offers': {
|
||||
get: { tags: ['Marketplace'], summary: 'Public offers', security: [], responses: { '200': ok } },
|
||||
'/storefront/offers': {
|
||||
get: { tags: ['Storefront'], summary: 'Public offers', security: [], responses: { '200': ok } },
|
||||
},
|
||||
'/marketplace/search': {
|
||||
'/storefront/search': {
|
||||
get: {
|
||||
tags: ['Marketplace'],
|
||||
tags: ['Storefront'],
|
||||
summary: 'Search available vehicles',
|
||||
security: [],
|
||||
parameters: [
|
||||
@@ -1021,30 +1021,30 @@ export const openApiDocument: JsonObject = {
|
||||
responses: { '200': ok },
|
||||
},
|
||||
},
|
||||
'/marketplace/reservations': {
|
||||
post: { tags: ['Marketplace'], summary: 'Create marketplace booking', security: [], responses: { '201': ok } },
|
||||
'/storefront/reservations': {
|
||||
post: { tags: ['Storefront'], summary: 'Create storefront booking', security: [], responses: { '201': ok } },
|
||||
},
|
||||
'/marketplace/offers/{code}/validate': {
|
||||
post: { tags: ['Marketplace'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/storefront/offers/{code}/validate': {
|
||||
post: { tags: ['Storefront'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
'/marketplace/review/{token}': {
|
||||
get: { tags: ['Marketplace'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
post: { tags: ['Marketplace'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/storefront/review/{token}': {
|
||||
get: { tags: ['Storefront'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
post: { tags: ['Storefront'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
'/marketplace/{slug}': {
|
||||
get: { tags: ['Marketplace'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/storefront/{slug}': {
|
||||
get: { tags: ['Storefront'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
'/marketplace/{slug}/reviews': {
|
||||
get: { tags: ['Marketplace'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/storefront/{slug}/reviews': {
|
||||
get: { tags: ['Storefront'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
'/marketplace/{slug}/vehicles': {
|
||||
get: { tags: ['Marketplace'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/storefront/{slug}/vehicles': {
|
||||
get: { tags: ['Storefront'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
'/marketplace/{slug}/vehicles/{id}': {
|
||||
get: { tags: ['Marketplace'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
|
||||
'/storefront/{slug}/vehicles/{id}': {
|
||||
get: { tags: ['Storefront'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
|
||||
},
|
||||
'/marketplace/{slug}/offers': {
|
||||
get: { tags: ['Marketplace'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
'/storefront/{slug}/offers': {
|
||||
get: { tags: ['Storefront'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||
},
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
+11
-11
@@ -40,12 +40,12 @@ vi.mock('../../modules/notifications/notification.service', () => ({
|
||||
getRenterPreferences: vi.fn(),
|
||||
setRenterPreferences: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/marketplace/marketplace.service', () => ({
|
||||
vi.mock('../../modules/storefront/storefront.service', () => ({
|
||||
getCities: vi.fn(),
|
||||
getListedCompanies: vi.fn(),
|
||||
searchVehicles: vi.fn(),
|
||||
getVehicleDetail: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
createStorefrontReservation: vi.fn(),
|
||||
getCompanyPage: vi.fn(),
|
||||
getCompanyReviews: vi.fn(),
|
||||
getCompanyVehicles: vi.fn(),
|
||||
@@ -61,11 +61,11 @@ import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as employeeService from '../../modules/auth/auth.employee.service'
|
||||
import * as notificationService from '../../modules/notifications/notification.service'
|
||||
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
|
||||
import * as storefrontService from '../../modules/storefront/storefront.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('employee, notification, and marketplace API validation contracts', () => {
|
||||
describe('employee, notification, and storefront API validation contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(employeeService.login).mockResolvedValue({ token: 'jwt', employee: { id: 'employee_1' } } as never)
|
||||
@@ -73,8 +73,8 @@ describe('employee, notification, and marketplace API validation contracts', ()
|
||||
vi.mocked(employeeService.updateLanguage).mockResolvedValue({ language: 'ar' } as never)
|
||||
vi.mocked(employeeService.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } as never)
|
||||
vi.mocked(notificationService.setPreferences).mockResolvedValue({ success: true } as never)
|
||||
vi.mocked(marketplaceService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never)
|
||||
vi.mocked(marketplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
||||
vi.mocked(storefrontService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never)
|
||||
vi.mocked(storefrontService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
||||
})
|
||||
|
||||
it('normalizes employee login email before service execution', async () => {
|
||||
@@ -110,11 +110,11 @@ describe('employee, notification, and marketplace API validation contracts', ()
|
||||
expect(notificationService.setPreferences).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults marketplace search pagination at the public route boundary', async () => {
|
||||
const res = await request(app).get('/api/v1/marketplace/search?city=Casablanca')
|
||||
it('defaults storefront search pagination at the public route boundary', async () => {
|
||||
const res = await request(app).get('/api/v1/storefront/search?city=Casablanca')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(marketplaceService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(storefrontService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
|
||||
city: 'Casablanca',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
@@ -122,7 +122,7 @@ describe('employee, notification, and marketplace API validation contracts', ()
|
||||
})
|
||||
|
||||
it('rejects out-of-range public review ratings before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/marketplace/review/token_123').send({
|
||||
const res = await request(app).post('/api/v1/storefront/review/token_123').send({
|
||||
overallRating: 6,
|
||||
vehicleRating: 5,
|
||||
serviceRating: 5,
|
||||
@@ -130,6 +130,6 @@ describe('employee, notification, and marketplace API validation contracts', ()
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
|
||||
expect(storefrontService.submitReview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -30,12 +30,12 @@ vi.mock('../../modules/site/site.service', () => ({
|
||||
handleContact: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/marketplace/marketplace.service', () => ({
|
||||
vi.mock('../../modules/storefront/storefront.service', () => ({
|
||||
getPublicOffers: vi.fn(),
|
||||
getCities: vi.fn(),
|
||||
getListedCompanies: vi.fn(),
|
||||
searchVehicles: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
createStorefrontReservation: vi.fn(),
|
||||
getReviewContext: vi.fn(),
|
||||
submitReview: vi.fn(),
|
||||
validateOfferCode: vi.fn(),
|
||||
@@ -49,7 +49,7 @@ vi.mock('../../modules/marketplace/marketplace.service', () => ({
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as siteService from '../../modules/site/site.service'
|
||||
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
|
||||
import * as storefrontService from '../../modules/storefront/storefront.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
@@ -59,9 +59,9 @@ describe('public validation API contracts', () => {
|
||||
vi.mocked(siteService.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never)
|
||||
vi.mocked(siteService.initPayment).mockResolvedValue({ checkoutUrl: 'https://pay.example.test' } as never)
|
||||
vi.mocked(siteService.handleContact).mockResolvedValue({ success: true } as never)
|
||||
vi.mocked(marketplaceService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never)
|
||||
vi.mocked(marketplaceService.createMarketplaceReservation).mockResolvedValue({ id: 'reservation_1' } as never)
|
||||
vi.mocked(marketplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
||||
vi.mocked(storefrontService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never)
|
||||
vi.mocked(storefrontService.createStorefrontReservation).mockResolvedValue({ id: 'reservation_1' } as never)
|
||||
vi.mocked(storefrontService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
||||
})
|
||||
|
||||
it('rejects malformed public availability checks before site service execution', async () => {
|
||||
@@ -120,11 +120,11 @@ describe('public validation API contracts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces marketplace search pagination and price filters', async () => {
|
||||
const res = await request(app).get('/api/v1/marketplace/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
|
||||
it('coerces storefront search pagination and price filters', async () => {
|
||||
const res = await request(app).get('/api/v1/storefront/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(marketplaceService.searchVehicles).toHaveBeenCalledWith({
|
||||
expect(storefrontService.searchVehicles).toHaveBeenCalledWith({
|
||||
city: 'Rabat',
|
||||
maxPrice: 500,
|
||||
page: 2,
|
||||
@@ -132,15 +132,15 @@ describe('public validation API contracts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects oversized marketplace pagination before search execution', async () => {
|
||||
const res = await request(app).get('/api/v1/marketplace/search?pageSize=500')
|
||||
it('rejects oversized storefront pagination before search execution', async () => {
|
||||
const res = await request(app).get('/api/v1/storefront/search?pageSize=500')
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(marketplaceService.searchVehicles).not.toHaveBeenCalled()
|
||||
expect(storefrontService.searchVehicles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects marketplace reservations with invalid public contact data before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/marketplace/reservations').send({
|
||||
it('rejects storefront reservations with invalid public contact data before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/storefront/reservations').send({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
companySlug: 'atlas-cars',
|
||||
firstName: 'Aya',
|
||||
@@ -151,13 +151,13 @@ describe('public validation API contracts', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(marketplaceService.createMarketplaceReservation).not.toHaveBeenCalled()
|
||||
expect(storefrontService.createStorefrontReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid public review ratings before creating reviews', async () => {
|
||||
const res = await request(app).post('/api/v1/marketplace/review/token_1').send({ overallRating: 6 })
|
||||
const res = await request(app).post('/api/v1/storefront/review/token_1').send({ overallRating: 6 })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
|
||||
expect(storefrontService.submitReview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('employee and marketplace public boundary smoke', () => {
|
||||
describe('employee and storefront public boundary smoke', () => {
|
||||
it('rejects malformed employee login payloads without leaking internals', async () => {
|
||||
const res = await request(app).post('/api/v1/auth/employee/login').send({ email: 'not-an-email', password: 'x' })
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('employee and marketplace public boundary smoke', () => {
|
||||
})
|
||||
|
||||
it('rejects invalid public review payloads without executing private infrastructure', async () => {
|
||||
const res = await request(app).post('/api/v1/marketplace/review/token_123').send({ overallRating: 0 })
|
||||
const res = await request(app).post('/api/v1/storefront/review/token_123').send({ overallRating: 0 })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(JSON.stringify(res.body)).not.toContain('Prisma')
|
||||
@@ -24,12 +24,12 @@ vi.mock('../../modules/site/site.service', () => ({
|
||||
capturePaypal: vi.fn(),
|
||||
handleContact: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/marketplace/marketplace.service', () => ({
|
||||
vi.mock('../../modules/storefront/storefront.service', () => ({
|
||||
getPublicOffers: vi.fn(),
|
||||
getCities: vi.fn(),
|
||||
getListedCompanies: vi.fn(),
|
||||
searchVehicles: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
createStorefrontReservation: vi.fn(),
|
||||
getReviewContext: vi.fn(),
|
||||
submitReview: vi.fn(),
|
||||
validateOfferCode: vi.fn(),
|
||||
@@ -43,7 +43,7 @@ vi.mock('../../modules/marketplace/marketplace.service', () => ({
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as siteService from '../../modules/site/site.service'
|
||||
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
|
||||
import * as storefrontService from '../../modules/storefront/storefront.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
@@ -61,8 +61,8 @@ describe('public validation e2e smoke', () => {
|
||||
expect(payment.status).toBe(400)
|
||||
expect(siteService.initPayment).not.toHaveBeenCalled()
|
||||
|
||||
const review = await request(app).post('/api/v1/marketplace/review/token_1').send({ overallRating: 0 })
|
||||
const review = await request(app).post('/api/v1/storefront/review/token_1').send({ overallRating: 0 })
|
||||
expect(review.status).toBe(400)
|
||||
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
|
||||
expect(storefrontService.submitReview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('billing, marketplace, and vehicle integration boundaries', () => {
|
||||
describe('billing, storefront, and vehicle integration boundaries', () => {
|
||||
it('documents the Batch-10 DB-backed regression targets for the real integration environment', () => {
|
||||
expect([
|
||||
'admin billing invoice lifecycle uses persisted line items and audit logs',
|
||||
'marketplace company pages enrich vehicles with real availability records',
|
||||
'storefront company pages enrich vehicles with real availability records',
|
||||
'vehicle location settings persist through create/update/list flows',
|
||||
]).toHaveLength(3)
|
||||
})
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('employee, marketplace, and notification integration boundaries', () => {
|
||||
describe('employee, storefront, and notification integration boundaries', () => {
|
||||
it('documents the Batch-17 DB-backed integration boundary', () => {
|
||||
expect({
|
||||
employeeAuth: 'password reset/login flows require generated Prisma and mail-provider test doubles',
|
||||
marketplace: 'public booking/review flows require seeded company, vehicle, reservation, and token records',
|
||||
storefront: 'public booking/review flows require seeded company, vehicle, reservation, and token records',
|
||||
notifications: 'preference persistence requires notificationPreference composite indexes',
|
||||
}).toEqual(expect.objectContaining({
|
||||
employeeAuth: expect.stringContaining('Prisma'),
|
||||
marketplace: expect.stringContaining('seeded'),
|
||||
storefront: expect.stringContaining('seeded'),
|
||||
notifications: expect.stringContaining('composite'),
|
||||
}))
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseBody, parseQuery } from '../../http/validate'
|
||||
import { marketplaceReservationSchema, paginationSchema } from '../../modules/marketplace/marketplace.schemas'
|
||||
import { storefrontReservationSchema, paginationSchema } from '../../modules/storefront/storefront.schemas'
|
||||
import { paySchema } from '../../modules/site/site.schemas'
|
||||
|
||||
function reqWith(body: unknown, query: unknown = {}) {
|
||||
@@ -8,7 +8,7 @@ function reqWith(body: unknown, query: unknown = {}) {
|
||||
}
|
||||
|
||||
describe('public validation boundaries', () => {
|
||||
it('keeps marketplace pagination coercion consistent with route parsing', () => {
|
||||
it('keeps storefront pagination coercion consistent with route parsing', () => {
|
||||
expect(parseQuery(paginationSchema, reqWith({}, { page: '4', pageSize: '25' }))).toEqual({
|
||||
page: 4,
|
||||
pageSize: 25,
|
||||
@@ -24,8 +24,8 @@ describe('public validation boundaries', () => {
|
||||
}))).toThrow('Invalid enum value')
|
||||
})
|
||||
|
||||
it('requires enough anonymous marketplace reservation identity to create a booking request', () => {
|
||||
const parsed = parseBody(marketplaceReservationSchema, reqWith({
|
||||
it('requires enough anonymous storefront reservation identity to create a booking request', () => {
|
||||
const parsed = parseBody(storefrontReservationSchema, reqWith({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
companySlug: 'atlas-cars',
|
||||
firstName: 'Aya',
|
||||
|
||||
@@ -29,7 +29,7 @@ The app covers:
|
||||
|
||||
It is distinct from:
|
||||
|
||||
- the marketplace app, which is public and cross-company
|
||||
- the storefront app, which is public and cross-company
|
||||
- the company site app, which is public and company-branded
|
||||
- the admin app, which is platform-internal rather than tenant-scoped
|
||||
|
||||
@@ -65,7 +65,7 @@ This lets the dashboard run:
|
||||
|
||||
- directly in dev on port `3001`
|
||||
- behind a reverse proxy
|
||||
- embedded under a shared marketplace/admin hostname while still loading its own chunks correctly
|
||||
- embedded under a shared storefront/admin hostname while still loading its own chunks correctly
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
@@ -92,7 +92,7 @@ apps/dashboard/
|
||||
api.ts cookie-aware API fetch wrapper
|
||||
preferences.ts language/theme persistence scoped per employee
|
||||
dashboardPaths.ts normalizes basePath-aware routes
|
||||
urls.ts resolves marketplace/admin app links
|
||||
urls.ts resolves storefront/admin app links
|
||||
src/middleware.ts auth gate and redirect logic
|
||||
```
|
||||
|
||||
@@ -142,7 +142,7 @@ Important behavior:
|
||||
|
||||
- public dashboard routes are limited to sign-in and forgot-password flows
|
||||
- all other `/dashboard/*` routes require the `employee_session` cookie
|
||||
- unauthenticated users are redirected either to the marketplace root or to `/dashboard/sign-in`, depending on host context
|
||||
- unauthenticated users are redirected either to the storefront root or to `/dashboard/sign-in`, depending on host context
|
||||
- duplicate `/dashboard/dashboard` paths are normalized back to `/dashboard`
|
||||
|
||||
This means route protection is enforced before React renders the protected pages.
|
||||
@@ -284,7 +284,7 @@ The onboarding flow then completes the initial tenant setup in three steps:
|
||||
|
||||
1. basic company info via `PATCH /companies/me`
|
||||
2. brand/public profile via `PATCH /companies/me/brand`
|
||||
3. payment and marketplace settings via `PATCH /companies/me/brand`
|
||||
3. payment and storefront settings via `PATCH /companies/me/brand`
|
||||
|
||||
This makes onboarding a dashboard-owned continuation of the API signup flow.
|
||||
|
||||
@@ -604,7 +604,7 @@ This is the broad configuration surface for the tenant.
|
||||
|
||||
It includes:
|
||||
|
||||
- public brand and marketplace profile
|
||||
- public brand and storefront profile
|
||||
- payment account identifiers
|
||||
- custom domain setup
|
||||
- rental policies
|
||||
@@ -645,7 +645,7 @@ Examples in the code:
|
||||
|
||||
It also coordinates with sibling apps:
|
||||
|
||||
- `marketplaceUrl` links staff back to the public marketplace domain
|
||||
- `storefrontUrl` links staff back to the public storefront domain
|
||||
- `adminUrl` supports cookie-based handoff into the admin interface
|
||||
- host-aware URL rewriting allows the same app to function under external domains, internal Docker hostnames, and proxied environments
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ const DASHBOARD_BASE_PATH = '/dashboard'
|
||||
// basePath is required so the client-side router knows it's mounted at /dashboard.
|
||||
// Without it, React cannot hydrate because the URL (/dashboard/sign-up) doesn't
|
||||
// match the route (/sign-up). The Turbopack double-basePath prefetch bug is
|
||||
// handled by the marketplace middleware (307 redirect /dashboard/dashboard → /dashboard).
|
||||
// handled by the storefront middleware (307 redirect /dashboard/dashboard → /dashboard).
|
||||
//
|
||||
// In local development the dashboard is often viewed through the marketplace
|
||||
// In local development the dashboard is often viewed through the storefront
|
||||
// proxy on port 3000 while the dashboard dev server runs on port 3001. Next
|
||||
// rewrites do not reliably proxy HMR WebSocket upgrades, so emit absolute
|
||||
// dashboard asset/HMR URLs directly to the dashboard dev server.
|
||||
|
||||
@@ -113,7 +113,7 @@ function offerToForm(o: Offer): FormState {
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Offers',
|
||||
subtitle: 'Promotions shown on your site and optionally on the marketplace.',
|
||||
subtitle: 'Promotions shown on your site and optionally on the storefront.',
|
||||
createOffer: 'Create Offer',
|
||||
editOffer: 'Edit Offer',
|
||||
deleteOffer: 'Delete Offer',
|
||||
@@ -126,7 +126,7 @@ const copy = {
|
||||
ends: 'ends',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
marketplace: 'Marketplace',
|
||||
storefront: 'Storefront',
|
||||
featured: 'Featured',
|
||||
empty: 'No offers yet. Create your first offer to attract customers.',
|
||||
labelTitle: 'Title *',
|
||||
@@ -146,7 +146,7 @@ const copy = {
|
||||
labelValidFrom: 'Valid From *',
|
||||
labelValidUntil: 'Valid Until *',
|
||||
labelIsActive: 'Active',
|
||||
labelIsPublic: 'Show on marketplace',
|
||||
labelIsPublic: 'Show on storefront',
|
||||
labelIsFeatured: 'Featured',
|
||||
typePERCENTAGE: 'Percentage (%)',
|
||||
typeFIXED_AMOUNT: 'Fixed Amount (MAD)',
|
||||
@@ -174,7 +174,7 @@ const copy = {
|
||||
},
|
||||
fr: {
|
||||
title: 'Offres',
|
||||
subtitle: 'Promotions affichées sur votre site et éventuellement sur la marketplace.',
|
||||
subtitle: 'Promotions affichées sur votre site et éventuellement sur la storefront.',
|
||||
createOffer: 'Créer une offre',
|
||||
editOffer: "Modifier l'offre",
|
||||
deleteOffer: "Supprimer l'offre",
|
||||
@@ -187,7 +187,7 @@ const copy = {
|
||||
ends: 'expire le',
|
||||
active: 'Actif',
|
||||
inactive: 'Inactif',
|
||||
marketplace: 'Marketplace',
|
||||
storefront: 'Storefront',
|
||||
featured: 'Mise en avant',
|
||||
empty: 'Aucune offre. Créez votre première offre pour attirer des clients.',
|
||||
labelTitle: 'Titre *',
|
||||
@@ -207,7 +207,7 @@ const copy = {
|
||||
labelValidFrom: 'Valable du *',
|
||||
labelValidUntil: "Valable jusqu'au *",
|
||||
labelIsActive: 'Actif',
|
||||
labelIsPublic: 'Afficher sur la marketplace',
|
||||
labelIsPublic: 'Afficher sur la storefront',
|
||||
labelIsFeatured: 'Mise en avant',
|
||||
typePERCENTAGE: 'Pourcentage (%)',
|
||||
typeFIXED_AMOUNT: 'Montant fixe (MAD)',
|
||||
@@ -248,7 +248,7 @@ const copy = {
|
||||
ends: 'تنتهي في',
|
||||
active: 'نشط',
|
||||
inactive: 'غير نشط',
|
||||
marketplace: 'السوق',
|
||||
storefront: 'السوق',
|
||||
featured: 'مميز',
|
||||
empty: 'لا توجد عروض بعد. أنشئ أول عرض لجذب العملاء.',
|
||||
labelTitle: 'العنوان *',
|
||||
@@ -514,7 +514,7 @@ export default function OffersPage() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{offer.isPublic && <span className="badge-blue">{t.marketplace}</span>}
|
||||
{offer.isPublic && <span className="badge-blue">{t.storefront}</span>}
|
||||
{offer.isFeatured && <span className="badge-purple">{t.featured}</span>}
|
||||
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
|
||||
{typeof offer.redemptionCount === 'number' && (
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function OnlineReservationsPage() {
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Online Reservations',
|
||||
subtitle: 'Requests submitted by customers through the marketplace. Confirm or decline each one.',
|
||||
subtitle: 'Requests submitted by customers through the storefront. Confirm or decline each one.',
|
||||
refresh: 'Refresh',
|
||||
pendingApproval: 'Pending approval',
|
||||
loading: 'Loading…',
|
||||
@@ -122,7 +122,7 @@ export default function OnlineReservationsPage() {
|
||||
},
|
||||
fr: {
|
||||
title: 'Réservations en ligne',
|
||||
subtitle: 'Demandes envoyées par les clients via la marketplace. Confirmez ou refusez chaque demande.',
|
||||
subtitle: 'Demandes envoyées par les clients via la storefront. Confirmez ou refusez chaque demande.',
|
||||
refresh: 'Actualiser',
|
||||
pendingApproval: 'En attente de validation',
|
||||
loading: 'Chargement…',
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function SettingsPage() {
|
||||
save: 'Saving…',
|
||||
saveBrand: 'Save brand',
|
||||
brandProfile: 'Brand and public profile',
|
||||
brandProfileDesc: 'Control how your company appears on the marketplace and public booking site.',
|
||||
brandProfileDesc: 'Control how your company appears on the storefront and public booking site.',
|
||||
payments: 'Rental payment methods',
|
||||
paymentsDesc: 'Configure how renters pay your company on the public booking site.',
|
||||
savePayments: 'Save payments',
|
||||
@@ -104,7 +104,7 @@ export default function SettingsPage() {
|
||||
contractLanguage: 'Contract language',
|
||||
whatsapp: 'WhatsApp number',
|
||||
brandColor: 'Brand color',
|
||||
listedMarketplace: 'Listed on marketplace',
|
||||
listedStorefront: 'Listed on storefront',
|
||||
logoUpload: 'Logo upload',
|
||||
heroUpload: 'Hero image upload',
|
||||
uploading: 'Uploading…',
|
||||
@@ -155,7 +155,7 @@ export default function SettingsPage() {
|
||||
save: 'Enregistrement…',
|
||||
saveBrand: 'Enregistrer la marque',
|
||||
brandProfile: 'Marque et profil public',
|
||||
brandProfileDesc: 'Contrôlez l’affichage de votre entreprise sur la marketplace et le site de réservation.',
|
||||
brandProfileDesc: 'Contrôlez l’affichage de votre entreprise sur la storefront et le site de réservation.',
|
||||
payments: 'Méthodes de paiement location',
|
||||
paymentsDesc: 'Configurez comment les clients paient votre entreprise.',
|
||||
savePayments: 'Enregistrer les paiements',
|
||||
@@ -179,7 +179,7 @@ export default function SettingsPage() {
|
||||
contractLanguage: 'Langue du contrat',
|
||||
whatsapp: 'Numéro WhatsApp',
|
||||
brandColor: 'Couleur de marque',
|
||||
listedMarketplace: 'Publié sur la marketplace',
|
||||
listedStorefront: 'Publié sur la storefront',
|
||||
logoUpload: 'Logo',
|
||||
heroUpload: 'Image principale',
|
||||
uploading: 'Téléversement…',
|
||||
@@ -254,7 +254,7 @@ export default function SettingsPage() {
|
||||
contractLanguage: 'لغة العقد',
|
||||
whatsapp: 'رقم واتساب',
|
||||
brandColor: 'لون العلامة',
|
||||
listedMarketplace: 'مدرج في السوق',
|
||||
listedStorefront: 'مدرج في السوق',
|
||||
logoUpload: 'رفع الشعار',
|
||||
heroUpload: 'رفع صورة الواجهة',
|
||||
uploading: 'جارٍ الرفع…',
|
||||
@@ -597,7 +597,7 @@ export default function SettingsPage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} />
|
||||
{copy.listedMarketplace}
|
||||
{copy.listedStorefront}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function SubscriptionPage() {
|
||||
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record<string, string>,
|
||||
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
|
||||
planFeatures: {
|
||||
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
|
||||
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Storefront listing'],
|
||||
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
|
||||
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
|
||||
} as Record<Plan, string[]>,
|
||||
@@ -162,7 +162,7 @@ export default function SubscriptionPage() {
|
||||
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record<string, string>,
|
||||
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
|
||||
planFeatures: {
|
||||
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence marketplace'],
|
||||
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence storefront'],
|
||||
GROWTH: ['Jusqu’à 50 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'],
|
||||
PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
|
||||
} as Record<Plan, string[]>,
|
||||
|
||||
@@ -271,7 +271,7 @@ export default function OnboardingPage() {
|
||||
checked={payments.isListedOnMarketplace}
|
||||
onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">List my company on the RentalDriveGo marketplace</span>
|
||||
<span className="text-sm text-slate-700">List my company on the RentalDriveGo storefront</span>
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button>
|
||||
|
||||
@@ -610,7 +610,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
},
|
||||
reservations: {
|
||||
heading: 'Booking',
|
||||
subtitle: 'All booking sources, including dashboard, public site, and marketplace.',
|
||||
subtitle: 'All booking sources, including dashboard, public site, and storefront.',
|
||||
colCustomer: 'Customer',
|
||||
colVehicle: 'Vehicle',
|
||||
colDates: 'Dates',
|
||||
@@ -966,7 +966,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
},
|
||||
reservations: {
|
||||
heading: 'Réservations',
|
||||
subtitle: 'Toutes les sources de réservation : tableau de bord, site public et marketplace.',
|
||||
subtitle: 'Toutes les sources de réservation : tableau de bord, site public et storefront.',
|
||||
colCustomer: 'Client',
|
||||
colVehicle: 'Véhicule',
|
||||
colDates: 'Dates',
|
||||
|
||||
@@ -27,7 +27,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('dashboard cross-app URLs', () => {
|
||||
it('uses default marketplace and admin bases on the server', async () => {
|
||||
it('uses default storefront and admin bases on the server', async () => {
|
||||
const { storefrontUrl, adminUrl } = await loadUrls()
|
||||
|
||||
expect(storefrontUrl).toBe('http://localhost:3000')
|
||||
@@ -54,11 +54,11 @@ describe('dashboard cross-app URLs', () => {
|
||||
|
||||
it('preserves non-URL values after normalizing their trailing slash', async () => {
|
||||
const { storefrontUrl, adminUrl } = await loadUrls({
|
||||
NEXT_PUBLIC_STOREFRONT_URL: '/marketplace/',
|
||||
NEXT_PUBLIC_STOREFRONT_URL: '/storefront/',
|
||||
NEXT_PUBLIC_ADMIN_URL: '/admin/',
|
||||
})
|
||||
|
||||
expect(storefrontUrl).toBe('/marketplace')
|
||||
expect(storefrontUrl).toBe('/storefront')
|
||||
expect(adminUrl).toBe('/admin')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@ function localJwtMiddleware(req: NextRequest): NextResponse {
|
||||
const token = req.cookies.get('employee_session')?.value
|
||||
const pathname = toDashboardAppPath(req.nextUrl.pathname)
|
||||
|
||||
// Redirect signed-in users from sign-in to dashboard (through marketplace proxy)
|
||||
// Redirect signed-in users from sign-in to dashboard (through storefront proxy)
|
||||
if (token && pathname === '/sign-in') {
|
||||
const dashboardUrl = resolveProxyUrl(req, DASHBOARD_BASE_PATH)
|
||||
return NextResponse.redirect(dashboardUrl)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
|
||||
|
||||
// The marketplace proxies /dashboard and /admin to their own Next dev servers.
|
||||
// The storefront proxies /dashboard and /admin to their own Next dev servers.
|
||||
// Allow those asset-prefix origins so proxied pages can load chunks and HMR.
|
||||
const dashboardAssetSource = normalizeAssetPrefix(
|
||||
process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined),
|
||||
|
||||
@@ -16,7 +16,7 @@ function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string
|
||||
expect(page.props.forcedLanguage).toBe(forcedLanguage)
|
||||
}
|
||||
|
||||
describe('marketplace static app policy pages', () => {
|
||||
describe('storefront static app policy pages', () => {
|
||||
it('binds app privacy pages to explicit locales', () => {
|
||||
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
|
||||
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import { storefrontFetchOrDefault } from '@/lib/api'
|
||||
import { getStorefrontLanguage } from '@/lib/i18n.server'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
|
||||
interface CompanyProfile {
|
||||
@@ -21,14 +21,14 @@ interface CompanyProfile {
|
||||
}
|
||||
|
||||
export default async function CompanyProfilePage({ params }: { params: { slug: string } }) {
|
||||
const language = await getMarketplaceLanguage()
|
||||
const language = await getStorefrontLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
unavailable: 'Marketplace unavailable',
|
||||
unavailable: 'Storefront unavailable',
|
||||
unavailableTitle: 'Company details are temporarily unavailable.',
|
||||
unavailableBody: 'The marketplace API is running, but the database is not reachable in this local environment.',
|
||||
unavailableBody: 'The storefront API is running, but the database is not reachable in this local environment.',
|
||||
backToExplore: 'Back to explore',
|
||||
partner: 'Marketplace partner',
|
||||
partner: 'Storefront partner',
|
||||
vehicles: 'vehicles',
|
||||
offers: 'active offers',
|
||||
rating: 'Rating',
|
||||
@@ -42,11 +42,11 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
||||
unavailableNoDate: 'Temporarily unavailable for new reservations.',
|
||||
},
|
||||
fr: {
|
||||
unavailable: 'Marketplace indisponible',
|
||||
unavailable: 'Storefront indisponible',
|
||||
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
|
||||
unavailableBody: "L'API marketplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
|
||||
unavailableBody: "L'API storefront fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
|
||||
backToExplore: "Retour à l'exploration",
|
||||
partner: 'Partenaire marketplace',
|
||||
partner: 'Partenaire storefront',
|
||||
vehicles: 'véhicules',
|
||||
offers: 'offres actives',
|
||||
rating: 'Note',
|
||||
@@ -78,7 +78,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
||||
unavailableNoDate: 'غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
},
|
||||
}[language]
|
||||
const company = await marketplaceFetchOrDefault<CompanyProfile | null>(`/marketplace/${params.slug}`, null)
|
||||
const company = await storefrontFetchOrDefault<CompanyProfile | null>(`/storefront/${params.slug}`, null)
|
||||
|
||||
if (!company) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import { storefrontFetchOrDefault } from '@/lib/api'
|
||||
import { getStorefrontLanguage } from '@/lib/i18n.server'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import BookingForm from '@/components/BookingForm'
|
||||
|
||||
@@ -33,12 +33,12 @@ interface VehicleDetail {
|
||||
}
|
||||
|
||||
export default async function VehicleDetailPage({ params }: { params: { slug: string; id: string } }) {
|
||||
const language = await getMarketplaceLanguage()
|
||||
const language = await getStorefrontLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
unavailable: 'Marketplace unavailable',
|
||||
unavailable: 'Storefront unavailable',
|
||||
unavailableTitle: 'Vehicle details are temporarily unavailable.',
|
||||
unavailableBody: 'The marketplace frontend is running, but the backing database is not reachable right now.',
|
||||
unavailableBody: 'The storefront frontend is running, but the backing database is not reachable right now.',
|
||||
backToFleet: 'Back to fleet',
|
||||
backToExplore: 'Back to explore',
|
||||
noPhotos: 'No photos available.',
|
||||
@@ -59,9 +59,9 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
photos: 'Photos',
|
||||
},
|
||||
fr: {
|
||||
unavailable: 'Marketplace indisponible',
|
||||
unavailable: 'Storefront indisponible',
|
||||
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
|
||||
unavailableBody: "Le frontend marketplace fonctionne, mais la base de données n'est pas accessible pour le moment.",
|
||||
unavailableBody: "Le frontend storefront fonctionne, mais la base de données n'est pas accessible pour le moment.",
|
||||
backToFleet: 'Retour à la flotte',
|
||||
backToExplore: 'Retour à l’exploration',
|
||||
noPhotos: 'Aucune photo disponible.',
|
||||
@@ -106,7 +106,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
},
|
||||
}[language]
|
||||
|
||||
const vehicle = await marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${params.slug}/vehicles/${params.id}`, null)
|
||||
const vehicle = await storefrontFetchOrDefault<VehicleDetail | null>(`/storefront/${params.slug}/vehicles/${params.id}`, null)
|
||||
|
||||
if (!vehicle) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import { storefrontFetchOrDefault } from '@/lib/api'
|
||||
import { getStorefrontLanguage } from '@/lib/i18n.server'
|
||||
import ExploreSearchForm from './ExploreSearchForm'
|
||||
import ExploreVehicleGrid from './ExploreVehicleGrid'
|
||||
|
||||
@@ -68,7 +68,7 @@ const CATEGORY_VALUES = [
|
||||
|
||||
export default async function ExplorePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const resolvedSearchParams = await searchParams
|
||||
const language = await getMarketplaceLanguage()
|
||||
const language = await getStorefrontLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
kicker: 'Discovery only',
|
||||
@@ -89,10 +89,10 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'Search',
|
||||
currentDeals: 'Current deals',
|
||||
featuredOffers: 'Featured marketplace offers',
|
||||
featuredOffers: 'Featured storefront offers',
|
||||
company: 'Company',
|
||||
validUntil: 'Valid until',
|
||||
offersUnavailable: 'Marketplace offers are unavailable right now.',
|
||||
offersUnavailable: 'Storefront offers are unavailable right now.',
|
||||
availableVehicles: 'Available vehicles',
|
||||
listings: 'listings',
|
||||
rentalCompany: 'Rental company',
|
||||
@@ -108,8 +108,8 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
|
||||
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
|
||||
browseByCategory: 'Browse by category',
|
||||
browseByCompany: 'Browse by company',
|
||||
marketplacePartners: 'Marketplace partners',
|
||||
marketplacePartner: 'Marketplace partner',
|
||||
storefrontPartners: 'Storefront partners',
|
||||
storefrontPartner: 'Storefront partner',
|
||||
rating: 'Rating',
|
||||
new: 'New',
|
||||
publishedVehicles: 'published vehicles',
|
||||
@@ -135,10 +135,10 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'Rechercher',
|
||||
currentDeals: 'Offres du moment',
|
||||
featuredOffers: 'Offres marketplace mises en avant',
|
||||
featuredOffers: 'Offres storefront mises en avant',
|
||||
company: 'Entreprise',
|
||||
validUntil: "Valable jusqu'au",
|
||||
offersUnavailable: 'Les offres marketplace sont indisponibles pour le moment.',
|
||||
offersUnavailable: 'Les offres storefront sont indisponibles pour le moment.',
|
||||
availableVehicles: 'Véhicules disponibles',
|
||||
listings: 'annonces',
|
||||
rentalCompany: 'Société de location',
|
||||
@@ -154,8 +154,8 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
|
||||
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',
|
||||
storefrontPartners: 'Partenaires storefront',
|
||||
storefrontPartner: 'Partenaire storefront',
|
||||
rating: 'Note',
|
||||
new: 'Nouveau',
|
||||
publishedVehicles: 'véhicules publiés',
|
||||
@@ -200,8 +200,8 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
|
||||
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
||||
browseByCategory: 'تصفح حسب الفئة',
|
||||
browseByCompany: 'تصفح حسب الشركة',
|
||||
marketplacePartners: 'شركاء السوق',
|
||||
marketplacePartner: 'شريك في السوق',
|
||||
storefrontPartners: 'شركاء السوق',
|
||||
storefrontPartner: 'شريك في السوق',
|
||||
rating: 'التقييم',
|
||||
new: 'جديد',
|
||||
publishedVehicles: 'سيارات منشورة',
|
||||
@@ -241,9 +241,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
|
||||
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', []),
|
||||
storefrontFetchOrDefault<Offer[]>('/storefront/offers', []),
|
||||
storefrontFetchOrDefault<Vehicle[]>(`/storefront/search?${query.toString()}`, []),
|
||||
storefrontFetchOrDefault<CompanyCard[]>('/storefront/companies?pageSize=8', []),
|
||||
])
|
||||
const cities = Array.from(
|
||||
new Set(
|
||||
@@ -309,12 +309,12 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCompany}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.marketplacePartners}</p>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.storefrontPartners}</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{companies.map((company) => (
|
||||
<Link key={company.id} href={`/explore/${company.brand?.subdomain ?? ''}`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
|
||||
<p className="truncate text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{company.brand?.publicCity ?? dict.marketplacePartner}</p>
|
||||
<p className="truncate text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{company.brand?.publicCity ?? dict.storefrontPartner}</p>
|
||||
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{company.brand?.displayName ?? dict.rentalCompany}</h3>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</p>
|
||||
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{company._count.vehicles} {dict.publishedVehicles}</p>
|
||||
|
||||
@@ -13,7 +13,7 @@ import FooterContentPage from '@/components/FooterContentPage'
|
||||
import FooterPage, { generateStaticParams } from './page'
|
||||
import { footerPageSlugs } from '@/lib/footerContent'
|
||||
|
||||
describe('marketplace footer route', () => {
|
||||
describe('storefront footer route', () => {
|
||||
it('generates one static route per registered footer slug', () => {
|
||||
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import MarketplaceHeader from '@/components/MarketplaceHeader'
|
||||
import MarketplaceFooter from '@/components/MarketplaceFooter'
|
||||
import { useMarketplacePreferences, getFooterContent, localeOptions } from '@/components/MarketplaceShell'
|
||||
import StorefrontHeader from '@/components/StorefrontHeader'
|
||||
import StorefrontFooter from '@/components/StorefrontFooter'
|
||||
import { useStorefrontPreferences, getFooterContent, localeOptions } from '@/components/StorefrontShell'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const { language, theme, dict, companyName, setLanguage, setTheme } = useMarketplacePreferences()
|
||||
const { language, theme, dict, companyName, setLanguage, setTheme } = useStorefrontPreferences()
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
const footerContent = getFooterContent(language)
|
||||
const available = localeOptions.filter((o) => o.value !== language)
|
||||
@@ -14,7 +14,7 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
||||
|
||||
return (
|
||||
<div className="site-page flex min-h-screen flex-col">
|
||||
<MarketplaceHeader
|
||||
<StorefrontHeader
|
||||
dict={dict}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
@@ -26,7 +26,7 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<MarketplaceFooter
|
||||
<StorefrontFooter
|
||||
primaryItems={footerContent.primary}
|
||||
secondaryItems={footerContent.secondary}
|
||||
localeLabel={footerContent.localeLabel}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import MarketplaceShell from '@/components/MarketplaceShell'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import StorefrontShell from '@/components/StorefrontShell'
|
||||
import { getStorefrontLanguage } from '@/lib/i18n.server'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Marketplace',
|
||||
title: 'RentalDriveGo Storefront',
|
||||
description: 'Discover vehicles from trusted rental companies.',
|
||||
icons: {
|
||||
icon: '/rentaldrivego.png',
|
||||
@@ -15,11 +15,11 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const language = await getMarketplaceLanguage()
|
||||
const language = await getStorefrontLanguage()
|
||||
const cookieStore = await cookies()
|
||||
const rawTheme =
|
||||
cookieStore.get('rentaldrivego-theme')?.value ??
|
||||
cookieStore.get('marketplace-theme')?.value
|
||||
cookieStore.get('storefront-theme')?.value
|
||||
const theme = rawTheme === 'dark' ? 'dark' : 'light'
|
||||
|
||||
return (
|
||||
@@ -29,12 +29,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('marketplace-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('storefront-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<MarketplaceShell initialLanguage={language} initialTheme={theme}>{children}</MarketplaceShell>
|
||||
<StorefrontShell initialLanguage={language} initialTheme={theme}>{children}</StorefrontShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { useStorefrontPreferences } from '@/components/StorefrontShell'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
@@ -29,7 +29,7 @@ type Status = 'loading' | 'ready' | 'error'
|
||||
|
||||
export default function RenterDashboardPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const { language } = useStorefrontPreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
loadProfile: 'Could not load profile.',
|
||||
@@ -52,7 +52,7 @@ export default function RenterDashboardPage() {
|
||||
savedCompanies: 'Saved companies',
|
||||
noneSaved: 'None saved yet',
|
||||
saved: 'saved',
|
||||
saveCompaniesPrompt: 'Save companies you like while browsing the marketplace.',
|
||||
saveCompaniesPrompt: 'Save companies you like while browsing the storefront.',
|
||||
startExploring: 'Start exploring',
|
||||
rentalCompany: 'Rental company',
|
||||
},
|
||||
@@ -77,7 +77,7 @@ export default function RenterDashboardPage() {
|
||||
savedCompanies: 'Entreprises sauvegardées',
|
||||
noneSaved: 'Aucune pour le moment',
|
||||
saved: 'sauvegardées',
|
||||
saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la marketplace.',
|
||||
saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la storefront.',
|
||||
startExploring: 'Commencer à explorer',
|
||||
rentalCompany: 'Société de location',
|
||||
},
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
markRenterNotificationRead,
|
||||
type RenterNotification,
|
||||
} from '@/lib/renter'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { useStorefrontPreferences } from '@/components/StorefrontShell'
|
||||
|
||||
export default function RenterNotificationsPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const { language } = useStorefrontPreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
load: 'Could not load notifications.',
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type RenterPreference,
|
||||
type RenterProfile,
|
||||
} from '@/lib/renter'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { useStorefrontPreferences } from '@/components/StorefrontShell'
|
||||
|
||||
type Status = 'loading' | 'ready' | 'error'
|
||||
const RENTER_EVENTS = ['BOOKING_CONFIRMED', 'BOOKING_REMINDER', 'RETURN_REMINDER', 'REFUND_ISSUED', 'NEW_OFFER']
|
||||
@@ -20,7 +20,7 @@ const RENTER_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'PUSH', 'IN_APP']
|
||||
|
||||
export default function RenterProfilePage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const { language } = useStorefrontPreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
profile: 'Profile',
|
||||
|
||||
@@ -4,11 +4,11 @@ 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'
|
||||
import { useStorefrontPreferences } from '@/components/StorefrontShell'
|
||||
|
||||
export default function RenterSavedCompaniesPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const { language } = useStorefrontPreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
load: 'Could not load saved companies.',
|
||||
@@ -17,7 +17,7 @@ export default function RenterSavedCompaniesPage() {
|
||||
back: 'Back to dashboard',
|
||||
loading: 'Loading saved companies…',
|
||||
empty: 'You have not saved any companies yet.',
|
||||
browse: 'Browse the marketplace',
|
||||
browse: 'Browse the storefront',
|
||||
rentalCompany: 'Rental company',
|
||||
},
|
||||
fr: {
|
||||
@@ -27,7 +27,7 @@ export default function RenterSavedCompaniesPage() {
|
||||
back: 'Retour au tableau de bord',
|
||||
loading: 'Chargement des entreprises sauvegardées…',
|
||||
empty: 'Vous n’avez encore enregistré aucune entreprise.',
|
||||
browse: 'Parcourir la marketplace',
|
||||
browse: 'Parcourir la storefront',
|
||||
rentalCompany: 'Société de location',
|
||||
},
|
||||
ar: {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { MarketplaceApiError, marketplaceFetch, marketplacePost } from '@/lib/api'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { StorefrontApiError, storefrontFetch, storefrontPost } from '@/lib/api'
|
||||
import { useStorefrontPreferences } from '@/components/StorefrontShell'
|
||||
|
||||
type Props = {
|
||||
vehicleId: string
|
||||
@@ -314,7 +314,7 @@ function formatLocalizedDate(value: string, language: 'en' | 'fr' | 'ar') {
|
||||
function getBookingSessionId() {
|
||||
if (typeof window === 'undefined') return 'server'
|
||||
|
||||
const existing = window.sessionStorage.getItem('marketplace-booking-session-id')
|
||||
const existing = window.sessionStorage.getItem('storefront-booking-session-id')
|
||||
if (existing) return existing
|
||||
|
||||
const created =
|
||||
@@ -322,7 +322,7 @@ function getBookingSessionId() {
|
||||
? window.crypto.randomUUID()
|
||||
: `booking-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
window.sessionStorage.setItem('marketplace-booking-session-id', created)
|
||||
window.sessionStorage.setItem('storefront-booking-session-id', created)
|
||||
return created
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ export default function BookingForm({
|
||||
allowDifferentDropoff,
|
||||
dropoffLocations,
|
||||
}: Props) {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const { language } = useStorefrontPreferences()
|
||||
const t = copy[language]
|
||||
const pickupOptions = Array.isArray(pickupLocations) ? pickupLocations : []
|
||||
const dropoffOptions = Array.isArray(dropoffLocations) ? dropoffLocations : []
|
||||
@@ -400,7 +400,7 @@ export default function BookingForm({
|
||||
}
|
||||
|
||||
async function trackEvent(eventName: FunnelEventName, metadata?: Record<string, string | number | boolean | null>) {
|
||||
await marketplacePost('/marketplace/events', {
|
||||
await storefrontPost('/storefront/events', {
|
||||
eventName,
|
||||
companySlug,
|
||||
vehicleId,
|
||||
@@ -413,8 +413,8 @@ export default function BookingForm({
|
||||
async function checkAvailability(startDate: string, endDate: string, signal?: AbortSignal) {
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
const result = await marketplaceFetch<AvailabilityResponse>(
|
||||
`/marketplace/${companySlug}/vehicles/${vehicleId}/availability?startDate=${encodeURIComponent(start.toISOString())}&endDate=${encodeURIComponent(end.toISOString())}`,
|
||||
const result = await storefrontFetch<AvailabilityResponse>(
|
||||
`/storefront/${companySlug}/vehicles/${vehicleId}/availability?startDate=${encodeURIComponent(start.toISOString())}&endDate=${encodeURIComponent(end.toISOString())}`,
|
||||
signal ? { signal } : undefined,
|
||||
)
|
||||
|
||||
@@ -471,7 +471,7 @@ export default function BookingForm({
|
||||
|
||||
void checkAvailability(fields.startDate, fields.endDate, controller.signal).catch((err) => {
|
||||
if (controller.signal.aborted) return
|
||||
if (err instanceof MarketplaceApiError && err.code === 'invalid_dates') {
|
||||
if (err instanceof StorefrontApiError && err.code === 'invalid_dates') {
|
||||
setAvailability({ status: 'idle', nextAvailableAt: null })
|
||||
return
|
||||
}
|
||||
@@ -520,7 +520,7 @@ export default function BookingForm({
|
||||
return
|
||||
}
|
||||
|
||||
const booking = await marketplacePost<BookingResponse>('/marketplace/reservations', {
|
||||
const booking = await storefrontPost<BookingResponse>('/storefront/reservations', {
|
||||
vehicleId,
|
||||
companySlug,
|
||||
firstName: fields.firstName.trim(),
|
||||
@@ -546,7 +546,7 @@ export default function BookingForm({
|
||||
elapsedMs: Date.now() - startedAtRef.current,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof MarketplaceApiError && err.code === 'unavailable') {
|
||||
if (err instanceof StorefrontApiError && err.code === 'unavailable') {
|
||||
setAvailability({ status: 'unavailable', nextAvailableAt: err.nextAvailableAt ?? null })
|
||||
setError(buildUnavailableMessage(err.nextAvailableAt ?? null))
|
||||
} else {
|
||||
@@ -554,7 +554,7 @@ export default function BookingForm({
|
||||
}
|
||||
|
||||
void trackEvent('booking_request_failed', {
|
||||
reason: err instanceof MarketplaceApiError ? err.code ?? 'request_failed' : 'request_failed',
|
||||
reason: err instanceof StorefrontApiError ? err.code ?? 'request_failed' : 'request_failed',
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
|
||||
@@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
|
||||
|
||||
vi.mock('@/components/MarketplaceShell', () => ({
|
||||
useMarketplacePreferences: () => ({ language: preferenceState.language, theme: 'light' }),
|
||||
vi.mock('@/components/StorefrontShell', () => ({
|
||||
useStorefrontPreferences: () => ({ language: preferenceState.language, theme: 'light' }),
|
||||
}))
|
||||
|
||||
import FooterContentPage from './FooterContentPage'
|
||||
@@ -29,7 +29,7 @@ describe('FooterContentPage', () => {
|
||||
preferenceState.language = 'en'
|
||||
})
|
||||
|
||||
it('uses the current marketplace language when no forced language is provided', () => {
|
||||
it('uses the current storefront language when no forced language is provided', () => {
|
||||
preferenceState.language = 'fr'
|
||||
const page = FooterContentPage({ slug: 'contact-sales' })
|
||||
const text = collectText(page).join(' ')
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { useStorefrontPreferences } from '@/components/StorefrontShell'
|
||||
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
|
||||
import { type MarketplaceLanguage } from '@/lib/i18n'
|
||||
import { type StorefrontLanguage } from '@/lib/i18n'
|
||||
|
||||
const pageMeta = {
|
||||
en: {
|
||||
@@ -53,8 +53,8 @@ function renderParagraphText(paragraph: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: MarketplaceLanguage }) {
|
||||
const { language } = useMarketplacePreferences()
|
||||
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: StorefrontLanguage }) {
|
||||
const { language } = useStorefrontPreferences()
|
||||
const activeLanguage = forcedLanguage ?? language
|
||||
const content = getFooterPageContent(activeLanguage, slug)
|
||||
const meta = pageMeta[activeLanguage]
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Car,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { useStorefrontPreferences } from '@/components/StorefrontShell'
|
||||
import { clearRenterSession, loadRenterProfile } from '@/lib/renter'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -63,7 +63,7 @@ function computeInitials(name: string): string {
|
||||
export default function RenterShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { language } = useMarketplacePreferences()
|
||||
const { language } = useStorefrontPreferences()
|
||||
const dict = dicts[language]
|
||||
const isRtl = language === 'ar'
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ type FooterItem = {
|
||||
href?: string
|
||||
}
|
||||
|
||||
export default function MarketplaceFooter({
|
||||
export default function StorefrontFooter({
|
||||
primaryItems,
|
||||
secondaryItems,
|
||||
localeLabel,
|
||||
+2
-2
@@ -4,9 +4,9 @@ import {
|
||||
companyInitial,
|
||||
localeMenuPositionClass,
|
||||
ownerWorkspaceHref,
|
||||
} from './MarketplaceHeader'
|
||||
} from './StorefrontHeader'
|
||||
|
||||
describe('MarketplaceHeader helpers', () => {
|
||||
describe('StorefrontHeader helpers', () => {
|
||||
it('builds dashboard sign-in links with language and theme query parameters', () => {
|
||||
expect(buildDashboardSignInHref('https://app.example.com/dashboard', 'fr', 'dark')).toBe(
|
||||
'https://app.example.com/dashboard/sign-in?lang=fr&theme=dark'
|
||||
+3
-3
@@ -9,7 +9,7 @@ export type Theme = 'light' | 'dark'
|
||||
export type Language = 'en' | 'fr' | 'ar'
|
||||
|
||||
type Dictionary = {
|
||||
marketplace: string
|
||||
storefront: string
|
||||
explore: string
|
||||
signIn: string
|
||||
ownerSignIn: string
|
||||
@@ -44,7 +44,7 @@ export function companyInitial(companyName: string): string {
|
||||
return companyName.trim().charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
export default function MarketplaceHeader({
|
||||
export default function StorefrontHeader({
|
||||
dict,
|
||||
theme,
|
||||
setTheme,
|
||||
@@ -124,7 +124,7 @@ export default function MarketplaceHeader({
|
||||
href="/"
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{dict.marketplace}
|
||||
{dict.storefront}
|
||||
</Link>
|
||||
<Link
|
||||
href="/explore"
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './MarketplaceShell'
|
||||
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './StorefrontShell'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('MarketplaceShell auth helpers', () => {
|
||||
describe('StorefrontShell auth helpers', () => {
|
||||
it('does not report an employee profile when the browser cache is empty', () => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getFooterContent, localeOptions } from './MarketplaceShell'
|
||||
import { getFooterContent, localeOptions } from './StorefrontShell'
|
||||
|
||||
describe('MarketplaceShell footer content registry', () => {
|
||||
it('exposes exactly the supported marketplace locales in selector order', () => {
|
||||
describe('StorefrontShell footer content registry', () => {
|
||||
it('exposes exactly the supported storefront locales in selector order', () => {
|
||||
expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr'])
|
||||
expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true)
|
||||
})
|
||||
+39
-39
@@ -3,13 +3,13 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { appPrivacyHref, footerPageHref } from '@/lib/footerContent'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isStorefrontLanguage, type StorefrontLanguage } from '@/lib/i18n'
|
||||
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
type Dictionary = {
|
||||
marketplace: string
|
||||
storefront: string
|
||||
explore: string
|
||||
signIn: string
|
||||
ownerSignIn: string
|
||||
@@ -20,31 +20,31 @@ type Dictionary = {
|
||||
preferences: string
|
||||
}
|
||||
|
||||
const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
|
||||
const dictionaries: Record<StorefrontLanguage, Dictionary> = {
|
||||
en: {
|
||||
marketplace: 'Home',
|
||||
explore: 'Marketplace',
|
||||
storefront: 'Home',
|
||||
explore: 'Storefront',
|
||||
signIn: 'Sign in',
|
||||
ownerSignIn: 'Create Agency Space',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
preferences: 'Marketplace preferences',
|
||||
preferences: 'Storefront preferences',
|
||||
},
|
||||
fr: {
|
||||
marketplace: 'Accueil',
|
||||
explore: 'Marketplace',
|
||||
storefront: 'Accueil',
|
||||
explore: 'Storefront',
|
||||
signIn: 'Connexion',
|
||||
ownerSignIn: "Créer un espace d'agence",
|
||||
language: 'Langue',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
preferences: 'Préférences marketplace',
|
||||
preferences: 'Préférences storefront',
|
||||
},
|
||||
ar: {
|
||||
marketplace: 'الرئيسية',
|
||||
storefront: 'الرئيسية',
|
||||
explore: 'السوق',
|
||||
signIn: 'تسجيل الدخول',
|
||||
ownerSignIn: 'إنشاء مساحة الوكالة',
|
||||
@@ -58,13 +58,13 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
|
||||
|
||||
const EMPLOYEE_PROFILE_KEY = 'employee_profile'
|
||||
|
||||
export const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [
|
||||
export const localeOptions: Array<{ value: StorefrontLanguage; label: string; flag: string }> = [
|
||||
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
|
||||
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
|
||||
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
|
||||
]
|
||||
|
||||
export function getFooterContent(language: MarketplaceLanguage): {
|
||||
export function getFooterContent(language: StorefrontLanguage): {
|
||||
primary: Array<{ label: string; href?: string }>
|
||||
secondary: Array<{ label: string; href?: string }>
|
||||
localeLabel: string
|
||||
@@ -158,50 +158,50 @@ function detectBrowserLanguage(): string | null {
|
||||
}
|
||||
|
||||
type PreferencesContextValue = {
|
||||
language: MarketplaceLanguage
|
||||
language: StorefrontLanguage
|
||||
theme: Theme
|
||||
dict: Dictionary
|
||||
companyName: string | null
|
||||
setLanguage: (language: MarketplaceLanguage) => void
|
||||
setLanguage: (language: StorefrontLanguage) => void
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
|
||||
|
||||
export function useMarketplacePreferences() {
|
||||
export function useStorefrontPreferences() {
|
||||
const context = useContext(PreferencesContext)
|
||||
if (!context) throw new Error('useMarketplacePreferences must be used within MarketplaceShell')
|
||||
if (!context) throw new Error('useStorefrontPreferences must be used within StorefrontShell')
|
||||
return context
|
||||
}
|
||||
|
||||
export default function MarketplaceShell({
|
||||
export default function StorefrontShell({
|
||||
children,
|
||||
initialLanguage = 'en',
|
||||
initialTheme = 'light',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
initialLanguage?: MarketplaceLanguage
|
||||
initialLanguage?: StorefrontLanguage
|
||||
initialTheme?: Theme
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
const [language, setLanguageState] = useState<MarketplaceLanguage>(initialLanguage)
|
||||
const [language, setLanguageState] = useState<StorefrontLanguage>(initialLanguage)
|
||||
const [theme, setThemeState] = useState<Theme>(initialTheme)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
|
||||
const previousLanguage = useRef<StorefrontLanguage>(initialLanguage)
|
||||
const [companyName, setCompanyName] = useState<string | null>(null)
|
||||
|
||||
function applyLanguage(nextLanguage: MarketplaceLanguage) {
|
||||
function applyLanguage(nextLanguage: StorefrontLanguage) {
|
||||
if (nextLanguage === language) return
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
document.documentElement.lang = nextLanguage
|
||||
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
|
||||
try {
|
||||
sessionStorage.setItem('marketplace-language', nextLanguage)
|
||||
sessionStorage.setItem('storefront-language', nextLanguage)
|
||||
} catch {}
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['marketplace-language'])
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['storefront-language'])
|
||||
}
|
||||
|
||||
setLanguageState(nextLanguage)
|
||||
@@ -214,7 +214,7 @@ export default function MarketplaceShell({
|
||||
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
|
||||
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
|
||||
document.body.dataset.theme = nextTheme
|
||||
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['marketplace-theme'])
|
||||
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['storefront-theme'])
|
||||
}
|
||||
|
||||
setThemeState(nextTheme)
|
||||
@@ -247,10 +247,10 @@ export default function MarketplaceShell({
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionLanguage(): MarketplaceLanguage | null {
|
||||
function readSessionLanguage(): StorefrontLanguage | null {
|
||||
try {
|
||||
const val = sessionStorage.getItem('marketplace-language')
|
||||
return isMarketplaceLanguage(val) ? val : null
|
||||
const val = sessionStorage.getItem('storefront-language')
|
||||
return isStorefrontLanguage(val) ? val : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -262,7 +262,7 @@ export default function MarketplaceShell({
|
||||
if (sessionLang !== language) setLanguageState(sessionLang)
|
||||
} else {
|
||||
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
|
||||
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
|
||||
if (isStorefrontLanguage(scopedLanguage) && scopedLanguage !== language) {
|
||||
setLanguageState(scopedLanguage)
|
||||
}
|
||||
}
|
||||
@@ -271,7 +271,7 @@ export default function MarketplaceShell({
|
||||
if ((scopedTheme === 'light' || scopedTheme === 'dark') && scopedTheme !== theme) {
|
||||
setThemeState(scopedTheme)
|
||||
} else {
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['storefront-theme'])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,22 +279,22 @@ export default function MarketplaceShell({
|
||||
const sessionLang = readSessionLanguage()
|
||||
if (sessionLang) {
|
||||
setLanguageState(sessionLang)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['marketplace-language'])
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['storefront-language'])
|
||||
} else {
|
||||
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
|
||||
if (isMarketplaceLanguage(storedLanguage)) {
|
||||
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['storefront-language'])
|
||||
if (isStorefrontLanguage(storedLanguage)) {
|
||||
setLanguageState(storedLanguage)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['marketplace-language'])
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['storefront-language'])
|
||||
} else {
|
||||
const detected = detectBrowserLanguage()
|
||||
if (isMarketplaceLanguage(detected)) {
|
||||
if (isStorefrontLanguage(detected)) {
|
||||
setLanguageState(detected)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['marketplace-language'])
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['storefront-language'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
|
||||
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['storefront-theme']) as Theme | null
|
||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||
setThemeState(storedTheme)
|
||||
}
|
||||
@@ -337,8 +337,8 @@ export default function MarketplaceShell({
|
||||
|
||||
if (!hydrated) return
|
||||
|
||||
try { sessionStorage.setItem('marketplace-language', language) } catch {}
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
|
||||
try { sessionStorage.setItem('storefront-language', language) } catch {}
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['storefront-language'])
|
||||
|
||||
if (previousLanguage.current !== language && pathname !== '/sign-in') {
|
||||
router.refresh()
|
||||
@@ -351,7 +351,7 @@ export default function MarketplaceShell({
|
||||
document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light'
|
||||
document.body.dataset.theme = theme
|
||||
if (hydrated) {
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['storefront-theme'])
|
||||
}
|
||||
}, [theme, hydrated])
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('WorkspaceFrame helpers', () => {
|
||||
})
|
||||
|
||||
it('keeps unauthenticated visitors on the embedded sign-in path', () => {
|
||||
stubBrowser({ cookie: 'marketplace-language=fr' })
|
||||
stubBrowser({ cookie: 'storefront-language=fr' })
|
||||
|
||||
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
import { useMarketplacePreferences } from './MarketplaceShell'
|
||||
import { useStorefrontPreferences } from './StorefrontShell'
|
||||
|
||||
export type FrameId = 'sign-in'
|
||||
|
||||
@@ -37,7 +37,7 @@ export function buildFrameUrl(appUrl: string, path: string) {
|
||||
export const FRAME_HEIGHT = 'calc(100vh - 56px)'
|
||||
|
||||
export default function WorkspaceFrame({ target }: { target: FrameId }) {
|
||||
const { language, theme } = useMarketplacePreferences()
|
||||
const { language, theme } = useStorefrontPreferences()
|
||||
const [src, setSrc] = useState('')
|
||||
const [framePath, setFramePath] = useState(() => getDefaultFramePath(target))
|
||||
const config = frameConfig[target]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MarketplaceApiError, marketplaceFetch, marketplaceFetchOrDefault, marketplacePost } from './api'
|
||||
import { StorefrontApiError, storefrontFetch, storefrontFetchOrDefault, storefrontPost } from './api'
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_API_URL
|
||||
@@ -8,19 +8,19 @@ afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
})
|
||||
|
||||
describe('marketplace API helpers', () => {
|
||||
describe('storefront API helpers', () => {
|
||||
it('unwraps successful GET responses from the data envelope', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [{ id: 'vehicle_1' }] }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
await expect(marketplaceFetch('/marketplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/marketplace\/vehicles$/), {
|
||||
await expect(storefrontFetch('/storefront/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/storefront\/vehicles$/), {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws rich marketplace API errors', async () => {
|
||||
it('throws rich storefront API errors', async () => {
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({
|
||||
@@ -30,8 +30,8 @@ describe('marketplace API helpers', () => {
|
||||
})),
|
||||
})
|
||||
|
||||
await expect(marketplaceFetch('/marketplace/book')).rejects.toMatchObject({
|
||||
name: 'MarketplaceApiError',
|
||||
await expect(storefrontFetch('/storefront/book')).rejects.toMatchObject({
|
||||
name: 'StorefrontApiError',
|
||||
message: 'Vehicle unavailable',
|
||||
status: 409,
|
||||
code: 'VEHICLE_UNAVAILABLE',
|
||||
@@ -45,7 +45,7 @@ describe('marketplace API helpers', () => {
|
||||
value: vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ message: 'Down' }) })),
|
||||
})
|
||||
|
||||
await expect(marketplaceFetchOrDefault('/marketplace/homepage', { hero: null })).resolves.toEqual({ hero: null })
|
||||
await expect(storefrontFetchOrDefault('/storefront/homepage', { hero: null })).resolves.toEqual({ hero: null })
|
||||
})
|
||||
|
||||
it('posts JSON payloads and unwraps successful responses', async () => {
|
||||
@@ -53,7 +53,7 @@ describe('marketplace API helpers', () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { id: 'reservation_1' } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
await expect(marketplacePost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
|
||||
await expect(storefrontPost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -68,8 +68,8 @@ describe('marketplace API helpers', () => {
|
||||
value: vi.fn(async () => ({ ok: false, status: 502, json: async () => { throw new Error('bad json') } })),
|
||||
})
|
||||
|
||||
await expect(marketplaceFetch('/site/homepage')).rejects.toMatchObject({
|
||||
name: 'MarketplaceApiError',
|
||||
await expect(storefrontFetch('/site/homepage')).rejects.toMatchObject({
|
||||
name: 'StorefrontApiError',
|
||||
message: 'Request failed',
|
||||
status: 502,
|
||||
})
|
||||
|
||||
@@ -3,40 +3,40 @@ const API_BASE =
|
||||
?? process.env.NEXT_PUBLIC_API_URL
|
||||
?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export class MarketplaceApiError extends Error {
|
||||
export class StorefrontApiError extends Error {
|
||||
status: number
|
||||
code?: string
|
||||
nextAvailableAt?: string | null
|
||||
|
||||
constructor(message: string, status: number, code?: string, nextAvailableAt?: string | null) {
|
||||
super(message)
|
||||
this.name = 'MarketplaceApiError'
|
||||
this.name = 'StorefrontApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.nextAvailableAt = nextAvailableAt
|
||||
}
|
||||
}
|
||||
|
||||
export async function marketplaceFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
export async function storefrontFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
...init,
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
|
||||
if (!res.ok) throw new StorefrontApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
export async function marketplaceFetchOrDefault<T>(path: string, fallback: T): Promise<T> {
|
||||
export async function storefrontFetchOrDefault<T>(path: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
return await marketplaceFetch<T>(path)
|
||||
return await storefrontFetch<T>(path)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export async function marketplacePost<T>(path: string, body: unknown): Promise<T> {
|
||||
export async function storefrontPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
@@ -45,6 +45,6 @@ export async function marketplacePost<T>(path: string, body: unknown): Promise<T
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
|
||||
if (!res.ok) throw new StorefrontApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
})
|
||||
|
||||
describe('marketplace app URL resolution', () => {
|
||||
describe('storefront app URL resolution', () => {
|
||||
it('leaves server-side browser fallback unchanged when window is unavailable', () => {
|
||||
expect(resolveBrowserAppUrl('http://localhost:3000')).toBe('http://localhost:3000')
|
||||
})
|
||||
@@ -29,7 +29,7 @@ describe('marketplace app URL resolution', () => {
|
||||
|
||||
it('keeps path prefixes while rewriting the browser origin', () => {
|
||||
installWindow('rental.example.com', 'https:')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3000/marketplace')).toBe('https://rental.example.com/marketplace')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3000/storefront')).toBe('https://rental.example.com/storefront')
|
||||
})
|
||||
|
||||
it('resolves server URLs from forwarded host/proto and handles invalid fallbacks safely', () => {
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
getFooterPageContent,
|
||||
isFooterPageSlug,
|
||||
} from './footerContent'
|
||||
import type { MarketplaceLanguage } from './i18n'
|
||||
import type { StorefrontLanguage } from './i18n'
|
||||
|
||||
const languages: MarketplaceLanguage[] = ['en', 'fr', 'ar']
|
||||
const languages: StorefrontLanguage[] = ['en', 'fr', 'ar']
|
||||
|
||||
describe('marketplace footer content registry', () => {
|
||||
describe('storefront footer content registry', () => {
|
||||
it('keeps every declared slug routable and recognizable', () => {
|
||||
expect(footerPageSlugs.length).toBeGreaterThan(5)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MarketplaceLanguage } from './i18n'
|
||||
import type { StorefrontLanguage } from './i18n'
|
||||
|
||||
export const footerPageSlugs = [
|
||||
'about-us',
|
||||
@@ -47,7 +47,7 @@ export const footerPageHref: Record<FooterPageSlug, string> = {
|
||||
'general-conditions': '/footer/general-conditions',
|
||||
}
|
||||
|
||||
const footerContent: Record<MarketplaceLanguage, Record<FooterPageSlug, FooterPageContent>> = {
|
||||
const footerContent: Record<StorefrontLanguage, Record<FooterPageSlug, FooterPageContent>> = {
|
||||
en: {
|
||||
'about-us': {
|
||||
title: 'About Us',
|
||||
@@ -175,7 +175,7 @@ const footerContent: Record<MarketplaceLanguage, Record<FooterPageSlug, FooterPa
|
||||
{
|
||||
heading: '5. Legacy Preference Cookies',
|
||||
paragraphs: [
|
||||
'Names: dashboard-language, marketplace-language',
|
||||
'Names: dashboard-language, storefront-language',
|
||||
'These cookies exist for backward compatibility with earlier versions of the platform and store the same language preference as the primary cookie. They are read only as a fallback and will be phased out in a future update.',
|
||||
],
|
||||
},
|
||||
@@ -419,7 +419,7 @@ const footerContent: Record<MarketplaceLanguage, Record<FooterPageSlug, FooterPa
|
||||
{
|
||||
heading: '5. Cookies de préférence hérités',
|
||||
paragraphs: [
|
||||
'Noms : dashboard-language, marketplace-language',
|
||||
'Noms : dashboard-language, storefront-language',
|
||||
'Ces cookies existent pour assurer la compatibilité ascendante avec les versions antérieures de la plateforme et stockent la même préférence de langue que le cookie principal. Ils ne sont lus qu’en secours et seront supprimés dans une prochaine mise à jour.',
|
||||
],
|
||||
},
|
||||
@@ -663,7 +663,7 @@ const footerContent: Record<MarketplaceLanguage, Record<FooterPageSlug, FooterPa
|
||||
{
|
||||
heading: '5. ملفات تعريف الارتباط الموروثة',
|
||||
paragraphs: [
|
||||
'الأسماء: dashboard-language, marketplace-language',
|
||||
'الأسماء: dashboard-language, storefront-language',
|
||||
'توجد هذه الملفات لضمان التوافق مع الإصدارات السابقة من المنصة، وتخزّن تفضيل اللغة ذاته الموجود في الملف الرئيسي. لا تُقرأ إلا كخيار احتياطي، وستُزال في تحديث مستقبلي.',
|
||||
],
|
||||
},
|
||||
@@ -785,6 +785,6 @@ export function isFooterPageSlug(value: string): value is FooterPageSlug {
|
||||
return footerPageSlugs.includes(value as FooterPageSlug)
|
||||
}
|
||||
|
||||
export function getFooterPageContent(language: MarketplaceLanguage, slug: FooterPageSlug): FooterPageContent {
|
||||
export function getFooterPageContent(language: StorefrontLanguage, slug: FooterPageSlug): FooterPageContent {
|
||||
return footerContent[language][slug]
|
||||
}
|
||||
|
||||
@@ -11,31 +11,31 @@ vi.mock('next/headers', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
import { getMarketplaceLanguage } from './i18n.server'
|
||||
import { getStorefrontLanguage } from './i18n.server'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE } from './i18n'
|
||||
|
||||
describe('getMarketplaceLanguage', () => {
|
||||
describe('getStorefrontLanguage', () => {
|
||||
beforeEach(() => {
|
||||
cookieValues.clear()
|
||||
})
|
||||
|
||||
it('prefers the shared language cookie over the legacy marketplace cookie', async () => {
|
||||
it('prefers the shared language cookie over the legacy storefront cookie', async () => {
|
||||
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'ar')
|
||||
cookieValues.set(MARKETPLACE_LANGUAGE_COOKIE, 'fr')
|
||||
|
||||
await expect(getMarketplaceLanguage()).resolves.toBe('ar')
|
||||
await expect(getStorefrontLanguage()).resolves.toBe('ar')
|
||||
})
|
||||
|
||||
it('falls back to the legacy marketplace cookie during migration', async () => {
|
||||
it('falls back to the legacy storefront cookie during migration', async () => {
|
||||
cookieValues.set(MARKETPLACE_LANGUAGE_COOKIE, 'fr')
|
||||
|
||||
await expect(getMarketplaceLanguage()).resolves.toBe('fr')
|
||||
await expect(getStorefrontLanguage()).resolves.toBe('fr')
|
||||
})
|
||||
|
||||
it('defaults to English when cookies are absent or invalid', async () => {
|
||||
await expect(getMarketplaceLanguage()).resolves.toBe('en')
|
||||
await expect(getStorefrontLanguage()).resolves.toBe('en')
|
||||
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'es')
|
||||
|
||||
await expect(getMarketplaceLanguage()).resolves.toBe('en')
|
||||
await expect(getStorefrontLanguage()).resolves.toBe('en')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from './i18n'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isStorefrontLanguage, type StorefrontLanguage } from './i18n'
|
||||
|
||||
export async function getMarketplaceLanguage(): Promise<MarketplaceLanguage> {
|
||||
export async function getStorefrontLanguage(): Promise<StorefrontLanguage> {
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue =
|
||||
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ??
|
||||
cookieStore.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
|
||||
return isMarketplaceLanguage(cookieValue) ? cookieValue : 'en'
|
||||
return isStorefrontLanguage(cookieValue) ? cookieValue : 'en'
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isMarketplaceLanguage } from './i18n'
|
||||
import { isStorefrontLanguage } from './i18n'
|
||||
|
||||
describe('marketplace language guard', () => {
|
||||
it('accepts the supported marketplace locales', () => {
|
||||
expect(isMarketplaceLanguage('en')).toBe(true)
|
||||
expect(isMarketplaceLanguage('fr')).toBe(true)
|
||||
expect(isMarketplaceLanguage('ar')).toBe(true)
|
||||
describe('storefront language guard', () => {
|
||||
it('accepts the supported storefront locales', () => {
|
||||
expect(isStorefrontLanguage('en')).toBe(true)
|
||||
expect(isStorefrontLanguage('fr')).toBe(true)
|
||||
expect(isStorefrontLanguage('ar')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unsupported, missing, and case-mismatched locales', () => {
|
||||
expect(isMarketplaceLanguage('de')).toBe(false)
|
||||
expect(isMarketplaceLanguage('EN')).toBe(false)
|
||||
expect(isMarketplaceLanguage('')).toBe(false)
|
||||
expect(isMarketplaceLanguage(null)).toBe(false)
|
||||
expect(isMarketplaceLanguage(undefined)).toBe(false)
|
||||
expect(isStorefrontLanguage('de')).toBe(false)
|
||||
expect(isStorefrontLanguage('EN')).toBe(false)
|
||||
expect(isStorefrontLanguage('')).toBe(false)
|
||||
expect(isStorefrontLanguage(null)).toBe(false)
|
||||
expect(isStorefrontLanguage(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export type MarketplaceLanguage = 'en' | 'fr' | 'ar'
|
||||
export type StorefrontLanguage = 'en' | 'fr' | 'ar'
|
||||
|
||||
export const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
|
||||
export const MARKETPLACE_LANGUAGE_COOKIE = 'storefront-language'
|
||||
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
||||
|
||||
export function isMarketplaceLanguage(value: string | null | undefined): value is MarketplaceLanguage {
|
||||
export function isStorefrontLanguage(value: string | null | undefined): value is StorefrontLanguage {
|
||||
return value === 'en' || value === 'fr' || value === 'ar'
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'atob')
|
||||
})
|
||||
|
||||
describe('marketplace scoped preferences', () => {
|
||||
describe('storefront scoped preferences', () => {
|
||||
it('returns unscoped keys when no usable token exists', () => {
|
||||
installBrowser()
|
||||
expect(getScopedPreferenceKey('rentaldrivego-language')).toBe('rentaldrivego-language')
|
||||
@@ -52,9 +52,9 @@ describe('marketplace scoped preferences', () => {
|
||||
|
||||
it('reads shared cookies before legacy local-storage fallbacks', () => {
|
||||
const browser = installBrowser('rentaldrivego-language=fr')
|
||||
browser.store.set('marketplace-language', 'ar')
|
||||
browser.store.set('storefront-language', 'ar')
|
||||
|
||||
expect(readScopedPreference('rentaldrivego-language', ['marketplace-language'])).toBe('fr')
|
||||
expect(readScopedPreference('rentaldrivego-language', ['storefront-language'])).toBe('fr')
|
||||
})
|
||||
|
||||
it('writes shared and legacy values without auth-token scoping', () => {
|
||||
|
||||
@@ -63,7 +63,7 @@ beforeEach(() => {
|
||||
nextServer.redirect.mockClear()
|
||||
})
|
||||
|
||||
describe('marketplace proxy language bootstrap', () => {
|
||||
describe('storefront proxy language bootstrap', () => {
|
||||
|
||||
it('rejects proxy subrequest headers before language handling', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
@@ -85,11 +85,11 @@ describe('marketplace proxy language bootstrap', () => {
|
||||
expect(response.cookies.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('migrates the legacy marketplace language cookie into the shared cookie and request headers', async () => {
|
||||
it('migrates the legacy storefront language cookie into the shared cookie and request headers', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
|
||||
const response = proxy(request({
|
||||
cookies: { 'marketplace-language': 'ar' },
|
||||
cookies: { 'storefront-language': 'ar' },
|
||||
headers: { cookie: 'session=abc' },
|
||||
}) as never) as any
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('marketplace proxy language bootstrap', () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
|
||||
const response = proxy(request({
|
||||
cookies: { 'rentaldrivego-language': 'es', 'marketplace-language': 'it' },
|
||||
cookies: { 'rentaldrivego-language': 'es', 'storefront-language': 'it' },
|
||||
headers: { 'accept-language': 'fr-CA,ar;q=0.5' },
|
||||
}) as never) as any
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('marketplace proxy language bootstrap', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('marketplace middleware dashboard path canonicalization', () => {
|
||||
describe('storefront middleware dashboard path canonicalization', () => {
|
||||
it('redirects duplicate dashboard prefixes to the clean dashboard URL', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
||||
const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
|
||||
const MARKETPLACE_LANGUAGE_COOKIE = 'storefront-language'
|
||||
const DASHBOARD_BASE_PATH = '/dashboard'
|
||||
|
||||
function rejectInternalSubrequest(request: NextRequest): NextResponse | null {
|
||||
|
||||
Reference in New Issue
Block a user