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
+23
View File
@@ -98,6 +98,29 @@ export class BillingController {
return this.billingService.calculateDeployPayment(req.user.id, dto, dto.cycle, dto.couponCode);
}
// ─── Global discount (platform-wide) ───────────────────────────
@Get('settings/global-discount')
@ApiOperation({ summary: 'Get the platform-wide discount percentage' })
async getGlobalDiscount() {
return this.billingService.getGlobalDiscount();
}
@Patch('settings/global-discount')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Set the platform-wide discount percentage (Admin)' })
async setGlobalDiscount(@Body() body: { percentOff: number }) {
if (
body.percentOff === undefined ||
typeof body.percentOff !== 'number' ||
body.percentOff < 0 ||
body.percentOff > 100
) {
throw new BadRequestException('percentOff must be a number between 0 and 100');
}
return this.billingService.setGlobalDiscount(body.percentOff);
}
// ─── Custom Domain Pricing ─────────────────────────────────────
@Get('settings/custom-domain-price')
+4 -1
View File
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingService } from './billing.service';
import { BillingController } from './billing.controller';
import { PublicPricingController } from './public-pricing.controller';
import { DiscountController } from './discount.controller';
import { DiscountService } from './discount.service';
import { PricingCatalogService } from './pricing-catalog.service';
@@ -9,6 +10,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
import { AddonRate } from './entities/addon-rate.entity';
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { ResourceCredit } from './entities/resource-credit.entity';
@@ -34,12 +36,13 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
InvoiceLine,
Discount,
DiscountRedemption,
PlatformSetting,
]),
forwardRef(() => LifecycleModule),
forwardRef(() => ApplicationsModule),
forwardRef(() => KubernetesModule),
],
controllers: [BillingController, DiscountController],
controllers: [BillingController, PublicPricingController, DiscountController],
providers: [BillingService, PricingCatalogService, DiscountService],
exports: [BillingService, PricingCatalogService, DiscountService],
})
+12
View File
@@ -48,6 +48,18 @@ export class BillingService {
return this.pricingCatalog.updateCatalog(dto);
}
// ─── Global (platform-wide) discount ──────────────────────────────
/** Current platform-wide discount percentage (0100). */
async getGlobalDiscount(): Promise<{ percentOff: number }> {
return { percentOff: await this.pricingCatalog.getGlobalDiscountPercent(true) };
}
/** Set the platform-wide discount percentage (Admin). */
async setGlobalDiscount(percentOff: number): Promise<{ percentOff: number }> {
return { percentOff: await this.pricingCatalog.setGlobalDiscountPercent(percentOff) };
}
// ─── Cost Calculation ─────────────────────────────────────────────
/**
@@ -5,6 +5,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
import { AddonRate } from './entities/addon-rate.entity';
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import {
AppRuntime,
BillingCycle,
@@ -47,6 +48,13 @@ describe('PricingCatalogService', () => {
create: jest.fn().mockImplementation((x) => x),
};
const settingsRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
create: jest.fn().mockImplementation((x) => x),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
@@ -56,6 +64,7 @@ describe('PricingCatalogService', () => {
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
{ provide: getRepositoryToken(PlatformSetting), useValue: settingsRepo },
],
}).compile();
+106 -8
View File
@@ -5,6 +5,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
import { AddonRate } from './entities/addon-rate.entity';
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import {
AppRuntime,
BillingCycle,
@@ -39,12 +40,22 @@ import {
} from './dto/pricing-catalog.dto';
import { OptionalServiceResourcesDto } from './dto/optional-service-resources.dto';
/** PlatformSetting key holding the platform-wide discount percentage (0100). */
export const GLOBAL_DISCOUNT_SETTING_KEY = 'global_discount_percent';
export interface CyclePrices {
hourly: number;
monthly: number;
yearly: number;
}
export interface CostTotals {
hourly: number;
monthly: number;
yearly: number;
breakdown: CostBreakdownLine[];
}
export interface PricingRateRow {
resourceType: PricingResourceType;
hourlyPrice: number;
@@ -129,10 +140,18 @@ export class PricingCatalogService implements OnModuleInit {
private readonly optionalProfileRepo: Repository<OptionalServiceProfile>,
@InjectRepository(OptionalServiceRate)
private readonly optionalRateRepo: Repository<OptionalServiceRate>,
@InjectRepository(PlatformSetting)
private readonly settingsRepo: Repository<PlatformSetting>,
) {}
/** In-memory cache of the global discount % (TTL-refreshed; single-replica safe). */
private cachedGlobalDiscountPct = 0;
private cachedGlobalDiscountAt = 0;
private static readonly GLOBAL_DISCOUNT_TTL_MS = 30_000;
async onModuleInit() {
await this.ensureDefaults();
await this.getGlobalDiscountPercent(true);
}
async ensureDefaults() {
@@ -337,13 +356,89 @@ export class PricingCatalogService implements OnModuleInit {
};
}
async computeTotalsFromDb(dto: CalculateCostDto) {
/** Raw totals straight from the catalog, before any platform-wide discount. */
async computeTotalsRawFromDb(dto: CalculateCostDto): Promise<CostTotals> {
const runtime = dto.runtime as AppRuntime;
const rates = await this.getRatesForRuntime(runtime);
const optional = await this.getOptionalBillingContext();
return this.computeTotalsWithRates(dto, rates, optional);
}
/**
* Totals with the platform-wide discount applied. This is the single choke
* point every real charge funnels through (calculateCost → invoices), so the
* discount automatically reaches previews, deploys, renewals and upgrades.
*/
async computeTotalsFromDb(dto: CalculateCostDto): Promise<CostTotals> {
const raw = await this.computeTotalsRawFromDb(dto);
const pct = await this.getGlobalDiscountPercent();
return this.applyGlobalDiscount(raw, pct);
}
/** Scale totals (and each breakdown line) by the platform-wide discount. */
applyGlobalDiscount(totals: CostTotals, percentOff: number): CostTotals {
const pct = Math.min(100, Math.max(0, percentOff || 0));
if (pct <= 0) return totals;
const factor = 1 - pct / 100;
const scale = (n: number) => Math.round(n * factor);
return {
hourly: scale(totals.hourly),
monthly: scale(totals.monthly),
yearly: scale(totals.yearly),
breakdown: totals.breakdown.map((line) => ({
...line,
hourly: scale(line.hourly),
monthly: scale(line.monthly),
yearly: scale(line.yearly),
})),
};
}
/** Platform-wide discount percentage (0100), cached with a short TTL. */
async getGlobalDiscountPercent(force = false): Promise<number> {
const now = Date.now();
if (
!force &&
now - this.cachedGlobalDiscountAt < PricingCatalogService.GLOBAL_DISCOUNT_TTL_MS
) {
return this.cachedGlobalDiscountPct;
}
try {
const setting = await this.settingsRepo.findOne({
where: { key: GLOBAL_DISCOUNT_SETTING_KEY },
});
const parsed = setting ? parseInt(setting.value, 10) : 0;
this.cachedGlobalDiscountPct = Number.isFinite(parsed)
? Math.min(100, Math.max(0, parsed))
: 0;
this.cachedGlobalDiscountAt = now;
} catch (e: any) {
this.logger.warn(`Failed to read global discount setting: ${e?.message}`);
}
return this.cachedGlobalDiscountPct;
}
/** Persist the platform-wide discount percentage (Admin) and refresh the cache. */
async setGlobalDiscountPercent(percentOff: number): Promise<number> {
const clamped = Math.min(100, Math.max(0, Math.round(percentOff || 0)));
let setting = await this.settingsRepo.findOne({
where: { key: GLOBAL_DISCOUNT_SETTING_KEY },
});
if (!setting) {
setting = this.settingsRepo.create({
key: GLOBAL_DISCOUNT_SETTING_KEY,
value: String(clamped),
description: 'Platform-wide discount percentage applied to all pricing',
});
} else {
setting.value = String(clamped);
}
await this.settingsRepo.save(setting);
this.cachedGlobalDiscountPct = clamped;
this.cachedGlobalDiscountAt = Date.now();
return clamped;
}
computeTotalsWithRates(
dto: CalculateCostDto,
rates: PricingRate[],
@@ -739,13 +834,16 @@ export class PricingCatalogService implements OnModuleInit {
cpuQty += this.parseCpuToCores(dto.databaseResources.cpuLimit);
memoryQty += this.parseMemoryToGb(dto.databaseResources.memoryLimit);
}
const storageQty =
(dto.dbStorageSize
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
: 0) +
(dto.appStorageSize
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
: 0);
// App resources (CPU/RAM/storage) bill per replica — each replica is a full
// copy of the user-selected footprint. The database is a single-replica
// workload, so its storage is billed once regardless of app replicas.
const dbStorage = dto.dbStorageSize
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
: 0;
const appStorage = dto.appStorageSize
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
: 0;
const storageQty = dbStorage + appStorage * replicas;
const map = new Map<PricingResourceType, number>();
map.set(PricingResourceType.BASE_FEE, 1);
@@ -0,0 +1,41 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { PricingCatalogService } from './pricing-catalog.service';
import { CalculateCostDto } from './dto/billing.dto';
/**
* Unauthenticated pricing endpoints for the public landing page. Exposes the
* read-only pricing catalog and a cost estimator, both annotated with the
* platform-wide discount so the marketing site can show before/after prices.
*/
@ApiTags('Public Pricing')
@Controller('public/pricing')
export class PublicPricingController {
constructor(private readonly pricingCatalog: PricingCatalogService) {}
@Get('catalog')
@ApiOperation({ summary: 'Public pricing catalog + platform-wide discount' })
async getCatalog() {
const [catalog, globalDiscountPercent] = await Promise.all([
this.pricingCatalog.getCatalog(),
this.pricingCatalog.getGlobalDiscountPercent(),
]);
return { ...catalog, globalDiscountPercent };
}
@Post('calculate')
@ApiOperation({ summary: 'Estimate cost for a configuration (gross + discounted)' })
async calculate(@Body() dto: CalculateCostDto) {
const [gross, globalDiscountPercent] = await Promise.all([
this.pricingCatalog.computeTotalsRawFromDb(dto),
this.pricingCatalog.getGlobalDiscountPercent(),
]);
const net = this.pricingCatalog.applyGlobalDiscount(gross, globalDiscountPercent);
return {
gross: { hourly: gross.hourly, monthly: gross.monthly, yearly: gross.yearly },
net: { hourly: net.hourly, monthly: net.monthly, yearly: net.yearly },
breakdown: net.breakdown,
globalDiscountPercent,
};
}
}
@@ -14,7 +14,7 @@ import type {
PricingResourceType,
LifecycleSettings,
} from '@/types';
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe, Percent } from 'lucide-react';
import { Select } from '@/components/ui/select';
import DiscountsSection from './DiscountsSection';
@@ -712,6 +712,8 @@ export default function AdminBillingPage() {
</>
)}
<GlobalDiscountSection />
<DiscountsSection />
<LifecycleSettingsSection />
@@ -719,6 +721,98 @@ export default function AdminBillingPage() {
);
}
function GlobalDiscountSection() {
const t = useT();
const g = t.dashboard.billing.globalDiscount;
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [percent, setPercent] = useState('0');
const { data, isLoading } = useQuery<{ percentOff: number }>({
queryKey: ['global-discount'],
queryFn: () => api.get('/billing/settings/global-discount').then((r) => r.data),
});
const saveMutation = useMutation({
mutationFn: (percentOff: number) =>
api.patch('/billing/settings/global-discount', { percentOff }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-discount'] });
notify.success(g.saved);
setEditing(false);
},
onError: (err: unknown) => notify.error(err, t.dashboard.billing.saveFailedShort),
});
const current = data?.percentOff ?? 0;
const startEditing = () => {
setPercent(String(current));
setEditing(true);
};
return (
<div className="card space-y-4 mt-8">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-start gap-3">
<Percent className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{g.title}</h2>
<p className="text-sm text-gray-500 mt-0.5">{g.subtitle}</p>
</div>
</div>
{!editing && (
<button onClick={startEditing} className="btn-secondary text-xs flex items-center gap-1">
<Edit2 className="w-3 h-3" /> {g.edit}
</button>
)}
</div>
{isLoading ? (
<div className="text-sm text-gray-400">{t.common.loading}</div>
) : editing ? (
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="text-xs font-medium text-gray-600">{g.label}</label>
<div className="flex items-center gap-2 mt-0.5">
<input
type="number"
min={0}
max={100}
className="input-field w-28"
value={percent}
onChange={(e) => setPercent(e.target.value)}
/>
<span className="text-gray-500">٪</span>
</div>
<p className="text-xs text-gray-400 mt-1">{g.hint}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => saveMutation.mutate(Math.min(100, Math.max(0, Number(percent) || 0)))}
disabled={saveMutation.isPending}
className="btn-primary text-sm disabled:opacity-50"
>
{saveMutation.isPending ? g.saving : g.save}
</button>
<button onClick={() => setEditing(false)} className="btn-secondary text-sm">
{g.cancel}
</button>
</div>
</div>
) : (
<p
className={`text-sm rounded-lg px-3 py-2 ${
current > 0 ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-500'
}`}
>
{current > 0 ? g.active.replace('{p}', String(current)) : g.none}
</p>
)}
</div>
);
}
function LifecycleSettingsSection() {
const t = useT();
const b = t.dashboard.billing;
@@ -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>
);
}
+86
View File
@@ -66,6 +66,32 @@ const en: Dictionary = {
ctaSecondary: 'Sign in',
scrollHint: 'Dive into the clouds',
},
services: {
title: 'Platform services',
subtitle: 'Everything you need to build and run your product, in one place.',
comingSoon: 'Coming soon',
explore: 'Click to explore',
paas: {
label: 'PaaS',
name: 'Platform as a Service',
desc: 'Ship your app straight from code to Kubernetes — automatic build and release.',
},
dbaas: {
label: 'DBaaS',
name: 'Database as a Service',
desc: 'Managed, reliable databases ready in seconds.',
},
kaas: {
label: 'KaaS',
name: 'Kubernetes as a Service',
desc: 'A dedicated, managed Kubernetes cluster for your team.',
},
laas: {
label: 'LaaS',
name: 'Logging as a Service',
desc: 'Centralized log collection, retention and analysis.',
},
},
value: {
lead: 'A ',
highlight: 'self-service PaaS',
@@ -103,6 +129,53 @@ const en: Dictionary = {
{ kpi: 'Iran-native', label: 'Optimized for Iranian users' },
],
},
pricing: {
title: 'Transparent pricing',
subtitle: 'Pay only for the resources you use. These rates are read straight from the platforms active plans.',
runtimeLabel: 'Application type',
cycle: { hourly: 'Hourly', monthly: 'Monthly', yearly: 'Yearly' },
perCycle: { hourly: 'per hour', monthly: 'per month', yearly: 'per year' },
discountBadge: '{p}% off',
free: 'Free',
resources: {
base_fee: 'Base fee',
cpu_per_core: 'CPU (per core)',
memory_per_gb: 'Memory (per GB)',
storage_per_gb: 'Storage (per GB)',
database_addon: 'Managed database',
custom_domain_addon: 'Custom domain + SSL',
},
currency: 'Toman',
note: 'Your final bill is based on actual usage; these are the per-unit rates for each resource.',
loadError: 'Could not load pricing.',
},
estimator: {
title: 'Estimate your package',
subtitle: 'Pick the resources you need and see an instant cost estimate.',
runtime: 'Application type',
cpu: 'CPU',
memory: 'Memory (RAM)',
storage: 'Storage',
replicas: 'Replicas',
database: 'Database',
databaseType: 'Database type',
options: 'Add-on services',
redis: 'Redis',
rabbitmq: 'RabbitMQ',
elasticsearch: 'Elasticsearch (logging)',
customDomain: 'Custom domain + SSL',
cores: '{n} cores',
gb: '{n} GB',
none: 'None',
estimatedTitle: 'Estimated cost',
perCycle: { hourly: 'per hour', monthly: 'per month', yearly: 'per year' },
cycle: { hourly: 'Hourly', monthly: 'Monthly', yearly: 'Yearly' },
youSave: '{p}% discount applied',
calculating: 'Calculating…',
cta: 'Build this package',
currency: 'Toman',
error: 'Could not calculate cost.',
},
finalCta: {
titleLead: 'The sky is clear; its time to ',
titleHighlight: 'go live',
@@ -1103,6 +1176,19 @@ const en: Dictionary = {
saveFailedShort: 'Failed to save',
hours: 'hours',
days: 'days',
globalDiscount: {
title: 'Platform-wide discount',
subtitle: 'A single percentage applied to every price and real invoice. 0 means no discount.',
label: 'Global discount %',
hint: 'A number between 0 and 100',
save: 'Save discount',
saving: 'Saving…',
saved: 'Global discount saved',
edit: 'Edit',
cancel: 'Cancel',
none: 'No platform-wide discount is currently active.',
active: 'A {p}% discount is active on all services.',
},
discounts: {
title: 'Discount codes',
subtitle: 'Percentage discounts on different services — public or for specific users.',
+86
View File
@@ -65,6 +65,32 @@ const fa = {
ctaSecondary: 'ورود',
scrollHint: 'به دلِ ابرها بزن',
},
services: {
title: 'سرویس‌های پلتفرم',
subtitle: 'هرچه برای ساخت و اجرای محصولت لازم داری، یک‌جا.',
comingSoon: 'به‌زودی',
explore: 'برای مشاهده کلیک کن',
paas: {
label: 'PaaS',
name: 'پلتفرم به‌عنوان سرویس',
desc: 'اپلیکیشنت را مستقیم از کد روی کوبرنتیز منتشر کن — بیلد و انتشار خودکار.',
},
dbaas: {
label: 'DBaaS',
name: 'دیتابیس به‌عنوان سرویس',
desc: 'دیتابیس‌های مدیریت‌شده و پایدار، آماده در چند ثانیه.',
},
kaas: {
label: 'KaaS',
name: 'کوبرنتیز به‌عنوان سرویس',
desc: 'کلاستر کوبرنتیز اختصاصی و مدیریت‌شده برای تیم تو.',
},
laas: {
label: 'LaaS',
name: 'لاگ به‌عنوان سرویس',
desc: 'جمع‌آوری، نگه‌داری و تحلیل متمرکز لاگ‌ها.',
},
},
value: {
lead: 'یک پلتفرمِ ',
highlight: 'PaaS خودسرویس',
@@ -102,6 +128,53 @@ const fa = {
{ kpi: 'بومیِ ایران', label: 'بهینه برای کاربر ایرانی' },
],
},
pricing: {
title: 'قیمت‌گذاری شفاف',
subtitle: 'فقط بابت منابعی که مصرف می‌کنی پرداخت کن. این نرخ‌ها مستقیماً از پلن‌های فعال پلتفرم خوانده می‌شوند.',
runtimeLabel: 'نوع اپلیکیشن',
cycle: { hourly: 'ساعتی', monthly: 'ماهانه', yearly: 'سالانه' },
perCycle: { hourly: 'هر ساعت', monthly: 'هر ماه', yearly: 'هر سال' },
discountBadge: '{p}٪ تخفیف',
free: 'رایگان',
resources: {
base_fee: 'هزینهٔ پایه',
cpu_per_core: 'پردازنده (هر هسته)',
memory_per_gb: 'حافظه (هر گیگابایت)',
storage_per_gb: 'فضای ذخیره (هر گیگابایت)',
database_addon: 'دیتابیس مدیریت‌شده',
custom_domain_addon: 'دامنهٔ اختصاصی + SSL',
},
currency: 'تومان',
note: 'هزینهٔ نهایی بر اساس مصرف واقعی شما محاسبه می‌شود؛ این اعداد نرخ واحد هر منبع هستند.',
loadError: 'دریافت قیمت‌ها ممکن نشد.',
},
estimator: {
title: 'پکیج خودت را تخمین بزن',
subtitle: 'منابع موردنیازت را انتخاب کن تا همین حالا برآورد هزینه را ببینی.',
runtime: 'نوع اپلیکیشن',
cpu: 'پردازنده (CPU)',
memory: 'حافظه (RAM)',
storage: 'فضای ذخیره‌سازی',
replicas: 'تعداد نمونه',
database: 'دیتابیس',
databaseType: 'نوع دیتابیس',
options: 'سرویس‌های جانبی',
redis: 'Redis',
rabbitmq: 'RabbitMQ',
elasticsearch: 'Elasticsearch (لاگ)',
customDomain: 'دامنهٔ اختصاصی + SSL',
cores: '{n} هسته',
gb: '{n} گیگابایت',
none: 'بدون',
estimatedTitle: 'برآورد هزینه',
perCycle: { hourly: 'در ساعت', monthly: 'در ماه', yearly: 'در سال' },
cycle: { hourly: 'ساعتی', monthly: 'ماهانه', yearly: 'سالانه' },
youSave: '{p}٪ تخفیف اعمال شد',
calculating: 'در حال محاسبه…',
cta: 'ساخت این پکیج',
currency: 'تومان',
error: 'محاسبهٔ هزینه ممکن نشد.',
},
finalCta: {
titleLead: 'آسمان صاف است؛ وقتِ ',
titleHighlight: 'زنده',
@@ -1102,6 +1175,19 @@ const fa = {
saveFailedShort: 'ذخیره ناموفق بود',
hours: 'ساعت',
days: 'روز',
globalDiscount: {
title: 'تخفیف کلی پلتفرم',
subtitle: 'یک درصد تخفیف که روی همهٔ قیمت‌ها و صورت‌حساب‌های واقعی اعمال می‌شود. ۰ یعنی بدون تخفیف.',
label: 'درصد تخفیف کلی',
hint: 'عددی بین ۰ تا ۱۰۰',
save: 'ذخیرهٔ تخفیف',
saving: 'در حال ذخیره…',
saved: 'تخفیف کلی ذخیره شد',
edit: 'ویرایش',
cancel: 'انصراف',
none: 'در حال حاضر تخفیف کلی فعال نیست.',
active: 'تخفیف {p}٪ روی همهٔ سرویس‌ها فعال است.',
},
discounts: {
title: 'کدهای تخفیف',
subtitle: 'تخفیف درصدی روی سرویس‌های مختلف؛ عمومی یا مخصوص کاربران خاص.',