fix: remove medium theme, fix navbar re-render, fix Node.js env loading
- Remove 'medium' theme from dashboard and marketplace; keep only light/dark - Marketplace: move public pages into (public)/ route group so header/footer persist across navigations without re-rendering (eliminates usePathname) - MarketplaceShell is now a pure context provider; chrome lives in (public)/layout.tsx which Next.js holds stable across navigations - WorkspaceFrame: remove history.replaceState and standalone-route logic - Docker API: bypass npm run dev to avoid node --env-file in containers - Add scripts/run-with-env-file.cjs for Node.js < 20.6 compatibility; update apps/api/package.json dev script to use it Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
type Currency = 'MAD' | 'USD' | 'EUR'
|
||||
type Billing = 'monthly' | 'annual'
|
||||
|
||||
const SYMBOL: Record<Currency, string> = { MAD: 'MAD', USD: '$', EUR: '€' }
|
||||
|
||||
const PRICES: Record<string, Record<Currency, { monthly: number; annual: number }>> = {
|
||||
STARTER: {
|
||||
MAD: { monthly: 299, annual: 2990 },
|
||||
USD: { monthly: 29, annual: 290 },
|
||||
EUR: { monthly: 27, annual: 270 },
|
||||
},
|
||||
GROWTH: {
|
||||
MAD: { monthly: 599, annual: 5990 },
|
||||
USD: { monthly: 59, annual: 590 },
|
||||
EUR: { monthly: 55, annual: 550 },
|
||||
},
|
||||
PRO: {
|
||||
MAD: { monthly: 999, annual: 9990 },
|
||||
USD: { monthly: 99, annual: 990 },
|
||||
EUR: { monthly: 92, annual: 920 },
|
||||
},
|
||||
}
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
key: 'STARTER',
|
||||
name: 'Starter',
|
||||
tagline: 'Launch your fleet online',
|
||||
features: [
|
||||
'Up to 10 vehicles',
|
||||
'1 user account',
|
||||
'Basic analytics',
|
||||
'Marketplace listing',
|
||||
],
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
key: 'GROWTH',
|
||||
name: 'Growth',
|
||||
tagline: 'Scale with confidence',
|
||||
features: [
|
||||
'Up to 50 vehicles',
|
||||
'5 user accounts',
|
||||
'Full analytics',
|
||||
'Priority marketplace placement',
|
||||
'Custom branding',
|
||||
],
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
key: 'PRO',
|
||||
name: 'Pro',
|
||||
tagline: 'Enterprise-grade power',
|
||||
features: [
|
||||
'Unlimited vehicles',
|
||||
'Unlimited user accounts',
|
||||
'Advanced reports',
|
||||
'API access',
|
||||
'Dedicated support',
|
||||
],
|
||||
highlight: false,
|
||||
},
|
||||
]
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
monthly: 'Monthly',
|
||||
annual: 'Annual',
|
||||
yearly: '/ year',
|
||||
youSave: 'You save',
|
||||
withAnnual: 'with annual billing — billed as one payment per year.',
|
||||
mostPopular: 'Most popular',
|
||||
perMonth: '/mo',
|
||||
billedAs: 'Billed as',
|
||||
getStarted: 'Get started',
|
||||
footer: 'All plans include a 14-day free trial. No credit card required.',
|
||||
},
|
||||
fr: {
|
||||
monthly: 'Mensuel',
|
||||
annual: 'Annuel',
|
||||
yearly: '/ an',
|
||||
youSave: 'Vous économisez',
|
||||
withAnnual: 'avec la facturation annuelle, prélevée en un seul paiement par an.',
|
||||
mostPopular: 'Le plus populaire',
|
||||
perMonth: '/mois',
|
||||
billedAs: 'Facturé à',
|
||||
getStarted: 'Commencer',
|
||||
footer: 'Toutes les formules incluent un essai gratuit de 14 jours. Aucune carte bancaire n’est requise.',
|
||||
},
|
||||
ar: {
|
||||
monthly: 'شهري',
|
||||
annual: 'سنوي',
|
||||
yearly: '/ سنة',
|
||||
youSave: 'توفر',
|
||||
withAnnual: 'مع الفوترة السنوية، تُسدد دفعة واحدة كل سنة.',
|
||||
mostPopular: 'الأكثر شيوعاً',
|
||||
perMonth: '/شهر',
|
||||
billedAs: 'يتم الفوترة بمبلغ',
|
||||
getStarted: 'ابدأ الآن',
|
||||
footer: 'تشمل جميع الخطط تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.',
|
||||
},
|
||||
} as const
|
||||
|
||||
function fmt(value: number, currency: Currency, billing: Billing): string {
|
||||
const sym = SYMBOL[currency]
|
||||
const amount = billing === 'annual' ? Math.round(value / 12) : value
|
||||
return currency === 'MAD' ? `${amount} ${sym}` : `${sym}${amount}`
|
||||
}
|
||||
|
||||
function annualSavingsPct(plan: string, currency: Currency): number {
|
||||
const { monthly, annual } = PRICES[plan][currency]
|
||||
const wouldPay = monthly * 12
|
||||
return Math.round(((wouldPay - annual) / wouldPay) * 100)
|
||||
}
|
||||
|
||||
export default function PricingClient() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
const [currency, setCurrency] = useState<Currency>('MAD')
|
||||
const [billing, setBilling] = useState<Billing>('monthly')
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard')
|
||||
|
||||
const savings = annualSavingsPct('STARTER', currency) // same % for all plans
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Toggles */}
|
||||
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
|
||||
{/* Billing toggle */}
|
||||
<div className="flex items-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm">
|
||||
{(['monthly', 'annual'] as Billing[]).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setBilling(b)}
|
||||
className={`relative rounded-full px-5 py-2 text-sm font-semibold transition-colors ${
|
||||
billing === b
|
||||
? 'bg-stone-900 text-white'
|
||||
: 'text-stone-600 hover:text-stone-900'
|
||||
}`}
|
||||
>
|
||||
{b === 'monthly' ? dict.monthly : dict.annual}
|
||||
{b === 'annual' && billing !== 'annual' && (
|
||||
<span className="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-700">
|
||||
-{savings}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Currency toggle */}
|
||||
<div className="flex items-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm">
|
||||
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCurrency(c)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
currency === c
|
||||
? 'bg-amber-500 text-white'
|
||||
: 'text-stone-600 hover:text-stone-900'
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{billing === 'annual' && (
|
||||
<p className="text-center text-sm text-amber-700 font-medium">
|
||||
{dict.youSave} {savings}% {dict.withAnnual}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{PLANS.map((plan) => {
|
||||
const price = PRICES[plan.key][currency]
|
||||
const displayPrice = billing === 'annual'
|
||||
? Math.round(price.annual / 12)
|
||||
: price.monthly
|
||||
const sym = SYMBOL[currency]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.key}
|
||||
className={`card relative flex flex-col overflow-hidden ${
|
||||
plan.highlight
|
||||
? 'border-amber-400 ring-2 ring-amber-400 ring-offset-2'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{plan.highlight && (
|
||||
<div className="bg-amber-500 py-1.5 text-center text-xs font-bold uppercase tracking-widest text-white">
|
||||
{dict.mostPopular}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-amber-700">
|
||||
{plan.name}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-500">{plan.tagline}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-end gap-1">
|
||||
<span className="text-4xl font-black text-stone-900">
|
||||
{currency === 'MAD' ? displayPrice : `${sym}${displayPrice}`}
|
||||
</span>
|
||||
{currency === 'MAD' && (
|
||||
<span className="mb-1 text-lg font-semibold text-stone-500">{sym}</span>
|
||||
)}
|
||||
<span className="mb-1 text-sm text-stone-400">{dict.perMonth}</span>
|
||||
</div>
|
||||
{billing === 'annual' && (
|
||||
<p className="mt-1 text-xs text-stone-400">
|
||||
{dict.billedAs}{' '}
|
||||
{currency === 'MAD'
|
||||
? `${price.annual} ${sym}`
|
||||
: `${sym}${price.annual}`}{' '}
|
||||
{dict.yearly}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 flex-1 space-y-3">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2.5 text-sm text-stone-700">
|
||||
<svg
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-amber-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}¤cy=${currency}`}
|
||||
className={`mt-10 block rounded-full px-6 py-3 text-center text-sm font-semibold transition-colors ${
|
||||
plan.highlight
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-stone-900 text-white hover:bg-stone-700'
|
||||
}`}
|
||||
>
|
||||
{dict.getStarted}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<p className="text-center text-sm text-stone-400">{dict.footer}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user