feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
Render engine - Add Remotion (code-based) as a 2nd render engine alongside After Effects. node-agent dispatches on Job.Engine; RunRemotion maps bindings -> --props, renders native then ffmpeg-scales to the quality tier (aspect-preserving). - content.projects.render_engine + render_remotion_comp (migration 32); render-svc claim resolves engine and routes (skips .aep for Remotion). - Admin TemplatesAdmin gains an engine picker + Remotion composition id field. Template pack (services/remotion) - 16 branded, Persian (Vazirmatn), color- and text-editable templates, each in 3 aspects (16:9 / 1:1 / 9:16): LogoMotion, Opener, InstaPromo, YouTubeIntro, Slideshow, HappyBirthday, SalePromo, QuoteCard, EventInvite, Countdown, GlitterReveal (editable logo image), NowruzGreeting (animated characters), and 4 cinematic 3D templates via @remotion/three (Hero3D, Nowruz3D, Birthday3D, Promo3D) with reflections + bloom/DOF/vignette. - scripts/seed_remotion_templates.py seeds containers/projects/scenes/colors. Pricing - Rewrite /pricing to the seconds-based model (charge = length x resolution), data-driven from /v1/plans, Toman, broker checkout. Coming-soon - Persian experimental-build overlay on all pages (launch date + countdown). Fixes - middleware matcher bypasses all static asset paths; catalog mapping passes cover image + preview video so real thumbnails render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
||||
import { getMessages, getTranslations } from "next-intl/server";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { ComingSoonOverlay } from "@/components/layout/ComingSoonOverlay";
|
||||
import { DirectionProvider } from "@/components/layout/DirectionProvider";
|
||||
import { SiteChrome } from "@/components/layout/SiteChrome";
|
||||
import { GlobalRenderProgress } from "@/components/render/GlobalRenderProgress";
|
||||
@@ -117,6 +118,7 @@ export default async function LocaleLayout({
|
||||
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
|
||||
<SiteChrome user={navUser}>{children}</SiteChrome>
|
||||
<GlobalRenderProgress authed={!!navUser} />
|
||||
<ComingSoonOverlay />
|
||||
</DirectionProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createPageMetadata } from "@/lib/metadata";
|
||||
export const metadata: Metadata = createPageMetadata({
|
||||
title: "Pricing",
|
||||
description:
|
||||
"Compare FlatRender Lite, Pro, and Business plans. Monthly or yearly billing with templates, exports, and AI tools for creators.",
|
||||
"FlatRender pricing is by the second, not by the video. Every plan grants a monthly bucket of render-seconds; a render costs the video length × a quality multiplier.",
|
||||
path: "/pricing",
|
||||
});
|
||||
|
||||
|
||||
@@ -7,25 +7,16 @@ import { getAccessToken } from "@/lib/auth/session";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const checkoutSchema = z.object({
|
||||
plan: z.enum(["pro", "business"]),
|
||||
billing: z.enum(["monthly", "annual"]),
|
||||
planId: z.string().uuid(),
|
||||
});
|
||||
|
||||
interface V2Plan {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
billing_period: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a plan purchase through the V2 Identity/payments flow.
|
||||
* Start a seconds-based plan purchase through the V2 Identity/payments flow.
|
||||
*
|
||||
* Replaces the direct Stripe Checkout + Supabase profile loop. We resolve the
|
||||
* requested plan ("pro"/"business" × "monthly"/"annual") to a plan GUID via
|
||||
* `/v1/plans` (codes follow the `pro_monthly` / `business_annual` convention),
|
||||
* then POST `/v1/users/me/plan/purchase`. The payments service owns the gateway
|
||||
* (ZarinPal/Stripe) and returns a redirect URL we hand back to the client.
|
||||
* The pricing page is data-driven (plans come from `/v1/plans`), so the client
|
||||
* sends the chosen plan's GUID directly. We POST `/v1/users/me/plan/purchase`;
|
||||
* the payments service owns the gateway (ZarinPal broker) and returns a redirect
|
||||
* URL we hand back to the client.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const token = await getAccessToken();
|
||||
@@ -45,37 +36,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const parsed = checkoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid plan or billing period." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { plan, billing } = parsed.data;
|
||||
const targetCode = `${plan}_${billing === "annual" ? "annual" : "monthly"}`;
|
||||
|
||||
// Resolve plan code → GUID. Plans are public, but pass the token so tenant
|
||||
// overrides resolve correctly.
|
||||
const plansRes = await fetch(gatewayUrl("/v1/plans"), {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const plansJson = plansRes.ok
|
||||
? ((await plansRes.json().catch(() => null)) as { data?: V2Plan[] } | null)
|
||||
: null;
|
||||
const match = plansJson?.data?.find(
|
||||
(p) => p.code?.toLowerCase() === targetCode
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"This plan is not available yet. Please try again later or contact support.",
|
||||
code: "PLAN_NOT_AVAILABLE",
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
return NextResponse.json({ error: "Invalid plan." }, { status: 400 });
|
||||
}
|
||||
|
||||
const purchaseRes = await fetch(gatewayUrl("/v1/users/me/plan/purchase"), {
|
||||
@@ -86,7 +47,7 @@ export async function POST(request: Request) {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ plan_id: match.id }),
|
||||
body: JSON.stringify({ plan_id: parsed.data.planId }),
|
||||
});
|
||||
|
||||
if (!purchaseRes.ok) {
|
||||
|
||||
@@ -34,10 +34,12 @@ interface Detail extends Container {
|
||||
interface Proj {
|
||||
id: string; name: string; aspect?: string | null; resolution?: string;
|
||||
aep_file_url?: string | null; aep_file_size_bytes?: number | null; render_aep_comp?: string;
|
||||
render_engine?: string; render_remotion_comp?: string | null;
|
||||
}
|
||||
|
||||
const PRIMARY_MODES = ["FIX", "FLEXIBLE", "MockUp", "MusicVisualizer", "VoiceOver"];
|
||||
const RESOLUTIONS = ["HD", "FullHD", "TwoK", "FourK"];
|
||||
const RENDER_ENGINES = ["AfterEffects", "Remotion"];
|
||||
const CHOOSE_MODES = ["FIX", "FLEXIBLE", "MockUp", "MusicVisualizer", "VoiceOver"];
|
||||
|
||||
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
|
||||
@@ -94,7 +96,11 @@ export function TemplatesAdmin() {
|
||||
const res = await fetch(api(`projects/${p.id}`), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ aspect: p.aspect ?? "", resolution: p.resolution }),
|
||||
body: JSON.stringify({
|
||||
aspect: p.aspect ?? "", resolution: p.resolution,
|
||||
render_engine: p.render_engine || "AfterEffects",
|
||||
render_remotion_comp: p.render_remotion_comp ?? null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => null);
|
||||
@@ -139,7 +145,11 @@ export function TemplatesAdmin() {
|
||||
setSavingProj(p.id);
|
||||
await fetch(api(`projects/${p.id}/aep`), {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ render_aep_comp: p.render_aep_comp || "flatrender" }),
|
||||
body: JSON.stringify({
|
||||
render_aep_comp: p.render_aep_comp || "flatrender",
|
||||
render_engine: p.render_engine || "AfterEffects",
|
||||
render_remotion_comp: p.render_remotion_comp ?? null,
|
||||
}),
|
||||
});
|
||||
setSavingProj(null);
|
||||
};
|
||||
@@ -352,9 +362,14 @@ export function TemplatesAdmin() {
|
||||
<div key={p.id} className="rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-[90px] flex-1 truncate text-xs text-gray-200">{p.name}</span>
|
||||
{p.aep_file_url
|
||||
? <span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] text-emerald-300">AE ✓</span>
|
||||
: <span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-300">بدون فایل AE</span>}
|
||||
{(p.render_engine || "AfterEffects") === "Remotion"
|
||||
? <span className="rounded bg-sky-500/15 px-1.5 py-0.5 text-[10px] text-sky-300">Remotion</span>
|
||||
: p.aep_file_url
|
||||
? <span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] text-emerald-300">AE ✓</span>
|
||||
: <span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-300">بدون فایل AE</span>}
|
||||
<select className={`${inp} w-28 py-1 text-xs`} dir="ltr" value={p.render_engine || "AfterEffects"} onChange={(e) => updateProj(p.id, { render_engine: e.target.value })} title="موتور رندر">
|
||||
{RENDER_ENGINES.map((eng) => <option key={eng} value={eng}>{eng}</option>)}
|
||||
</select>
|
||||
<input className={`${inp} w-20 py-1 text-xs`} placeholder="16:9" value={p.aspect ?? ""} onChange={(e) => updateProj(p.id, { aspect: e.target.value })} />
|
||||
<select className={`${inp} w-24 py-1 text-xs`} value={p.resolution ?? "FullHD"} onChange={(e) => updateProj(p.id, { resolution: e.target.value })}>
|
||||
{RESOLUTIONS.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
@@ -363,17 +378,30 @@ export function TemplatesAdmin() {
|
||||
<button type="button" className="rounded-lg border border-red-500/30 px-2 py-1 text-xs text-red-300 hover:bg-red-500/10" onClick={() => removeProject(p)}>حذف</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-end gap-2 border-t border-[#1e2235] pt-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-0.5 block text-[10px] text-gray-500">فایل افترافکت (.aep / .zip)</label>
|
||||
<FileUploadField value={p.aep_file_url ?? ""} onChange={(u) => attachAep(p, u)} accept=".aep,.aepx,.zip" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[10px] text-gray-500">نام کامپوزیشن رندر</label>
|
||||
<div className="flex gap-1">
|
||||
<input className={`${inp} w-32 py-1 text-xs`} dir="ltr" placeholder="flatrender" value={p.render_aep_comp ?? ""} onChange={(e) => updateProj(p.id, { render_aep_comp: e.target.value })} />
|
||||
<button type="button" className={ghost} onClick={() => saveComp(p)} disabled={savingProj === p.id}>ذخیره</button>
|
||||
{(p.render_engine || "AfterEffects") === "Remotion" ? (
|
||||
<div className="flex-1">
|
||||
<label className="mb-0.5 block text-[10px] text-gray-500">شناسهٔ کامپوزیشن Remotion</label>
|
||||
<div className="flex gap-1">
|
||||
<input className={`${inp} w-48 py-1 text-xs`} dir="ltr" placeholder="KineticQuote" value={p.render_remotion_comp ?? ""} onChange={(e) => updateProj(p.id, { render_remotion_comp: e.target.value })} />
|
||||
<button type="button" className={ghost} onClick={() => saveComp(p)} disabled={savingProj === p.id}>ذخیره</button>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[10px] text-gray-600">قالب کدمحور — بدون نیاز به فایل افترافکت.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<label className="mb-0.5 block text-[10px] text-gray-500">فایل افترافکت (.aep / .zip)</label>
|
||||
<FileUploadField value={p.aep_file_url ?? ""} onChange={(u) => attachAep(p, u)} accept=".aep,.aepx,.zip" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[10px] text-gray-500">نام کامپوزیشن رندر</label>
|
||||
<div className="flex gap-1">
|
||||
<input className={`${inp} w-32 py-1 text-xs`} dir="ltr" placeholder="flatrender" value={p.render_aep_comp ?? ""} onChange={(e) => updateProj(p.id, { render_aep_comp: e.target.value })} />
|
||||
<button type="button" className={ghost} onClick={() => saveComp(p)} disabled={savingProj === p.id}>ذخیره</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-end">
|
||||
<button type="button" className="rounded-lg border border-indigo-500/40 px-2.5 py-1 text-xs text-indigo-300 hover:bg-indigo-500/10" onClick={() => setScEditor({ id: p.id, name: p.name })}>
|
||||
صحنهها و رنگها
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { LogoMark } from "@/components/ui/LogoMark";
|
||||
|
||||
/**
|
||||
* Full-screen Persian "coming soon" curtain shown on every page (mounted in the
|
||||
* locale layout). It announces that this is an experimental build of FlatRender,
|
||||
* the launch date, and the headline features — with a live countdown. Visitors
|
||||
* can peek the experimental site via the CTA; the choice is remembered for the
|
||||
* browser session so navigation isn't blocked.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "fr_coming_soon_dismissed";
|
||||
// Launch: 1 Mordad 1405 ≈ 23 July 2026.
|
||||
const LAUNCH = new Date("2026-07-23T00:00:00+03:30");
|
||||
|
||||
const faDigits = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
|
||||
const fa = (n: number | string) =>
|
||||
String(n).replace(/[0-9]/g, (d) => faDigits[Number(d)]);
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: "⚡", title: "رندر فوقسریع ویدیو", desc: "ویدیوهایتان را در چند دقیقه و در فضای ابری رندر کنید" },
|
||||
{ icon: "💰", title: "هزینهٔ بسیار پایین", desc: "پرداخت بر اساس ثانیه؛ مقرونبهصرفهتر از همیشه" },
|
||||
{ icon: "🎬", title: "بیش از ۳۰۰۰ قالب آماده", desc: "برای هر مناسبت، برند و شبکهٔ اجتماعی" },
|
||||
{ icon: "✨", title: "ویژگیهای جدید در راه", desc: "ساخت ویدیو با هوش مصنوعی و امکانات تازه بهزودی" },
|
||||
];
|
||||
|
||||
function useCountdown(target: Date) {
|
||||
const [now, setNow] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return useMemo(() => {
|
||||
if (now === null) return null;
|
||||
const diff = Math.max(0, target.getTime() - now);
|
||||
const s = Math.floor(diff / 1000);
|
||||
return {
|
||||
days: Math.floor(s / 86400),
|
||||
hours: Math.floor((s % 86400) / 3600),
|
||||
minutes: Math.floor((s % 3600) / 60),
|
||||
seconds: s % 60,
|
||||
};
|
||||
}, [now, target]);
|
||||
}
|
||||
|
||||
export function ComingSoonOverlay() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cd = useCountdown(LAUNCH);
|
||||
|
||||
useEffect(() => {
|
||||
// Show unless dismissed earlier this session.
|
||||
if (typeof window === "undefined") return;
|
||||
const dismissed = sessionStorage.getItem(STORAGE_KEY);
|
||||
setOpen(dismissed !== "1");
|
||||
}, []);
|
||||
|
||||
// Lock background scroll while the curtain is up.
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const dismiss = () => {
|
||||
sessionStorage.setItem(STORAGE_KEY, "1");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const units = cd
|
||||
? [
|
||||
{ label: "روز", value: cd.days },
|
||||
{ label: "ساعت", value: cd.hours },
|
||||
{ label: "دقیقه", value: cd.minutes },
|
||||
{ label: "ثانیه", value: cd.seconds },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
dir="rtl"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto bg-[#070617] font-vazirmatn"
|
||||
style={{ fontFamily: "var(--font-vazirmatn), Vazirmatn, sans-serif" }}
|
||||
>
|
||||
{/* Animated gradient + floating blobs */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(59,167,255,0.28),transparent_45%),radial-gradient(circle_at_75%_30%,rgba(168,85,247,0.26),transparent_45%),radial-gradient(circle_at_55%_85%,rgba(236,72,153,0.22),transparent_45%)]" />
|
||||
{[
|
||||
{ c: "#3ba7ff", s: 480, x: "8%", y: "10%", d: 0 },
|
||||
{ c: "#a855f7", s: 420, x: "70%", y: "8%", d: 1.5 },
|
||||
{ c: "#ec4899", s: 520, x: "55%", y: "65%", d: 3 },
|
||||
{ c: "#22d3ee", s: 360, x: "12%", y: "70%", d: 2.2 },
|
||||
].map((b, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
left: b.x,
|
||||
top: b.y,
|
||||
width: b.s,
|
||||
height: b.s,
|
||||
background: `radial-gradient(circle, ${b.c}55, transparent 70%)`,
|
||||
filter: "blur(40px)",
|
||||
}}
|
||||
animate={{ x: [0, 40, -30, 0], y: [0, -30, 25, 0], scale: [1, 1.12, 0.95, 1] }}
|
||||
transition={{ duration: 16 + i * 3, repeat: Infinity, ease: "easeInOut", delay: b.d }}
|
||||
/>
|
||||
))}
|
||||
{/* Floating sparkles */}
|
||||
{Array.from({ length: 18 }).map((_, i) => (
|
||||
<motion.span
|
||||
key={`s${i}`}
|
||||
className="absolute h-1.5 w-1.5 rounded-full bg-white/70"
|
||||
style={{ left: `${(i * 53) % 100}%`, top: `${(i * 37) % 100}%` }}
|
||||
animate={{ opacity: [0.1, 0.8, 0.1], scale: [0.6, 1.3, 0.6] }}
|
||||
transition={{ duration: 3 + (i % 5), repeat: Infinity, delay: i * 0.3 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0, scale: 0.96 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.15, duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative z-10 mx-4 my-10 w-full max-w-3xl rounded-3xl border border-white/10 bg-white/[0.04] p-8 text-center shadow-2xl backdrop-blur-xl sm:p-12"
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="mb-6 flex items-center justify-center gap-3">
|
||||
<LogoMark size={44} />
|
||||
<span className="text-2xl font-extrabold text-white">فلترندر</span>
|
||||
</div>
|
||||
|
||||
{/* Experimental badge */}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-amber-300/30 bg-amber-400/10 px-4 py-1.5 text-sm font-semibold text-amber-300">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||
این یک نسخهٔ آزمایشی از فلترندر است
|
||||
</span>
|
||||
|
||||
<h1 className="mt-6 bg-gradient-to-l from-[#3ba7ff] via-[#a855f7] to-[#ec4899] bg-clip-text text-5xl font-black leading-tight text-transparent sm:text-6xl">
|
||||
بهزودی
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-white/70 sm:text-lg">
|
||||
نسخهٔ کامل فلترندر در راه است؛ پلتفرمی برای ساخت ویدیوهای حرفهای، سریع و
|
||||
ارزان — تنها در چند کلیک.
|
||||
</p>
|
||||
|
||||
{/* Launch date */}
|
||||
<div className="mt-7 inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-5 py-2.5 text-white">
|
||||
<span className="text-xl">🚀</span>
|
||||
<span className="text-sm text-white/70">زمان انتشار:</span>
|
||||
<span className="text-lg font-extrabold text-white">۱ مرداد</span>
|
||||
</div>
|
||||
|
||||
{/* Countdown */}
|
||||
<div className="mt-6 flex justify-center gap-3 sm:gap-4">
|
||||
{units.map((u) => (
|
||||
<div
|
||||
key={u.label}
|
||||
className="min-w-[68px] rounded-2xl border border-white/10 bg-gradient-to-b from-white/10 to-white/[0.02] px-3 py-3 sm:min-w-[80px]"
|
||||
>
|
||||
<div className="text-3xl font-black tabular-nums text-white sm:text-4xl">
|
||||
{fa(String(u.value).padStart(2, "0"))}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-white/60">{u.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Feature grid */}
|
||||
<div className="mt-9 grid grid-cols-1 gap-3 text-right sm:grid-cols-2">
|
||||
{FEATURES.map((f, i) => (
|
||||
<motion.div
|
||||
key={f.title}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 + i * 0.1 }}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/10 bg-white/[0.03] p-4"
|
||||
>
|
||||
<span className="text-2xl">{f.icon}</span>
|
||||
<div>
|
||||
<div className="font-bold text-white">{f.title}</div>
|
||||
<div className="mt-0.5 text-sm leading-relaxed text-white/60">{f.desc}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-9">
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="group inline-flex items-center gap-2 rounded-full bg-gradient-to-l from-[#3ba7ff] to-[#a855f7] px-8 py-3.5 text-base font-bold text-white shadow-lg shadow-indigo-500/30 transition hover:scale-[1.03] hover:shadow-indigo-500/50"
|
||||
>
|
||||
مشاهدهٔ نسخهٔ آزمایشی
|
||||
<span className="transition group-hover:-translate-x-1">←</span>
|
||||
</button>
|
||||
<p className="mt-3 text-xs text-white/40">
|
||||
میتوانید همین حالا نسخهٔ آزمایشی را ببینید
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
import { cfgVal } from "@/lib/home-layout";
|
||||
import { PricingBillingToggle } from "@/components/sections/PricingBillingToggle";
|
||||
import { PricingCard } from "@/components/sections/PricingCard";
|
||||
import { PricingCompareTable } from "@/components/sections/PricingCompareTable";
|
||||
import { PricingFreeBanner } from "@/components/sections/PricingFreeBanner";
|
||||
import { PricingSectionShell } from "@/components/sections/PricingBackground";
|
||||
import { SectionReveal } from "@/components/sections/SectionReveal";
|
||||
import type { BillingPeriod } from "@/components/sections/pricing-data";
|
||||
import { PRICING_TIERS } from "@/components/sections/pricing-data";
|
||||
import { fetchPlans } from "@/lib/plans-catalog";
|
||||
import { PricingPlans } from "@/components/sections/PricingPlans";
|
||||
|
||||
export interface PricingProps {
|
||||
className?: string;
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function Pricing({ className, config }: PricingProps) {
|
||||
const t = useTranslations("pricing");
|
||||
const locale = useLocale();
|
||||
const [billing, setBilling] = useState<BillingPeriod>("annual");
|
||||
|
||||
return (
|
||||
<PricingSectionShell className={className}>
|
||||
<SectionReveal className="text-center">
|
||||
<h2 className="font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
|
||||
{cfgVal(config, "heading", locale) ?? t("heading")}
|
||||
</h2>
|
||||
</SectionReveal>
|
||||
|
||||
<SectionReveal className="mt-8">
|
||||
<PricingFreeBanner />
|
||||
</SectionReveal>
|
||||
|
||||
<SectionReveal className="mt-10 flex justify-center">
|
||||
<PricingBillingToggle billing={billing} onChange={setBilling} />
|
||||
</SectionReveal>
|
||||
|
||||
<SectionReveal className="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-3 lg:gap-5">
|
||||
{PRICING_TIERS.map((tier) => (
|
||||
<PricingCard key={tier.id} tier={tier} billing={billing} />
|
||||
))}
|
||||
</SectionReveal>
|
||||
|
||||
<SectionReveal className="mt-16">
|
||||
<PricingCompareTable billing={billing} onBillingChange={setBilling} />
|
||||
</SectionReveal>
|
||||
</PricingSectionShell>
|
||||
);
|
||||
/**
|
||||
* Pricing section (server). Reads the seconds-based plans from the Identity
|
||||
* service and renders the data-driven pricing UI. Used both on the homepage
|
||||
* (via the section manager `config`) and the standalone /pricing page.
|
||||
*/
|
||||
export async function Pricing({ className, config }: PricingProps) {
|
||||
const plans = await fetchPlans();
|
||||
return <PricingPlans plans={plans} className={className} config={config} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { SecondsPlan } from "@/lib/plans-catalog";
|
||||
|
||||
interface Props {
|
||||
plan: SecondsPlan;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a plan purchase. Free plans send the user into onboarding; paid plans
|
||||
* POST /api/checkout (which resolves the plan → broker redirect URL) and
|
||||
* forward the browser to the payment gateway. A 401 bounces to sign-in.
|
||||
*/
|
||||
export function PricingBuyButton({ plan, className }: Props) {
|
||||
const t = useTranslations("pricing");
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isFree = plan.priceTomans <= 0;
|
||||
|
||||
const onClick = async () => {
|
||||
setError(null);
|
||||
if (isFree) {
|
||||
router.push("/dashboard");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ planId: plan.id }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
router.push("/auth?next=/pricing");
|
||||
return;
|
||||
}
|
||||
const data = (await res.json().catch(() => null)) as {
|
||||
url?: string;
|
||||
error?: string;
|
||||
} | null;
|
||||
if (res.ok && data?.url) {
|
||||
window.location.href = data.url;
|
||||
return;
|
||||
}
|
||||
setError(data?.error ?? "خطا");
|
||||
setLoading(false);
|
||||
} catch {
|
||||
setError("خطا");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className={className}
|
||||
>
|
||||
{loading
|
||||
? t("processing")
|
||||
: isFree
|
||||
? t("startFree")
|
||||
: t("choosePlan")}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-2 text-center text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
import { cfgVal } from "@/lib/home-layout";
|
||||
import { SectionReveal } from "@/components/sections/SectionReveal";
|
||||
import { PricingSectionShell } from "@/components/sections/PricingBackground";
|
||||
import { PricingBuyButton } from "@/components/sections/PricingBuyButton";
|
||||
import { SecondsCalculator } from "@/components/sections/SecondsCalculator";
|
||||
import {
|
||||
RESOLUTION_MULTIPLIERS,
|
||||
RESOLUTION_ORDER,
|
||||
formatToman,
|
||||
type SecondsPlan,
|
||||
} from "@/lib/plans-catalog";
|
||||
|
||||
export interface PricingPlansProps {
|
||||
plans: SecondsPlan[];
|
||||
className?: string;
|
||||
config?: Record<string, string>;
|
||||
currentPlanCode?: string | null;
|
||||
}
|
||||
|
||||
export function PricingPlans({
|
||||
plans,
|
||||
className,
|
||||
config,
|
||||
currentPlanCode,
|
||||
}: PricingPlansProps) {
|
||||
const t = useTranslations("pricing");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<PricingSectionShell className={className}>
|
||||
<SectionReveal className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
|
||||
{cfgVal(config, "heading", locale) ?? t("heading")}
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-relaxed text-neutral-500">
|
||||
{cfgVal(config, "subheading", locale) ?? t("subheading")}
|
||||
</p>
|
||||
</SectionReveal>
|
||||
|
||||
{plans.length === 0 ? (
|
||||
<p className="mt-12 text-center text-neutral-400">{t("emptyState")}</p>
|
||||
) : (
|
||||
<SectionReveal className="mt-12 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4 lg:gap-5">
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
locale={locale}
|
||||
isCurrent={currentPlanCode === plan.code}
|
||||
/>
|
||||
))}
|
||||
</SectionReveal>
|
||||
)}
|
||||
|
||||
{/* Seconds calculator + quality multiplier */}
|
||||
<SectionReveal className="mt-16 grid gap-6 lg:grid-cols-5">
|
||||
<div className="lg:col-span-3">
|
||||
<SecondsCalculator plans={plans} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<MultiplierTable />
|
||||
</div>
|
||||
</SectionReveal>
|
||||
|
||||
{/* FAQ */}
|
||||
<SectionReveal className="mx-auto mt-16 max-w-3xl">
|
||||
<h3 className="text-center font-heading text-2xl font-bold text-neutral-900">
|
||||
{t("faqTitle")}
|
||||
</h3>
|
||||
<div className="mt-6 space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<details
|
||||
key={i}
|
||||
className="group rounded-xl border border-neutral-200 bg-white p-5"
|
||||
>
|
||||
<summary className="cursor-pointer list-none font-medium text-neutral-900">
|
||||
{t(`faqQ${i}` as "faqQ1")}
|
||||
</summary>
|
||||
<p className="mt-2 text-sm leading-relaxed text-neutral-500">
|
||||
{t(`faqA${i}` as "faqA1")}
|
||||
</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</SectionReveal>
|
||||
</PricingSectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plan card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function PlanCard({
|
||||
plan,
|
||||
locale,
|
||||
isCurrent,
|
||||
}: {
|
||||
plan: SecondsPlan;
|
||||
locale: string;
|
||||
isCurrent: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pricing");
|
||||
const isFree = plan.priceTomans <= 0;
|
||||
|
||||
const features: string[] = [
|
||||
t("featSeconds", { seconds: plan.secondsCharge.toLocaleString(locale === "fa" ? "fa-IR" : "en-US") }),
|
||||
t("featResolution", { res: plan.maxResolution }),
|
||||
plan.parallelRenders > 1
|
||||
? t("featParallel", { n: plan.parallelRenders })
|
||||
: t("featParallelOne"),
|
||||
t("featStorage", { gb: plan.storageGb }),
|
||||
plan.renderSpeedFactor > 1
|
||||
? t("featSpeed", { factor: plan.renderSpeedFactor })
|
||||
: null,
|
||||
plan.watermark ? t("featWatermarkOn") : t("featWatermarkOff"),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col rounded-2xl border bg-white p-6 ${
|
||||
plan.isFeatured
|
||||
? "border-indigo-500 shadow-lg shadow-indigo-500/10 lg:-my-2 lg:py-8"
|
||||
: "border-neutral-200 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{plan.isFeatured && (
|
||||
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-600 to-violet-600 px-3 py-1 text-xs font-semibold text-white shadow">
|
||||
{t("mostPopular")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: plan.color ?? "#6366f1" }}
|
||||
/>
|
||||
<h3 className="font-heading text-lg font-bold text-neutral-900">
|
||||
{plan.name}
|
||||
</h3>
|
||||
</div>
|
||||
{plan.description && (
|
||||
<p className="mt-1 min-h-[2.5rem] text-sm text-neutral-500">
|
||||
{plan.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
<div className="mt-4">
|
||||
{isFree ? (
|
||||
<p className="text-3xl font-extrabold text-neutral-900">{t("free")}</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-1">
|
||||
<span className="text-3xl font-extrabold text-neutral-900">
|
||||
{formatToman(plan.priceTomans, locale)}
|
||||
</span>
|
||||
<span className="pb-1 text-sm font-medium text-neutral-500">
|
||||
{t("toman")} {t("perMonthSuffix")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{plan.beforePriceTomans && plan.beforePriceTomans > plan.priceTomans && (
|
||||
<p className="mt-0.5 text-sm text-neutral-400 line-through">
|
||||
{formatToman(plan.beforePriceTomans, locale)} {t("toman")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="mt-5 flex-1 space-y-2.5">
|
||||
{features.map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-neutral-600">
|
||||
<CheckIcon />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-6">
|
||||
{isCurrent ? (
|
||||
<div className="w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 text-center text-sm font-medium text-neutral-500">
|
||||
{t("currentPlan")}
|
||||
</div>
|
||||
) : (
|
||||
<PricingBuyButton
|
||||
plan={plan}
|
||||
className={`w-full rounded-xl py-2.5 text-sm font-semibold transition disabled:opacity-60 ${
|
||||
plan.isFeatured
|
||||
? "bg-gradient-to-r from-indigo-600 to-violet-600 text-white hover:opacity-90"
|
||||
: isFree
|
||||
? "border border-neutral-300 text-neutral-800 hover:bg-neutral-50"
|
||||
: "bg-neutral-900 text-white hover:bg-neutral-800"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Quality multiplier table ─────────────────────────────────────────────────
|
||||
|
||||
function MultiplierTable() {
|
||||
const t = useTranslations("pricing");
|
||||
return (
|
||||
<div className="h-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h3 className="font-heading text-xl font-bold text-neutral-900">
|
||||
{t("multiplierTitle")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-neutral-500">{t("multiplierDesc")}</p>
|
||||
<table className="mt-5 w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-neutral-400">
|
||||
<th className="pb-2 text-start font-medium">{t("multiplierColRes")}</th>
|
||||
<th className="pb-2 text-end font-medium">{t("multiplierColMul")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{RESOLUTION_ORDER.map((r) => (
|
||||
<tr key={r} className="border-t border-neutral-100">
|
||||
<td className="py-2.5 font-medium text-neutral-800">{r}</td>
|
||||
<td className="py-2.5 text-end font-semibold text-indigo-600" dir="ltr">
|
||||
×{RESOLUTION_MULTIPLIERS[r]}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="mt-0.5 h-4 w-4 flex-shrink-0 text-indigo-500"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.7 5.3a1 1 0 0 1 0 1.4l-7.5 7.5a1 1 0 0 1-1.4 0L3.3 9.7a1 1 0 1 1 1.4-1.4l3.3 3.3 6.8-6.8a1 1 0 0 1 1.4 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
RESOLUTION_ORDER,
|
||||
renderSecondsCost,
|
||||
type SecondsPlan,
|
||||
} from "@/lib/plans-catalog";
|
||||
|
||||
interface Props {
|
||||
plans: SecondsPlan[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive "how many seconds do I need" helper. The user picks a video length
|
||||
* and resolution; we show the per-render cost (length × resolution multiplier)
|
||||
* and how many such videos each paid plan's monthly seconds would cover.
|
||||
*/
|
||||
export function SecondsCalculator({ plans }: Props) {
|
||||
const t = useTranslations("pricing");
|
||||
const [length, setLength] = useState(15);
|
||||
const [resolution, setResolution] = useState("720p");
|
||||
|
||||
const cost = useMemo(
|
||||
() => renderSecondsCost(length, resolution),
|
||||
[length, resolution]
|
||||
);
|
||||
|
||||
const paidPlans = plans.filter((p) => p.priceTomans > 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h3 className="font-heading text-xl font-bold text-neutral-900">
|
||||
{t("calcTitle")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-neutral-500">{t("calcDesc")}</p>
|
||||
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
{/* Length */}
|
||||
<div>
|
||||
<label className="mb-2 flex items-center justify-between text-sm font-medium text-neutral-700">
|
||||
<span>{t("calcLength")}</span>
|
||||
<span className="font-bold text-neutral-900">
|
||||
{length} {t("calcSecondsUnit")}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={120}
|
||||
value={length}
|
||||
onChange={(e) => setLength(Number(e.target.value))}
|
||||
className="w-full accent-indigo-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resolution */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-neutral-700">
|
||||
{t("calcResolution")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{RESOLUTION_ORDER.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setResolution(r)}
|
||||
className={`rounded-lg border px-2.5 py-1.5 text-xs font-medium transition ${
|
||||
resolution === r
|
||||
? "border-indigo-600 bg-indigo-600 text-white"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300"
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost */}
|
||||
<div className="mt-6 flex flex-wrap items-end justify-between gap-4 rounded-xl bg-neutral-50 p-5">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">{t("calcCost")}</p>
|
||||
<p className="mt-1 text-3xl font-extrabold text-neutral-900">
|
||||
{cost}{" "}
|
||||
<span className="text-base font-medium text-neutral-500">
|
||||
{t("calcSecondsUnit")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{paidPlans.length > 0 && (
|
||||
<div className="text-end">
|
||||
<p className="mb-1 text-sm text-neutral-500">
|
||||
{t("calcRendersWith")}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{paidPlans.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs text-neutral-700"
|
||||
>
|
||||
<span className="font-semibold text-neutral-900">
|
||||
{p.name}
|
||||
</span>
|
||||
{": "}
|
||||
{t("calcVideosFmt", {
|
||||
count: Math.floor(p.secondsCharge / Math.max(cost, 1)),
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,8 +35,8 @@ export function TemplateDetailPreview({
|
||||
const t = useTranslations("auto.componentsTemplatesTemplateDetailPreview");
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const aspectOptions = getTemplateDetailAspectRatios(template);
|
||||
const posterSrc = getVideoTemplateImageSrc(template.id);
|
||||
const videoSrc = getTemplatePreviewVideoSrc(template.id);
|
||||
const posterSrc = template.coverImageUrl ?? getVideoTemplateImageSrc(template.id);
|
||||
const videoSrc = template.previewVideoUrl ?? getTemplatePreviewVideoSrc(template.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -27,8 +27,8 @@ export function VideoTemplateCompactCard({
|
||||
const t = useTranslations("auto.componentsTemplatesVideoVideoTemplateCompactCard");
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const imageSrc = getVideoTemplateImageSrc(template.id);
|
||||
const videoSrc = getTemplatePreviewVideoSrc(template.id);
|
||||
const imageSrc = template.coverImageUrl ?? getVideoTemplateImageSrc(template.id);
|
||||
const videoSrc = template.previewVideoUrl ?? getTemplatePreviewVideoSrc(template.id);
|
||||
const detailHref = `/templates/${template.id}`;
|
||||
|
||||
const handleEnter = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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<string, number> = {
|
||||
"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<string, unknown> | 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<SecondsPlan[]> {
|
||||
// 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);
|
||||
}
|
||||
@@ -84,6 +84,10 @@ export interface VideoCatalogTemplate {
|
||||
scriptToVideo: boolean;
|
||||
description?: string;
|
||||
isNew?: boolean;
|
||||
/** Real thumbnail + preview video URLs (from the admin catalog). When present,
|
||||
* used instead of the generated placeholder. */
|
||||
coverImageUrl?: string;
|
||||
previewVideoUrl?: string;
|
||||
}
|
||||
|
||||
export function getVideoTemplateCategoryLabel(
|
||||
@@ -517,9 +521,11 @@ export function adminProjectToCatalogTemplate(
|
||||
premium: false,
|
||||
sceneCount: 0,
|
||||
supports4k: false,
|
||||
colorChange: false,
|
||||
colorChange: true,
|
||||
scriptToVideo: false,
|
||||
description: p.description,
|
||||
isNew: true,
|
||||
coverImageUrl: p.coverImageUrl,
|
||||
previewVideoUrl: p.previewVideoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
+3
-1
@@ -132,6 +132,8 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||
// Skip API, Next internals, and any path with a file extension (static
|
||||
// assets: images, video, fonts, etc. served from public/).
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.[a-zA-Z0-9]+$).*)",
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user