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
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:
@@ -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: 'S’abonner',
|
subscribe: 'S’abonner',
|
||||||
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 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.',
|
noInvoices: 'Aucune facture pour le moment.',
|
||||||
noProviderConfigured: 'Aucun prestataire de paiement n’est configuré. Contactez le support pour activer AmanPay ou PayPal.',
|
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é.',
|
providerUnavailable: 'Ce prestataire de paiement n’est 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 '/'
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ Testimonials
|
|||||||
└── 2–3 quote cards with avatar, name, company
|
└── 2–3 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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
+15
@@ -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";
|
||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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! })),
|
||||||
|
|||||||
Reference in New Issue
Block a user