feat(landing,billing): public pricing, global discount, services bar & estimator

Backend
- Add platform-wide global discount (platform_settings: global_discount_percent),
  applied centrally in PricingCatalogService.computeTotalsFromDb so it reaches
  every real charge (previews, deploys, renewals, upgrades, invoices). Admin
  GET/PATCH /billing/settings/global-discount.
- Add unauthenticated PublicPricingController (catalog + calculate) for the
  public landing page, returning gross/net and the discount percentage.
- Bill application replicas by the user-selected footprint: app CPU/RAM/storage
  now all scale by replica count; the single-replica database stays unscaled.

Frontend
- Landing: Services bar (PaaS active, DBaaS, KaaS/LaaS "coming soon" with
  expandable runtime/database menus), transparent Pricing section (per-resource
  rate cards with cycle toggle + discount strikethrough), and a cost Estimator
  ("estimate your package").
- Optional services and the database are priced like runtimes: the estimator
  lets users pick their CPU/RAM/storage (and DB type) so the cost scales by need.
- Admin billing: global-discount editor.
- i18n: fa/en strings for services, pricing, estimator and global discount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-22 23:44:30 +03:30
parent a58142cc4a
commit f7974dd382
13 changed files with 1226 additions and 10 deletions
@@ -7,9 +7,12 @@ import Lenis from 'lenis';
import { setScroll, setPointer, flashState } from './scroll-store';
import { SiteHeader } from './sections/SiteHeader';
import { Hero } from './sections/Hero';
import { ServicesBar } from './sections/ServicesBar';
import { Value } from './sections/Value';
import { Features } from './sections/Features';
import { HowItWorks } from './sections/HowItWorks';
import { Pricing } from './sections/Pricing';
import { Estimator } from './sections/Estimator';
import { Trust } from './sections/Trust';
import { FinalCta } from './sections/FinalCta';
import { Footer } from './sections/Footer';
@@ -94,9 +97,12 @@ export function LandingPage() {
<div className="relative z-10">
<SiteHeader />
<Hero />
<ServicesBar />
<Value />
<Features />
<HowItWorks />
<Pricing />
<Estimator />
<Trust />
<FinalCta />
<Footer />
@@ -0,0 +1,419 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Sparkles } from 'lucide-react';
import api from '@/lib/api';
import { Link } from '@/i18n/Link';
import { useT, useLocale } from '@/i18n/I18nProvider';
import type { PricingCatalog, BillingCycle } from '@/types';
import { Reveal } from '../Reveal';
type PublicCatalog = PricingCatalog & { globalDiscountPercent: number };
type Cycle = BillingCycle;
interface CalcResult {
gross: { hourly: number; monthly: number; yearly: number };
net: { hourly: number; monthly: number; yearly: number };
globalDiscountPercent: number;
}
const CPU_OPTIONS = [0.5, 1, 2, 4];
const MEM_OPTIONS = [0.5, 1, 2, 4, 8];
const STORAGE_OPTIONS = [1, 5, 10, 20, 50];
const REPLICA_OPTIONS = [1, 2, 3];
const CYCLES: Cycle[] = ['hourly', 'monthly', 'yearly'];
// Optional services are priced like runtimes (per resource), so the estimator
// lets the user pick their resources too. Presets kept small/sane for a marketing UI.
const SVC_CPU_OPTIONS = [0.1, 0.25, 0.5, 1];
const SVC_MEM_OPTIONS = [0.25, 0.5, 1, 2];
const SVC_STORAGE_OPTIONS = [1, 2, 5, 10];
// Database is also priced by chosen resources (its CPU/RAM bill at the runtime
// rates, plus a flat database add-on). Tech names stay in code.
const DB_TYPES = [
{ value: 'postgresql', label: 'PostgreSQL' },
{ value: 'mysql', label: 'MySQL' },
{ value: 'mariadb', label: 'MariaDB' },
{ value: 'mongodb', label: 'MongoDB' },
];
const DB_STORAGE_OPTIONS = [1, 5, 10, 20];
/** Resources a user can pick for an optional service (Redis / RabbitMQ). */
interface SvcRes {
cpu: number; // cores
mem: number; // GB
storage: number; // GB
}
function toCpuLimit(cores: number): string {
return cores < 1 ? `${Math.round(cores * 1000)}m` : String(cores);
}
export function Estimator() {
const e = useT().landing.estimator;
const locale = useLocale();
const [runtime, setRuntime] = useState('');
const [cpu, setCpu] = useState(1);
const [memGb, setMemGb] = useState(1);
const [storageGb, setStorageGb] = useState(5);
const [replicas, setReplicas] = useState(1);
const [database, setDatabase] = useState(false);
const [redis, setRedis] = useState(false);
const [rabbitmq, setRabbitmq] = useState(false);
const [elasticsearch, setElasticsearch] = useState(false);
const [customDomain, setCustomDomain] = useState(false);
const [cycle, setCycle] = useState<Cycle>('monthly');
// Per-service resources (defaults aligned with the platform deploy specs).
const [redisRes, setRedisRes] = useState<SvcRes>({ cpu: 0.25, mem: 0.25, storage: 1 });
const [rabbitRes, setRabbitRes] = useState<SvcRes>({ cpu: 0.5, mem: 0.5, storage: 2 });
const [dbType, setDbType] = useState('postgresql');
const [dbRes, setDbRes] = useState<SvcRes>({ cpu: 0.5, mem: 0.5, storage: 1 });
const fmt = (n: number) => Math.round(n).toLocaleString(locale === 'fa-IR' ? 'fa-IR' : 'en-US');
const num = (n: number) => n.toLocaleString(locale === 'fa-IR' ? 'fa-IR' : 'en-US');
const { data: catalog } = useQuery<PublicCatalog>({
queryKey: ['public-pricing-catalog'],
queryFn: () => api.get('/public/pricing/catalog').then((r) => r.data),
});
const runtimeOptions = catalog?.runtimeOptions ?? [];
const activeRuntime = runtime || runtimeOptions[0]?.value || 'nodejs';
const svcResources = (r: SvcRes) => ({
cpuLimit: toCpuLimit(r.cpu),
memoryLimit: `${r.mem}Gi`,
storageGi: r.storage,
});
const config = {
runtime: activeRuntime,
databaseType: database ? dbType : 'none',
cpuLimit: String(cpu),
memoryLimit: `${memGb}Gi`,
appStorageSize: `${storageGb}Gi`,
dbStorageSize: database ? `${dbRes.storage}Gi` : undefined,
replicas,
enableRedis: redis,
enableRabbitmq: rabbitmq,
enableElasticsearch: elasticsearch,
enableCustomDomain: customDomain,
databaseResources: database ? svcResources(dbRes) : undefined,
redisResources: redis ? svcResources(redisRes) : undefined,
rabbitmqResources: rabbitmq ? svcResources(rabbitRes) : undefined,
};
const { data: result, isFetching, isError } = useQuery<CalcResult>({
queryKey: ['public-pricing-calc', config],
queryFn: () => api.post('/public/pricing/calculate', config).then((r) => r.data),
enabled: !!catalog,
placeholderData: (prev) => prev,
});
const discount = result?.globalDiscountPercent ?? catalog?.globalDiscountPercent ?? 0;
const net = result ? result.net[cycle] : 0;
const gross = result ? result.gross[cycle] : 0;
const chip = (active: boolean) =>
`rounded-lg px-3 py-1.5 text-sm font-semibold backdrop-blur-md transition ${
active
? 'bg-primary-600 text-white shadow-lg shadow-primary-600/30'
: 'bg-white/10 text-white/80 ring-1 ring-white/15 hover:bg-white/20'
}`;
const toggle = (active: boolean) =>
`flex items-center justify-between gap-2 rounded-xl px-4 py-3 text-sm font-semibold backdrop-blur-md transition ${
active
? 'bg-primary-600/90 text-white ring-1 ring-primary-300/50'
: 'bg-white/10 text-white/80 ring-1 ring-white/15 hover:bg-white/20'
}`;
return (
<section id="estimator" className="relative px-6 py-28">
<div className="mx-auto max-w-5xl">
<Reveal className="mb-12 flex justify-center">
<div className="abrban-panel rounded-3xl px-8 py-7 text-center">
<h2 className="abrban-ink flex items-center justify-center gap-2 text-4xl font-bold text-white">
<Sparkles className="h-7 w-7 text-primary-200" />
{e.title}
</h2>
<p className="abrban-ink mt-4 text-lg text-white/85">{e.subtitle}</p>
</div>
</Reveal>
<Reveal>
<div className="abrban-panel grid gap-8 rounded-3xl p-6 sm:p-9 lg:grid-cols-5">
{/* Selectors */}
<div className="space-y-6 lg:col-span-3">
{runtimeOptions.length > 0 && (
<Field label={e.runtime}>
<div className="flex flex-wrap gap-2">
{runtimeOptions.map((opt) => (
<button key={opt.value} type="button" onClick={() => setRuntime(opt.value)} className={chip(activeRuntime === opt.value)}>
{opt.label}
</button>
))}
</div>
</Field>
)}
<Field label={e.cpu}>
<div className="flex flex-wrap gap-2">
{CPU_OPTIONS.map((c) => (
<button key={c} type="button" onClick={() => setCpu(c)} className={chip(cpu === c)}>
{e.cores.replace('{n}', num(c))}
</button>
))}
</div>
</Field>
<Field label={e.memory}>
<div className="flex flex-wrap gap-2">
{MEM_OPTIONS.map((m) => (
<button key={m} type="button" onClick={() => setMemGb(m)} className={chip(memGb === m)}>
{e.gb.replace('{n}', num(m))}
</button>
))}
</div>
</Field>
<Field label={e.storage}>
<div className="flex flex-wrap gap-2">
{STORAGE_OPTIONS.map((s) => (
<button key={s} type="button" onClick={() => setStorageGb(s)} className={chip(storageGb === s)}>
{e.gb.replace('{n}', num(s))}
</button>
))}
</div>
</Field>
<Field label={e.replicas}>
<div className="flex flex-wrap gap-2">
{REPLICA_OPTIONS.map((r) => (
<button key={r} type="button" onClick={() => setReplicas(r)} className={chip(replicas === r)}>
{num(r)}
</button>
))}
</div>
</Field>
<Field label={e.options}>
<div className="grid gap-2 sm:grid-cols-2">
<button type="button" onClick={() => setDatabase((v) => !v)} className={toggle(database)}>
{e.database}<span>{database ? '✓' : '+'}</span>
</button>
<button type="button" onClick={() => setRedis((v) => !v)} className={toggle(redis)}>
{e.redis}<span>{redis ? '✓' : '+'}</span>
</button>
<button type="button" onClick={() => setRabbitmq((v) => !v)} className={toggle(rabbitmq)}>
{e.rabbitmq}<span>{rabbitmq ? '✓' : '+'}</span>
</button>
<button type="button" onClick={() => setElasticsearch((v) => !v)} className={toggle(elasticsearch)}>
{e.elasticsearch}<span>{elasticsearch ? '✓' : '+'}</span>
</button>
<button type="button" onClick={() => setCustomDomain((v) => !v)} className={toggle(customDomain)}>
{e.customDomain}<span>{customDomain ? '✓' : '+'}</span>
</button>
</div>
{/* Database and optional services are billed per chosen resources, like runtimes. */}
{database && (
<DbResourceConfig
dbType={dbType}
setDbType={setDbType}
res={dbRes}
setRes={setDbRes}
e={e}
num={num}
chip={chip}
/>
)}
{redis && (
<SvcResourceConfig title={e.redis} res={redisRes} setRes={setRedisRes} e={e} num={num} chip={chip} />
)}
{rabbitmq && (
<SvcResourceConfig title={e.rabbitmq} res={rabbitRes} setRes={setRabbitRes} e={e} num={num} chip={chip} />
)}
</Field>
</div>
{/* Result panel */}
<div className="lg:col-span-2">
<div className="sticky top-24 rounded-2xl bg-slate-950/40 p-6 ring-1 ring-white/10 backdrop-blur-md">
<div className="inline-flex rounded-xl bg-slate-950/40 p-1 ring-1 ring-white/10">
{CYCLES.map((c) => (
<button
key={c}
type="button"
onClick={() => setCycle(c)}
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${
cycle === c ? 'bg-primary-600 text-white' : 'text-white/75 hover:text-white'
}`}
>
{e.cycle[c]}
</button>
))}
</div>
<p className="abrban-ink mt-6 text-sm text-white/70">{e.estimatedTitle}</p>
{isError ? (
<p className="mt-3 text-sm text-rose-200">{e.error}</p>
) : (
<>
<div className="mt-1 flex items-end gap-2">
<span className="abrban-ink text-4xl font-black text-white">
{isFetching && !result ? '…' : fmt(net)}
</span>
<span className="mb-1.5 text-sm text-white/70">
{e.currency} {e.perCycle[cycle]}
</span>
</div>
{discount > 0 && (
<div className="mt-2 space-y-1">
<div className="text-sm text-white/55 line-through">
{fmt(gross)} {e.currency}
</div>
<span className="inline-flex rounded-full bg-emerald-500/25 px-3 py-1 text-xs font-bold text-emerald-100 ring-1 ring-emerald-300/40">
{e.youSave.replace('{p}', num(discount))}
</span>
</div>
)}
</>
)}
<Link
href="/register"
className="group mt-7 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3.5 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500"
>
{e.cta}
<ArrowLeft className="h-4 w-4 transition group-hover:-translate-x-1 ltr:rotate-180" />
</Link>
</div>
</div>
</div>
</Reveal>
</div>
</section>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<p className="abrban-ink mb-2 text-sm font-semibold text-white/85">{label}</p>
{children}
</div>
);
}
function SvcResourceConfig({
title,
res,
setRes,
e,
num,
chip,
}: {
title: string;
res: SvcRes;
setRes: (r: SvcRes) => void;
e: ReturnType<typeof useT>['landing']['estimator'];
num: (n: number) => string;
chip: (active: boolean) => string;
}) {
return (
<div className="mt-3 rounded-xl bg-slate-950/30 p-4 ring-1 ring-white/10">
<p className="abrban-ink mb-3 text-xs font-bold text-primary-200">{title}</p>
<div className="space-y-3">
<Row label={e.cpu}>
{SVC_CPU_OPTIONS.map((c) => (
<button key={c} type="button" onClick={() => setRes({ ...res, cpu: c })} className={chip(res.cpu === c)}>
{e.cores.replace('{n}', num(c))}
</button>
))}
</Row>
<Row label={e.memory}>
{SVC_MEM_OPTIONS.map((m) => (
<button key={m} type="button" onClick={() => setRes({ ...res, mem: m })} className={chip(res.mem === m)}>
{e.gb.replace('{n}', num(m))}
</button>
))}
</Row>
<Row label={e.storage}>
{SVC_STORAGE_OPTIONS.map((s) => (
<button key={s} type="button" onClick={() => setRes({ ...res, storage: s })} className={chip(res.storage === s)}>
{e.gb.replace('{n}', num(s))}
</button>
))}
</Row>
</div>
</div>
);
}
function DbResourceConfig({
dbType,
setDbType,
res,
setRes,
e,
num,
chip,
}: {
dbType: string;
setDbType: (v: string) => void;
res: SvcRes;
setRes: (r: SvcRes) => void;
e: ReturnType<typeof useT>['landing']['estimator'];
num: (n: number) => string;
chip: (active: boolean) => string;
}) {
return (
<div className="mt-3 rounded-xl bg-slate-950/30 p-4 ring-1 ring-white/10">
<p className="abrban-ink mb-3 text-xs font-bold text-primary-200">{e.database}</p>
<div className="space-y-3">
<Row label={e.databaseType}>
{DB_TYPES.map((d) => (
<button key={d.value} type="button" onClick={() => setDbType(d.value)} className={chip(dbType === d.value)}>
{d.label}
</button>
))}
</Row>
<Row label={e.cpu}>
{SVC_CPU_OPTIONS.map((c) => (
<button key={c} type="button" onClick={() => setRes({ ...res, cpu: c })} className={chip(res.cpu === c)}>
{e.cores.replace('{n}', num(c))}
</button>
))}
</Row>
<Row label={e.memory}>
{SVC_MEM_OPTIONS.map((m) => (
<button key={m} type="button" onClick={() => setRes({ ...res, mem: m })} className={chip(res.mem === m)}>
{e.gb.replace('{n}', num(m))}
</button>
))}
</Row>
<Row label={e.storage}>
{DB_STORAGE_OPTIONS.map((s) => (
<button key={s} type="button" onClick={() => setRes({ ...res, storage: s })} className={chip(res.storage === s)}>
{e.gb.replace('{n}', num(s))}
</button>
))}
</Row>
</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="w-24 shrink-0 text-xs text-white/70">{label}</span>
<div className="flex flex-wrap gap-2">{children}</div>
</div>
);
}
@@ -0,0 +1,198 @@
'use client';
import { useMemo, useState } from 'react';
import { Cpu, MemoryStick, HardDrive, Database, Globe, Server, type LucideIcon } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { useT, useLocale } from '@/i18n/I18nProvider';
import type { PricingCatalog, BillingCycle, PricingResourceType, PricingRateRow } from '@/types';
import { Reveal } from '../Reveal';
type PublicCatalog = PricingCatalog & { globalDiscountPercent: number };
const CYCLES: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
// Per-resource cards we surface on the marketing page (in display order).
const RESOURCE_ORDER: PricingResourceType[] = [
'base_fee',
'cpu_per_core',
'memory_per_gb',
'storage_per_gb',
'database_addon',
];
const RESOURCE_ICONS: Partial<Record<PricingResourceType, LucideIcon>> = {
base_fee: Server,
cpu_per_core: Cpu,
memory_per_gb: MemoryStick,
storage_per_gb: HardDrive,
database_addon: Database,
};
type Tab = { value: string; label: string; kind: 'runtime' | 'optional' };
function priceForCycle(row: PricingRateRow, cycle: BillingCycle): number {
return cycle === 'hourly' ? row.hourlyPrice : cycle === 'monthly' ? row.monthlyPrice : row.yearlyPrice;
}
export function Pricing() {
const p = useT().landing.pricing;
const locale = useLocale();
const [cycle, setCycle] = useState<BillingCycle>('monthly');
const [tabValue, setTabValue] = useState<string>('');
const { data: catalog, isError } = useQuery<PublicCatalog>({
queryKey: ['public-pricing-catalog'],
queryFn: () => api.get('/public/pricing/catalog').then((r) => r.data),
});
const fmt = (n: number) => Math.round(n).toLocaleString(locale === 'fa-IR' ? 'fa-IR' : 'en-US');
// Runtimes and optional services share the exact same per-resource pricing
// model, so we present them in one tab strip — optional services are priced
// "like runtimes", scaling with the resources each user picks.
const tabs: Tab[] = useMemo(() => {
if (!catalog) return [];
const rt: Tab[] = (catalog.runtimeOptions ?? []).map((o) => ({ ...o, kind: 'runtime' as const }));
const os: Tab[] = (catalog.optionalServiceOptions ?? []).map((o) => ({ ...o, kind: 'optional' as const }));
return [...rt, ...os];
}, [catalog]);
const activeTab = tabs.find((t) => t.value === tabValue) ?? tabs[0];
const discount = catalog?.globalDiscountPercent ?? 0;
const cards = useMemo(() => {
if (!catalog || !activeTab) return [];
const rows =
activeTab.kind === 'runtime'
? catalog.runtimes[activeTab.value] ?? []
: catalog.optionalServices[activeTab.value]?.rates ?? [];
const out: { key: string; label: string; price: number; Icon: LucideIcon }[] = [];
for (const rt of RESOURCE_ORDER) {
const row = rows.find((r) => r.resourceType === rt);
if (!row) continue;
const price = priceForCycle(row, cycle);
if (price <= 0) continue;
out.push({
key: rt,
label: (p.resources as Record<string, string>)[rt] ?? rt,
price,
Icon: RESOURCE_ICONS[rt] ?? Server,
});
}
// Custom domain is a global add-on — show it only on the runtime view.
if (activeTab.kind === 'runtime') {
const cdPrice = priceForCycle(catalog.customDomain as PricingRateRow, cycle);
if (cdPrice > 0) {
out.push({ key: 'custom_domain_addon', label: p.resources.custom_domain_addon, price: cdPrice, Icon: Globe });
}
}
return out;
}, [catalog, activeTab, cycle, p]);
if (isError) {
return (
<section className="relative px-6 py-28">
<div className="mx-auto max-w-6xl">
<p className="abrban-panel abrban-ink rounded-2xl px-6 py-5 text-center text-white/85">
{p.loadError}
</p>
</div>
</section>
);
}
return (
<section id="pricing" className="relative px-6 py-28">
<div className="mx-auto max-w-6xl">
<Reveal className="mb-10 flex justify-center">
<div className="abrban-panel rounded-3xl px-8 py-7 text-center">
<h2 className="abrban-ink text-4xl font-bold text-white">{p.title}</h2>
<p className="abrban-ink mt-4 text-lg text-white/85">{p.subtitle}</p>
{discount > 0 && (
<span className="mt-4 inline-flex items-center gap-2 rounded-full bg-emerald-500/25 px-4 py-1.5 text-sm font-bold text-emerald-100 ring-1 ring-emerald-300/40">
{p.discountBadge.replace('{p}', fmt(discount))}
</span>
)}
</div>
</Reveal>
{/* Controls: runtime + optional-service tabs, then cycle toggle */}
<Reveal className="mb-10 flex flex-col items-center gap-4">
{tabs.length > 0 && (
<div className="flex flex-wrap justify-center gap-2">
{tabs.map((tab) => (
<button
key={`${tab.kind}:${tab.value}`}
type="button"
onClick={() => setTabValue(tab.value)}
className={`rounded-full px-4 py-1.5 text-sm font-semibold backdrop-blur-md transition ${
activeTab?.value === tab.value
? 'bg-primary-600 text-white shadow-lg shadow-primary-600/30'
: 'bg-white/10 text-white/80 ring-1 ring-white/15 hover:bg-white/20'
}`}
>
{tab.label}
</button>
))}
</div>
)}
<div className="inline-flex rounded-xl bg-slate-950/30 p-1 ring-1 ring-white/10 backdrop-blur-md">
{CYCLES.map((c) => (
<button
key={c}
type="button"
onClick={() => setCycle(c)}
className={`rounded-lg px-4 py-1.5 text-sm font-semibold transition ${
cycle === c ? 'bg-primary-600 text-white' : 'text-white/80 hover:text-white'
}`}
>
{p.cycle[c]}
</button>
))}
</div>
</Reveal>
{cards.length === 0 ? (
<Reveal>
<p className="abrban-ink text-center text-white/70">{p.note}</p>
</Reveal>
) : (
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{cards.map((card, i) => {
const discounted = discount > 0 ? Math.round(card.price * (1 - discount / 100)) : card.price;
const Icon = card.Icon;
return (
<Reveal key={card.key} delay={(i % 3) * 0.07}>
<div className="abrban-panel group h-full rounded-2xl p-6 transition hover:-translate-y-1 hover:ring-1 hover:ring-primary-400/50">
<div className="flex items-center gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-xl bg-primary-500/25 text-primary-100 ring-1 ring-primary-400/40">
<Icon className="h-5 w-5" />
</div>
<h3 className="abrban-ink text-lg font-bold text-white">{card.label}</h3>
</div>
<div className="mt-6 flex items-end gap-2">
<span className="abrban-ink text-3xl font-black text-white">{fmt(discounted)}</span>
<span className="mb-1 text-sm text-white/70">
{p.currency} / {p.perCycle[cycle]}
</span>
</div>
{discount > 0 && (
<div className="mt-1 text-sm text-white/55 line-through">
{fmt(card.price)} {p.currency}
</div>
)}
</div>
</Reveal>
);
})}
</div>
)}
<Reveal className="mt-8">
<p className="abrban-ink text-center text-sm text-white/70">{p.note}</p>
</Reveal>
</div>
</section>
);
}
@@ -0,0 +1,141 @@
'use client';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Rocket, Database, Boxes, ScrollText, ChevronDown, Clock, type LucideIcon } from 'lucide-react';
import { useT } from '@/i18n/I18nProvider';
import { Reveal } from '../Reveal';
type ServiceKey = 'paas' | 'dbaas' | 'kaas' | 'laas';
// Tech names are brand identifiers (not translatable), so they live in code —
// same convention as the feature/trust icons in content.ts.
const PAAS_ITEMS = ['Node.js', 'Laravel', 'WordPress', 'Go', 'PHP', 'Python', 'Django', '.NET'];
const DBAAS_ITEMS = ['PostgreSQL', 'MySQL', 'MariaDB', 'MongoDB', 'Redis', 'RabbitMQ', 'Elasticsearch'];
const ICONS: Record<ServiceKey, LucideIcon> = {
paas: Rocket,
dbaas: Database,
kaas: Boxes,
laas: ScrollText,
};
export function ServicesBar() {
const s = useT().landing.services;
// PaaS is the active product, so it starts expanded.
const [open, setOpen] = useState<ServiceKey | null>('paas');
const services: {
key: ServiceKey;
label: string;
name: string;
desc: string;
items?: string[];
soon: boolean;
}[] = [
{ key: 'paas', label: s.paas.label, name: s.paas.name, desc: s.paas.desc, items: PAAS_ITEMS, soon: false },
{ key: 'dbaas', label: s.dbaas.label, name: s.dbaas.name, desc: s.dbaas.desc, items: DBAAS_ITEMS, soon: false },
{ key: 'kaas', label: s.kaas.label, name: s.kaas.name, desc: s.kaas.desc, soon: true },
{ key: 'laas', label: s.laas.label, name: s.laas.name, desc: s.laas.desc, soon: true },
];
const activeService = services.find((x) => x.key === open && !x.soon && x.items);
return (
<section id="services" className="relative px-6 py-24">
<div className="mx-auto max-w-6xl">
<Reveal className="mb-10 flex justify-center">
<div className="abrban-panel rounded-3xl px-8 py-7 text-center">
<h2 className="abrban-ink text-4xl font-bold text-white">{s.title}</h2>
<p className="abrban-ink mt-4 text-lg text-white/85">{s.subtitle}</p>
</div>
</Reveal>
{/* The bar: one tile per service */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{services.map((svc, i) => {
const Icon = ICONS[svc.key];
const isOpen = open === svc.key && !svc.soon;
return (
<Reveal key={svc.key} delay={(i % 4) * 0.07}>
<button
type="button"
disabled={svc.soon}
aria-expanded={isOpen}
onClick={() => setOpen((cur) => (cur === svc.key ? null : svc.key))}
className={`abrban-panel group flex h-full w-full flex-col rounded-2xl p-6 text-right transition ltr:text-left ${
svc.soon
? 'cursor-default opacity-70'
: 'hover:-translate-y-1 hover:ring-1 hover:ring-primary-400/50'
} ${isOpen ? 'ring-1 ring-primary-400/60' : ''}`}
>
<div className="flex items-center justify-between gap-2">
<span
className={`inline-flex h-12 w-12 items-center justify-center rounded-xl ring-1 ${
svc.soon
? 'bg-white/10 text-white/70 ring-white/15'
: 'bg-primary-500/25 text-primary-100 ring-primary-400/40'
}`}
>
<Icon className="h-6 w-6" />
</span>
{svc.soon ? (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-400/20 px-2.5 py-1 text-xs font-bold text-amber-100 ring-1 ring-amber-300/40">
<Clock className="h-3 w-3" />
{s.comingSoon}
</span>
) : (
<ChevronDown
className={`h-5 w-5 text-white/70 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
)}
</div>
<h3 className="abrban-ink mt-5 text-2xl font-black text-white">{svc.label}</h3>
<p className="abrban-ink mt-1 text-sm font-semibold text-primary-200">{svc.name}</p>
<p className="mt-3 text-sm leading-7 text-white/80">{svc.desc}</p>
{!svc.soon && (
<span className="mt-4 text-xs font-medium text-white/60">{s.explore}</span>
)}
</button>
</Reveal>
);
})}
</div>
{/* Expanding menu for the open service (PaaS / DBaaS). A stable key keeps
the panel mounted while switching between services so its content
swaps in place; AnimatePresence only animates open↔closed. */}
<AnimatePresence initial={false}>
{activeService && (
<motion.div
key="services-panel"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden"
>
<div className="abrban-panel mt-4 rounded-2xl p-6">
<p className="abrban-ink mb-4 text-sm font-semibold text-white/85">
{activeService.label} · {activeService.name}
</p>
<div className="flex flex-wrap gap-2.5">
{activeService.items!.map((item) => (
<span
key={item}
className="rounded-xl bg-white/10 px-4 py-2 text-sm font-semibold text-white/90 ring-1 ring-white/15 backdrop-blur-md transition hover:bg-white/20"
>
{item}
</span>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}