Repository navigation
Description
Feature Description
Let people pay GPK dues on the site through Stripe, and grab their name, email, and shirt size while they're at it so we can order shirts without chasing anyone down.
I want to keep this small on purpose. Phase 1 is a Membership section on the homepage with a "Pay dues" button that bounces you to a Stripe hosted checkout page. No login and no database. Whoever is handling the money opens the Stripe Dashboard and sees who paid and what size they picked.
Problem / Motivation
Right now the only "join" button on the site is the Discord invite in the hero. There's no way to actually pay dues.
So every term dues and shirt sizes get collected in separate places and somebody has to match them up by hand. Shirt orders need one list of names and sizes we trust, and the easiest way to get that is to make the payment itself carry the size.
Also, #32 installed drizzle-orm, postgres, and drizzle-kit "so we can start building the database layer" and nothing has touched them since. This gives that work somewhere to go later without making it a blocker now.
Proposed Solution
What a student sees
- A new
#membershipsection on the homepage: the price, what you get, a "Pay dues" button. Maybe a second button next to "Join Us" in the hero, up to whoever builds it. - The button posts to
/api/checkout. That creates a Stripe Checkout Session and redirects to Stripe's page. - Stripe's page asks for email and name on its own. We add two custom fields:
- T-shirt size, required dropdown (XS, S, M, L, XL, 2XL, 3XL)
- Discord username, optional text, so we can hand out roles later
- After paying they land on a short "you're in, here's the Discord" page.
What officers see
Every payment in Dashboard > Payments shows the shirt size and Discord name. We create the session with customer_creation: "always" so each payer becomes a Customer in Stripe, which means the Customers tab is basically the member list. The payments export can include the custom field columns, and that export is the shirt order.
One Stripe Product per term ("GPK Membership - Fall 2026") so filtering by semester is a dropdown in the Dashboard instead of a spreadsheet problem.
Implementation sketch
Should fit in one PR under the ~300 line guideline from CONTRIBUTING.
npm i stripe. Server SDK only, hosted Checkout doesn't need anything client side.lib/stripe.tswith a lazily created client. Don't donew Stripe(process.env.STRIPE_SECRET_KEY!)at the top of the module. CI and the Docker build run with zero env vars and will fall over.app/api/checkout/route.ts(POST). Creates the session withmode: "payment",line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }],customer_creation: "always", the two custom fields above,metadata: { term },success_url,cancel_url. Redirect tosession.url.components/MembershipSection.tsxplus a<section id="membership">inapp/page.tsx. Thesectionsarray inNavbar.tsxand the Club links inFooter.tsxare both hardcoded, so add"membership"to both.app/join/success/page.tsx, or just a query param banner on/if that's less hassle..env.examplewithSTRIPE_SECRET_KEY,STRIPE_PRICE_ID,NEXT_PUBLIC_SITE_URL. Heads up:.gitignorehas.env*which would swallow the example file, so add!.env.example. Addenv_file: .envto thegpkwebservice indocker-compose.yml.- Styling: the tokens and the pill button / card classes from
HeroSection.tsxandChallengeCard.tsxalready cover this. Please don't invent new ones.
Done when
- In Stripe test mode you can click "Pay dues" on the site, pay with a test card, and land on the success page.
- The payment in the Dashboard shows name, email, shirt size, Discord username, and term.
- Each payer has a Customer record.
- Shirt size is required, Discord username isn't.
-
npm run lintandnpm run buildpass with no Stripe env vars set, because CI has none. - Navbar highlights Membership when you scroll to it and the footer links to it.
- No secrets in the repo.
.env.examplelists every variable.
Things we need to decide first
| Decision | Who | Default if nobody says otherwise |
|---|---|---|
| Price, and per semester vs per year | Treasurer + President | One payment per term, no subscriptions |
| Whose Stripe account this is, and whether the club is allowed to collect dues into it under UCF RSO/SGA rules | Treasurer | Figure out before merge; the code doesn't care either way |
| Shirt sizes | Treasurer | XS through 3XL |
| Pass the card fee on to the member? | Treasurer | No, bake it into the price |
Homepage section or a separate /join page |
Dev lead | Homepage section. The site is one page and the Navbar hash links only work on / |
Not in this issue
- The webhook. A
POST /api/stripe/webhooklistening forcheckout.session.completedand writing to a Drizzlememberstable (finally using the stuff from chore: install drizzle orm dependencies #32), plus some protected roster or CSV page. Needs a real host with a public HTTPS URL and a signing secret, and we don't have either yet. Also has to be idempotent since Stripe retries. Separate issue when we get there. - Discord bot role sync, login, a "my membership" page. Later, once phase 1 exists and we know where this is hosted.
- Subscriptions. Students come and go every semester and refunding a one time charge is way less annoying than cancelling a subscription.
Alternatives Considered
The lazy option is a Stripe Payment Link. Make the product and the shirt size dropdown in the Dashboard and just link to it, zero code. Honestly this gets most of the way there. Downsides: dropdowns made in the Dashboard cap at 10 options (fine for us), you can't set customer_creation from the Dashboard so people show up as guest customers, and there's no path to the webhook later. If we want dues live before the PR lands, do this first and swap the button to /api/checkout afterward. Nobody will notice.
The other extreme is a full membership portal with login, a DB, and an admin dashboard. I don't want to go there yet. The repo has no auth, no env setup, no schema, no known production host, and no webhook URL, and phase 1 needs none of that. I'd rather ship something.
Google Form plus Venmo or Zelle is what we're trying to get away from. No proof of payment and someone still has to reconcile by hand.
Embedded Checkout would mean more UI work and pulling in @stripe/stripe-js just to render a form with one required field, so hosted it is.
Additional Context
- Checkout custom fields (max 3 per session, dropdown/text/numeric, show up on the payment in the Dashboard, in exports, and in
checkout.session.completed): https://docs.stripe.com/payments/checkout/custom-fields customer_creationandcustom_fieldsparams: https://docs.stripe.com/api/checkout/sessions/create- Payment Links custom fields: https://docs.stripe.com/payment-links/customize
- Stripe says don't put personal or sensitive data in custom fields. Shirt size and a Discord handle are fine. No NID, no UCF ID.
- For context on why the scope is what it is: the repo has no
app/api/, noprocess.envanywhere, no.env.example, no schema ordrizzle.config.ts(chore: install drizzle orm dependencies #32 was deps only), a standalone Docker image, and nothing that says where production runs. - Related: chore: install drizzle orm dependencies #32
Activity