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:
@@ -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')
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -48,6 +48,18 @@ export class BillingService {
|
||||
return this.pricingCatalog.updateCatalog(dto);
|
||||
}
|
||||
|
||||
// ─── Global (platform-wide) discount ──────────────────────────────
|
||||
|
||||
/** Current platform-wide discount percentage (0–100). */
|
||||
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();
|
||||
|
||||
|
||||
@@ -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 (0–100). */
|
||||
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 (0–100), 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>
|
||||
);
|
||||
}
|
||||
@@ -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 platform’s 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; it’s 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.',
|
||||
|
||||
@@ -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: 'تخفیف درصدی روی سرویسهای مختلف؛ عمومی یا مخصوص کاربران خاص.',
|
||||
|
||||
Reference in New Issue
Block a user