add subscription billing flow and Stripe integration components
This commit is contained in:
parent
df5f593369
commit
5d5484c0b0
371
src/client/features/billing/ActivePlanStatusBanner.tsx
Normal file
371
src/client/features/billing/ActivePlanStatusBanner.tsx
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Calendar,
|
||||||
|
RefreshCw,
|
||||||
|
CreditCard,
|
||||||
|
ExternalLink,
|
||||||
|
Sparkles,
|
||||||
|
ShieldCheck,
|
||||||
|
ChevronRight,
|
||||||
|
ArrowDown,
|
||||||
|
Loader2,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { CRAWLERX_PLANS, type PricingPlan, type BillingInterval } from "./PricingData";
|
||||||
|
|
||||||
|
export interface OrganizationBillingData {
|
||||||
|
isPaying: boolean;
|
||||||
|
planId: "free" | "starter" | "pro" | "agency";
|
||||||
|
status: "active" | "trialing" | "canceled" | "past_due" | "incomplete" | "none";
|
||||||
|
currentPeriodEnd: number | null;
|
||||||
|
daysRemaining: number | null;
|
||||||
|
hoursRemaining: number | null;
|
||||||
|
formattedExpiry: string | null;
|
||||||
|
isTrial: boolean;
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
interval: "monthly" | "annual";
|
||||||
|
customerId: string | null;
|
||||||
|
hasCustomer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivePlanStatusBannerProps {
|
||||||
|
billing?: OrganizationBillingData;
|
||||||
|
onRenewPlan: (plan: PricingPlan, interval: BillingInterval) => void;
|
||||||
|
onOpenPortal: () => void;
|
||||||
|
isRenewing?: boolean;
|
||||||
|
isOpeningPortal?: boolean;
|
||||||
|
onChangePlanClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivePlanStatusBanner({
|
||||||
|
billing,
|
||||||
|
onRenewPlan,
|
||||||
|
onOpenPortal,
|
||||||
|
isRenewing = false,
|
||||||
|
isOpeningPortal = false,
|
||||||
|
onChangePlanClick,
|
||||||
|
}: ActivePlanStatusBannerProps) {
|
||||||
|
const [showRenewModal, setShowRenewModal] = React.useState(false);
|
||||||
|
const [selectedInterval, setSelectedInterval] = React.useState<BillingInterval>(
|
||||||
|
billing?.interval ?? "monthly",
|
||||||
|
);
|
||||||
|
|
||||||
|
const planId = billing?.planId ?? "free";
|
||||||
|
const currentPlan =
|
||||||
|
CRAWLERX_PLANS.find((p) => p.id === planId) ?? CRAWLERX_PLANS[0];
|
||||||
|
|
||||||
|
const isPaid = billing?.isPaying && planId !== "free";
|
||||||
|
const days = billing?.daysRemaining ?? 0;
|
||||||
|
const hours = billing?.hoursRemaining ?? 0;
|
||||||
|
const isTrial = billing?.isTrial ?? false;
|
||||||
|
|
||||||
|
// Approximate percentage of billing cycle remaining
|
||||||
|
const totalCycleDays = isTrial ? 7 : billing?.interval === "annual" ? 365 : 30;
|
||||||
|
const percentRemaining = Math.max(
|
||||||
|
5,
|
||||||
|
Math.min(100, Math.round(((days * 24 + hours) / (totalCycleDays * 24)) * 100)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isPaid) {
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-3xl border border-slate-200/90 bg-white p-6 shadow-sm">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3.5">
|
||||||
|
<div className="size-11 rounded-2xl bg-slate-100 flex items-center justify-center text-slate-700">
|
||||||
|
<Zap className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-slate-900 text-base">Free Plan Active</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-100 text-slate-600 uppercase tracking-wider">
|
||||||
|
Basic Tier
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
1 Managed Site • 100 Pages per crawl • Basic SEO metrics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onChangePlanClick}
|
||||||
|
className="h-10 px-5 rounded-xl bg-gradient-to-r from-[#3779b9] to-[#2563eb] text-white text-xs font-bold shadow-sm hover:opacity-95 transition-all flex items-center gap-2 self-start sm:self-auto cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>Upgrade Plan</span>
|
||||||
|
<ArrowDown className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative overflow-hidden rounded-[28px] border border-[#3779b9]/30 bg-gradient-to-br from-[#0f1d34] via-[#13233f] to-[#1a335a] text-white shadow-xl shadow-[#0f1d34]/15 p-6 sm:p-8 transition-all">
|
||||||
|
{/* Subtle decorative background glow */}
|
||||||
|
<div className="absolute top-0 right-0 -mr-16 -mt-16 size-72 rounded-full bg-[#3779b9]/20 blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 left-1/3 -mb-16 size-60 rounded-full bg-blue-500/10 blur-2xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col lg:flex-row lg:items-center justify-between gap-6">
|
||||||
|
{/* Left: Plan Info & Time Remaining Highlight */}
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
{/* Top Badges */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold tracking-wider uppercase bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||||
|
<span className="size-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
{isTrial ? "7-Day Free Trial" : "Active Subscription"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-semibold bg-white/10 text-slate-200 border border-white/10 backdrop-blur-sm">
|
||||||
|
<ShieldCheck className="size-3.5 text-cyan-300" />
|
||||||
|
CrawlerX {currentPlan.name} Tier
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-xs text-slate-300 font-medium">
|
||||||
|
• Billed {billing?.interval === "annual" ? "Annually" : "Monthly"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & Limits */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl sm:text-2xl font-extrabold text-white tracking-tight flex items-center gap-2.5">
|
||||||
|
<span>{currentPlan.name} Plan</span>
|
||||||
|
<span className="text-sm font-normal text-slate-300">
|
||||||
|
({currentPlan.sitesLimit})
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-300 mt-1">
|
||||||
|
Includes {currentPlan.limits.pagesPerAudit} Pages/audit, {currentPlan.limits.trackedKeywords} Tracked Keywords, and AI Fix Engine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown Box: Days & Hours Remaining */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||||
|
<div className="flex items-center gap-2 bg-white/10 border border-white/15 px-3.5 py-2 rounded-2xl backdrop-blur-md">
|
||||||
|
<Clock className="size-4 text-cyan-300 shrink-0" />
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-lg sm:text-xl font-black text-white font-mono">
|
||||||
|
{days}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-slate-300">
|
||||||
|
{days === 1 ? "day" : "days"}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg sm:text-xl font-black text-white font-mono ml-1">
|
||||||
|
{hours}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-slate-300">
|
||||||
|
{hours === 1 ? "hour" : "hours"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-300 ml-1">remaining</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{billing?.formattedExpiry ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-300 bg-white/5 border border-white/10 px-3 py-2 rounded-2xl">
|
||||||
|
<Calendar className="size-3.5 text-slate-400 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{isTrial ? "Trial ends" : "Next renewal"}:{" "}
|
||||||
|
<strong className="text-white font-semibold">
|
||||||
|
{billing.formattedExpiry}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar of current cycle */}
|
||||||
|
<div className="space-y-1.5 max-w-md pt-0.5">
|
||||||
|
<div className="flex justify-between text-[11px] text-slate-300 font-medium">
|
||||||
|
<span>Billing Period Progress</span>
|
||||||
|
<span className="text-cyan-300 font-bold">{days} days left</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden p-0.5">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-emerald-400 via-cyan-400 to-[#3779b9] transition-all duration-500"
|
||||||
|
style={{ width: `${percentRemaining}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Actions (Renew Plan & Customer Portal) */}
|
||||||
|
<div className="flex flex-col sm:flex-row lg:flex-col gap-3 shrink-0 lg:w-64 pt-2 lg:pt-0">
|
||||||
|
{/* Primary Renew Plan Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRenewModal(true)}
|
||||||
|
disabled={isRenewing}
|
||||||
|
className="w-full h-12 px-5 rounded-2xl bg-gradient-to-r from-[#3779b9] via-[#2563eb] to-[#38bdf8] hover:from-[#2e68a3] hover:to-[#0284c7] text-white text-sm font-bold shadow-lg shadow-[#2563eb]/25 transition-all flex items-center justify-center gap-2 active:scale-[0.99] cursor-pointer disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isRenewing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin text-white" />
|
||||||
|
<span>Processing...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="size-4 text-cyan-200" />
|
||||||
|
<span>Renew Plan</span>
|
||||||
|
<ChevronRight className="size-4 opacity-75" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Manage Subscription / Billing Portal */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenPortal}
|
||||||
|
disabled={isOpeningPortal}
|
||||||
|
className="w-full h-11 px-4 rounded-2xl bg-white/10 hover:bg-white/15 border border-white/15 text-white text-xs font-semibold transition-all flex items-center justify-center gap-2 active:scale-[0.99] cursor-pointer disabled:opacity-60 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
{isOpeningPortal ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
<span>Opening Portal...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CreditCard className="size-3.5 text-slate-300" />
|
||||||
|
<span>Manage Billing & Invoices</span>
|
||||||
|
<ExternalLink className="size-3 opacity-60 ml-0.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Change Plan shortcut */}
|
||||||
|
{onChangePlanClick ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onChangePlanClick}
|
||||||
|
className="text-center text-xs font-semibold text-slate-300 hover:text-white transition-colors flex items-center justify-center gap-1 py-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>Change to another plan</span>
|
||||||
|
<ArrowDown className="size-3" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Renew Plan Dialog / Modal */}
|
||||||
|
{showRenewModal ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="relative w-full max-w-md rounded-3xl bg-white p-6 sm:p-7 shadow-2xl border border-slate-200 text-[#0f1d34] space-y-5">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-10 rounded-2xl bg-blue-50 text-[#3779b9] flex items-center justify-center">
|
||||||
|
<RefreshCw className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-slate-900">
|
||||||
|
Renew {currentPlan.name} Plan
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Extend your subscription cycle seamlessly
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRenewModal(false)}
|
||||||
|
className="size-8 rounded-full bg-slate-100 hover:bg-slate-200 text-slate-500 flex items-center justify-center text-sm font-bold cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Details */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-50 border border-slate-200/70 space-y-2 text-xs">
|
||||||
|
<div className="flex justify-between text-slate-600">
|
||||||
|
<span>Current Plan:</span>
|
||||||
|
<strong className="text-slate-900 font-semibold">{currentPlan.name} ({currentPlan.sitesLimit})</strong>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-slate-600">
|
||||||
|
<span>Time Remaining:</span>
|
||||||
|
<strong className="text-emerald-700 font-semibold">{days} days, {hours} hours</strong>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-slate-600">
|
||||||
|
<span>Target Renewal Date:</span>
|
||||||
|
<strong className="text-slate-900 font-semibold">{billing?.formattedExpiry ?? "At end of cycle"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Billing Interval Toggle */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-slate-700 block">
|
||||||
|
Select Renewal Duration
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedInterval("monthly")}
|
||||||
|
className={`p-3 rounded-2xl border text-left transition-all cursor-pointer ${
|
||||||
|
selectedInterval === "monthly"
|
||||||
|
? "border-[#3779b9] bg-blue-50/60 ring-2 ring-[#3779b9]/20"
|
||||||
|
: "border-slate-200 hover:border-slate-300 bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-bold text-slate-900">Monthly Renewal</div>
|
||||||
|
<div className="text-sm font-extrabold text-[#3779b9] mt-0.5">
|
||||||
|
₹{currentPlan.monthlyPrice.INR.toLocaleString()}/mo
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-500 mt-0.5">Billed every month</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedInterval("annual")}
|
||||||
|
className={`relative p-3 rounded-2xl border text-left transition-all cursor-pointer ${
|
||||||
|
selectedInterval === "annual"
|
||||||
|
? "border-[#3779b9] bg-blue-50/60 ring-2 ring-[#3779b9]/20"
|
||||||
|
: "border-slate-200 hover:border-slate-300 bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="absolute -top-2 right-2 px-1.5 py-0.5 rounded-full text-[9px] font-extrabold bg-emerald-600 text-white">
|
||||||
|
SAVE 17%
|
||||||
|
</span>
|
||||||
|
<div className="text-xs font-bold text-slate-900">Annual Renewal</div>
|
||||||
|
<div className="text-sm font-extrabold text-[#3779b9] mt-0.5">
|
||||||
|
₹{currentPlan.annualMonthlyPrice.INR.toLocaleString()}/mo
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-500 mt-0.5">Billed annually</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRenewModal(false)}
|
||||||
|
className="w-1/3 h-11 rounded-xl border border-slate-200 hover:bg-slate-100 text-slate-700 text-xs font-bold cursor-pointer transition-all"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isRenewing}
|
||||||
|
onClick={() => {
|
||||||
|
setShowRenewModal(false);
|
||||||
|
onRenewPlan(currentPlan, selectedInterval);
|
||||||
|
}}
|
||||||
|
className="w-2/3 h-11 rounded-xl bg-gradient-to-r from-[#3779b9] to-[#2563eb] hover:from-[#2e68a3] hover:to-[#1d4ed8] text-white text-xs font-bold shadow-md shadow-[#3779b9]/25 flex items-center justify-center gap-1.5 cursor-pointer transition-all disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isRenewing ? (
|
||||||
|
<Loader2 className="size-4 animate-spin text-white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="size-3.5 text-cyan-200" />
|
||||||
|
<span>Proceed to Renew</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/client/features/billing/SubscribeAccountMenu.tsx
Normal file
51
src/client/features/billing/SubscribeAccountMenu.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { Settings, User } from "lucide-react";
|
||||||
|
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||||
|
import { signOutAndRedirect } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
interface SubscribeAccountMenuProps {
|
||||||
|
email: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscribeAccountMenu({ email }: SubscribeAccountMenuProps) {
|
||||||
|
if (!email) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="btn btn-ghost btn-circle btn-sm"
|
||||||
|
aria-label="Open account menu"
|
||||||
|
>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<ul
|
||||||
|
tabIndex={0}
|
||||||
|
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl"
|
||||||
|
>
|
||||||
|
<li className="menu-title max-w-full">
|
||||||
|
<span className="truncate text-slate-900 text-xs" data-ph-mask>
|
||||||
|
{email}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/settings" className="flex items-center gap-2 text-xs">
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<ThemePreferenceMenuItems />
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-rose-600 text-xs"
|
||||||
|
onClick={() => void signOutAndRedirect()}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useCustomer } from "autumn-js/react";
|
import { useCustomer } from "autumn-js/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ArrowLeft, ChevronDown, Globe, Settings, User } from "lucide-react";
|
import { ArrowLeft, ChevronDown, Globe } from "lucide-react";
|
||||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
||||||
@ -20,6 +19,7 @@ import type {
|
|||||||
} from "@/client/features/billing/PricingData";
|
} from "@/client/features/billing/PricingData";
|
||||||
import {
|
import {
|
||||||
createStripeCheckoutSession,
|
createStripeCheckoutSession,
|
||||||
|
createStripePortalSession,
|
||||||
getOrganizationBilling,
|
getOrganizationBilling,
|
||||||
verifyStripeCheckoutSession,
|
verifyStripeCheckoutSession,
|
||||||
} from "@/serverFunctions/stripe";
|
} from "@/serverFunctions/stripe";
|
||||||
@ -30,6 +30,8 @@ import {
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { getProjects } from "@/serverFunctions/projects";
|
import { getProjects } from "@/serverFunctions/projects";
|
||||||
import { WorkspaceUsageBanner } from "@/client/features/billing/WorkspaceUsageBanner";
|
import { WorkspaceUsageBanner } from "@/client/features/billing/WorkspaceUsageBanner";
|
||||||
|
import { ActivePlanStatusBanner } from "@/client/features/billing/ActivePlanStatusBanner";
|
||||||
|
import { SubscribeAccountMenu } from "@/client/features/billing/SubscribeAccountMenu";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const SUPPORT_EMAIL = "support@metatroncube.com";
|
const SUPPORT_EMAIL = "support@metatroncube.com";
|
||||||
@ -68,6 +70,8 @@ function SubscribePage() {
|
|||||||
} = Route.useSearch();
|
} = Route.useSearch();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
|
const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
|
||||||
|
const [isRenewing, setIsRenewing] = useState(false);
|
||||||
|
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [finalizingTimedOut, setFinalizingTimedOut] = useState(false);
|
const [finalizingTimedOut, setFinalizingTimedOut] = useState(false);
|
||||||
const [hasVerifiedSession, setHasVerifiedSession] = useState(false);
|
const [hasVerifiedSession, setHasVerifiedSession] = useState(false);
|
||||||
@ -247,13 +251,48 @@ function SubscribePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRenewPlan(
|
||||||
|
plan: PricingPlan,
|
||||||
|
interval: BillingInterval = "monthly",
|
||||||
|
) {
|
||||||
|
setIsRenewing(true);
|
||||||
|
try {
|
||||||
|
await handleSelectPlan(plan, interval, "INR");
|
||||||
|
} finally {
|
||||||
|
setIsRenewing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenPortal() {
|
||||||
|
if (!canManageBilling) {
|
||||||
|
toast.error("Only the organization owner can manage billing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpeningPortal(true);
|
||||||
|
try {
|
||||||
|
const res = await createStripePortalSession();
|
||||||
|
if (res?.portalUrl) {
|
||||||
|
window.location.assign(res.portalUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("Unable to retrieve Stripe billing portal URL.");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as { message?: string; data?: { message?: string } } | undefined;
|
||||||
|
const msg = errObj?.data?.message || errObj?.message || "Failed to open billing portal.";
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setIsOpeningPortal(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-[#f8fafc] text-[#151515] overflow-x-hidden selection:bg-[#3779b9] selection:text-white">
|
<div className="relative min-h-screen bg-[#f8fafc] text-[#151515] overflow-x-hidden selection:bg-[#3779b9] selection:text-white">
|
||||||
{/* 3D Organic Fluid Corner Ribbons & Aura in Theme Blues */}
|
{/* 3D Organic Fluid Corner Ribbons & Aura in Theme Blues */}
|
||||||
<CornerWaveDecoration />
|
<CornerWaveDecoration />
|
||||||
|
|
||||||
{/* Main Content Container */}
|
{/* Main Content Container */}
|
||||||
<div className="relative z-10 max-w-[1680px] w-full mx-auto px-4 sm:px-8 lg:px-12 py-6 sm:py-8 space-y-12">
|
<div className="relative z-10 max-w-[1680px] w-full mx-auto px-4 sm:px-8 lg:px-12 py-6 sm:py-8 space-y-10">
|
||||||
{/* Top Navbar */}
|
{/* Top Navbar */}
|
||||||
<header className="flex items-center justify-between gap-4 py-2">
|
<header className="flex items-center justify-between gap-4 py-2">
|
||||||
{/* Exact CrawlerX Cube Logo & Subtitle */}
|
{/* Exact CrawlerX Cube Logo & Subtitle */}
|
||||||
@ -296,10 +335,23 @@ function SubscribePage() {
|
|||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<SubscribePageAccountMenu email={session?.user?.email} />
|
<SubscribeAccountMenu email={session?.user?.email} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Active Plan Status & Expiry Details */}
|
||||||
|
<ActivePlanStatusBanner
|
||||||
|
billing={billingQuery.data}
|
||||||
|
onRenewPlan={handleRenewPlan}
|
||||||
|
onOpenPortal={handleOpenPortal}
|
||||||
|
isRenewing={isRenewing}
|
||||||
|
isOpeningPortal={isOpeningPortal}
|
||||||
|
onChangePlanClick={() => {
|
||||||
|
const el = document.getElementById("pricing-plans");
|
||||||
|
el?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Workspace Usage Notice */}
|
{/* Workspace Usage Notice */}
|
||||||
<WorkspaceUsageBanner
|
<WorkspaceUsageBanner
|
||||||
currentPlanId={activePlanId}
|
currentPlanId={activePlanId}
|
||||||
@ -333,46 +385,3 @@ function SubscribePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
|
|
||||||
if (!email) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dropdown dropdown-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className="btn btn-ghost btn-circle btn-sm"
|
|
||||||
aria-label="Open account menu"
|
|
||||||
>
|
|
||||||
<User className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<ul
|
|
||||||
tabIndex={0}
|
|
||||||
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl"
|
|
||||||
>
|
|
||||||
<li className="menu-title max-w-full">
|
|
||||||
<span className="truncate text-slate-900 text-xs" data-ph-mask>
|
|
||||||
{email}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link to="/settings" className="flex items-center gap-2 text-xs">
|
|
||||||
<Settings className="h-3.5 w-3.5" />
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<ThemePreferenceMenuItems />
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-rose-600 text-xs"
|
|
||||||
onClick={() => void signOutAndRedirect()}
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -32,7 +32,105 @@ export async function syncStripeSubscriptionStatus(args: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrganizationBillingStatus(organizationId: string) {
|
export interface OrganizationBillingDetails {
|
||||||
|
isPaying: boolean;
|
||||||
|
planId: "free" | "starter" | "pro" | "agency";
|
||||||
|
status: "active" | "trialing" | "canceled" | "past_due" | "incomplete" | "none";
|
||||||
|
currentPeriodEnd: number | null;
|
||||||
|
daysRemaining: number | null;
|
||||||
|
hoursRemaining: number | null;
|
||||||
|
formattedExpiry: string | null;
|
||||||
|
isTrial: boolean;
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
interval: "monthly" | "annual";
|
||||||
|
customerId: string | null;
|
||||||
|
hasCustomer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedCustomerData {
|
||||||
|
periodEndMs: number | null;
|
||||||
|
isTrial: boolean;
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
customerId: string | null;
|
||||||
|
interval: "monthly" | "annual";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStripeCustomerJson(jsonStr: string, isTrialing: boolean): ParsedCustomerData {
|
||||||
|
let periodEndMs: number | null = null;
|
||||||
|
let isTrial = isTrialing;
|
||||||
|
let cancelAtPeriodEnd = false;
|
||||||
|
let customerId: string | null = null;
|
||||||
|
let interval: "monthly" | "annual" = "monthly";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonStr) as Record<string, unknown>;
|
||||||
|
const sub =
|
||||||
|
data && typeof data.subscription === "object" && data.subscription !== null
|
||||||
|
? (data.subscription as Record<string, unknown>)
|
||||||
|
: data;
|
||||||
|
|
||||||
|
const rawCustomer = data.customer ?? sub?.customer;
|
||||||
|
if (typeof rawCustomer === "string") {
|
||||||
|
customerId = rawCustomer;
|
||||||
|
} else if (rawCustomer && typeof rawCustomer === "object" && "id" in rawCustomer) {
|
||||||
|
customerId = String((rawCustomer as { id: unknown }).id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub) {
|
||||||
|
if (sub.status === "trialing" || Boolean(sub.trial_end)) {
|
||||||
|
isTrial = true;
|
||||||
|
}
|
||||||
|
cancelAtPeriodEnd = Boolean(sub.cancel_at_period_end);
|
||||||
|
|
||||||
|
const items = sub.items as
|
||||||
|
| { data?: Array<{ current_period_end?: number; plan?: { interval?: string } }> }
|
||||||
|
| undefined;
|
||||||
|
const itemPeriodEnd = items?.data?.[0]?.current_period_end;
|
||||||
|
const subPeriodEnd = (sub.current_period_end ?? sub.trial_end) as number | undefined;
|
||||||
|
const rawSeconds = itemPeriodEnd ?? subPeriodEnd;
|
||||||
|
if (typeof rawSeconds === "number" && rawSeconds > 0) {
|
||||||
|
periodEndMs = rawSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items?.data?.[0]?.plan?.interval === "year") {
|
||||||
|
interval = "annual";
|
||||||
|
}
|
||||||
|
} else if (typeof data.expires_at === "number") {
|
||||||
|
periodEndMs = data.expires_at * 1000;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return { periodEndMs, isTrial, cancelAtPeriodEnd, customerId, interval };
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTimeRemaining(periodEndMs: number | null) {
|
||||||
|
if (!periodEndMs) {
|
||||||
|
return { daysRemaining: null, hoursRemaining: null, formattedExpiry: null };
|
||||||
|
}
|
||||||
|
const diffMs = periodEndMs - Date.now();
|
||||||
|
const totalHours = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60)));
|
||||||
|
const daysRemaining = Math.floor(totalHours / 24);
|
||||||
|
const hoursRemaining = totalHours % 24;
|
||||||
|
|
||||||
|
let formattedExpiry: string;
|
||||||
|
try {
|
||||||
|
formattedExpiry = new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(periodEndMs));
|
||||||
|
} catch {
|
||||||
|
formattedExpiry = new Date(periodEndMs).toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { daysRemaining, hoursRemaining, formattedExpiry };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationBillingStatus(
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<OrganizationBillingDetails> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(billingCustomerStatus)
|
.from(billingCustomerStatus)
|
||||||
@ -42,8 +140,17 @@ export async function getOrganizationBillingStatus(organizationId: string) {
|
|||||||
if (!row) {
|
if (!row) {
|
||||||
return {
|
return {
|
||||||
isPaying: false,
|
isPaying: false,
|
||||||
planId: "free" as const,
|
planId: "free",
|
||||||
status: "none" as const,
|
status: "none",
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
daysRemaining: null,
|
||||||
|
hoursRemaining: null,
|
||||||
|
formattedExpiry: null,
|
||||||
|
isTrial: false,
|
||||||
|
cancelAtPeriodEnd: false,
|
||||||
|
interval: "monthly",
|
||||||
|
customerId: null,
|
||||||
|
hasCustomer: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,9 +165,35 @@ export async function getOrganizationBillingStatus(organizationId: string) {
|
|||||||
? (row.paidPlanId as "free" | "starter" | "pro" | "agency")
|
? (row.paidPlanId as "free" | "starter" | "pro" | "agency")
|
||||||
: "free";
|
: "free";
|
||||||
|
|
||||||
|
const parsed = parseStripeCustomerJson(
|
||||||
|
row.customerJson,
|
||||||
|
row.paidPlanStatus === "trialing",
|
||||||
|
);
|
||||||
|
|
||||||
|
let finalPeriodEnd = parsed.periodEndMs;
|
||||||
|
if (isValidPaying && !finalPeriodEnd && row.syncedAt) {
|
||||||
|
const syncTime = new Date(row.syncedAt).getTime();
|
||||||
|
if (!Number.isNaN(syncTime)) {
|
||||||
|
const days = parsed.isTrial ? 7 : (parsed.interval === "annual" ? 365 : 30);
|
||||||
|
finalPeriodEnd = syncTime + days * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { daysRemaining, hoursRemaining, formattedExpiry } =
|
||||||
|
calculateTimeRemaining(finalPeriodEnd);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPaying: isValidPaying,
|
isPaying: isValidPaying,
|
||||||
planId: isValidPaying ? matchedPlan : ("free" as const),
|
planId: isValidPaying ? matchedPlan : "free",
|
||||||
status: row.paidPlanStatus ?? "none",
|
status: (row.paidPlanStatus as OrganizationBillingDetails["status"]) ?? "none",
|
||||||
|
currentPeriodEnd: finalPeriodEnd,
|
||||||
|
daysRemaining,
|
||||||
|
hoursRemaining,
|
||||||
|
formattedExpiry,
|
||||||
|
isTrial: parsed.isTrial,
|
||||||
|
cancelAtPeriodEnd: parsed.cancelAtPeriodEnd,
|
||||||
|
interval: parsed.interval,
|
||||||
|
customerId: parsed.customerId,
|
||||||
|
hasCustomer: Boolean(parsed.customerId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -181,19 +181,38 @@ export const createStripePortalSession = createServerFn({ method: "POST" })
|
|||||||
const baseUrl =
|
const baseUrl =
|
||||||
(await getOptionalEnvValue("BETTER_AUTH_URL")) ?? "http://localhost:3001";
|
(await getOptionalEnvValue("BETTER_AUTH_URL")) ?? "http://localhost:3001";
|
||||||
|
|
||||||
const existingCustomers = await stripe.customers.list({
|
const billingStatus = await getOrganizationBillingStatus(context.organizationId);
|
||||||
email: context.userEmail,
|
let customerId = billingStatus.customerId;
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingCustomers.data.length === 0) {
|
if (!customerId) {
|
||||||
|
const existingCustomers = await stripe.customers.list({
|
||||||
|
email: context.userEmail,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
if (existingCustomers.data.length > 0) {
|
||||||
|
customerId = existingCustomers.data[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
throw new AppError("NOT_FOUND", "No Stripe customer found for this account.");
|
throw new AppError("NOT_FOUND", "No Stripe customer found for this account.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const portalSession = await stripe.billingPortal.sessions.create({
|
try {
|
||||||
customer: existingCustomers.data[0].id,
|
const portalSession = await stripe.billingPortal.sessions.create({
|
||||||
return_url: `${baseUrl}/subscribe`,
|
customer: customerId,
|
||||||
});
|
return_url: `${baseUrl}/subscribe`,
|
||||||
|
});
|
||||||
|
|
||||||
return { portalUrl: portalSession.url };
|
return { portalUrl: portalSession.url };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes("activate the customer portal")) {
|
||||||
|
throw new AppError(
|
||||||
|
"INTERNAL_ERROR",
|
||||||
|
"Stripe Customer Portal is not active yet in your Stripe dashboard settings. Please use the direct Renew Plan button or enable Customer Portal in your Stripe settings.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new AppError("INTERNAL_ERROR", msg);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user