From 43d1d8f646bc4b2b5672147e8e0b484fd8c01e00 Mon Sep 17 00:00:00 2001 From: Stevin Date: Wed, 4 Nov 2026 12:41:52 -0500 Subject: [PATCH] feat: add stripe membership dues checkout Adds a Membership section and navbar "Pay dues" button that post to /api/checkout, which creates a Stripe hosted Checkout Session (one-time payment, customer_creation always, required T-shirt size dropdown and Discord username fields, term metadata) and redirects to it. Paid members land on /join/success. Stripe client is created lazily so lint, build and the Docker image work with no env vars set. Closes #38 Co-Authored-By: Claude Fable 5.1 --- .env.example | 13 ++++++ .gitignore | 1 + README.md | 12 ++++++ app/api/checkout/route.ts | 73 ++++++++++++++++++++++++++++++++ app/join/success/page.tsx | 45 ++++++++++++++++++++ app/page.tsx | 9 ++++ components/Footer.tsx | 1 + components/MembershipSection.tsx | 52 +++++++++++++++++++++++ components/Navbar.tsx | 24 ++++++++--- docker-compose.yml | 5 +++ lib/membership.ts | 32 ++++++++++++++ lib/stripe.ts | 14 ++++++ package-lock.json | 20 +++++++++ package.json | 1 + 14 files changed, 295 insertions(+), 7 deletions(-) create mode 100644 .env.example create mode 100644 app/api/checkout/route.ts create mode 100644 app/join/success/page.tsx create mode 100644 components/MembershipSection.tsx create mode 100644 lib/membership.ts create mode 100644 lib/stripe.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..91148cb --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Copy to .env.local for `npm run dev`, or to .env for `docker compose up`. +# Never commit real values; .gitignore keeps .env and .env.local out of git. + +# Stripe secret key (test mode: sk_test_..., live: sk_live_...). Server only. +STRIPE_SECRET_KEY= + +# Stripe Price ID for the current membership term. +# One Product per term in the Dashboard, e.g. "GPK Membership - 2026-27". +STRIPE_PRICE_ID= + +# Public origin used to build Stripe's success/cancel URLs, e.g. https://gpknights.org (no trailing slash). +# Leave empty locally: the checkout route falls back to the request's origin. Set it in production. +NEXT_PUBLIC_SITE_URL= diff --git a/.gitignore b/.gitignore index cfb8c36..0cca794 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/README.md b/README.md index dedc88e..69bf617 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,18 @@ Open . For full setup details (Node version pinning, lint, build, Docker), the PR workflow, branch/commit conventions, and a command cheat sheet, see [CONTRIBUTING.md](CONTRIBUTING.md). +### Membership dues (Stripe) + +The "Pay dues" button on the homepage sends members to Stripe Checkout. To run it locally: + +1. Copy `.env.example` to `.env.local` (or to `.env` for `docker compose up`). +2. In the Stripe Dashboard, create one Product + Price per membership term (e.g. "GPK Membership - 2026-27") and paste the Price ID into `STRIPE_PRICE_ID`. +3. Use test mode keys and the test card `4242 4242 4242 4242` to try the full flow. + +Each payment in the Dashboard shows the payer's name, email, T-shirt size and Discord username (also available as columns in the payments export), and every payer gets a Customer record. + +The site builds and runs with no Stripe variables set; the Pay dues button then returns a "not set up yet" message. + ### Project Structure ```text diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts new file mode 100644 index 0000000..19d61a2 --- /dev/null +++ b/app/api/checkout/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import type Stripe from "stripe"; +import { getStripe } from "@/lib/stripe"; +import { MEMBERSHIP } from "@/lib/membership"; + +const NOT_CONFIGURED = "Dues payments aren't set up yet. Please ask a GPK officer."; +const FAILED = "Couldn't start checkout. Please try again or contact a GPK officer."; + +/** Minimal HTML so a member who hits an error still has a way back to the site. */ +function errorPage(message: string, status: number): Response { + const html = `GPK dues

${message}

Back to the site

`; + return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); +} + +/** Public origin for Stripe's redirect URLs: the env var, else the (proxy-aware) request origin. */ +function siteOrigin(request: Request): string { + const configured = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/+$/, ""); + if (configured) return configured; + const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); + const proto = request.headers.get("x-forwarded-proto") ?? new URL(request.url).protocol.replace(":", ""); + return host ? `${proto}://${host}` : new URL(request.url).origin; +} + +/** Creates a Stripe hosted Checkout Session and redirects the browser to it. The request body is ignored. */ +export async function POST(request: Request): Promise { + const priceId = process.env.STRIPE_PRICE_ID; + if (!process.env.STRIPE_SECRET_KEY || !priceId) return errorPage(NOT_CONFIGURED, 503); + + const siteUrl = siteOrigin(request); + + const params: Stripe.Checkout.SessionCreateParams = { + mode: "payment", + line_items: [{ price: priceId, quantity: 1 }], + customer_creation: "always", + submit_type: "pay", + // Stripe requires custom-field keys and dropdown values to be alphanumeric. + // Both fields are required (no `optional: true`). + custom_fields: [ + { + key: "shirtsize", + label: { type: "custom", custom: "T-shirt size" }, + type: "dropdown", + dropdown: { + options: MEMBERSHIP.shirtSizes.map((s) => ({ label: s.label, value: s.value })), + }, + }, + { + key: "discord", + label: { type: "custom", custom: "Discord username" }, + type: "text", + text: { minimum_length: 2, maximum_length: 37 }, + }, + ], + metadata: { term: MEMBERSHIP.term }, + payment_intent_data: { + description: `GPK Membership - ${MEMBERSHIP.term}`, + metadata: { term: MEMBERSHIP.term }, + }, + success_url: `${siteUrl}/join/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${siteUrl}/#membership`, + }; + + try { + const session = await getStripe().checkout.sessions.create(params); + // 303 so the browser follows the redirect with GET rather than re-POSTing. + if (typeof session.url === "string") return NextResponse.redirect(session.url, 303); + console.error("checkout session failed:", "session has no url"); + } catch (err) { + // Log only the message: never the Stripe error object (it can carry request details). + console.error("checkout session failed:", err instanceof Error ? err.message : String(err)); + } + return errorPage(FAILED, 502); +} diff --git a/app/join/success/page.tsx b/app/join/success/page.tsx new file mode 100644 index 0000000..2e0d8ed --- /dev/null +++ b/app/join/success/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { FaDiscord } from "react-icons/fa"; +import { MEMBERSHIP } from "@/lib/membership"; + +export const metadata: Metadata = { + title: "You're in | Graphics Programming Knights", +}; + +// Static page: Stripe redirects here after payment. Reading nothing from the +// request keeps it prerendered so `next build` works with no env vars set. +export default function JoinSuccessPage() { + return ( +
+
+
+ {MEMBERSHIP.term} Membership +
+

+ You're in! +

+

+ Thanks for paying your GPK dues. Your T-shirt size and Discord + username were saved with your payment, and a receipt is on its way to + your email. +

+ + Join the Discord + + + + Back to the site + +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index b667975..224d84f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,7 @@ import AboutSection from "@/components/AboutSection"; import Footer from "@/components/Footer"; import HeroSection from "@/components/HeroSection"; +import MembershipSection from "@/components/MembershipSection"; import TeamSection from "@/components/TeamSection"; import ChallengesSection from "@/components/ChallengesSection" @@ -29,6 +30,14 @@ export default function Home() { + {/* START OF THE MEMBERSHIP SECTION */} +
+

+ Membership +

+ +
+ {/* START OF THE TEAM SECTION */}

diff --git a/components/Footer.tsx b/components/Footer.tsx index 3f97bc9..4986692 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -9,6 +9,7 @@ const linkGroups = [ { label: "Home", href: "#home" }, { label: "About us", href: "#about" }, { label: "Challenges", href: "#challenges" }, + { label: "Membership", href: "#membership" }, { label: "Team", href: "#team" }, ], }, diff --git a/components/MembershipSection.tsx b/components/MembershipSection.tsx new file mode 100644 index 0000000..dbd3e00 --- /dev/null +++ b/components/MembershipSection.tsx @@ -0,0 +1,52 @@ +import { FaArrowRight, FaCheck } from "react-icons/fa"; +import { MEMBERSHIP } from "@/lib/membership"; + +export default function MembershipSection() { + return ( +
+
+ {/* Left Column: term badge, price, and blurb */} +
+
+ {MEMBERSHIP.term} Membership +
+

+ {MEMBERSHIP.priceLabel} + {MEMBERSHIP.cadence} +

+

+ Pay your dues online and you're a GPK member for the year. Your + T-shirt size and Discord username are collected at checkout so we can + order shirts and hand out roles. +

+
+ + {/* Right Column: perks and checkout. Stripe collects every field, so no inputs here. */} +
+
    + {MEMBERSHIP.perks.map((perk) => ( +
  • +
  • + ))} +
+
+
+ +
+

+ Secure checkout by Stripe. Name, email, T-shirt size and Discord username are required. +

+
+
+
+
+ ); +} diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 080a895..d471508 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; -const sections = ["home", "about", "challenges", "team"]; +const sections = ["home", "about", "challenges", "membership", "team"]; export default function Navbar() { const [activeSection, setActiveSection] = useState("home"); @@ -36,24 +36,34 @@ export default function Navbar() { /* contains navbar w gpk tiny logo on left and blurs anything near navbar*/ return (