Rename legacy storefront app and references to carplace
Build & Push / Build & Push Docker Image (push) Failing after 10m39s

Replace storefront naming across source, tests, docs, config, and production scripts. Rename the legacy top-level app directory and Carplace component files, remove duplicate storefront startup scripts, and refresh the lockfile.
This commit is contained in:
root
2026-07-04 18:10:08 -04:00
parent fefaf8108d
commit c77d0a8e89
113 changed files with 366 additions and 434 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
# Frontend public URLs
NEXT_PUBLIC_HOMEPAGE_URL=https://example.com
NEXT_PUBLIC_STOREFRONT_URL=https://example.com
NEXT_PUBLIC_CARPLACE_URL=https://example.com
NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
+1 -1
View File
@@ -88,7 +88,7 @@ REDIS_URL=redis://localhost:6379
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
NEXT_PUBLIC_STOREFRONT_URL=http://localhost:3000/storefront
NEXT_PUBLIC_CARPLACE_URL=http://localhost:3000/carplace
NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000
# Public site is subdomain-based; use this for local dev:
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
+6 -6
View File
@@ -93,7 +93,7 @@ jobs:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
NEXT_PUBLIC_STOREFRONT_URL: ${{ secrets.NEXT_PUBLIC_STOREFRONT_URL }}
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
@@ -114,8 +114,8 @@ jobs:
esac
}
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$NEXT_PUBLIC_STOREFRONT_URL" ]; then
NEXT_PUBLIC_CARPLACE_URL="${NEXT_PUBLIC_STOREFRONT_URL%/}/carplace"
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
fi
validate_url NEXT_PUBLIC_API_URL "$NEXT_PUBLIC_API_URL"
@@ -251,7 +251,7 @@ jobs:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
NEXT_PUBLIC_STOREFRONT_URL: ${{ secrets.NEXT_PUBLIC_STOREFRONT_URL }}
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
@@ -265,8 +265,8 @@ jobs:
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$NEXT_PUBLIC_STOREFRONT_URL" ]; then
NEXT_PUBLIC_CARPLACE_URL="${NEXT_PUBLIC_STOREFRONT_URL%/}/carplace"
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
fi
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
set -e
+2 -2
View File
@@ -28,14 +28,14 @@ api_tests:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
storefront_tests:
carplace_tests:
stage: test
image: node:20-bookworm
before_script:
- npm ci
- npm run build --workspace @rentaldrivego/types
script:
- npm run test:storefront
- npm run test:carplace
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
+2 -2
View File
@@ -7,7 +7,7 @@ The Phase 16 visual system has been applied at source level to the legacy applic
- `homepage`: retained as the authoritative Phase 16 marketing implementation already present in the legacy archive.
- `admin`: migrated to the Phase 16 token palette and component treatment; navigation was rebuilt as a responsive, accessible application shell.
- `dashboard`: migrated to the same semantic surfaces, typography, borders, shadows, focus treatment, dark theme, and blue/orange action hierarchy.
- `storefront`: migrated to the same public-site surfaces, conversion treatment, cards, forms, dark theme, and brand naming.
- `carplace`: migrated to the same public-site surfaces, conversion treatment, cards, forms, dark theme, and brand naming.
- `api`: intentionally unchanged. A visual migration should not casually rewrite business logic because that is how weekends disappear.
## Design rules applied
@@ -29,7 +29,7 @@ The supplied `RentalDriveGo_Phase16_Evidence_Package_v1.0.zip` is an evidence an
- `admin/src/styles/phase16-tokens.css`
- `dashboard/src/styles/phase16-tokens.css`
- `storefront/src/styles/phase16-tokens.css`
- `carplace/src/styles/phase16-tokens.css`
- `scripts/validate-phase16-design.mjs`
- `DESIGN_MIGRATION_REPORT.md`
- `PHASE16_DESIGN_MIGRATION_MANIFEST.json`
+3 -3
View File
@@ -7,14 +7,14 @@
3. Added `PublicPageLayout` as the reusable composition layer for authentication and onboarding pages.
4. Updated sign-in, create-account, forgot-password, reset-password, verify-email, onboarding, and invitation pages to use the public component API.
5. Preserved legacy component paths through compatibility exports.
6. Moved storefront navbar/footer assembly out of the Next.js route layout and into reusable public components.
6. Moved carplace navbar/footer assembly out of the Next.js route layout and into reusable public components.
7. Added active-page semantics to dashboard sign-in and create-account navbar actions.
8. Added focused tests and implementation documentation.
## Deliberately unchanged
- Authentication requests and API endpoints
- Redirect behavior between storefront, dashboard, and admin applications
- Redirect behavior between carplace, dashboard, and admin applications
- Language and theme persistence
- Embedded workspace behavior
- Authenticated dashboard and admin navigation
@@ -25,7 +25,7 @@
- Verified all relative and `@/` local imports resolve.
- Verified every `use client` directive remains the first statement.
- Verified sign-in and create-account import and render `PublicPageLayout`.
- Verified the storefront route layout no longer assembles navbar/footer inline.
- Verified the carplace route layout no longer assembles navbar/footer inline.
- Verified embedded password-recovery navigation preserves `embedded=1`.
## Validation limitation
+7 -7
View File
@@ -11,7 +11,7 @@
"homepage": "retained as embedded Phase 16 source of truth",
"admin": "migrated",
"dashboard": "migrated",
"storefront": "migrated",
"carplace": "migrated",
"api": "unchanged"
},
"validation": {
@@ -37,37 +37,37 @@
"sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64"
},
{
"path": "storefront/src/app/layout.tsx",
"path": "carplace/src/app/layout.tsx",
"status": "modified",
"bytes": 1833,
"sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27"
},
{
"path": "storefront/src/app/globals.css",
"path": "carplace/src/app/globals.css",
"status": "modified",
"bytes": 7525,
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
},
{
"path": "storefront/src/components/StorefrontFooter.tsx",
"path": "carplace/src/components/CarplaceFooter.tsx",
"status": "modified",
"bytes": 4632,
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
},
{
"path": "storefront/src/components/StorefrontHeader.tsx",
"path": "carplace/src/components/CarplaceHeader.tsx",
"status": "modified",
"bytes": 10438,
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
},
{
"path": "storefront/src/styles/phase16-tokens.css",
"path": "carplace/src/styles/phase16-tokens.css",
"status": "added",
"bytes": 4214,
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
},
{
"path": "storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
"path": "carplace/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
"status": "modified",
"bytes": 13979,
"sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b"
+1 -1
View File
@@ -7,7 +7,7 @@
],
"includedApplications": [
"homepage",
"storefront",
"carplace",
"dashboard",
"admin",
"api"
+1 -1
View File
@@ -13,7 +13,7 @@ The dashboard and admin applications now use the same visual language as the Pha
The previous navbar/footer refactor archive omitted the `homepage` and `api` applications. This package was rebuilt from the complete Phase 16 archive, then overlaid with the public navbar/footer refactor before the operational UI migration. The final repository contains:
- `homepage`
- `storefront`
- `carplace`
- `dashboard`
- `admin`
- `api`
+2 -2
View File
@@ -17,9 +17,9 @@
| Light/dark and Arabic RTL token mappings present | Passed |
| Reduced-motion and forced-colors rules present | Passed |
| Shared sign-in/create-account public layout retained | Passed |
| Shared storefront navbar/footer components retained | Passed |
| Shared carplace navbar/footer components retained | Passed |
| Retired `RentalDriveGo` brand in dashboard/admin source | 0 matches |
| Complete app directories retained | `homepage`, `storefront`, `dashboard`, `admin`, `api` |
| Complete app directories retained | `homepage`, `carplace`, `dashboard`, `admin`, `api` |
## Reproducible design gate
@@ -60,15 +60,15 @@ Changed files:
- `apps/api/src/app.ts`
- `apps/dashboard/src/middleware.ts`
- `apps/storefront/src/middleware.ts`
- `apps/carplace/src/middleware.ts`
- `apps/admin/src/middleware.ts`
- `apps/dashboard/src/middleware.test.ts`
- `apps/storefront/src/middleware.test.ts`
- `apps/carplace/src/middleware.test.ts`
What changed:
- API now rejects requests containing `x-middleware-subrequest` before route handling.
- Dashboard, storefront, and admin Next middleware now reject the same header at the app layer.
- Dashboard, carplace, and admin Next middleware now reject the same header at the app layer.
- Added/updated middleware tests for the rejection path.
Security effect:
@@ -2,7 +2,7 @@
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
// The storefront proxies /dashboard and /admin to their own Next dev servers.
// The carplace proxies /dashboard and /admin to their own Next dev servers.
// Allow those asset-prefix origins so proxied pages can load chunks and HMR.
const dashboardAssetSource = normalizeAssetPrefix(
process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined),
@@ -1,5 +1,5 @@
{
"name": "@rentaldrivego/storefront",
"name": "@rentaldrivego/carplace",
"version": "1.0.0",
"private": true,
"scripts": {

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 302 B

After

Width:  |  Height:  |  Size: 302 B

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 836 KiB

After

Width:  |  Height:  |  Size: 836 KiB

@@ -16,7 +16,7 @@ function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string
expect(page.props.forcedLanguage).toBe(forcedLanguage)
}
describe('storefront static app policy pages', () => {
describe('carplace static app policy pages', () => {
it('binds app privacy pages to explicit locales', () => {
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
@@ -1,6 +1,6 @@
import Link from 'next/link'
import { storefrontFetchOrDefault } from '@/lib/api'
import { getStorefrontLanguage } from '@/lib/i18n.server'
import { carplaceFetchOrDefault } from '@/lib/api'
import { getCarplaceLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types'
interface CompanyProfile {
@@ -21,14 +21,14 @@ interface CompanyProfile {
}
export default async function CompanyProfilePage({ params }: { params: { slug: string } }) {
const language = await getStorefrontLanguage()
const language = await getCarplaceLanguage()
const dict = {
en: {
unavailable: 'Storefront unavailable',
unavailable: 'Carplace unavailable',
unavailableTitle: 'Company details are temporarily unavailable.',
unavailableBody: 'The storefront API is running, but the database is not reachable in this local environment.',
unavailableBody: 'The carplace API is running, but the database is not reachable in this local environment.',
backToExplore: 'Back to explore',
partner: 'Storefront partner',
partner: 'Carplace partner',
vehicles: 'vehicles',
offers: 'active offers',
rating: 'Rating',
@@ -42,11 +42,11 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
unavailableNoDate: 'Temporarily unavailable for new reservations.',
},
fr: {
unavailable: 'Storefront indisponible',
unavailable: 'Carplace indisponible',
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
unavailableBody: "L'API storefront fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
unavailableBody: "L'API carplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
backToExplore: "Retour à l'exploration",
partner: 'Partenaire storefront',
partner: 'Partenaire carplace',
vehicles: 'véhicules',
offers: 'offres actives',
rating: 'Note',
@@ -78,7 +78,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
unavailableNoDate: 'غير متاحة مؤقتاً للحجوزات الجديدة.',
},
}[language]
const company = await storefrontFetchOrDefault<CompanyProfile | null>(`/storefront/${params.slug}`, null)
const company = await carplaceFetchOrDefault<CompanyProfile | null>(`/carplace/${params.slug}`, null)
if (!company) {
return (
@@ -1,6 +1,6 @@
import Link from 'next/link'
import { storefrontFetchOrDefault } from '@/lib/api'
import { getStorefrontLanguage } from '@/lib/i18n.server'
import { carplaceFetchOrDefault } from '@/lib/api'
import { getCarplaceLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types'
import BookingForm from '@/components/BookingForm'
@@ -33,12 +33,12 @@ interface VehicleDetail {
}
export default async function VehicleDetailPage({ params }: { params: { slug: string; id: string } }) {
const language = await getStorefrontLanguage()
const language = await getCarplaceLanguage()
const dict = {
en: {
unavailable: 'Storefront unavailable',
unavailable: 'Carplace unavailable',
unavailableTitle: 'Vehicle details are temporarily unavailable.',
unavailableBody: 'The storefront frontend is running, but the backing database is not reachable right now.',
unavailableBody: 'The carplace frontend is running, but the backing database is not reachable right now.',
backToFleet: 'Back to fleet',
backToExplore: 'Back to explore',
noPhotos: 'No photos available.',
@@ -59,9 +59,9 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
photos: 'Photos',
},
fr: {
unavailable: 'Storefront indisponible',
unavailable: 'Carplace indisponible',
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
unavailableBody: "Le frontend storefront fonctionne, mais la base de données n'est pas accessible pour le moment.",
unavailableBody: "Le frontend carplace fonctionne, mais la base de données n'est pas accessible pour le moment.",
backToFleet: 'Retour à la flotte',
backToExplore: 'Retour à lexploration',
noPhotos: 'Aucune photo disponible.',
@@ -106,7 +106,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
},
}[language]
const vehicle = await storefrontFetchOrDefault<VehicleDetail | null>(`/storefront/${params.slug}/vehicles/${params.id}`, null)
const vehicle = await carplaceFetchOrDefault<VehicleDetail | null>(`/carplace/${params.slug}/vehicles/${params.id}`, null)
if (!vehicle) {
return (
@@ -1,6 +1,6 @@
import Link from 'next/link'
import { storefrontFetchOrDefault } from '@/lib/api'
import { getStorefrontLanguage } from '@/lib/i18n.server'
import { carplaceFetchOrDefault } from '@/lib/api'
import { getCarplaceLanguage } from '@/lib/i18n.server'
import ExploreSearchForm from './ExploreSearchForm'
import ExploreVehicleGrid from './ExploreVehicleGrid'
@@ -68,7 +68,7 @@ const CATEGORY_VALUES = [
export default async function ExplorePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
const resolvedSearchParams = await searchParams
const language = await getStorefrontLanguage()
const language = await getCarplaceLanguage()
const dict = {
en: {
kicker: 'Discovery only',
@@ -89,10 +89,10 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Search',
currentDeals: 'Current deals',
featuredOffers: 'Featured storefront offers',
featuredOffers: 'Featured carplace offers',
company: 'Company',
validUntil: 'Valid until',
offersUnavailable: 'Storefront offers are unavailable right now.',
offersUnavailable: 'Carplace offers are unavailable right now.',
availableVehicles: 'Available vehicles',
listings: 'listings',
rentalCompany: 'Rental company',
@@ -108,8 +108,8 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
browseByCategory: 'Browse by category',
browseByCompany: 'Browse by company',
storefrontPartners: 'Storefront partners',
storefrontPartner: 'Storefront partner',
carplacePartners: 'Carplace partners',
carplacePartner: 'Carplace partner',
rating: 'Rating',
new: 'New',
publishedVehicles: 'published vehicles',
@@ -135,10 +135,10 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Rechercher',
currentDeals: 'Offres du moment',
featuredOffers: 'Offres storefront mises en avant',
featuredOffers: 'Offres carplace mises en avant',
company: 'Entreprise',
validUntil: "Valable jusqu'au",
offersUnavailable: 'Les offres storefront sont indisponibles pour le moment.',
offersUnavailable: 'Les offres carplace sont indisponibles pour le moment.',
availableVehicles: 'Véhicules disponibles',
listings: 'annonces',
rentalCompany: 'Société de location',
@@ -154,8 +154,8 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
browseByCategory: 'Parcourir par catégorie',
browseByCompany: 'Parcourir par entreprise',
storefrontPartners: 'Partenaires storefront',
storefrontPartner: 'Partenaire storefront',
carplacePartners: 'Partenaires carplace',
carplacePartner: 'Partenaire carplace',
rating: 'Note',
new: 'Nouveau',
publishedVehicles: 'véhicules publiés',
@@ -200,8 +200,8 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
browseByCategory: 'تصفح حسب الفئة',
browseByCompany: 'تصفح حسب الشركة',
storefrontPartners: 'شركاء السوق',
storefrontPartner: 'شريك في السوق',
carplacePartners: 'شركاء السوق',
carplacePartner: 'شريك في السوق',
rating: 'التقييم',
new: 'جديد',
publishedVehicles: 'سيارات منشورة',
@@ -241,9 +241,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
query.set('pageSize', '24')
const [offers, vehicles, companies] = await Promise.all([
storefrontFetchOrDefault<Offer[]>('/storefront/offers', []),
storefrontFetchOrDefault<Vehicle[]>(`/storefront/search?${query.toString()}`, []),
storefrontFetchOrDefault<CompanyCard[]>('/storefront/companies?pageSize=8', []),
carplaceFetchOrDefault<Offer[]>('/carplace/offers', []),
carplaceFetchOrDefault<Vehicle[]>(`/carplace/search?${query.toString()}`, []),
carplaceFetchOrDefault<CompanyCard[]>('/carplace/companies?pageSize=8', []),
])
const cities = Array.from(
new Set(
@@ -309,12 +309,12 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Pro
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCompany}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.storefrontPartners}</p>
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.carplacePartners}</p>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{companies.map((company) => (
<Link key={company.id} href={`/explore/${company.brand?.subdomain ?? ''}`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
<p className="truncate text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{company.brand?.publicCity ?? dict.storefrontPartner}</p>
<p className="truncate text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{company.brand?.publicCity ?? dict.carplacePartner}</p>
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{company.brand?.displayName ?? dict.rentalCompany}</h3>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</p>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{company._count.vehicles} {dict.publishedVehicles}</p>
@@ -13,7 +13,7 @@ import FooterContentPage from '@/components/FooterContentPage'
import FooterPage, { generateStaticParams } from './page'
import { footerPageSlugs } from '@/lib/footerContent'
describe('storefront footer route', () => {
describe('carplace footer route', () => {
it('generates one static route per registered footer slug', () => {
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
})
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation'
export default function StorefrontHomePage() {
export default function CarplaceHomePage() {
redirect('/explore')
}
@@ -1,11 +1,11 @@
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import StorefrontShell from '@/components/StorefrontShell'
import { getStorefrontLanguage } from '@/lib/i18n.server'
import CarplaceShell from '@/components/CarplaceShell'
import { getCarplaceLanguage } from '@/lib/i18n.server'
import './globals.css'
export const metadata: Metadata = {
title: 'RentalDriveGo Storefront',
title: 'RentalDriveGo Carplace',
description: 'Discover vehicles from trusted rental companies.',
icons: {
icon: '/rentaldrivego.png',
@@ -15,11 +15,11 @@ export const metadata: Metadata = {
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const language = await getStorefrontLanguage()
const language = await getCarplaceLanguage()
const cookieStore = await cookies()
const rawTheme =
cookieStore.get('rentaldrivego-theme')?.value ??
cookieStore.get('storefront-theme')?.value
cookieStore.get('carplace-theme')?.value
const theme = rawTheme === 'dark' ? 'dark' : 'light'
return (
@@ -29,12 +29,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('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){}})();",
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('carplace-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
}}
/>
</head>
<body suppressHydrationWarning>
<StorefrontShell initialLanguage={language} initialTheme={theme}>{children}</StorefrontShell>
<CarplaceShell initialLanguage={language} initialTheme={theme}>{children}</CarplaceShell>
</body>
</html>
)
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { useCarplacePreferences } from '@/components/CarplaceShell'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
@@ -29,7 +29,7 @@ type Status = 'loading' | 'ready' | 'error'
export default function RenterDashboardPage() {
const router = useRouter()
const { language } = useStorefrontPreferences()
const { language } = useCarplacePreferences()
const dict = {
en: {
loadProfile: 'Could not load profile.',
@@ -52,7 +52,7 @@ export default function RenterDashboardPage() {
savedCompanies: 'Saved companies',
noneSaved: 'None saved yet',
saved: 'saved',
saveCompaniesPrompt: 'Save companies you like while browsing the storefront.',
saveCompaniesPrompt: 'Save companies you like while browsing the carplace.',
startExploring: 'Start exploring',
rentalCompany: 'Rental company',
},
@@ -77,7 +77,7 @@ export default function RenterDashboardPage() {
savedCompanies: 'Entreprises sauvegardées',
noneSaved: 'Aucune pour le moment',
saved: 'sauvegardées',
saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la storefront.',
saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la carplace.',
startExploring: 'Commencer à explorer',
rentalCompany: 'Société de location',
},
@@ -9,11 +9,11 @@ import {
markRenterNotificationRead,
type RenterNotification,
} from '@/lib/renter'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { useCarplacePreferences } from '@/components/CarplaceShell'
export default function RenterNotificationsPage() {
const router = useRouter()
const { language } = useStorefrontPreferences()
const { language } = useCarplacePreferences()
const dict = {
en: {
load: 'Could not load notifications.',
@@ -12,7 +12,7 @@ import {
type RenterPreference,
type RenterProfile,
} from '@/lib/renter'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { useCarplacePreferences } from '@/components/CarplaceShell'
type Status = 'loading' | 'ready' | 'error'
const RENTER_EVENTS = ['BOOKING_CONFIRMED', 'BOOKING_REMINDER', 'RETURN_REMINDER', 'REFUND_ISSUED', 'NEW_OFFER']
@@ -20,7 +20,7 @@ const RENTER_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'PUSH', 'IN_APP']
export default function RenterProfilePage() {
const router = useRouter()
const { language } = useStorefrontPreferences()
const { language } = useCarplacePreferences()
const dict = {
en: {
profile: 'Profile',
@@ -4,11 +4,11 @@ import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { RenterAuthError, loadRenterProfile, type SavedCompany } from '@/lib/renter'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { useCarplacePreferences } from '@/components/CarplaceShell'
export default function RenterSavedCompaniesPage() {
const router = useRouter()
const { language } = useStorefrontPreferences()
const { language } = useCarplacePreferences()
const dict = {
en: {
load: 'Could not load saved companies.',
@@ -17,7 +17,7 @@ export default function RenterSavedCompaniesPage() {
back: 'Back to dashboard',
loading: 'Loading saved companies…',
empty: 'You have not saved any companies yet.',
browse: 'Browse the storefront',
browse: 'Browse the carplace',
rentalCompany: 'Rental company',
},
fr: {
@@ -27,7 +27,7 @@ export default function RenterSavedCompaniesPage() {
back: 'Retour au tableau de bord',
loading: 'Chargement des entreprises sauvegardées…',
empty: 'Vous navez encore enregistré aucune entreprise.',
browse: 'Parcourir la storefront',
browse: 'Parcourir la carplace',
rentalCompany: 'Société de location',
},
ar: {
@@ -2,8 +2,8 @@
import { useEffect, useRef, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { StorefrontApiError, storefrontFetch, storefrontPost } from '@/lib/api'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { CarplaceApiError, carplaceFetch, carplacePost } from '@/lib/api'
import { useCarplacePreferences } from '@/components/CarplaceShell'
type Props = {
vehicleId: string
@@ -314,7 +314,7 @@ function formatLocalizedDate(value: string, language: 'en' | 'fr' | 'ar') {
function getBookingSessionId() {
if (typeof window === 'undefined') return 'server'
const existing = window.sessionStorage.getItem('storefront-booking-session-id')
const existing = window.sessionStorage.getItem('carplace-booking-session-id')
if (existing) return existing
const created =
@@ -322,7 +322,7 @@ function getBookingSessionId() {
? window.crypto.randomUUID()
: `booking-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
window.sessionStorage.setItem('storefront-booking-session-id', created)
window.sessionStorage.setItem('carplace-booking-session-id', created)
return created
}
@@ -364,7 +364,7 @@ export default function BookingForm({
allowDifferentDropoff,
dropoffLocations,
}: Props) {
const { language } = useStorefrontPreferences()
const { language } = useCarplacePreferences()
const t = copy[language]
const pickupOptions = Array.isArray(pickupLocations) ? pickupLocations : []
const dropoffOptions = Array.isArray(dropoffLocations) ? dropoffLocations : []
@@ -400,7 +400,7 @@ export default function BookingForm({
}
async function trackEvent(eventName: FunnelEventName, metadata?: Record<string, string | number | boolean | null>) {
await storefrontPost('/storefront/events', {
await carplacePost('/carplace/events', {
eventName,
companySlug,
vehicleId,
@@ -413,8 +413,8 @@ export default function BookingForm({
async function checkAvailability(startDate: string, endDate: string, signal?: AbortSignal) {
const start = new Date(startDate)
const end = new Date(endDate)
const result = await storefrontFetch<AvailabilityResponse>(
`/storefront/${companySlug}/vehicles/${vehicleId}/availability?startDate=${encodeURIComponent(start.toISOString())}&endDate=${encodeURIComponent(end.toISOString())}`,
const result = await carplaceFetch<AvailabilityResponse>(
`/carplace/${companySlug}/vehicles/${vehicleId}/availability?startDate=${encodeURIComponent(start.toISOString())}&endDate=${encodeURIComponent(end.toISOString())}`,
signal ? { signal } : undefined,
)
@@ -471,7 +471,7 @@ export default function BookingForm({
void checkAvailability(fields.startDate, fields.endDate, controller.signal).catch((err) => {
if (controller.signal.aborted) return
if (err instanceof StorefrontApiError && err.code === 'invalid_dates') {
if (err instanceof CarplaceApiError && err.code === 'invalid_dates') {
setAvailability({ status: 'idle', nextAvailableAt: null })
return
}
@@ -520,7 +520,7 @@ export default function BookingForm({
return
}
const booking = await storefrontPost<BookingResponse>('/storefront/reservations', {
const booking = await carplacePost<BookingResponse>('/carplace/reservations', {
vehicleId,
companySlug,
firstName: fields.firstName.trim(),
@@ -546,7 +546,7 @@ export default function BookingForm({
elapsedMs: Date.now() - startedAtRef.current,
})
} catch (err: unknown) {
if (err instanceof StorefrontApiError && err.code === 'unavailable') {
if (err instanceof CarplaceApiError && err.code === 'unavailable') {
setAvailability({ status: 'unavailable', nextAvailableAt: err.nextAvailableAt ?? null })
setError(buildUnavailableMessage(err.nextAvailableAt ?? null))
} else {
@@ -554,7 +554,7 @@ export default function BookingForm({
}
void trackEvent('booking_request_failed', {
reason: err instanceof StorefrontApiError ? err.code ?? 'request_failed' : 'request_failed',
reason: err instanceof CarplaceApiError ? err.code ?? 'request_failed' : 'request_failed',
})
} finally {
setSubmitting(false)
@@ -15,7 +15,7 @@ type FooterItem = {
href?: string
}
export default function StorefrontFooter({
export default function CarplaceFooter({
primaryItems,
secondaryItems,
localeLabel,
@@ -4,9 +4,9 @@ import {
companyInitial,
localeMenuPositionClass,
ownerWorkspaceHref,
} from './StorefrontHeader'
} from './CarplaceHeader'
describe('StorefrontHeader helpers', () => {
describe('CarplaceHeader helpers', () => {
it('builds dashboard sign-in links with language and theme query parameters', () => {
expect(buildDashboardSignInHref('https://app.example.com/dashboard', 'fr', 'dark')).toBe(
'https://app.example.com/dashboard/sign-in?lang=fr&theme=dark'
@@ -9,7 +9,7 @@ export type Theme = 'light' | 'dark'
export type Language = 'en' | 'fr' | 'ar'
type Dictionary = {
storefront: string
carplace: string
explore: string
signIn: string
ownerSignIn: string
@@ -44,7 +44,7 @@ export function companyInitial(companyName: string): string {
return companyName.trim().charAt(0).toUpperCase()
}
export default function StorefrontHeader({
export default function CarplaceHeader({
dict,
theme,
setTheme,
@@ -124,7 +124,7 @@ export default function StorefrontHeader({
href="/"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.storefront}
{dict.carplace}
</Link>
<Link
href="/explore"
@@ -1,11 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './StorefrontShell'
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './CarplaceShell'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('StorefrontShell auth helpers', () => {
describe('CarplaceShell auth helpers', () => {
it('does not report an employee profile when the browser cache is empty', () => {
vi.stubGlobal('window', {
localStorage: {
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { getFooterContent, localeOptions } from './StorefrontShell'
import { getFooterContent, localeOptions } from './CarplaceShell'
describe('StorefrontShell footer content registry', () => {
it('exposes exactly the supported storefront locales in selector order', () => {
describe('CarplaceShell footer content registry', () => {
it('exposes exactly the supported carplace locales in selector order', () => {
expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr'])
expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true)
})
@@ -3,13 +3,13 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { appPrivacyHref, footerPageHref } from '@/lib/footerContent'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isStorefrontLanguage, type StorefrontLanguage } from '@/lib/i18n'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isCarplaceLanguage, type CarplaceLanguage } from '@/lib/i18n'
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
type Theme = 'light' | 'dark'
type Dictionary = {
storefront: string
carplace: string
explore: string
signIn: string
ownerSignIn: string
@@ -20,31 +20,31 @@ type Dictionary = {
preferences: string
}
const dictionaries: Record<StorefrontLanguage, Dictionary> = {
const dictionaries: Record<CarplaceLanguage, Dictionary> = {
en: {
storefront: 'Home',
explore: 'Storefront',
carplace: 'Home',
explore: 'Carplace',
signIn: 'Sign in',
ownerSignIn: 'Create Agency Space',
language: 'Language',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
preferences: 'Storefront preferences',
preferences: 'Carplace preferences',
},
fr: {
storefront: 'Accueil',
explore: 'Storefront',
carplace: 'Accueil',
explore: 'Carplace',
signIn: 'Connexion',
ownerSignIn: "Créer un espace d'agence",
language: 'Langue',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
preferences: 'Préférences storefront',
preferences: 'Préférences carplace',
},
ar: {
storefront: 'الرئيسية',
carplace: 'الرئيسية',
explore: 'السوق',
signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء مساحة الوكالة',
@@ -58,13 +58,13 @@ const dictionaries: Record<StorefrontLanguage, Dictionary> = {
const EMPLOYEE_PROFILE_KEY = 'employee_profile'
export const localeOptions: Array<{ value: StorefrontLanguage; label: string; flag: string }> = [
export const localeOptions: Array<{ value: CarplaceLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
]
export function getFooterContent(language: StorefrontLanguage): {
export function getFooterContent(language: CarplaceLanguage): {
primary: Array<{ label: string; href?: string }>
secondary: Array<{ label: string; href?: string }>
localeLabel: string
@@ -158,50 +158,50 @@ function detectBrowserLanguage(): string | null {
}
type PreferencesContextValue = {
language: StorefrontLanguage
language: CarplaceLanguage
theme: Theme
dict: Dictionary
companyName: string | null
setLanguage: (language: StorefrontLanguage) => void
setLanguage: (language: CarplaceLanguage) => void
setTheme: (theme: Theme) => void
}
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
export function useStorefrontPreferences() {
export function useCarplacePreferences() {
const context = useContext(PreferencesContext)
if (!context) throw new Error('useStorefrontPreferences must be used within StorefrontShell')
if (!context) throw new Error('useCarplacePreferences must be used within CarplaceShell')
return context
}
export default function StorefrontShell({
export default function CarplaceShell({
children,
initialLanguage = 'en',
initialTheme = 'light',
}: {
children: React.ReactNode
initialLanguage?: StorefrontLanguage
initialLanguage?: CarplaceLanguage
initialTheme?: Theme
}) {
const router = useRouter()
const pathname = usePathname()
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const [language, setLanguageState] = useState<StorefrontLanguage>(initialLanguage)
const [language, setLanguageState] = useState<CarplaceLanguage>(initialLanguage)
const [theme, setThemeState] = useState<Theme>(initialTheme)
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<StorefrontLanguage>(initialLanguage)
const previousLanguage = useRef<CarplaceLanguage>(initialLanguage)
const [companyName, setCompanyName] = useState<string | null>(null)
function applyLanguage(nextLanguage: StorefrontLanguage) {
function applyLanguage(nextLanguage: CarplaceLanguage) {
if (nextLanguage === language) return
if (typeof window !== 'undefined') {
document.documentElement.lang = nextLanguage
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
try {
sessionStorage.setItem('storefront-language', nextLanguage)
sessionStorage.setItem('carplace-language', nextLanguage)
} catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['storefront-language'])
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['carplace-language'])
}
setLanguageState(nextLanguage)
@@ -214,7 +214,7 @@ export default function StorefrontShell({
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
document.body.dataset.theme = nextTheme
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['storefront-theme'])
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['carplace-theme'])
}
setThemeState(nextTheme)
@@ -247,10 +247,10 @@ export default function StorefrontShell({
}
}
function readSessionLanguage(): StorefrontLanguage | null {
function readSessionLanguage(): CarplaceLanguage | null {
try {
const val = sessionStorage.getItem('storefront-language')
return isStorefrontLanguage(val) ? val : null
const val = sessionStorage.getItem('carplace-language')
return isCarplaceLanguage(val) ? val : null
} catch {
return null
}
@@ -262,7 +262,7 @@ export default function StorefrontShell({
if (sessionLang !== language) setLanguageState(sessionLang)
} else {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (isStorefrontLanguage(scopedLanguage) && scopedLanguage !== language) {
if (isCarplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguageState(scopedLanguage)
}
}
@@ -271,7 +271,7 @@ export default function StorefrontShell({
if ((scopedTheme === 'light' || scopedTheme === 'dark') && scopedTheme !== theme) {
setThemeState(scopedTheme)
} else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['storefront-theme'])
writeScopedPreference(SHARED_THEME_KEY, theme, ['carplace-theme'])
}
}
@@ -279,22 +279,22 @@ export default function StorefrontShell({
const sessionLang = readSessionLanguage()
if (sessionLang) {
setLanguageState(sessionLang)
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['storefront-language'])
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['carplace-language'])
} else {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['storefront-language'])
if (isStorefrontLanguage(storedLanguage)) {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['carplace-language'])
if (isCarplaceLanguage(storedLanguage)) {
setLanguageState(storedLanguage)
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['storefront-language'])
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['carplace-language'])
} else {
const detected = detectBrowserLanguage()
if (isStorefrontLanguage(detected)) {
if (isCarplaceLanguage(detected)) {
setLanguageState(detected)
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['storefront-language'])
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['carplace-language'])
}
}
}
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['storefront-theme']) as Theme | null
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['carplace-theme']) as Theme | null
if (storedTheme === 'light' || storedTheme === 'dark') {
setThemeState(storedTheme)
}
@@ -337,8 +337,8 @@ export default function StorefrontShell({
if (!hydrated) return
try { sessionStorage.setItem('storefront-language', language) } catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['storefront-language'])
try { sessionStorage.setItem('carplace-language', language) } catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['carplace-language'])
if (previousLanguage.current !== language && pathname !== '/sign-in') {
router.refresh()
@@ -351,7 +351,7 @@ export default function StorefrontShell({
document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light'
document.body.dataset.theme = theme
if (hydrated) {
writeScopedPreference(SHARED_THEME_KEY, theme, ['storefront-theme'])
writeScopedPreference(SHARED_THEME_KEY, theme, ['carplace-theme'])
}
}, [theme, hydrated])
@@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
vi.mock('@/components/StorefrontShell', () => ({
useStorefrontPreferences: () => ({ language: preferenceState.language, theme: 'light' }),
vi.mock('@/components/CarplaceShell', () => ({
useCarplacePreferences: () => ({ language: preferenceState.language, theme: 'light' }),
}))
import FooterContentPage from './FooterContentPage'
@@ -29,7 +29,7 @@ describe('FooterContentPage', () => {
preferenceState.language = 'en'
})
it('uses the current storefront language when no forced language is provided', () => {
it('uses the current carplace language when no forced language is provided', () => {
preferenceState.language = 'fr'
const page = FooterContentPage({ slug: 'contact-sales' })
const text = collectText(page).join(' ')
@@ -2,9 +2,9 @@
import React from 'react'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { useCarplacePreferences } from '@/components/CarplaceShell'
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
import { type StorefrontLanguage } from '@/lib/i18n'
import { type CarplaceLanguage } from '@/lib/i18n'
const pageMeta = {
en: {
@@ -53,8 +53,8 @@ function renderParagraphText(paragraph: string) {
})
}
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: StorefrontLanguage }) {
const { language } = useStorefrontPreferences()
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: CarplaceLanguage }) {
const { language } = useCarplacePreferences()
const activeLanguage = forcedLanguage ?? language
const content = getFooterPageContent(activeLanguage, slug)
const meta = pageMeta[activeLanguage]
@@ -13,7 +13,7 @@ import {
Car,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { useCarplacePreferences } from '@/components/CarplaceShell'
import { clearRenterSession, loadRenterProfile } from '@/lib/renter'
const NAV_ITEMS = [
@@ -63,7 +63,7 @@ function computeInitials(name: string): string {
export default function RenterShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const { language } = useStorefrontPreferences()
const { language } = useCarplacePreferences()
const dict = dicts[language]
const isRtl = language === 'ar'
const [open, setOpen] = useState(false)
@@ -29,7 +29,7 @@ describe('WorkspaceFrame helpers', () => {
})
it('keeps unauthenticated visitors on the embedded sign-in path', () => {
stubBrowser({ cookie: 'storefront-language=fr' })
stubBrowser({ cookie: 'carplace-language=fr' })
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
})
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { useStorefrontPreferences } from './StorefrontShell'
import { useCarplacePreferences } from './CarplaceShell'
export type FrameId = 'sign-in'
@@ -37,7 +37,7 @@ export function buildFrameUrl(appUrl: string, path: string) {
export const FRAME_HEIGHT = 'calc(100vh - 56px)'
export default function WorkspaceFrame({ target }: { target: FrameId }) {
const { language, theme } = useStorefrontPreferences()
const { language, theme } = useCarplacePreferences()
const [src, setSrc] = useState('')
const [framePath, setFramePath] = useState(() => getDefaultFramePath(target))
const config = frameConfig[target]
@@ -0,0 +1,4 @@
'use client'
// Public marketing footer used by the homepage and other carplace pages.
export { default } from '@/components/CarplaceFooter'
@@ -0,0 +1,4 @@
'use client'
// Public marketing navbar used by the homepage and other carplace pages.
export { default } from '@/components/CarplaceHeader'
@@ -2,7 +2,7 @@ import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
describe('storefront public page layout', () => {
describe('carplace public page layout', () => {
it('keeps the navbar and footer in reusable component modules', () => {
const layoutPath = fileURLToPath(new URL('./SitePageLayout.tsx', import.meta.url))
const source = fs.readFileSync(layoutPath, 'utf8')
@@ -7,12 +7,12 @@ import SiteNavbar from './SiteNavbar'
import {
getFooterContent,
localeOptions,
useStorefrontPreferences,
} from '@/components/StorefrontShell'
useCarplacePreferences,
} from '@/components/CarplaceShell'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
export default function SitePageLayout({ children }: { children: ReactNode }) {
const { language, theme, dict, companyName, setLanguage, setTheme } = useStorefrontPreferences()
const { language, theme, dict, companyName, setLanguage, setTheme } = useCarplacePreferences()
const dashboardUrl = resolveBrowserAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { StorefrontApiError, storefrontFetch, storefrontFetchOrDefault, storefrontPost } from './api'
import { CarplaceApiError, carplaceFetch, carplaceFetchOrDefault, carplacePost } from './api'
afterEach(() => {
delete process.env.NEXT_PUBLIC_API_URL
@@ -8,19 +8,19 @@ afterEach(() => {
Reflect.deleteProperty(globalThis, 'fetch')
})
describe('storefront API helpers', () => {
describe('carplace API helpers', () => {
it('unwraps successful GET responses from the data envelope', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [{ id: 'vehicle_1' }] }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
await expect(storefrontFetch('/storefront/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/storefront\/vehicles$/), {
await expect(carplaceFetch('/carplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/carplace\/vehicles$/), {
cache: 'no-store',
credentials: 'include',
})
})
it('throws rich storefront API errors', async () => {
it('throws rich carplace API errors', async () => {
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: vi.fn(async () => ({
@@ -30,8 +30,8 @@ describe('storefront API helpers', () => {
})),
})
await expect(storefrontFetch('/storefront/book')).rejects.toMatchObject({
name: 'StorefrontApiError',
await expect(carplaceFetch('/carplace/book')).rejects.toMatchObject({
name: 'CarplaceApiError',
message: 'Vehicle unavailable',
status: 409,
code: 'VEHICLE_UNAVAILABLE',
@@ -45,7 +45,7 @@ describe('storefront API helpers', () => {
value: vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ message: 'Down' }) })),
})
await expect(storefrontFetchOrDefault('/storefront/homepage', { hero: null })).resolves.toEqual({ hero: null })
await expect(carplaceFetchOrDefault('/carplace/homepage', { hero: null })).resolves.toEqual({ hero: null })
})
it('posts JSON payloads and unwraps successful responses', async () => {
@@ -53,7 +53,7 @@ describe('storefront API helpers', () => {
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { id: 'reservation_1' } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
await expect(storefrontPost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
await expect(carplacePost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({
method: 'POST',
credentials: 'include',
@@ -68,8 +68,8 @@ describe('storefront API helpers', () => {
value: vi.fn(async () => ({ ok: false, status: 502, json: async () => { throw new Error('bad json') } })),
})
await expect(storefrontFetch('/site/homepage')).rejects.toMatchObject({
name: 'StorefrontApiError',
await expect(carplaceFetch('/site/homepage')).rejects.toMatchObject({
name: 'CarplaceApiError',
message: 'Request failed',
status: 502,
})
@@ -3,40 +3,40 @@ const API_BASE =
?? process.env.NEXT_PUBLIC_API_URL
?? 'http://localhost:4000/api/v1'
export class StorefrontApiError extends Error {
export class CarplaceApiError extends Error {
status: number
code?: string
nextAvailableAt?: string | null
constructor(message: string, status: number, code?: string, nextAvailableAt?: string | null) {
super(message)
this.name = 'StorefrontApiError'
this.name = 'CarplaceApiError'
this.status = status
this.code = code
this.nextAvailableAt = nextAvailableAt
}
}
export async function storefrontFetch<T>(path: string, init?: RequestInit): Promise<T> {
export async function carplaceFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
cache: 'no-store',
credentials: 'include',
...init,
})
const json = await res.json().catch(() => null)
if (!res.ok) throw new StorefrontApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
if (!res.ok) throw new CarplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
return json.data as T
}
export async function storefrontFetchOrDefault<T>(path: string, fallback: T): Promise<T> {
export async function carplaceFetchOrDefault<T>(path: string, fallback: T): Promise<T> {
try {
return await storefrontFetch<T>(path)
return await carplaceFetch<T>(path)
} catch {
return fallback
}
}
export async function storefrontPost<T>(path: string, body: unknown): Promise<T> {
export async function carplacePost<T>(path: string, body: unknown): Promise<T> {
const base = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const res = await fetch(`${base}${path}`, {
method: 'POST',
@@ -45,6 +45,6 @@ export async function storefrontPost<T>(path: string, body: unknown): Promise<T>
body: JSON.stringify(body),
})
const json = await res.json().catch(() => null)
if (!res.ok) throw new StorefrontApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
if (!res.ok) throw new CarplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
return json.data as T
}
@@ -12,7 +12,7 @@ afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
})
describe('storefront app URL resolution', () => {
describe('carplace app URL resolution', () => {
it('leaves server-side browser fallback unchanged when window is unavailable', () => {
expect(resolveBrowserAppUrl('http://localhost:3000')).toBe('http://localhost:3000')
})
@@ -29,7 +29,7 @@ describe('storefront app URL resolution', () => {
it('keeps path prefixes while rewriting the browser origin', () => {
installWindow('rental.example.com', 'https:')
expect(resolveBrowserAppUrl('http://localhost:3000/storefront')).toBe('https://rental.example.com/storefront')
expect(resolveBrowserAppUrl('http://localhost:3000/carplace')).toBe('https://rental.example.com/carplace')
})
it('resolves server URLs from forwarded host/proto and handles invalid fallbacks safely', () => {
@@ -7,11 +7,11 @@ import {
getFooterPageContent,
isFooterPageSlug,
} from './footerContent'
import type { StorefrontLanguage } from './i18n'
import type { CarplaceLanguage } from './i18n'
const languages: StorefrontLanguage[] = ['en', 'fr', 'ar']
const languages: CarplaceLanguage[] = ['en', 'fr', 'ar']
describe('storefront footer content registry', () => {
describe('carplace footer content registry', () => {
it('keeps every declared slug routable and recognizable', () => {
expect(footerPageSlugs.length).toBeGreaterThan(5)
@@ -1,4 +1,4 @@
import type { StorefrontLanguage } from './i18n'
import type { CarplaceLanguage } from './i18n'
export const footerPageSlugs = [
'about-us',
@@ -47,7 +47,7 @@ export const footerPageHref: Record<FooterPageSlug, string> = {
'general-conditions': '/footer/general-conditions',
}
const footerContent: Record<StorefrontLanguage, Record<FooterPageSlug, FooterPageContent>> = {
const footerContent: Record<CarplaceLanguage, Record<FooterPageSlug, FooterPageContent>> = {
en: {
'about-us': {
title: 'About Us',
@@ -175,7 +175,7 @@ const footerContent: Record<StorefrontLanguage, Record<FooterPageSlug, FooterPag
{
heading: '5. Legacy Preference Cookies',
paragraphs: [
'Names: dashboard-language, storefront-language',
'Names: dashboard-language, carplace-language',
'These cookies exist for backward compatibility with earlier versions of the platform and store the same language preference as the primary cookie. They are read only as a fallback and will be phased out in a future update.',
],
},
@@ -419,7 +419,7 @@ const footerContent: Record<StorefrontLanguage, Record<FooterPageSlug, FooterPag
{
heading: '5. Cookies de préférence hérités',
paragraphs: [
'Noms : dashboard-language, storefront-language',
'Noms : dashboard-language, carplace-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.',
],
},
@@ -663,7 +663,7 @@ const footerContent: Record<StorefrontLanguage, Record<FooterPageSlug, FooterPag
{
heading: '5. ملفات تعريف الارتباط الموروثة',
paragraphs: [
'الأسماء: dashboard-language, storefront-language',
'الأسماء: dashboard-language, carplace-language',
'توجد هذه الملفات لضمان التوافق مع الإصدارات السابقة من المنصة، وتخزّن تفضيل اللغة ذاته الموجود في الملف الرئيسي. لا تُقرأ إلا كخيار احتياطي، وستُزال في تحديث مستقبلي.',
],
},
@@ -785,6 +785,6 @@ export function isFooterPageSlug(value: string): value is FooterPageSlug {
return footerPageSlugs.includes(value as FooterPageSlug)
}
export function getFooterPageContent(language: StorefrontLanguage, slug: FooterPageSlug): FooterPageContent {
export function getFooterPageContent(language: CarplaceLanguage, slug: FooterPageSlug): FooterPageContent {
return footerContent[language][slug]
}
@@ -11,31 +11,31 @@ vi.mock('next/headers', () => ({
}),
}))
import { getStorefrontLanguage } from './i18n.server'
import { getCarplaceLanguage } from './i18n.server'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE } from './i18n'
describe('getStorefrontLanguage', () => {
describe('getCarplaceLanguage', () => {
beforeEach(() => {
cookieValues.clear()
})
it('prefers the shared language cookie over the legacy storefront cookie', async () => {
it('prefers the shared language cookie over the legacy carplace cookie', async () => {
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'ar')
cookieValues.set(MARKETPLACE_LANGUAGE_COOKIE, 'fr')
await expect(getStorefrontLanguage()).resolves.toBe('ar')
await expect(getCarplaceLanguage()).resolves.toBe('ar')
})
it('falls back to the legacy storefront cookie during migration', async () => {
it('falls back to the legacy carplace cookie during migration', async () => {
cookieValues.set(MARKETPLACE_LANGUAGE_COOKIE, 'fr')
await expect(getStorefrontLanguage()).resolves.toBe('fr')
await expect(getCarplaceLanguage()).resolves.toBe('fr')
})
it('defaults to English when cookies are absent or invalid', async () => {
await expect(getStorefrontLanguage()).resolves.toBe('en')
await expect(getCarplaceLanguage()).resolves.toBe('en')
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'es')
await expect(getStorefrontLanguage()).resolves.toBe('en')
await expect(getCarplaceLanguage()).resolves.toBe('en')
})
})
@@ -1,10 +1,10 @@
import { cookies } from 'next/headers'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isStorefrontLanguage, type StorefrontLanguage } from './i18n'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isCarplaceLanguage, type CarplaceLanguage } from './i18n'
export async function getStorefrontLanguage(): Promise<StorefrontLanguage> {
export async function getCarplaceLanguage(): Promise<CarplaceLanguage> {
const cookieStore = await cookies()
const cookieValue =
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ??
cookieStore.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
return isStorefrontLanguage(cookieValue) ? cookieValue : 'en'
return isCarplaceLanguage(cookieValue) ? cookieValue : 'en'
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { isCarplaceLanguage } from './i18n'
describe('carplace language guard', () => {
it('accepts the supported carplace locales', () => {
expect(isCarplaceLanguage('en')).toBe(true)
expect(isCarplaceLanguage('fr')).toBe(true)
expect(isCarplaceLanguage('ar')).toBe(true)
})
it('rejects unsupported, missing, and case-mismatched locales', () => {
expect(isCarplaceLanguage('de')).toBe(false)
expect(isCarplaceLanguage('EN')).toBe(false)
expect(isCarplaceLanguage('')).toBe(false)
expect(isCarplaceLanguage(null)).toBe(false)
expect(isCarplaceLanguage(undefined)).toBe(false)
})
})
+8
View File
@@ -0,0 +1,8 @@
export type CarplaceLanguage = 'en' | 'fr' | 'ar'
export const MARKETPLACE_LANGUAGE_COOKIE = 'carplace-language'
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
export function isCarplaceLanguage(value: string | null | undefined): value is CarplaceLanguage {
return value === 'en' || value === 'fr' || value === 'ar'
}
@@ -43,7 +43,7 @@ afterEach(() => {
Reflect.deleteProperty(globalThis, 'atob')
})
describe('storefront scoped preferences', () => {
describe('carplace scoped preferences', () => {
it('returns unscoped keys when no usable token exists', () => {
installBrowser()
expect(getScopedPreferenceKey('rentaldrivego-language')).toBe('rentaldrivego-language')
@@ -52,9 +52,9 @@ describe('storefront scoped preferences', () => {
it('reads shared cookies before legacy local-storage fallbacks', () => {
const browser = installBrowser('rentaldrivego-language=fr')
browser.store.set('storefront-language', 'ar')
browser.store.set('carplace-language', 'ar')
expect(readScopedPreference('rentaldrivego-language', ['storefront-language'])).toBe('fr')
expect(readScopedPreference('rentaldrivego-language', ['carplace-language'])).toBe('fr')
})
it('writes shared and legacy values without auth-token scoping', () => {
@@ -63,7 +63,7 @@ beforeEach(() => {
nextServer.redirect.mockClear()
})
describe('storefront proxy language bootstrap', () => {
describe('carplace proxy language bootstrap', () => {
it('rejects proxy subrequest headers before language handling', async () => {
const { proxy } = await import('./proxy')
@@ -85,11 +85,11 @@ describe('storefront proxy language bootstrap', () => {
expect(response.cookies.set).not.toHaveBeenCalled()
})
it('migrates the legacy storefront language cookie into the shared cookie and request headers', async () => {
it('migrates the legacy carplace language cookie into the shared cookie and request headers', async () => {
const { proxy } = await import('./proxy')
const response = proxy(request({
cookies: { 'storefront-language': 'ar' },
cookies: { 'carplace-language': 'ar' },
headers: { cookie: 'session=abc' },
}) as never) as any
@@ -118,7 +118,7 @@ describe('storefront proxy language bootstrap', () => {
const { proxy } = await import('./proxy')
const response = proxy(request({
cookies: { 'rentaldrivego-language': 'es', 'storefront-language': 'it' },
cookies: { 'rentaldrivego-language': 'es', 'carplace-language': 'it' },
headers: { 'accept-language': 'fr-CA,ar;q=0.5' },
}) as never) as any
@@ -127,7 +127,7 @@ describe('storefront proxy language bootstrap', () => {
})
})
describe('storefront middleware dashboard path canonicalization', () => {
describe('carplace middleware dashboard path canonicalization', () => {
it('redirects duplicate dashboard prefixes to the clean dashboard URL', async () => {
const { proxy } = await import('./proxy')
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
const MARKETPLACE_LANGUAGE_COOKIE = 'storefront-language'
const MARKETPLACE_LANGUAGE_COOKIE = 'carplace-language'
const DASHBOARD_BASE_PATH = '/dashboard'
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:
`apps/storefront/src/components/BookingForm.tsx`
`apps/carplace/src/components/BookingForm.tsx`
Acceptance criteria:
@@ -50,7 +50,7 @@ Recommended behavior:
Backend candidate:
- Add or expose `GET /storefront/:slug/vehicles/:id/availability?startDate=&endDate=`.
- Add or expose `GET /carplace/: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.
### Phase 3: Booking Status Transparency
+12 -12
View File
@@ -1,10 +1,10 @@
# 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Storefront
# 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Carplace
## Platform Model
**Rental Companies (B2B)** — pay RentalDriveGoa subscription (AmanPay or PayPal), get:
- A fully private dashboard (zero data overlap with any other company)
- Fleet management with vehicle photo upload (photos auto-appear on global storefront)
- Fleet management with vehicle photo upload (photos auto-appear on global carplace)
- Promotional offers management
- A white-label site at `company.RentalDriveGo.com` where renters book and pay
@@ -29,21 +29,21 @@
---
## 🌐 Storefront is Discovery Only — No Booking on RentalDriveGo.com/explore
## 🌐 Carplace is Discovery Only — No Booking on RentalDriveGo.com/explore
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.
The global carplace 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=carplace`). The booking form is pre-filled. All payment happens on the company site using the company's own payment accounts.
---
## 📸 Vehicle Photos → Storefront Visibility
## 📸 Vehicle Photos → Carplace Visibility
```
Company uploads photos in /dashboard/fleet
→ Stored in Cloudinary
→ Vehicle.photos[] holds the URLs
→ First photo = cover image on storefront cards
→ All photos shown in storefront vehicle gallery
→ isPublished=true makes vehicle appear on storefront
→ First photo = cover image on carplace cards
→ All photos shown in carplace vehicle gallery
→ isPublished=true makes vehicle appear on carplace
```
---
@@ -52,7 +52,7 @@ Company uploads photos in /dashboard/fleet
```
Layer 1A: RentalDriveGo.com — B2B marketing site
Layer 1B: RentalDriveGo.com/explore — B2C storefront (discovery + redirect only)
Layer 1B: RentalDriveGo.com/explore — B2C carplace (discovery + redirect only)
Layer 2: app.RentalDriveGo.com — Company dashboard (private, subscription-gated)
Layer 3: {slug}.RentalDriveGo.com — Company branded site (booking + payment here)
Layer 4: api.RentalDriveGo.com — REST API
@@ -66,9 +66,9 @@ Layer 4: api.RentalDriveGo.com — REST API
rental-car-site/
├── README.md
├── docs/
│ ├── ARCHITECTURE.md ← Payment providers, storefront redirect model, photo flow
│ ├── ARCHITECTURE.md ← Payment providers, carplace redirect model, photo flow
│ ├── DESIGN_SYSTEM.md
│ ├── PAGES.md ← All pages. Storefront = discovery only. Booking = company site.
│ ├── PAGES.md ← All pages. Carplace = discovery only. Booking = company site.
│ ├── FEATURES.md
│ └── DEPLOYMENT.md
└── skills/
@@ -80,7 +80,7 @@ rental-car-site/
│ ├── api-routes.md ← All routes
│ ├── payment-service.md ← AmanPay + PayPal implementation ← KEY FILE
│ ├── subscription-service.md ← Manual renewal, plan prices in MAD/USD/EUR
│ ├── subdomain-service.md ← Storefront redirect + company site
│ ├── subdomain-service.md ← Carplace redirect + company site
│ └── notification-service.md ← All 5 channels
└── rental-car-i18n/
```
@@ -9,13 +9,13 @@
## 1. Purpose
This plan replaces the legacy RentalDriveGo storefront with Carplace, a new renter-first marketplace experience and retains the production dashboard authentication repair as a separate priority workstream.
This plan replaces the legacy RentalDriveGo carplace with Carplace, a new renter-first marketplace experience and retains the production dashboard authentication repair as a separate priority workstream.
### 1.1 Product naming
**Carplace** is the public product name for the renter marketplace. The word “storefront” is retained only when referring to the legacy implementation or existing technical paths during migration.
**Carplace** is the public product name for the renter marketplace. The word “carplace” is retained only when referring to the legacy implementation or existing technical paths during migration.
The canonical public route will become `/carplace`. Existing `/storefront` URLs must remain functional through permanent path-preserving redirects so bookmarks, indexed pages, and external company links do not break. The deployable application may remain at `apps/storefront` during the first implementation phase to avoid an unnecessary repository-wide rename while product behavior is still changing.
The canonical public route will become `/carplace`. Existing `/carplace` URLs must remain functional through permanent path-preserving redirects so bookmarks, indexed pages, and external company links do not break. The deployable application may remain at `apps/carplace` during the first implementation phase to avoid an unnecessary repository-wide rename while product behavior is still changing.
**Carplace is trilingual from its first release. English (`en`), French (`fr`), and Arabic (`ar`) are equal, required product languages. No page, workflow, validation message, metadata field, or release phase may be shipped in only one or two languages.**
@@ -28,14 +28,14 @@ The new Carplace marketplace must:
4. Preserve existing public URLs during migration.
5. Use real platform data and avoid unsupported claims such as instant confirmation, final-price guarantees, or renter accounts that the backend does not currently provide.
6. Allow controlled company branding without letting every company turn the page into an unrelated website.
7. Be deployable beside the legacy storefront and switched over safely.
7. Be deployable beside the legacy carplace and switched over safely.
8. Preserve full feature and content parity across English, French, and Arabic, including proper Arabic RTL behavior.
---
## 2. Findings From the Current Code
### 2.1 The legacy storefront is a collection of pages, not a coherent product
### 2.1 The legacy carplace is a collection of pages, not a coherent product
The current application has working foundations:
@@ -55,10 +55,10 @@ However, the user experience is assembled from independent cards and utility cla
### 2.2 Carplace and the homepage need one visual foundation
The legacy storefront currently renders:
The legacy carplace currently renders:
- `StorefrontHeader`
- `StorefrontFooter`
- `CarplaceHeader`
- `CarplaceFooter`
The homepage renders:
@@ -108,13 +108,13 @@ unless the backend and company settings later support those guarantees.
### 2.5 Renter account authentication is disabled
The API currently rejects renter sign-up and sign-in. The legacy storefront contains renter sign-in, registration, dashboard, saved-company, notification, and profile pages, but the authentication service intentionally disables login and registration.
The API currently rejects renter sign-up and sign-in. The legacy carplace contains renter sign-in, registration, dashboard, saved-company, notification, and profile pages, but the authentication service intentionally disables login and registration.
Carplace must therefore launch as a guest-first experience. Renter-account links must remain hidden until the backend capability is deliberately enabled and tested.
### 2.6 The legacy editable homepage content model is mismatched
The current admin-managed storefront homepage schema contains operator-oriented content such as:
The current admin-managed carplace homepage schema contains operator-oriented content such as:
- Start trial
- Create workspace
@@ -217,7 +217,7 @@ Secondary conversions are:
- Search-engine metadata
- Accessibility baseline
- Funnel analytics
- Safe cutover from the legacy storefront
- Safe cutover from the legacy carplace
### 4.2 Explicitly excluded from the first launch
@@ -238,9 +238,9 @@ These exclusions are deliberate. A strong marketplace is not one with the larges
## 5. Recommended Technical Strategy
### 5.1 Rebuild inside the legacy storefront application
### 5.1 Rebuild inside the legacy carplace application
Keep `apps/storefront` as the deployable application.
Keep `apps/carplace` as the deployable application.
Do not create another standalone Next.js application. A second Carplace app would duplicate:
@@ -257,7 +257,7 @@ The new experience should be implemented as a clean feature area inside the exis
Recommended structure:
```text
apps/storefront/src/
apps/carplace/src/
app/
(public)/
page.tsx
@@ -299,9 +299,9 @@ Use a server-side release setting such as:
CARPLACE_EXPERIENCE_VERSION=v2
```
During migration, the legacy `/storefront/*` routes should continue serving V1 until their matching Carplace routes are ready. Once a Carplace route passes its release gates, redirect the corresponding legacy path to `/carplace/*`.
During migration, the legacy `/carplace/*` routes should continue serving V1 until their matching Carplace routes are ready. Once a Carplace route passes its release gates, redirect the corresponding legacy path to `/carplace/*`.
Do not leave two independently indexed public URL systems. `/carplace/*` becomes canonical, while `/storefront/*` exists only as a compatibility redirect. Redirects must preserve the resolved language, path, query parameters, selected dates, locations, offer codes, and campaign attribution.
Do not leave two independently indexed public URL systems. `/carplace/*` becomes canonical, while `/carplace/*` exists only as a compatibility redirect. Redirects must preserve the resolved language, path, query parameters, selected dates, locations, offer codes, and campaign attribution.
### 5.3 Introduce Carplace routes without breaking legacy URLs
@@ -314,7 +314,7 @@ Keep these routes working:
/carplace/[company-slug]/vehicles/[vehicle-id]
```
Existing company and vehicle links must not break during cutover. Add permanent, path-preserving redirects from `/storefront/*` to `/carplace/*`, including query parameters used for dates, locations, offers, and campaign attribution. The redirect must also migrate the legacy `storefront-language` preference into the shared `rentaldrivego-language` cookie when needed.
Existing company and vehicle links must not break during cutover. Add permanent, path-preserving redirects from `/carplace/*` to `/carplace/*`, including query parameters used for dates, locations, offers, and campaign attribution. The redirect must also migrate the legacy `carplace-language` preference into the shared `rentaldrivego-language` cookie when needed.
### 5.4 Separate API response types from page components
@@ -507,7 +507,7 @@ Move renters immediately toward a useful search while establishing trust.
### Homepage content model
Create a dedicated `CarplaceHomepageConfig`, separate from the existing operator-focused `StorefrontHomepageConfig`.
Create a dedicated `CarplaceHomepageConfig`, separate from the existing operator-focused `CarplaceHomepageConfig`.
Suggested fields:
@@ -893,7 +893,7 @@ POST /api/v1/carplace/reservations
GET /api/v1/carplace/requests/:token
```
During migration, existing `/api/v1/storefront/*` endpoints should remain as compatibility aliases or internal adapters until all deployed clients use Carplace contract.
During migration, existing `/api/v1/carplace/*` endpoints should remain as compatibility aliases or internal adapters until all deployed clients use Carplace contract.
### 9.3 Marketplace-home aggregation
@@ -1027,7 +1027,7 @@ Resolve language on the server in this order:
1. Explicit valid language selected by the user.
2. Existing `rentaldrivego-language` cookie.
3. Migrated legacy `storefront-language` cookie.
3. Migrated legacy `carplace-language` cookie.
4. Supported browser `Accept-Language` value.
5. English fallback.
@@ -1038,7 +1038,7 @@ The language selector must:
- Preserve the current pathname and query string.
- Preserve search dates, locations, filters, sorting, offer codes, and attribution parameters.
- Preserve completed reservation-form fields when switching language during the request flow.
- Never redirect a Carplace user back to the legacy `/storefront` route.
- Never redirect a Carplace user back to the legacy `/carplace` route.
### 11.3 Translation architecture
@@ -1117,7 +1117,7 @@ Reusable vehicle specifications, transmission labels, category names, policy lab
The initial canonical route remains `/carplace`, using server-resolved language from the shared cookie so the migration does not simultaneously change product name, path structure, and localization architecture.
All `/storefront/*` redirects must preserve language preference and query state. Locale-prefixed Carplace URLs may be introduced later only as a deliberate SEO migration with complete redirects, canonical tags, `hreflang`, sitemap updates, and analytics normalization. Do not create three competing URL systems during the first cutover.
All `/carplace/*` redirects must preserve language preference and query state. Locale-prefixed Carplace URLs may be introduced later only as a deliberate SEO migration with complete redirects, canonical tags, `hreflang`, sitemap updates, and analytics normalization. Do not create three competing URL systems during the first cutover.
---
@@ -103,7 +103,7 @@ packages/i18n/
└── index.ts
```
Dashboard, Admin, and any future storefront or mobile application must consume the same translation package. Independent translation copies are prohibited because they create terminology drift.
Dashboard, Admin, and any future carplace or mobile application must consume the same translation package. Independent translation copies are prohibited because they create terminology drift.
Example menu definition:
@@ -38,7 +38,7 @@ The target design must:
The system will seed seven settings menu items:
1. Company Profile
2. Branding and Storefront
2. Branding and Carplace
3. Payment Methods
4. Rental Policies
5. Insurance Policies
@@ -74,7 +74,7 @@ Protected section routes:
```text
/dashboard/settings?section=company
/dashboard/settings?section=storefront
/dashboard/settings?section=carplace
/dashboard/settings?section=payments
/dashboard/settings?section=rental-policies
/dashboard/settings?section=insurance
@@ -149,10 +149,10 @@ Legend:
| Company profile | Edit | Edit | Edit |
| Public contact information | Edit | Edit | Edit |
| Default language and currency | Edit | Edit | Edit |
| Storefront listing toggle | Edit | Edit | Edit |
| Carplace listing toggle | Edit | Edit | Edit |
| Basic logo and public identity | Edit | Edit | Edit |
| Custom brand colors | Locked | Edit | Edit |
| Hero image and advanced storefront branding | Locked | Edit | Edit |
| Hero image and advanced carplace branding | Locked | Edit | Edit |
| Renter payment providers | Locked | Edit | Edit |
| Basic fuel and damage policies | Edit | Edit | Edit |
| Automated additional-driver fees | Locked | Edit | Edit |
@@ -178,7 +178,7 @@ Recommended keys:
settings.company_profile
settings.public_contact
settings.locale_currency
settings.storefront_listing
settings.carplace_listing
settings.branding_basic
settings.branding_custom
settings.branding_hero
@@ -232,26 +232,26 @@ Rules:
- It remains available during subscription recovery.
- Use the shared Moroccan city selector where applicable.
- Validate email, phone, website, locale, and currency.
- Explain whether the selected language affects the storefront, contracts, or both.
- Explain whether the selected language affects the carplace, contracts, or both.
---
## 7.2 Branding and Storefront
## 7.2 Branding and Carplace
### STARTER
Available:
- Storefront listing
- Carplace listing
- Company logo
- Public identity preview
- Basic storefront information
- Basic carplace information
Locked:
- Custom primary and accent colors
- Hero image
- Advanced storefront appearance controls
- Advanced carplace appearance controls
### GROWTH and PRO
@@ -260,7 +260,7 @@ Available:
- All STARTER branding capabilities
- Custom brand colors
- Hero image
- Full storefront branding controls
- Full carplace branding controls
### UX behavior
@@ -675,7 +675,7 @@ Enforcement must cover:
- Reservation price calculation
- Insurance selection
- Additional-driver fee calculation
- Storefront branding response
- Carplace branding response
- Online payment processing
- Scheduled report jobs
@@ -60,15 +60,15 @@ Changed files:
- `apps/api/src/app.ts`
- `apps/dashboard/src/middleware.ts`
- `apps/storefront/src/middleware.ts`
- `apps/carplace/src/middleware.ts`
- `apps/admin/src/middleware.ts`
- `apps/dashboard/src/middleware.test.ts`
- `apps/storefront/src/middleware.test.ts`
- `apps/carplace/src/middleware.test.ts`
What changed:
- API now rejects requests containing `x-middleware-subrequest` before route handling.
- Dashboard, storefront, and admin Next middleware now reject the same header at the app layer.
- Dashboard, carplace, and admin Next middleware now reject the same header at the app layer.
- Added/updated middleware tests for the rejection path.
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
### From the storefront homepage (port 3000)
### From the carplace homepage (port 3000)
- Click **"Create Agency Space"** in the header navigation.
- Click **"Get Started"** on any pricing plan card (`/pricing` page).
@@ -26,7 +26,7 @@ Both actions navigate to:
http://localhost:3000/dashboard/sign-up
```
The storefront proxies this request to the dashboard app (port 3001), which renders the multi-step form.
The carplace proxies this request to the dashboard app (port 3001), which renders the multi-step form.
### URL parameters (optional)
@@ -111,7 +111,7 @@ Select one of three plans:
| Plan | Description |
|---|---|
| **STARTER** | Core fleet, offers, and bookings |
| **GROWTH** | Featured storefront placement and more room to scale |
| **GROWTH** | Featured carplace placement and more room to scale |
| **PRO** | Full white-label and premium controls |
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:
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 storefront listing preference.
2. **Step 2 — Payments**: Configure AmanPay merchant ID / PayPal email and carplace listing preference.
3. **Step 3 — Completion**: Redirect to the dashboard home.
### Subscription
@@ -228,7 +228,7 @@ New accounts start on a **30-day free trial** with the selected plan. The subscr
| File | Purpose |
|---|---|
| `apps/storefront/src/app/renter/sign-up/page.tsx` | Storefront redirect page to dashboard sign-up |
| `apps/carplace/src/app/renter/sign-up/page.tsx` | Carplace 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/components/ui/BilingualInput.tsx` | Bilingual (FR/AR) input component |
| `apps/dashboard/src/components/layout/PublicShell.tsx` | Public layout wrapper for the sign-up page |
@@ -241,10 +241,10 @@ New accounts start on a **30-day free trial** with the selected plan. The subscr
| File | Location |
|---|---|
| `apps/storefront/src/components/StorefrontHeader.tsx` | "Create Agency Space" button |
| `apps/storefront/src/components/WorkspaceTabs.tsx` | "Owner sign in" link |
| `apps/storefront/src/app/(public)/pricing/PricingClient.tsx` | "Get Started" pricing CTA |
| `apps/storefront/src/app/(public)/HomeContent.tsx` | Homepage CTA |
| `apps/carplace/src/components/CarplaceHeader.tsx` | "Create Agency Space" button |
| `apps/carplace/src/components/WorkspaceTabs.tsx` | "Owner sign in" link |
| `apps/carplace/src/app/(public)/pricing/PricingClient.tsx` | "Get Started" pricing CTA |
| `apps/carplace/src/app/(public)/HomeContent.tsx` | Homepage CTA |
### Backend
+1 -1
View File
@@ -4,7 +4,7 @@ docker compose -f docker-compose.dev.yml up -d redis
# app services
docker compose -f docker-compose.dev.yml --profile api up --build
docker compose -f docker-compose.dev.yml --profile storefront up --build
docker compose -f docker-compose.dev.yml --profile carplace up --build
docker compose -f docker-compose.dev.yml --profile dashboard 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.1 Storefront — Public Pages
### 3.1 Carplace — Public Pages
#### Home Page (`/`)
Current issues to fix:
@@ -70,7 +70,7 @@ Stats Strip
How It Works
├── Step 1: Create your workspace
├── Step 2: Add your fleet
├── Step 3: Go live on storefront
├── Step 3: Go live on carplace
└── Step 4: Manage everything from dashboard
Services / Features Grid
@@ -154,8 +154,8 @@ Redesign:
## 4. Component Redesign Checklist
### Shared Components
- [ ] `StorefrontHeader` — add mobile hamburger menu, improve nav spacing
- [ ] `StorefrontFooter` — add logo, social links, newsletter input
- [ ] `CarplaceHeader` — add mobile hamburger menu, improve nav spacing
- [ ] `CarplaceFooter` — add logo, social links, newsletter input
- [ ] `BookingForm` — improve step layout, add progress indicator
- [ ] `WorkspaceTabs` — improve active tab styling
@@ -187,7 +187,7 @@ Redesign:
| Numbered step process | Home "How it works" |
| Stats/counters strip | Home page |
| Testimonials carousel | Home page |
| Mega footer with columns | Storefront footer |
| Mega footer with columns | Carplace footer |
| Sidebar with grouped nav | Dashboard |
| KPI metric cards | Dashboard home |
@@ -202,7 +202,7 @@ Redesign:
- Wide: `xl: 1280px`, `2xl: 1536px`
### Mobile Improvements Needed
- [ ] Hamburger menu for storefront header
- [ ] Hamburger menu for carplace header
- [ ] Collapsible sidebar for dashboard
- [ ] Stack pricing cards vertically
- [ ] Full-width booking form
@@ -228,7 +228,7 @@ Redesign:
## 8. Implementation Phases
### Phase 1 — Storefront Public Pages (Priority)
### Phase 1 — Carplace Public Pages (Priority)
1. Home page redesign
- Stats strip
- How it works section
@@ -254,15 +254,15 @@ Redesign:
## 9. Files to Modify
### Storefront
### Carplace
| File | Change |
|---|---|
| `src/app/(public)/HomeContent.tsx` | Add stats, how-it-works, testimonials sections |
| `src/app/(public)/features/page.tsx` | Alternating layout |
| `src/app/(public)/pricing/PricingPageContent.tsx` | Toggle + improved cards |
| `src/app/(public)/explore/ExploreVehicleGrid.tsx` | Improved vehicle cards |
| `src/components/StorefrontHeader.tsx` | Mobile menu |
| `src/components/StorefrontFooter.tsx` | Mega footer |
| `src/components/CarplaceHeader.tsx` | Mobile menu |
| `src/components/CarplaceFooter.tsx` | Mega footer |
| `src/app/globals.css` | No changes needed |
### 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 |
| `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** |
| `PUBLISHABLE` | Legal identity block (legal name, legal form, RC, ICE, IF, license) | Publishing a listing live on the storefront |
| `PUBLISHABLE` | Legal identity block (legal name, legal form, RC, ICE, IF, license) | Publishing a listing live on the carplace |
| `PAYABLE` | Payment provider config (AmanPay/PayPal) + responsible person | Accepting bookings/payments |
| `SUBSCRIBED` | Plan + billing period selection | Past the 30-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.
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.
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 carplace. 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 |
|---|---|
| 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. |
| 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. |
| Carplace 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. |
| 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. |
@@ -53,7 +53,7 @@ whenever new endpoints are added.
Integration tests exist, but the heaviest workflows still benefit from deeper coverage:
- subscription billing transitions
- storefront reservation intake
- carplace reservation intake
- public booking and payment initialization
- reservation inspection and close flows
- 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=/`) |
| **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 storefront, 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 carplace, 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.
**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 |
|---|---|
| **Names** | `dashboard-language`, `storefront-language` |
| **Names** | `dashboard-language`, `carplace-language` |
| **Category** | Functional / preference |
| **Duration** | 1 year |
| **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:
- `apps/storefront`
- `apps/carplace`
- `apps/dashboard`
- `apps/admin`
- `apps/api`
@@ -14,7 +14,7 @@ Source of truth:
The current workspace contains four active apps:
- `apps/storefront`
- `apps/carplace`
- `apps/dashboard`
- `apps/admin`
- `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
- Subscription management
### Storefront and public platform
### Carplace and public platform
- Public marketing homepage
- Public pricing page
- Public features page
- Public storefront/explore flow
- Public carplace/explore flow
- Explore company profile pages under `/explore/[slug]`
- Public review submission page via review token
- 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
- Pricing configuration and promotions
- Notification review
- Storefront/site config management
- Carplace/site config management
## 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
- Google renter auth
- automatic custom-domain provisioning
- Zapier/webhook storefront integrations beyond the current API/webhook handlers
- Zapier/webhook carplace integrations beyond the current API/webhook handlers
## Notes
- 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 storefront 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 carplace 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:
- `apps/storefront/src/app`
- `apps/carplace/src/app`
- `apps/dashboard/src/app`
- `apps/admin/src/app`
## Storefront App
## Carplace App
App:
- `apps/storefront`
- `apps/carplace`
### Public routes
@@ -37,11 +37,11 @@ App:
- `/` is the public marketing home.
- `/pricing` consumes platform pricing from `/site/platform/pricing`.
- `/explore` is the public storefront search/discovery page.
- `/explore/[slug]` is the company storefront profile page.
- `/explore` is the public carplace search/discovery page.
- `/explore/[slug]` is the company carplace profile page.
- `/review` is the review submission page driven by a reservation review token.
### Not present in the current storefront app
### Not present in the current carplace app
- `/about`
- `/contact`
@@ -51,7 +51,7 @@ App:
### Renter routes present in code but not active product entrypoints
These routes exist in the storefront app:
These routes exist in the carplace app:
- `/renter/dashboard`
- `/renter/notifications`
@@ -114,7 +114,7 @@ Base path:
- `/dashboard` shows KPIs and booking-source analytics.
- `/dashboard/fleet*` manages vehicles, maintenance, and calendar blocks.
- `/dashboard/reservations*` manages the booking lifecycle and inspection workflows.
- `/dashboard/online-reservations` handles public/storefront booking intake.
- `/dashboard/online-reservations` handles public/carplace booking intake.
- `/dashboard/customers` is the company CRM view.
- `/dashboard/offers` manages promotions.
- `/dashboard/team` manages employees.
@@ -164,7 +164,7 @@ App:
- `/dashboard/billing` manages platform billing operations.
- `/dashboard/pricing` edits platform pricing, features, and promotions.
- `/dashboard/notifications` inspects platform notifications.
- `/dashboard/site-config` edits storefront/site configuration content.
- `/dashboard/site-config` edits carplace/site configuration content.
## Deliberately Not Listed
@@ -8,7 +8,7 @@
## 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, storefront, 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, carplace, public-site) found no confirmed vulnerabilities above the reporting threshold.
| # | Severity | Category | Status |
|---|----------|----------|--------|
@@ -127,7 +127,7 @@ An authenticated employee from Company A could fetch the full maintenance histor
### Exploit Scenario
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.
An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/maintenance`. Vehicle UUIDs can leak through carplace 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
@@ -156,7 +156,7 @@ An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/
## Frontend Scan Results
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.
A full scan of all four frontend applications (dashboard, admin, carplace, public-site) was performed. No vulnerabilities above the reporting threshold (confidence ≥ 8/10) were confirmed.
### 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.
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.
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 carplace origin, and add `Content-Security-Policy: frame-ancestors 'self' <carplace-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
`requireSubscription` blocks company routes when the company status is `PENDING` or `SUSPENDED`. Public site and storefront routes are intentionally not behind this guard.
`requireSubscription` blocks company routes when the company status is `PENDING` or `SUSPENDED`. Public site and carplace routes are intentionally not behind this guard.
### Role-Based Access Control
@@ -90,7 +90,7 @@ Admin roles are hierarchical:
`requireRenterAuth` validates a Bearer token with `type === "renter"` and attaches `req.renterId`.
`optionalRenterAuth` is used on storefront routes so public reads still work without a token.
`optionalRenterAuth` is used on carplace routes so public reads still work without a token.
### 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/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/storefront` | Public, optional renter JWT | Storefront discovery and reservation intake | Designed for discovery and lead capture across companies |
| `/api/v1/carplace` | Public, optional renter JWT | Carplace 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/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 |
@@ -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.
### Storefront and public site
### Carplace and public site
The public surface is split in two modules on purpose.
`storefront` is the cross-company discovery layer:
`carplace` is the cross-company discovery layer:
- featured/public offers
- storefront cities
- carplace cities
- listed companies
- vehicle search
- storefront reservation intake
- carplace reservation intake
- review submission by token
- 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
- pricing config, plan features, and promotions
- subscription overrides and extensions
- storefront homepage configuration
- carplace homepage configuration
This is the only route group allowed to work across tenants.

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