348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { Suspense, useEffect, useMemo, useState } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { signIn, signOut, useSession } from "next-auth/react";
|
|
|
|
type AccountStatus = {
|
|
readyToProcessPayments: boolean;
|
|
onboardingComplete: boolean;
|
|
requirementsStatus?: string;
|
|
};
|
|
|
|
function ConnectDashboardClient() {
|
|
const [displayName, setDisplayName] = useState(
|
|
process.env.NEXT_PUBLIC_STORE_DEFAULT_NAME || ""
|
|
);
|
|
const [contactEmail, setContactEmail] = useState("");
|
|
const [accountId, setAccountId] = useState("");
|
|
const [storeSlug, setStoreSlug] = useState("storeshifted");
|
|
const [status, setStatus] = useState<AccountStatus | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [message, setMessage] = useState("");
|
|
const [productName, setProductName] = useState("");
|
|
const [productDescription, setProductDescription] = useState("");
|
|
const [productPrice, setProductPrice] = useState("4999");
|
|
const [currency, setCurrency] = useState("usd");
|
|
const searchParams = useSearchParams();
|
|
const { data: session, status: sessionStatus } = useSession();
|
|
const isAuthenticated = sessionStatus === "authenticated";
|
|
|
|
useEffect(() => {
|
|
const stored = window.localStorage.getItem("connectAccountId");
|
|
if (stored) setAccountId(stored);
|
|
const storedSlug = window.localStorage.getItem("connectStoreSlug");
|
|
if (storedSlug) setStoreSlug(storedSlug);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const fromQuery = searchParams.get("accountId");
|
|
if (fromQuery) setAccountId(fromQuery);
|
|
}, [searchParams]);
|
|
|
|
useEffect(() => {
|
|
async function loadStore() {
|
|
if (!isAuthenticated) return;
|
|
try {
|
|
const res = await fetch("/api/store/me");
|
|
const data = await res.json();
|
|
if (res.ok && data.slug) {
|
|
setStoreSlug(data.slug);
|
|
if (data.stripeAccountId) setAccountId(data.stripeAccountId);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
loadStore();
|
|
}, [session]);
|
|
|
|
useEffect(() => {
|
|
if (accountId) {
|
|
window.localStorage.setItem("connectAccountId", accountId);
|
|
}
|
|
}, [accountId]);
|
|
|
|
useEffect(() => {
|
|
if (storeSlug) {
|
|
window.localStorage.setItem("connectStoreSlug", storeSlug);
|
|
}
|
|
}, [storeSlug]);
|
|
|
|
const storefrontUrl = useMemo(() => {
|
|
if (!storeSlug) return "";
|
|
return `/storefront/${storeSlug}`;
|
|
}, [storeSlug]);
|
|
|
|
async function createAccount() {
|
|
if (!isAuthenticated) {
|
|
setMessage("Please sign in first.");
|
|
return;
|
|
}
|
|
setMessage("");
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch("/api/connect/account/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ displayName, contactEmail, slug: storeSlug }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Failed to create account");
|
|
setAccountId(data.accountId);
|
|
setMessage("Connected account created.");
|
|
} catch (err: any) {
|
|
setMessage(err.message || "Failed to create account.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function refreshStatus() {
|
|
if (!accountId) return;
|
|
setMessage("");
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/connect/account/status?accountId=${accountId}`);
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Failed to fetch status");
|
|
setStatus(data);
|
|
} catch (err: any) {
|
|
setMessage(err.message || "Failed to fetch status.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function startOnboarding() {
|
|
if (!accountId) return;
|
|
setMessage("");
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch("/api/connect/account/link", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ accountId }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Failed to create account link");
|
|
window.location.href = data.url;
|
|
} catch (err: any) {
|
|
setMessage(err.message || "Failed to start onboarding.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function createProduct() {
|
|
if (!accountId) return;
|
|
setMessage("");
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch("/api/connect/products/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
accountId,
|
|
name: productName,
|
|
description: productDescription,
|
|
priceInCents: Number(productPrice),
|
|
currency,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Failed to create product");
|
|
setMessage(`Product created: ${data.productId}`);
|
|
} catch (err: any) {
|
|
setMessage(err.message || "Failed to create product.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function createSubscription() {
|
|
if (!accountId) return;
|
|
setMessage("");
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch("/api/connect/subscription/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ accountId }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Failed to create subscription");
|
|
window.location.href = data.url;
|
|
} catch (err: any) {
|
|
setMessage(err.message || "Failed to create subscription.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function openBillingPortal() {
|
|
if (!accountId) return;
|
|
setMessage("");
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch("/api/connect/subscription/portal", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ accountId }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Failed to create portal session");
|
|
window.location.href = data.url;
|
|
} catch (err: any) {
|
|
setMessage(err.message || "Failed to open billing portal.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="connect-page">
|
|
<section className="section">
|
|
<div className="container connect-page__inner">
|
|
<h1 className="page-title">Stripe Connect Demo</h1>
|
|
<p className="connect-page__subtitle">
|
|
This demo onboards connected accounts, creates products on their
|
|
account, and provides a storefront for customers to purchase.
|
|
</p>
|
|
|
|
<div className="connect-card">
|
|
<h2>Account Access</h2>
|
|
{isAuthenticated ? (
|
|
<div className="connect-actions">
|
|
<div className="connect-muted">Signed in as {session!.user.email}</div>
|
|
<button className="btn btn--ghost" onClick={() => signOut()}>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="connect-actions">
|
|
<div className="connect-muted">Sign in to create an account.</div>
|
|
<button className="btn" onClick={() => signIn()}>
|
|
Sign in
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="connect-card">
|
|
<h2>Create Connected Account</h2>
|
|
<div className="connect-form">
|
|
<input
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
placeholder="Display name"
|
|
/>
|
|
<input
|
|
value={contactEmail}
|
|
onChange={(e) => setContactEmail(e.target.value)}
|
|
placeholder="Contact email"
|
|
/>
|
|
<input
|
|
value={storeSlug}
|
|
onChange={(e) => setStoreSlug(e.target.value)}
|
|
placeholder="Store slug (e.g. storeshifted)"
|
|
/>
|
|
<button className="btn" onClick={createAccount} disabled={loading}>
|
|
Create Account
|
|
</button>
|
|
</div>
|
|
{accountId ? (
|
|
<div className="connect-muted">Connected account: {accountId}</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="connect-card">
|
|
<h2>Onboard to Collect Payments</h2>
|
|
<div className="connect-actions">
|
|
<button className="btn" onClick={startOnboarding} disabled={loading}>
|
|
Onboard to collect payments
|
|
</button>
|
|
<button className="btn" onClick={refreshStatus} disabled={loading}>
|
|
Refresh status
|
|
</button>
|
|
</div>
|
|
{status ? (
|
|
<div className="connect-status">
|
|
<div>Ready to process payments: {String(status.readyToProcessPayments)}</div>
|
|
<div>Onboarding complete: {String(status.onboardingComplete)}</div>
|
|
<div>Requirements status: {status.requirementsStatus || "unknown"}</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="connect-card">
|
|
<h2>Create Product on Connected Account</h2>
|
|
<div className="connect-form">
|
|
<input
|
|
value={productName}
|
|
onChange={(e) => setProductName(e.target.value)}
|
|
placeholder="Product name"
|
|
/>
|
|
<input
|
|
value={productDescription}
|
|
onChange={(e) => setProductDescription(e.target.value)}
|
|
placeholder="Description"
|
|
/>
|
|
<input
|
|
value={productPrice}
|
|
onChange={(e) => setProductPrice(e.target.value)}
|
|
placeholder="Price in cents"
|
|
/>
|
|
<input
|
|
value={currency}
|
|
onChange={(e) => setCurrency(e.target.value)}
|
|
placeholder="Currency (usd)"
|
|
/>
|
|
<button className="btn" onClick={createProduct} disabled={loading}>
|
|
Create Product
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="connect-card">
|
|
<h2>Storefront</h2>
|
|
<p className="connect-muted">
|
|
This demo uses the connected account ID in the URL. Replace this
|
|
with your own identifier in production.
|
|
</p>
|
|
<div className="connect-actions">
|
|
<a className="btn" href={storefrontUrl || "#"}>Open Storefront</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="connect-card">
|
|
<h2>Subscription (Platform Billing)</h2>
|
|
<p className="connect-muted">
|
|
Creates a hosted subscription checkout session and a billing portal
|
|
for the connected account.
|
|
</p>
|
|
<div className="connect-actions">
|
|
<button className="btn" onClick={createSubscription} disabled={loading}>
|
|
Subscribe
|
|
</button>
|
|
<button className="btn" onClick={openBillingPortal} disabled={loading}>
|
|
Open Billing Portal
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{message ? <div className="connect-message">{message}</div> : null}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default function ConnectDashboardPage() {
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<ConnectDashboardClient />
|
|
</Suspense>
|
|
);
|
|
}
|