379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
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 { captureClientEvent } from "@/client/lib/posthog";
|
|
import { signOutAndRedirect, 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";
|
|
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
|
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
|
import { useCanManageBilling } from "@/client/features/team/organizationQueries";
|
|
import { AUTUMN_MANAGED_ACCESS_FEATURE_ID } from "@/shared/billing";
|
|
import { PricingTable } from "@/client/features/billing/PricingTable";
|
|
import type {
|
|
PricingPlan,
|
|
BillingInterval,
|
|
Currency,
|
|
} from "@/client/features/billing/PricingData";
|
|
import {
|
|
createStripeCheckoutSession,
|
|
getOrganizationBilling,
|
|
verifyStripeCheckoutSession,
|
|
} from "@/serverFunctions/stripe";
|
|
import {
|
|
CornerWaveDecoration,
|
|
CrawlerXLogo,
|
|
} from "@/client/features/billing/PricingGraphics";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { getProjects } from "@/serverFunctions/projects";
|
|
import { WorkspaceUsageBanner } from "@/client/features/billing/WorkspaceUsageBanner";
|
|
import { toast } from "sonner";
|
|
|
|
const SUPPORT_EMAIL = "support@metatroncube.com";
|
|
const FINALIZING_TIMEOUT_MS = 30_000;
|
|
|
|
export const Route = createFileRoute("/_authenticated/subscribe")({
|
|
validateSearch: (
|
|
search: Record<string, unknown>,
|
|
): {
|
|
upgrade?: true;
|
|
redirect?: string;
|
|
checkout?: "success";
|
|
session_id?: string;
|
|
} => ({
|
|
upgrade:
|
|
search.upgrade === true || search.upgrade === "true" ? true : undefined,
|
|
redirect:
|
|
typeof search.redirect === "string"
|
|
? normalizeAuthRedirect(search.redirect)
|
|
: undefined,
|
|
checkout: search.checkout === "success" ? "success" : undefined,
|
|
session_id:
|
|
typeof search.session_id === "string" ? search.session_id : undefined,
|
|
}),
|
|
component: SubscribePage,
|
|
});
|
|
|
|
function SubscribePage() {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const {
|
|
upgrade: isUpgradeFlow,
|
|
redirect,
|
|
checkout,
|
|
session_id: sessionId,
|
|
} = Route.useSearch();
|
|
const { data: session } = useSession();
|
|
const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [finalizingTimedOut, setFinalizingTimedOut] = useState(false);
|
|
const [hasVerifiedSession, setHasVerifiedSession] = useState(false);
|
|
const checkoutCompleted = checkout === "success";
|
|
|
|
const hasSession = Boolean(session?.user?.id);
|
|
const customerQuery = useCustomer({
|
|
queryOptions: { enabled: hasSession },
|
|
});
|
|
|
|
const billingQuery = useQuery({
|
|
queryKey: ["organization-billing", session?.user?.id],
|
|
queryFn: () => getOrganizationBilling(),
|
|
enabled: hasSession,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (checkoutCompleted && sessionId && !hasVerifiedSession) {
|
|
setHasVerifiedSession(true);
|
|
void (async () => {
|
|
try {
|
|
const res = await verifyStripeCheckoutSession({
|
|
data: { sessionId },
|
|
});
|
|
if (res?.success) {
|
|
await queryClient.invalidateQueries({
|
|
queryKey: ["organization-billing"],
|
|
});
|
|
toast.success(
|
|
`Payment successful! Your ${res.planId.toUpperCase()} plan is now active.`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to verify Stripe checkout session:", err);
|
|
}
|
|
})();
|
|
}
|
|
}, [checkoutCompleted, sessionId, hasVerifiedSession, queryClient]);
|
|
|
|
const canManageBilling = useCanManageBilling();
|
|
const hasManagedAccess = isHostedClientAuthMode()
|
|
? customerQuery.check({ featureId: AUTUMN_MANAGED_ACCESS_FEATURE_ID })
|
|
.allowed
|
|
: true;
|
|
|
|
const planStatus = getCustomerPlanStatus(customerQuery.data);
|
|
const projectsQuery = useQuery({
|
|
queryKey: ["projects"],
|
|
queryFn: () => getProjects(),
|
|
enabled: hasSession,
|
|
});
|
|
const siteCount = Math.max(1, projectsQuery.data?.length ?? 1);
|
|
const activePlanId: "free" | "starter" | "pro" | "agency" =
|
|
billingQuery.data?.planId ??
|
|
(planStatus === "paid" ? "pro" : "free");
|
|
const subscribeRouteState = getSubscribeRouteState({
|
|
hasSession,
|
|
isCustomerLoading: customerQuery.isLoading,
|
|
isCustomerError: customerQuery.isError,
|
|
hasManagedAccess,
|
|
planStatus,
|
|
isUpgradeFlow: isUpgradeFlow === true,
|
|
checkoutCompleted: checkoutCompleted && !sessionId,
|
|
finalizingTimedOut,
|
|
});
|
|
|
|
const isFinalizing = !sessionId && subscribeRouteState === "finalizing";
|
|
const { refetch: refetchCustomer } = customerQuery;
|
|
useEffect(() => {
|
|
if (!isFinalizing) return;
|
|
const interval = setInterval(() => {
|
|
void refetchCustomer();
|
|
}, 2000);
|
|
return () => clearInterval(interval);
|
|
}, [refetchCustomer, isFinalizing]);
|
|
|
|
useEffect(() => {
|
|
if (!checkoutCompleted || finalizingTimedOut) return;
|
|
const timeout = setTimeout(
|
|
() => setFinalizingTimedOut(true),
|
|
FINALIZING_TIMEOUT_MS,
|
|
);
|
|
return () => clearTimeout(timeout);
|
|
}, [checkoutCompleted, finalizingTimedOut]);
|
|
|
|
useEffect(() => {
|
|
if (subscribeRouteState === "redirectToApp") {
|
|
if (checkoutCompleted) {
|
|
captureClientEvent("billing:checkout_success");
|
|
}
|
|
void navigate({ href: redirect ?? "/", replace: true });
|
|
}
|
|
}, [checkoutCompleted, navigate, redirect, subscribeRouteState]);
|
|
|
|
useEffect(() => {
|
|
if (subscribeRouteState === "showPaywall" && !isUpgradeFlow) {
|
|
captureClientEvent("billing:paywall_viewed");
|
|
}
|
|
}, [isUpgradeFlow, subscribeRouteState]);
|
|
|
|
if (
|
|
subscribeRouteState === "loading" ||
|
|
subscribeRouteState === "redirectToApp"
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
if (subscribeRouteState === "finalizing") {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center p-4">
|
|
<div className="w-full max-w-sm space-y-4 text-center p-8 rounded-3xl bg-white border border-slate-200 shadow-xl">
|
|
<img
|
|
src="/transparent-logo.png"
|
|
alt="CrawlerX"
|
|
className="mx-auto size-12 object-contain"
|
|
/>
|
|
<h1 className="text-xl font-bold text-slate-900">
|
|
Finalizing your subscription…
|
|
</h1>
|
|
<span className="loading loading-spinner loading-md text-blue-600" />
|
|
<p className="text-sm text-slate-500">
|
|
Setting up your workspace limits and credits.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
async function handleSelectPlan(
|
|
plan: PricingPlan,
|
|
interval: BillingInterval = "monthly",
|
|
currency: Currency = "INR",
|
|
) {
|
|
if (plan.id === "free") {
|
|
void navigate({ to: redirect ?? "/", replace: true });
|
|
return;
|
|
}
|
|
|
|
if (!canManageBilling) {
|
|
toast.error("Only the organization owner can change subscription plans.");
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
setLoadingPlanId(plan.id);
|
|
try {
|
|
captureClientEvent("billing:checkout_start", {
|
|
planId: plan.id,
|
|
interval,
|
|
currency,
|
|
});
|
|
|
|
const response = await createStripeCheckoutSession({
|
|
data: {
|
|
planId: plan.id,
|
|
interval,
|
|
currency,
|
|
},
|
|
});
|
|
|
|
if (response?.checkoutUrl) {
|
|
window.location.assign(response.checkoutUrl);
|
|
return;
|
|
}
|
|
|
|
throw new Error("Stripe checkout session did not return a valid URL.");
|
|
} catch (err: unknown) {
|
|
const errObj = err as { message?: string; data?: { message?: string } } | undefined;
|
|
const rawMsg = errObj?.data?.message || errObj?.message;
|
|
const msg = rawMsg
|
|
? getStandardErrorMessage(new Error(rawMsg), rawMsg)
|
|
: "Stripe payment is not configured yet. Please set your STRIPE_SECRET_KEY in your .env file.";
|
|
setError(msg);
|
|
toast.error(msg);
|
|
} finally {
|
|
setLoadingPlanId(null);
|
|
}
|
|
}
|
|
|
|
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">
|
|
{/* Top Navbar */}
|
|
<header className="flex items-center justify-between gap-4 py-2">
|
|
{/* Exact CrawlerX Cube Logo & Subtitle */}
|
|
<Link to="/" className="group">
|
|
<CrawlerXLogo />
|
|
</Link>
|
|
|
|
{/* Center Navigation Links */}
|
|
<nav className="hidden md:flex items-center gap-8 text-sm font-semibold text-slate-600">
|
|
<div className="flex items-center gap-1 hover:text-[#0f1d34] transition-colors cursor-pointer">
|
|
<span>Features</span>
|
|
<ChevronDown className="size-3.5 opacity-60" />
|
|
</div>
|
|
<div className="relative text-[#0f1d34] font-bold">
|
|
<span>Pricing</span>
|
|
<div className="absolute -bottom-2.5 left-0 right-0 h-0.5 bg-[#3779b9] rounded-full" />
|
|
</div>
|
|
<div className="flex items-center gap-1 hover:text-[#0f1d34] transition-colors cursor-pointer">
|
|
<span>Resources</span>
|
|
<ChevronDown className="size-3.5 opacity-60" />
|
|
</div>
|
|
<Link to="/" className="hover:text-[#0f1d34] transition-colors">
|
|
About
|
|
</Link>
|
|
</nav>
|
|
|
|
{/* Right Actions */}
|
|
<div className="flex items-center gap-3 sm:gap-4">
|
|
<div className="hidden sm:flex items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-[#0f1d34] transition-colors cursor-pointer">
|
|
<Globe className="size-3.5 text-slate-500" />
|
|
<span>INR (₹)</span>
|
|
<ChevronDown className="size-3 opacity-60" />
|
|
</div>
|
|
|
|
<Link
|
|
to="/"
|
|
className="h-10 px-4 rounded-xl bg-slate-100 hover:bg-slate-200 text-[#0f1d34] text-xs sm:text-sm font-semibold transition-all flex items-center gap-1.5 shadow-2xs"
|
|
>
|
|
<ArrowLeft className="size-3.5" />
|
|
<span>Dashboard</span>
|
|
</Link>
|
|
|
|
<SubscribePageAccountMenu email={session?.user?.email} />
|
|
</div>
|
|
</header>
|
|
|
|
{/* Workspace Usage Notice */}
|
|
<WorkspaceUsageBanner
|
|
currentPlanId={activePlanId}
|
|
siteCount={siteCount}
|
|
onUpgradeClick={() => {
|
|
const el = document.getElementById("pricing-plans");
|
|
el?.scrollIntoView({ behavior: "smooth" });
|
|
}}
|
|
/>
|
|
|
|
{error ? (
|
|
<div className="max-w-md mx-auto mb-6 p-4 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 text-xs font-medium text-center">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Pricing Component */}
|
|
<main>
|
|
<PricingTable
|
|
onSelectPlan={handleSelectPlan}
|
|
loadingPlanId={loadingPlanId}
|
|
currentPlanId={activePlanId}
|
|
/>
|
|
</main>
|
|
|
|
{/* Footer */}
|
|
<footer className="pt-8 pb-12 text-center text-xs text-slate-400 border-t border-slate-100">
|
|
CrawlerX by Metatroncube Solutions • Enterprise & Custom Agency Plans: {SUPPORT_EMAIL}
|
|
</footer>
|
|
</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>
|
|
);
|
|
}
|