fix signin signout and apply the new style

This commit is contained in:
root
2026-05-24 23:58:54 -04:00
parent bf97a072dd
commit 9bd0938951
68 changed files with 851 additions and 200 deletions
@@ -0,0 +1,543 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
type Currency = 'MAD' | 'USD' | 'EUR'
interface Subscription {
id: string
plan: Plan
billingPeriod: BillingPeriod
status: string
currency: Currency
trialEndAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
}
interface Invoice {
id: string
amount: number
currency: Currency
status: string
paymentProvider: string
paidAt: string | null
createdAt: string
}
interface ProviderAvailability {
amanpay: boolean
paypal: boolean
}
interface EmployeeProfile {
role?: string
}
const STATUS_BADGE: Record<string, string> = {
TRIALING: 'bg-sky-100 text-sky-700',
ACTIVE: 'bg-green-100 text-green-700',
PAST_DUE: 'bg-orange-100 text-orange-700',
CANCELLED: 'bg-slate-100 text-slate-600',
UNPAID: 'bg-red-100 text-red-700',
}
const INVOICE_STATUS: Record<string, string> = {
PAID: 'bg-green-100 text-green-700',
PENDING: 'bg-orange-100 text-orange-700',
FAILED: 'bg-red-100 text-red-700',
REFUNDED: 'bg-slate-100 text-slate-600',
}
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
export default function SubscriptionPage() {
const router = useRouter()
const { language } = useDashboardI18n()
const [subscription, setSubscription] = useState<Subscription | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [canViewPage, setCanViewPage] = useState<boolean | null>(null)
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const [currency, setCurrency] = useState<Currency>('MAD')
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ amanpay: false, paypal: false })
const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false)
const copy = {
en: {
title: 'Subscription',
subtitle: 'Manage your plan, payment provider, and subscription invoices.',
trial: 'Free trial',
remaining: 'remaining. Subscribe before it ends to keep access.',
currentPlan: 'Current plan',
renews: 'renews',
cancelScheduled: 'Cancellation scheduled at end of billing period.',
undo: 'Undo',
cancelling: 'Cancelling…',
cancelPlan: 'Cancel plan',
changePlan: 'Change plan',
subscribe: 'Subscribe',
selectPlan: 'Select a plan and payment provider to proceed.',
monthly: 'Monthly',
annual: 'Annual (save ~17%)',
active: 'Active',
perMonthShort: 'mo',
perYearShort: 'yr',
paymentProvider: 'Payment provider',
total: 'Total',
perMonth: 'month',
perYear: 'year',
redirecting: 'Redirecting…',
subscribeNow: 'Subscribe now',
invoiceHistory: 'Invoice history',
date: 'Date',
provider: 'Provider',
status: 'Status',
paid: 'Paid',
amount: 'Amount',
loading: 'Loading…',
noInvoices: 'No invoices yet.',
noProviderConfigured: 'No payment provider is configured. Contact support to enable AmanPay or PayPal.',
providerUnavailable: 'This payment provider is not configured.',
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
planFeatures: {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
} as Record<Plan, string[]>,
},
fr: {
title: 'Abonnement',
subtitle: 'Gérez votre plan, le prestataire de paiement et les factures dabonnement.',
trial: 'Essai gratuit',
remaining: 'restants. Abonnez-vous avant la fin pour garder laccès.',
currentPlan: 'Plan actuel',
renews: 'renouvelle le',
cancelScheduled: 'Annulation programmée à la fin de la période.',
undo: 'Annuler',
cancelling: 'Annulation…',
cancelPlan: 'Annuler le plan',
changePlan: 'Changer de plan',
subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan et un prestataire de paiement.',
monthly: 'Mensuel',
annual: 'Annuel (économie ~17%)',
active: 'Actif',
perMonthShort: 'mois',
perYearShort: 'an',
paymentProvider: 'Prestataire de paiement',
total: 'Total',
perMonth: 'mois',
perYear: 'an',
redirecting: 'Redirection…',
subscribeNow: 'Sabonner maintenant',
invoiceHistory: 'Historique des factures',
date: 'Date',
provider: 'Prestataire',
status: 'Statut',
paid: 'Payé',
amount: 'Montant',
loading: 'Chargement…',
noInvoices: 'Aucune facture pour le moment.',
noProviderConfigured: 'Aucun prestataire de paiement nest configuré. Contactez le support pour activer AmanPay ou PayPal.',
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>,
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
planFeatures: {
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence marketplace'],
GROWTH: ['Jusqu’à 50 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'],
PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
} as Record<Plan, string[]>,
},
ar: {
title: 'الاشتراك',
subtitle: 'إدارة الخطة ومزوّد الدفع وفواتير الاشتراك.',
trial: 'تجربة مجانية',
remaining: 'متبقية. اشترك قبل انتهائها للحفاظ على الوصول.',
currentPlan: 'الخطة الحالية',
renews: 'يتجدد في',
cancelScheduled: 'تمت جدولة الإلغاء عند نهاية فترة الفوترة.',
undo: 'تراجع',
cancelling: 'جارٍ الإلغاء…',
cancelPlan: 'إلغاء الخطة',
changePlan: 'تغيير الخطة',
subscribe: 'اشتراك',
selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.',
monthly: 'شهري',
annual: 'سنوي (توفير ~17%)',
active: 'نشط',
perMonthShort: 'شهر',
perYearShort: 'سنة',
paymentProvider: 'مزوّد الدفع',
total: 'الإجمالي',
perMonth: 'شهر',
perYear: 'سنة',
redirecting: 'جارٍ التحويل…',
subscribeNow: 'اشترك الآن',
invoiceHistory: 'سجل الفواتير',
date: 'التاريخ',
provider: 'المزوّد',
status: 'الحالة',
paid: 'مدفوع',
amount: 'المبلغ',
loading: 'جارٍ التحميل…',
noInvoices: 'لا توجد فواتير حتى الآن.',
noProviderConfigured: 'لا يوجد مزوّد دفع مهيأ. تواصل مع الدعم لتفعيل AmanPay أو PayPal.',
providerUnavailable: 'مزوّد الدفع هذا غير مهيأ.',
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
planFeatures: {
STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
GROWTH: ['حتى 50 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'],
PRO: ['مركبات غير محدودة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'],
} as Record<Plan, string[]>,
},
}[language]
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as EmployeeProfile
const allowed = profile.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
return
} catch {}
}
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
.then(({ employee }) => {
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const allowed = employee.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
})
.catch(() => {
setCanViewPage(false)
router.replace('/')
})
}, [router])
useEffect(() => {
if (canViewPage !== true) return
Promise.all([
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<ProviderAvailability>('/subscriptions/providers'),
])
.then(([sub, inv, availability]) => {
setProviderAvailability(availability)
if (availability.amanpay) setProvider('AMANPAY')
else if (availability.paypal) setProvider('PAYPAL')
if (sub) {
setSubscription(sub)
setSelectedPlan(sub.plan)
setBillingPeriod(sub.billingPeriod)
setCurrency(sub.currency as Currency)
}
setInvoices(inv ?? [])
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [canViewPage])
if (canViewPage !== true) {
return null
}
async function handleCheckout() {
setPaying(true)
setError(null)
try {
if (provider === 'AMANPAY' && !providerAvailability.amanpay) throw new Error(copy.providerUnavailable)
if (provider === 'PAYPAL' && !providerAvailability.paypal) throw new Error(copy.providerUnavailable)
const currentUrl = new URL(window.location.href)
currentUrl.search = ''
currentUrl.hash = ''
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
method: 'POST',
body: JSON.stringify({
plan: selectedPlan,
billingPeriod,
currency,
provider,
successUrl: `${currentUrl.toString()}?payment=success`,
failureUrl: `${currentUrl.toString()}?payment=failed`,
}),
})
window.location.href = result.checkoutUrl
} catch (err: any) {
setError(err.message)
setPaying(false)
}
}
async function handleCancel() {
setCancelling(true)
setError(null)
try {
const sub = await apiFetch<Subscription>('/subscriptions/cancel', { method: 'POST' })
setSubscription(sub)
} catch (err: any) {
setError(err.message)
} finally {
setCancelling(false)
}
}
async function handleResume() {
setCancelling(true)
setError(null)
try {
const sub = await apiFetch<Subscription>('/subscriptions/resume', { method: 'POST' })
setSubscription(sub)
} catch (err: any) {
setError(err.message)
} finally {
setCancelling(false)
}
}
const planPrice = PLAN_PRICES[selectedPlan]?.[billingPeriod]?.[currency]
const daysLeft = subscription?.trialEndAt
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
: null
return (
<div className="space-y-8">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
{error && (
<div className="card p-4 border-red-200 bg-red-50 text-sm text-red-700">{error}</div>
)}
{/* Trial banner */}
{subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (
<div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between">
<p className="text-sm font-medium text-sky-800">
{copy.trial} <strong>{daysLeft} days</strong> {copy.remaining}
</p>
</div>
)}
{/* Current plan */}
{subscription && (
<div className="card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.currentPlan}</p>
<div className="mt-1 flex items-center gap-3">
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.statusLabels[subscription.status] ?? subscription.status}
</span>
</div>
<p className="mt-1 text-sm text-slate-500">
{subscription.billingPeriod} · {subscription.currency}
{subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
</p>
{subscription.cancelAtPeriodEnd && (
<p className="mt-2 text-sm font-medium text-orange-700">
{copy.cancelScheduled}{' '}
<button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button>
</p>
)}
</div>
{!subscription.cancelAtPeriodEnd && (
<button
onClick={handleCancel}
disabled={cancelling}
className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm"
>
{cancelling ? copy.cancelling : copy.cancelPlan}
</button>
)}
</div>
</div>
)}
{/* Plan selector + checkout */}
<div className="card p-6 space-y-6">
{!providerAvailability.amanpay && !providerAvailability.paypal ? (
<div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700">
{copy.noProviderConfigured}
</div>
) : null}
<div>
<h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
</h3>
<p className="mt-1 text-sm text-slate-500">{copy.selectPlan}</p>
</div>
{/* Billing period toggle */}
<div className="flex items-center gap-2">
{(['MONTHLY', 'ANNUAL'] as BillingPeriod[]).map((p) => (
<button
key={p}
onClick={() => setBillingPeriod(p)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
billingPeriod === p ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{p === 'MONTHLY' ? copy.monthly : copy.annual}
</button>
))}
</div>
{/* Currency selector */}
<div className="flex items-center gap-2">
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
<button
key={c}
onClick={() => setCurrency(c)}
className={`px-3 py-1 rounded-lg text-sm font-medium border transition-colors ${
currency === c ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{c}
</button>
))}
</div>
{/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3">
{PLANS.map((plan) => {
const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency]
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
return (
<button
key={plan}
onClick={() => setSelectedPlan(plan)}
className={`text-left p-5 rounded-xl border-2 transition-all ${
selectedPlan === plan
? 'border-blue-500 bg-blue-50/50'
: 'border-slate-200 hover:border-slate-300 bg-white'
} ${isActive ? 'ring-2 ring-green-200' : ''}`}
>
<div className="flex items-center justify-between">
<p className="font-semibold text-slate-900">{plan}</p>
{isActive && <span className="badge-green">{copy.active}</span>}
</div>
<p className="mt-2 text-2xl font-black text-slate-900">
{price ? formatCurrency(price, currency) : '—'}
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p>
<ul className="mt-3 space-y-1">
{copy.planFeatures[plan].map((f) => (
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f}
</li>
))}
</ul>
</button>
)
})}
</div>
{/* Provider selector */}
<div>
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
<div className="flex gap-3">
{providerAvailability.amanpay ? (
<button
onClick={() => setProvider('AMANPAY')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
provider === 'AMANPAY' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
🏦 AmanPay
</button>
) : null}
{providerAvailability.paypal ? (
<button
onClick={() => setProvider('PAYPAL')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
provider === 'PAYPAL' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
🔵 PayPal
</button>
) : null}
</div>
</div>
{/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
<p className="text-sm text-slate-500">{copy.total}</p>
<p className="text-xl font-black text-slate-900">
{planPrice ? formatCurrency(planPrice, currency) : '—'}
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
</p>
</div>
<button
onClick={handleCheckout}
disabled={paying || loading || (!providerAvailability.amanpay && !providerAvailability.paypal)}
className="btn-primary px-8 py-3"
>
{paying ? copy.redirecting : subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow}
</button>
</div>
</div>
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-base font-semibold text-slate-900">{copy.invoiceHistory}</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.date}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paid}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.amount}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
) : invoices.map((inv) => (
<tr key={inv.id}>
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.invoiceStatusLabels[inv.status] ?? inv.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
{formatCurrency(inv.amount, inv.currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}