chore: wire Carplace into dev and production stacks
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
NODE_ENV=production
|
||||
|
||||
# Canonical public application URLs.
|
||||
NEXT_PUBLIC_WEBSITE_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_CARPLACE_URL=/carplace
|
||||
NEXT_PUBLIC_ADMIN_URL=/admin
|
||||
NEXT_PUBLIC_DASHBOARD_URL=/dashboard
|
||||
|
||||
# Browser authentication requests stay on the dashboard public origin.
|
||||
NEXT_PUBLIC_API_URL=/dashboard/api/v1
|
||||
NEXT_PUBLIC_DASHBOARD_DIRECT_API=false
|
||||
|
||||
# Internal API used by Next.js rewrites and server-side requests.
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
API_URL=http://api:4000
|
||||
|
||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||
@@ -29,7 +29,7 @@ The app covers:
|
||||
|
||||
It is distinct from:
|
||||
|
||||
- the storefront app, which is public and cross-company
|
||||
- the Carplace app, which is public and cross-company
|
||||
- the company site app, which is public and company-branded
|
||||
- the admin app, which is platform-internal rather than tenant-scoped
|
||||
|
||||
@@ -65,7 +65,7 @@ This lets the dashboard run:
|
||||
|
||||
- directly in dev on port `3001`
|
||||
- behind a reverse proxy
|
||||
- embedded under a shared storefront/admin hostname while still loading its own chunks correctly
|
||||
- embedded under a shared Carplace/admin hostname while still loading its own chunks correctly
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
@@ -92,7 +92,7 @@ apps/dashboard/
|
||||
api.ts cookie-aware API fetch wrapper
|
||||
preferences.ts language/theme persistence scoped per employee
|
||||
dashboardPaths.ts normalizes basePath-aware routes
|
||||
urls.ts resolves storefront/admin app links
|
||||
urls.ts resolves Carplace/admin app links
|
||||
src/middleware.ts auth gate and redirect logic
|
||||
```
|
||||
|
||||
@@ -142,7 +142,7 @@ Important behavior:
|
||||
|
||||
- public dashboard routes are limited to sign-in and forgot-password flows
|
||||
- all other `/dashboard/*` routes require the `employee_session` cookie
|
||||
- unauthenticated users are redirected either to the storefront root or to `/dashboard/sign-in`, depending on host context
|
||||
- unauthenticated users are redirected either to the Carplace root or to `/dashboard/sign-in`, depending on host context
|
||||
- duplicate `/dashboard/dashboard` paths are normalized back to `/dashboard`
|
||||
|
||||
This means route protection is enforced before React renders the protected pages.
|
||||
@@ -284,7 +284,7 @@ The onboarding flow then completes the initial tenant setup in three steps:
|
||||
|
||||
1. basic company info via `PATCH /companies/me`
|
||||
2. brand/public profile via `PATCH /companies/me/brand`
|
||||
3. payment and storefront settings via `PATCH /companies/me/brand`
|
||||
3. payment and Carplace settings via `PATCH /companies/me/brand`
|
||||
|
||||
This makes onboarding a dashboard-owned continuation of the API signup flow.
|
||||
|
||||
@@ -391,7 +391,7 @@ Route:
|
||||
|
||||
Backed by:
|
||||
|
||||
- `GET /reservations?source=MARKETPLACE&status=DRAFT`
|
||||
- `GET /reservations?source=CARPLACE&status=DRAFT`
|
||||
- `POST /reservations/:id/confirm`
|
||||
- `POST /reservations/:id/cancel`
|
||||
|
||||
@@ -604,7 +604,7 @@ This is the broad configuration surface for the tenant.
|
||||
|
||||
It includes:
|
||||
|
||||
- public brand and storefront profile
|
||||
- public brand and Carplace profile
|
||||
- payment account identifiers
|
||||
- custom domain setup
|
||||
- rental policies
|
||||
@@ -645,7 +645,7 @@ Examples in the code:
|
||||
|
||||
It also coordinates with sibling apps:
|
||||
|
||||
- `storefrontUrl` links staff back to the public storefront domain
|
||||
- `CarplaceUrl` links staff back to the public Carplace domain
|
||||
- `adminUrl` supports cookie-based handoff into the admin interface
|
||||
- host-aware URL rewriting allows the same app to function under external domains, internal Docker hostnames, and proxied environments
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ const DASHBOARD_BASE_PATH = '/dashboard'
|
||||
// basePath is required so the client-side router knows it's mounted at /dashboard.
|
||||
// Without it, React cannot hydrate because the URL (/dashboard/sign-up) doesn't
|
||||
// match the route (/sign-up). The Turbopack double-basePath prefetch bug is
|
||||
// handled by the storefront middleware (307 redirect /dashboard/dashboard → /dashboard).
|
||||
// handled by the Carplace middleware (307 redirect /dashboard/dashboard → /dashboard).
|
||||
//
|
||||
// In local development the dashboard is often viewed through the storefront
|
||||
// In local development the dashboard is often viewed through the Carplace
|
||||
// proxy on port 3000 while the dashboard dev server runs on port 3001. Next
|
||||
// rewrites do not reliably proxy HMR WebSocket upgrades, so emit absolute
|
||||
// dashboard asset/HMR URLs directly to the dashboard dev server.
|
||||
|
||||
@@ -113,7 +113,7 @@ function offerToForm(o: Offer): FormState {
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Offers',
|
||||
subtitle: 'Promotions shown on your site and optionally on the storefront.',
|
||||
subtitle: 'Promotions shown on your site and optionally on Carplace.',
|
||||
createOffer: 'Create Offer',
|
||||
editOffer: 'Edit Offer',
|
||||
deleteOffer: 'Delete Offer',
|
||||
@@ -126,7 +126,7 @@ const copy = {
|
||||
ends: 'ends',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
storefront: 'Storefront',
|
||||
carplace: 'Carplace',
|
||||
featured: 'Featured',
|
||||
empty: 'No offers yet. Create your first offer to attract customers.',
|
||||
labelTitle: 'Title *',
|
||||
@@ -146,7 +146,7 @@ const copy = {
|
||||
labelValidFrom: 'Valid From *',
|
||||
labelValidUntil: 'Valid Until *',
|
||||
labelIsActive: 'Active',
|
||||
labelIsPublic: 'Show on storefront',
|
||||
labelIsPublic: 'Show on Carplace',
|
||||
labelIsFeatured: 'Featured',
|
||||
typePERCENTAGE: 'Percentage (%)',
|
||||
typeFIXED_AMOUNT: 'Fixed Amount (MAD)',
|
||||
@@ -174,7 +174,7 @@ const copy = {
|
||||
},
|
||||
fr: {
|
||||
title: 'Offres',
|
||||
subtitle: 'Promotions affichées sur votre site et éventuellement sur la storefront.',
|
||||
subtitle: 'Promotions affichées sur votre site et éventuellement sur Carplace.',
|
||||
createOffer: 'Créer une offre',
|
||||
editOffer: "Modifier l'offre",
|
||||
deleteOffer: "Supprimer l'offre",
|
||||
@@ -187,7 +187,7 @@ const copy = {
|
||||
ends: 'expire le',
|
||||
active: 'Actif',
|
||||
inactive: 'Inactif',
|
||||
storefront: 'Storefront',
|
||||
carplace: 'Carplace',
|
||||
featured: 'Mise en avant',
|
||||
empty: 'Aucune offre. Créez votre première offre pour attirer des clients.',
|
||||
labelTitle: 'Titre *',
|
||||
@@ -207,7 +207,7 @@ const copy = {
|
||||
labelValidFrom: 'Valable du *',
|
||||
labelValidUntil: "Valable jusqu'au *",
|
||||
labelIsActive: 'Actif',
|
||||
labelIsPublic: 'Afficher sur la storefront',
|
||||
labelIsPublic: 'Afficher sur Carplace',
|
||||
labelIsFeatured: 'Mise en avant',
|
||||
typePERCENTAGE: 'Pourcentage (%)',
|
||||
typeFIXED_AMOUNT: 'Montant fixe (MAD)',
|
||||
@@ -248,7 +248,7 @@ const copy = {
|
||||
ends: 'تنتهي في',
|
||||
active: 'نشط',
|
||||
inactive: 'غير نشط',
|
||||
storefront: 'السوق',
|
||||
carplace: 'Carplace',
|
||||
featured: 'مميز',
|
||||
empty: 'لا توجد عروض بعد. أنشئ أول عرض لجذب العملاء.',
|
||||
labelTitle: 'العنوان *',
|
||||
@@ -514,7 +514,7 @@ export default function OffersPage() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{offer.isPublic && <span className="badge-blue">{t.storefront}</span>}
|
||||
{offer.isPublic && <span className="badge-blue">{t.carplace}</span>}
|
||||
{offer.isFeatured && <span className="badge-purple">{t.featured}</span>}
|
||||
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
|
||||
{typeof offer.redemptionCount === 'number' && (
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function OnlineReservationsPage() {
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Online Reservations',
|
||||
subtitle: 'Requests submitted by customers through the storefront. Confirm or decline each one.',
|
||||
subtitle: 'Requests submitted by customers through Carplace. Confirm or decline each one.',
|
||||
refresh: 'Refresh',
|
||||
pendingApproval: 'Pending approval',
|
||||
loading: 'Loading…',
|
||||
@@ -122,7 +122,7 @@ export default function OnlineReservationsPage() {
|
||||
},
|
||||
fr: {
|
||||
title: 'Réservations en ligne',
|
||||
subtitle: 'Demandes envoyées par les clients via la storefront. Confirmez ou refusez chaque demande.',
|
||||
subtitle: 'Demandes envoyées par les clients via Carplace. Confirmez ou refusez chaque demande.',
|
||||
refresh: 'Actualiser',
|
||||
pendingApproval: 'En attente de validation',
|
||||
loading: 'Chargement…',
|
||||
@@ -157,7 +157,7 @@ export default function OnlineReservationsPage() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await apiFetch<OnlineReservation[]>('/reservations?source=MARKETPLACE&status=DRAFT&pageSize=100')
|
||||
const result = await apiFetch<OnlineReservation[]>('/reservations?source=CARPLACE&status=DRAFT&pageSize=100')
|
||||
setRows(
|
||||
(result ?? []).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
)
|
||||
|
||||
@@ -595,7 +595,7 @@ export default function ReservationDetailPage() {
|
||||
|
||||
{actionError && <div className="card p-4 text-sm text-red-600">{actionError}</div>}
|
||||
|
||||
{reservation.source === 'MARKETPLACE' ? (
|
||||
{reservation.source === 'CARPLACE' ? (
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.bookingProgress}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -17,12 +17,12 @@ import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { getMoroccanCityOptions } from '@/lib/moroccanCities'
|
||||
|
||||
type SectionKey = 'company' | 'storefront' | 'payments' | 'rental-policies' | 'insurance' | 'pricing' | 'accounting'
|
||||
type SectionKey = 'company' | 'carplace' | 'payments' | 'rental-policies' | 'insurance' | 'pricing' | 'accounting'
|
||||
type FeatureKey =
|
||||
| 'settings.company_profile'
|
||||
| 'settings.public_contact'
|
||||
| 'settings.locale_currency'
|
||||
| 'settings.storefront_listing'
|
||||
| 'settings.carplace_listing'
|
||||
| 'settings.branding_basic'
|
||||
| 'settings.branding_custom'
|
||||
| 'settings.branding_hero'
|
||||
@@ -82,7 +82,7 @@ interface BrandSettings {
|
||||
amanpaySecretKey?: string | null
|
||||
defaultLocale?: string
|
||||
defaultCurrency?: string
|
||||
isListedOnMarketplace: boolean
|
||||
isListedOnCarplace: boolean
|
||||
}
|
||||
|
||||
interface ContractSettings {
|
||||
@@ -148,12 +148,12 @@ function SettingsPageContent() {
|
||||
loading: 'Loading settings...', save: 'Saving...', saved: 'Saved.', saveSection: 'Save section', currentPlan: 'Current plan',
|
||||
subscriptionStatus: 'Subscription status', manageSubscription: 'Manage subscription', requiredPlan: 'Requires',
|
||||
lockedTitle: 'This settings area is not included in your current plan.', lockedBody: 'Your saved configuration is preserved and becomes editable again after upgrade.',
|
||||
readOnly: 'This section is read-only while your subscription access is restricted.', companyHint: 'Default language affects storefront text and generated contracts when available.',
|
||||
publicProfile: 'Public profile', storefrontBasics: 'Storefront basics', premiumBranding: 'Available on GROWTH',
|
||||
readOnly: 'This section is read-only while your subscription access is restricted.', companyHint: 'Default language affects Carplace text and generated contracts when available.',
|
||||
publicProfile: 'Public profile', carplaceBasics: 'Carplace basics', premiumBranding: 'Available on GROWTH',
|
||||
paymentsBody: 'Configure renter payment providers. Secret values are never shown after saving.',
|
||||
policies: 'Fuel and damage policies', additionalDriver: 'Additional-driver automation', insuranceNew: 'New insurance policy',
|
||||
pricingNew: 'New pricing rule', accountingSchedule: 'Scheduled delivery is available on PRO.', noItems: 'No records configured yet.',
|
||||
marketplace: 'Listed on storefront', logo: 'Logo', hero: 'Hero image', upload: 'Upload', active: 'Active', inactive: 'Inactive',
|
||||
carplace: 'Listed on Carplace', logo: 'Logo', hero: 'Hero image', upload: 'Upload', active: 'Active', inactive: 'Inactive',
|
||||
labels: {
|
||||
displayName: 'Display name', tagline: 'Tagline', publicEmail: 'Public email', publicPhone: 'Public phone', whatsapp: 'WhatsApp number',
|
||||
city: 'City', country: 'Country', websiteUrl: 'Website URL', defaultLocale: 'Default language', defaultCurrency: 'Default currency',
|
||||
@@ -169,11 +169,11 @@ function SettingsPageContent() {
|
||||
subscriptionStatus: 'Statut abonnement', manageSubscription: 'Gérer l’abonnement', requiredPlan: 'Requiert',
|
||||
lockedTitle: 'Cette section n’est pas incluse dans votre plan actuel.', lockedBody: 'La configuration enregistrée est conservée et redevient modifiable après mise à niveau.',
|
||||
readOnly: 'Cette section est en lecture seule pendant la restriction d’accès.', companyHint: 'La langue par défaut affecte la vitrine et les contrats générés si disponibles.',
|
||||
publicProfile: 'Profil public', storefrontBasics: 'Bases de la vitrine', premiumBranding: 'Disponible avec GROWTH',
|
||||
publicProfile: 'Profil public', carplaceBasics: 'Paramètres Carplace', premiumBranding: 'Disponible avec GROWTH',
|
||||
paymentsBody: 'Configurez les prestataires de paiement. Les secrets ne sont jamais affichés après enregistrement.',
|
||||
policies: 'Carburant et dommages', additionalDriver: 'Automatisation conducteur additionnel', insuranceNew: 'Nouvelle police',
|
||||
pricingNew: 'Nouvelle règle', accountingSchedule: 'L’envoi planifié est disponible avec PRO.', noItems: 'Aucun enregistrement.',
|
||||
marketplace: 'Publié sur la vitrine', logo: 'Logo', hero: 'Image principale', upload: 'Téléverser', active: 'Actif', inactive: 'Inactif',
|
||||
carplace: 'Publié sur Carplace', logo: 'Logo', hero: 'Image principale', upload: 'Téléverser', active: 'Actif', inactive: 'Inactif',
|
||||
labels: {
|
||||
displayName: 'Nom affiché', tagline: 'Slogan', publicEmail: 'E-mail public', publicPhone: 'Téléphone public', whatsapp: 'Numéro WhatsApp',
|
||||
city: 'Ville', country: 'Pays', websiteUrl: 'Site web', defaultLocale: 'Langue par défaut', defaultCurrency: 'Devise par défaut',
|
||||
@@ -189,11 +189,11 @@ function SettingsPageContent() {
|
||||
subscriptionStatus: 'حالة الاشتراك', manageSubscription: 'إدارة الاشتراك', requiredPlan: 'يتطلب',
|
||||
lockedTitle: 'هذا القسم غير مشمول في خطتك الحالية.', lockedBody: 'يتم الاحتفاظ بالإعدادات المحفوظة وتعود قابلة للتعديل بعد الترقية.',
|
||||
readOnly: 'هذا القسم للقراءة فقط أثناء تقييد الوصول.', companyHint: 'تؤثر اللغة الافتراضية على الواجهة والعقود عند توفرها.',
|
||||
publicProfile: 'الملف العام', storefrontBasics: 'أساسيات الواجهة', premiumBranding: 'متاح في GROWTH',
|
||||
publicProfile: 'الملف العام', carplaceBasics: 'أساسيات الواجهة', premiumBranding: 'متاح في GROWTH',
|
||||
paymentsBody: 'إعداد مزودي دفع المستأجرين. لا يتم عرض المفاتيح السرية بعد الحفظ.',
|
||||
policies: 'سياسات الوقود والأضرار', additionalDriver: 'أتمتة السائق الإضافي', insuranceNew: 'سياسة تأمين جديدة',
|
||||
pricingNew: 'قاعدة تسعير جديدة', accountingSchedule: 'الإرسال المجدول متاح في PRO.', noItems: 'لا توجد سجلات بعد.',
|
||||
marketplace: 'مدرج في الواجهة', logo: 'الشعار', hero: 'صورة الواجهة', upload: 'رفع', active: 'نشط', inactive: 'غير نشط',
|
||||
carplace: 'مدرج على Carplace', logo: 'الشعار', hero: 'صورة الواجهة', upload: 'رفع', active: 'نشط', inactive: 'غير نشط',
|
||||
labels: {
|
||||
displayName: 'اسم العرض', tagline: 'الشعار', publicEmail: 'البريد العام', publicPhone: 'الهاتف العام', whatsapp: 'رقم واتساب',
|
||||
city: 'المدينة', country: 'الدولة', websiteUrl: 'الموقع', defaultLocale: 'اللغة الافتراضية', defaultCurrency: 'العملة الافتراضية',
|
||||
@@ -253,7 +253,7 @@ function SettingsPageContent() {
|
||||
useEffect(() => {
|
||||
async function loadSection() {
|
||||
try {
|
||||
if ((activeSection === 'company' || activeSection === 'storefront' || activeSection === 'payments') && !brand) {
|
||||
if ((activeSection === 'company' || activeSection === 'carplace' || activeSection === 'payments') && !brand) {
|
||||
setBrand(await apiFetch<BrandSettings | null>('/companies/me/brand'))
|
||||
}
|
||||
if (activeSection === 'rental-policies' && !contractSettings) {
|
||||
@@ -295,7 +295,7 @@ function SettingsPageContent() {
|
||||
publicCity: brand.publicCity || undefined, publicCountry: brand.publicCountry || undefined,
|
||||
websiteUrl: brand.websiteUrl || undefined, defaultLocale: brand.defaultLocale || undefined,
|
||||
whatsappNumber: brand.whatsappNumber || undefined, defaultCurrency: brand.defaultCurrency || undefined,
|
||||
isListedOnMarketplace: brand.isListedOnMarketplace,
|
||||
isListedOnCarplace: brand.isListedOnCarplace,
|
||||
amanpayMerchantId: canEdit('settings.renter_payments') ? brand.amanpayMerchantId || undefined : undefined,
|
||||
amanpaySecretKey: canEdit('settings.renter_payments') ? brand.amanpaySecretKey || undefined : undefined,
|
||||
paypalEmail: canEdit('settings.renter_payments') ? brand.paypalEmail || undefined : undefined,
|
||||
@@ -471,11 +471,11 @@ function SettingsPageContent() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{activeSection === 'storefront' && brand && (
|
||||
{activeSection === 'carplace' && brand && (
|
||||
<SectionCard onSave={saveBrand} saving={saving} copy={copy}>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={brand.isListedOnMarketplace} disabled={!canEdit('settings.storefront_listing')} onChange={(e) => setBrand({ ...brand, isListedOnMarketplace: e.target.checked })} />
|
||||
{copy.marketplace}
|
||||
<input type="checkbox" checked={brand.isListedOnCarplace} disabled={!canEdit('settings.carplace_listing')} onChange={(e) => setBrand({ ...brand, isListedOnCarplace: e.target.checked })} />
|
||||
{copy.carplace}
|
||||
</label>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<AssetField label={copy.logo} value={brand.logoUrl} disabled={!canEdit('settings.branding_basic')} uploading={uploadingAsset === 'logo'} copy={copy} onChange={(file) => uploadBrandAsset('logo', file)} />
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function SubscriptionPage() {
|
||||
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', CANCELED: 'Canceled', UNPAID: 'Unpaid', EXPIRED: 'Expired', SUSPENDED: 'Suspended' } as Record<string, string>,
|
||||
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
|
||||
planFeatures: {
|
||||
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Storefront listing'],
|
||||
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Carplace listing'],
|
||||
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
|
||||
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
|
||||
} as Record<Plan, string[]>,
|
||||
@@ -177,7 +177,7 @@ export default function SubscriptionPage() {
|
||||
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', CANCELED: 'Annulé', UNPAID: 'Impayé', EXPIRED: 'Expiré', SUSPENDED: 'Suspendu' } as Record<string, string>,
|
||||
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
|
||||
planFeatures: {
|
||||
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence storefront'],
|
||||
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence sur Carplace'],
|
||||
GROWTH: ['Jusqu’à 50 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'],
|
||||
PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
|
||||
} as Record<Plan, string[]>,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
import { useDashboardI18n } from "@/components/I18nProvider";
|
||||
import PublicShell from "@/components/layout/PublicShell";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { storefrontUrl } from "@/lib/urls";
|
||||
import { carplaceUrl } from "@/lib/urls";
|
||||
|
||||
const DASHBOARD_LOGO_SRC = "/dashboard/rentaldrivego.png";
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function ForgotPasswordPageClient({
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<a href={storefrontUrl} target="_top">
|
||||
<a href={carplaceUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { storefrontUrl } from '@/lib/urls'
|
||||
import { carplaceUrl } from '@/lib/urls'
|
||||
|
||||
type Step = 1 | 2 | 3
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function OnboardingPage() {
|
||||
amanpayMerchantId: '',
|
||||
amanpaySecretKey: '',
|
||||
paypalEmail: '',
|
||||
isListedOnMarketplace: true,
|
||||
isListedOnCarplace: true,
|
||||
})
|
||||
|
||||
async function handleStep1() {
|
||||
@@ -81,7 +81,7 @@ export default function OnboardingPage() {
|
||||
amanpayMerchantId: payments.amanpayMerchantId || undefined,
|
||||
amanpaySecretKey: payments.amanpaySecretKey || undefined,
|
||||
paypalEmail: payments.paypalEmail || undefined,
|
||||
isListedOnMarketplace: payments.isListedOnMarketplace,
|
||||
isListedOnCarplace: payments.isListedOnCarplace,
|
||||
}),
|
||||
})
|
||||
router.push('/')
|
||||
@@ -97,7 +97,7 @@ export default function OnboardingPage() {
|
||||
<main className="flex items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<a href={storefrontUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">RentalDriveGo</a>
|
||||
<a href={carplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">RentalDriveGo</a>
|
||||
<h1 className="mt-2 text-3xl font-bold text-slate-900">Set up your account</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Step {step} of 3</p>
|
||||
</div>
|
||||
@@ -268,10 +268,10 @@ export default function OnboardingPage() {
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||
checked={payments.isListedOnMarketplace}
|
||||
onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })}
|
||||
checked={payments.isListedOnCarplace}
|
||||
onChange={(e) => setPayments({ ...payments, isListedOnCarplace: e.target.checked })}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">List my company on the RentalDriveGo storefront</span>
|
||||
<span className="text-sm text-slate-700">List my company on the RentalDriveGo Carplace</span>
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDashboardI18n } from "@/components/I18nProvider";
|
||||
import PublicShell from "@/components/layout/PublicShell";
|
||||
import { adminUrl, storefrontUrl } from "@/lib/urls";
|
||||
import { adminUrl, websiteUrl } from "@/lib/urls";
|
||||
import { API_BASE, EMPLOYEE_PROFILE_KEY } from "@/lib/api";
|
||||
import { toPublicDashboardPath } from "@/lib/dashboardPaths";
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function SignInPageClient({
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<a href={storefrontUrl} target="_top">
|
||||
<a href={websiteUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import PublicShell from "@/components/layout/PublicShell";
|
||||
import { useDashboardI18n } from "@/components/I18nProvider";
|
||||
import { storefrontUrl } from "@/lib/urls";
|
||||
import { websiteUrl } from "@/lib/urls";
|
||||
|
||||
const DASHBOARD_LOGO_SRC = "/dashboard/rentaldrivego.png";
|
||||
|
||||
@@ -281,7 +281,7 @@ export default function SignUpForm({
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<a href={storefrontUrl} target="_top">
|
||||
<a href={websiteUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
|
||||
@@ -613,7 +613,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
},
|
||||
reservations: {
|
||||
heading: 'Booking',
|
||||
subtitle: 'All booking sources, including dashboard, public site, and storefront.',
|
||||
subtitle: 'All booking sources, including dashboard, public site, and Carplace.',
|
||||
colCustomer: 'Customer',
|
||||
colVehicle: 'Vehicle',
|
||||
colDates: 'Dates',
|
||||
@@ -971,7 +971,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
},
|
||||
reservations: {
|
||||
heading: 'Réservations',
|
||||
subtitle: 'Toutes les sources de réservation : tableau de bord, site public et storefront.',
|
||||
subtitle: 'Toutes les sources de réservation : tableau de bord, site public et Carplace.',
|
||||
colCustomer: 'Client',
|
||||
colVehicle: 'Véhicule',
|
||||
colDates: 'Dates',
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type DashboardLanguage,
|
||||
useDashboardI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import { storefrontUrl } from '@/lib/urls'
|
||||
import { carplaceUrl } from '@/lib/urls'
|
||||
|
||||
type FooterItem = {
|
||||
label: string
|
||||
@@ -15,9 +15,9 @@ type FooterItem = {
|
||||
}
|
||||
|
||||
const appPrivacyHref = {
|
||||
en: `${storefrontUrl}/app-privacy-en`,
|
||||
fr: `${storefrontUrl}/app-privacy-fr`,
|
||||
ar: `${storefrontUrl}/app-privacy-ar`,
|
||||
en: `${carplaceUrl}/app-privacy-en`,
|
||||
fr: `${carplaceUrl}/app-privacy-fr`,
|
||||
ar: `${carplaceUrl}/app-privacy-ar`,
|
||||
} as const
|
||||
|
||||
const localeOptions: Array<{ value: DashboardLanguage; label: string; flag: string }> = [
|
||||
@@ -36,17 +36,17 @@ function getFooterContent(language: DashboardLanguage): {
|
||||
case 'fr':
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'À propos de nous', href: `${storefrontUrl}/footer/about-us` },
|
||||
{ label: 'Conditions d’utilisation', href: `${storefrontUrl}/footer/terms-of-service` },
|
||||
{ label: 'Sécurité', href: `${storefrontUrl}/footer/security` },
|
||||
{ label: 'Conformité', href: `${storefrontUrl}/footer/compliance` },
|
||||
{ label: 'À propos de nous', href: `${carplaceUrl}/footer/about-us` },
|
||||
{ label: 'Conditions d’utilisation', href: `${carplaceUrl}/footer/terms-of-service` },
|
||||
{ label: 'Sécurité', href: `${carplaceUrl}/footer/security` },
|
||||
{ label: 'Conformité', href: `${carplaceUrl}/footer/compliance` },
|
||||
{ label: 'Politique de confidentialité', href: appPrivacyHref.fr },
|
||||
{ label: 'Politique relative aux cookies', href: `${storefrontUrl}/footer/cookie-policy` },
|
||||
{ label: 'Politique relative aux cookies', href: `${carplaceUrl}/footer/cookie-policy` },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'Newsletter', href: `${storefrontUrl}/footer/newsletter` },
|
||||
{ label: 'Contacter les ventes', href: `${storefrontUrl}/footer/contact-sales` },
|
||||
{ label: 'Conditions générales', href: `${storefrontUrl}/footer/general-conditions` },
|
||||
{ label: 'Newsletter', href: `${carplaceUrl}/footer/newsletter` },
|
||||
{ label: 'Contacter les ventes', href: `${carplaceUrl}/footer/contact-sales` },
|
||||
{ label: 'Conditions générales', href: `${carplaceUrl}/footer/general-conditions` },
|
||||
],
|
||||
localeLabel: 'Europe (French)',
|
||||
rightsLabel: 'Tous droits réservés.',
|
||||
@@ -54,17 +54,17 @@ function getFooterContent(language: DashboardLanguage): {
|
||||
case 'ar':
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'من نحن', href: `${storefrontUrl}/footer/about-us` },
|
||||
{ label: 'شروط الاستخدام', href: `${storefrontUrl}/footer/terms-of-service` },
|
||||
{ label: 'الأمان', href: `${storefrontUrl}/footer/security` },
|
||||
{ label: 'الامتثال', href: `${storefrontUrl}/footer/compliance` },
|
||||
{ label: 'من نحن', href: `${carplaceUrl}/footer/about-us` },
|
||||
{ label: 'شروط الاستخدام', href: `${carplaceUrl}/footer/terms-of-service` },
|
||||
{ label: 'الأمان', href: `${carplaceUrl}/footer/security` },
|
||||
{ label: 'الامتثال', href: `${carplaceUrl}/footer/compliance` },
|
||||
{ label: 'سياسة الخصوصية', href: appPrivacyHref.ar },
|
||||
{ label: 'سياسة ملفات تعريف الارتباط', href: `${storefrontUrl}/footer/cookie-policy` },
|
||||
{ label: 'سياسة ملفات تعريف الارتباط', href: `${carplaceUrl}/footer/cookie-policy` },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'النشرة الإخبارية', href: `${storefrontUrl}/footer/newsletter` },
|
||||
{ label: 'تواصل مع المبيعات', href: `${storefrontUrl}/footer/contact-sales` },
|
||||
{ label: 'الشروط العامة', href: `${storefrontUrl}/footer/general-conditions` },
|
||||
{ label: 'النشرة الإخبارية', href: `${carplaceUrl}/footer/newsletter` },
|
||||
{ label: 'تواصل مع المبيعات', href: `${carplaceUrl}/footer/contact-sales` },
|
||||
{ label: 'الشروط العامة', href: `${carplaceUrl}/footer/general-conditions` },
|
||||
],
|
||||
localeLabel: 'North Africa (Arabic)',
|
||||
rightsLabel: 'جميع الحقوق محفوظة.',
|
||||
@@ -73,17 +73,17 @@ function getFooterContent(language: DashboardLanguage): {
|
||||
default:
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'About Us', href: `${storefrontUrl}/footer/about-us` },
|
||||
{ label: 'Terms of Service', href: `${storefrontUrl}/footer/terms-of-service` },
|
||||
{ label: 'Security', href: `${storefrontUrl}/footer/security` },
|
||||
{ label: 'Compliance', href: `${storefrontUrl}/footer/compliance` },
|
||||
{ label: 'About Us', href: `${carplaceUrl}/footer/about-us` },
|
||||
{ label: 'Terms of Service', href: `${carplaceUrl}/footer/terms-of-service` },
|
||||
{ label: 'Security', href: `${carplaceUrl}/footer/security` },
|
||||
{ label: 'Compliance', href: `${carplaceUrl}/footer/compliance` },
|
||||
{ label: 'Privacy Policy', href: appPrivacyHref.en },
|
||||
{ label: 'Cookie Policy', href: `${storefrontUrl}/footer/cookie-policy` },
|
||||
{ label: 'Cookie Policy', href: `${carplaceUrl}/footer/cookie-policy` },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'Newsletter', href: `${storefrontUrl}/footer/newsletter` },
|
||||
{ label: 'Contact Sales', href: `${storefrontUrl}/footer/contact-sales` },
|
||||
{ label: 'General Conditions', href: `${storefrontUrl}/footer/general-conditions` },
|
||||
{ label: 'Newsletter', href: `${carplaceUrl}/footer/newsletter` },
|
||||
{ label: 'Contact Sales', href: `${carplaceUrl}/footer/contact-sales` },
|
||||
{ label: 'General Conditions', href: `${carplaceUrl}/footer/general-conditions` },
|
||||
],
|
||||
localeLabel: 'Global (English)',
|
||||
rightsLabel: 'All rights reserved.',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ChevronDown } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDashboardI18n } from "@/components/I18nProvider";
|
||||
import { storefrontUrl } from "@/lib/urls";
|
||||
import { websiteUrl } from "@/lib/urls";
|
||||
|
||||
const BRAND_LOGO_SRC = "/dashboard/rentaldrivego.png";
|
||||
|
||||
@@ -84,16 +84,16 @@ export default function PublicHeader({
|
||||
setThemeMenuOpen((open) => !open);
|
||||
}
|
||||
|
||||
const signInHref = `${storefrontUrl}/${language}#signin`;
|
||||
const signInHref = `${websiteUrl}/${language}#signin`;
|
||||
|
||||
const signUpHref = `${storefrontUrl}/${language}#demo`;
|
||||
const signUpHref = `${websiteUrl}/${language}#demo`;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.32)]">
|
||||
<div className="mx-auto flex min-h-14 max-w-7xl flex-col gap-1.5 px-4 py-1.5 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:px-8 lg:py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<a
|
||||
href={`${storefrontUrl}/${language}`}
|
||||
href={`${websiteUrl}/${language}`}
|
||||
className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 no-underline sm:text-sm sm:tracking-[0.2em] dark:text-stone-100"
|
||||
aria-label="RentalDriveGo home"
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { storefrontUrl } from '@/lib/urls'
|
||||
import { carplaceUrl } from '@/lib/urls'
|
||||
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
|
||||
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
||||
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
|
||||
@@ -395,7 +395,7 @@ export default function Sidebar() {
|
||||
void fetch('/dashboard/api/v1/auth/employee/logout', { method: 'POST', credentials: 'include' })
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||
notifyParent({ type: 'rentaldrivego:employee-logout' })
|
||||
window.location.href = storefrontUrl
|
||||
window.location.href = carplaceUrl
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -432,7 +432,7 @@ export default function Sidebar() {
|
||||
].join(' ')}
|
||||
>
|
||||
<a
|
||||
href={storefrontUrl}
|
||||
href={carplaceUrl}
|
||||
target="_top"
|
||||
className="flex items-center gap-3 border-b border-blue-200/70 px-5 py-5 transition-colors dark:border-blue-400/10"
|
||||
>
|
||||
|
||||
@@ -6,7 +6,9 @@ function normalizeApiBase(value: string): string {
|
||||
export const API_BASE = normalizeApiBase(
|
||||
typeof window === 'undefined'
|
||||
? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1')
|
||||
: (process.env.NEXT_PUBLIC_API_URL || '/dashboard/api/v1'),
|
||||
: (process.env.NEXT_PUBLIC_DASHBOARD_DIRECT_API === 'true' && process.env.NEXT_PUBLIC_API_URL
|
||||
? process.env.NEXT_PUBLIC_API_URL
|
||||
: '/dashboard/api/v1'),
|
||||
)
|
||||
|
||||
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
|
||||
|
||||
@@ -1,64 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function installWindow(hostname: string, protocol = 'https:') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { location: { hostname, protocol } },
|
||||
})
|
||||
}
|
||||
|
||||
async function loadUrls(env: Record<string, string | undefined> = {}) {
|
||||
vi.resetModules()
|
||||
for (const key of ['NEXT_PUBLIC_STOREFRONT_URL', 'NEXT_PUBLIC_ADMIN_URL']) {
|
||||
if (env[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = env[key]
|
||||
}
|
||||
for (const key of ['NEXT_PUBLIC_WEBSITE_URL', 'NEXT_PUBLIC_CARPLACE_URL', 'NEXT_PUBLIC_ADMIN_URL']) {
|
||||
delete process.env[key]
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
return import('./urls')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
delete process.env.NEXT_PUBLIC_STOREFRONT_URL
|
||||
delete process.env.NEXT_PUBLIC_ADMIN_URL
|
||||
vi.resetModules()
|
||||
for (const key of ['NEXT_PUBLIC_WEBSITE_URL', 'NEXT_PUBLIC_CARPLACE_URL', 'NEXT_PUBLIC_ADMIN_URL']) {
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
|
||||
describe('dashboard cross-app URLs', () => {
|
||||
it('uses default storefront and admin bases on the server', async () => {
|
||||
const { storefrontUrl, adminUrl } = await loadUrls()
|
||||
|
||||
expect(storefrontUrl).toBe('http://localhost:3000')
|
||||
describe('application URLs', () => {
|
||||
it('uses the website as the canonical public origin', async () => {
|
||||
const { websiteUrl, carplaceUrl, adminUrl } = await loadUrls()
|
||||
expect(websiteUrl).toBe('http://localhost:3000')
|
||||
expect(carplaceUrl).toBe('http://localhost:3000/carplace')
|
||||
expect(adminUrl).toBe('http://localhost:3000/admin')
|
||||
})
|
||||
|
||||
it('strips trailing slashes from configured absolute URLs', async () => {
|
||||
const { storefrontUrl, adminUrl } = await loadUrls({
|
||||
NEXT_PUBLIC_STOREFRONT_URL: 'https://market.example.com/',
|
||||
NEXT_PUBLIC_ADMIN_URL: 'https://ops.example.com/admin/',
|
||||
it('accepts explicit Carplace and admin URLs', async () => {
|
||||
const { websiteUrl, carplaceUrl, adminUrl } = await loadUrls({
|
||||
NEXT_PUBLIC_WEBSITE_URL: 'https://rentaldrivego.ma/',
|
||||
NEXT_PUBLIC_CARPLACE_URL: 'https://rentaldrivego.ma/carplace/',
|
||||
NEXT_PUBLIC_ADMIN_URL: 'https://rentaldrivego.ma/admin/',
|
||||
})
|
||||
|
||||
expect(storefrontUrl).toBe('https://market.example.com')
|
||||
expect(adminUrl).toBe('https://ops.example.com/admin')
|
||||
expect(websiteUrl).toBe('https://rentaldrivego.ma')
|
||||
expect(carplaceUrl).toBe('https://rentaldrivego.ma/carplace')
|
||||
expect(adminUrl).toBe('https://rentaldrivego.ma/admin')
|
||||
})
|
||||
|
||||
it('rewrites local browser fallbacks onto the current production host', async () => {
|
||||
installWindow('tenant.rentaldrivego.com', 'https:')
|
||||
const { storefrontUrl, adminUrl } = await loadUrls()
|
||||
|
||||
expect(storefrontUrl).toBe('https://tenant.rentaldrivego.com')
|
||||
expect(adminUrl).toBe('https://tenant.rentaldrivego.com/admin')
|
||||
})
|
||||
|
||||
it('preserves non-URL values after normalizing their trailing slash', async () => {
|
||||
const { storefrontUrl, adminUrl } = await loadUrls({
|
||||
NEXT_PUBLIC_STOREFRONT_URL: '/storefront/',
|
||||
NEXT_PUBLIC_ADMIN_URL: '/admin/',
|
||||
})
|
||||
|
||||
expect(storefrontUrl).toBe('/storefront')
|
||||
expect(adminUrl).toBe('/admin')
|
||||
it('adds the Carplace path when only an origin is supplied', async () => {
|
||||
const { carplaceUrl } = await loadUrls({ NEXT_PUBLIC_CARPLACE_URL: 'https://market.example.com' })
|
||||
expect(carplaceUrl).toBe('https://market.example.com/carplace')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,17 +2,26 @@ import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
function toAppBase(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return (u.origin + u.pathname).replace(/\/$/, '')
|
||||
const parsed = new URL(url)
|
||||
return (parsed.origin + parsed.pathname).replace(/\/$/, '')
|
||||
} catch {
|
||||
return url.replace(/\/$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
export const storefrontUrl = toAppBase(
|
||||
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_STOREFRONT_URL ?? 'http://localhost:3000'),
|
||||
function withCarplacePath(url: string): string {
|
||||
const base = toAppBase(url)
|
||||
return /\/carplace$/i.test(base) ? base : `${base}/carplace`
|
||||
}
|
||||
|
||||
export const websiteUrl = toAppBase(
|
||||
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000'),
|
||||
)
|
||||
|
||||
export const carplaceUrl = withCarplacePath(
|
||||
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_CARPLACE_URL ?? websiteUrl),
|
||||
)
|
||||
|
||||
export const adminUrl = toAppBase(
|
||||
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin'),
|
||||
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? `${websiteUrl}/admin`),
|
||||
)
|
||||
|
||||
@@ -37,9 +37,9 @@ function request(input: string, options: { token?: string; headers?: Record<stri
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMiddleware(storefrontUrl = 'https://market.example.com') {
|
||||
async function loadMiddleware(websiteUrl = 'https://market.example.com') {
|
||||
vi.resetModules()
|
||||
process.env.NEXT_PUBLIC_STOREFRONT_URL = storefrontUrl
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL = websiteUrl
|
||||
return import('./middleware')
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_STOREFRONT_URL
|
||||
delete process.env.NEXT_PUBLIC_WEBSITE_URL
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const STOREFRONT_URL = process.env.NEXT_PUBLIC_STOREFRONT_URL ?? 'http://localhost:3000'
|
||||
const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000'
|
||||
const DASHBOARD_BASE_PATH = '/dashboard'
|
||||
|
||||
function toDashboardAppPath(pathname: string): string {
|
||||
@@ -32,7 +32,7 @@ function deduplicatePublicDashboardPath(pathname: string): string | null {
|
||||
function resolveProxyUrl(req: NextRequest, pathname: string): URL {
|
||||
const forwardedHost = req.headers.get('x-forwarded-host')
|
||||
const forwardedProto = req.headers.get('x-forwarded-proto')
|
||||
const storefrontOrigin = new URL(STOREFRONT_URL)
|
||||
const websiteOrigin = new URL(WEBSITE_URL)
|
||||
|
||||
const url = new URL(pathname, req.nextUrl.origin)
|
||||
if (forwardedHost && !isInternalHost(forwardedHost)) {
|
||||
@@ -44,9 +44,9 @@ function resolveProxyUrl(req: NextRequest, pathname: string): URL {
|
||||
url.protocol = req.nextUrl.protocol
|
||||
if (!hasExplicitPort(req.nextUrl.host) || isInternalAppPort(url.port)) url.port = ''
|
||||
} else {
|
||||
url.host = storefrontOrigin.host
|
||||
url.protocol = storefrontOrigin.protocol
|
||||
if (!hasExplicitPort(storefrontOrigin.host) || isInternalAppPort(url.port)) url.port = ''
|
||||
url.host = websiteOrigin.host
|
||||
url.protocol = websiteOrigin.protocol
|
||||
if (!hasExplicitPort(websiteOrigin.host) || isInternalAppPort(url.port)) url.port = ''
|
||||
}
|
||||
return url
|
||||
}
|
||||
@@ -62,7 +62,7 @@ function isInternalAppPort(port: string): boolean {
|
||||
function isInternalHost(host: string | null): boolean {
|
||||
if (!host) return false
|
||||
const hostname = host.split(':')[0]?.toLowerCase()
|
||||
return ['host.docker.internal', 'dashboard', 'admin', 'api', 'homepage', 'storefront'].includes(hostname)
|
||||
return ['host.docker.internal', 'dashboard', 'admin', 'api', 'homepage', 'carplace'].includes(hostname)
|
||||
}
|
||||
|
||||
function isProtectedRoute(req: NextRequest) {
|
||||
@@ -76,7 +76,7 @@ function localJwtMiddleware(req: NextRequest): NextResponse {
|
||||
const token = req.cookies.get('employee_session')?.value
|
||||
const pathname = toDashboardAppPath(req.nextUrl.pathname)
|
||||
|
||||
// Redirect signed-in users from sign-in to dashboard (through storefront proxy)
|
||||
// Redirect signed-in users from sign-in to dashboard (through the website proxy)
|
||||
if (token && pathname === '/sign-in') {
|
||||
const dashboardUrl = resolveProxyUrl(req, DASHBOARD_BASE_PATH)
|
||||
return NextResponse.redirect(dashboardUrl)
|
||||
|
||||
Reference in New Issue
Block a user