/** * Server-side catalog of the V2 seconds-based subscription plans. * * FlatRender charges by **render-seconds**, not by number of videos. Each plan * grants a monthly bucket of render-seconds (`secondsCharge`); a render consumes * seconds equal to the video's length × a resolution multiplier (see * RESOLUTION_MULTIPLIERS). Plans live in the Identity service and are read here * from the gateway so prices/quotas are editable in admin without code changes. */ import { gatewayUrl } from "@/lib/api/gateway"; // ── Resolution multipliers: render-seconds = videoLengthSec × multiplier ─────── // Baseline is 720p (×1). Higher resolutions cost proportionally more seconds. export const RESOLUTION_MULTIPLIERS: Record = { "360p": 0.5, "540p": 0.75, "720p": 1, "1080p": 2, "2K": 3, "4K": 4, }; export const RESOLUTION_ORDER = ["360p", "540p", "720p", "1080p", "2K", "4K"]; /** Multiplier for a resolution label, defaulting to 1 for unknown labels. */ export function resolutionMultiplier(resolution: string): number { return RESOLUTION_MULTIPLIERS[resolution] ?? 1; } /** Render-seconds a single render consumes at the given length + resolution. */ export function renderSecondsCost(lengthSec: number, resolution: string): number { return Math.ceil(lengthSec * resolutionMultiplier(resolution)); } // ── Types ────────────────────────────────────────────────────────────────── export interface SecondsPlan { id: string; code: string; name: string; description?: string | null; /** Display price in Toman (price_minor is stored in Rial = Toman × 10). */ priceTomans: number; beforePriceTomans?: number | null; currency: string; /** Render-seconds granted per billing period. */ secondsCharge: number; monthlyRendersQuota?: number | null; storageGb: number; parallelRenders: number; maxResolution: string; renderSpeedFactor: number; isFeatured: boolean; color?: string | null; /** True when renders are watermarked (free tier). */ watermark: boolean; } interface V2PlanRow { id: string; code: string; name: string; description?: string | null; price_minor: number; before_price_minor?: number | null; currency: string; seconds_charge: number; monthly_renders_quota?: number | null; storage_gb: number; parallel_renders: number; max_resolution: string; render_speed_factor: number | string; is_featured: boolean; color?: string | null; features?: Record | null; } function mapPlan(p: V2PlanRow): SecondsPlan { return { id: p.id, code: p.code, name: p.name, description: p.description, priceTomans: Math.round((p.price_minor ?? 0) / 10), beforePriceTomans: p.before_price_minor != null ? Math.round(p.before_price_minor / 10) : null, currency: p.currency, secondsCharge: p.seconds_charge, monthlyRendersQuota: p.monthly_renders_quota, storageGb: p.storage_gb, parallelRenders: p.parallel_renders, maxResolution: p.max_resolution, renderSpeedFactor: Number(p.render_speed_factor), isFeatured: p.is_featured, color: p.color, watermark: Boolean(p.features?.watermark), }; } /** * Fetch the active plans from the Identity service (public, ISR-cached). * Returns an empty array when the gateway is unset/unreachable so the page can * render a graceful empty state instead of throwing. */ export async function fetchPlans(): Promise { // Retry once: a single slow/cold gateway response shouldn't blank the page. for (let attempt = 0; attempt < 2; attempt++) { try { const res = await fetch(gatewayUrl("/v1/plans"), { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(6000), next: { revalidate: 60 }, }); if (!res.ok) continue; const json = (await res.json().catch(() => null)) as { data?: V2PlanRow[]; } | null; const rows = json?.data ?? []; return rows.map(mapPlan).sort((a, b) => a.priceTomans - b.priceTomans); } catch { // fall through to the next attempt, then to the empty fallback } } return []; } /** Format a Toman amount with locale digit grouping. */ export function formatToman(amount: number, locale: string): string { return new Intl.NumberFormat(locale === "fa" ? "fa-IR" : "en-US").format(amount); }