From f22e0d45e11f77d6280c69b5e79e828646f933c3 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 29 Jun 2026 23:15:55 -0400 Subject: [PATCH] fix subscription page --- apps/api/src/data/platform-content.json | 10 +- .../middleware/requireSubscription.test.ts | 4 +- .../api/src/middleware/requireSubscription.ts | 2 +- .../src/modules/auth/auth.account.service.ts | 2 +- .../src/modules/auth/auth.company.service.ts | 2 +- .../subscriptions/subscription.policy.ts | 2 +- .../subscription.service.edge.test.ts | 14 +- .../src/tests/api/auth-middleware.api.test.ts | 2 +- .../integration/auth-company-signup.test.ts | 6 +- .../src/app/(dashboard)/subscription/page.tsx | 102 +++- .../DashboardAccessGuard.boundary.test.ts | 16 + .../layout/DashboardAccessGuard.tsx | 78 ++- .../src/components/layout/Sidebar.tsx | 29 +- .../src/lib/dashboardRoutePolicies.ts | 60 ++ docs/create-account-guide.md | 2 +- docs/frontend_redesign_plan.md | 2 +- docs/progressive-signup-plan.md | 2 +- docs/project-design/api-routes.md | 2 +- docs/subscription-page-redirect-fix-plan.md | 538 ++++++++++++++++++ .../migration.sql | 15 + packages/types/src/api.ts | 12 +- packages/types/src/storefront-homepage.ts | 12 +- 22 files changed, 842 insertions(+), 72 deletions(-) create mode 100644 apps/dashboard/src/lib/dashboardRoutePolicies.ts create mode 100644 docs/subscription-page-redirect-fix-plan.md create mode 100644 packages/database/prisma/migrations/20260630120000_update_subscription_plan_prices/migration.sql diff --git a/apps/api/src/data/platform-content.json b/apps/api/src/data/platform-content.json index 518a073..ab49132 100644 --- a/apps/api/src/data/platform-content.json +++ b/apps/api/src/data/platform-content.json @@ -71,7 +71,7 @@ { "number": "1", "title": "Create Account", - "description": "Sign up for your company workspace and choose your pricing plan for the 90-day free trial." + "description": "Sign up for your company workspace and choose your pricing plan for the 30-day free trial." }, { "number": "2", @@ -89,7 +89,7 @@ { "step": "1", "title": "Create your company workspace", - "body": "Pick a plan, launch your 90-day trial, and verify the owner account." + "body": "Pick a plan, launch your 30-day trial, and verify the owner account." }, { "step": "2", @@ -180,7 +180,7 @@ { "number": "1", "title": "Créer un compte", - "description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 90 jours." + "description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 30 jours." }, { "number": "2", @@ -198,7 +198,7 @@ { "step": "1", "title": "Créez votre espace entreprise", - "body": "Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire." + "body": "Choisissez un plan, lancez votre essai de 30 jours et vérifiez le compte propriétaire." }, { "step": "2", @@ -289,7 +289,7 @@ { "number": "1", "title": "إنشاء حساب", - "description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً." + "description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً." }, { "number": "2", diff --git a/apps/api/src/middleware/requireSubscription.test.ts b/apps/api/src/middleware/requireSubscription.test.ts index 444501e..6aa555b 100644 --- a/apps/api/src/middleware/requireSubscription.test.ts +++ b/apps/api/src/middleware/requireSubscription.test.ts @@ -27,7 +27,7 @@ describe('requireSubscription middleware', () => { expect(next).not.toHaveBeenCalled() }) - it('blocks suspended companies with a billing URL', () => { + it('blocks suspended companies with a subscription recovery URL', () => { const req = { company: { status: 'SUSPENDED' } } as Request const res = responseStub() const next = vi.fn() as NextFunction @@ -39,7 +39,7 @@ describe('requireSubscription middleware', () => { error: 'subscription_suspended', message: 'Your account has been suspended. Please contact support or renew your subscription.', statusCode: 402, - billingUrl: 'https://dashboard.example.test/billing', + billingUrl: 'https://dashboard.example.test/subscription', }) expect(next).not.toHaveBeenCalled() }) diff --git a/apps/api/src/middleware/requireSubscription.ts b/apps/api/src/middleware/requireSubscription.ts index fabce45..9f4439c 100644 --- a/apps/api/src/middleware/requireSubscription.ts +++ b/apps/api/src/middleware/requireSubscription.ts @@ -21,7 +21,7 @@ export function requireSubscription(req: Request, res: Response, next: NextFunct company.status === 'SUSPENDED' ? 'Your account has been suspended. Please contact support or renew your subscription.' : 'Your account is pending activation. Please complete your subscription setup.', - { billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing` }, + { billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription` }, ) } diff --git a/apps/api/src/modules/auth/auth.account.service.ts b/apps/api/src/modules/auth/auth.account.service.ts index 831524d..ed4babc 100644 --- a/apps/api/src/modules/auth/auth.account.service.ts +++ b/apps/api/src/modules/auth/auth.account.service.ts @@ -37,7 +37,7 @@ export async function startAccount(body: AccountStartInput) { const result = await prisma.$transaction(async (tx: any) => { const now = new Date() - const trialEndAt = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000) + const trialEndAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) const company = await tx.company.create({ data: { diff --git a/apps/api/src/modules/auth/auth.company.service.ts b/apps/api/src/modules/auth/auth.company.service.ts index 832fdf0..4a42c2a 100644 --- a/apps/api/src/modules/auth/auth.company.service.ts +++ b/apps/api/src/modules/auth/auth.company.service.ts @@ -13,7 +13,7 @@ import { companySignupSchema } from './auth.company.schemas' import type { output } from 'zod' type CompanySignupInput = output -const TRIAL_PERIOD_DAYS = 90 +const TRIAL_PERIOD_DAYS = 30 function slugify(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company' diff --git a/apps/api/src/modules/subscriptions/subscription.policy.ts b/apps/api/src/modules/subscriptions/subscription.policy.ts index 06aa26d..4dcf2a6 100644 --- a/apps/api/src/modules/subscriptions/subscription.policy.ts +++ b/apps/api/src/modules/subscriptions/subscription.policy.ts @@ -2,7 +2,7 @@ export type AccessLevel = 'full' | 'limited' | 'read_only' | 'none' export const SUBSCRIPTION_POLICY = { trial: { - durationDays: 14, + durationDays: 30, requiresPaymentMethod: false, // set true when payment capture is mandatory oneTrialPerCompany: true, }, diff --git a/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts b/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts index 056a028..2513c92 100644 --- a/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts +++ b/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts @@ -70,18 +70,18 @@ describe('subscription.service operational edges', () => { it('builds plans from pricing config rows when platform overrides exist', async () => { vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([ - { plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 9900 }, - { plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 99000 }, - { plan: 'PRO', billingPeriod: 'MONTHLY', amount: 29900 }, + { plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 14900 }, + { plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 143040 }, + { plan: 'PRO', billingPeriod: 'MONTHLY', amount: 39900 }, ] as never) await expect(service.getPlans()).resolves.toEqual({ STARTER: { - MONTHLY: { MAD: 9900 }, - ANNUAL: { MAD: 99000 }, + MONTHLY: { MAD: 14900 }, + ANNUAL: { MAD: 143040 }, }, PRO: { - MONTHLY: { MAD: 29900 }, + MONTHLY: { MAD: 39900 }, }, }) }) @@ -102,7 +102,7 @@ describe('subscription.service operational edges', () => { 'PRO', 'MONTHLY', 'MAD', - new Date('2026-06-15T00:00:00.000Z'), + new Date('2026-07-01T00:00:00.000Z'), ) expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({ subscriptionId: 'sub_1', diff --git a/apps/api/src/tests/api/auth-middleware.api.test.ts b/apps/api/src/tests/api/auth-middleware.api.test.ts index e2c5ae6..42ddf62 100644 --- a/apps/api/src/tests/api/auth-middleware.api.test.ts +++ b/apps/api/src/tests/api/auth-middleware.api.test.ts @@ -94,7 +94,7 @@ describe('auth middleware API boundaries', () => { expect(res.status).toBe(402) expect(res.body).toEqual(expect.objectContaining({ error: 'subscription_suspended', - billingUrl: 'https://dashboard.example.test/billing', + billingUrl: 'https://dashboard.example.test/subscription', })) expect(vehicleService.listVehicles).not.toHaveBeenCalled() }) diff --git a/apps/api/src/tests/integration/auth-company-signup.test.ts b/apps/api/src/tests/integration/auth-company-signup.test.ts index 125f8f6..07ec4f4 100644 --- a/apps/api/src/tests/integration/auth-company-signup.test.ts +++ b/apps/api/src/tests/integration/auth-company-signup.test.ts @@ -8,7 +8,7 @@ function uniqueEmail(prefix: string) { } describe('Company signup API', () => { - it('creates new accounts with a 90-day trial period', async () => { + it('creates new accounts with a 30-day trial period', async () => { const startedAt = Date.now() const res = await request(app) @@ -54,7 +54,7 @@ describe('Company signup API', () => { const trialEndsAt = new Date(res.body.data.trialEndsAt).getTime() const trialDurationDays = (trialEndsAt - startedAt) / (24 * 60 * 60 * 1000) - expect(trialDurationDays).toBeGreaterThan(89.9) - expect(trialDurationDays).toBeLessThan(90.1) + expect(trialDurationDays).toBeGreaterThan(29.9) + expect(trialDurationDays).toBeLessThan(30.1) }) }) diff --git a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx index 28f6104..c108b19 100644 --- a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx @@ -51,7 +51,10 @@ const STATUS_BADGE: Record = { ACTIVE: 'bg-green-100 text-green-700', PAST_DUE: 'bg-orange-100 text-orange-700', CANCELLED: 'bg-slate-100 text-slate-600', + CANCELED: 'bg-slate-100 text-slate-600', UNPAID: 'bg-red-100 text-red-700', + EXPIRED: 'bg-red-100 text-red-700', + SUSPENDED: 'bg-red-100 text-red-700', } const INVOICE_STATUS: Record = { @@ -70,6 +73,8 @@ export default function SubscriptionPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [canViewPage, setCanViewPage] = useState(null) + const [verificationError, setVerificationError] = useState(null) + const [verificationAttempt, setVerificationAttempt] = useState(0) const [selectedPlan, setSelectedPlan] = useState('STARTER') const [billingPeriod, setBillingPeriod] = useState('MONTHLY') @@ -96,7 +101,7 @@ export default function SubscriptionPage() { subscribe: 'Subscribe', selectPlan: 'Select a plan and payment provider to proceed.', monthly: 'Monthly', - annual: 'Annual (save ~17%)', + annual: 'Annual (save 20%)', active: 'Active', perMonthShort: 'mo', perYearShort: 'yr', @@ -113,10 +118,15 @@ export default function SubscriptionPage() { paid: 'Paid', amount: 'Amount', loading: 'Loading…', + verifying: 'Verifying access…', + accessDenied: 'Access denied', + accessDeniedBody: 'Only account owners can manage subscriptions.', + retry: 'Retry', + accessUnavailable: 'Unable to verify your access right now. Please try again.', noInvoices: 'No invoices yet.', noProviderConfigured: 'No payment provider is configured. Contact support to enable AmanPay or PayPal.', providerUnavailable: 'This payment provider is not configured.', - statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record, + statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', CANCELED: 'Canceled', UNPAID: 'Unpaid', EXPIRED: 'Expired', SUSPENDED: 'Suspended' } as Record, invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record, planFeatures: { STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Storefront listing'], @@ -139,7 +149,7 @@ export default function SubscriptionPage() { subscribe: 'S’abonner', selectPlan: 'Sélectionnez un plan et un prestataire de paiement.', monthly: 'Mensuel', - annual: 'Annuel (économie ~17%)', + annual: 'Annuel (économie 20%)', active: 'Actif', perMonthShort: 'mois', perYearShort: 'an', @@ -156,10 +166,15 @@ export default function SubscriptionPage() { paid: 'Payé', amount: 'Montant', loading: 'Chargement…', + verifying: 'Vérification de l’accès…', + accessDenied: 'Accès refusé', + accessDeniedBody: 'Seuls les propriétaires du compte peuvent gérer les abonnements.', + retry: 'Réessayer', + accessUnavailable: 'Impossible de vérifier votre accès pour le moment. Veuillez réessayer.', noInvoices: 'Aucune facture pour le moment.', noProviderConfigured: 'Aucun prestataire de paiement n’est configuré. Contactez le support pour activer AmanPay ou PayPal.', providerUnavailable: 'Ce prestataire de paiement n’est pas configuré.', - statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record, + statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', CANCELED: 'Annulé', UNPAID: 'Impayé', EXPIRED: 'Expiré', SUSPENDED: 'Suspendu' } as Record, invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record, planFeatures: { STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence storefront'], @@ -182,7 +197,7 @@ export default function SubscriptionPage() { subscribe: 'اشتراك', selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.', monthly: 'شهري', - annual: 'سنوي (توفير ~17%)', + annual: 'سنوي (توفير 20%)', active: 'نشط', perMonthShort: 'شهر', perYearShort: 'سنة', @@ -199,10 +214,15 @@ export default function SubscriptionPage() { paid: 'مدفوع', amount: 'المبلغ', loading: 'جارٍ التحميل…', + verifying: 'جارٍ التحقق من الوصول…', + accessDenied: 'تم رفض الوصول', + accessDeniedBody: 'يمكن لمالكي الحساب فقط إدارة الاشتراكات.', + retry: 'إعادة المحاولة', + accessUnavailable: 'تعذر التحقق من وصولك الآن. يرجى المحاولة مرة أخرى.', noInvoices: 'لا توجد فواتير حتى الآن.', noProviderConfigured: 'لا يوجد مزوّد دفع مهيأ. تواصل مع الدعم لتفعيل AmanPay أو PayPal.', providerUnavailable: 'مزوّد الدفع هذا غير مهيأ.', - statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record, + statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', CANCELED: 'ملغى', UNPAID: 'غير مدفوع', EXPIRED: 'منتهي', SUSPENDED: 'معلّق' } as Record, invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record, planFeatures: { STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'], @@ -213,29 +233,34 @@ export default function SubscriptionPage() { }[language] useEffect(() => { - const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY) - if (cached) { - try { - const profile = JSON.parse(cached) as EmployeeProfile - const allowed = profile.role === 'OWNER' - setCanViewPage(allowed) - if (!allowed) router.replace('/') - return - } catch {} - } - + let cancelled = false + setCanViewPage(null) + setVerificationError(null) apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me') .then(({ employee }) => { + if (cancelled) return window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee)) const allowed = employee.role === 'OWNER' setCanViewPage(allowed) if (!allowed) router.replace('/') }) - .catch(() => { - setCanViewPage(false) - router.replace('/') + .catch((err: any) => { + if (cancelled) return + if (err?.statusCode === 401) { + router.replace('/sign-in?redirect=%2Fsubscription') + return + } + if (err?.statusCode === 403) { + setCanViewPage(false) + return + } + setVerificationError(err?.message ?? copy.accessUnavailable) }) - }, [router]) + + return () => { + cancelled = true + } + }, [copy.accessUnavailable, router, verificationAttempt]) const fetchPlanData = useCallback(async () => { const [prices, features] = await Promise.all([ @@ -285,8 +310,41 @@ export default function SubscriptionPage() { } }, [canViewPage, fetchPlanData]) + if (verificationError) { + return ( +
+
+

{copy.accessUnavailable}

+

{verificationError}

+ +
+
+ ) + } + + if (canViewPage === null) { + return ( +
+
+
+ ) + } + if (canViewPage !== true) { - return null + return ( +
+
+

{copy.accessDenied}

+

{copy.accessDeniedBody}

+
+
+ ) } async function handleCheckout() { diff --git a/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts b/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts index e456bd1..357439a 100644 --- a/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts +++ b/apps/dashboard/src/components/layout/DashboardAccessGuard.boundary.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { resolveDashboardRoutePolicy, roleCanAccessPolicy } from '@/lib/dashboardRoutePolicies' import { flattenInternalRoutes, isAllowedRoute, resolveAccessRedirect } from './DashboardAccessGuard' describe('DashboardAccessGuard route helpers', () => { @@ -38,4 +39,19 @@ describe('DashboardAccessGuard route helpers', () => { expect(resolveAccessRedirect('/settings', ['/', '/fleet'])).toBe('/') expect(resolveAccessRedirect('/fleet/123', ['/', '/fleet'])).toBeNull() }) + + it('treats subscription as an owner-only recovery route independent of menu registration', () => { + const policy = resolveDashboardRoutePolicy('/subscription') + + expect(policy).toMatchObject({ + authenticationRequired: true, + allowedRoles: ['OWNER'], + subscriptionRequired: false, + menuRegistrationRequired: false, + billingRecoveryRoute: true, + }) + expect(roleCanAccessPolicy('OWNER', policy)).toBe(true) + expect(roleCanAccessPolicy('MANAGER', policy)).toBe(false) + expect(roleCanAccessPolicy('AGENT', policy)).toBe(false) + }) }) diff --git a/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx b/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx index 067b64f..f24a259 100644 --- a/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx +++ b/apps/dashboard/src/components/layout/DashboardAccessGuard.tsx @@ -4,6 +4,11 @@ import { usePathname, useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { apiFetch } from '@/lib/api' import { toDashboardAppPath } from '@/lib/dashboardPaths' +import { + getDashboardFallbackRoute, + resolveDashboardRoutePolicy, + roleCanAccessPolicy, +} from '@/lib/dashboardRoutePolicies' type GeneratedMenuItem = { id: string @@ -16,6 +21,12 @@ type EmployeeMenuResponse = { items: GeneratedMenuItem[] } +type EmployeeProfileResponse = { + employee: { + role: string + } +} + export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] { const routes: string[] = [] @@ -45,40 +56,75 @@ export function resolveAccessRedirect(currentPath: string, allowedRoutes: string return fallbackRoute === currentPath ? null : fallbackRoute } +function buildSignInRedirect(currentPath: string) { + const params = new URLSearchParams() + params.set('redirect', currentPath) + return `/sign-in?${params.toString()}` +} + export default function DashboardAccessGuard({ children }: { children: React.ReactNode }) { const pathname = usePathname() const router = useRouter() const [ready, setReady] = useState(false) + const [error, setError] = useState(null) useEffect(() => { let cancelled = false async function enforceAccess() { + const currentPath = toDashboardAppPath(pathname) + const policy = resolveDashboardRoutePolicy(currentPath) try { setReady(false) - const currentPath = toDashboardAppPath(pathname) - const menu = await apiFetch('/auth/employee/menu') + setError(null) + + const { employee } = await apiFetch('/auth/employee/me') if (cancelled) return - const allowedRoutes = flattenInternalRoutes(menu.items) - const redirectPath = resolveAccessRedirect(currentPath, allowedRoutes) + if (!roleCanAccessPolicy(employee.role, policy)) { + const fallbackRoute = getDashboardFallbackRoute(employee.role) + if (fallbackRoute !== currentPath) { + router.replace(fallbackRoute) + return + } + } - if (redirectPath) { - router.replace(redirectPath) - return + if (policy.menuRegistrationRequired) { + const menu = await apiFetch('/auth/employee/menu') + if (cancelled) return + + const allowedRoutes = flattenInternalRoutes(menu.items) + const redirectPath = resolveAccessRedirect(currentPath, allowedRoutes) + + if (redirectPath) { + router.replace(redirectPath) + return + } } setReady(true) } catch (error: any) { if (cancelled) return - if (error?.statusCode === 401 || error?.statusCode === 403 || error?.statusCode === 402) { - router.replace('/') + if (error?.statusCode === 401) { + const target = buildSignInRedirect(currentPath) + if (target !== currentPath) router.replace(target) return } - // Do not deadlock the dashboard on transient network failures. - setReady(true) + if (error?.statusCode === 402) { + if (policy.billingRecoveryRoute) setReady(true) + else router.replace('/subscription') + return + } + + if (error?.statusCode === 403) { + if (currentPath !== '/') router.replace('/') + else setReady(true) + return + } + + setError(error?.message ?? 'Unable to verify dashboard access. Please try again.') } } @@ -89,6 +135,16 @@ export default function DashboardAccessGuard({ children }: { children: React.Rea } }, [pathname, router]) + if (error) { + return ( +
+
+ {error} +
+
+ ) + } + if (!ready) { return (
diff --git a/apps/dashboard/src/components/layout/Sidebar.tsx b/apps/dashboard/src/components/layout/Sidebar.tsx index 2e46cd8..42db1b0 100644 --- a/apps/dashboard/src/components/layout/Sidebar.tsx +++ b/apps/dashboard/src/components/layout/Sidebar.tsx @@ -95,6 +95,10 @@ const NAV_ITEMS = [ { href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' }, ] as const +const OWNER_SYSTEM_NAV_ITEMS = [ + { href: '/subscription', key: 'subscription', icon: 'CreditCard', minRole: 'OWNER' }, +] as const + export const APPROVED_BASELINE_MENU_KEYS = NAV_ITEMS.map((item) => item.key) const ICON_MAP = { @@ -280,7 +284,30 @@ export default function Sidebar() { })) const useGeneratedMenu = menuLoadState === 'loaded' - const resolvedMenuItems = useGeneratedMenu ? (menuItems ?? []) : fallbackMenuItems + const ownerSystemMenuItems = OWNER_SYSTEM_NAV_ITEMS + .filter((item) => !mounted || hasMinRole(role, item.minRole)) + .map((item) => ({ + id: `system:${item.href}`, + systemKey: item.key, + label: dict.nav[item.key] ?? item.key, + itemType: 'INTERNAL_PAGE' as const, + routeOrUrl: item.href, + icon: item.icon, + parentId: null, + openInNewTab: false, + displayOrder: 0, + children: [], + })) + const generatedMenuItems = useGeneratedMenu ? (menuItems ?? []) : fallbackMenuItems + const generatedRoutes = new Set( + generatedMenuItems + .filter((item) => item.itemType === 'INTERNAL_PAGE' && item.routeOrUrl) + .map((item) => toDashboardAppPath(item.routeOrUrl)), + ) + const resolvedMenuItems = [ + ...generatedMenuItems, + ...ownerSystemMenuItems.filter((item) => !generatedRoutes.has(toDashboardAppPath(item.routeOrUrl))), + ] function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode { return items.map((item) => { diff --git a/apps/dashboard/src/lib/dashboardRoutePolicies.ts b/apps/dashboard/src/lib/dashboardRoutePolicies.ts new file mode 100644 index 0000000..d57bbc4 --- /dev/null +++ b/apps/dashboard/src/lib/dashboardRoutePolicies.ts @@ -0,0 +1,60 @@ +import { toDashboardAppPath } from './dashboardPaths' + +export type DashboardRole = 'OWNER' | 'MANAGER' | 'AGENT' + +export type DashboardRoutePolicy = { + authenticationRequired: boolean + allowedRoles: DashboardRole[] | null + subscriptionRequired: boolean + menuRegistrationRequired: boolean + billingRecoveryRoute?: boolean +} + +export const OWNER_ONLY_BILLING_RECOVERY_POLICY: DashboardRoutePolicy = { + authenticationRequired: true, + allowedRoles: ['OWNER'], + subscriptionRequired: false, + menuRegistrationRequired: false, + billingRecoveryRoute: true, +} + +export const DASHBOARD_FEATURE_POLICY: DashboardRoutePolicy = { + authenticationRequired: true, + allowedRoles: null, + subscriptionRequired: true, + menuRegistrationRequired: true, +} + +export const dashboardRoutePolicies: Record = { + '/': { + authenticationRequired: true, + allowedRoles: null, + subscriptionRequired: false, + menuRegistrationRequired: false, + }, + '/subscription': OWNER_ONLY_BILLING_RECOVERY_POLICY, + '/subscription/success': OWNER_ONLY_BILLING_RECOVERY_POLICY, + '/subscription/cancel': OWNER_ONLY_BILLING_RECOVERY_POLICY, +} + +export function resolveDashboardRoutePolicy(pathname: string): DashboardRoutePolicy { + const currentPath = toDashboardAppPath(pathname) + const matchingRoute = Object.keys(dashboardRoutePolicies) + .sort((a, b) => b.length - a.length) + .find((route) => { + if (route === '/') return currentPath === '/' + return currentPath === route || currentPath.startsWith(`${route}/`) + }) + + return matchingRoute ? dashboardRoutePolicies[matchingRoute] : DASHBOARD_FEATURE_POLICY +} + +export function roleCanAccessPolicy(role: string | null | undefined, policy: DashboardRoutePolicy): boolean { + if (!policy.allowedRoles) return true + return Boolean(role && policy.allowedRoles.includes(role as DashboardRole)) +} + +export function getDashboardFallbackRoute(role: string | null | undefined): string { + if (role === 'OWNER' || role === 'MANAGER' || role === 'AGENT') return '/' + return '/' +} diff --git a/docs/create-account-guide.md b/docs/create-account-guide.md index 7e23113..1ee0caa 100644 --- a/docs/create-account-guide.md +++ b/docs/create-account-guide.md @@ -218,7 +218,7 @@ After first sign-in, the user is directed to the **onboarding** page (`/onboardi ### Subscription -New accounts start on a **90-day free trial** with the selected plan. The subscription status is shown in a banner on the dashboard home. Users can manage billing from the `/subscription` page. +New accounts start on a **30-day free trial** with the selected plan. The subscription status is shown in a banner on the dashboard home. Users can manage billing from the `/subscription` page. --- diff --git a/docs/frontend_redesign_plan.md b/docs/frontend_redesign_plan.md index 5d2129d..bf14a1b 100644 --- a/docs/frontend_redesign_plan.md +++ b/docs/frontend_redesign_plan.md @@ -84,7 +84,7 @@ Testimonials └── 2–3 quote cards with avatar, name, company Pricing CTA Banner - └── "Start free for 90 days" with orange CTA + └── "Start free for 30 days" with orange CTA Footer ``` diff --git a/docs/progressive-signup-plan.md b/docs/progressive-signup-plan.md index 2df6f79..8ae0eee 100644 --- a/docs/progressive-signup-plan.md +++ b/docs/progressive-signup-plan.md @@ -72,7 +72,7 @@ Replace the rigid 4-step form with an explicit состояние/state on the ` | `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 | | `PAYABLE` | Payment provider config (AmanPay/PayPal) + responsible person | Accepting bookings/payments | -| `SUBSCRIBED` | Plan + billing period selection | Past the 90-day trial, billed account | +| `SUBSCRIBED` | Plan + billing period selection | Past the 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. diff --git a/docs/project-design/api-routes.md b/docs/project-design/api-routes.md index 50eaad1..5349ffc 100644 --- a/docs/project-design/api-routes.md +++ b/docs/project-design/api-routes.md @@ -157,7 +157,7 @@ The table below describes the current route groups mounted in `app.ts`. ### Company and employee onboarding - `POST /api/v1/auth/company/signup` creates the company tenant, owner employee, default subscription state, and related baseline records in one transaction. -- The signup flow generates a unique company slug, hashes the owner password, starts a 90-day trial window, and sends account-created notifications. +- The signup flow generates a unique company slug, hashes the owner password, starts a 30-day trial window, and sends account-created notifications. - Employee auth supports login, forgot-password, reset-password, profile read, and language updates. ### Company profile and business configuration diff --git a/docs/subscription-page-redirect-fix-plan.md b/docs/subscription-page-redirect-fix-plan.md new file mode 100644 index 0000000..d0209c0 --- /dev/null +++ b/docs/subscription-page-redirect-fix-plan.md @@ -0,0 +1,538 @@ +# Subscription Page Redirect Fix Plan + +## 1. Objective + +Restore reliable access to: + +```text +http://localhost:3000/dashboard/subscription +``` + +The subscription page must remain accessible to authorized account owners, including owners whose subscription is expired, unpaid, past due, canceled, or suspended. + +The fix must not weaken backend authorization or allow non-owner employees to manage billing. + +--- + +## 2. Current Problem + +The subscription page exists, but authorized users are redirected to: + +```text +http://localhost:3000/dashboard +``` + +The likely causes are: + +1. The dashboard route guard treats the generated sidebar menu as a route-authorization list. +2. `/subscription` may not be returned by `GET /auth/employee/menu`. +3. The subscription page trusts the cached `employee_profile` value in `localStorage`. +4. Subscription enforcement may return `402 Payment Required` before the owner can reach the billing recovery page. +5. Redirect targets use ambiguous paths such as `/`, which resolve to `/dashboard` because of the configured Next.js base path. + +--- + +## 3. Required Access Policy + +The subscription page must be classified as an owner-only system route. + +| User state | Expected access | +|---|---| +| Authenticated owner with active subscription | Allowed | +| Authenticated owner in trial | Allowed | +| Authenticated owner with expired subscription | Allowed | +| Authenticated owner with past-due subscription | Allowed | +| Authenticated owner with unpaid subscription | Allowed | +| Authenticated owner with canceled subscription | Allowed | +| Authenticated owner with suspended account | Allowed for billing recovery | +| Manager | Denied | +| Agent or regular employee | Denied | +| Unauthenticated user | Redirect to sign-in | + +The route must not depend on whether it appears in the configurable dashboard menu. + +--- + +## 4. Separate Navigation From Authorization + +### Problem + +The dashboard menu currently appears to serve two different purposes: + +- Controlling which sidebar links are displayed +- Controlling which routes a user may access + +These responsibilities must be separated. + +### Required change + +Use the menu response only for navigation visibility. + +Use a dedicated access policy for route authorization. + +The following route categories should be defined: + +- Public authentication routes +- Core authenticated dashboard routes +- Owner-only system routes +- Subscription-dependent feature routes +- Admin-configurable feature routes +- Billing recovery routes + +Register `/subscription` as: + +```ts +{ + authenticationRequired: true, + allowedRoles: ["OWNER"], + subscriptionRequired: false, + menuRegistrationRequired: false +} +``` + +The exact implementation may differ, but there must be one authoritative route policy. + +--- + +## 5. Create a Central Route Access Policy + +Add a shared route-policy configuration. + +Example: + +```ts +export const dashboardRoutePolicies = { + "/subscription": { + authenticationRequired: true, + allowedRoles: ["OWNER"], + subscriptionRequired: false, + menuRegistrationRequired: false, + }, +}; +``` + +The policy should be used by: + +- `DashboardAccessGuard` +- Dashboard layout +- Subscription page +- Sidebar rendering +- Subscription-status middleware +- Automated tests + +Do not duplicate owner checks and subscription checks across unrelated components. + +--- + +## 6. Update `DashboardAccessGuard` + +### Current behavior to remove + +The guard must not redirect a user merely because the current route is absent from `/auth/employee/menu`. + +### Required behavior + +The guard should: + +1. Resolve the current route policy. +2. Confirm authentication. +3. Confirm the authenticated role. +4. Apply subscription restrictions only when the route requires an active subscription. +5. Allow billing recovery routes regardless of subscription state. +6. Redirect only after all required state has finished loading. +7. Avoid redirecting to the current route. +8. Use a stable role-based fallback route. + +### Expected error handling + +| Response | Required behavior | +|---|---| +| `401 Unauthorized` | Redirect to sign-in | +| `403 Forbidden` | Show access denied or redirect to an allowed dashboard route | +| `402 Payment Required` | Redirect owner to `/subscription`, unless already there | +| Network failure | Show retryable error | +| Server failure | Show error state without pretending it is an authorization failure | + +--- + +## 7. Fix Owner Verification on the Subscription Page + +### Current risk + +The page uses `employee_profile` from `localStorage` as an authorization source. + +Browser storage can be: + +- Missing +- Stale +- Malformed +- Left from another session +- Temporarily unavailable + +### Required behavior + +1. Treat local storage only as optional display cache. +2. Fetch the authenticated profile from: + +```text +GET /auth/employee/me +``` + +3. Wait for the request to resolve before redirecting. +4. Allow access only when the API confirms the user is an owner. +5. Redirect confirmed non-owners to an allowed dashboard route. +6. Redirect unauthenticated users to sign-in. +7. Show a loading state during verification. +8. Show a retryable error for temporary API failures. + +The page must not redirect while authentication or role state is unresolved. + +--- + +## 8. Add Billing Recovery Route Exceptions + +Subscription enforcement must never block the routes needed to repair a subscription. + +Minimum allowlist: + +```text +/dashboard/subscription +/dashboard/subscription/success +/dashboard/subscription/cancel +``` + +Depending on the product flow, also consider: + +```text +/dashboard/profile +/dashboard/support +/dashboard/logout +``` + +These exceptions must be implemented wherever subscription enforcement occurs: + +- Frontend route guards +- API middleware +- API interceptors +- Account suspension guards +- Subscription-status hooks + +--- + +## 9. Review Backend Authorization + +Inspect these API areas: + +- `GET /auth/employee/menu` +- `GET /auth/employee/me` +- Authentication middleware +- Subscription-status middleware +- Account suspension middleware +- Subscription controllers +- Subscription services +- Billing portal endpoint +- Checkout endpoint +- Subscription update endpoint +- Menu configuration schema +- Menu seed data + +### Backend requirements + +- `/auth/employee/me` must remain available to authenticated owners during billing failure. +- Subscription read and recovery endpoints must remain available during `PAST_DUE`, `UNPAID`, `EXPIRED`, `CANCELED`, or `SUSPENDED` states. +- Owner authorization must be enforced server-side. +- Managers and employees must remain unable to manage subscriptions. +- Menu entries must not be treated as route permissions unless the API explicitly defines them as permissions. +- `402` responses must include enough structured information for the frontend to route owners to billing recovery. + +--- + +## 10. Correct Redirect Targets + +Audit redirects in: + +- `DashboardAccessGuard.tsx` +- `subscription/page.tsx` +- Dashboard layout +- Authentication hooks +- API client interceptors +- Subscription hooks +- Suspended-account handling + +Avoid ambiguous redirects such as: + +```ts +router.replace("/"); +``` + +Use explicit destinations based on the router and base-path convention. + +Examples: + +```ts +router.replace("/dashboard"); +router.replace("/login"); +router.replace("/dashboard/subscription"); +``` + +Confirm whether the project router expects paths with or without the configured `/dashboard` base path, then use one convention consistently. + +--- + +## 11. Prevent Redirect Loops + +Protect against: + +```text +/subscription -> /dashboard -> /subscription +/login -> /dashboard -> /login +/subscription -> / -> /dashboard +``` + +The route guard must: + +- Compare the current path with the redirect destination. +- Never redirect to the current path. +- Avoid redirecting while authentication is loading. +- Avoid redirecting while role verification is loading. +- Avoid redirecting while route policy is loading. +- Preserve intended destinations after login where appropriate. +- Use a deterministic fallback route for each role. + +--- + +## 12. Fix Sidebar Behavior + +The sidebar must remain a navigation tool, not an authorization engine. + +Required behavior: + +- Show `Subscription` only to owners. +- Hide it from managers and regular employees. +- Keep it outside admin-configurable feature menu items. +- Do not allow subscription-plan configuration to disable it. +- Do not require it to be returned by `/auth/employee/menu`. +- Validate direct URL access through the central route policy. + +The administrator may configure optional feature menus, but must not be able to remove the account owner's billing recovery route. + +--- + +## 13. Add Loading and Error States + +The subscription page should support these explicit states: + +1. Verifying authentication +2. Verifying owner role +3. Loading subscription data +4. Subscription loaded +5. No subscription found +6. Payment required +7. Subscription expired +8. Subscription past due +9. Subscription canceled +10. Account suspended +11. Access denied +12. API unavailable + +Do not briefly render protected content before redirecting. + +Do not convert every API failure into a redirect. + +--- + +## 14. Add Development Logging + +During implementation, log structured route decisions: + +```ts +{ + pathname, + authenticated, + role, + subscriptionStatus, + menuContainsRoute, + routePolicy, + accessDecision, + redirectTarget +} +``` + +Do not log: + +- Authentication tokens +- Payment details +- Full customer records +- Personal information +- Complete API response bodies + +Remove or disable verbose logs before production release. + +--- + +## 15. Automated Test Plan + +### 15.1 Route guard tests + +Test that: + +- An owner can access `/subscription` when it is absent from the menu response. +- An owner with an active subscription can access the page. +- An owner in trial can access the page. +- An owner with an expired subscription can access the page. +- An owner with a past-due subscription can access the page. +- An owner with an unpaid subscription can access the page. +- An owner with a canceled subscription can access the page. +- An owner can access the page when another request returns `402`. +- Stale local storage does not override the authenticated API profile. +- A manager cannot access the page. +- An agent cannot access the page. +- An unauthenticated user is redirected to sign-in. +- A temporary API failure shows an error instead of redirecting. +- A redirect is not issued when the destination equals the current path. + +### 15.2 Subscription page tests + +Test that: + +- No redirect occurs before `/auth/employee/me` resolves. +- A confirmed owner sees subscription information. +- A confirmed non-owner is denied. +- `401` redirects to sign-in. +- `403` shows access denied or redirects safely. +- `402` displays billing recovery. +- Network errors provide retry behavior. +- Loading state prevents protected-content flicker. + +### 15.3 Integration tests + +Test the complete flow: + +```text +Owner login +-> open /dashboard/subscription directly +-> load current subscription +-> open checkout or billing portal +-> return through success or cancel URL +-> subscription page loads correctly +``` + +Test all supported statuses: + +```text +ACTIVE +TRIALING +PAST_DUE +UNPAID +EXPIRED +CANCELED +SUSPENDED +``` + +--- + +## 16. Localization Validation + +Validate the complete flow in: + +- English +- French +- Arabic + +Required checks: + +- All labels are translated. +- Error messages are translated. +- Loading states are translated. +- Billing recovery messages are translated. +- Access-denied messages are translated. +- Arabic renders in true RTL. +- Buttons and status badges remain correctly aligned in RTL. +- No hard-coded English strings remain in the subscription flow. + +--- + +## 17. Implementation Order + +Execute the work in this order: + +1. Define the route access policy. +2. Classify `/subscription` as an owner-only recovery route. +3. Update `DashboardAccessGuard`. +4. Remove menu-based authorization for system routes. +5. Replace local-storage-only role verification. +6. Verify the user through `/auth/employee/me`. +7. Add subscription recovery exceptions. +8. Review backend subscription enforcement. +9. Correct redirect targets and base-path handling. +10. Add redirect-loop protection. +11. Update sidebar visibility rules. +12. Add loading and error states. +13. Add unit tests. +14. Add integration tests. +15. Validate every subscription state. +16. Validate English, French, and Arabic. +17. Run type-check, lint, tests, and production build. +18. Remove development-only logging. + +--- + +## 18. Required Code for Complete Implementation + +The dashboard package is sufficient for a temporary frontend patch. + +A complete fix requires these API parts: + +- `/auth/employee/menu` route, controller, and service +- `/auth/employee/me` route, controller, and service +- Authentication middleware +- Role authorization middleware +- Subscription enforcement middleware +- Account suspension middleware +- Subscription routes +- Subscription controller +- Subscription service +- Billing portal integration +- Checkout integration +- API error interceptor or response-normalization logic +- Menu configuration schema +- Menu seed files + +--- + +## 19. Acceptance Criteria + +The fix is complete only when: + +- `/dashboard/subscription` no longer redirects an authorized owner to `/dashboard`. +- The route works when it is absent from the generated menu. +- Owners with billing problems can access billing recovery. +- Non-owners remain blocked. +- Backend authorization remains enforced. +- Authorization does not rely on local storage. +- No redirect loop occurs. +- Direct URL access and sidebar navigation behave consistently. +- Base-path routing works correctly. +- `401`, `402`, `403`, network errors, and server errors are handled distinctly. +- English, French, and Arabic render correctly. +- Type-check passes. +- Lint passes. +- Unit tests pass. +- Integration tests pass. +- Production build succeeds. + +--- + +## 20. Out of Scope + +Do not include unrelated work in this repair: + +- Subscription pricing redesign +- Billing provider migration +- Dashboard visual redesign +- New subscription tiers +- General sidebar restructuring +- Domain or publishing settings +- Unrelated permission-system changes + +Any broader authorization redesign should be handled as a separate phase after the subscription route is stable. diff --git a/packages/database/prisma/migrations/20260630120000_update_subscription_plan_prices/migration.sql b/packages/database/prisma/migrations/20260630120000_update_subscription_plan_prices/migration.sql new file mode 100644 index 0000000..67abd8f --- /dev/null +++ b/packages/database/prisma/migrations/20260630120000_update_subscription_plan_prices/migration.sql @@ -0,0 +1,15 @@ +-- Update subscription plan defaults. +-- Amounts are stored in MAD centimes. +-- Annual prices are calculated as monthly * 12 with a 20% discount. +INSERT INTO "pricing_configs" ("id", "plan", "billingPeriod", "amount", "updatedAt") +VALUES + ('prc_starter_monthly', 'STARTER', 'MONTHLY', 14900, NOW()), + ('prc_starter_annual', 'STARTER', 'ANNUAL', 143040, NOW()), + ('prc_growth_monthly', 'GROWTH', 'MONTHLY', 29900, NOW()), + ('prc_growth_annual', 'GROWTH', 'ANNUAL', 287040, NOW()), + ('prc_pro_monthly', 'PRO', 'MONTHLY', 39900, NOW()), + ('prc_pro_annual', 'PRO', 'ANNUAL', 383040, NOW()) +ON CONFLICT ("plan", "billingPeriod") DO UPDATE +SET + "amount" = EXCLUDED."amount", + "updatedAt" = EXCLUDED."updatedAt"; diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 88184a5..88b8e5e 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -22,16 +22,16 @@ export interface ApiError { // Plan prices in smallest currency unit (MAD, in centimes) export const PLAN_PRICES: Record>> = { STARTER: { - MONTHLY: { MAD: 2990 }, - ANNUAL: { MAD: 29900 }, + MONTHLY: { MAD: 14900 }, + ANNUAL: { MAD: 143040 }, }, GROWTH: { - MONTHLY: { MAD: 3990 }, - ANNUAL: { MAD: 39900 }, + MONTHLY: { MAD: 29900 }, + ANNUAL: { MAD: 287040 }, }, PRO: { - MONTHLY: { MAD: 99900 }, - ANNUAL: { MAD: 999000 }, + MONTHLY: { MAD: 39900 }, + ANNUAL: { MAD: 383040 }, }, } diff --git a/packages/types/src/storefront-homepage.ts b/packages/types/src/storefront-homepage.ts index 61ab50b..05e6558 100644 --- a/packages/types/src/storefront-homepage.ts +++ b/packages/types/src/storefront-homepage.ts @@ -142,7 +142,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = { { number: '1', title: 'Create Your Workspace', - description: 'Sign up and launch your fleet workspace with a 90-day free trial — no credit card required.', + description: 'Sign up and launch your fleet workspace with a 30-day free trial — no credit card required.', }, { number: '2', @@ -157,7 +157,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = { ], stepsTitle: 'How fleets launch on RentalDriveGo', steps: [ - ['1', 'Create your fleet workspace', 'Choose a plan, start your 90-day trial, and verify your operator account.'], + ['1', 'Create your fleet workspace', 'Choose a plan, start your 30-day trial, and verify your operator account.'], ['2', 'Publish vehicles and offers', 'Upload vehicle data once and control exactly what appears on every channel.'], ['3', 'Accept bookings on your terms', 'Customers discover your fleet on RentalDriveGo, then complete the booking on your own branded checkout.'], ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })), @@ -241,7 +241,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = { { number: '1', title: 'Créez votre espace', - description: 'Inscrivez-vous et lancez votre espace de flotte avec un essai gratuit de 90 jours — sans carte bancaire.', + description: 'Inscrivez-vous et lancez votre espace de flotte avec un essai gratuit de 30 jours — sans carte bancaire.', }, { number: '2', @@ -256,7 +256,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = { ], stepsTitle: 'Comment les flottes se lancent sur RentalDriveGo', steps: [ - ['1', 'Créez votre espace de flotte', 'Choisissez un plan, démarrez votre essai de 90 jours et vérifiez votre compte opérateur.'], + ['1', 'Créez votre espace de flotte', 'Choisissez un plan, démarrez votre essai de 30 jours et vérifiez votre compte opérateur.'], ['2', 'Publiez véhicules et offres', 'Ajoutez vos données une seule fois et contrôlez précisément ce qui apparaît sur chaque canal.'], ['3', 'Acceptez les réservations selon vos conditions', 'Les clients découvrent votre flotte sur RentalDriveGo, puis finalisent sur votre propre page de réservation.'], ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })), @@ -340,7 +340,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = { { number: '1', title: 'أنشئ مساحتك', - description: 'سجّل وأطلق مساحة أسطولك مع تجربة مجانية لمدة 90 يوماً — بدون بطاقة ائتمان.', + description: 'سجّل وأطلق مساحة أسطولك مع تجربة مجانية لمدة 30 يوماً — بدون بطاقة ائتمان.', }, { number: '2', @@ -355,7 +355,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = { ], stepsTitle: 'كيف تنطلق الأساطيل على RentalDriveGo', steps: [ - ['1', 'أنشئ مساحة عمل أسطولك', 'اختر الخطة وابدأ تجربتك لمدة 90 يوماً وتحقق من حساب المشغل الخاص بك.'], + ['1', 'أنشئ مساحة عمل أسطولك', 'اختر الخطة وابدأ تجربتك لمدة 30 يوماً وتحقق من حساب المشغل الخاص بك.'], ['2', 'انشر المركبات والعروض', 'أضف بيانات المركبات مرة واحدة وتحكم بدقة فيما يظهر على كل قناة.'], ['3', 'استقبل الحجوزات بشروطك', 'يكتشف العملاء أسطولك على RentalDriveGo، ثم يكملون الحجز على صفحة الدفع الخاصة بك.'], ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),