feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
CI/CD / CI · Web (tsc) (push) Successful in 1m21s
CI/CD / Deploy · full stack (push) Failing after 20s

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:
soroush.asadi
2026-06-21 15:52:52 +03:30
parent b9b91397b0
commit 4f04f6bf75
137 changed files with 8942 additions and 135 deletions
+2
View File
@@ -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>
+1 -1
View File
@@ -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",
});
+8 -47
View File
@@ -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) {
+43 -15
View File
@@ -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 })}>
صحنهها و رنگها
+220
View File
@@ -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>
);
}
+10 -46
View File
@@ -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>
);
}
+251
View File
@@ -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(() => {
+131
View File
@@ -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);
}
+7 -1
View File
@@ -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
View File
@@ -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]+$).*)",
],
};