256ff0814e
Build & Deploy / Build & Push Docker Image (push) Failing after 44s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m0s
Test / Marketplace Unit Tests (push) Failing after 4m51s
Test / Admin Unit Tests (push) Successful in 9m31s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
- Remove apps/marketplace entirely - Create apps/homepage (port 3000): landing/marketing pages (home, features, pricing, platform-ops, review, legal) - Create apps/storefront (port 3004): vehicle browsing (explore) and renter account (dashboard, profile, notifications) - Duplicate shared components/libs into each app - Update homepage header nav: Home, Features, Pricing instead of Home, Explore - Fix all /explore cross-references in homepage to point to /features - Update docker-compose.dev.yml: new homepage and storefront services, remove marketplace - Update docker-compose.production.yml: split into homepage (main domain) and storefront (path-based routes) - Update Dockerfiles: EXPOSE 3004 instead of 3003 - Update root package.json scripts: homepage/storefront profiles, tests, prod scripts - Add docker-prod-up-homepage.sh and docker-prod-up-storefront.sh, remove marketplace script
227 lines
8.5 KiB
TypeScript
227 lines
8.5 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { getCurrencyLabel, PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
|
|
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
|
import { marketplaceFetchOrDefault } from '@/lib/api'
|
|
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
|
|
|
type Billing = 'monthly' | 'annual'
|
|
|
|
type PricingMatrix = Record<string, Record<string, Record<string, number>>>
|
|
type PlanFeatureMap = Record<string, string[]>
|
|
|
|
type PlatformPricing = {
|
|
prices: PricingMatrix
|
|
planFeatures: PlanFeatureMap
|
|
}
|
|
|
|
export const DEFAULT_PRICING: PlatformPricing = {
|
|
prices: PLAN_PRICES,
|
|
planFeatures: PLAN_FEATURES,
|
|
}
|
|
|
|
const PLANS = [
|
|
{
|
|
key: 'STARTER',
|
|
name: 'Starter',
|
|
tagline: 'Launch your fleet online',
|
|
highlight: false,
|
|
},
|
|
{
|
|
key: 'GROWTH',
|
|
name: 'Growth',
|
|
tagline: 'Scale with confidence',
|
|
highlight: true,
|
|
},
|
|
{
|
|
key: 'PRO',
|
|
name: 'Pro',
|
|
tagline: 'Enterprise-grade power',
|
|
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 90-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 90 jours. Aucune carte bancaire n'est requise.",
|
|
},
|
|
ar: {
|
|
monthly: 'شهري',
|
|
annual: 'سنوي',
|
|
yearly: '/ سنة',
|
|
youSave: 'توفر',
|
|
withAnnual: 'مع الفوترة السنوية، تُسدد دفعة واحدة كل سنة.',
|
|
mostPopular: 'الأكثر شيوعاً',
|
|
perMonth: '/شهر',
|
|
billedAs: 'يتم الفوترة بمبلغ',
|
|
getStarted: 'ابدأ الآن',
|
|
footer: 'تشمل جميع الخطط تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.',
|
|
},
|
|
} as const
|
|
|
|
function getPlanPrice(prices: PricingMatrix, plan: string, billingPeriod: 'MONTHLY' | 'ANNUAL') {
|
|
return (prices[plan]?.[billingPeriod]?.MAD ?? PLAN_PRICES[plan]?.[billingPeriod]?.MAD ?? 0) / 100
|
|
}
|
|
|
|
function annualSavingsPct(prices: PricingMatrix, plan: string): number {
|
|
const monthly = getPlanPrice(prices, plan, 'MONTHLY')
|
|
const annual = getPlanPrice(prices, plan, 'ANNUAL')
|
|
const wouldPay = monthly * 12
|
|
if (wouldPay <= 0) return 0
|
|
return Math.max(0, Math.round(((wouldPay - annual) / wouldPay) * 100))
|
|
}
|
|
|
|
export default function PricingClient({ initialPricing = DEFAULT_PRICING }: { initialPricing?: PlatformPricing }) {
|
|
const { language } = useMarketplacePreferences()
|
|
const currencyLabel = getCurrencyLabel(language)
|
|
const dict = copy[language]
|
|
const [billing, setBilling] = useState<Billing>('monthly')
|
|
const [pricing, setPricing] = useState<PlatformPricing>(initialPricing)
|
|
|
|
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
|
|
|
useEffect(() => {
|
|
marketplaceFetchOrDefault<PlatformPricing>('/site/platform/pricing', DEFAULT_PRICING)
|
|
.then(setPricing)
|
|
}, [])
|
|
|
|
const savings = annualSavingsPct(pricing.prices, 'STARTER')
|
|
|
|
return (
|
|
<div className="space-y-10">
|
|
{/* Billing toggle */}
|
|
<div className="flex items-center justify-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm dark:border-blue-800 dark:bg-blue-950">
|
|
{(['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-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
|
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100'
|
|
}`}
|
|
>
|
|
{b === 'monthly' ? dict.monthly : dict.annual}
|
|
{b === 'annual' && billing !== 'annual' && (
|
|
<span className="ml-2 rounded-full bg-orange-100 px-2 py-0.5 text-[10px] font-bold text-orange-700 dark:bg-orange-950/50 dark:text-orange-400">
|
|
-{savings}%
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{billing === 'annual' && (
|
|
<p className="text-center text-sm font-medium text-orange-700 dark:text-orange-400">
|
|
{dict.youSave} {savings}% {dict.withAnnual}
|
|
</p>
|
|
)}
|
|
|
|
{/* Plan cards */}
|
|
<div className="grid gap-6 md:grid-cols-3">
|
|
{PLANS.map((plan) => {
|
|
const monthlyPrice = getPlanPrice(pricing.prices, plan.key, 'MONTHLY')
|
|
const annualPrice = getPlanPrice(pricing.prices, plan.key, 'ANNUAL')
|
|
const displayPrice = billing === 'annual'
|
|
? Math.round(annualPrice / 12)
|
|
: monthlyPrice
|
|
const features = pricing.planFeatures[plan.key] ?? PLAN_FEATURES[plan.key] ?? []
|
|
|
|
return (
|
|
<div
|
|
key={plan.key}
|
|
className={`card relative flex flex-col overflow-hidden ${
|
|
plan.highlight
|
|
? 'border-orange-400 ring-2 ring-orange-400 ring-offset-2 dark:ring-offset-blue-950'
|
|
: ''
|
|
}`}
|
|
>
|
|
{plan.highlight && (
|
|
<div className="bg-orange-500 dark:bg-orange-400 py-1.5 text-center text-xs font-bold uppercase tracking-widest text-white dark:text-stone-900">
|
|
{dict.mostPopular}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-1 flex-col p-8">
|
|
<p className="text-xs font-bold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-400">
|
|
{plan.name}
|
|
</p>
|
|
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{plan.tagline}</p>
|
|
|
|
<div className="mt-6">
|
|
<div className="flex items-end gap-1">
|
|
<span className="text-4xl font-black text-stone-900 dark:text-stone-100">
|
|
{displayPrice}
|
|
</span>
|
|
<span className="mb-1 text-lg font-semibold text-stone-500 dark:text-stone-400">{currencyLabel}</span>
|
|
<span className="mb-1 text-sm text-stone-400 dark:text-stone-500">{dict.perMonth}</span>
|
|
</div>
|
|
{billing === 'annual' && (
|
|
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
|
|
{dict.billedAs} {annualPrice} {currencyLabel} {dict.yearly}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<ul className="mt-8 flex-1 space-y-3">
|
|
{features.map((f) => (
|
|
<li key={f} className="flex items-start gap-2.5 text-sm text-stone-700 dark:text-stone-300">
|
|
<svg
|
|
className="mt-0.5 h-4 w-4 shrink-0 text-orange-500 dark:text-orange-400"
|
|
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>
|
|
|
|
<a
|
|
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}¤cy=MAD`}
|
|
className={`mt-10 block rounded-full px-6 py-3 text-center text-sm font-semibold transition-colors ${
|
|
plan.highlight
|
|
? 'bg-orange-500 text-white hover:bg-orange-600 dark:bg-orange-400 dark:text-stone-900 dark:hover:bg-orange-300'
|
|
: 'bg-blue-900 text-white hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400'
|
|
}`}
|
|
>
|
|
{dict.getStarted}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Footer note */}
|
|
<p className="text-center text-sm text-stone-400 dark:text-stone-500">{dict.footer}</p>
|
|
</div>
|
|
)
|
|
}
|