[grade=B] Complete customer billing confirmation flow

This commit is contained in:
Krystie
2026-08-22 19:03:09 -07:00
parent e2b4ce4447
commit e58130756e
2 changed files with 364 additions and 121 deletions
+1
View File
@@ -28,3 +28,4 @@ STRIPE_PAYMENT_LINK_ANNUAL=https://buy.stripe.com/REPLACE_ANNUAL_LINK
# App URL # App URL
NEXT_PUBLIC_APP_URL=https://dashcaddy.net NEXT_PUBLIC_APP_URL=https://dashcaddy.net
NEXT_PUBLIC_LOOKUP_URL=https://licenses.dashcaddy.net/api/checkout/session
+265 -23
View File
@@ -2,23 +2,191 @@
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Suspense } from "react"; import { Suspense, useEffect, useState } from "react";
import Navbar from "@/components/Navbar"; import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
const LOOKUP_URL = process.env.NEXT_PUBLIC_LOOKUP_URL || "https://licenses.dashcaddy.net/api/checkout/session";
interface LookupResult {
status: "delivered" | "pending_email" | "processing" | "not_found" | "expired";
code?: string;
codeId?: string;
productId?: string;
durationDays?: number;
deliveredVia?: string;
}
function SuccessContent() { function SuccessContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const sessionId = searchParams.get("session_id"); const sessionId = searchParams.get("session_id");
const [result, setResult] = useState<LookupResult | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const [sessionCopied, setSessionCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setResult(null);
setError(null);
setLoading(true);
setCopied(false);
setSessionCopied(false);
if (!sessionId) {
setLoading(false);
setError("This page is missing the Stripe checkout session ID. Open the link from your Stripe confirmation or contact support.");
return;
}
if (!/^cs_[A-Za-z0-9_]{3,252}$/.test(sessionId)) {
setLoading(false);
setError("The Stripe checkout session ID is invalid. Open the confirmation link again or contact support.");
return;
}
let cancelled = false;
let activeRequest: AbortController | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let attempts = 0;
const fetchOnce = async () => {
attempts += 1;
activeRequest = new AbortController();
const requestTimeout = setTimeout(() => activeRequest?.abort(), 10000);
try {
const r = await fetch(`${LOOKUP_URL}/${encodeURIComponent(sessionId)}`, {
cache: "no-store",
signal: activeRequest.signal,
});
if (cancelled) return;
if (r.ok) {
let j: LookupResult;
try {
j = (await r.json()) as LookupResult;
} catch {
setError("The billing server returned malformed data. Check your Stripe or email receipt, then contact support.");
setLoading(false);
return;
}
const allowedStatuses = new Set(["delivered", "pending_email", "processing", "not_found", "expired"]);
if (!j || typeof j !== "object") {
setError("The billing server returned an invalid response. Check your Stripe or email receipt, then contact support.");
setLoading(false);
return;
}
const optionalFieldsValid =
(j.code === undefined || typeof j.code === "string") &&
(j.codeId === undefined || typeof j.codeId === "string") &&
(j.productId === undefined || typeof j.productId === "string") &&
(j.durationDays === undefined || (Number.isInteger(j.durationDays) && j.durationDays > 0)) &&
(j.deliveredVia === undefined || typeof j.deliveredVia === "string");
if (typeof j.status !== "string" || !allowedStatuses.has(j.status) || !optionalFieldsValid) {
setError("The billing server returned an invalid response. Check your Stripe or email receipt, then contact support.");
setLoading(false);
return;
}
if ((j.status === "delivered" || j.status === "pending_email") &&
(typeof j.code !== "string" || j.code.trim().length === 0)) {
setError("Payment was confirmed, but the license key was not returned. Please contact support with the session ID below.");
setLoading(false);
return;
}
if (j.status === "processing" || j.status === "not_found") {
if (attempts >= 40) {
if (j.status === "not_found") setResult({ status: "not_found" });
else setError("Stripe confirmation is taking longer than expected. Check your Stripe or email receipt, then contact support if needed.");
setLoading(false);
return;
}
timer = setTimeout(fetchOnce, 3000);
return;
}
setResult(j);
if (j.status === "expired") {
setError("This checkout session has expired. If you were charged, contact support with the session ID below.");
}
setLoading(false);
} else if (r.status === 404) {
if (attempts >= 40) {
setResult({ status: "not_found" });
setLoading(false);
} else {
timer = setTimeout(fetchOnce, 3000);
}
} else if (r.status === 408 || r.status === 429 || r.status >= 500) {
if (attempts >= 40) {
setError(`The billing server remained unavailable (HTTP ${r.status}). Check your Stripe or email receipt, then contact support.`);
setLoading(false);
} else {
timer = setTimeout(fetchOnce, 3000);
}
} else {
setError(`The billing server is temporarily unavailable (HTTP ${r.status}). Check your Stripe or email receipt, then try again.`);
setLoading(false);
}
} catch {
if (!cancelled) {
if (attempts >= 40) {
setError("The billing request repeatedly timed out or could not reach the server. Check your Stripe or email receipt, then contact support.");
setLoading(false);
} else {
timer = setTimeout(fetchOnce, 3000);
}
}
} finally {
clearTimeout(requestTimeout);
}
};
fetchOnce();
return () => {
cancelled = true;
activeRequest?.abort();
if (timer) clearTimeout(timer);
};
}, [sessionId]);
const copyCode = async () => {
if (!result?.code) return;
try {
await navigator.clipboard.writeText(result.code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* noop */
}
};
const copySessionId = async () => {
if (!sessionId) return;
try {
await navigator.clipboard.writeText(sessionId);
setSessionCopied(true);
setTimeout(() => setSessionCopied(false), 2000);
} catch {
/* noop */
}
};
const productLabel =
result?.productId === "pro-30d"
? "1 month"
: result?.productId === "pro-90d"
? "3 months"
: result?.productId === "pro-180d"
? "6 months"
: result?.productId === "pro-365d"
? "12 months"
: null;
const confirmed = Boolean(result?.code) && (result?.status === "delivered" || result?.status === "pending_email");
const failed = Boolean(error) || result?.status === "not_found" || result?.status === "expired";
return ( return (
<> <>
<Navbar /> <Navbar />
<main className="flex-1 flex items-center justify-center px-4 py-24"> <main className="flex-1 flex items-center justify-center px-4 py-24">
<div className="max-w-lg w-full text-center"> <div className="max-w-lg w-full text-center">
{/* Success icon */} {/* Order status icon */}
<div className="mx-auto w-20 h-20 rounded-full bg-green-500/20 flex items-center justify-center mb-8"> <div className={`mx-auto w-20 h-20 rounded-full flex items-center justify-center mb-8 ${confirmed ? "bg-green-500/20" : failed ? "bg-red-500/20" : "bg-brand-500/20"}`}>
<svg <svg
className="w-10 h-10 text-green-400" className={`w-10 h-10 ${confirmed ? "text-green-400" : failed ? "text-red-400" : "text-brand-400"}`}
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -27,40 +195,109 @@ function SuccessContent() {
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
d="M5 13l4 4L19 7" d={confirmed ? "M5 13l4 4L19 7" : failed ? "M6 18L18 6M6 6l12 12" : "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"}
/> />
</svg> </svg>
</div> </div>
<h1 className="text-4xl font-bold text-white mb-4"> <h1 className="text-4xl font-bold text-surface-50 mb-4">
Welcome to DashCaddy Premium! {confirmed ? "Welcome to DashCaddy Premium!" : failed ? "We couldn't confirm your DashCaddy order" : "Confirming your DashCaddy order"}
</h1> </h1>
<p className="text-lg text-surface-300 mb-8"> <p className="text-lg text-surface-300 mb-8">
Your subscription is active. Check your email for your license key {error
and setup instructions. ? "Automatic confirmation has stopped. Try again below or contact support with the session ID."
: result?.status === "delivered" || result?.status === "pending_email"
? "Your purchase is complete. Here's your license key:"
: "Your license key will appear here once Stripe confirms payment."}
</p> </p>
<div className="glass-card rounded-xl p-6 mb-8 text-left"> {/* License key panel — only shown once the bridge returns the code */}
<h2 className="text-lg font-semibold text-white mb-4"> {!loading && result && (result.status === "delivered" || result.status === "pending_email") && result.code && (
Next Steps <div className="glass-card rounded-xl p-6 mb-8 text-left border border-brand-500/40">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-surface-50">
Your license key{productLabel ? ` · ${productLabel}` : ""}
</h2> </h2>
<button
onClick={copyCode}
className="text-xs px-3 py-1 rounded-md bg-brand-500/10 text-brand-300 hover:bg-brand-500/20 transition-colors"
>
{copied ? "✓ Copied" : "Copy"}
</button>
</div>
<pre className="font-mono text-base text-brand-300 bg-surface-950/60 rounded-md p-4 overflow-x-auto border border-surface-700/50">
{result.code}
</pre>
{result.status === "pending_email" && (
<p className="mt-3 text-xs text-surface-500">
Email delivery didn&apos;t complete, but the license code above is valid now.
</p>
)}
</div>
)}
{/* Loading state */}
{loading && (
<div className="glass-card rounded-xl p-6 mb-8 text-left border border-surface-700/50">
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full border-2 border-brand-500 border-t-transparent animate-spin" />
<span className="text-surface-300 text-sm">Confirming payment and preparing your license&hellip;</span>
</div>
</div>
)}
{!loading && error && (
<div role="alert" className="glass-card rounded-xl p-6 mb-8 text-left border border-red-500/40 bg-red-500/5">
<h2 className="text-lg font-semibold text-red-300 mb-2">We couldn&apos;t finish loading this order</h2>
<p className="text-sm text-surface-300">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 text-sm px-4 py-2 rounded-md bg-brand-600 hover:bg-brand-500 text-white"
>
Try again
</button>
</div>
)}
{/* Not-found state (no session_id passed or session unknown) */}
{!loading && result && result.status === "not_found" && (
<div className="glass-card rounded-xl p-6 mb-8 text-left border border-amber-500/40 bg-amber-500/5">
<h2 className="text-lg font-semibold text-amber-300 mb-2">
We couldn&apos;t find that session
</h2>
<p className="text-sm text-surface-300">
Stripe did not confirm this session within the confirmation window. If you were charged,
keep the session ID below and contact{" "}
<a href="mailto:support@dashcaddy.net" className="text-brand-400 underline">
support
</a>
.
</p>
</div>
)}
{/* Next Steps */}
{result?.code && (
<div className="glass-card rounded-xl p-6 mb-8 text-left">
<h2 className="text-lg font-semibold text-surface-50 mb-4">Next Steps</h2>
<ol className="space-y-3 text-surface-300"> <ol className="space-y-3 text-surface-300">
<li className="flex gap-3"> <li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium"> <span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
1 1
</span> </span>
<span> <span>
Check your email for your license key (DC-XXXXX-...) {result?.code ? (
<>Copy the license key above{result.deliveredVia === "smtp" ? " (also emailed)" : ""}</>
) : (
<>Check your email for your license key (DC-XXXXX-)</>
)}
</span> </span>
</li> </li>
<li className="flex gap-3"> <li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium"> <span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
2 2
</span> </span>
<span> <span>Open your DashCaddy dashboard and go to Admin License</span>
Open your DashCaddy dashboard and go to Admin &rarr; License
</span>
</li> </li>
<li className="flex gap-3"> <li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium"> <span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
@@ -72,17 +309,22 @@ function SuccessContent() {
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium"> <span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
4 4
</span> </span>
<span> <span>Enjoy SSO, Recipes, Docker Swarm, Fleet Management, and all premium features!</span>
Enjoy SSO, Recipes, Docker Swarm, Fleet Management, and all premium features!
</span>
</li> </li>
</ol> </ol>
</div> </div>
)}
{sessionId && ( {sessionId && (
<p className="text-sm text-surface-500 mb-6"> <div className="mb-6 text-left rounded-lg border border-surface-700 p-3">
Session ID: {sessionId.substring(0, 20)}... <div className="flex items-center justify-between gap-3 mb-1">
</p> <span className="text-xs font-medium text-surface-400">Stripe session ID</span>
<button onClick={copySessionId} className="text-xs text-brand-400 hover:underline">
{sessionCopied ? "Copied" : "Copy"}
</button>
</div>
<code className="block text-xs text-surface-500 break-all">{sessionId}</code>
</div>
)} )}
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">