blog2video-test / frontend /src /components /CustomTemplateLimitModal.tsx
Muhammad-Mehdi's picture
lite plan added
ac490ae
Raw
History Blame Contribute Delete
14.8 kB
import { useState } from "react";
import ReactDOM from "react-dom";
import {
createCheckoutSession,
createCustomTemplateCheckout,
} from "../api/client";
import {
LITE_MONTHLY_PRICE,
LITE_ANNUAL_MONTHLY_PRICE,
LITE_CUSTOM_TEMPLATE_COUNT,
LITE_AI_EDIT_ALLOWANCE,
STANDARD_MONTHLY_PRICE,
STANDARD_ANNUAL_MONTHLY_PRICE,
STANDARD_AI_EDIT_ALLOWANCE,
PRO_MONTHLY_PRICE,
PRO_ANNUAL_MONTHLY_PRICE,
PRO_AI_EDIT_ALLOWANCE,
STANDARD_CUSTOM_TEMPLATE_COUNT,
PRO_CUSTOM_TEMPLATE_COUNT,
} from "../content/pricingContent";
import { useAuth } from "../hooks/useAuth";
interface Props {
open: boolean;
onClose: () => void;
/** Optional custom heading */
title?: string;
/** Optional custom subtext below the heading */
subtitle?: string;
}
/**
* Compact, plan-tiered upgrade modal for the custom-template creation limit.
*
* Unlike the general UpgradePlanModal, this is purpose-built for the templates
* tab: it shows only the plans that are an actual step up for the current user
* (free → Lite + Standard + Pro, lite → Standard + Pro, standard → Pro, pro → none)
* plus a one-off $5 slot that's always available. No per-video card, and it fits
* without scrolling.
*/
export default function CustomTemplateLimitModal({
open,
onClose,
title: titleProp,
subtitle: subtitleProp,
}: Props) {
const { user } = useAuth();
const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly");
const [slotQty, setSlotQty] = useState(1);
const SLOT_PRICE = 5;
const SLOT_MIN = 1;
const SLOT_MAX = 20;
if (!open) return null;
const plan = user?.plan ?? "free";
const showLite = plan === "free";
const showStandard = plan === "free" || plan === "lite";
const showPro = plan !== "pro";
const handleSubscribe = async (target: "lite" | "standard" | "pro") => {
if (!user) {
window.location.href = "/pricing";
return;
}
setLoadingPlan(target);
try {
const res = await createCheckoutSession({
plan: target,
billing_cycle: billingCycle,
});
window.location.href = res.data.checkout_url;
} catch {
setLoadingPlan(null);
}
};
const handleExtraSlot = async () => {
if (!user) {
window.location.href = "/pricing";
return;
}
setLoadingPlan("extra_slot");
try {
const res = await createCustomTemplateCheckout(slotQty);
if (res.data.checkout_url) window.location.href = res.data.checkout_url;
} catch {
setLoadingPlan(null);
}
};
const clampQty = (n: number) => Math.max(SLOT_MIN, Math.min(n, SLOT_MAX));
const planCards = (showLite ? 1 : 0) + (showStandard ? 1 : 0) + (showPro ? 1 : 0);
const gridCols =
planCards >= 3
? "sm:grid-cols-3"
: planCards >= 2
? "sm:grid-cols-2"
: "sm:grid-cols-1 sm:max-w-sm sm:mx-auto";
return ReactDOM.createPortal(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div
className="relative bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 w-7 h-7 flex items-center justify-center rounded-full border border-purple-500/80 text-purple-600 hover:bg-purple-600 hover:text-white hover:border-purple-600 transition-colors"
title="Close"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-semibold text-gray-900 pr-8">
{titleProp ?? "You've reached your custom-template limit"}
</h2>
<p className="text-sm text-gray-500 mt-1">
{subtitleProp ??
"Upgrade your plan for more custom templates, or grab a single extra slot."}
</p>
{planCards > 0 && (
<>
{/* Monthly / Annual toggle */}
<div className="flex items-center gap-2 mt-4">
<span className={`text-xs font-medium ${billingCycle === "monthly" ? "text-gray-900" : "text-gray-400"}`}>
Monthly
</span>
<button
type="button"
onClick={() => setBillingCycle(billingCycle === "monthly" ? "annual" : "monthly")}
className={`relative w-10 h-5 rounded-full transition-colors ${billingCycle === "annual" ? "bg-purple-600" : "bg-gray-200"}`}
>
<div
className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${billingCycle === "annual" ? "translate-x-5" : "translate-x-0.5"}`}
/>
</button>
<span className={`text-xs font-medium ${billingCycle === "annual" ? "text-gray-900" : "text-gray-400"}`}>
Annual
</span>
<span className="px-1.5 py-0.5 bg-green-50 text-green-600 text-[10px] font-semibold rounded-full">
Save 20%
</span>
</div>
<div className={`grid grid-cols-1 ${gridCols} gap-4 mt-5`}>
{showLite && (
<PlanCard
name="Lite"
blurb={`${LITE_CUSTOM_TEMPLATE_COUNT} custom templates`}
monthly={LITE_MONTHLY_PRICE}
annualMonthly={LITE_ANNUAL_MONTHLY_PRICE}
billingCycle={billingCycle}
templateCount={LITE_CUSTOM_TEMPLATE_COUNT}
videoCount={10}
aiEditLine={`${LITE_AI_EDIT_ALLOWANCE} AI edit credits/month`}
cta="Upgrade to Lite"
loading={loadingPlan === "lite"}
disabled={!!loadingPlan}
onClick={() => handleSubscribe("lite")}
/>
)}
{showStandard && (
<PlanCard
name="Standard"
blurb={`${STANDARD_CUSTOM_TEMPLATE_COUNT} custom templates`}
monthly={STANDARD_MONTHLY_PRICE}
annualMonthly={STANDARD_ANNUAL_MONTHLY_PRICE}
billingCycle={billingCycle}
templateCount={STANDARD_CUSTOM_TEMPLATE_COUNT}
videoCount={30}
aiEditLine={`${STANDARD_AI_EDIT_ALLOWANCE} AI edit credits/month`}
cta="Upgrade to Standard"
loading={loadingPlan === "standard"}
disabled={!!loadingPlan}
onClick={() => handleSubscribe("standard")}
/>
)}
{showPro && (
<PlanCard
name="Pro"
blurb={`${PRO_CUSTOM_TEMPLATE_COUNT} custom templates`}
monthly={PRO_MONTHLY_PRICE}
annualMonthly={PRO_ANNUAL_MONTHLY_PRICE}
billingCycle={billingCycle}
templateCount={PRO_CUSTOM_TEMPLATE_COUNT}
videoCount={100}
aiEditLine={`${PRO_AI_EDIT_ALLOWANCE} AI edit credits/month`}
cta="Upgrade to Pro"
highlight
loading={loadingPlan === "pro"}
disabled={!!loadingPlan}
onClick={() => handleSubscribe("pro")}
/>
)}
</div>
</>
)}
{/* One-off extra slots — always available, even on Pro. */}
<div className="mt-5 flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-purple-100 bg-purple-50/50 px-4 py-3">
<div>
<p className="text-sm font-medium text-gray-900">
{slotQty > 1 ? `Need ${slotQty} more templates?` : "Just need one more template?"}
</p>
<p className="text-xs text-gray-500 mt-0.5">
Buy custom template slots — one-time $5 each, no subscription.
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
{/* Ticker counter */}
<div className="flex items-center gap-1 rounded-full bg-purple-100/70 p-1">
<button
type="button"
onClick={() => setSlotQty((q) => clampQty(q - 1))}
disabled={!!loadingPlan || slotQty <= SLOT_MIN}
aria-label="Decrease quantity"
className="w-7 h-7 flex items-center justify-center rounded-full text-purple-700 hover:bg-white hover:shadow-sm transition-all disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:shadow-none"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" d="M5 12h14" />
</svg>
</button>
<span className="w-6 text-center text-sm font-semibold text-gray-900 tabular-nums select-none">
{slotQty}
</span>
<button
type="button"
onClick={() => setSlotQty((q) => clampQty(q + 1))}
disabled={!!loadingPlan || slotQty >= SLOT_MAX}
aria-label="Increase quantity"
className="w-7 h-7 flex items-center justify-center rounded-full text-purple-700 hover:bg-white hover:shadow-sm transition-all disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:shadow-none"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
<button
onClick={handleExtraSlot}
disabled={!!loadingPlan}
className="shrink-0 px-4 py-2 text-xs font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors disabled:opacity-60"
>
{loadingPlan === "extra_slot" ? "Redirecting…" : `Buy — $${slotQty * SLOT_PRICE}`}
</button>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-4">
<button
onClick={onClose}
className="text-sm text-gray-400 hover:text-purple-600 transition-colors"
>
Maybe later
</button>
{/* Share B2V disabled
<a
href="/survey"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-purple-50 border border-purple-200 text-xs font-medium text-purple-700 hover:bg-purple-100 hover:border-purple-400 transition-all"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 12 20 22 4 22 4 12" /><rect x="2" y="7" width="20" height="5" /><line x1="12" y1="22" x2="12" y2="7" />
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z" /><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z" />
</svg>
Share B2V to get 5 videos free
</a> */}
</div>
</div>
</div>
</div>,
document.body
);
}
function PlanCard({
name,
blurb,
monthly,
annualMonthly,
billingCycle,
templateCount,
videoCount,
aiEditLine,
cta,
highlight,
loading,
disabled,
onClick,
}: {
name: string;
blurb: string;
monthly: number;
annualMonthly: number;
billingCycle: "monthly" | "annual";
templateCount: number;
videoCount: number;
aiEditLine?: string;
cta: string;
highlight?: boolean;
loading: boolean;
disabled: boolean;
onClick: () => void;
}) {
const isAnnual = billingCycle === "annual";
return (
<div className={`glass-card p-5 flex flex-col relative ${highlight ? "ring-2 ring-purple-100" : ""}`}>
{highlight && (
<div className="absolute -top-2.5 left-1/2 -translate-x-1/2">
<span className="px-3 py-0.5 bg-purple-600 text-white text-[10px] font-semibold rounded-full">
Best value
</span>
</div>
)}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-900">{name}</h3>
<p className="text-xs text-gray-400 mt-0.5">{blurb}</p>
</div>
<div className="mb-4">
<span className="text-2xl font-bold text-gray-900">${isAnnual ? annualMonthly : monthly}</span>
<span className="text-xs text-gray-400 ml-1">/month</span>
<p className="text-[10px] text-gray-400 mt-1">
{isAnnual
? `billed annually`
: <>or <span className="font-medium text-gray-500">${annualMonthly}/mo</span> billed annually</>}
</p>
</div>
<ul className="space-y-2 mb-5 flex-1 text-xs text-gray-500">
<li className="flex items-start gap-2"><CheckMark /><span className="font-medium text-gray-700">{templateCount} custom video templates</span></li>
<li className="flex items-start gap-2"><CheckMark />{videoCount} videos / month</li>
<li className="flex items-start gap-2"><CheckMark />{aiEditLine}</li>
<li className="flex items-start gap-2"><CheckMark />Premium voiceover + cloning</li>
<li className="flex items-start gap-2"><CheckMark />Priority support</li>
</ul>
<button
onClick={onClick}
disabled={disabled}
className={`w-full py-2 text-xs font-medium text-white rounded-lg transition-colors disabled:opacity-60 ${
highlight ? "bg-purple-600 hover:bg-purple-700" : "bg-gray-900 hover:bg-gray-800"
}`}
>
{loading ? "Redirecting…" : cta}
</button>
</div>
);
}
function CheckMark() {
return (
<svg className="w-3.5 h-3.5 text-purple-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
);
}