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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user