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

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:
root
2026-06-28 01:14:46 -04:00
parent bffda95a7c
commit 8aab968e09
147 changed files with 829 additions and 829 deletions
+2 -2
View File
@@ -28,14 +28,14 @@ api_tests:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
marketplace_tests: storefront_tests:
stage: test stage: test
image: node:20-bookworm image: node:20-bookworm
before_script: before_script:
- npm ci - npm ci
- npm run build --workspace @rentaldrivego/types - npm run build --workspace @rentaldrivego/types
script: script:
- npm run test:marketplace - npm run test:storefront
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
+2 -2
View File
@@ -49,13 +49,13 @@
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6" "sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
}, },
{ {
"path": "storefront/src/components/MarketplaceFooter.tsx", "path": "storefront/src/components/StorefrontFooter.tsx",
"status": "modified", "status": "modified",
"bytes": 4632, "bytes": 4632,
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3" "sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
}, },
{ {
"path": "storefront/src/components/MarketplaceHeader.tsx", "path": "storefront/src/components/StorefrontHeader.tsx",
"status": "modified", "status": "modified",
"bytes": 10438, "bytes": 10438,
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213" "sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
@@ -60,15 +60,15 @@ Changed files:
- `apps/api/src/app.ts` - `apps/api/src/app.ts`
- `apps/dashboard/src/middleware.ts` - `apps/dashboard/src/middleware.ts`
- `apps/marketplace/src/middleware.ts` - `apps/storefront/src/middleware.ts`
- `apps/admin/src/middleware.ts` - `apps/admin/src/middleware.ts`
- `apps/dashboard/src/middleware.test.ts` - `apps/dashboard/src/middleware.test.ts`
- `apps/marketplace/src/middleware.test.ts` - `apps/storefront/src/middleware.test.ts`
What changed: What changed:
- API now rejects requests containing `x-middleware-subrequest` before route handling. - API now rejects requests containing `x-middleware-subrequest` before route handling.
- Dashboard, marketplace, and admin Next middleware now reject the same header at the app layer. - Dashboard, storefront, and admin Next middleware now reject the same header at the app layer.
- Added/updated middleware tests for the rejection path. - Added/updated middleware tests for the rejection path.
Security effect: Security effect:
+1 -1
View File
@@ -5,7 +5,7 @@ const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http:
const apiUrl = new URL(apiOrigin) const apiUrl = new URL(apiOrigin)
const ADMIN_BASE_PATH = '/admin' 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 // 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 // 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). // port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
@@ -724,7 +724,7 @@ export default function AdminCompanyDetailPage() {
</label> </label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300"> <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 })} /> <input type="checkbox" checked={form.brand.isListedOnMarketplace} onChange={(e) => updateSection('brand', { isListedOnMarketplace: e.target.checked })} />
Listed on marketplace Listed on storefront
</label> </label>
</div> </div>
</section> </section>
+4 -4
View File
@@ -24,7 +24,7 @@ export default function AdminDashboardPage() {
brand: 'RentalDriveGo', brand: 'RentalDriveGo',
eyebrow: 'Operations command', eyebrow: 'Operations command',
platformOverview: 'RentalDriveGo admin dashboard', 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', liveSignal: 'Live platform signal',
readiness: 'Operational readiness', readiness: 'Operational readiness',
readinessBody: 'Core admin workflows are connected and ready for platform support.', 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'], ['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'], ['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: { fr: {
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'], kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
brand: 'RentalDriveGo', brand: 'RentalDriveGo',
eyebrow: 'Centre des opérations', eyebrow: 'Centre des opérations',
platformOverview: 'Tableau de bord admin RentalDriveGo', platformOverview: 'Tableau de bord admin RentalDriveGo',
subtitle: 'Suivez la santé de la marketplace, les abonnements, lactivité des locataires et les actions opérateur depuis une même interface.', subtitle: 'Suivez la santé de la storefront, les abonnements, lactivité des locataires et les actions opérateur depuis une même interface.',
liveSignal: 'Signal plateforme en direct', liveSignal: 'Signal plateforme en direct',
readiness: 'Disponibilité opérationnelle', readiness: 'Disponibilité opérationnelle',
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.', 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'], ['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
['Journaux daudit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'], ['Journaux daudit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
], ],
health: ['Comptes locataires', 'Identité marketplace', 'Piste daudit admin'], health: ['Comptes locataires', 'Identité storefront', 'Piste daudit admin'],
}, },
ar: { ar: {
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'], kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
@@ -3,12 +3,12 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { import {
cloneMarketplaceHomepageContent, cloneStorefrontHomepageContent,
resolveMarketplaceHomepageSections, resolveStorefrontHomepageSections,
type MarketplaceHomepageConfig, type StorefrontHomepageConfig,
type MarketplaceHomepageContent, type StorefrontHomepageContent,
type MarketplaceHomepageSectionType, type StorefrontHomepageSectionType,
type MarketplaceLanguage, type StorefrontLanguage,
} from '@rentaldrivego/types' } from '@rentaldrivego/types'
import { useAdminI18n } from '@/components/I18nProvider' import { useAdminI18n } from '@/components/I18nProvider'
import { ADMIN_API_BASE } from '@/lib/api' 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 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' const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
function cloneHomepageContent(content: MarketplaceHomepageConfig) { function cloneHomepageContent(content: StorefrontHomepageConfig) {
const cloned = JSON.parse(JSON.stringify(content)) as MarketplaceHomepageConfig const cloned = JSON.parse(JSON.stringify(content)) as StorefrontHomepageConfig
;(['en', 'fr', 'ar'] as MarketplaceLanguage[]).forEach((language) => { ;(['en', 'fr', 'ar'] as StorefrontLanguage[]).forEach((language) => {
cloned[language].sections = resolveMarketplaceHomepageSections(cloned[language].sections) cloned[language].sections = resolveStorefrontHomepageSections(cloned[language].sections)
}) })
return cloned return cloned
} }
const HOMEPAGE_SECTIONS: MarketplaceHomepageSectionType[] = [ const HOMEPAGE_SECTIONS: StorefrontHomepageSectionType[] = [
'hero', 'hero',
'surface', 'surface',
'pillars', 'pillars',
@@ -58,12 +58,12 @@ export default function AdminSiteConfigPage() {
const copy = { const copy = {
en: { en: {
title: 'Site configuration', 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', 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', saveHomepage: 'Save homepage',
savingHomepage: 'Saving…', savingHomepage: 'Saving…',
homepageSaved: 'Marketplace homepage saved.', homepageSaved: 'Storefront homepage saved.',
search: 'Search by company or slug…', search: 'Search by company or slug…',
loading: 'Loading…', loading: 'Loading…',
empty: 'No companies found', empty: 'No companies found',
@@ -86,7 +86,7 @@ export default function AdminSiteConfigPage() {
previewTitle: 'Live preview', previewTitle: 'Live preview',
previewDescription: 'Draft changes appear here before you save them.', previewDescription: 'Draft changes appear here before you save them.',
homepageSections: 'Homepage sections', 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', addSection: 'Add',
removeSection: 'Remove', removeSection: 'Remove',
expand: 'Expand', expand: 'Expand',
@@ -94,12 +94,12 @@ export default function AdminSiteConfigPage() {
}, },
fr: { fr: {
title: 'Configuration du site', title: 'Configuration du site',
description: 'Modifiez ici la page daccueil principale de la marketplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.', description: 'Modifiez ici la page daccueil principale de la storefront, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
homepageTitle: 'Page daccueil du site principal', homepageTitle: 'Page daccueil du site principal',
homepageDescription: 'Cette section contrôle la page daccueil marketplace affichée sur le site principal de RentalDriveGo.', homepageDescription: 'Cette section contrôle la page daccueil storefront affichée sur le site principal de RentalDriveGo.',
saveHomepage: 'Enregistrer la page daccueil', saveHomepage: 'Enregistrer la page daccueil',
savingHomepage: 'Enregistrement…', savingHomepage: 'Enregistrement…',
homepageSaved: 'Page daccueil marketplace enregistrée.', homepageSaved: 'Page daccueil storefront enregistrée.',
search: 'Rechercher par entreprise ou slug…', search: 'Rechercher par entreprise ou slug…',
loading: 'Chargement…', loading: 'Chargement…',
empty: 'Aucune entreprise trouvée', empty: 'Aucune entreprise trouvée',
@@ -112,7 +112,7 @@ export default function AdminSiteConfigPage() {
companiesTitle: 'Sites de marque des entreprises', companiesTitle: 'Sites de marque des entreprises',
languageLabel: 'Langue', languageLabel: 'Langue',
hero: 'Bloc principal', hero: 'Bloc principal',
surface: 'Bloc marketplace', surface: 'Bloc storefront',
companySection: 'Bloc opérateur', companySection: 'Bloc opérateur',
renterSection: 'Bloc client', renterSection: 'Bloc client',
valueSection: 'Blocs de valeur', valueSection: 'Blocs de valeur',
@@ -122,7 +122,7 @@ export default function AdminSiteConfigPage() {
previewTitle: 'Aperçu en direct', previewTitle: 'Aperçu en direct',
previewDescription: 'Les brouillons apparaissent ici avant lenregistrement.', previewDescription: 'Les brouillons apparaissent ici avant lenregistrement.',
homepageSections: 'Sections de la page daccueil', homepageSections: 'Sections de la page daccueil',
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page daccueil marketplace principale.', homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page daccueil storefront principale.',
addSection: 'Ajouter', addSection: 'Ajouter',
removeSection: 'Retirer', removeSection: 'Retirer',
expand: 'Ouvrir', expand: 'Ouvrir',
@@ -171,8 +171,8 @@ export default function AdminSiteConfigPage() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [homepage, setHomepage] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent()) const [homepage, setHomepage] = useState<StorefrontHomepageConfig>(cloneStorefrontHomepageContent())
const [homepageLanguage, setHomepageLanguage] = useState<MarketplaceLanguage>(language) const [homepageLanguage, setHomepageLanguage] = useState<StorefrontLanguage>(language)
const [homepageSaving, setHomepageSaving] = useState(false) const [homepageSaving, setHomepageSaving] = useState(false)
const [homepageMessage, setHomepageMessage] = useState<string | null>(null) const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
const [homepageExpanded, setHomepageExpanded] = useState(false) const [homepageExpanded, setHomepageExpanded] = useState(false)
@@ -189,7 +189,7 @@ export default function AdminSiteConfigPage() {
credentials: 'include', credentials: 'include',
cache: 'no-store', cache: 'no-store',
}), }),
fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, { fetch(`${ADMIN_API_BASE}/admin/site-config/storefront-homepage`, {
credentials: 'include', credentials: 'include',
cache: 'no-store', cache: 'no-store',
}), }),
@@ -202,7 +202,7 @@ export default function AdminSiteConfigPage() {
const homepageJson = await homepageRes.json() const homepageJson = await homepageRes.json()
if (homepageRes.ok && homepageJson?.data) { if (homepageRes.ok && homepageJson?.data) {
setHomepage(cloneHomepageContent(homepageJson.data as MarketplaceHomepageConfig)) setHomepage(cloneHomepageContent(homepageJson.data as StorefrontHomepageConfig))
} }
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
@@ -225,7 +225,7 @@ export default function AdminSiteConfigPage() {
) )
}, [search, companies]) }, [search, companies])
function updateHomepageContent(patch: Partial<MarketplaceHomepageContent>) { function updateHomepageContent(patch: Partial<StorefrontHomepageContent>) {
setHomepage((current) => ({ setHomepage((current) => ({
...current, ...current,
[homepageLanguage]: { [homepageLanguage]: {
@@ -257,16 +257,16 @@ export default function AdminSiteConfigPage() {
} }
function getActiveSections() { function getActiveSections() {
return resolveMarketplaceHomepageSections(activeContent.sections) return resolveStorefrontHomepageSections(activeContent.sections)
} }
function addHomepageSection(section: MarketplaceHomepageSectionType) { function addHomepageSection(section: StorefrontHomepageSectionType) {
const sections = getActiveSections() const sections = getActiveSections()
if (sections.includes(section)) return if (sections.includes(section)) return
updateHomepageContent({ sections: [...sections, section] }) updateHomepageContent({ sections: [...sections, section] })
} }
function removeHomepageSection(section: MarketplaceHomepageSectionType) { function removeHomepageSection(section: StorefrontHomepageSectionType) {
const sections = getActiveSections().filter((item) => item !== section) const sections = getActiveSections().filter((item) => item !== section)
if (sections.length === 0) return if (sections.length === 0) return
updateHomepageContent({ sections }) updateHomepageContent({ sections })
@@ -276,7 +276,7 @@ export default function AdminSiteConfigPage() {
setHomepageSaving(true) setHomepageSaving(true)
setHomepageMessage(null) setHomepageMessage(null)
try { 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', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -286,7 +286,7 @@ export default function AdminSiteConfigPage() {
}) })
const json = await res.json() const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage') 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) setHomepageMessage(copy.homepageSaved)
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
@@ -298,7 +298,7 @@ export default function AdminSiteConfigPage() {
const activeContent = homepage[homepageLanguage] const activeContent = homepage[homepageLanguage]
const activeSections = getActiveSections() const activeSections = getActiveSections()
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section)) const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
const sectionLabels: Record<MarketplaceHomepageSectionType, string> = { const sectionLabels: Record<StorefrontHomepageSectionType, string> = {
hero: copy.hero, hero: copy.hero,
surface: copy.surface, surface: copy.surface,
pillars: copy.valueSection, 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">
<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> <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 <button
key={value} key={value}
type="button" type="button"
+4 -4
View File
@@ -28,7 +28,7 @@ import customersRouter from './modules/customers/customer.routes'
import vehiclesRouter from './modules/vehicles/vehicle.routes' import vehiclesRouter from './modules/vehicles/vehicle.routes'
import companiesRouter from './modules/companies/company.routes' import companiesRouter from './modules/companies/company.routes'
import reservationsRouter from './modules/reservations/reservation.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 siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.routes' import reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.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/dashboard`, description: 'Dashboard analytics' },
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' }, { method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' }, { method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
{ method: 'GET', path: `${v1}/marketplace/cities`, description: 'Marketplace cities' }, { method: 'GET', path: `${v1}/storefront/cities`, description: 'Storefront cities' },
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' }, { 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}/site/:slug/brand`, description: 'Public site brand config' },
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' }, { method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' }, { 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/auth`, authLimiter)
app.use(`${v1}/admin`, adminLimiter, adminRouter) 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}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter) app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter) app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
+25 -25
View File
@@ -1,5 +1,5 @@
{ {
"marketplaceHomepage": { "storefrontHomepage": {
"en": { "en": {
"sections": [ "sections": [
"hero", "hero",
@@ -13,13 +13,13 @@
"closing" "closing"
], ],
"heroKicker": "RentalDriveGo", "heroKicker": "RentalDriveGo",
"heroTitle": "Marketplace discovery with a sharper front door.", "heroTitle": "Storefront discovery with a sharper front door.",
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the companys own branded checkout.", "heroBody": "Rental companies run private operations, renters browse a shared storefront, and every booking ends on the companys own branded checkout.",
"startTrial": "Start free trial", "startTrial": "Start free trial",
"exploreVehicles": "Explore vehicles", "exploreVehicles": "Explore vehicles",
"surfaceLabel": "Marketplace surface", "surfaceLabel": "Storefront surface",
"surfaceTitle": "Designed for two audiences at once.", "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", "liveLabel": "Live network",
"trustedFleets": "Trusted fleets", "trustedFleets": "Trusted fleets",
"brandedFlows": "Branded booking flows", "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.", "companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
"renterKicker": "Renter experience", "renterKicker": "Renter experience",
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.", "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": [ "pillars": [
{ {
"title": "Unified publishing", "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", "title": "Qualified discovery",
@@ -47,7 +47,7 @@
"metrics": [ "metrics": [
{ {
"value": "01", "value": "01",
"label": "Shared marketplace visibility" "label": "Shared storefront visibility"
}, },
{ {
"value": "02", "value": "02",
@@ -61,7 +61,7 @@
"featureLabel": "What the product covers", "featureLabel": "What the product covers",
"features": [ "features": [
"Fleet management with multi-photo uploads", "Fleet management with multi-photo uploads",
"Marketplace offers and redirect booking flow", "Storefront offers and redirect booking flow",
"Branded public booking site per company", "Branded public booking site per company",
"Customer CRM, analytics, and billing controls" "Customer CRM, analytics, and billing controls"
], ],
@@ -76,7 +76,7 @@
{ {
"number": "2", "number": "2",
"title": "Add Your Fleet", "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", "number": "3",
@@ -94,7 +94,7 @@
{ {
"step": "2", "step": "2",
"title": "Publish vehicles and offers", "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", "step": "3",
@@ -104,8 +104,8 @@
], ],
"stepLabel": "Step", "stepLabel": "Step",
"readyKicker": "Ready to launch", "readyKicker": "Ready to launch",
"readyTitle": "The marketplace homepage should explain the product in seconds.", "readyTitle": "The storefront 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.", "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", "viewPricing": "View pricing",
"createWorkspace": "Create workspace" "createWorkspace": "Create workspace"
}, },
@@ -122,13 +122,13 @@
"closing" "closing"
], ],
"heroKicker": "RentalDriveGo", "heroKicker": "RentalDriveGo",
"heroTitle": "Une vitrine marketplace plus claire et plus percutante.", "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 marketplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.", "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 lessai gratuit", "startTrial": "Commencer lessai gratuit",
"exploreVehicles": "Explorer les véhicules", "exploreVehicles": "Explorer les véhicules",
"surfaceLabel": "Vitrine marketplace", "surfaceLabel": "Vitrine storefront",
"surfaceTitle": "Pensée pour deux audiences à la fois.", "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", "liveLabel": "Réseau actif",
"trustedFleets": "Flottes fiables", "trustedFleets": "Flottes fiables",
"brandedFlows": "Parcours de marque", "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.", "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", "renterKicker": "Expérience client",
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site dentreprise.", "renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site dentreprise.",
"renterBody": "La marketplace sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.", "renterBody": "La storefront sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
"pillars": [ "pillars": [
{ {
"title": "Publication unifiée", "title": "Publication unifiée",
"body": "Les photos, tarifs et offres circulent depuis un seul flux dadministration vers la découverte marketplace et les pages de réservation de marque." "body": "Les photos, tarifs et offres circulent depuis un seul flux dadministration vers la découverte storefront et les pages de réservation de marque."
}, },
{ {
"title": "Découverte qualifiée", "title": "Découverte qualifiée",
@@ -156,7 +156,7 @@
"metrics": [ "metrics": [
{ {
"value": "01", "value": "01",
"label": "Visibilité marketplace partagée" "label": "Visibilité storefront partagée"
}, },
{ {
"value": "02", "value": "02",
@@ -170,7 +170,7 @@
"featureLabel": "Ce que couvre le produit", "featureLabel": "Ce que couvre le produit",
"features": [ "features": [
"Gestion de flotte avec téléversement de plusieurs photos", "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", "Site public de réservation par entreprise",
"CRM client, analytics et contrôle de facturation" "CRM client, analytics et contrôle de facturation"
], ],
@@ -185,7 +185,7 @@
{ {
"number": "2", "number": "2",
"title": "Ajouter votre flotte", "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", "number": "3",
@@ -203,7 +203,7 @@
{ {
"step": "2", "step": "2",
"title": "Publiez véhicules et offres", "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", "step": "3",
@@ -213,8 +213,8 @@
], ],
"stepLabel": "Étape", "stepLabel": "Étape",
"readyKicker": "Prêt à démarrer", "readyKicker": "Prêt à démarrer",
"readyTitle": "La page daccueil marketplace doit présenter le produit en quelques secondes.", "readyTitle": "La page daccueil storefront 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.", "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", "viewPricing": "Voir les tarifs",
"createWorkspace": "Créer lespace" "createWorkspace": "Créer lespace"
}, },
+3 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
formatDate, formatDate,
marketplaceReservationEmail, storefrontReservationEmail,
resetPasswordEmail, resetPasswordEmail,
signupEmail, signupEmail,
} from './emailTranslations' } from './emailTranslations'
@@ -40,8 +40,8 @@ describe('emailTranslations', () => {
expect(html).toContain('45') expect(html).toContain('45')
}) })
it('includes optional contact phone in marketplace reservation HTML when present', () => { it('includes optional contact phone in storefront reservation HTML when present', () => {
const html = marketplaceReservationEmail.html({ const html = storefrontReservationEmail.html({
firstName: 'Yassine', firstName: 'Yassine',
vehicleYear: 2024, vehicleYear: 2024,
vehicleMake: 'Dacia', vehicleMake: 'Dacia',
+2 -2
View File
@@ -117,9 +117,9 @@ export const resetPasswordEmail = {
}, lang), }, lang),
} }
// ─── Marketplace reservation request ───────────────────────────────────────── // ─── Storefront reservation request ─────────────────────────────────────────
export const marketplaceReservationEmail = { export const storefrontReservationEmail = {
subject: (vehicleName: string, lang: Lang) => t({ subject: (vehicleName: string, lang: Lang) => t({
en: `Reservation Request Received — ${vehicleName}`, en: `Reservation Request Received — ${vehicleName}`,
fr: `Demande de réservation reçue — ${vehicleName}`, fr: `Demande de réservation reçue — ${vehicleName}`,
+1 -1
View File
@@ -82,7 +82,7 @@ export const apiLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 }, 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({ export const publicLimiter = rateLimit({
windowMs: 60 * 1000, windowMs: 60 * 1000,
max: 60, max: 60,
+4 -4
View File
@@ -559,16 +559,16 @@ router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAd
// ─── Site config ─────────────────────────────────────────────── // ─── 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 { try {
ok(res, await service.getMarketplaceHomepage()) ok(res, await service.getStorefrontHomepage())
} catch (err) { next(err) } } 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 { try {
const { homepage } = parseBody(homepageUpdateSchema, req) 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) } } catch (err) { next(err) }
}) })
+19 -19
View File
@@ -1,5 +1,5 @@
import { z } from 'zod' 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({ export const loginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(), email: z.string().email().max(255).trim().toLowerCase(),
@@ -175,35 +175,35 @@ export const adminCompanyUpdateSchema = z.object({
}).optional(), }).optional(),
}) })
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) }) const storefrontMetricSchema: z.ZodType<StorefrontHomepageMetric> = 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 storefrontPillarSchema: z.ZodType<StorefrontHomepagePillar> = 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 storefrontStepSchema: z.ZodType<StorefrontHomepageStep> = 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 storefrontHowItWorksStepSchema: z.ZodType<StorefrontHomepageHowItWorksStep> = 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 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 marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing']) const storefrontSectionSchema: z.ZodType<StorefrontHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({ const storefrontHomepageContentSchema: z.ZodType<StorefrontHomepageContent> = z.object({
sections: z.array(marketplaceSectionSchema).min(1), sections: z.array(storefrontSectionSchema).min(1),
heroKicker: z.string().min(1), heroTitle: z.string().min(1), heroBody: z.string().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), 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), 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), 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), 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), renterKicker: z.string().min(1), renterTitle: z.string().min(1), renterBody: z.string().min(1),
pillars: z.array(marketplacePillarSchema).length(3), pillars: z.array(storefrontPillarSchema).length(3),
metrics: z.array(marketplaceMetricSchema).length(3), metrics: z.array(storefrontMetricSchema).length(3),
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1), 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), howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(storefrontHowItWorksStepSchema).min(1),
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().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(marketplaceTestimonialSchema).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), 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), viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
}) })
export const marketplaceHomepageConfigSchema = z.object({ export const storefrontHomepageConfigSchema = z.object({
en: marketplaceHomepageContentSchema, en: storefrontHomepageContentSchema,
fr: marketplaceHomepageContentSchema, fr: storefrontHomepageContentSchema,
ar: marketplaceHomepageContentSchema, ar: storefrontHomepageContentSchema,
}) })
export const idParamSchema = z.object({ export const idParamSchema = z.object({
@@ -300,7 +300,7 @@ export const billingRefundSchema = z.object({
}) })
export const homepageUpdateSchema = z.object({ export const homepageUpdateSchema = z.object({
homepage: marketplaceHomepageConfigSchema, homepage: storefrontHomepageConfigSchema,
}) })
export const pricingUpdateSchema = z.object({ export const pricingUpdateSchema = z.object({
+6 -6
View File
@@ -3,7 +3,7 @@ import crypto from 'crypto'
import { authenticator } from 'otplib' import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens' import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode' import qrcode from 'qrcode'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService' import { getStorefrontHomepageContent, saveStorefrontHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService' import { sendTransactionalEmail } from '../../services/notificationService'
import * as presenter from './admin.presenter' import * as presenter from './admin.presenter'
import * as repo from './admin.repo' import * as repo from './admin.repo'
@@ -402,16 +402,16 @@ export function issueBillingRefund(
return billingService.issueRefund(invoiceId, data, adminId, ip) return billingService.issueRefund(invoiceId, data, adminId, ip)
} }
export function getMarketplaceHomepage() { export function getStorefrontHomepage() {
return getMarketplaceHomepageContent() return getStorefrontHomepageContent()
} }
export async function updateMarketplaceHomepage(homepage: any, adminId: string, ip?: string) { export async function updateStorefrontHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveMarketplaceHomepageContent(homepage) const saved = await saveStorefrontHomepageContent(homepage)
await repo.createAuditLog({ await repo.createAuditLog({
adminUserId: adminId, adminUserId: adminId,
action: 'UPDATE', action: 'UPDATE',
resource: 'MarketplaceHomepage', resource: 'StorefrontHomepage',
after: toAuditJson(saved), after: toAuditJson(saved),
ipAddress: ip, ipAddress: ip,
userAgent: undefined, userAgent: undefined,
@@ -10,7 +10,7 @@ vi.mock('../../lib/prisma', () => ({
planFeature: { findMany: vi.fn() }, 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/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() })) vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() })) vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
+2 -2
View File
@@ -3,7 +3,7 @@ import { AppError, NotFoundError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyPricingRules } from '../../services/pricingRuleService' import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense } from '../../services/licenseValidationService' import { validateLicense } from '../../services/licenseValidationService'
import { getMarketplaceHomepageContent } from '../../services/platformContentService' import { getStorefrontHomepageContent } from '../../services/platformContentService'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import * as amanpay from '../../services/amanpayService' import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService' import * as paypal from '../../services/paypalService'
@@ -53,7 +53,7 @@ function generateBookingReference() {
} }
export async function getPlatformHomepage() { export async function getPlatformHomepage() {
return getMarketplaceHomepageContent() return getStorefrontHomepageContent()
} }
export async function getPlatformPricing() { export async function getPlatformPricing() {
+1 -1
View File
@@ -66,7 +66,7 @@ vi.mock('../../services/paypalService', () => ({
})) }))
vi.mock('../../services/platformContentService', () => ({ vi.mock('../../services/platformContentService', () => ({
getMarketplaceHomepageContent: vi.fn(), getStorefrontHomepageContent: vi.fn(),
})) }))
import * as repo from './site.repo' import * as repo from './site.repo'
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' 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', () => { it('adds public availability fields without mutating the original vehicle shape', () => {
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z') const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 } const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
@@ -11,9 +11,9 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock })) 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 () => { it('finds public offers using active/public/current-window filters and featured ordering', async () => {
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z')) vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
@@ -33,7 +33,7 @@ describe('marketplace.repo public query and write boundaries', () => {
vi.useRealTimers() 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() await repo.findCitiesFromCompanies()
expect(prismaMock.company.findMany).toHaveBeenCalledWith({ 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 () => { it('requires published vehicles and an active company when loading storefront vehicle details', async () => {
await repo.findVehicleForMarketplace('vehicle_1', 'atlas') await repo.findVehicleForStorefront('vehicle_1', 'atlas')
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({ expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
where: { 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({ prismaMock.customer.findUnique.mockResolvedValueOnce({
id: 'customer_1', id: 'customer_1',
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-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 startDate = new Date('2026-07-01T10:00:00.000Z')
const endDate = new Date('2026-07-05T10:00:00.000Z') const endDate = new Date('2026-07-05T10:00:00.000Z')
await repo.createMarketplaceReservation({ await repo.createStorefrontReservation({
companyId: 'company_1', companyId: 'company_1',
vehicleId: 'vehicle_1', vehicleId: 'vehicle_1',
customerId: 'customer_1', customerId: 'customer_1',
@@ -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({ return prisma.vehicle.findFirst({
where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } }, where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
include: { company: { include: { brand: true } } }, include: { company: { include: { brand: true } } },
}) })
} }
export async function findVehicleForMarketplaceById(vehicleId: string) { export async function findVehicleForStorefrontById(vehicleId: string) {
return prisma.vehicle.findFirst({ return prisma.vehicle.findFirst({
where: { where: {
id: vehicleId, id: vehicleId,
@@ -115,7 +115,7 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
return prisma.customer.update({ where: { id: existing.id }, data: payload }) 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 companyId: string; vehicleId: string; customerId: string
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string 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 eventName: string
companySlug: string companySlug: string
vehicleId: string vehicleId: string
@@ -3,12 +3,12 @@ import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody, parseParams, parseQuery } from '../../http/validate' import { parseBody, parseParams, parseQuery } from '../../http/validate'
import { ok, created } from '../../http/respond' import { ok, created } from '../../http/respond'
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable' import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
import * as service from './marketplace.service' import * as service from './storefront.service'
import { import {
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema, paginationSchema, searchSchema, companiesQuerySchema, storefrontReservationSchema,
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema, reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema, vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
} from './marketplace.schemas' } from './storefront.schemas'
const router = Router() const router = Router()
router.use(optionalRenterAuth) router.use(optionalRenterAuth)
@@ -55,8 +55,8 @@ router.get('/search', async (req, res, next) => {
router.post('/reservations', async (req, res, next) => { router.post('/reservations', async (req, res, next) => {
try { try {
const body = parseBody(marketplaceReservationSchema, req) const body = parseBody(storefrontReservationSchema, req)
const result = await service.createMarketplaceReservation(body) const result = await service.createStorefrontReservation(body)
created(res, result) created(res, result)
} catch (err) { } catch (err) {
if (isDatabaseUnavailableError(err)) { if (isDatabaseUnavailableError(err)) {
@@ -69,7 +69,7 @@ router.post('/reservations', async (req, res, next) => {
router.post('/events', async (req, res, next) => { router.post('/events', async (req, res, next) => {
try { try {
const body = parseBody(marketplaceFunnelEventSchema, req) const body = parseBody(marketplaceFunnelEventSchema, req)
ok(res, await service.trackMarketplaceFunnelEvent(body, req.renterId)) ok(res, await service.trackStorefrontFunnelEvent(body, req.renterId))
} catch (err) { } catch (err) {
if (isDatabaseUnavailableError(err)) { if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Analytics events are temporarily unavailable', statusCode: 503 }) 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)) ok(res, await service.getCompanyPage(slug))
} catch (err) { } catch (err) {
if (isDatabaseUnavailableError(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) next(err)
} }
@@ -154,7 +154,7 @@ router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
try { try {
const { slug, id } = parseParams(vehicleParamSchema, req) const { slug, id } = parseParams(vehicleParamSchema, req)
const { startDate, endDate } = parseQuery(vehicleAvailabilityQuerySchema, 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) { } catch (err) {
if (isDatabaseUnavailableError(err)) { if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 }) return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' 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', () => { it('coerces pagination and caps untrusted public page sizes', () => {
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 }) expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 }) expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
@@ -18,8 +18,8 @@ describe('marketplace.schemas', () => {
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow() expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
}) })
it('defaults marketplace reservation language while keeping date and email validation strict', () => { it('defaults storefront reservation language while keeping date and email validation strict', () => {
const parsed = marketplaceReservationSchema.parse({ const parsed = storefrontReservationSchema.parse({
vehicleId: 'ckvvehicle000000000000001', vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars', companySlug: 'atlas-cars',
firstName: 'Aya', firstName: 'Aya',
@@ -30,8 +30,8 @@ describe('marketplace.schemas', () => {
}) })
expect(parsed.language).toBe('fr') expect(parsed.language).toBe('fr')
expect(() => marketplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow() expect(() => storefrontReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
expect(() => marketplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow() expect(() => storefrontReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
}) })
it('validates review ratings as bounded integers', () => { it('validates review ratings as bounded integers', () => {
@@ -29,7 +29,7 @@ export const vehicleAvailabilityQuerySchema = z.object({
endDate: z.string().datetime(), endDate: z.string().datetime(),
}) })
export const marketplaceReservationSchema = z.object({ export const storefrontReservationSchema = z.object({
vehicleId: z.string().cuid(), vehicleId: z.string().cuid(),
companySlug: z.string().trim().max(100).optional(), companySlug: z.string().trim().max(100).optional(),
firstName: z.string().min(1).max(100), firstName: z.string().min(1).max(100),
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./marketplace.repo', () => ({ vi.mock('./storefront.repo', () => ({
findCompanyPage: vi.fn(), findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(), findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(), findCompanyReviews: vi.fn(),
@@ -12,16 +12,16 @@ vi.mock('./marketplace.repo', () => ({
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() })) vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() })) vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
vi.mock('../../lib/emailTranslations', () => ({ 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 { 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()) 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 () => { it('enriches company page vehicles with availability without changing company metadata', async () => {
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z') const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
vi.mocked(repo.findCompanyPage).mockResolvedValue({ vi.mocked(repo.findCompanyPage).mockResolvedValue({
@@ -1,8 +1,8 @@
import { AppError, NotFoundError, ConflictError } from '../../http/errors' import { AppError, NotFoundError, ConflictError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService' import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
import { marketplaceReservationEmail, type Lang } from '../../lib/emailTranslations' import { storefrontReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './marketplace.repo' import * as repo from './storefront.repo'
function normalizeLocation(value?: string | null) { function normalizeLocation(value?: string | null) {
const normalized = value?.trim() const normalized = value?.trim()
@@ -120,12 +120,12 @@ export async function searchVehicles(params: {
})) }))
} }
export async function getMarketplaceVehicleAvailability( export async function getStorefrontVehicleAvailability(
slug: string, slug: string,
vehicleId: string, vehicleId: string,
params: { startDate: string; endDate: 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') if (!vehicle) throw new NotFoundError('Vehicle not found')
const startDate = new Date(params.startDate) const startDate = new Date(params.startDate)
@@ -154,7 +154,7 @@ function generateBookingReference() {
return `BK-${year}-${random}` return `BK-${year}-${random}`
} }
export async function createMarketplaceReservation(body: { export async function createStorefrontReservation(body: {
vehicleId: string; companySlug?: string; firstName: string; lastName: string vehicleId: string; companySlug?: string; firstName: string; lastName: string
email: string; phone?: string; dateOfBirth?: string; nationality?: string email: string; phone?: string; dateOfBirth?: string; nationality?: string
identityDocumentNumber?: string; fullAddress?: 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') 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') if (!vehicle) throw new NotFoundError('Vehicle not found')
const pickupLocation = normalizeLocation(body.pickupLocation) 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 totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
const totalAmount = vehicle.dailyRate * totalDays const totalAmount = vehicle.dailyRate * totalDays
const reservation = await repo.createMarketplaceReservation({ const reservation = await repo.createStorefrontReservation({
companyId: vehicle.companyId, companyId: vehicle.companyId,
vehicleId: body.vehicleId, vehicleId: body.vehicleId,
customerId: customer.id, customerId: customer.id,
@@ -245,14 +245,14 @@ export async function createMarketplaceReservation(body: {
sendTransactionalEmail({ sendTransactionalEmail({
to: body.email, to: body.email,
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang), subject: storefrontReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
html: marketplaceReservationEmail.html(emailOpts, lang), html: storefrontReservationEmail.html(emailOpts, lang),
text: marketplaceReservationEmail.text(emailOpts, lang), text: storefrontReservationEmail.text(emailOpts, lang),
}).catch(() => null) }).catch(() => null)
sendNotification({ sendNotification({
type: 'NEW_BOOKING', 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)}.`, 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, companyId: vehicle.companyId,
channels: ['IN_APP'], channels: ['IN_APP'],
@@ -270,7 +270,7 @@ export async function createMarketplaceReservation(body: {
} }
} }
export async function trackMarketplaceFunnelEvent(body: { export async function trackStorefrontFunnelEvent(body: {
eventName: string eventName: string
companySlug: string companySlug: string
vehicleId: string vehicleId: string
@@ -278,7 +278,7 @@ export async function trackMarketplaceFunnelEvent(body: {
path?: string path?: string
metadata?: Record<string, string | number | boolean | null> metadata?: Record<string, string | number | boolean | null>
}, renterId?: string) { }, renterId?: string) {
await repo.createMarketplaceFunnelEvent({ await repo.createStorefrontFunnelEvent({
...body, ...body,
renterId: renterId ?? null, renterId: renterId ?? null,
}) })
@@ -1,15 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NotFoundError, ConflictError, AppError } from '../../http/errors' import { NotFoundError, ConflictError, AppError } from '../../http/errors'
vi.mock('./marketplace.repo', () => ({ vi.mock('./storefront.repo', () => ({
findPublicOffers: vi.fn(), findPublicOffers: vi.fn(),
findCitiesFromCompanies: vi.fn(), findCitiesFromCompanies: vi.fn(),
findListedCompanies: vi.fn(), findListedCompanies: vi.fn(),
findPublishedVehicles: vi.fn(), findPublishedVehicles: vi.fn(),
findVehicleForMarketplace: vi.fn(), findVehicleForStorefront: vi.fn(),
findVehicleForMarketplaceById: vi.fn(), findVehicleForStorefrontById: vi.fn(),
upsertMarketplaceCustomer: vi.fn(), upsertMarketplaceCustomer: vi.fn(),
createMarketplaceReservation: vi.fn(), createStorefrontReservation: vi.fn(),
findCompanyPage: vi.fn(), findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(), findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(), findCompanyReviews: vi.fn(),
@@ -33,19 +33,19 @@ vi.mock('../../services/notificationService', () => ({
})) }))
vi.mock('../../lib/emailTranslations', () => ({ vi.mock('../../lib/emailTranslations', () => ({
marketplaceReservationEmail: { storefrontReservationEmail: {
subject: vi.fn(() => 'Subject'), subject: vi.fn(() => 'Subject'),
html: vi.fn(() => '<html>'), html: vi.fn(() => '<html>'),
text: vi.fn(() => 'text'), text: vi.fn(() => 'text'),
}, },
})) }))
import * as repo from './marketplace.repo' import * as repo from './storefront.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { import {
getCities, searchVehicles, createMarketplaceReservation, getCities, searchVehicles, createStorefrontReservation,
getReviewContext, submitReview, validateOfferCode, getReviewContext, submitReview, validateOfferCode,
} from './marketplace.service' } from './storefront.service'
const SLUG = 'test-company' const SLUG = 'test-company'
@@ -119,7 +119,7 @@ describe('searchVehicles', () => {
}) })
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
describe('createMarketplaceReservation', () => { describe('createStorefrontReservation', () => {
const baseBody = { const baseBody = {
vehicleId: 'v-1', companySlug: SLUG, vehicleId: 'v-1', companySlug: SLUG,
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
@@ -137,12 +137,12 @@ describe('createMarketplaceReservation', () => {
} }
it('creates reservation and returns reservationId', async () => { 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(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any) 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.reservationId).toBe('r-1')
expect(result.bookingReference).toBe('BK-2026-ABC123') expect(result.bookingReference).toBe('BK-2026-ABC123')
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({ expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
@@ -150,36 +150,36 @@ describe('createMarketplaceReservation', () => {
fullAddress: '123 Avenue Hassan II, Casablanca', fullAddress: '123 Avenue Hassan II, Casablanca',
driverLicense: 'DL-123456', driverLicense: 'DL-123456',
})) }))
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({ expect(repo.createStorefrontReservation).toHaveBeenCalledWith(expect.objectContaining({
pickupLocation: 'Casablanca', pickupLocation: 'Casablanca',
returnLocation: 'Casablanca', returnLocation: 'Casablanca',
})) }))
}) })
it('throws NotFoundError when vehicle not found', async () => { 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 () => { 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' }) 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 () => { 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( await expect(
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }), createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
).rejects.toMatchObject({ error: 'same_dropoff_required' }) ).rejects.toMatchObject({ error: 'same_dropoff_required' })
}) })
it('throws AppError for invalid date range', async () => { it('throws AppError for invalid date range', async () => {
await expect( 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' }) ).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 () => { it('returns cloned default homepage content when the file does not exist or cannot be parsed', async () => {
const service = await importFreshService() const service = await importFreshService()
const first = await service.getMarketplaceHomepageContent() const first = await service.getStorefrontHomepageContent()
first.en.heroTitle = 'Mutated by test' 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.sections).toContain('hero')
expect(second.en.heroTitle).not.toBe('Mutated by test') 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') const dataPath = path.join(tempDir, 'apps/api/src/data/platform-content.json')
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => { await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => {
await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true })) await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true }))
@@ -43,13 +43,13 @@ describe('platformContentService', () => {
}) })
const service = await importFreshService() const service = await importFreshService()
const homepage = await service.getMarketplaceHomepageContent() const homepage = await service.getStorefrontHomepageContent()
homepage.en.heroTitle = 'A better marketplace title' 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')) const raw = JSON.parse(await readFile(dataPath, 'utf8'))
expect(raw.otherSetting).toEqual({ keep: true }) 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 { mkdir, readFile, writeFile } from 'fs/promises'
import path from 'path' import path from 'path'
import { cloneMarketplaceHomepageContent, type MarketplaceHomepageConfig } from '@rentaldrivego/types' import { cloneStorefrontHomepageContent, type StorefrontHomepageConfig } from '@rentaldrivego/types'
function resolveContentPath() { function resolveContentPath() {
const cwd = process.cwd() const cwd = process.cwd()
@@ -12,7 +12,7 @@ function resolveContentPath() {
const platformContentPath = resolveContentPath() const platformContentPath = resolveContentPath()
type PlatformContentFile = { type PlatformContentFile = {
marketplaceHomepage?: MarketplaceHomepageConfig storefrontHomepage?: StorefrontHomepageConfig
} }
async function readPlatformContentFile(): Promise<PlatformContentFile> { async function readPlatformContentFile(): Promise<PlatformContentFile> {
@@ -29,16 +29,16 @@ async function writePlatformContentFile(content: PlatformContentFile) {
await writeFile(platformContentPath, JSON.stringify(content, null, 2)) await writeFile(platformContentPath, JSON.stringify(content, null, 2))
} }
export async function getMarketplaceHomepageContent() { export async function getStorefrontHomepageContent() {
const content = await readPlatformContentFile() 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() const content = await readPlatformContentFile()
await writePlatformContentFile({ await writePlatformContentFile({
...content, ...content,
marketplaceHomepage: homepage, storefrontHomepage: homepage,
}) })
return homepage return homepage
} }
@@ -16,7 +16,7 @@ describe('OpenAPI document boundary contract', () => {
}), }),
})) }))
expect(openApiDocument.tags).toEqual(expect.arrayContaining([ expect(openApiDocument.tags).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'Marketplace' }), expect.objectContaining({ name: 'Storefront' }),
expect.objectContaining({ name: 'Subscriptions' }), expect.objectContaining({ name: 'Subscriptions' }),
expect.objectContaining({ name: 'Admin' }), expect.objectContaining({ name: 'Admin' }),
])) ]))
+26 -26
View File
@@ -119,7 +119,7 @@ export const openApiDocument: JsonObject = {
{ name: 'Companies', description: 'Company profile & settings' }, { name: 'Companies', description: 'Company profile & settings' },
{ name: 'Team', description: 'Employee management' }, { name: 'Team', description: 'Employee management' },
{ name: 'Subscriptions', description: 'Plan management & billing' }, { 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: 'Site', description: 'White-label site API' },
{ name: 'Admin', description: 'Platform administration' }, { name: 'Admin', description: 'Platform administration' },
], ],
@@ -998,18 +998,18 @@ export const openApiDocument: JsonObject = {
// ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════
// MARKETPLACE (public — no auth required) // MARKETPLACE (public — no auth required)
// ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════
'/marketplace/cities': { '/storefront/cities': {
get: { tags: ['Marketplace'], summary: 'Cities with active companies', security: [], responses: { '200': ok } }, get: { tags: ['Storefront'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
}, },
'/marketplace/companies': { '/storefront/companies': {
get: { tags: ['Marketplace'], summary: 'List companies', security: [], responses: { '200': ok } }, get: { tags: ['Storefront'], summary: 'List companies', security: [], responses: { '200': ok } },
}, },
'/marketplace/offers': { '/storefront/offers': {
get: { tags: ['Marketplace'], summary: 'Public offers', security: [], responses: { '200': ok } }, get: { tags: ['Storefront'], summary: 'Public offers', security: [], responses: { '200': ok } },
}, },
'/marketplace/search': { '/storefront/search': {
get: { get: {
tags: ['Marketplace'], tags: ['Storefront'],
summary: 'Search available vehicles', summary: 'Search available vehicles',
security: [], security: [],
parameters: [ parameters: [
@@ -1021,30 +1021,30 @@ export const openApiDocument: JsonObject = {
responses: { '200': ok }, responses: { '200': ok },
}, },
}, },
'/marketplace/reservations': { '/storefront/reservations': {
post: { tags: ['Marketplace'], summary: 'Create marketplace booking', security: [], responses: { '201': ok } }, post: { tags: ['Storefront'], summary: 'Create storefront booking', security: [], responses: { '201': ok } },
}, },
'/marketplace/offers/{code}/validate': { '/storefront/offers/{code}/validate': {
post: { tags: ['Marketplace'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, post: { tags: ['Storefront'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
}, },
'/marketplace/review/{token}': { '/storefront/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 } }, 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: ['Marketplace'], summary: 'Submit review', 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}': { '/storefront/{slug}': {
get: { tags: ['Marketplace'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, get: { tags: ['Storefront'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
}, },
'/marketplace/{slug}/reviews': { '/storefront/{slug}/reviews': {
get: { tags: ['Marketplace'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, get: { tags: ['Storefront'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
}, },
'/marketplace/{slug}/vehicles': { '/storefront/{slug}/vehicles': {
get: { tags: ['Marketplace'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, get: { tags: ['Storefront'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
}, },
'/marketplace/{slug}/vehicles/{id}': { '/storefront/{slug}/vehicles/{id}': {
get: { tags: ['Marketplace'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } }, get: { tags: ['Storefront'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
}, },
'/marketplace/{slug}/offers': { '/storefront/{slug}/offers': {
get: { tags: ['Marketplace'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } }, get: { tags: ['Storefront'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
}, },
// ════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════
@@ -40,12 +40,12 @@ vi.mock('../../modules/notifications/notification.service', () => ({
getRenterPreferences: vi.fn(), getRenterPreferences: vi.fn(),
setRenterPreferences: vi.fn(), setRenterPreferences: vi.fn(),
})) }))
vi.mock('../../modules/marketplace/marketplace.service', () => ({ vi.mock('../../modules/storefront/storefront.service', () => ({
getCities: vi.fn(), getCities: vi.fn(),
getListedCompanies: vi.fn(), getListedCompanies: vi.fn(),
searchVehicles: vi.fn(), searchVehicles: vi.fn(),
getVehicleDetail: vi.fn(), getVehicleDetail: vi.fn(),
createMarketplaceReservation: vi.fn(), createStorefrontReservation: vi.fn(),
getCompanyPage: vi.fn(), getCompanyPage: vi.fn(),
getCompanyReviews: vi.fn(), getCompanyReviews: vi.fn(),
getCompanyVehicles: vi.fn(), getCompanyVehicles: vi.fn(),
@@ -61,11 +61,11 @@ import request from 'supertest'
import { createApp } from '../../app' import { createApp } from '../../app'
import * as employeeService from '../../modules/auth/auth.employee.service' import * as employeeService from '../../modules/auth/auth.employee.service'
import * as notificationService from '../../modules/notifications/notification.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() const app = createApp()
describe('employee, notification, and marketplace API validation contracts', () => { describe('employee, notification, and storefront API validation contracts', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(employeeService.login).mockResolvedValue({ token: 'jwt', employee: { id: 'employee_1' } } as never) 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.updateLanguage).mockResolvedValue({ language: 'ar' } as never)
vi.mocked(employeeService.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } 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(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(storefrontService.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.submitReview).mockResolvedValue({ id: 'review_1' } as never)
}) })
it('normalizes employee login email before service execution', async () => { 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() expect(notificationService.setPreferences).not.toHaveBeenCalled()
}) })
it('defaults marketplace search pagination at the public route boundary', async () => { it('defaults storefront search pagination at the public route boundary', async () => {
const res = await request(app).get('/api/v1/marketplace/search?city=Casablanca') const res = await request(app).get('/api/v1/storefront/search?city=Casablanca')
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(marketplaceService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({ expect(storefrontService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
city: 'Casablanca', city: 'Casablanca',
page: 1, page: 1,
pageSize: 20, 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 () => { 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, overallRating: 6,
vehicleRating: 5, vehicleRating: 5,
serviceRating: 5, serviceRating: 5,
@@ -130,6 +130,6 @@ describe('employee, notification, and marketplace API validation contracts', ()
}) })
expect(res.status).toBe(400) 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(), handleContact: vi.fn(),
})) }))
vi.mock('../../modules/marketplace/marketplace.service', () => ({ vi.mock('../../modules/storefront/storefront.service', () => ({
getPublicOffers: vi.fn(), getPublicOffers: vi.fn(),
getCities: vi.fn(), getCities: vi.fn(),
getListedCompanies: vi.fn(), getListedCompanies: vi.fn(),
searchVehicles: vi.fn(), searchVehicles: vi.fn(),
createMarketplaceReservation: vi.fn(), createStorefrontReservation: vi.fn(),
getReviewContext: vi.fn(), getReviewContext: vi.fn(),
submitReview: vi.fn(), submitReview: vi.fn(),
validateOfferCode: vi.fn(), validateOfferCode: vi.fn(),
@@ -49,7 +49,7 @@ vi.mock('../../modules/marketplace/marketplace.service', () => ({
import request from 'supertest' import request from 'supertest'
import { createApp } from '../../app' import { createApp } from '../../app'
import * as siteService from '../../modules/site/site.service' 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() 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.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never)
vi.mocked(siteService.initPayment).mockResolvedValue({ checkoutUrl: 'https://pay.example.test' } 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(siteService.handleContact).mockResolvedValue({ success: true } as never)
vi.mocked(marketplaceService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never) vi.mocked(storefrontService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never)
vi.mocked(marketplaceService.createMarketplaceReservation).mockResolvedValue({ id: 'reservation_1' } as never) vi.mocked(storefrontService.createStorefrontReservation).mockResolvedValue({ id: 'reservation_1' } as never)
vi.mocked(marketplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never) vi.mocked(storefrontService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
}) })
it('rejects malformed public availability checks before site service execution', async () => { 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 () => { it('coerces storefront search pagination and price filters', async () => {
const res = await request(app).get('/api/v1/marketplace/search?city=Rabat&maxPrice=500&page=2&pageSize=30') const res = await request(app).get('/api/v1/storefront/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(marketplaceService.searchVehicles).toHaveBeenCalledWith({ expect(storefrontService.searchVehicles).toHaveBeenCalledWith({
city: 'Rabat', city: 'Rabat',
maxPrice: 500, maxPrice: 500,
page: 2, page: 2,
@@ -132,15 +132,15 @@ describe('public validation API contracts', () => {
}) })
}) })
it('rejects oversized marketplace pagination before search execution', async () => { it('rejects oversized storefront pagination before search execution', async () => {
const res = await request(app).get('/api/v1/marketplace/search?pageSize=500') const res = await request(app).get('/api/v1/storefront/search?pageSize=500')
expect(res.status).toBe(400) 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 () => { it('rejects storefront reservations with invalid public contact data before service execution', async () => {
const res = await request(app).post('/api/v1/marketplace/reservations').send({ const res = await request(app).post('/api/v1/storefront/reservations').send({
vehicleId: 'ckvvehicle000000000000001', vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars', companySlug: 'atlas-cars',
firstName: 'Aya', firstName: 'Aya',
@@ -151,13 +151,13 @@ describe('public validation API contracts', () => {
}) })
expect(res.status).toBe(400) 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 () => { 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(res.status).toBe(400)
expect(marketplaceService.submitReview).not.toHaveBeenCalled() expect(storefrontService.submitReview).not.toHaveBeenCalled()
}) })
}) })
@@ -10,7 +10,7 @@ import { createApp } from '../../app'
const app = createApp() 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 () => { 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' }) 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 () => { 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(res.status).toBe(400)
expect(JSON.stringify(res.body)).not.toContain('Prisma') expect(JSON.stringify(res.body)).not.toContain('Prisma')
@@ -24,12 +24,12 @@ vi.mock('../../modules/site/site.service', () => ({
capturePaypal: vi.fn(), capturePaypal: vi.fn(),
handleContact: vi.fn(), handleContact: vi.fn(),
})) }))
vi.mock('../../modules/marketplace/marketplace.service', () => ({ vi.mock('../../modules/storefront/storefront.service', () => ({
getPublicOffers: vi.fn(), getPublicOffers: vi.fn(),
getCities: vi.fn(), getCities: vi.fn(),
getListedCompanies: vi.fn(), getListedCompanies: vi.fn(),
searchVehicles: vi.fn(), searchVehicles: vi.fn(),
createMarketplaceReservation: vi.fn(), createStorefrontReservation: vi.fn(),
getReviewContext: vi.fn(), getReviewContext: vi.fn(),
submitReview: vi.fn(), submitReview: vi.fn(),
validateOfferCode: vi.fn(), validateOfferCode: vi.fn(),
@@ -43,7 +43,7 @@ vi.mock('../../modules/marketplace/marketplace.service', () => ({
import request from 'supertest' import request from 'supertest'
import { createApp } from '../../app' import { createApp } from '../../app'
import * as siteService from '../../modules/site/site.service' 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() const app = createApp()
@@ -61,8 +61,8 @@ describe('public validation e2e smoke', () => {
expect(payment.status).toBe(400) expect(payment.status).toBe(400)
expect(siteService.initPayment).not.toHaveBeenCalled() 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(review.status).toBe(400)
expect(marketplaceService.submitReview).not.toHaveBeenCalled() expect(storefrontService.submitReview).not.toHaveBeenCalled()
}) })
}) })
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest' 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', () => { it('documents the Batch-10 DB-backed regression targets for the real integration environment', () => {
expect([ expect([
'admin billing invoice lifecycle uses persisted line items and audit logs', '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', 'vehicle location settings persist through create/update/list flows',
]).toHaveLength(3) ]).toHaveLength(3)
}) })
@@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest' 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', () => { it('documents the Batch-17 DB-backed integration boundary', () => {
expect({ expect({
employeeAuth: 'password reset/login flows require generated Prisma and mail-provider test doubles', 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', notifications: 'preference persistence requires notificationPreference composite indexes',
}).toEqual(expect.objectContaining({ }).toEqual(expect.objectContaining({
employeeAuth: expect.stringContaining('Prisma'), employeeAuth: expect.stringContaining('Prisma'),
marketplace: expect.stringContaining('seeded'), storefront: expect.stringContaining('seeded'),
notifications: expect.stringContaining('composite'), notifications: expect.stringContaining('composite'),
})) }))
}) })
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { parseBody, parseQuery } from '../../http/validate' 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' import { paySchema } from '../../modules/site/site.schemas'
function reqWith(body: unknown, query: unknown = {}) { function reqWith(body: unknown, query: unknown = {}) {
@@ -8,7 +8,7 @@ function reqWith(body: unknown, query: unknown = {}) {
} }
describe('public validation boundaries', () => { 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({ expect(parseQuery(paginationSchema, reqWith({}, { page: '4', pageSize: '25' }))).toEqual({
page: 4, page: 4,
pageSize: 25, pageSize: 25,
@@ -24,8 +24,8 @@ describe('public validation boundaries', () => {
}))).toThrow('Invalid enum value') }))).toThrow('Invalid enum value')
}) })
it('requires enough anonymous marketplace reservation identity to create a booking request', () => { it('requires enough anonymous storefront reservation identity to create a booking request', () => {
const parsed = parseBody(marketplaceReservationSchema, reqWith({ const parsed = parseBody(storefrontReservationSchema, reqWith({
vehicleId: 'ckvvehicle000000000000001', vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars', companySlug: 'atlas-cars',
firstName: 'Aya', firstName: 'Aya',
+7 -7
View File
@@ -29,7 +29,7 @@ The app covers:
It is distinct from: 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 company site app, which is public and company-branded
- the admin app, which is platform-internal rather than tenant-scoped - 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` - directly in dev on port `3001`
- behind a reverse proxy - 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 ## Top-Level Structure
@@ -92,7 +92,7 @@ apps/dashboard/
api.ts cookie-aware API fetch wrapper api.ts cookie-aware API fetch wrapper
preferences.ts language/theme persistence scoped per employee preferences.ts language/theme persistence scoped per employee
dashboardPaths.ts normalizes basePath-aware routes 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 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 - public dashboard routes are limited to sign-in and forgot-password flows
- all other `/dashboard/*` routes require the `employee_session` cookie - 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` - duplicate `/dashboard/dashboard` paths are normalized back to `/dashboard`
This means route protection is enforced before React renders the protected pages. 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` 1. basic company info via `PATCH /companies/me`
2. brand/public profile via `PATCH /companies/me/brand` 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. 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: It includes:
- public brand and marketplace profile - public brand and storefront profile
- payment account identifiers - payment account identifiers
- custom domain setup - custom domain setup
- rental policies - rental policies
@@ -645,7 +645,7 @@ Examples in the code:
It also coordinates with sibling apps: 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 - `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 - host-aware URL rewriting allows the same app to function under external domains, internal Docker hostnames, and proxied environments
+2 -2
View File
@@ -30,9 +30,9 @@ const DASHBOARD_BASE_PATH = '/dashboard'
// basePath is required so the client-side router knows it's mounted at /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 // 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 // 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 // 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 // rewrites do not reliably proxy HMR WebSocket upgrades, so emit absolute
// dashboard asset/HMR URLs directly to the dashboard dev server. // dashboard asset/HMR URLs directly to the dashboard dev server.
@@ -113,7 +113,7 @@ function offerToForm(o: Offer): FormState {
const copy = { const copy = {
en: { en: {
title: 'Offers', 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', createOffer: 'Create Offer',
editOffer: 'Edit Offer', editOffer: 'Edit Offer',
deleteOffer: 'Delete Offer', deleteOffer: 'Delete Offer',
@@ -126,7 +126,7 @@ const copy = {
ends: 'ends', ends: 'ends',
active: 'Active', active: 'Active',
inactive: 'Inactive', inactive: 'Inactive',
marketplace: 'Marketplace', storefront: 'Storefront',
featured: 'Featured', featured: 'Featured',
empty: 'No offers yet. Create your first offer to attract customers.', empty: 'No offers yet. Create your first offer to attract customers.',
labelTitle: 'Title *', labelTitle: 'Title *',
@@ -146,7 +146,7 @@ const copy = {
labelValidFrom: 'Valid From *', labelValidFrom: 'Valid From *',
labelValidUntil: 'Valid Until *', labelValidUntil: 'Valid Until *',
labelIsActive: 'Active', labelIsActive: 'Active',
labelIsPublic: 'Show on marketplace', labelIsPublic: 'Show on storefront',
labelIsFeatured: 'Featured', labelIsFeatured: 'Featured',
typePERCENTAGE: 'Percentage (%)', typePERCENTAGE: 'Percentage (%)',
typeFIXED_AMOUNT: 'Fixed Amount (MAD)', typeFIXED_AMOUNT: 'Fixed Amount (MAD)',
@@ -174,7 +174,7 @@ const copy = {
}, },
fr: { fr: {
title: 'Offres', 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', createOffer: 'Créer une offre',
editOffer: "Modifier l'offre", editOffer: "Modifier l'offre",
deleteOffer: "Supprimer l'offre", deleteOffer: "Supprimer l'offre",
@@ -187,7 +187,7 @@ const copy = {
ends: 'expire le', ends: 'expire le',
active: 'Actif', active: 'Actif',
inactive: 'Inactif', inactive: 'Inactif',
marketplace: 'Marketplace', storefront: 'Storefront',
featured: 'Mise en avant', featured: 'Mise en avant',
empty: 'Aucune offre. Créez votre première offre pour attirer des clients.', empty: 'Aucune offre. Créez votre première offre pour attirer des clients.',
labelTitle: 'Titre *', labelTitle: 'Titre *',
@@ -207,7 +207,7 @@ const copy = {
labelValidFrom: 'Valable du *', labelValidFrom: 'Valable du *',
labelValidUntil: "Valable jusqu'au *", labelValidUntil: "Valable jusqu'au *",
labelIsActive: 'Actif', labelIsActive: 'Actif',
labelIsPublic: 'Afficher sur la marketplace', labelIsPublic: 'Afficher sur la storefront',
labelIsFeatured: 'Mise en avant', labelIsFeatured: 'Mise en avant',
typePERCENTAGE: 'Pourcentage (%)', typePERCENTAGE: 'Pourcentage (%)',
typeFIXED_AMOUNT: 'Montant fixe (MAD)', typeFIXED_AMOUNT: 'Montant fixe (MAD)',
@@ -248,7 +248,7 @@ const copy = {
ends: 'تنتهي في', ends: 'تنتهي في',
active: 'نشط', active: 'نشط',
inactive: 'غير نشط', inactive: 'غير نشط',
marketplace: 'السوق', storefront: 'السوق',
featured: 'مميز', featured: 'مميز',
empty: 'لا توجد عروض بعد. أنشئ أول عرض لجذب العملاء.', empty: 'لا توجد عروض بعد. أنشئ أول عرض لجذب العملاء.',
labelTitle: 'العنوان *', labelTitle: 'العنوان *',
@@ -514,7 +514,7 @@ export default function OffersPage() {
)} )}
<div className="flex flex-wrap gap-2"> <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.isFeatured && <span className="badge-purple">{t.featured}</span>}
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>} {offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
{typeof offer.redemptionCount === 'number' && ( {typeof offer.redemptionCount === 'number' && (
@@ -109,7 +109,7 @@ export default function OnlineReservationsPage() {
const copy = { const copy = {
en: { en: {
title: 'Online Reservations', 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', refresh: 'Refresh',
pendingApproval: 'Pending approval', pendingApproval: 'Pending approval',
loading: 'Loading…', loading: 'Loading…',
@@ -122,7 +122,7 @@ export default function OnlineReservationsPage() {
}, },
fr: { fr: {
title: 'Réservations en ligne', 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', refresh: 'Actualiser',
pendingApproval: 'En attente de validation', pendingApproval: 'En attente de validation',
loading: 'Chargement…', loading: 'Chargement…',
@@ -80,7 +80,7 @@ export default function SettingsPage() {
save: 'Saving…', save: 'Saving…',
saveBrand: 'Save brand', saveBrand: 'Save brand',
brandProfile: 'Brand and public profile', 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', payments: 'Rental payment methods',
paymentsDesc: 'Configure how renters pay your company on the public booking site.', paymentsDesc: 'Configure how renters pay your company on the public booking site.',
savePayments: 'Save payments', savePayments: 'Save payments',
@@ -104,7 +104,7 @@ export default function SettingsPage() {
contractLanguage: 'Contract language', contractLanguage: 'Contract language',
whatsapp: 'WhatsApp number', whatsapp: 'WhatsApp number',
brandColor: 'Brand color', brandColor: 'Brand color',
listedMarketplace: 'Listed on marketplace', listedStorefront: 'Listed on storefront',
logoUpload: 'Logo upload', logoUpload: 'Logo upload',
heroUpload: 'Hero image upload', heroUpload: 'Hero image upload',
uploading: 'Uploading…', uploading: 'Uploading…',
@@ -155,7 +155,7 @@ export default function SettingsPage() {
save: 'Enregistrement…', save: 'Enregistrement…',
saveBrand: 'Enregistrer la marque', saveBrand: 'Enregistrer la marque',
brandProfile: 'Marque et profil public', brandProfile: 'Marque et profil public',
brandProfileDesc: 'Contrôlez laffichage de votre entreprise sur la marketplace et le site de réservation.', brandProfileDesc: 'Contrôlez laffichage de votre entreprise sur la storefront et le site de réservation.',
payments: 'Méthodes de paiement location', payments: 'Méthodes de paiement location',
paymentsDesc: 'Configurez comment les clients paient votre entreprise.', paymentsDesc: 'Configurez comment les clients paient votre entreprise.',
savePayments: 'Enregistrer les paiements', savePayments: 'Enregistrer les paiements',
@@ -179,7 +179,7 @@ export default function SettingsPage() {
contractLanguage: 'Langue du contrat', contractLanguage: 'Langue du contrat',
whatsapp: 'Numéro WhatsApp', whatsapp: 'Numéro WhatsApp',
brandColor: 'Couleur de marque', brandColor: 'Couleur de marque',
listedMarketplace: 'Publié sur la marketplace', listedStorefront: 'Publié sur la storefront',
logoUpload: 'Logo', logoUpload: 'Logo',
heroUpload: 'Image principale', heroUpload: 'Image principale',
uploading: 'Téléversement…', uploading: 'Téléversement…',
@@ -254,7 +254,7 @@ export default function SettingsPage() {
contractLanguage: 'لغة العقد', contractLanguage: 'لغة العقد',
whatsapp: 'رقم واتساب', whatsapp: 'رقم واتساب',
brandColor: 'لون العلامة', brandColor: 'لون العلامة',
listedMarketplace: 'مدرج في السوق', listedStorefront: 'مدرج في السوق',
logoUpload: 'رفع الشعار', logoUpload: 'رفع الشعار',
heroUpload: 'رفع صورة الواجهة', heroUpload: 'رفع صورة الواجهة',
uploading: 'جارٍ الرفع…', uploading: 'جارٍ الرفع…',
@@ -597,7 +597,7 @@ export default function SettingsPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700"> <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)} /> <input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} />
{copy.listedMarketplace} {copy.listedStorefront}
</label> </label>
</div> </div>
</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>, 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>, invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
planFeatures: { 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'], 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'], PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
} as Record<Plan, string[]>, } 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>, 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>, invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
planFeatures: { 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'], 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é'], PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
} as Record<Plan, string[]>, } as Record<Plan, string[]>,
+1 -1
View File
@@ -271,7 +271,7 @@ export default function OnboardingPage() {
checked={payments.isListedOnMarketplace} checked={payments.isListedOnMarketplace}
onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })} 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> </label>
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button> <button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button>
@@ -610,7 +610,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
}, },
reservations: { reservations: {
heading: 'Booking', heading: 'Booking',
subtitle: 'All booking sources, including dashboard, public site, and marketplace.', subtitle: 'All booking sources, including dashboard, public site, and storefront.',
colCustomer: 'Customer', colCustomer: 'Customer',
colVehicle: 'Vehicle', colVehicle: 'Vehicle',
colDates: 'Dates', colDates: 'Dates',
@@ -966,7 +966,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
}, },
reservations: { reservations: {
heading: 'Réservations', 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', colCustomer: 'Client',
colVehicle: 'Véhicule', colVehicle: 'Véhicule',
colDates: 'Dates', colDates: 'Dates',
+3 -3
View File
@@ -27,7 +27,7 @@ afterEach(() => {
}) })
describe('dashboard cross-app URLs', () => { 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() const { storefrontUrl, adminUrl } = await loadUrls()
expect(storefrontUrl).toBe('http://localhost:3000') 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 () => { it('preserves non-URL values after normalizing their trailing slash', async () => {
const { storefrontUrl, adminUrl } = await loadUrls({ const { storefrontUrl, adminUrl } = await loadUrls({
NEXT_PUBLIC_STOREFRONT_URL: '/marketplace/', NEXT_PUBLIC_STOREFRONT_URL: '/storefront/',
NEXT_PUBLIC_ADMIN_URL: '/admin/', NEXT_PUBLIC_ADMIN_URL: '/admin/',
}) })
expect(storefrontUrl).toBe('/marketplace') expect(storefrontUrl).toBe('/storefront')
expect(adminUrl).toBe('/admin') expect(adminUrl).toBe('/admin')
}) })
}) })
+1 -1
View File
@@ -64,7 +64,7 @@ function localJwtMiddleware(req: NextRequest): NextResponse {
const token = req.cookies.get('employee_session')?.value const token = req.cookies.get('employee_session')?.value
const pathname = toDashboardAppPath(req.nextUrl.pathname) 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') { if (token && pathname === '/sign-in') {
const dashboardUrl = resolveProxyUrl(req, DASHBOARD_BASE_PATH) const dashboardUrl = resolveProxyUrl(req, DASHBOARD_BASE_PATH)
return NextResponse.redirect(dashboardUrl) return NextResponse.redirect(dashboardUrl)
+1 -1
View File
@@ -2,7 +2,7 @@
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders') 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. // Allow those asset-prefix origins so proxied pages can load chunks and HMR.
const dashboardAssetSource = normalizeAssetPrefix( const dashboardAssetSource = normalizeAssetPrefix(
process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined), 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) 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', () => { it('binds app privacy pages to explicit locales', () => {
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en') expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr') expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
@@ -1,6 +1,6 @@
import Link from 'next/link' import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api' import { storefrontFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } from '@/lib/i18n.server' import { getStorefrontLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types' import { formatCurrency } from '@rentaldrivego/types'
interface CompanyProfile { interface CompanyProfile {
@@ -21,14 +21,14 @@ interface CompanyProfile {
} }
export default async function CompanyProfilePage({ params }: { params: { slug: string } }) { export default async function CompanyProfilePage({ params }: { params: { slug: string } }) {
const language = await getMarketplaceLanguage() const language = await getStorefrontLanguage()
const dict = { const dict = {
en: { en: {
unavailable: 'Marketplace unavailable', unavailable: 'Storefront unavailable',
unavailableTitle: 'Company details are temporarily 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', backToExplore: 'Back to explore',
partner: 'Marketplace partner', partner: 'Storefront partner',
vehicles: 'vehicles', vehicles: 'vehicles',
offers: 'active offers', offers: 'active offers',
rating: 'Rating', rating: 'Rating',
@@ -42,11 +42,11 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
unavailableNoDate: 'Temporarily unavailable for new reservations.', unavailableNoDate: 'Temporarily unavailable for new reservations.',
}, },
fr: { fr: {
unavailable: 'Marketplace indisponible', unavailable: 'Storefront indisponible',
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.", 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", backToExplore: "Retour à l'exploration",
partner: 'Partenaire marketplace', partner: 'Partenaire storefront',
vehicles: 'véhicules', vehicles: 'véhicules',
offers: 'offres actives', offers: 'offres actives',
rating: 'Note', rating: 'Note',
@@ -78,7 +78,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
unavailableNoDate: 'غير متاحة مؤقتاً للحجوزات الجديدة.', unavailableNoDate: 'غير متاحة مؤقتاً للحجوزات الجديدة.',
}, },
}[language] }[language]
const company = await marketplaceFetchOrDefault<CompanyProfile | null>(`/marketplace/${params.slug}`, null) const company = await storefrontFetchOrDefault<CompanyProfile | null>(`/storefront/${params.slug}`, null)
if (!company) { if (!company) {
return ( return (
@@ -1,6 +1,6 @@
import Link from 'next/link' import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api' import { storefrontFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } from '@/lib/i18n.server' import { getStorefrontLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types' import { formatCurrency } from '@rentaldrivego/types'
import BookingForm from '@/components/BookingForm' import BookingForm from '@/components/BookingForm'
@@ -33,12 +33,12 @@ interface VehicleDetail {
} }
export default async function VehicleDetailPage({ params }: { params: { slug: string; id: string } }) { export default async function VehicleDetailPage({ params }: { params: { slug: string; id: string } }) {
const language = await getMarketplaceLanguage() const language = await getStorefrontLanguage()
const dict = { const dict = {
en: { en: {
unavailable: 'Marketplace unavailable', unavailable: 'Storefront unavailable',
unavailableTitle: 'Vehicle details are temporarily 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', backToFleet: 'Back to fleet',
backToExplore: 'Back to explore', backToExplore: 'Back to explore',
noPhotos: 'No photos available.', noPhotos: 'No photos available.',
@@ -59,9 +59,9 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
photos: 'Photos', photos: 'Photos',
}, },
fr: { fr: {
unavailable: 'Marketplace indisponible', unavailable: 'Storefront indisponible',
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.', 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', backToFleet: 'Retour à la flotte',
backToExplore: 'Retour à lexploration', backToExplore: 'Retour à lexploration',
noPhotos: 'Aucune photo disponible.', noPhotos: 'Aucune photo disponible.',
@@ -106,7 +106,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
}, },
}[language] }[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) { if (!vehicle) {
return ( return (
@@ -1,6 +1,6 @@
import Link from 'next/link' import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api' import { storefrontFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } from '@/lib/i18n.server' import { getStorefrontLanguage } from '@/lib/i18n.server'
import ExploreSearchForm from './ExploreSearchForm' import ExploreSearchForm from './ExploreSearchForm'
import ExploreVehicleGrid from './ExploreVehicleGrid' import ExploreVehicleGrid from './ExploreVehicleGrid'
@@ -68,7 +68,7 @@ const CATEGORY_VALUES = [
export default async function ExplorePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) { export default async function ExplorePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
const resolvedSearchParams = await searchParams const resolvedSearchParams = await searchParams
const language = await getMarketplaceLanguage() const language = await getStorefrontLanguage()
const dict = { const dict = {
en: { en: {
kicker: 'Discovery only', kicker: 'Discovery only',
@@ -89,10 +89,10 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
ageOptions: ['18+', '21+', '23+', '25+', '30+'], ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Search', search: 'Search',
currentDeals: 'Current deals', currentDeals: 'Current deals',
featuredOffers: 'Featured marketplace offers', featuredOffers: 'Featured storefront offers',
company: 'Company', company: 'Company',
validUntil: 'Valid until', validUntil: 'Valid until',
offersUnavailable: 'Marketplace offers are unavailable right now.', offersUnavailable: 'Storefront offers are unavailable right now.',
availableVehicles: 'Available vehicles', availableVehicles: 'Available vehicles',
listings: 'listings', listings: 'listings',
rentalCompany: 'Rental company', rentalCompany: 'Rental company',
@@ -108,8 +108,8 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
vehicleUnavailable: 'Vehicle listings are unavailable right now.', vehicleUnavailable: 'Vehicle listings are unavailable right now.',
browseByCategory: 'Browse by category', browseByCategory: 'Browse by category',
browseByCompany: 'Browse by company', browseByCompany: 'Browse by company',
marketplacePartners: 'Marketplace partners', storefrontPartners: 'Storefront partners',
marketplacePartner: 'Marketplace partner', storefrontPartner: 'Storefront partner',
rating: 'Rating', rating: 'Rating',
new: 'New', new: 'New',
publishedVehicles: 'published vehicles', publishedVehicles: 'published vehicles',
@@ -135,10 +135,10 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
ageOptions: ['18+', '21+', '23+', '25+', '30+'], ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Rechercher', search: 'Rechercher',
currentDeals: 'Offres du moment', currentDeals: 'Offres du moment',
featuredOffers: 'Offres marketplace mises en avant', featuredOffers: 'Offres storefront mises en avant',
company: 'Entreprise', company: 'Entreprise',
validUntil: "Valable jusqu'au", 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', availableVehicles: 'Véhicules disponibles',
listings: 'annonces', listings: 'annonces',
rentalCompany: 'Société de location', 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.', vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
browseByCategory: 'Parcourir par catégorie', browseByCategory: 'Parcourir par catégorie',
browseByCompany: 'Parcourir par entreprise', browseByCompany: 'Parcourir par entreprise',
marketplacePartners: 'Partenaires marketplace', storefrontPartners: 'Partenaires storefront',
marketplacePartner: 'Partenaire marketplace', storefrontPartner: 'Partenaire storefront',
rating: 'Note', rating: 'Note',
new: 'Nouveau', new: 'Nouveau',
publishedVehicles: 'véhicules publiés', publishedVehicles: 'véhicules publiés',
@@ -200,8 +200,8 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.', vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
browseByCategory: 'تصفح حسب الفئة', browseByCategory: 'تصفح حسب الفئة',
browseByCompany: 'تصفح حسب الشركة', browseByCompany: 'تصفح حسب الشركة',
marketplacePartners: 'شركاء السوق', storefrontPartners: 'شركاء السوق',
marketplacePartner: 'شريك في السوق', storefrontPartner: 'شريك في السوق',
rating: 'التقييم', rating: 'التقييم',
new: 'جديد', new: 'جديد',
publishedVehicles: 'سيارات منشورة', publishedVehicles: 'سيارات منشورة',
@@ -241,9 +241,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
query.set('pageSize', '24') query.set('pageSize', '24')
const [offers, vehicles, companies] = await Promise.all([ const [offers, vehicles, companies] = await Promise.all([
marketplaceFetchOrDefault<Offer[]>('/marketplace/offers', []), storefrontFetchOrDefault<Offer[]>('/storefront/offers', []),
marketplaceFetchOrDefault<Vehicle[]>(`/marketplace/search?${query.toString()}`, []), storefrontFetchOrDefault<Vehicle[]>(`/storefront/search?${query.toString()}`, []),
marketplaceFetchOrDefault<CompanyCard[]>('/marketplace/companies?pageSize=8', []), storefrontFetchOrDefault<CompanyCard[]>('/storefront/companies?pageSize=8', []),
]) ])
const cities = Array.from( const cities = Array.from(
new Set( new Set(
@@ -309,12 +309,12 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
<section> <section>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCompany}</h2> <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>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{companies.map((company) => ( {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"> <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> <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-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> <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 FooterPage, { generateStaticParams } from './page'
import { footerPageSlugs } from '@/lib/footerContent' import { footerPageSlugs } from '@/lib/footerContent'
describe('marketplace footer route', () => { describe('storefront footer route', () => {
it('generates one static route per registered footer slug', () => { it('generates one static route per registered footer slug', () => {
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug }))) expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
}) })
+6 -6
View File
@@ -1,12 +1,12 @@
'use client' 'use client'
import MarketplaceHeader from '@/components/MarketplaceHeader' import StorefrontHeader from '@/components/StorefrontHeader'
import MarketplaceFooter from '@/components/MarketplaceFooter' import StorefrontFooter from '@/components/StorefrontFooter'
import { useMarketplacePreferences, getFooterContent, localeOptions } from '@/components/MarketplaceShell' import { useStorefrontPreferences, getFooterContent, localeOptions } from '@/components/StorefrontShell'
import { resolveBrowserAppUrl } from '@/lib/appUrls' import { resolveBrowserAppUrl } from '@/lib/appUrls'
export default function PublicLayout({ children }: { children: React.ReactNode }) { 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 dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const footerContent = getFooterContent(language) const footerContent = getFooterContent(language)
const available = localeOptions.filter((o) => o.value !== language) const available = localeOptions.filter((o) => o.value !== language)
@@ -14,7 +14,7 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
return ( return (
<div className="site-page flex min-h-screen flex-col"> <div className="site-page flex min-h-screen flex-col">
<MarketplaceHeader <StorefrontHeader
dict={dict} dict={dict}
theme={theme} theme={theme}
setTheme={setTheme} setTheme={setTheme}
@@ -26,7 +26,7 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
/> />
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<div className="flex-1">{children}</div> <div className="flex-1">{children}</div>
<MarketplaceFooter <StorefrontFooter
primaryItems={footerContent.primary} primaryItems={footerContent.primary}
secondaryItems={footerContent.secondary} secondaryItems={footerContent.secondary}
localeLabel={footerContent.localeLabel} localeLabel={footerContent.localeLabel}
+7 -7
View File
@@ -1,11 +1,11 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import MarketplaceShell from '@/components/MarketplaceShell' import StorefrontShell from '@/components/StorefrontShell'
import { getMarketplaceLanguage } from '@/lib/i18n.server' import { getStorefrontLanguage } from '@/lib/i18n.server'
import './globals.css' import './globals.css'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'RentalDriveGo Marketplace', title: 'RentalDriveGo Storefront',
description: 'Discover vehicles from trusted rental companies.', description: 'Discover vehicles from trusted rental companies.',
icons: { icons: {
icon: '/rentaldrivego.png', icon: '/rentaldrivego.png',
@@ -15,11 +15,11 @@ export const metadata: Metadata = {
} }
export default async function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const language = await getMarketplaceLanguage() const language = await getStorefrontLanguage()
const cookieStore = await cookies() const cookieStore = await cookies()
const rawTheme = const rawTheme =
cookieStore.get('rentaldrivego-theme')?.value ?? cookieStore.get('rentaldrivego-theme')?.value ??
cookieStore.get('marketplace-theme')?.value cookieStore.get('storefront-theme')?.value
const theme = rawTheme === 'dark' ? 'dark' : 'light' const theme = rawTheme === 'dark' ? 'dark' : 'light'
return ( return (
@@ -29,12 +29,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: __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> </head>
<body suppressHydrationWarning> <body suppressHydrationWarning>
<MarketplaceShell initialLanguage={language} initialTheme={theme}>{children}</MarketplaceShell> <StorefrontShell initialLanguage={language} initialTheme={theme}>{children}</StorefrontShell>
</body> </body>
</html> </html>
) )
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' 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' 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() { export default function RenterDashboardPage() {
const router = useRouter() const router = useRouter()
const { language } = useMarketplacePreferences() const { language } = useStorefrontPreferences()
const dict = { const dict = {
en: { en: {
loadProfile: 'Could not load profile.', loadProfile: 'Could not load profile.',
@@ -52,7 +52,7 @@ export default function RenterDashboardPage() {
savedCompanies: 'Saved companies', savedCompanies: 'Saved companies',
noneSaved: 'None saved yet', noneSaved: 'None saved yet',
saved: 'saved', saved: 'saved',
saveCompaniesPrompt: 'Save companies you like while browsing the marketplace.', saveCompaniesPrompt: 'Save companies you like while browsing the storefront.',
startExploring: 'Start exploring', startExploring: 'Start exploring',
rentalCompany: 'Rental company', rentalCompany: 'Rental company',
}, },
@@ -77,7 +77,7 @@ export default function RenterDashboardPage() {
savedCompanies: 'Entreprises sauvegardées', savedCompanies: 'Entreprises sauvegardées',
noneSaved: 'Aucune pour le moment', noneSaved: 'Aucune pour le moment',
saved: 'sauvegardées', 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', startExploring: 'Commencer à explorer',
rentalCompany: 'Société de location', rentalCompany: 'Société de location',
}, },
@@ -9,11 +9,11 @@ import {
markRenterNotificationRead, markRenterNotificationRead,
type RenterNotification, type RenterNotification,
} from '@/lib/renter' } from '@/lib/renter'
import { useMarketplacePreferences } from '@/components/MarketplaceShell' import { useStorefrontPreferences } from '@/components/StorefrontShell'
export default function RenterNotificationsPage() { export default function RenterNotificationsPage() {
const router = useRouter() const router = useRouter()
const { language } = useMarketplacePreferences() const { language } = useStorefrontPreferences()
const dict = { const dict = {
en: { en: {
load: 'Could not load notifications.', load: 'Could not load notifications.',
@@ -12,7 +12,7 @@ import {
type RenterPreference, type RenterPreference,
type RenterProfile, type RenterProfile,
} from '@/lib/renter' } from '@/lib/renter'
import { useMarketplacePreferences } from '@/components/MarketplaceShell' import { useStorefrontPreferences } from '@/components/StorefrontShell'
type Status = 'loading' | 'ready' | 'error' type Status = 'loading' | 'ready' | 'error'
const RENTER_EVENTS = ['BOOKING_CONFIRMED', 'BOOKING_REMINDER', 'RETURN_REMINDER', 'REFUND_ISSUED', 'NEW_OFFER'] 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() { export default function RenterProfilePage() {
const router = useRouter() const router = useRouter()
const { language } = useMarketplacePreferences() const { language } = useStorefrontPreferences()
const dict = { const dict = {
en: { en: {
profile: 'Profile', profile: 'Profile',
@@ -4,11 +4,11 @@ import { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { RenterAuthError, loadRenterProfile, type SavedCompany } from '@/lib/renter' import { RenterAuthError, loadRenterProfile, type SavedCompany } from '@/lib/renter'
import { useMarketplacePreferences } from '@/components/MarketplaceShell' import { useStorefrontPreferences } from '@/components/StorefrontShell'
export default function RenterSavedCompaniesPage() { export default function RenterSavedCompaniesPage() {
const router = useRouter() const router = useRouter()
const { language } = useMarketplacePreferences() const { language } = useStorefrontPreferences()
const dict = { const dict = {
en: { en: {
load: 'Could not load saved companies.', load: 'Could not load saved companies.',
@@ -17,7 +17,7 @@ export default function RenterSavedCompaniesPage() {
back: 'Back to dashboard', back: 'Back to dashboard',
loading: 'Loading saved companies…', loading: 'Loading saved companies…',
empty: 'You have not saved any companies yet.', empty: 'You have not saved any companies yet.',
browse: 'Browse the marketplace', browse: 'Browse the storefront',
rentalCompany: 'Rental company', rentalCompany: 'Rental company',
}, },
fr: { fr: {
@@ -27,7 +27,7 @@ export default function RenterSavedCompaniesPage() {
back: 'Retour au tableau de bord', back: 'Retour au tableau de bord',
loading: 'Chargement des entreprises sauvegardées…', loading: 'Chargement des entreprises sauvegardées…',
empty: 'Vous navez encore enregistré aucune entreprise.', empty: 'Vous navez encore enregistré aucune entreprise.',
browse: 'Parcourir la marketplace', browse: 'Parcourir la storefront',
rentalCompany: 'Société de location', rentalCompany: 'Société de location',
}, },
ar: { ar: {
+12 -12
View File
@@ -2,8 +2,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types' import { formatCurrency } from '@rentaldrivego/types'
import { MarketplaceApiError, marketplaceFetch, marketplacePost } from '@/lib/api' import { StorefrontApiError, storefrontFetch, storefrontPost } from '@/lib/api'
import { useMarketplacePreferences } from '@/components/MarketplaceShell' import { useStorefrontPreferences } from '@/components/StorefrontShell'
type Props = { type Props = {
vehicleId: string vehicleId: string
@@ -314,7 +314,7 @@ function formatLocalizedDate(value: string, language: 'en' | 'fr' | 'ar') {
function getBookingSessionId() { function getBookingSessionId() {
if (typeof window === 'undefined') return 'server' 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 if (existing) return existing
const created = const created =
@@ -322,7 +322,7 @@ function getBookingSessionId() {
? window.crypto.randomUUID() ? window.crypto.randomUUID()
: `booking-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : `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 return created
} }
@@ -364,7 +364,7 @@ export default function BookingForm({
allowDifferentDropoff, allowDifferentDropoff,
dropoffLocations, dropoffLocations,
}: Props) { }: Props) {
const { language } = useMarketplacePreferences() const { language } = useStorefrontPreferences()
const t = copy[language] const t = copy[language]
const pickupOptions = Array.isArray(pickupLocations) ? pickupLocations : [] const pickupOptions = Array.isArray(pickupLocations) ? pickupLocations : []
const dropoffOptions = Array.isArray(dropoffLocations) ? dropoffLocations : [] 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>) { async function trackEvent(eventName: FunnelEventName, metadata?: Record<string, string | number | boolean | null>) {
await marketplacePost('/marketplace/events', { await storefrontPost('/storefront/events', {
eventName, eventName,
companySlug, companySlug,
vehicleId, vehicleId,
@@ -413,8 +413,8 @@ export default function BookingForm({
async function checkAvailability(startDate: string, endDate: string, signal?: AbortSignal) { async function checkAvailability(startDate: string, endDate: string, signal?: AbortSignal) {
const start = new Date(startDate) const start = new Date(startDate)
const end = new Date(endDate) const end = new Date(endDate)
const result = await marketplaceFetch<AvailabilityResponse>( const result = await storefrontFetch<AvailabilityResponse>(
`/marketplace/${companySlug}/vehicles/${vehicleId}/availability?startDate=${encodeURIComponent(start.toISOString())}&endDate=${encodeURIComponent(end.toISOString())}`, `/storefront/${companySlug}/vehicles/${vehicleId}/availability?startDate=${encodeURIComponent(start.toISOString())}&endDate=${encodeURIComponent(end.toISOString())}`,
signal ? { signal } : undefined, signal ? { signal } : undefined,
) )
@@ -471,7 +471,7 @@ export default function BookingForm({
void checkAvailability(fields.startDate, fields.endDate, controller.signal).catch((err) => { void checkAvailability(fields.startDate, fields.endDate, controller.signal).catch((err) => {
if (controller.signal.aborted) return 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 }) setAvailability({ status: 'idle', nextAvailableAt: null })
return return
} }
@@ -520,7 +520,7 @@ export default function BookingForm({
return return
} }
const booking = await marketplacePost<BookingResponse>('/marketplace/reservations', { const booking = await storefrontPost<BookingResponse>('/storefront/reservations', {
vehicleId, vehicleId,
companySlug, companySlug,
firstName: fields.firstName.trim(), firstName: fields.firstName.trim(),
@@ -546,7 +546,7 @@ export default function BookingForm({
elapsedMs: Date.now() - startedAtRef.current, elapsedMs: Date.now() - startedAtRef.current,
}) })
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof MarketplaceApiError && err.code === 'unavailable') { if (err instanceof StorefrontApiError && err.code === 'unavailable') {
setAvailability({ status: 'unavailable', nextAvailableAt: err.nextAvailableAt ?? null }) setAvailability({ status: 'unavailable', nextAvailableAt: err.nextAvailableAt ?? null })
setError(buildUnavailableMessage(err.nextAvailableAt ?? null)) setError(buildUnavailableMessage(err.nextAvailableAt ?? null))
} else { } else {
@@ -554,7 +554,7 @@ export default function BookingForm({
} }
void trackEvent('booking_request_failed', { 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 { } finally {
setSubmitting(false) setSubmitting(false)
@@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' })) const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
vi.mock('@/components/MarketplaceShell', () => ({ vi.mock('@/components/StorefrontShell', () => ({
useMarketplacePreferences: () => ({ language: preferenceState.language, theme: 'light' }), useStorefrontPreferences: () => ({ language: preferenceState.language, theme: 'light' }),
})) }))
import FooterContentPage from './FooterContentPage' import FooterContentPage from './FooterContentPage'
@@ -29,7 +29,7 @@ describe('FooterContentPage', () => {
preferenceState.language = 'en' 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' preferenceState.language = 'fr'
const page = FooterContentPage({ slug: 'contact-sales' }) const page = FooterContentPage({ slug: 'contact-sales' })
const text = collectText(page).join(' ') const text = collectText(page).join(' ')
@@ -2,9 +2,9 @@
import React from 'react' import React from 'react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell' import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent' import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
import { type MarketplaceLanguage } from '@/lib/i18n' import { type StorefrontLanguage } from '@/lib/i18n'
const pageMeta = { const pageMeta = {
en: { en: {
@@ -53,8 +53,8 @@ function renderParagraphText(paragraph: string) {
}) })
} }
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: MarketplaceLanguage }) { export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: StorefrontLanguage }) {
const { language } = useMarketplacePreferences() const { language } = useStorefrontPreferences()
const activeLanguage = forcedLanguage ?? language const activeLanguage = forcedLanguage ?? language
const content = getFooterPageContent(activeLanguage, slug) const content = getFooterPageContent(activeLanguage, slug)
const meta = pageMeta[activeLanguage] const meta = pageMeta[activeLanguage]
@@ -13,7 +13,7 @@ import {
Car, Car,
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell' import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { clearRenterSession, loadRenterProfile } from '@/lib/renter' import { clearRenterSession, loadRenterProfile } from '@/lib/renter'
const NAV_ITEMS = [ const NAV_ITEMS = [
@@ -63,7 +63,7 @@ function computeInitials(name: string): string {
export default function RenterShell({ children }: { children: React.ReactNode }) { export default function RenterShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const { language } = useMarketplacePreferences() const { language } = useStorefrontPreferences()
const dict = dicts[language] const dict = dicts[language]
const isRtl = language === 'ar' const isRtl = language === 'ar'
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -15,7 +15,7 @@ type FooterItem = {
href?: string href?: string
} }
export default function MarketplaceFooter({ export default function StorefrontFooter({
primaryItems, primaryItems,
secondaryItems, secondaryItems,
localeLabel, localeLabel,
@@ -4,9 +4,9 @@ import {
companyInitial, companyInitial,
localeMenuPositionClass, localeMenuPositionClass,
ownerWorkspaceHref, ownerWorkspaceHref,
} from './MarketplaceHeader' } from './StorefrontHeader'
describe('MarketplaceHeader helpers', () => { describe('StorefrontHeader helpers', () => {
it('builds dashboard sign-in links with language and theme query parameters', () => { it('builds dashboard sign-in links with language and theme query parameters', () => {
expect(buildDashboardSignInHref('https://app.example.com/dashboard', 'fr', 'dark')).toBe( expect(buildDashboardSignInHref('https://app.example.com/dashboard', 'fr', 'dark')).toBe(
'https://app.example.com/dashboard/sign-in?lang=fr&theme=dark' 'https://app.example.com/dashboard/sign-in?lang=fr&theme=dark'
@@ -9,7 +9,7 @@ export type Theme = 'light' | 'dark'
export type Language = 'en' | 'fr' | 'ar' export type Language = 'en' | 'fr' | 'ar'
type Dictionary = { type Dictionary = {
marketplace: string storefront: string
explore: string explore: string
signIn: string signIn: string
ownerSignIn: string ownerSignIn: string
@@ -44,7 +44,7 @@ export function companyInitial(companyName: string): string {
return companyName.trim().charAt(0).toUpperCase() return companyName.trim().charAt(0).toUpperCase()
} }
export default function MarketplaceHeader({ export default function StorefrontHeader({
dict, dict,
theme, theme,
setTheme, setTheme,
@@ -124,7 +124,7 @@ export default function MarketplaceHeader({
href="/" 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" 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>
<Link <Link
href="/explore" href="/explore"
@@ -1,11 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './MarketplaceShell' import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './StorefrontShell'
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
}) })
describe('MarketplaceShell auth helpers', () => { describe('StorefrontShell auth helpers', () => {
it('does not report an employee profile when the browser cache is empty', () => { it('does not report an employee profile when the browser cache is empty', () => {
vi.stubGlobal('window', { vi.stubGlobal('window', {
localStorage: { localStorage: {
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { getFooterContent, localeOptions } from './MarketplaceShell' import { getFooterContent, localeOptions } from './StorefrontShell'
describe('MarketplaceShell footer content registry', () => { describe('StorefrontShell footer content registry', () => {
it('exposes exactly the supported marketplace locales in selector order', () => { it('exposes exactly the supported storefront locales in selector order', () => {
expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr']) expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr'])
expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true) expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true)
}) })
@@ -3,13 +3,13 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { appPrivacyHref, footerPageHref } from '@/lib/footerContent' 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' import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
type Theme = 'light' | 'dark' type Theme = 'light' | 'dark'
type Dictionary = { type Dictionary = {
marketplace: string storefront: string
explore: string explore: string
signIn: string signIn: string
ownerSignIn: string ownerSignIn: string
@@ -20,31 +20,31 @@ type Dictionary = {
preferences: string preferences: string
} }
const dictionaries: Record<MarketplaceLanguage, Dictionary> = { const dictionaries: Record<StorefrontLanguage, Dictionary> = {
en: { en: {
marketplace: 'Home', storefront: 'Home',
explore: 'Marketplace', explore: 'Storefront',
signIn: 'Sign in', signIn: 'Sign in',
ownerSignIn: 'Create Agency Space', ownerSignIn: 'Create Agency Space',
language: 'Language', language: 'Language',
theme: 'Theme', theme: 'Theme',
light: 'Light', light: 'Light',
dark: 'Dark', dark: 'Dark',
preferences: 'Marketplace preferences', preferences: 'Storefront preferences',
}, },
fr: { fr: {
marketplace: 'Accueil', storefront: 'Accueil',
explore: 'Marketplace', explore: 'Storefront',
signIn: 'Connexion', signIn: 'Connexion',
ownerSignIn: "Créer un espace d'agence", ownerSignIn: "Créer un espace d'agence",
language: 'Langue', language: 'Langue',
theme: 'Mode', theme: 'Mode',
light: 'Clair', light: 'Clair',
dark: 'Sombre', dark: 'Sombre',
preferences: 'Préférences marketplace', preferences: 'Préférences storefront',
}, },
ar: { ar: {
marketplace: 'الرئيسية', storefront: 'الرئيسية',
explore: 'السوق', explore: 'السوق',
signIn: 'تسجيل الدخول', signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء مساحة الوكالة', ownerSignIn: 'إنشاء مساحة الوكالة',
@@ -58,13 +58,13 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
const EMPLOYEE_PROFILE_KEY = 'employee_profile' 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: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' }, { value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' }, { value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
] ]
export function getFooterContent(language: MarketplaceLanguage): { export function getFooterContent(language: StorefrontLanguage): {
primary: Array<{ label: string; href?: string }> primary: Array<{ label: string; href?: string }>
secondary: Array<{ label: string; href?: string }> secondary: Array<{ label: string; href?: string }>
localeLabel: string localeLabel: string
@@ -158,50 +158,50 @@ function detectBrowserLanguage(): string | null {
} }
type PreferencesContextValue = { type PreferencesContextValue = {
language: MarketplaceLanguage language: StorefrontLanguage
theme: Theme theme: Theme
dict: Dictionary dict: Dictionary
companyName: string | null companyName: string | null
setLanguage: (language: MarketplaceLanguage) => void setLanguage: (language: StorefrontLanguage) => void
setTheme: (theme: Theme) => void setTheme: (theme: Theme) => void
} }
const PreferencesContext = createContext<PreferencesContextValue | null>(null) const PreferencesContext = createContext<PreferencesContextValue | null>(null)
export function useMarketplacePreferences() { export function useStorefrontPreferences() {
const context = useContext(PreferencesContext) 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 return context
} }
export default function MarketplaceShell({ export default function StorefrontShell({
children, children,
initialLanguage = 'en', initialLanguage = 'en',
initialTheme = 'light', initialTheme = 'light',
}: { }: {
children: React.ReactNode children: React.ReactNode
initialLanguage?: MarketplaceLanguage initialLanguage?: StorefrontLanguage
initialTheme?: Theme initialTheme?: Theme
}) { }) {
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1' 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 [theme, setThemeState] = useState<Theme>(initialTheme)
const [hydrated, setHydrated] = useState(false) const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage) const previousLanguage = useRef<StorefrontLanguage>(initialLanguage)
const [companyName, setCompanyName] = useState<string | null>(null) const [companyName, setCompanyName] = useState<string | null>(null)
function applyLanguage(nextLanguage: MarketplaceLanguage) { function applyLanguage(nextLanguage: StorefrontLanguage) {
if (nextLanguage === language) return if (nextLanguage === language) return
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
document.documentElement.lang = nextLanguage document.documentElement.lang = nextLanguage
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr' document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
try { try {
sessionStorage.setItem('marketplace-language', nextLanguage) sessionStorage.setItem('storefront-language', nextLanguage)
} catch {} } catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['marketplace-language']) writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['storefront-language'])
} }
setLanguageState(nextLanguage) setLanguageState(nextLanguage)
@@ -214,7 +214,7 @@ export default function MarketplaceShell({
document.documentElement.classList.toggle('dark', nextTheme === 'dark') document.documentElement.classList.toggle('dark', nextTheme === 'dark')
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark' document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
document.body.dataset.theme = nextTheme document.body.dataset.theme = nextTheme
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['marketplace-theme']) writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['storefront-theme'])
} }
setThemeState(nextTheme) setThemeState(nextTheme)
@@ -247,10 +247,10 @@ export default function MarketplaceShell({
} }
} }
function readSessionLanguage(): MarketplaceLanguage | null { function readSessionLanguage(): StorefrontLanguage | null {
try { try {
const val = sessionStorage.getItem('marketplace-language') const val = sessionStorage.getItem('storefront-language')
return isMarketplaceLanguage(val) ? val : null return isStorefrontLanguage(val) ? val : null
} catch { } catch {
return null return null
} }
@@ -262,7 +262,7 @@ export default function MarketplaceShell({
if (sessionLang !== language) setLanguageState(sessionLang) if (sessionLang !== language) setLanguageState(sessionLang)
} else { } else {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY) const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) { if (isStorefrontLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguageState(scopedLanguage) setLanguageState(scopedLanguage)
} }
} }
@@ -271,7 +271,7 @@ export default function MarketplaceShell({
if ((scopedTheme === 'light' || scopedTheme === 'dark') && scopedTheme !== theme) { if ((scopedTheme === 'light' || scopedTheme === 'dark') && scopedTheme !== theme) {
setThemeState(scopedTheme) setThemeState(scopedTheme)
} else { } 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() const sessionLang = readSessionLanguage()
if (sessionLang) { if (sessionLang) {
setLanguageState(sessionLang) setLanguageState(sessionLang)
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['marketplace-language']) writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['storefront-language'])
} else { } else {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language']) const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['storefront-language'])
if (isMarketplaceLanguage(storedLanguage)) { if (isStorefrontLanguage(storedLanguage)) {
setLanguageState(storedLanguage) setLanguageState(storedLanguage)
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['marketplace-language']) writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['storefront-language'])
} else { } else {
const detected = detectBrowserLanguage() const detected = detectBrowserLanguage()
if (isMarketplaceLanguage(detected)) { if (isStorefrontLanguage(detected)) {
setLanguageState(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') { if (storedTheme === 'light' || storedTheme === 'dark') {
setThemeState(storedTheme) setThemeState(storedTheme)
} }
@@ -337,8 +337,8 @@ export default function MarketplaceShell({
if (!hydrated) return if (!hydrated) return
try { sessionStorage.setItem('marketplace-language', language) } catch {} try { sessionStorage.setItem('storefront-language', language) } catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language']) writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['storefront-language'])
if (previousLanguage.current !== language && pathname !== '/sign-in') { if (previousLanguage.current !== language && pathname !== '/sign-in') {
router.refresh() router.refresh()
@@ -351,7 +351,7 @@ export default function MarketplaceShell({
document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light' document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light'
document.body.dataset.theme = theme document.body.dataset.theme = theme
if (hydrated) { if (hydrated) {
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme']) writeScopedPreference(SHARED_THEME_KEY, theme, ['storefront-theme'])
} }
}, [theme, hydrated]) }, [theme, hydrated])
@@ -29,7 +29,7 @@ describe('WorkspaceFrame helpers', () => {
}) })
it('keeps unauthenticated visitors on the embedded sign-in path', () => { 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') expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
}) })
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { resolveBrowserAppUrl } from '@/lib/appUrls' import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { useMarketplacePreferences } from './MarketplaceShell' import { useStorefrontPreferences } from './StorefrontShell'
export type FrameId = 'sign-in' export type FrameId = 'sign-in'
@@ -37,7 +37,7 @@ export function buildFrameUrl(appUrl: string, path: string) {
export const FRAME_HEIGHT = 'calc(100vh - 56px)' export const FRAME_HEIGHT = 'calc(100vh - 56px)'
export default function WorkspaceFrame({ target }: { target: FrameId }) { export default function WorkspaceFrame({ target }: { target: FrameId }) {
const { language, theme } = useMarketplacePreferences() const { language, theme } = useStorefrontPreferences()
const [src, setSrc] = useState('') const [src, setSrc] = useState('')
const [framePath, setFramePath] = useState(() => getDefaultFramePath(target)) const [framePath, setFramePath] = useState(() => getDefaultFramePath(target))
const config = frameConfig[target] const config = frameConfig[target]
+11 -11
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { MarketplaceApiError, marketplaceFetch, marketplaceFetchOrDefault, marketplacePost } from './api' import { StorefrontApiError, storefrontFetch, storefrontFetchOrDefault, storefrontPost } from './api'
afterEach(() => { afterEach(() => {
delete process.env.NEXT_PUBLIC_API_URL delete process.env.NEXT_PUBLIC_API_URL
@@ -8,19 +8,19 @@ afterEach(() => {
Reflect.deleteProperty(globalThis, 'fetch') Reflect.deleteProperty(globalThis, 'fetch')
}) })
describe('marketplace API helpers', () => { describe('storefront API helpers', () => {
it('unwraps successful GET responses from the data envelope', async () => { it('unwraps successful GET responses from the data envelope', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [{ id: 'vehicle_1' }] }) })) const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [{ id: 'vehicle_1' }] }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
await expect(marketplaceFetch('/marketplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }]) await expect(storefrontFetch('/storefront/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/marketplace\/vehicles$/), { expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/storefront\/vehicles$/), {
cache: 'no-store', cache: 'no-store',
credentials: 'include', credentials: 'include',
}) })
}) })
it('throws rich marketplace API errors', async () => { it('throws rich storefront API errors', async () => {
Object.defineProperty(globalThis, 'fetch', { Object.defineProperty(globalThis, 'fetch', {
configurable: true, configurable: true,
value: vi.fn(async () => ({ value: vi.fn(async () => ({
@@ -30,8 +30,8 @@ describe('marketplace API helpers', () => {
})), })),
}) })
await expect(marketplaceFetch('/marketplace/book')).rejects.toMatchObject({ await expect(storefrontFetch('/storefront/book')).rejects.toMatchObject({
name: 'MarketplaceApiError', name: 'StorefrontApiError',
message: 'Vehicle unavailable', message: 'Vehicle unavailable',
status: 409, status: 409,
code: 'VEHICLE_UNAVAILABLE', code: 'VEHICLE_UNAVAILABLE',
@@ -45,7 +45,7 @@ describe('marketplace API helpers', () => {
value: vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ message: 'Down' }) })), 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 () => { 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' } }) })) const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { id: 'reservation_1' } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) 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({ expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({
method: 'POST', method: 'POST',
credentials: 'include', 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') } })), value: vi.fn(async () => ({ ok: false, status: 502, json: async () => { throw new Error('bad json') } })),
}) })
await expect(marketplaceFetch('/site/homepage')).rejects.toMatchObject({ await expect(storefrontFetch('/site/homepage')).rejects.toMatchObject({
name: 'MarketplaceApiError', name: 'StorefrontApiError',
message: 'Request failed', message: 'Request failed',
status: 502, status: 502,
}) })
+8 -8
View File
@@ -3,40 +3,40 @@ const API_BASE =
?? process.env.NEXT_PUBLIC_API_URL ?? process.env.NEXT_PUBLIC_API_URL
?? 'http://localhost:4000/api/v1' ?? 'http://localhost:4000/api/v1'
export class MarketplaceApiError extends Error { export class StorefrontApiError extends Error {
status: number status: number
code?: string code?: string
nextAvailableAt?: string | null nextAvailableAt?: string | null
constructor(message: string, status: number, code?: string, nextAvailableAt?: string | null) { constructor(message: string, status: number, code?: string, nextAvailableAt?: string | null) {
super(message) super(message)
this.name = 'MarketplaceApiError' this.name = 'StorefrontApiError'
this.status = status this.status = status
this.code = code this.code = code
this.nextAvailableAt = nextAvailableAt 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}`, { const res = await fetch(`${API_BASE}${path}`, {
cache: 'no-store', cache: 'no-store',
credentials: 'include', credentials: 'include',
...init, ...init,
}) })
const json = await res.json().catch(() => null) 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 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 { try {
return await marketplaceFetch<T>(path) return await storefrontFetch<T>(path)
} catch { } catch {
return fallback 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 base = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const res = await fetch(`${base}${path}`, { const res = await fetch(`${base}${path}`, {
method: 'POST', method: 'POST',
@@ -45,6 +45,6 @@ export async function marketplacePost<T>(path: string, body: unknown): Promise<T
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
const json = await res.json().catch(() => null) 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 return json.data as T
} }
+2 -2
View File
@@ -12,7 +12,7 @@ afterEach(() => {
Reflect.deleteProperty(globalThis, 'window') 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', () => { it('leaves server-side browser fallback unchanged when window is unavailable', () => {
expect(resolveBrowserAppUrl('http://localhost:3000')).toBe('http://localhost:3000') 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', () => { it('keeps path prefixes while rewriting the browser origin', () => {
installWindow('rental.example.com', 'https:') 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', () => { it('resolves server URLs from forwarded host/proto and handles invalid fallbacks safely', () => {
@@ -7,11 +7,11 @@ import {
getFooterPageContent, getFooterPageContent,
isFooterPageSlug, isFooterPageSlug,
} from './footerContent' } 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', () => { it('keeps every declared slug routable and recognizable', () => {
expect(footerPageSlugs.length).toBeGreaterThan(5) expect(footerPageSlugs.length).toBeGreaterThan(5)
+6 -6
View File
@@ -1,4 +1,4 @@
import type { MarketplaceLanguage } from './i18n' import type { StorefrontLanguage } from './i18n'
export const footerPageSlugs = [ export const footerPageSlugs = [
'about-us', 'about-us',
@@ -47,7 +47,7 @@ export const footerPageHref: Record<FooterPageSlug, string> = {
'general-conditions': '/footer/general-conditions', 'general-conditions': '/footer/general-conditions',
} }
const footerContent: Record<MarketplaceLanguage, Record<FooterPageSlug, FooterPageContent>> = { const footerContent: Record<StorefrontLanguage, Record<FooterPageSlug, FooterPageContent>> = {
en: { en: {
'about-us': { 'about-us': {
title: 'About Us', title: 'About Us',
@@ -175,7 +175,7 @@ const footerContent: Record<MarketplaceLanguage, Record<FooterPageSlug, FooterPa
{ {
heading: '5. Legacy Preference Cookies', heading: '5. Legacy Preference Cookies',
paragraphs: [ 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.', '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', heading: '5. Cookies de préférence hérités',
paragraphs: [ 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 quen secours et seront supprimés dans une prochaine mise à jour.', '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 quen secours et seront supprimés dans une prochaine mise à jour.',
], ],
}, },
@@ -663,7 +663,7 @@ const footerContent: Record<MarketplaceLanguage, Record<FooterPageSlug, FooterPa
{ {
heading: '5. ملفات تعريف الارتباط الموروثة', heading: '5. ملفات تعريف الارتباط الموروثة',
paragraphs: [ 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) 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] return footerContent[language][slug]
} }
+8 -8
View File
@@ -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' import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE } from './i18n'
describe('getMarketplaceLanguage', () => { describe('getStorefrontLanguage', () => {
beforeEach(() => { beforeEach(() => {
cookieValues.clear() 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(SHARED_LANGUAGE_COOKIE, 'ar')
cookieValues.set(MARKETPLACE_LANGUAGE_COOKIE, 'fr') 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') 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 () => { 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') cookieValues.set(SHARED_LANGUAGE_COOKIE, 'es')
await expect(getMarketplaceLanguage()).resolves.toBe('en') await expect(getStorefrontLanguage()).resolves.toBe('en')
}) })
}) })
+3 -3
View File
@@ -1,10 +1,10 @@
import { cookies } from 'next/headers' 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 cookieStore = await cookies()
const cookieValue = const cookieValue =
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ?? cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ??
cookieStore.get(MARKETPLACE_LANGUAGE_COOKIE)?.value cookieStore.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
return isMarketplaceLanguage(cookieValue) ? cookieValue : 'en' return isStorefrontLanguage(cookieValue) ? cookieValue : 'en'
} }
+11 -11
View File
@@ -1,18 +1,18 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { isMarketplaceLanguage } from './i18n' import { isStorefrontLanguage } from './i18n'
describe('marketplace language guard', () => { describe('storefront language guard', () => {
it('accepts the supported marketplace locales', () => { it('accepts the supported storefront locales', () => {
expect(isMarketplaceLanguage('en')).toBe(true) expect(isStorefrontLanguage('en')).toBe(true)
expect(isMarketplaceLanguage('fr')).toBe(true) expect(isStorefrontLanguage('fr')).toBe(true)
expect(isMarketplaceLanguage('ar')).toBe(true) expect(isStorefrontLanguage('ar')).toBe(true)
}) })
it('rejects unsupported, missing, and case-mismatched locales', () => { it('rejects unsupported, missing, and case-mismatched locales', () => {
expect(isMarketplaceLanguage('de')).toBe(false) expect(isStorefrontLanguage('de')).toBe(false)
expect(isMarketplaceLanguage('EN')).toBe(false) expect(isStorefrontLanguage('EN')).toBe(false)
expect(isMarketplaceLanguage('')).toBe(false) expect(isStorefrontLanguage('')).toBe(false)
expect(isMarketplaceLanguage(null)).toBe(false) expect(isStorefrontLanguage(null)).toBe(false)
expect(isMarketplaceLanguage(undefined)).toBe(false) expect(isStorefrontLanguage(undefined)).toBe(false)
}) })
}) })
+3 -3
View File
@@ -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 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' return value === 'en' || value === 'fr' || value === 'ar'
} }
+3 -3
View File
@@ -43,7 +43,7 @@ afterEach(() => {
Reflect.deleteProperty(globalThis, 'atob') Reflect.deleteProperty(globalThis, 'atob')
}) })
describe('marketplace scoped preferences', () => { describe('storefront scoped preferences', () => {
it('returns unscoped keys when no usable token exists', () => { it('returns unscoped keys when no usable token exists', () => {
installBrowser() installBrowser()
expect(getScopedPreferenceKey('rentaldrivego-language')).toBe('rentaldrivego-language') expect(getScopedPreferenceKey('rentaldrivego-language')).toBe('rentaldrivego-language')
@@ -52,9 +52,9 @@ describe('marketplace scoped preferences', () => {
it('reads shared cookies before legacy local-storage fallbacks', () => { it('reads shared cookies before legacy local-storage fallbacks', () => {
const browser = installBrowser('rentaldrivego-language=fr') 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', () => { it('writes shared and legacy values without auth-token scoping', () => {
+5 -5
View File
@@ -63,7 +63,7 @@ beforeEach(() => {
nextServer.redirect.mockClear() nextServer.redirect.mockClear()
}) })
describe('marketplace proxy language bootstrap', () => { describe('storefront proxy language bootstrap', () => {
it('rejects proxy subrequest headers before language handling', async () => { it('rejects proxy subrequest headers before language handling', async () => {
const { proxy } = await import('./proxy') const { proxy } = await import('./proxy')
@@ -85,11 +85,11 @@ describe('marketplace proxy language bootstrap', () => {
expect(response.cookies.set).not.toHaveBeenCalled() 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 { proxy } = await import('./proxy')
const response = proxy(request({ const response = proxy(request({
cookies: { 'marketplace-language': 'ar' }, cookies: { 'storefront-language': 'ar' },
headers: { cookie: 'session=abc' }, headers: { cookie: 'session=abc' },
}) as never) as any }) as never) as any
@@ -118,7 +118,7 @@ describe('marketplace proxy language bootstrap', () => {
const { proxy } = await import('./proxy') const { proxy } = await import('./proxy')
const response = proxy(request({ 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' }, headers: { 'accept-language': 'fr-CA,ar;q=0.5' },
}) as never) as any }) 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 () => { it('redirects duplicate dashboard prefixes to the clean dashboard URL', async () => {
const { proxy } = await import('./proxy') const { proxy } = await import('./proxy')
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language' const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language' const MARKETPLACE_LANGUAGE_COOKIE = 'storefront-language'
const DASHBOARD_BASE_PATH = '/dashboard' const DASHBOARD_BASE_PATH = '/dashboard'
function rejectInternalSubrequest(request: NextRequest): NextResponse | null { function rejectInternalSubrequest(request: NextRequest): NextResponse | null {
+2 -2
View File
@@ -28,7 +28,7 @@ This reduces cognitive load, reduces privacy anxiety, and makes mobile completio
Replace the current booking form with the revised version in: Replace the current booking form with the revised version in:
`apps/marketplace/src/components/BookingForm.tsx` `apps/storefront/src/components/BookingForm.tsx`
Acceptance criteria: Acceptance criteria:
@@ -50,7 +50,7 @@ Recommended behavior:
Backend candidate: Backend candidate:
- Add or expose `GET /marketplace/:slug/vehicles/:id/availability?startDate=&endDate=`. - Add or expose `GET /storefront/:slug/vehicles/:id/availability?startDate=&endDate=`.
- Reuse the existing `getVehicleAvailabilitySummary` service instead of inventing yet another inconsistent truth source, because apparently systems enjoy lying to themselves. - Reuse the existing `getVehicleAvailabilitySummary` service instead of inventing yet another inconsistent truth source, because apparently systems enjoy lying to themselves.
### Phase 3: Booking Status Transparency ### Phase 3: Booking Status Transparency
+7 -7
View File
@@ -20,7 +20,7 @@ docker compose -f docker-compose.dev.yml --profile full up --build
Services: Services:
- marketplace: `http://localhost:3000` - storefront: `http://localhost:3000`
- dashboard: `http://localhost:3000/dashboard` - dashboard: `http://localhost:3000/dashboard`
- admin: `http://localhost:3000/admin` - admin: `http://localhost:3000/admin`
- public-site: `http://localhost:3003` - public-site: `http://localhost:3003`
@@ -31,12 +31,12 @@ Each dev app now runs in its own container and can be started independently with
```bash ```bash
docker compose -f docker-compose.dev.yml --profile api up --build docker compose -f docker-compose.dev.yml --profile api up --build
docker compose -f docker-compose.dev.yml --profile marketplace up --build docker compose -f docker-compose.dev.yml --profile storefront up --build
docker compose -f docker-compose.dev.yml --profile dashboard up --build docker compose -f docker-compose.dev.yml --profile dashboard up --build
docker compose -f docker-compose.dev.yml --profile admin up --build docker compose -f docker-compose.dev.yml --profile admin up --build
docker compose -f docker-compose.dev.yml --profile public-site up --build docker compose -f docker-compose.dev.yml --profile public-site up --build
docker compose -f docker-compose.dev.yml --profile tools up --build docker compose -f docker-compose.dev.yml --profile tools up --build
docker compose -f docker-compose.dev.yml --profile api --profile marketplace --profile dashboard --profile admin up --build docker compose -f docker-compose.dev.yml --profile api --profile storefront --profile dashboard --profile admin up --build
``` ```
Notes: Notes:
@@ -121,7 +121,7 @@ Add an A record for every subdomain to your server's public IP before deploying
| Subdomain | Service | | Subdomain | Service |
|---|---| |---|---|
| `rentaldrivego.ma` | marketplace and public site | | `rentaldrivego.ma` | storefront and public site |
| `api.rentaldrivego.ma` | API | | `api.rentaldrivego.ma` | API |
| `pgmanage.rentaldrivego.ma` | pgManage (DB admin) | | `pgmanage.rentaldrivego.ma` | pgManage (DB admin) |
| `portainer.rentaldrivego.ma` | Portainer | | `portainer.rentaldrivego.ma` | Portainer |
@@ -163,7 +163,7 @@ Open `.env.docker.production` and fill in every value. The minimum required secr
Production now derives `DATABASE_URL` inside the app container from `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` when `DATABASE_URL_FROM_POSTGRES=true`. That avoids Prisma auth failures when the database password contains reserved URL characters such as `@`, `:`, or `/`. Production now derives `DATABASE_URL` inside the app container from `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` when `DATABASE_URL_FROM_POSTGRES=true`. That avoids Prisma auth failures when the database password contains reserved URL characters such as `@`, `:`, or `/`.
The example file uses `rentaldrivego.ma` for the marketplace and public site. The dashboard and admin panel are routed under that same host at `/dashboard` and `/admin`. The example file uses `rentaldrivego.ma` for the storefront and public site. The dashboard and admin panel are routed under that same host at `/dashboard` and `/admin`.
Set `PORTAINER_DOMAIN=portainer.rentaldrivego.ma` in `.env.docker.production` to expose Portainer through Traefik. Set `PORTAINER_DOMAIN=portainer.rentaldrivego.ma` in `.env.docker.production` to expose Portainer through Traefik.
Set `PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma` to expose pgManage through Traefik, and set `PGMANAGE_LICENSE_KEY` if you are running the enterprise image. Set `PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma` to expose pgManage through Traefik, and set `PGMANAGE_LICENSE_KEY` if you are running the enterprise image.
Set `REGISTRY_DOMAIN=registry.rentaldrivego.ma` to expose the Docker registry through Traefik. Set `REGISTRY_DOMAIN=registry.rentaldrivego.ma` to expose the Docker registry through Traefik.
@@ -255,7 +255,7 @@ npm run docker:prod:start:traefik
npm run docker:prod:start:postgres npm run docker:prod:start:postgres
npm run docker:prod:start:redis npm run docker:prod:start:redis
npm run docker:prod:start:api npm run docker:prod:start:api
npm run docker:prod:start:marketplace npm run docker:prod:start:storefront
npm run docker:prod:start:dashboard npm run docker:prod:start:dashboard
npm run docker:prod:start:admin npm run docker:prod:start:admin
npm run docker:prod:start:frontends npm run docker:prod:start:frontends
@@ -268,7 +268,7 @@ npm run docker:prod:start:api && npm run docker:prod:start:frontends
Docker will: Docker will:
1. Build the monorepo image on the server 1. Build the monorepo image on the server
2. Start all app services (`api`, `marketplace`, `dashboard`, `admin`, `pgmanage`) 2. Start all app services (`api`, `storefront`, `dashboard`, `admin`, `pgmanage`)
Traefik automatically picks up the containers and provisions TLS certificates. Services are live at their `https://` URLs within ~30 seconds. Traefik automatically picks up the containers and provisions TLS certificates. Services are live at their `https://` URLs within ~30 seconds.
+12 -12
View File
@@ -1,10 +1,10 @@
# 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Marketplace # 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Storefront
## Platform Model ## Platform Model
**Rental Companies (B2B)** — pay RentalDriveGoa subscription (AmanPay or PayPal), get: **Rental Companies (B2B)** — pay RentalDriveGoa subscription (AmanPay or PayPal), get:
- A fully private dashboard (zero data overlap with any other company) - A fully private dashboard (zero data overlap with any other company)
- Fleet management with vehicle photo upload (photos auto-appear on global marketplace) - Fleet management with vehicle photo upload (photos auto-appear on global storefront)
- Promotional offers management - Promotional offers management
- A white-label site at `company.RentalDriveGo.com` where renters book and pay - A white-label site at `company.RentalDriveGo.com` where renters book and pay
@@ -29,21 +29,21 @@
--- ---
## 🌐 Marketplace is Discovery Only — No Booking on RentalDriveGo.com/explore ## 🌐 Storefront is Discovery Only — No Booking on RentalDriveGo.com/explore
The global marketplace shows all vehicles with photos from all companies. When a renter clicks "Book this vehicle", they are **redirected to the company's branded site** (`{slug}.RentalDriveGo.com/book?vehicleId=X&from=Y&to=Z&ref=marketplace`). The booking form is pre-filled. All payment happens on the company site using the company's own payment accounts. The global storefront shows all vehicles with photos from all companies. When a renter clicks "Book this vehicle", they are **redirected to the company's branded site** (`{slug}.RentalDriveGo.com/book?vehicleId=X&from=Y&to=Z&ref=storefront`). The booking form is pre-filled. All payment happens on the company site using the company's own payment accounts.
--- ---
## 📸 Vehicle Photos → Marketplace Visibility ## 📸 Vehicle Photos → Storefront Visibility
``` ```
Company uploads photos in /dashboard/fleet Company uploads photos in /dashboard/fleet
→ Stored in Cloudinary → Stored in Cloudinary
→ Vehicle.photos[] holds the URLs → Vehicle.photos[] holds the URLs
→ First photo = cover image on marketplace cards → First photo = cover image on storefront cards
→ All photos shown in marketplace vehicle gallery → All photos shown in storefront vehicle gallery
→ isPublished=true makes vehicle appear on marketplace → isPublished=true makes vehicle appear on storefront
``` ```
--- ---
@@ -52,7 +52,7 @@ Company uploads photos in /dashboard/fleet
``` ```
Layer 1A: RentalDriveGo.com — B2B marketing site Layer 1A: RentalDriveGo.com — B2B marketing site
Layer 1B: RentalDriveGo.com/explore — B2C marketplace (discovery + redirect only) Layer 1B: RentalDriveGo.com/explore — B2C storefront (discovery + redirect only)
Layer 2: app.RentalDriveGo.com — Company dashboard (private, subscription-gated) Layer 2: app.RentalDriveGo.com — Company dashboard (private, subscription-gated)
Layer 3: {slug}.RentalDriveGo.com — Company branded site (booking + payment here) Layer 3: {slug}.RentalDriveGo.com — Company branded site (booking + payment here)
Layer 4: api.RentalDriveGo.com — REST API Layer 4: api.RentalDriveGo.com — REST API
@@ -66,9 +66,9 @@ Layer 4: api.RentalDriveGo.com — REST API
rental-car-site/ rental-car-site/
├── README.md ├── README.md
├── docs/ ├── docs/
│ ├── ARCHITECTURE.md ← Payment providers, marketplace redirect model, photo flow │ ├── ARCHITECTURE.md ← Payment providers, storefront redirect model, photo flow
│ ├── DESIGN_SYSTEM.md │ ├── DESIGN_SYSTEM.md
│ ├── PAGES.md ← All pages. Marketplace = discovery only. Booking = company site. │ ├── PAGES.md ← All pages. Storefront = discovery only. Booking = company site.
│ ├── FEATURES.md │ ├── FEATURES.md
│ └── DEPLOYMENT.md │ └── DEPLOYMENT.md
└── skills/ └── skills/
@@ -80,7 +80,7 @@ rental-car-site/
│ ├── api-routes.md ← All routes │ ├── api-routes.md ← All routes
│ ├── payment-service.md ← AmanPay + PayPal implementation ← KEY FILE │ ├── payment-service.md ← AmanPay + PayPal implementation ← KEY FILE
│ ├── subscription-service.md ← Manual renewal, plan prices in MAD/USD/EUR │ ├── subscription-service.md ← Manual renewal, plan prices in MAD/USD/EUR
│ ├── subdomain-service.md ← Marketplace redirect + company site │ ├── subdomain-service.md ← Storefront redirect + company site
│ └── notification-service.md ← All 5 channels │ └── notification-service.md ← All 5 channels
└── rental-car-i18n/ └── rental-car-i18n/
``` ```
@@ -60,15 +60,15 @@ Changed files:
- `apps/api/src/app.ts` - `apps/api/src/app.ts`
- `apps/dashboard/src/middleware.ts` - `apps/dashboard/src/middleware.ts`
- `apps/marketplace/src/middleware.ts` - `apps/storefront/src/middleware.ts`
- `apps/admin/src/middleware.ts` - `apps/admin/src/middleware.ts`
- `apps/dashboard/src/middleware.test.ts` - `apps/dashboard/src/middleware.test.ts`
- `apps/marketplace/src/middleware.test.ts` - `apps/storefront/src/middleware.test.ts`
What changed: What changed:
- API now rejects requests containing `x-middleware-subrequest` before route handling. - API now rejects requests containing `x-middleware-subrequest` before route handling.
- Dashboard, marketplace, and admin Next middleware now reject the same header at the app layer. - Dashboard, storefront, and admin Next middleware now reject the same header at the app layer.
- Added/updated middleware tests for the rejection path. - Added/updated middleware tests for the rejection path.
Security effect: Security effect:
+9 -9
View File
@@ -15,7 +15,7 @@ Creating an account registers a new rental company on the platform. The flow has
## 1. Accessing the Sign-up Page ## 1. Accessing the Sign-up Page
### From the marketplace homepage (port 3000) ### From the storefront homepage (port 3000)
- Click **"Create Agency Space"** in the header navigation. - Click **"Create Agency Space"** in the header navigation.
- Click **"Get Started"** on any pricing plan card (`/pricing` page). - Click **"Get Started"** on any pricing plan card (`/pricing` page).
@@ -26,7 +26,7 @@ Both actions navigate to:
http://localhost:3000/dashboard/sign-up http://localhost:3000/dashboard/sign-up
``` ```
The marketplace proxies this request to the dashboard app (port 3001), which renders the multi-step form. The storefront proxies this request to the dashboard app (port 3001), which renders the multi-step form.
### URL parameters (optional) ### URL parameters (optional)
@@ -111,7 +111,7 @@ Select one of three plans:
| Plan | Description | | Plan | Description |
|---|---| |---|---|
| **STARTER** | Core fleet, offers, and bookings | | **STARTER** | Core fleet, offers, and bookings |
| **GROWTH** | Featured marketplace placement and more room to scale | | **GROWTH** | Featured storefront placement and more room to scale |
| **PRO** | Full white-label and premium controls | | **PRO** | Full white-label and premium controls |
Additional options: Additional options:
@@ -213,7 +213,7 @@ Navigate to `/sign-in` (redirects to `/dashboard/sign-in`). Enter the owner emai
After first sign-in, the user is directed to the **onboarding** page (`/onboarding`) where they can: After first sign-in, the user is directed to the **onboarding** page (`/onboarding`) where they can:
1. **Step 1 — Company profile**: Set display name, tagline, brand color, and public location. 1. **Step 1 — Company profile**: Set display name, tagline, brand color, and public location.
2. **Step 2 — Payments**: Configure AmanPay merchant ID / PayPal email and marketplace listing preference. 2. **Step 2 — Payments**: Configure AmanPay merchant ID / PayPal email and storefront listing preference.
3. **Step 3 — Completion**: Redirect to the dashboard home. 3. **Step 3 — Completion**: Redirect to the dashboard home.
### Subscription ### Subscription
@@ -228,7 +228,7 @@ New accounts start on a **90-day free trial** with the selected plan. The subscr
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| `apps/marketplace/src/app/renter/sign-up/page.tsx` | Marketplace redirect page to dashboard sign-up | | `apps/storefront/src/app/renter/sign-up/page.tsx` | Storefront redirect page to dashboard sign-up |
| `apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx` | Main multi-step sign-up form (4 steps, 937 lines) | | `apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx` | Main multi-step sign-up form (4 steps, 937 lines) |
| `apps/dashboard/src/components/ui/BilingualInput.tsx` | Bilingual (FR/AR) input component | | `apps/dashboard/src/components/ui/BilingualInput.tsx` | Bilingual (FR/AR) input component |
| `apps/dashboard/src/components/layout/PublicShell.tsx` | Public layout wrapper for the sign-up page | | `apps/dashboard/src/components/layout/PublicShell.tsx` | Public layout wrapper for the sign-up page |
@@ -241,10 +241,10 @@ New accounts start on a **90-day free trial** with the selected plan. The subscr
| File | Location | | File | Location |
|---|---| |---|---|
| `apps/marketplace/src/components/MarketplaceHeader.tsx` | "Create Agency Space" button | | `apps/storefront/src/components/StorefrontHeader.tsx` | "Create Agency Space" button |
| `apps/marketplace/src/components/WorkspaceTabs.tsx` | "Owner sign in" link | | `apps/storefront/src/components/WorkspaceTabs.tsx` | "Owner sign in" link |
| `apps/marketplace/src/app/(public)/pricing/PricingClient.tsx` | "Get Started" pricing CTA | | `apps/storefront/src/app/(public)/pricing/PricingClient.tsx` | "Get Started" pricing CTA |
| `apps/marketplace/src/app/(public)/HomeContent.tsx` | Homepage CTA | | `apps/storefront/src/app/(public)/HomeContent.tsx` | Homepage CTA |
### Backend ### Backend
+1 -1
View File
@@ -4,7 +4,7 @@ docker compose -f docker-compose.dev.yml up -d redis
# app services # app services
docker compose -f docker-compose.dev.yml --profile api up --build docker compose -f docker-compose.dev.yml --profile api up --build
docker compose -f docker-compose.dev.yml --profile marketplace up --build docker compose -f docker-compose.dev.yml --profile storefront up --build
docker compose -f docker-compose.dev.yml --profile dashboard up --build docker compose -f docker-compose.dev.yml --profile dashboard up --build
docker compose -f docker-compose.dev.yml --profile admin up --build docker compose -f docker-compose.dev.yml --profile admin up --build
+10 -10
View File
@@ -44,7 +44,7 @@
## 3. What to Redesign (Inspired by isysolution.com) ## 3. What to Redesign (Inspired by isysolution.com)
### 3.1 Marketplace — Public Pages ### 3.1 Storefront — Public Pages
#### Home Page (`/`) #### Home Page (`/`)
Current issues to fix: Current issues to fix:
@@ -70,7 +70,7 @@ Stats Strip
How It Works How It Works
├── Step 1: Create your workspace ├── Step 1: Create your workspace
├── Step 2: Add your fleet ├── Step 2: Add your fleet
├── Step 3: Go live on marketplace ├── Step 3: Go live on storefront
└── Step 4: Manage everything from dashboard └── Step 4: Manage everything from dashboard
Services / Features Grid Services / Features Grid
@@ -154,8 +154,8 @@ Redesign:
## 4. Component Redesign Checklist ## 4. Component Redesign Checklist
### Shared Components ### Shared Components
- [ ] `MarketplaceHeader` — add mobile hamburger menu, improve nav spacing - [ ] `StorefrontHeader` — add mobile hamburger menu, improve nav spacing
- [ ] `MarketplaceFooter` — add logo, social links, newsletter input - [ ] `StorefrontFooter` — add logo, social links, newsletter input
- [ ] `BookingForm` — improve step layout, add progress indicator - [ ] `BookingForm` — improve step layout, add progress indicator
- [ ] `WorkspaceTabs` — improve active tab styling - [ ] `WorkspaceTabs` — improve active tab styling
@@ -187,7 +187,7 @@ Redesign:
| Numbered step process | Home "How it works" | | Numbered step process | Home "How it works" |
| Stats/counters strip | Home page | | Stats/counters strip | Home page |
| Testimonials carousel | Home page | | Testimonials carousel | Home page |
| Mega footer with columns | Marketplace footer | | Mega footer with columns | Storefront footer |
| Sidebar with grouped nav | Dashboard | | Sidebar with grouped nav | Dashboard |
| KPI metric cards | Dashboard home | | KPI metric cards | Dashboard home |
@@ -202,7 +202,7 @@ Redesign:
- Wide: `xl: 1280px`, `2xl: 1536px` - Wide: `xl: 1280px`, `2xl: 1536px`
### Mobile Improvements Needed ### Mobile Improvements Needed
- [ ] Hamburger menu for marketplace header - [ ] Hamburger menu for storefront header
- [ ] Collapsible sidebar for dashboard - [ ] Collapsible sidebar for dashboard
- [ ] Stack pricing cards vertically - [ ] Stack pricing cards vertically
- [ ] Full-width booking form - [ ] Full-width booking form
@@ -228,7 +228,7 @@ Redesign:
## 8. Implementation Phases ## 8. Implementation Phases
### Phase 1 — Marketplace Public Pages (Priority) ### Phase 1 — Storefront Public Pages (Priority)
1. Home page redesign 1. Home page redesign
- Stats strip - Stats strip
- How it works section - How it works section
@@ -254,15 +254,15 @@ Redesign:
## 9. Files to Modify ## 9. Files to Modify
### Marketplace ### Storefront
| File | Change | | File | Change |
|---|---| |---|---|
| `src/app/(public)/HomeContent.tsx` | Add stats, how-it-works, testimonials sections | | `src/app/(public)/HomeContent.tsx` | Add stats, how-it-works, testimonials sections |
| `src/app/(public)/features/page.tsx` | Alternating layout | | `src/app/(public)/features/page.tsx` | Alternating layout |
| `src/app/(public)/pricing/PricingPageContent.tsx` | Toggle + improved cards | | `src/app/(public)/pricing/PricingPageContent.tsx` | Toggle + improved cards |
| `src/app/(public)/explore/ExploreVehicleGrid.tsx` | Improved vehicle cards | | `src/app/(public)/explore/ExploreVehicleGrid.tsx` | Improved vehicle cards |
| `src/components/MarketplaceHeader.tsx` | Mobile menu | | `src/components/StorefrontHeader.tsx` | Mobile menu |
| `src/components/MarketplaceFooter.tsx` | Mega footer | | `src/components/StorefrontFooter.tsx` | Mega footer |
| `src/app/globals.css` | No changes needed | | `src/app/globals.css` | No changes needed |
### Dashboard ### Dashboard
+3 -3
View File
@@ -70,13 +70,13 @@ Replace the rigid 4-step form with an explicit состояние/state on the `
| `DRAFT` | Step 0 above | Browsing dashboard UI, exploring settings, reading docs | | `DRAFT` | Step 0 above | Browsing dashboard UI, exploring settings, reading docs |
| `IDENTIFIED` | Owner first/last name + company display name | Personalizing UI, inviting teammates | | `IDENTIFIED` | Owner first/last name + company display name | Personalizing UI, inviting teammates |
| `LISTABLE` | Company contact info + address | Creating/saving a fleet item or offer as **draft** | | `LISTABLE` | Company contact info + address | Creating/saving a fleet item or offer as **draft** |
| `PUBLISHABLE` | Legal identity block (legal name, legal form, RC, ICE, IF, license) | Publishing a listing live on the marketplace | | `PUBLISHABLE` | Legal identity block (legal name, legal form, RC, ICE, IF, license) | Publishing a listing live on the storefront |
| `PAYABLE` | Payment provider config (AmanPay/PayPal) + responsible person | Accepting bookings/payments | | `PAYABLE` | Payment provider config (AmanPay/PayPal) + responsible person | Accepting bookings/payments |
| `SUBSCRIBED` | Plan + billing period selection | Past the 90-day trial, billed account | | `SUBSCRIBED` | Plan + billing period selection | Past the 90-day trial, billed account |
Each state is just a derived boolean/computed field (`companyState`) based on which fields are filled — not a separate workflow table. The UI reads this state to decide what to show/lock. Each state is just a derived boolean/computed field (`companyState`) based on which fields are filled — not a separate workflow table. The UI reads this state to decide what to show/lock.
This also directly maps the **existing legal/compliance fields** (RC, ICE, IF, operating license) to the **one moment they actually matter**: right before something goes live on the marketplace. Today they're collected on day 1, before the user has even decided to publish anything. This also directly maps the **existing legal/compliance fields** (RC, ICE, IF, operating license) to the **one moment they actually matter**: right before something goes live on the storefront. Today they're collected on day 1, before the user has even decided to publish anything.
--- ---
@@ -171,7 +171,7 @@ Confirmation email still sends immediately after Step 0, but its copy changes fr
| Risk | Mitigation | | Risk | Mitigation |
|---|---| |---|---|
| Users abandon before ever reaching `PUBLISHABLE`/`PAYABLE` | That's expected and fine — they were never going to finish the old form either. Track funnel by state instead of all-or-nothing. | | Users abandon before ever reaching `PUBLISHABLE`/`PAYABLE` | That's expected and fine — they were never going to finish the old form either. Track funnel by state instead of all-or-nothing. |
| Marketplace ends up with unpublishable "ghost" companies | `DRAFT`/`IDENTIFIED` companies are simply never shown publicly — only `PUBLISHABLE`+ companies appear, so this is a non-issue by construction. | | Storefront ends up with unpublishable "ghost" companies | `DRAFT`/`IDENTIFIED` companies are simply never shown publicly — only `PUBLISHABLE`+ companies appear, so this is a non-issue by construction. |
| Legal/compliance requirements might mandate some data before *any* account exists, in some jurisdictions | Confirm with legal/compliance which fields (if any) are truly required by regulation before allowing a transaction vs. before allowing signup at all. Likely only the `PAYABLE` gate has real regulatory weight. | | Legal/compliance requirements might mandate some data before *any* account exists, in some jurisdictions | Confirm with legal/compliance which fields (if any) are truly required by regulation before allowing a transaction vs. before allowing signup at all. Likely only the `PAYABLE` gate has real regulatory weight. |
| Users get repeatedly interrupted by modals | Batch by group (Section 5), not by individual field; show the completion indicator so users see how close they are; never re-ask for data already given. | | Users get repeatedly interrupted by modals | Batch by group (Section 5), not by individual field; show the completion indicator so users see how close they are; never re-ask for data already given. |
| Existing accounts (already fully filled out under the old flow) | No migration needed — they'll simply compute as `SUBSCRIBED`/fully complete already. | | Existing accounts (already fully filled out under the old flow) | No migration needed — they'll simply compute as `SUBSCRIBED`/fully complete already. |
@@ -53,7 +53,7 @@ whenever new endpoints are added.
Integration tests exist, but the heaviest workflows still benefit from deeper coverage: Integration tests exist, but the heaviest workflows still benefit from deeper coverage:
- subscription billing transitions - subscription billing transitions
- marketplace reservation intake - storefront reservation intake
- public booking and payment initialization - public booking and payment initialization
- reservation inspection and close flows - reservation inspection and close flows
- admin billing operations - admin billing operations
+2 -2
View File
@@ -52,7 +52,7 @@ We do not use analytics cookies, advertising cookies, or third-party tracking co
| **Scope** | All pages (`path=/`) | | **Scope** | All pages (`path=/`) |
| **Third-party** | No | | **Third-party** | No |
**Purpose:** Stores your chosen display language so the interface appears in your preferred language on every visit across all parts of the platform (public marketplace, dashboard, and admin panel). Supported values are English (`en`), French (`fr`), and Arabic (`ar`). **Purpose:** Stores your chosen display language so the interface appears in your preferred language on every visit across all parts of the platform (public storefront, dashboard, and admin panel). Supported values are English (`en`), French (`fr`), and Arabic (`ar`).
**When it is set:** When you change the language using the language selector, or automatically on first visit based on your browser's language settings. **When it is set:** When you change the language using the language selector, or automatically on first visit based on your browser's language settings.
**Can you opt out?** You can block this cookie. If you do, the platform will fall back to a default language and you may need to select your language on every visit. **Can you opt out?** You can block this cookie. If you do, the platform will fall back to a default language and you may need to select your language on every visit.
@@ -97,7 +97,7 @@ We do not use analytics cookies, advertising cookies, or third-party tracking co
| Field | Value | | Field | Value |
|---|---| |---|---|
| **Names** | `dashboard-language`, `marketplace-language` | | **Names** | `dashboard-language`, `storefront-language` |
| **Category** | Functional / preference | | **Category** | Functional / preference |
| **Duration** | 1 year | | **Duration** | 1 year |
| **Scope** | All pages (`path=/`) | | **Scope** | All pages (`path=/`) |
+7 -7
View File
@@ -4,7 +4,7 @@ This document lists the features that are active in the current codebase.
Source of truth: Source of truth:
- `apps/marketplace` - `apps/storefront`
- `apps/dashboard` - `apps/dashboard`
- `apps/admin` - `apps/admin`
- `apps/api` - `apps/api`
@@ -14,7 +14,7 @@ Source of truth:
The current workspace contains four active apps: The current workspace contains four active apps:
- `apps/marketplace` - `apps/storefront`
- `apps/dashboard` - `apps/dashboard`
- `apps/admin` - `apps/admin`
- `apps/api` - `apps/api`
@@ -61,12 +61,12 @@ There is no separate frontend app for a white-label company public site in this
- Notification inbox and preferences - Notification inbox and preferences
- Subscription management - Subscription management
### Marketplace and public platform ### Storefront and public platform
- Public marketing homepage - Public marketing homepage
- Public pricing page - Public pricing page
- Public features page - Public features page
- Public marketplace/explore flow - Public storefront/explore flow
- Explore company profile pages under `/explore/[slug]` - Explore company profile pages under `/explore/[slug]`
- Public review submission page via review token - Public review submission page via review token
- Public legal/app policy pages - Public legal/app policy pages
@@ -120,7 +120,7 @@ There is no separate frontend app for a white-label company public site in this
- Billing operations - Billing operations
- Pricing configuration and promotions - Pricing configuration and promotions
- Notification review - Notification review
- Marketplace/site config management - Storefront/site config management
## Present But Not Active Product Features ## Present But Not Active Product Features
@@ -144,9 +144,9 @@ The following old design ideas should not be treated as active features unless c
- standalone about/contact page set - standalone about/contact page set
- Google renter auth - Google renter auth
- automatic custom-domain provisioning - automatic custom-domain provisioning
- Zapier/webhook marketplace integrations beyond the current API/webhook handlers - Zapier/webhook storefront integrations beyond the current API/webhook handlers
## Notes ## Notes
- The database still contains legacy fields such as `Employee.clerkUserId`, but those fields are no longer evidence of active Clerk integration. - The database still contains legacy fields such as `Employee.clerkUserId`, but those fields are no longer evidence of active Clerk integration.
- Some renter-facing `/renter/*` pages exist in the marketplace app, but the auth entrypoints they depend on are not active. They are therefore not listed as active product scope here. - Some renter-facing `/renter/*` pages exist in the storefront app, but the auth entrypoints they depend on are not active. They are therefore not listed as active product scope here.
+9 -9
View File
@@ -4,15 +4,15 @@ This document lists the pages that are actually present in the current frontend
Source of truth: Source of truth:
- `apps/marketplace/src/app` - `apps/storefront/src/app`
- `apps/dashboard/src/app` - `apps/dashboard/src/app`
- `apps/admin/src/app` - `apps/admin/src/app`
## Marketplace App ## Storefront App
App: App:
- `apps/marketplace` - `apps/storefront`
### Public routes ### Public routes
@@ -37,11 +37,11 @@ App:
- `/` is the public marketing home. - `/` is the public marketing home.
- `/pricing` consumes platform pricing from `/site/platform/pricing`. - `/pricing` consumes platform pricing from `/site/platform/pricing`.
- `/explore` is the public marketplace search/discovery page. - `/explore` is the public storefront search/discovery page.
- `/explore/[slug]` is the company marketplace profile page. - `/explore/[slug]` is the company storefront profile page.
- `/review` is the review submission page driven by a reservation review token. - `/review` is the review submission page driven by a reservation review token.
### Not present in the current marketplace app ### Not present in the current storefront app
- `/about` - `/about`
- `/contact` - `/contact`
@@ -51,7 +51,7 @@ App:
### Renter routes present in code but not active product entrypoints ### Renter routes present in code but not active product entrypoints
These routes exist in the marketplace app: These routes exist in the storefront app:
- `/renter/dashboard` - `/renter/dashboard`
- `/renter/notifications` - `/renter/notifications`
@@ -114,7 +114,7 @@ Base path:
- `/dashboard` shows KPIs and booking-source analytics. - `/dashboard` shows KPIs and booking-source analytics.
- `/dashboard/fleet*` manages vehicles, maintenance, and calendar blocks. - `/dashboard/fleet*` manages vehicles, maintenance, and calendar blocks.
- `/dashboard/reservations*` manages the booking lifecycle and inspection workflows. - `/dashboard/reservations*` manages the booking lifecycle and inspection workflows.
- `/dashboard/online-reservations` handles public/marketplace booking intake. - `/dashboard/online-reservations` handles public/storefront booking intake.
- `/dashboard/customers` is the company CRM view. - `/dashboard/customers` is the company CRM view.
- `/dashboard/offers` manages promotions. - `/dashboard/offers` manages promotions.
- `/dashboard/team` manages employees. - `/dashboard/team` manages employees.
@@ -164,7 +164,7 @@ App:
- `/dashboard/billing` manages platform billing operations. - `/dashboard/billing` manages platform billing operations.
- `/dashboard/pricing` edits platform pricing, features, and promotions. - `/dashboard/pricing` edits platform pricing, features, and promotions.
- `/dashboard/notifications` inspects platform notifications. - `/dashboard/notifications` inspects platform notifications.
- `/dashboard/site-config` edits marketplace/site configuration content. - `/dashboard/site-config` edits storefront/site configuration content.
## Deliberately Not Listed ## Deliberately Not Listed
@@ -8,7 +8,7 @@
## Summary ## Summary
A full security review of the API codebase identified 3 confirmed, high-confidence vulnerabilities. All 3 were fixed in the same session. A separate frontend scan (dashboard, admin, marketplace, public-site) found no confirmed vulnerabilities above the reporting threshold. A full security review of the API codebase identified 3 confirmed, high-confidence vulnerabilities. All 3 were fixed in the same session. A separate frontend scan (dashboard, admin, storefront, public-site) found no confirmed vulnerabilities above the reporting threshold.
| # | Severity | Category | Status | | # | Severity | Category | Status |
|---|----------|----------|--------| |---|----------|----------|--------|
@@ -127,7 +127,7 @@ An authenticated employee from Company A could fetch the full maintenance histor
### Exploit Scenario ### Exploit Scenario
An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/maintenance`. Vehicle UUIDs can leak through marketplace listings, shared bookings, or support interactions. The response contains the complete maintenance history of Company B's vehicle — including service records, mileage data, and operational schedules — across tenant boundaries. An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/maintenance`. Vehicle UUIDs can leak through storefront listings, shared bookings, or support interactions. The response contains the complete maintenance history of Company B's vehicle — including service records, mileage data, and operational schedules — across tenant boundaries.
### Fix ### Fix
@@ -156,7 +156,7 @@ An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/
## Frontend Scan Results ## Frontend Scan Results
A full scan of all four frontend applications (dashboard, admin, marketplace, public-site) was performed. No vulnerabilities above the reporting threshold (confidence ≥ 8/10) were confirmed. A full scan of all four frontend applications (dashboard, admin, storefront, public-site) was performed. No vulnerabilities above the reporting threshold (confidence ≥ 8/10) were confirmed.
### Hardening Recommendations (not vulnerabilities) ### Hardening Recommendations (not vulnerabilities)
@@ -164,7 +164,7 @@ Two items were identified as best-practice improvements:
1. **Admin auth-redirect `next` param** (`apps/admin/src/app/auth-redirect/page.tsx`) — Validate the `next` query parameter is a relative path (starts with `/`, does not contain `://`) before passing it to `router.replace`. The router itself does not perform cross-origin navigation, but defense-in-depth is recommended. 1. **Admin auth-redirect `next` param** (`apps/admin/src/app/auth-redirect/page.tsx`) — Validate the `next` query parameter is a relative path (starts with `/`, does not contain `://`) before passing it to `router.replace`. The router itself does not perform cross-origin navigation, but defense-in-depth is recommended.
2. **`postMessage` wildcard origin + missing `frame-ancestors` CSP** (`apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`, `apps/dashboard/next.config.js`) — Replace `targetOrigin: '*'` with the marketplace origin, and add `Content-Security-Policy: frame-ancestors 'self' <marketplace-origin>` to prevent the sign-in page from being embeddable by arbitrary third-party domains. 2. **`postMessage` wildcard origin + missing `frame-ancestors` CSP** (`apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`, `apps/dashboard/next.config.js`) — Replace `targetOrigin: '*'` with the storefront origin, and add `Content-Security-Policy: frame-ancestors 'self' <storefront-origin>` to prevent the sign-in page from being embeddable by arbitrary third-party domains.
--- ---
+8 -8
View File
@@ -68,7 +68,7 @@ This is the default for dashboard/company management routes.
### Subscription Guard ### Subscription Guard
`requireSubscription` blocks company routes when the company status is `PENDING` or `SUSPENDED`. Public site and marketplace routes are intentionally not behind this guard. `requireSubscription` blocks company routes when the company status is `PENDING` or `SUSPENDED`. Public site and storefront routes are intentionally not behind this guard.
### Role-Based Access Control ### Role-Based Access Control
@@ -90,7 +90,7 @@ Admin roles are hierarchical:
`requireRenterAuth` validates a Bearer token with `type === "renter"` and attaches `req.renterId`. `requireRenterAuth` validates a Bearer token with `type === "renter"` and attaches `req.renterId`.
`optionalRenterAuth` is used on marketplace routes so public reads still work without a token. `optionalRenterAuth` is used on storefront routes so public reads still work without a token.
### API Key ### API Key
@@ -136,7 +136,7 @@ The table below describes the current route groups mounted in `app.ts`.
| `/api/v1/auth/employee` | Public plus employee JWT | Employee login, password reset, profile, language | Dashboard/staff authentication path | | `/api/v1/auth/employee` | Public plus employee JWT | Employee login, password reset, profile, language | Dashboard/staff authentication path |
| `/api/v1/auth/renter` | Renter JWT for profile routes | Renter profile and push token | `signup` and `login` are currently disabled in this codebase | | `/api/v1/auth/renter` | Renter JWT for profile routes | Renter profile and push token | `signup` and `login` are currently disabled in this codebase |
| `/api/v1/admin` | Admin JWT | Platform operations | Includes company admin, billing, subscriptions, pricing, audit, and admin-user management | | `/api/v1/admin` | Admin JWT | Platform operations | Includes company admin, billing, subscriptions, pricing, audit, and admin-user management |
| `/api/v1/marketplace` | Public, optional renter JWT | Marketplace discovery and reservation intake | Designed for discovery and lead capture across companies | | `/api/v1/storefront` | Public, optional renter JWT | Storefront discovery and reservation intake | Designed for discovery and lead capture across companies |
| `/api/v1/site` | Public | White-label company site APIs | Drives each company-branded booking site | | `/api/v1/site` | Public | White-label company site APIs | Drives each company-branded booking site |
| `/api/v1/subscriptions` | Mixed | Plan listing, webhooks, subscription lifecycle | Public, webhook, and authenticated sub-routers share the same prefix | | `/api/v1/subscriptions` | Mixed | Plan listing, webhooks, subscription lifecycle | Public, webhook, and authenticated sub-routers share the same prefix |
| `/api/v1/vehicles` | Employee JWT + tenant + subscription | Fleet management | Includes photos, status, calendar blocks, maintenance, and availability | | `/api/v1/vehicles` | Employee JWT + tenant + subscription | Fleet management | Includes photos, status, calendar blocks, maintenance, and availability |
@@ -228,17 +228,17 @@ Customers are company-scoped CRM records, even when a renter identity also exist
This is intentionally separate from renter identity because one renter may interact with multiple companies while each company still needs its own operational customer record. This is intentionally separate from renter identity because one renter may interact with multiple companies while each company still needs its own operational customer record.
### Marketplace and public site ### Storefront and public site
The public surface is split in two modules on purpose. The public surface is split in two modules on purpose.
`marketplace` is the cross-company discovery layer: `storefront` is the cross-company discovery layer:
- featured/public offers - featured/public offers
- marketplace cities - storefront cities
- listed companies - listed companies
- vehicle search - vehicle search
- marketplace reservation intake - storefront reservation intake
- review submission by token - review submission by token
- company public pages under `/:slug` - company public pages under `/:slug`
@@ -318,7 +318,7 @@ The admin router is broad because it covers platform operations across multiple
- billing account and billing invoice operations - billing account and billing invoice operations
- pricing config, plan features, and promotions - pricing config, plan features, and promotions
- subscription overrides and extensions - subscription overrides and extensions
- marketplace homepage configuration - storefront homepage configuration
This is the only route group allowed to work across tenants. This is the only route group allowed to work across tenants.
+1 -1
View File
@@ -243,7 +243,7 @@ Purpose:
- public contact and social metadata - public contact and social metadata
- default locale and currency - default locale and currency
- company-owned payment provider settings for renter payments - company-owned payment provider settings for renter payments
- marketplace visibility and rating - storefront visibility and rating
- homepage/menu JSON configuration - homepage/menu JSON configuration
Important uniqueness: Important uniqueness:

Some files were not shown because too many files have changed in this diff Show More