Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,8 @@ import { useRouter } from "next/navigation";
import type { InsertMember } from "@forge/db/schemas/knight-hacks";
import { Button } from "@forge/ui/button";

import { api } from "~/trpc/react";

export default function PaymentButton({ member }: { member: InsertMember }) {
const { mutateAsync: createCheckoutUrl } =
api.duesPayment.createCheckout.useMutation();
const router = useRouter();

const [disableButton, setDisableButton] = useState<boolean>(false);

useEffect(() => {
Expand All @@ -33,15 +28,9 @@ export default function PaymentButton({ member }: { member: InsertMember }) {
}
}, [member.school]);

const handleCheckout = async () => {
const { checkoutUrl } = await createCheckoutUrl();
if (checkoutUrl) {
router.push(checkoutUrl);
}
};
return (
<Button
onClick={handleCheckout}
onClick={() => router.push("/member/checkout")}
disabled={disableButton}
className="w-full"
>
Expand Down
234 changes: 234 additions & 0 deletions apps/blade/src/app/_components/dashboard/member/checkout-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
"use client";

import type { Appearance } from "@stripe/stripe-js";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
Elements,
PaymentElement,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { useTheme } from "next-themes";
import { FaStripe } from "react-icons/fa";

import { Button } from "@forge/ui/button";
import { Input } from "@forge/ui/input";
import { Label } from "@forge/ui/label";
import { toast } from "@forge/ui/toast";

import { env } from "~/env";
import { api } from "~/trpc/react";

const stripePromise = loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);

function buildAppearance(isDark: boolean): Appearance {
return {
theme: isDark ? "night" : "stripe",
variables: {
colorPrimary: isDark ? "#6d28d9" : "#7c3aed",
colorBackground: isDark ? "#060b1a" : "#ffffff",
colorText: isDark ? "#f8fafc" : "#060b1a",
colorDanger: "#ef4444",
fontFamily: "GeistSans, system-ui, sans-serif",
borderRadius: "0.5rem",
colorTextPlaceholder: isDark ? "#94a3b8" : "#9ca3af",
},
rules: {
".Input": {
border: `1px solid ${isDark ? "#1e2d40" : "#e2e4eb"}`,
backgroundColor: isDark ? "#060b1a" : "#ffffff",
color: isDark ? "#f8fafc" : "#060b1a",
},
".Input:focus": {
borderColor: isDark ? "#6d28d9" : "#7c3aed",
boxShadow: `0 0 0 1px ${isDark ? "#6d28d9" : "#7c3aed"}`,
outline: "none",
},
".Label": {
color: isDark ? "#94a3b8" : "#6b7280",
fontSize: "0.875rem",
},
".Tab": {
border: `1px solid ${isDark ? "#1e2d40" : "#e2e4eb"}`,
backgroundColor: isDark ? "#060b1a" : "#ffffff",
},
".Tab:hover": {
backgroundColor: isDark ? "#1e2d40" : "#f3f4f6",
},
".Tab--selected": {
borderColor: "#7c3aed",
},
},
};
}

function PaymentForm() {
const stripe = useStripe();
const elements = useElements();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [email, setEmail] = useState("");

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) return;

setIsSubmitting(true);
setErrorMessage(null);

const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: "if_required",
confirmParams: {
return_url: `${window.location.origin}/member/success`,
receipt_email: email,
},
});

if (error) {
setErrorMessage(error.message ?? null);
setIsSubmitting(false);
return;
}

if (paymentIntent.status === "succeeded") {
router.push(`/member/success?payment_intent=${paymentIntent.id}`);
} else if (paymentIntent.status === "processing") {
setErrorMessage(
"Your payment is still processing. We'll update your membership once Stripe confirms it.",
);
setIsSubmitting(false);
} else {
setErrorMessage("Payment could not be completed. Please try again.");
setIsSubmitting(false);
}
};

return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div className="flex flex-col gap-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<PaymentElement />
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<p className="text-xs text-muted-foreground">
All payments are non-refundable.
</p>
<div className="flex items-center justify-center gap-1 text-xs text-muted-foreground">
<span>Powered by</span>
<FaStripe className="h-8 w-8 text-[#635BFF]" />
<span className="font-medium"> | Privacy Terms</span>
</div>
<div className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => router.push("/dashboard")}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !stripe}>
{isSubmitting ? "Processing..." : "Pay"}
</Button>
</div>
</form>
);
}

export function CheckoutForm() {
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [intentError, setIntentError] = useState<string | null>(null);

const { mutate: createPaymentIntent } =
api.duesPayment.createPaymentIntent.useMutation({
onSuccess: (data) => {
if (data.clientSecret) {
setClientSecret(data.clientSecret);
} else {
setIntentError("Could not initialize payment. Please try again.");
}
},
onError: (error) => {
setIntentError(error.message);
toast.error("Failed to start checkout.");
},
});

useEffect(() => {
createPaymentIntent();
}, [createPaymentIntent]);

return (
<div className="mx-auto max-w-4xl overflow-hidden rounded-lg border bg-card shadow-sm">
<div className="grid md:grid-cols-2">
{/* Left — order summary */}
<div className="flex flex-col justify-between bg-muted p-8">
<div>
<p className="text-sm font-medium text-muted-foreground">
Knight Hacks
</p>
<h2 className="mt-2 text-4xl font-bold">$25.00</h2>
<p className="mt-1 text-sm text-muted-foreground">
Club Membership
</p>
</div>
<div className="mt-8 border-t pt-6">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Knight Hacks Membership
</span>
<span className="font-medium">$25.00</span>
</div>
<div className="mt-3 flex items-center justify-between border-t pt-3 text-sm font-semibold">
<span>Total due today</span>
<span>$25.00</span>
</div>
</div>
</div>

{/* Right — payment form */}
<div className="p-8">
{intentError && (
<p className="mb-4 text-sm text-destructive">{intentError}</p>
)}

{!clientSecret && !intentError && (
<div className="space-y-3">
<div className="h-10 animate-pulse rounded-md bg-muted" />
<div className="h-10 animate-pulse rounded-md bg-muted" />
<div className="h-10 animate-pulse rounded-md bg-muted" />
</div>
)}

{clientSecret && (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: buildAppearance(isDark),
}}
>
<PaymentForm />
</Elements>
)}
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useEffect } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";

Expand All @@ -17,10 +18,23 @@ export function MembershipSuccess() {
const searchParams = useSearchParams();
const router = useRouter();

const checkoutSessionId = searchParams.get("session_id") ?? "";
const paymentIntentId = searchParams.get("payment_intent");

const { data, isPending, isError } =
api.duesPayment.orderSuccess.useQuery(checkoutSessionId);
const { data, isPending, isError } = api.duesPayment.orderSuccess.useQuery(
paymentIntentId ?? "",
{ enabled: Boolean(paymentIntentId) },
);

useEffect(() => {
if (!paymentIntentId) {
toast.error("Invalid confirmation link.");
router.replace(SIGN_IN_PATH);
}
}, [paymentIntentId, router]);

if (!paymentIntentId) {
return null;
}

if (isError) {
toast.error("Something went wrong, please contact support.");
Expand All @@ -32,10 +46,27 @@ export function MembershipSuccess() {
return <MembershipSuccessSkeleton />;
}

if (data.status === "unpaid") {
toast.error("Checkout session was not complete, please try again.");
router.push(SIGN_IN_PATH);
return null;
if (data.status === "processing") {
return (
<div className="min-h-screen py-12">
<div className="mx-auto max-w-3xl px-4">
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold">Payment Processing</h1>
<p className="mt-2">
Your payment is being processed. You will receive a confirmation
email once it is complete.
</p>
</div>
<Alert className="mb-8">
<AlertTitle>Pending</AlertTitle>
<AlertDescription>
Bank transfers can take 1–5 business days to settle. Your
membership will be activated once the payment clears.
</AlertDescription>
</Alert>
</div>
</div>
);
}

return (
Expand Down
Loading
Loading