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);
|
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 ─────────────────────────────────────
|
// ─── Custom Domain Pricing ─────────────────────────────────────
|
||||||
|
|
||||||
@Get('settings/custom-domain-price')
|
@Get('settings/custom-domain-price')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
import { BillingController } from './billing.controller';
|
import { BillingController } from './billing.controller';
|
||||||
|
import { PublicPricingController } from './public-pricing.controller';
|
||||||
import { DiscountController } from './discount.controller';
|
import { DiscountController } from './discount.controller';
|
||||||
import { DiscountService } from './discount.service';
|
import { DiscountService } from './discount.service';
|
||||||
import { PricingCatalogService } from './pricing-catalog.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 { AddonRate } from './entities/addon-rate.entity';
|
||||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||||
|
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||||
import { Wallet } from './entities/wallet.entity';
|
import { Wallet } from './entities/wallet.entity';
|
||||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||||
import { ResourceCredit } from './entities/resource-credit.entity';
|
import { ResourceCredit } from './entities/resource-credit.entity';
|
||||||
@@ -34,12 +36,13 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
|||||||
InvoiceLine,
|
InvoiceLine,
|
||||||
Discount,
|
Discount,
|
||||||
DiscountRedemption,
|
DiscountRedemption,
|
||||||
|
PlatformSetting,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => LifecycleModule),
|
forwardRef(() => LifecycleModule),
|
||||||
forwardRef(() => ApplicationsModule),
|
forwardRef(() => ApplicationsModule),
|
||||||
forwardRef(() => KubernetesModule),
|
forwardRef(() => KubernetesModule),
|
||||||
],
|
],
|
||||||
controllers: [BillingController, DiscountController],
|
controllers: [BillingController, PublicPricingController, DiscountController],
|
||||||
providers: [BillingService, PricingCatalogService, DiscountService],
|
providers: [BillingService, PricingCatalogService, DiscountService],
|
||||||
exports: [BillingService, PricingCatalogService, DiscountService],
|
exports: [BillingService, PricingCatalogService, DiscountService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ export class BillingService {
|
|||||||
return this.pricingCatalog.updateCatalog(dto);
|
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 ─────────────────────────────────────────────
|
// ─── Cost Calculation ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
|
|||||||
import { AddonRate } from './entities/addon-rate.entity';
|
import { AddonRate } from './entities/addon-rate.entity';
|
||||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||||
|
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||||
import {
|
import {
|
||||||
AppRuntime,
|
AppRuntime,
|
||||||
BillingCycle,
|
BillingCycle,
|
||||||
@@ -47,6 +48,13 @@ describe('PricingCatalogService', () => {
|
|||||||
create: jest.fn().mockImplementation((x) => x),
|
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 () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -56,6 +64,7 @@ describe('PricingCatalogService', () => {
|
|||||||
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
|
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
|
||||||
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
|
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
|
||||||
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
|
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
|
||||||
|
{ provide: getRepositoryToken(PlatformSetting), useValue: settingsRepo },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
|
|||||||
import { AddonRate } from './entities/addon-rate.entity';
|
import { AddonRate } from './entities/addon-rate.entity';
|
||||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||||
|
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||||
import {
|
import {
|
||||||
AppRuntime,
|
AppRuntime,
|
||||||
BillingCycle,
|
BillingCycle,
|
||||||
@@ -39,12 +40,22 @@ import {
|
|||||||
} from './dto/pricing-catalog.dto';
|
} from './dto/pricing-catalog.dto';
|
||||||
import { OptionalServiceResourcesDto } from './dto/optional-service-resources.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 {
|
export interface CyclePrices {
|
||||||
hourly: number;
|
hourly: number;
|
||||||
monthly: number;
|
monthly: number;
|
||||||
yearly: number;
|
yearly: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CostTotals {
|
||||||
|
hourly: number;
|
||||||
|
monthly: number;
|
||||||
|
yearly: number;
|
||||||
|
breakdown: CostBreakdownLine[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PricingRateRow {
|
export interface PricingRateRow {
|
||||||
resourceType: PricingResourceType;
|
resourceType: PricingResourceType;
|
||||||
hourlyPrice: number;
|
hourlyPrice: number;
|
||||||
@@ -129,10 +140,18 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
private readonly optionalProfileRepo: Repository<OptionalServiceProfile>,
|
private readonly optionalProfileRepo: Repository<OptionalServiceProfile>,
|
||||||
@InjectRepository(OptionalServiceRate)
|
@InjectRepository(OptionalServiceRate)
|
||||||
private readonly optionalRateRepo: Repository<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() {
|
async onModuleInit() {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults();
|
||||||
|
await this.getGlobalDiscountPercent(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureDefaults() {
|
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 runtime = dto.runtime as AppRuntime;
|
||||||
const rates = await this.getRatesForRuntime(runtime);
|
const rates = await this.getRatesForRuntime(runtime);
|
||||||
const optional = await this.getOptionalBillingContext();
|
const optional = await this.getOptionalBillingContext();
|
||||||
return this.computeTotalsWithRates(dto, rates, optional);
|
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(
|
computeTotalsWithRates(
|
||||||
dto: CalculateCostDto,
|
dto: CalculateCostDto,
|
||||||
rates: PricingRate[],
|
rates: PricingRate[],
|
||||||
@@ -739,13 +834,16 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
cpuQty += this.parseCpuToCores(dto.databaseResources.cpuLimit);
|
cpuQty += this.parseCpuToCores(dto.databaseResources.cpuLimit);
|
||||||
memoryQty += this.parseMemoryToGb(dto.databaseResources.memoryLimit);
|
memoryQty += this.parseMemoryToGb(dto.databaseResources.memoryLimit);
|
||||||
}
|
}
|
||||||
const storageQty =
|
// App resources (CPU/RAM/storage) bill per replica — each replica is a full
|
||||||
(dto.dbStorageSize
|
// 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
|
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
|
||||||
: 0) +
|
: 0;
|
||||||
(dto.appStorageSize
|
const appStorage = dto.appStorageSize
|
||||||
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
|
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
|
||||||
: 0);
|
: 0;
|
||||||
|
const storageQty = dbStorage + appStorage * replicas;
|
||||||
|
|
||||||
const map = new Map<PricingResourceType, number>();
|
const map = new Map<PricingResourceType, number>();
|
||||||
map.set(PricingResourceType.BASE_FEE, 1);
|
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,
|
PricingResourceType,
|
||||||
LifecycleSettings,
|
LifecycleSettings,
|
||||||
} from '@/types';
|
} 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 { Select } from '@/components/ui/select';
|
||||||
import DiscountsSection from './DiscountsSection';
|
import DiscountsSection from './DiscountsSection';
|
||||||
|
|
||||||
@@ -712,6 +712,8 @@ export default function AdminBillingPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<GlobalDiscountSection />
|
||||||
|
|
||||||
<DiscountsSection />
|
<DiscountsSection />
|
||||||
|
|
||||||
<LifecycleSettingsSection />
|
<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() {
|
function LifecycleSettingsSection() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const b = t.dashboard.billing;
|
const b = t.dashboard.billing;
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import Lenis from 'lenis';
|
|||||||
import { setScroll, setPointer, flashState } from './scroll-store';
|
import { setScroll, setPointer, flashState } from './scroll-store';
|
||||||
import { SiteHeader } from './sections/SiteHeader';
|
import { SiteHeader } from './sections/SiteHeader';
|
||||||
import { Hero } from './sections/Hero';
|
import { Hero } from './sections/Hero';
|
||||||
|
import { ServicesBar } from './sections/ServicesBar';
|
||||||
import { Value } from './sections/Value';
|
import { Value } from './sections/Value';
|
||||||
import { Features } from './sections/Features';
|
import { Features } from './sections/Features';
|
||||||
import { HowItWorks } from './sections/HowItWorks';
|
import { HowItWorks } from './sections/HowItWorks';
|
||||||
|
import { Pricing } from './sections/Pricing';
|
||||||
|
import { Estimator } from './sections/Estimator';
|
||||||
import { Trust } from './sections/Trust';
|
import { Trust } from './sections/Trust';
|
||||||
import { FinalCta } from './sections/FinalCta';
|
import { FinalCta } from './sections/FinalCta';
|
||||||
import { Footer } from './sections/Footer';
|
import { Footer } from './sections/Footer';
|
||||||
@@ -94,9 +97,12 @@ export function LandingPage() {
|
|||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<Hero />
|
<Hero />
|
||||||
|
<ServicesBar />
|
||||||
<Value />
|
<Value />
|
||||||
<Features />
|
<Features />
|
||||||
<HowItWorks />
|
<HowItWorks />
|
||||||
|
<Pricing />
|
||||||
|
<Estimator />
|
||||||
<Trust />
|
<Trust />
|
||||||
<FinalCta />
|
<FinalCta />
|
||||||
<Footer />
|
<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',
|
ctaSecondary: 'Sign in',
|
||||||
scrollHint: 'Dive into the clouds',
|
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: {
|
value: {
|
||||||
lead: 'A ',
|
lead: 'A ',
|
||||||
highlight: 'self-service PaaS',
|
highlight: 'self-service PaaS',
|
||||||
@@ -103,6 +129,53 @@ const en: Dictionary = {
|
|||||||
{ kpi: 'Iran-native', label: 'Optimized for Iranian users' },
|
{ 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: {
|
finalCta: {
|
||||||
titleLead: 'The sky is clear; it’s time to ',
|
titleLead: 'The sky is clear; it’s time to ',
|
||||||
titleHighlight: 'go live',
|
titleHighlight: 'go live',
|
||||||
@@ -1103,6 +1176,19 @@ const en: Dictionary = {
|
|||||||
saveFailedShort: 'Failed to save',
|
saveFailedShort: 'Failed to save',
|
||||||
hours: 'hours',
|
hours: 'hours',
|
||||||
days: 'days',
|
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: {
|
discounts: {
|
||||||
title: 'Discount codes',
|
title: 'Discount codes',
|
||||||
subtitle: 'Percentage discounts on different services — public or for specific users.',
|
subtitle: 'Percentage discounts on different services — public or for specific users.',
|
||||||
|
|||||||
@@ -65,6 +65,32 @@ const fa = {
|
|||||||
ctaSecondary: 'ورود',
|
ctaSecondary: 'ورود',
|
||||||
scrollHint: 'به دلِ ابرها بزن',
|
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: {
|
value: {
|
||||||
lead: 'یک پلتفرمِ ',
|
lead: 'یک پلتفرمِ ',
|
||||||
highlight: 'PaaS خودسرویس',
|
highlight: 'PaaS خودسرویس',
|
||||||
@@ -102,6 +128,53 @@ const fa = {
|
|||||||
{ kpi: 'بومیِ ایران', label: 'بهینه برای کاربر ایرانی' },
|
{ 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: {
|
finalCta: {
|
||||||
titleLead: 'آسمان صاف است؛ وقتِ ',
|
titleLead: 'آسمان صاف است؛ وقتِ ',
|
||||||
titleHighlight: 'زنده',
|
titleHighlight: 'زنده',
|
||||||
@@ -1102,6 +1175,19 @@ const fa = {
|
|||||||
saveFailedShort: 'ذخیره ناموفق بود',
|
saveFailedShort: 'ذخیره ناموفق بود',
|
||||||
hours: 'ساعت',
|
hours: 'ساعت',
|
||||||
days: 'روز',
|
days: 'روز',
|
||||||
|
globalDiscount: {
|
||||||
|
title: 'تخفیف کلی پلتفرم',
|
||||||
|
subtitle: 'یک درصد تخفیف که روی همهٔ قیمتها و صورتحسابهای واقعی اعمال میشود. ۰ یعنی بدون تخفیف.',
|
||||||
|
label: 'درصد تخفیف کلی',
|
||||||
|
hint: 'عددی بین ۰ تا ۱۰۰',
|
||||||
|
save: 'ذخیرهٔ تخفیف',
|
||||||
|
saving: 'در حال ذخیره…',
|
||||||
|
saved: 'تخفیف کلی ذخیره شد',
|
||||||
|
edit: 'ویرایش',
|
||||||
|
cancel: 'انصراف',
|
||||||
|
none: 'در حال حاضر تخفیف کلی فعال نیست.',
|
||||||
|
active: 'تخفیف {p}٪ روی همهٔ سرویسها فعال است.',
|
||||||
|
},
|
||||||
discounts: {
|
discounts: {
|
||||||
title: 'کدهای تخفیف',
|
title: 'کدهای تخفیف',
|
||||||
subtitle: 'تخفیف درصدی روی سرویسهای مختلف؛ عمومی یا مخصوص کاربران خاص.',
|
subtitle: 'تخفیف درصدی روی سرویسهای مختلف؛ عمومی یا مخصوص کاربران خاص.',
|
||||||
|
|||||||
Reference in New Issue
Block a user