fix subscription page
Build & Deploy / Build & Push Docker Image (push) Successful in 7m57s
Test / Type Check (all packages) (push) Successful in 4m27s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 3m2s
Test / Homepage Unit Tests (push) Successful in 4m3s
Test / Storefront Unit Tests (push) Successful in 3m32s
Test / Admin Unit Tests (push) Successful in 3m27s
Test / Dashboard Unit Tests (push) Successful in 3m3s
Test / API Integration Tests (push) Failing after 3m53s

This commit is contained in:
root
2026-06-29 23:15:55 -04:00
parent a752a399c2
commit f22e0d45e1
22 changed files with 842 additions and 72 deletions
+5 -5
View File
@@ -71,7 +71,7 @@
{ {
"number": "1", "number": "1",
"title": "Create Account", "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", "number": "2",
@@ -89,7 +89,7 @@
{ {
"step": "1", "step": "1",
"title": "Create your company workspace", "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", "step": "2",
@@ -180,7 +180,7 @@
{ {
"number": "1", "number": "1",
"title": "Créer un compte", "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", "number": "2",
@@ -198,7 +198,7 @@
{ {
"step": "1", "step": "1",
"title": "Créez votre espace entreprise", "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", "step": "2",
@@ -289,7 +289,7 @@
{ {
"number": "1", "number": "1",
"title": "إنشاء حساب", "title": "إنشاء حساب",
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً." "description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
}, },
{ {
"number": "2", "number": "2",
@@ -27,7 +27,7 @@ describe('requireSubscription middleware', () => {
expect(next).not.toHaveBeenCalled() 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 req = { company: { status: 'SUSPENDED' } } as Request
const res = responseStub() const res = responseStub()
const next = vi.fn() as NextFunction const next = vi.fn() as NextFunction
@@ -39,7 +39,7 @@ describe('requireSubscription middleware', () => {
error: 'subscription_suspended', error: 'subscription_suspended',
message: 'Your account has been suspended. Please contact support or renew your subscription.', message: 'Your account has been suspended. Please contact support or renew your subscription.',
statusCode: 402, statusCode: 402,
billingUrl: 'https://dashboard.example.test/billing', billingUrl: 'https://dashboard.example.test/subscription',
}) })
expect(next).not.toHaveBeenCalled() expect(next).not.toHaveBeenCalled()
}) })
@@ -21,7 +21,7 @@ export function requireSubscription(req: Request, res: Response, next: NextFunct
company.status === 'SUSPENDED' company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.' ? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.', : '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` },
) )
} }
@@ -37,7 +37,7 @@ export async function startAccount(body: AccountStartInput) {
const result = await prisma.$transaction(async (tx: any) => { const result = await prisma.$transaction(async (tx: any) => {
const now = new Date() 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({ const company = await tx.company.create({
data: { data: {
@@ -13,7 +13,7 @@ import { companySignupSchema } from './auth.company.schemas'
import type { output } from 'zod' import type { output } from 'zod'
type CompanySignupInput = output<typeof companySignupSchema> type CompanySignupInput = output<typeof companySignupSchema>
const TRIAL_PERIOD_DAYS = 90 const TRIAL_PERIOD_DAYS = 30
function slugify(value: string) { function slugify(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company' return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
@@ -2,7 +2,7 @@ export type AccessLevel = 'full' | 'limited' | 'read_only' | 'none'
export const SUBSCRIPTION_POLICY = { export const SUBSCRIPTION_POLICY = {
trial: { trial: {
durationDays: 14, durationDays: 30,
requiresPaymentMethod: false, // set true when payment capture is mandatory requiresPaymentMethod: false, // set true when payment capture is mandatory
oneTrialPerCompany: true, oneTrialPerCompany: true,
}, },
@@ -70,18 +70,18 @@ describe('subscription.service operational edges', () => {
it('builds plans from pricing config rows when platform overrides exist', async () => { it('builds plans from pricing config rows when platform overrides exist', async () => {
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([ vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 9900 }, { plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 14900 },
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 99000 }, { plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 143040 },
{ plan: 'PRO', billingPeriod: 'MONTHLY', amount: 29900 }, { plan: 'PRO', billingPeriod: 'MONTHLY', amount: 39900 },
] as never) ] as never)
await expect(service.getPlans()).resolves.toEqual({ await expect(service.getPlans()).resolves.toEqual({
STARTER: { STARTER: {
MONTHLY: { MAD: 9900 }, MONTHLY: { MAD: 14900 },
ANNUAL: { MAD: 99000 }, ANNUAL: { MAD: 143040 },
}, },
PRO: { PRO: {
MONTHLY: { MAD: 29900 }, MONTHLY: { MAD: 39900 },
}, },
}) })
}) })
@@ -102,7 +102,7 @@ describe('subscription.service operational edges', () => {
'PRO', 'PRO',
'MONTHLY', 'MONTHLY',
'MAD', 'MAD',
new Date('2026-06-15T00:00:00.000Z'), new Date('2026-07-01T00:00:00.000Z'),
) )
expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({ expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({
subscriptionId: 'sub_1', subscriptionId: 'sub_1',
@@ -94,7 +94,7 @@ describe('auth middleware API boundaries', () => {
expect(res.status).toBe(402) expect(res.status).toBe(402)
expect(res.body).toEqual(expect.objectContaining({ expect(res.body).toEqual(expect.objectContaining({
error: 'subscription_suspended', error: 'subscription_suspended',
billingUrl: 'https://dashboard.example.test/billing', billingUrl: 'https://dashboard.example.test/subscription',
})) }))
expect(vehicleService.listVehicles).not.toHaveBeenCalled() expect(vehicleService.listVehicles).not.toHaveBeenCalled()
}) })
@@ -8,7 +8,7 @@ function uniqueEmail(prefix: string) {
} }
describe('Company signup API', () => { 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 startedAt = Date.now()
const res = await request(app) const res = await request(app)
@@ -54,7 +54,7 @@ describe('Company signup API', () => {
const trialEndsAt = new Date(res.body.data.trialEndsAt).getTime() const trialEndsAt = new Date(res.body.data.trialEndsAt).getTime()
const trialDurationDays = (trialEndsAt - startedAt) / (24 * 60 * 60 * 1000) const trialDurationDays = (trialEndsAt - startedAt) / (24 * 60 * 60 * 1000)
expect(trialDurationDays).toBeGreaterThan(89.9) expect(trialDurationDays).toBeGreaterThan(29.9)
expect(trialDurationDays).toBeLessThan(90.1) expect(trialDurationDays).toBeLessThan(30.1)
}) })
}) })
@@ -51,7 +51,10 @@ const STATUS_BADGE: Record<string, string> = {
ACTIVE: 'bg-green-100 text-green-700', ACTIVE: 'bg-green-100 text-green-700',
PAST_DUE: 'bg-orange-100 text-orange-700', PAST_DUE: 'bg-orange-100 text-orange-700',
CANCELLED: 'bg-slate-100 text-slate-600', CANCELLED: 'bg-slate-100 text-slate-600',
CANCELED: 'bg-slate-100 text-slate-600',
UNPAID: 'bg-red-100 text-red-700', 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<string, string> = { const INVOICE_STATUS: Record<string, string> = {
@@ -70,6 +73,8 @@ export default function SubscriptionPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [canViewPage, setCanViewPage] = useState<boolean | null>(null) const [canViewPage, setCanViewPage] = useState<boolean | null>(null)
const [verificationError, setVerificationError] = useState<string | null>(null)
const [verificationAttempt, setVerificationAttempt] = useState(0)
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER') const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY') const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
@@ -96,7 +101,7 @@ export default function SubscriptionPage() {
subscribe: 'Subscribe', subscribe: 'Subscribe',
selectPlan: 'Select a plan and payment provider to proceed.', selectPlan: 'Select a plan and payment provider to proceed.',
monthly: 'Monthly', monthly: 'Monthly',
annual: 'Annual (save ~17%)', annual: 'Annual (save 20%)',
active: 'Active', active: 'Active',
perMonthShort: 'mo', perMonthShort: 'mo',
perYearShort: 'yr', perYearShort: 'yr',
@@ -113,10 +118,15 @@ export default function SubscriptionPage() {
paid: 'Paid', paid: 'Paid',
amount: 'Amount', amount: 'Amount',
loading: 'Loading…', 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.', noInvoices: 'No invoices yet.',
noProviderConfigured: 'No payment provider is configured. Contact support to enable AmanPay or PayPal.', noProviderConfigured: 'No payment provider is configured. Contact support to enable AmanPay or PayPal.',
providerUnavailable: 'This payment provider is not configured.', providerUnavailable: 'This payment provider is not configured.',
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record<string, string>, statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', 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>, invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
planFeatures: { planFeatures: {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Storefront listing'], STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Storefront listing'],
@@ -139,7 +149,7 @@ export default function SubscriptionPage() {
subscribe: 'Sabonner', subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan et un prestataire de paiement.', selectPlan: 'Sélectionnez un plan et un prestataire de paiement.',
monthly: 'Mensuel', monthly: 'Mensuel',
annual: 'Annuel (économie ~17%)', annual: 'Annuel (économie 20%)',
active: 'Actif', active: 'Actif',
perMonthShort: 'mois', perMonthShort: 'mois',
perYearShort: 'an', perYearShort: 'an',
@@ -156,10 +166,15 @@ export default function SubscriptionPage() {
paid: 'Payé', paid: 'Payé',
amount: 'Montant', amount: 'Montant',
loading: 'Chargement…', loading: 'Chargement…',
verifying: 'Vérification de laccè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.', noInvoices: 'Aucune facture pour le moment.',
noProviderConfigured: 'Aucun prestataire de paiement nest configuré. Contactez le support pour activer AmanPay ou PayPal.', noProviderConfigured: 'Aucun prestataire de paiement nest configuré. Contactez le support pour activer AmanPay ou PayPal.',
providerUnavailable: 'Ce prestataire de paiement nest pas configuré.', providerUnavailable: 'Ce prestataire de paiement nest pas configuré.',
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record<string, string>, statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', 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>, invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
planFeatures: { planFeatures: {
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence storefront'], STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence storefront'],
@@ -182,7 +197,7 @@ export default function SubscriptionPage() {
subscribe: 'اشتراك', subscribe: 'اشتراك',
selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.', selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.',
monthly: 'شهري', monthly: 'شهري',
annual: 'سنوي (توفير ~17%)', annual: 'سنوي (توفير 20%)',
active: 'نشط', active: 'نشط',
perMonthShort: 'شهر', perMonthShort: 'شهر',
perYearShort: 'سنة', perYearShort: 'سنة',
@@ -199,10 +214,15 @@ export default function SubscriptionPage() {
paid: 'مدفوع', paid: 'مدفوع',
amount: 'المبلغ', amount: 'المبلغ',
loading: 'جارٍ التحميل…', loading: 'جارٍ التحميل…',
verifying: 'جارٍ التحقق من الوصول…',
accessDenied: 'تم رفض الوصول',
accessDeniedBody: 'يمكن لمالكي الحساب فقط إدارة الاشتراكات.',
retry: 'إعادة المحاولة',
accessUnavailable: 'تعذر التحقق من وصولك الآن. يرجى المحاولة مرة أخرى.',
noInvoices: 'لا توجد فواتير حتى الآن.', noInvoices: 'لا توجد فواتير حتى الآن.',
noProviderConfigured: 'لا يوجد مزوّد دفع مهيأ. تواصل مع الدعم لتفعيل AmanPay أو PayPal.', noProviderConfigured: 'لا يوجد مزوّد دفع مهيأ. تواصل مع الدعم لتفعيل AmanPay أو PayPal.',
providerUnavailable: 'مزوّد الدفع هذا غير مهيأ.', providerUnavailable: 'مزوّد الدفع هذا غير مهيأ.',
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record<string, string>, statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', CANCELED: 'ملغى', UNPAID: 'غير مدفوع', EXPIRED: 'منتهي', SUSPENDED: 'معلّق' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>, invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
planFeatures: { planFeatures: {
STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'], STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
@@ -213,29 +233,34 @@ export default function SubscriptionPage() {
}[language] }[language]
useEffect(() => { useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY) let cancelled = false
if (cached) { setCanViewPage(null)
try { setVerificationError(null)
const profile = JSON.parse(cached) as EmployeeProfile
const allowed = profile.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
return
} catch {}
}
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me') apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
.then(({ employee }) => { .then(({ employee }) => {
if (cancelled) return
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee)) window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const allowed = employee.role === 'OWNER' const allowed = employee.role === 'OWNER'
setCanViewPage(allowed) setCanViewPage(allowed)
if (!allowed) router.replace('/') if (!allowed) router.replace('/')
}) })
.catch(() => { .catch((err: any) => {
if (cancelled) return
if (err?.statusCode === 401) {
router.replace('/sign-in?redirect=%2Fsubscription')
return
}
if (err?.statusCode === 403) {
setCanViewPage(false) setCanViewPage(false)
router.replace('/') return
}
setVerificationError(err?.message ?? copy.accessUnavailable)
}) })
}, [router])
return () => {
cancelled = true
}
}, [copy.accessUnavailable, router, verificationAttempt])
const fetchPlanData = useCallback(async () => { const fetchPlanData = useCallback(async () => {
const [prices, features] = await Promise.all([ const [prices, features] = await Promise.all([
@@ -285,8 +310,41 @@ export default function SubscriptionPage() {
} }
}, [canViewPage, fetchPlanData]) }, [canViewPage, fetchPlanData])
if (verificationError) {
return (
<div className="flex min-h-[40vh] items-center justify-center px-6">
<div className="card max-w-md p-6 text-center">
<h2 className="text-base font-semibold text-slate-900">{copy.accessUnavailable}</h2>
<p className="mt-2 text-sm text-slate-500">{verificationError}</p>
<button
type="button"
onClick={() => setVerificationAttempt((value) => value + 1)}
className="btn-primary mt-5"
>
{copy.retry}
</button>
</div>
</div>
)
}
if (canViewPage === null) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" aria-label={copy.verifying} />
</div>
)
}
if (canViewPage !== true) { if (canViewPage !== true) {
return null return (
<div className="flex min-h-[40vh] items-center justify-center px-6">
<div className="card max-w-md p-6 text-center">
<h2 className="text-base font-semibold text-slate-900">{copy.accessDenied}</h2>
<p className="mt-2 text-sm text-slate-500">{copy.accessDeniedBody}</p>
</div>
</div>
)
} }
async function handleCheckout() { async function handleCheckout() {
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { resolveDashboardRoutePolicy, roleCanAccessPolicy } from '@/lib/dashboardRoutePolicies'
import { flattenInternalRoutes, isAllowedRoute, resolveAccessRedirect } from './DashboardAccessGuard' import { flattenInternalRoutes, isAllowedRoute, resolveAccessRedirect } from './DashboardAccessGuard'
describe('DashboardAccessGuard route helpers', () => { describe('DashboardAccessGuard route helpers', () => {
@@ -38,4 +39,19 @@ describe('DashboardAccessGuard route helpers', () => {
expect(resolveAccessRedirect('/settings', ['/', '/fleet'])).toBe('/') expect(resolveAccessRedirect('/settings', ['/', '/fleet'])).toBe('/')
expect(resolveAccessRedirect('/fleet/123', ['/', '/fleet'])).toBeNull() 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)
})
}) })
@@ -4,6 +4,11 @@ import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths' import { toDashboardAppPath } from '@/lib/dashboardPaths'
import {
getDashboardFallbackRoute,
resolveDashboardRoutePolicy,
roleCanAccessPolicy,
} from '@/lib/dashboardRoutePolicies'
type GeneratedMenuItem = { type GeneratedMenuItem = {
id: string id: string
@@ -16,6 +21,12 @@ type EmployeeMenuResponse = {
items: GeneratedMenuItem[] items: GeneratedMenuItem[]
} }
type EmployeeProfileResponse = {
employee: {
role: string
}
}
export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] { export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] {
const routes: string[] = [] const routes: string[] = []
@@ -45,18 +56,40 @@ export function resolveAccessRedirect(currentPath: string, allowedRoutes: string
return fallbackRoute === currentPath ? null : fallbackRoute 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 }) { export default function DashboardAccessGuard({ children }: { children: React.ReactNode }) {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function enforceAccess() { async function enforceAccess() {
const currentPath = toDashboardAppPath(pathname)
const policy = resolveDashboardRoutePolicy(currentPath)
try { try {
setReady(false) setReady(false)
const currentPath = toDashboardAppPath(pathname) setError(null)
const { employee } = await apiFetch<EmployeeProfileResponse>('/auth/employee/me')
if (cancelled) return
if (!roleCanAccessPolicy(employee.role, policy)) {
const fallbackRoute = getDashboardFallbackRoute(employee.role)
if (fallbackRoute !== currentPath) {
router.replace(fallbackRoute)
return
}
}
if (policy.menuRegistrationRequired) {
const menu = await apiFetch<EmployeeMenuResponse>('/auth/employee/menu') const menu = await apiFetch<EmployeeMenuResponse>('/auth/employee/menu')
if (cancelled) return if (cancelled) return
@@ -67,18 +100,31 @@ export default function DashboardAccessGuard({ children }: { children: React.Rea
router.replace(redirectPath) router.replace(redirectPath)
return return
} }
}
setReady(true) setReady(true)
} catch (error: any) { } catch (error: any) {
if (cancelled) return if (cancelled) return
if (error?.statusCode === 401 || error?.statusCode === 403 || error?.statusCode === 402) { if (error?.statusCode === 401) {
router.replace('/') const target = buildSignInRedirect(currentPath)
if (target !== currentPath) router.replace(target)
return return
} }
// Do not deadlock the dashboard on transient network failures. if (error?.statusCode === 402) {
setReady(true) 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]) }, [pathname, router])
if (error) {
return (
<div className="flex min-h-[40vh] items-center justify-center px-6">
<div className="max-w-md rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{error}
</div>
</div>
)
}
if (!ready) { if (!ready) {
return ( return (
<div className="flex min-h-[40vh] items-center justify-center"> <div className="flex min-h-[40vh] items-center justify-center">
@@ -95,6 +95,10 @@ const NAV_ITEMS = [
{ href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' }, { href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' },
] as const ] 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) export const APPROVED_BASELINE_MENU_KEYS = NAV_ITEMS.map((item) => item.key)
const ICON_MAP = { const ICON_MAP = {
@@ -280,7 +284,30 @@ export default function Sidebar() {
})) }))
const useGeneratedMenu = menuLoadState === 'loaded' 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 { function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
return items.map((item) => { return items.map((item) => {
@@ -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<string, DashboardRoutePolicy> = {
'/': {
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 '/'
}
+1 -1
View File
@@ -218,7 +218,7 @@ After first sign-in, the user is directed to the **onboarding** page (`/onboardi
### Subscription ### 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.
--- ---
+1 -1
View File
@@ -84,7 +84,7 @@ Testimonials
└── 23 quote cards with avatar, name, company └── 23 quote cards with avatar, name, company
Pricing CTA Banner Pricing CTA Banner
└── "Start free for 90 days" with orange CTA └── "Start free for 30 days" with orange CTA
Footer Footer
``` ```
+1 -1
View File
@@ -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** | | `LISTABLE` | Company contact info + address | Creating/saving a fleet item or offer as **draft** |
| `PUBLISHABLE` | Legal identity block (legal name, legal form, RC, ICE, IF, license) | Publishing a listing live on the storefront | | `PUBLISHABLE` | Legal identity block (legal name, legal form, RC, ICE, IF, license) | Publishing a listing live on the storefront |
| `PAYABLE` | Payment provider config (AmanPay/PayPal) + responsible person | Accepting bookings/payments | | `PAYABLE` | Payment provider config (AmanPay/PayPal) + responsible person | Accepting bookings/payments |
| `SUBSCRIBED` | Plan + billing period selection | Past the 90-day trial, billed account | | `SUBSCRIBED` | Plan + billing period selection | Past the 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. 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.
+1 -1
View File
@@ -157,7 +157,7 @@ The table below describes the current route groups mounted in `app.ts`.
### Company and employee onboarding ### 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. - `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. - Employee auth supports login, forgot-password, reset-password, profile read, and language updates.
### Company profile and business configuration ### Company profile and business configuration
+538
View File
@@ -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.
@@ -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";
+6 -6
View File
@@ -22,16 +22,16 @@ export interface ApiError {
// Plan prices in smallest currency unit (MAD, in centimes) // Plan prices in smallest currency unit (MAD, in centimes)
export const PLAN_PRICES: Record<string, Record<string, Record<string, number>>> = { export const PLAN_PRICES: Record<string, Record<string, Record<string, number>>> = {
STARTER: { STARTER: {
MONTHLY: { MAD: 2990 }, MONTHLY: { MAD: 14900 },
ANNUAL: { MAD: 29900 }, ANNUAL: { MAD: 143040 },
}, },
GROWTH: { GROWTH: {
MONTHLY: { MAD: 3990 }, MONTHLY: { MAD: 29900 },
ANNUAL: { MAD: 39900 }, ANNUAL: { MAD: 287040 },
}, },
PRO: { PRO: {
MONTHLY: { MAD: 99900 }, MONTHLY: { MAD: 39900 },
ANNUAL: { MAD: 999000 }, ANNUAL: { MAD: 383040 },
}, },
} }
+6 -6
View File
@@ -142,7 +142,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = {
{ {
number: '1', number: '1',
title: 'Create Your Workspace', 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', number: '2',
@@ -157,7 +157,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = {
], ],
stepsTitle: 'How fleets launch on RentalDriveGo', stepsTitle: 'How fleets launch on RentalDriveGo',
steps: [ 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.'], ['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.'], ['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! })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
@@ -241,7 +241,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = {
{ {
number: '1', number: '1',
title: 'Créez votre espace', 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', number: '2',
@@ -256,7 +256,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = {
], ],
stepsTitle: 'Comment les flottes se lancent sur RentalDriveGo', stepsTitle: 'Comment les flottes se lancent sur RentalDriveGo',
steps: [ 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.'], ['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.'], ['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! })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
@@ -340,7 +340,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = {
{ {
number: '1', number: '1',
title: 'أنشئ مساحتك', title: 'أنشئ مساحتك',
description: 'سجّل وأطلق مساحة أسطولك مع تجربة مجانية لمدة 90 يوماً — بدون بطاقة ائتمان.', description: 'سجّل وأطلق مساحة أسطولك مع تجربة مجانية لمدة 30 يوماً — بدون بطاقة ائتمان.',
}, },
{ {
number: '2', number: '2',
@@ -355,7 +355,7 @@ export const defaultStorefrontHomepageContent: StorefrontHomepageConfig = {
], ],
stepsTitle: 'كيف تنطلق الأساطيل على RentalDriveGo', stepsTitle: 'كيف تنطلق الأساطيل على RentalDriveGo',
steps: [ steps: [
['1', 'أنشئ مساحة عمل أسطولك', 'اختر الخطة وابدأ تجربتك لمدة 90 يوماً وتحقق من حساب المشغل الخاص بك.'], ['1', 'أنشئ مساحة عمل أسطولك', 'اختر الخطة وابدأ تجربتك لمدة 30 يوماً وتحقق من حساب المشغل الخاص بك.'],
['2', 'انشر المركبات والعروض', 'أضف بيانات المركبات مرة واحدة وتحكم بدقة فيما يظهر على كل قناة.'], ['2', 'انشر المركبات والعروض', 'أضف بيانات المركبات مرة واحدة وتحكم بدقة فيما يظهر على كل قناة.'],
['3', 'استقبل الحجوزات بشروطك', 'يكتشف العملاء أسطولك على RentalDriveGo، ثم يكملون الحجز على صفحة الدفع الخاصة بك.'], ['3', 'استقبل الحجوزات بشروطك', 'يكتشف العملاء أسطولك على RentalDriveGo، ثم يكملون الحجز على صفحة الدفع الخاصة بك.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),