102 lines
2.8 KiB
TypeScript
102 lines
2.8 KiB
TypeScript
"use client";
|
|
|
|
// Stripe Payment Element for the booking-deposit flow.
|
|
// First real client component in the app — needed because Stripe.js can only
|
|
// run in the browser.
|
|
|
|
import { useState } from "react";
|
|
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
|
import {
|
|
Elements,
|
|
PaymentElement,
|
|
useElements,
|
|
useStripe,
|
|
} from "@stripe/react-stripe-js";
|
|
|
|
// Cache the Stripe object across renders. loadStripe returns a Promise-like.
|
|
let stripePromise: ReturnType<typeof loadStripe> | null = null;
|
|
function getStripe(publishableKey: string) {
|
|
if (!stripePromise) stripePromise = loadStripe(publishableKey);
|
|
return stripePromise;
|
|
}
|
|
|
|
export function PaymentForm({
|
|
publishableKey,
|
|
clientSecret,
|
|
amountCents,
|
|
returnUrl,
|
|
}: {
|
|
publishableKey: string;
|
|
clientSecret: string;
|
|
amountCents: number;
|
|
returnUrl: string;
|
|
}) {
|
|
return (
|
|
<Elements
|
|
stripe={getStripe(publishableKey)}
|
|
options={{
|
|
clientSecret,
|
|
appearance: { theme: "stripe" },
|
|
}}
|
|
>
|
|
<CheckoutInner amountCents={amountCents} returnUrl={returnUrl} />
|
|
</Elements>
|
|
);
|
|
}
|
|
|
|
function CheckoutInner({
|
|
amountCents,
|
|
returnUrl,
|
|
}: {
|
|
amountCents: number;
|
|
returnUrl: string;
|
|
}) {
|
|
const stripe = useStripe();
|
|
const elements = useElements();
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function onSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!stripe || !elements) return;
|
|
setSubmitting(true);
|
|
setError(null);
|
|
|
|
const { error: stripeError } = await stripe.confirmPayment({
|
|
elements,
|
|
confirmParams: { return_url: returnUrl },
|
|
});
|
|
|
|
// confirmPayment only returns here on validation/network errors;
|
|
// on success Stripe redirects to return_url.
|
|
if (stripeError) {
|
|
setError(stripeError.message ?? "Payment failed — try again.");
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={onSubmit} className="space-y-4">
|
|
<PaymentElement />
|
|
{error && (
|
|
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<button
|
|
type="submit"
|
|
disabled={!stripe || !elements || submitting}
|
|
className="w-full rounded-md bg-zinc-900 py-2.5 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
|
|
>
|
|
{submitting ? "Processing…" : `Pay $${(amountCents / 100).toFixed(2)} deposit`}
|
|
</button>
|
|
<p className="text-center text-xs text-zinc-500">
|
|
Your card is charged the deposit now. The remaining balance is paid in person.
|
|
</p>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// Type re-export so the page can declare props without importing @stripe/stripe-js.
|
|
export type { Stripe };
|