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 { useCustomer } from "autumn-js/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, ChevronDown, Globe, Settings, User } from "lucide-react";
|
||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||
import { ArrowLeft, ChevronDown, Globe } from "lucide-react";
|
||||
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 { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
||||
@ -20,6 +19,7 @@ import type {
|
||||
} from "@/client/features/billing/PricingData";
|
||||
import {
|
||||
createStripeCheckoutSession,
|
||||
createStripePortalSession,
|
||||
getOrganizationBilling,
|
||||
verifyStripeCheckoutSession,
|
||||
} from "@/serverFunctions/stripe";
|
||||
@ -30,6 +30,8 @@ import {
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getProjects } from "@/serverFunctions/projects";
|
||||
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";
|
||||
|
||||
const SUPPORT_EMAIL = "support@metatroncube.com";
|
||||
@ -68,6 +70,8 @@ function SubscribePage() {
|
||||
} = Route.useSearch();
|
||||
const { data: session } = useSession();
|
||||
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 [finalizingTimedOut, setFinalizingTimedOut] = 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 (
|
||||
<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 */}
|
||||
<CornerWaveDecoration />
|
||||
|
||||
{/* 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 */}
|
||||
<header className="flex items-center justify-between gap-4 py-2">
|
||||
{/* Exact CrawlerX Cube Logo & Subtitle */}
|
||||
@ -296,10 +335,23 @@ function SubscribePage() {
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
|
||||
<SubscribePageAccountMenu email={session?.user?.email} />
|
||||
<SubscribeAccountMenu email={session?.user?.email} />
|
||||
</div>
|
||||
</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 */}
|
||||
<WorkspaceUsageBanner
|
||||
currentPlanId={activePlanId}
|
||||
@ -333,46 +385,3 @@ function SubscribePage() {
|
||||
</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
|
||||
.select()
|
||||
.from(billingCustomerStatus)
|
||||
@ -42,8 +140,17 @@ export async function getOrganizationBillingStatus(organizationId: string) {
|
||||
if (!row) {
|
||||
return {
|
||||
isPaying: false,
|
||||
planId: "free" as const,
|
||||
status: "none" as const,
|
||||
planId: "free",
|
||||
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")
|
||||
: "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 {
|
||||
isPaying: isValidPaying,
|
||||
planId: isValidPaying ? matchedPlan : ("free" as const),
|
||||
status: row.paidPlanStatus ?? "none",
|
||||
planId: isValidPaying ? matchedPlan : "free",
|
||||
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 =
|
||||
(await getOptionalEnvValue("BETTER_AUTH_URL")) ?? "http://localhost:3001";
|
||||
|
||||
const billingStatus = await getOrganizationBillingStatus(context.organizationId);
|
||||
let customerId = billingStatus.customerId;
|
||||
|
||||
if (!customerId) {
|
||||
const existingCustomers = await stripe.customers.list({
|
||||
email: context.userEmail,
|
||||
limit: 1,
|
||||
});
|
||||
if (existingCustomers.data.length > 0) {
|
||||
customerId = existingCustomers.data[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingCustomers.data.length === 0) {
|
||||
if (!customerId) {
|
||||
throw new AppError("NOT_FOUND", "No Stripe customer found for this account.");
|
||||
}
|
||||
|
||||
try {
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: existingCustomers.data[0].id,
|
||||
customer: customerId,
|
||||
return_url: `${baseUrl}/subscribe`,
|
||||
});
|
||||
|
||||
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