[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
+363 -121
View File
@@ -1,121 +1,363 @@
"use client"; "use client";
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";
function SuccessContent() { const LOOKUP_URL = process.env.NEXT_PUBLIC_LOOKUP_URL || "https://licenses.dashcaddy.net/api/checkout/session";
const searchParams = useSearchParams();
const sessionId = searchParams.get("session_id"); interface LookupResult {
status: "delivered" | "pending_email" | "processing" | "not_found" | "expired";
return ( code?: string;
<> codeId?: string;
<Navbar /> productId?: string;
<main className="flex-1 flex items-center justify-center px-4 py-24"> durationDays?: number;
<div className="max-w-lg w-full text-center"> deliveredVia?: string;
{/* Success icon */} }
<div className="mx-auto w-20 h-20 rounded-full bg-green-500/20 flex items-center justify-center mb-8">
<svg function SuccessContent() {
className="w-10 h-10 text-green-400" const searchParams = useSearchParams();
fill="none" const sessionId = searchParams.get("session_id");
viewBox="0 0 24 24"
stroke="currentColor" const [result, setResult] = useState<LookupResult | null>(null);
strokeWidth={2} const [loading, setLoading] = useState(true);
> const [copied, setCopied] = useState(false);
<path const [sessionCopied, setSessionCopied] = useState(false);
strokeLinecap="round" const [error, setError] = useState<string | null>(null);
strokeLinejoin="round"
d="M5 13l4 4L19 7" useEffect(() => {
/> setResult(null);
</svg> setError(null);
</div> setLoading(true);
setCopied(false);
<h1 className="text-4xl font-bold text-white mb-4"> setSessionCopied(false);
Welcome to DashCaddy Premium! if (!sessionId) {
</h1> setLoading(false);
setError("This page is missing the Stripe checkout session ID. Open the link from your Stripe confirmation or contact support.");
<p className="text-lg text-surface-300 mb-8"> return;
Your subscription is active. Check your email for your license key }
and setup instructions. if (!/^cs_[A-Za-z0-9_]{3,252}$/.test(sessionId)) {
</p> setLoading(false);
setError("The Stripe checkout session ID is invalid. Open the confirmation link again or contact support.");
<div className="glass-card rounded-xl p-6 mb-8 text-left"> return;
<h2 className="text-lg font-semibold text-white mb-4"> }
Next Steps let cancelled = false;
</h2> let activeRequest: AbortController | null = null;
<ol className="space-y-3 text-surface-300"> let timer: ReturnType<typeof setTimeout> | null = null;
<li className="flex gap-3"> let attempts = 0;
<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"> const fetchOnce = async () => {
1 attempts += 1;
</span> activeRequest = new AbortController();
<span> const requestTimeout = setTimeout(() => activeRequest?.abort(), 10000);
Check your email for your license key (DC-XXXXX-...) try {
</span> const r = await fetch(`${LOOKUP_URL}/${encodeURIComponent(sessionId)}`, {
</li> cache: "no-store",
<li className="flex gap-3"> signal: activeRequest.signal,
<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 if (cancelled) return;
</span> if (r.ok) {
<span> let j: LookupResult;
Open your DashCaddy dashboard and go to Admin &rarr; License try {
</span> j = (await r.json()) as LookupResult;
</li> } catch {
<li className="flex gap-3"> setError("The billing server returned malformed data. Check your Stripe or email receipt, then contact support.");
<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"> setLoading(false);
3 return;
</span> }
<span>Paste your license key and click Activate</span> const allowedStatuses = new Set(["delivered", "pending_email", "processing", "not_found", "expired"]);
</li> if (!j || typeof j !== "object") {
<li className="flex gap-3"> setError("The billing server returned an invalid response. Check your Stripe or email receipt, then contact support.");
<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"> setLoading(false);
4 return;
</span> }
<span> const optionalFieldsValid =
Enjoy SSO, Recipes, Docker Swarm, Fleet Management, and all premium features! (j.code === undefined || typeof j.code === "string") &&
</span> (j.codeId === undefined || typeof j.codeId === "string") &&
</li> (j.productId === undefined || typeof j.productId === "string") &&
</ol> (j.durationDays === undefined || (Number.isInteger(j.durationDays) && j.durationDays > 0)) &&
</div> (j.deliveredVia === undefined || typeof j.deliveredVia === "string");
if (typeof j.status !== "string" || !allowedStatuses.has(j.status) || !optionalFieldsValid) {
{sessionId && ( setError("The billing server returned an invalid response. Check your Stripe or email receipt, then contact support.");
<p className="text-sm text-surface-500 mb-6"> setLoading(false);
Session ID: {sessionId.substring(0, 20)}... return;
</p> }
)} if ((j.status === "delivered" || j.status === "pending_email") &&
(typeof j.code !== "string" || j.code.trim().length === 0)) {
<div className="flex flex-col sm:flex-row gap-4 justify-center"> setError("Payment was confirmed, but the license key was not returned. Please contact support with the session ID below.");
<Link setLoading(false);
href="/docs" return;
className="px-6 py-3 rounded-lg bg-brand-600 hover:bg-brand-500 text-white font-medium transition-colors" }
> if (j.status === "processing" || j.status === "not_found") {
View Setup Guide if (attempts >= 40) {
</Link> if (j.status === "not_found") setResult({ status: "not_found" });
<Link else setError("Stripe confirmation is taking longer than expected. Check your Stripe or email receipt, then contact support if needed.");
href="/" setLoading(false);
className="px-6 py-3 rounded-lg border border-surface-700 hover:border-surface-500 text-surface-300 font-medium transition-colors" return;
> }
Back to Home timer = setTimeout(fetchOnce, 3000);
</Link> return;
</div> }
</div> setResult(j);
</main> if (j.status === "expired") {
<Footer /> 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) {
export default function SuccessPage() { setResult({ status: "not_found" });
return ( setLoading(false);
<Suspense } else {
fallback={ timer = setTimeout(fetchOnce, 3000);
<div className="flex-1 flex items-center justify-center"> }
<div className="text-surface-400">Loading...</div> } else if (r.status === 408 || r.status === 429 || r.status >= 500) {
</div> if (attempts >= 40) {
} setError(`The billing server remained unavailable (HTTP ${r.status}). Check your Stripe or email receipt, then contact support.`);
> setLoading(false);
<SuccessContent /> } else {
</Suspense> 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 (
<>
<Navbar />
<main className="flex-1 flex items-center justify-center px-4 py-24">
<div className="max-w-lg w-full text-center">
{/* Order status icon */}
<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
className={`w-10 h-10 ${confirmed ? "text-green-400" : failed ? "text-red-400" : "text-brand-400"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
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>
</div>
<h1 className="text-4xl font-bold text-surface-50 mb-4">
{confirmed ? "Welcome to DashCaddy Premium!" : failed ? "We couldn't confirm your DashCaddy order" : "Confirming your DashCaddy order"}
</h1>
<p className="text-lg text-surface-300 mb-8">
{error
? "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>
{/* License key panel — only shown once the bridge returns the code */}
{!loading && result && (result.status === "delivered" || result.status === "pending_email") && result.code && (
<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>
<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">
<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">
1
</span>
<span>
{result?.code ? (
<>Copy the license key above{result.deliveredVia === "smtp" ? " (also emailed)" : ""}</>
) : (
<>Check your email for your license key (DC-XXXXX-)</>
)}
</span>
</li>
<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">
2
</span>
<span>Open your DashCaddy dashboard and go to Admin License</span>
</li>
<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">
3
</span>
<span>Paste your license key and click Activate</span>
</li>
<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">
4
</span>
<span>Enjoy SSO, Recipes, Docker Swarm, Fleet Management, and all premium features!</span>
</li>
</ol>
</div>
)}
{sessionId && (
<div className="mb-6 text-left rounded-lg border border-surface-700 p-3">
<div className="flex items-center justify-between gap-3 mb-1">
<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">
<Link
href="/docs"
className="px-6 py-3 rounded-lg bg-brand-600 hover:bg-brand-500 text-white font-medium transition-colors"
>
View Setup Guide
</Link>
<Link
href="/"
className="px-6 py-3 rounded-lg border border-surface-700 hover:border-surface-500 text-surface-300 font-medium transition-colors"
>
Back to Home
</Link>
</div>
</div>
</main>
<Footer />
</>
);
}
export default function SuccessPage() {
return (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center">
<div className="text-surface-400">Loading...</div>
</div>
}
>
<SuccessContent />
</Suspense>
);
}