[grade=B] Complete customer billing confirmation flow
This commit is contained in:
@@ -28,3 +28,4 @@ STRIPE_PAYMENT_LINK_ANNUAL=https://buy.stripe.com/REPLACE_ANNUAL_LINK
|
||||
|
||||
# App URL
|
||||
NEXT_PUBLIC_APP_URL=https://dashcaddy.net
|
||||
NEXT_PUBLIC_LOOKUP_URL=https://licenses.dashcaddy.net/api/checkout/session
|
||||
|
||||
+265
-23
@@ -2,23 +2,191 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import Navbar from "@/components/Navbar";
|
||||
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() {
|
||||
const searchParams = useSearchParams();
|
||||
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 (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="flex-1 flex items-center justify-center px-4 py-24">
|
||||
<div className="max-w-lg w-full text-center">
|
||||
{/* Success icon */}
|
||||
<div className="mx-auto w-20 h-20 rounded-full bg-green-500/20 flex items-center justify-center mb-8">
|
||||
{/* 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 text-green-400"
|
||||
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"
|
||||
@@ -27,40 +195,109 @@ function SuccessContent() {
|
||||
<path
|
||||
strokeLinecap="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>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-bold text-white mb-4">
|
||||
Welcome to DashCaddy Premium!
|
||||
<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">
|
||||
Your subscription is active. Check your email for your license key
|
||||
and setup instructions.
|
||||
{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'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…</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'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'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-white mb-4">
|
||||
Next Steps
|
||||
</h2>
|
||||
<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>
|
||||
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>
|
||||
</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>
|
||||
<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">
|
||||
@@ -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">
|
||||
4
|
||||
</span>
|
||||
<span>
|
||||
Enjoy SSO, Recipes, Docker Swarm, Fleet Management, and all premium features!
|
||||
</span>
|
||||
<span>Enjoy SSO, Recipes, Docker Swarm, Fleet Management, and all premium features!</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionId && (
|
||||
<p className="text-sm text-surface-500 mb-6">
|
||||
Session ID: {sessionId.substring(0, 20)}...
|
||||
</p>
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user