Skip to content
Open
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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
!.env.example

# vercel
.vercel
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ Open <http://localhost:3000>.

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
Expand Down
73 changes: 73 additions & 0 deletions app/api/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html><title>GPK dues</title><p>${message}</p><p><a href="/#membership">Back to the site</a></p>`;
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<Response> {
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);
}
45 changes: 45 additions & 0 deletions app/join/success/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="mx-auto flex min-h-[calc(100svh-4rem)] w-full max-w-7xl flex-col items-center justify-center px-6 py-12 text-text-primary sm:px-10">
<div className="w-full max-w-2xl rounded-2xl border border-card-border bg-card-background p-8 text-center sm:p-12">
<div className="mx-auto w-fit rounded-full bg-accent px-5 py-1">
{MEMBERSHIP.term} Membership
</div>
<h1 className="mt-6 text-4xl font-extrabold tracking-tight sm:text-5xl">
You&apos;re in!
</h1>
<p className="mt-4 leading-8 text-text-secondary">
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.
</p>
<a
href={MEMBERSHIP.discordInvite}
target="_blank"
rel="noopener noreferrer"
className="mt-8 inline-flex shrink-0 items-center gap-2 rounded-full bg-accent px-6 py-3 text-lg font-medium text-text-primary transition-colors hover:bg-accent-light focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent-light"
>
Join the Discord
<FaDiscord className="h-6 w-6" />
</a>
<Link
href="/"
className="mt-4 block text-sm text-text-secondary transition-colors hover:text-accent-light"
>
Back to the site
</Link>
</div>
</main>
);
}
9 changes: 9 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -29,6 +30,14 @@ export default function Home() {
<ChallengesSection />
</section>

{/* START OF THE MEMBERSHIP SECTION */}
<section id="membership" className="min-h-screen w-full flex flex-col items-center px-6 sm:px-10 lg:px-24">
<h2 className="text-4xl font-bold p-16 m-16 h-16 gap-16">
Membership
</h2>
<MembershipSection />
</section>

{/* START OF THE TEAM SECTION */}
<section id="team" className="min-h-screen w-full flex flex-col items-center px-24">
<h2 className="text-4xl font-bold p-16 m-16 h-16 gap-16">
Expand Down
1 change: 1 addition & 0 deletions components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
},
Expand Down
52 changes: 52 additions & 0 deletions components/MembershipSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { FaArrowRight, FaCheck } from "react-icons/fa";
import { MEMBERSHIP } from "@/lib/membership";

export default function MembershipSection() {
return (
<div className="p-8 sm:p-12 bg-card-background border border-card-border text-text-primary rounded-2xl w-full">
<div className="grid gap-8 lg:grid-cols-[1.1fr_1fr] lg:gap-12">
{/* Left Column: term badge, price, and blurb */}
<div className="flex flex-col gap-6">
<div className="px-5 py-1 bg-accent rounded-full w-fit">
{MEMBERSHIP.term} Membership
</div>
<p className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-5xl font-bold">{MEMBERSHIP.priceLabel}</span>
<span className="text-text-secondary">{MEMBERSHIP.cadence}</span>
</p>
<p className="leading-8">
Pay your dues online and you&apos;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.
</p>
</div>

{/* Right Column: perks and checkout. Stripe collects every field, so no inputs here. */}
<div className="flex flex-col gap-6">
<ul className="flex flex-col gap-2">
{MEMBERSHIP.perks.map((perk) => (
<li key={perk} className="flex items-start gap-3 leading-8">
<FaCheck className="mt-2 h-4 w-4 shrink-0 text-accent-light" aria-hidden="true" />
{perk}
</li>
))}
</ul>
<div className="flex flex-col gap-3">
<form method="POST" action="/api/checkout">
<button
type="submit"
className="inline-flex shrink-0 cursor-pointer items-center gap-2 rounded-full bg-accent px-6 py-3 text-lg font-medium text-text-primary transition-colors hover:bg-accent-light focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent-light"
>
Pay dues
<FaArrowRight className="h-5 w-5" aria-hidden="true" />
</button>
</form>
<p className="text-sm text-text-secondary">
Secure checkout by Stripe. Name, email, T-shirt size and Discord username are required.
</p>
</div>
</div>
</div>
</div>
);
}
24 changes: 17 additions & 7 deletions components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -36,24 +36,34 @@ export default function Navbar() {
/* contains navbar w gpk tiny logo on left and blurs anything near navbar*/
return (
<nav aria-label="Primary" className="sticky top-0 z-50 bg-page-background/10 backdrop-blur-sm">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-y-3 px-6 py-4 md:flex-nowrap md:gap-x-6">
<div>
<a href="#home" className="font-bold text-text-primary">
GPK
</a>
</div>

{/* active section gets filled white box!!! */}
<div className="flex gap-8">
{/* same checkout form as the membership card; Stripe collects every field */}
<form method="POST" action="/api/checkout" className="shrink-0 md:order-last">
<button
type="submit"
className="cursor-pointer rounded-full bg-accent px-4 py-1.5 text-sm font-medium text-text-primary transition-colors hover:bg-accent-light focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent-light"
>
Pay dues
</button>
</form>

{/* active section gets filled white box!!! On phones the links drop to their own row. */}
<div className="flex w-full items-center justify-between md:ml-auto md:w-auto md:justify-end md:gap-4 lg:gap-8">
{sections.map((section) => (
<a
key={section}
href={`#${section}`}
aria-current={activeSection === section ? "location" : undefined}
className={`capitalize ${
className={`capitalize rounded-md border px-1.5 py-1 text-xs sm:text-sm md:px-3 md:text-base ${
activeSection === section
? "rounded-md border border-text-primary bg-white px-3 py-1 text-black"
: "rounded-md border border-transparent px-3 py-1 text-text-primary"
? "border-text-primary bg-white text-black"
: "border-transparent text-text-primary"
}`}
>
{section}
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ services:
- "3000:3000"
environment:
- NODE_ENV=production
# Stripe keys and site URL live in a local .env (see .env.example). Optional so
# `docker compose up` still works without one.
env_file:
- path: .env
required: false
restart: unless-stopped

# =============================================================================
Expand Down
32 changes: 32 additions & 0 deletions lib/membership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Membership constants shared by the homepage section, the navbar button,
* the checkout route, and the success page.
*
* Once per membership cycle: update `term` here and point STRIPE_PRICE_ID at
* that cycle's Stripe Price. The Stripe Price is the source of truth for the
* amount charged; `priceLabel` is display only.
*/
export const MEMBERSHIP = {
/** Membership cycle label. Mirrors the Stripe Product name ("GPK Membership - 2026-27"). */
term: "2026-27",
/** Display only. The amount actually charged comes from the Stripe Price. */
priceLabel: "$20",
cadence: "per academic year",
// TODO(officers): confirm this copy before launch.
perks: [
"Official GPK T-shirt (pick your size at checkout)",
"Member-only events and socials",
"Supports workshops, monthly challenges, and speaker events",
],
discordInvite: "https://discord.gg/XwDHkT3fRa",
/** Shown as a required dropdown on Stripe Checkout. Values must be alphanumeric. */
shirtSizes: [
{ label: "XS", value: "xs" },
{ label: "S", value: "s" },
{ label: "M", value: "m" },
{ label: "L", value: "l" },
{ label: "XL", value: "xl" },
{ label: "2XL", value: "2xl" },
{ label: "3XL", value: "3xl" },
],
} as const;
14 changes: 14 additions & 0 deletions lib/stripe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Stripe from "stripe";

let client: Stripe | undefined;

/**
* Lazily builds the Stripe client. The key is read at call time, never at
* module scope, so `next build` (which runs with no env vars) never touches it.
*/
export function getStripe(): Stripe {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
if (!client) client = new Stripe(key);
return client;
}
Loading
Loading