diff --git a/BACKEND_GUIDE.md b/BACKEND_GUIDE.md new file mode 100644 index 0000000..eabc365 --- /dev/null +++ b/BACKEND_GUIDE.md @@ -0,0 +1,261 @@ +# Backend guide β€” the other half (Part 2 of 2) + +You made it to Part 2. πŸ‘‹ If you've done the frontend guide and its two exercises, you already +know more than you think β€” you just know it from the *dining room* side. This guide walks you +through the kitchen door. + +You don't strictly need any of this to make the frontend beautiful. But every `api.…` call you +wrote in Part 1 disappeared through a door, and I don't want that door to feel like magic. +Understanding what's behind it will make you noticeably better at the frontend too β€” you'll +know *why* an endpoint returns what it does, and what's easy vs. hard to change. + +> **Read [`FRONTEND_GUIDE.md`](./FRONTEND_GUIDE.md) first.** This guide reuses its restaurant +> picture (dining room / waiter / kitchen / pantry / suppliers) and starts exactly where it +> stopped: the moment the waiter pushes through the kitchen door. + +The kitchen's whole job, in one sentence: **keep a fast, safe copy of the truth in the pantry, +and plate up exactly the right slice of it as JSON.** Everything below is just *how*. + +> It's all under `src/server/`, and it's refreshingly boring tech on purpose: **TypeScript + +> Express** (the web server) talking to **Postgres** (the database) through **Drizzle** (a +> type-safe way to write database queries). No mysterious framework magic. + +--- + +## 1. What happens after the waiter pushes through the door + +In Part 1, a page asked the waiter (`web/src/api.ts`) for data and the waiter did a `fetch` to +`/api/…`. Here's the rest of that same journey β€” the kitchen side: + +``` + the waiter's order arrives: GET /api/public/past + β”‚ + β–Ό + src/server/index.ts ← the kitchen door. Sends each order to the right station. + β”‚ "/api/public/…" β†’ the publicRouter station. + β–Ό + src/server/routes/public.ts ← the station. The actual code that fills this order. + β”‚ + β–Ό + a Drizzle query on Postgres ← "grab the matching rows from the pantry" + β”‚ db.select().from(events).where(...) + β–Ό + res.json({ events: [...] }) ← plate it up as JSON and hand it back to the waiter +``` + +That's the whole shape. For *any* endpoint in the app, you only ever need to answer two +questions: + +1. **Which file fills this order?** β†’ open `src/server/index.ts`; it maps each URL prefix to a + station (a "router"). +2. **What does that station do?** β†’ open the route file; most handlers are 5–15 readable lines. + +If you can answer those two, you can find and understand any behaviour in the backend. + +--- + +## 2. The kitchen, room by room + +Everything's under `src/server/`. You can ignore most of it at first β€” the two files that +matter most are marked. + +``` +src/server/ + index.ts ⭐ THE KITCHEN DOOR. Wires every station to a URL. Read this first. + env.ts Reads + checks environment variables. Refuses to start if a required + secret is missing β€” a loud failure now beats a mystery crash at 2am. + + db/ + schema.ts ⭐ THE MOST IMPORTANT FILE. Describes every pantry shelf (table). + index.ts The pantry connection β€” the `db` object you run queries on. + migrate.ts Applies database changes on startup. + + auth/ + jwt.ts Checks sign-in tokens from mac-auth. We never build our own login. + middleware.ts requireAuth / requireOrganiser β€” the "members only" guards for routes. + + routes/ One file per area. Each is a station wired up in index.ts. + health.ts "is the kitchen alive?" + public.ts public site data (no sign-in) + me.ts "who am I?" + dashboard.ts everything the participant dashboard needs + teams.ts create / join / invite / leave teams + events.ts organiser event management + tickets.ts trigger ticket syncs / CSV import + organiser.ts the gap report, override queue, CSV exports + stats.ts πŸ‘ˆ YOUR EXERCISE (a stub β€” see Β§6) + + content/ the Notion β†’ pantry delivery (public-site content) + tickets/ the Humanitix β†’ pantry delivery (who holds a ticket) + teams/ team status rules (status is worked out, never set by hand) + participants/ ticket-verification logic + lib/ small shared helpers (audit log, Discord alerts, codes…) +``` + +Honestly, if you read just two files to "get" the backend, read **`index.ts`** (how orders +find their station) and **`db/schema.ts`** (what data exists at all). Everything else is a +variation on the pattern in Β§1. + +--- + +## 3. The pantry, via Drizzle + +The database keeps data in **tables** β€” think spreadsheets: an `events` table, a +`participants` table, a `teams` table, and so on. We never hand-write SQL; instead we use +**Drizzle**, which lets us describe each table in TypeScript and then query it with real +autocomplete and type-checking (so your editor catches typos before the code ever runs). + +Here's a table, trimmed from `db/schema.ts`: + +```ts +export const events = pgTable("events", { + id: uuid("id").primaryKey().defaultRandom(), + slug: text("slug").notNull(), // e.g. "2026" + name: text("name").notNull(), + isPublished: boolean("is_published").notNull().default(false), + isArchived: boolean("is_archived").notNull().default(false), + // …more columns +}); +``` + +And here's a real query against it, from `routes/public.ts` β€” notice it reads almost like a +sentence: + +```ts +const rows = await db + .select() + .from(events) + .where(and(eq(events.isPublished, true), eq(events.isArchived, true))) + .orderBy(desc(events.startsAt)); +``` + +`eq` = "equals", `and` = "both of these are true", `desc` = "newest first". That small +vocabulary covers most of what you'll ever write. + +**Changing the pantry's shelves** β€” adding a column or a whole table β€” is a little two-step +ritual worth knowing exists (you won't need it for the exercise, which only reads): + +1. Edit `db/schema.ts`. +2. Run `npm run db:generate`. Drizzle writes a **migration** β€” a `.sql` file in `drizzle/` + describing the change β€” and `npm run db:migrate` applies it. + +Migrations are how the database changes the same way everywhere: your laptop, staging, and +production, all in the right order, without anyone hand-editing a live database at midnight. + +--- + +## 4. Why the kitchen looks so careful (the house rules) + +You'll notice the backend is written defensively in places. That's not paranoia β€” each rule +below comes from a real "imagine if this went wrong the night before the event" scenario. You +don't need to memorise them; just recognise them when you see them, because they explain a lot +of the code's shape: + +- **Never send raw pantry rows out to guests.** Public endpoints hand back a hand-picked + *whitelist* of fields (see `toPublicEvent()` in `public.ts`). Internal things β€” a Humanitix + id, someone's personal details β€” must never slip into a public response. When you write an + endpoint, choose on purpose what goes in it. +- **Check "members only" on the server, every single time.** A hidden button in the dining + room is *not* security β€” anyone can call the API directly. Protected routes put a guard in + front: `router.get("/me", requireAuth, handler)` (see `me.ts`), and organiser-only routes + add `requireOrganiser`. The sign-in token is re-checked on every request; we never just + trust what the browser claims to be. +- **Cook from the pantry, not from the supplier, on a page load.** Notion and Humanitix are + synced into Postgres on a timer (that's the `content/` and `tickets/` folders). So if Notion + is down, the site shrugs and serves the last good copy. A page must be fast and must never + depend on someone else's API being up at that exact second. +- **Nothing is ever truly deleted.** "Deleting" flips a flag (`isArchived`, a `withdrawn` + status); the row stays. And every meaningful action gets written to an append-only + `audit_log`, so we can always answer "who changed this, and when?" +- **Some things are worked out, not stored.** A team's status (`forming` / `confirmed` / …) + is *calculated* from its members and their tickets (`teams/status.ts`), never set by hand β€” + so it can never drift out of step with reality. + +The clearest example of this mindset is `tickets/sync.ts`. It has a **safety gate** that flat +-out refuses to un-verify a big chunk of attendees in a single sync β€” because the difference +between a normal bug and "200 people are locked out an hour before doors open" is enormous. +Have a read of the comments there someday; it's a lovely example of thinking about what happens +when things go wrong, which is most of what senior backend work actually is. + +--- + +## 5. Running and poking at the kitchen + +Setup is identical to Part 1 (`npm install`, `docker compose up -d db`, `npm run db:seed`, +`npm run dev`). The nice thing about the backend is you can talk to it directly from the +terminal β€” no browser needed β€” using `curl`, which is just "make a web request from the +command line": + +```bash +curl http://localhost:3000/api/health # {"status":"ok","db":"ok"} +curl http://localhost:3000/api/public/past # {"events":[...]} +curl http://localhost:3000/api/public/stats # your exercise β€” see Β§6 +``` + +Poking one endpoint at a time like this is the fastest way to understand it in isolation. (For +routes that need sign-in it's easier to test through the running site with dev sign-in β€” but +you won't need auth for the exercise.) + +**When the backend misbehaves, watch the terminal running `npm run dev`.** That's where server +errors and any `console.log` you add show up. (The *browser* console only shows dining-room +errors β€” a common early confusion. Frontend problems: browser console. Backend problems: +terminal.) + +--- + +## 6. Exercise β€” build your first endpoint (`src/server/routes/stats.ts`) + +This is the mirror image of frontend Exercise 1: there you drew data the kitchen already sent; +here you build the kitchen end of a brand-new order. The file is already created, stubbed, and +wired into `index.ts`, so it's *live right now* β€” +`curl http://localhost:3000/api/public/stats` returns `{"pastEventCount":0}`. Your job is to +make that number honest. + +**Goal:** make `GET /api/public/stats` return the real number of past events. + +**A gentle way in:** +1. Open `src/server/routes/public.ts` and find the `/public/past` handler. It already asks the + pantry for exactly the rows you care about (published **and** archived events). You're going + to reuse that same `.where(...)` filter. +2. In `stats.ts`, run that query and return the *count* rather than the list. The simplest + version: fetch the rows and return `rows.length`. (Uncomment the imports at the top of the + file as you reach for them.) +3. No restart needed β€” `npm run dev` reloads on save. Re-run the `curl` and watch the number + change. That instant feedback loop is the fun part. + +**You'll know it worked:** after `npm run db:seed` there are 2 past events, so a correct +version returns `{"pastEventCount":2}` instead of `0`. + +**And here's the whole point** β€” go back to the dining room and finish the circle: add an +`api.stats()` function to `web/src/api.ts` (the waiter learns a new order) and show +"N hackathons and counting" somewhere on your redesigned landing page. That's one small +feature you built through *every* layer β€” pantry, kitchen, waiter, dining room. Once you've +done that, none of this is magic anymore. πŸŽ‰ + +> Want more? Stretch goal: also return `publishedEventCount` (published but not archived). And +> if you'd like to see the full "add a new column" ritual from Β§3 for real, grab me β€” it's a +> great next exercise. + +--- + +## 7. What to leave alone for now + +While you're finding your feet, steer clear of these unless we're pairing on it. They have +sharp edges and real-world consequences: + +- **`tickets/sync.ts` and the safety gate** β€” get this wrong and real people get locked out. +- **`auth/`** β€” we never build authentication; mac-auth owns it entirely. +- **Anything that writes to `audit_log`, or that hard-deletes a row** β€” please don't add hard + deletes; it breaks a promise the whole system relies on. +- **`db/schema.ts` migrations against production data.** + +Adding **read-only** endpoints β€” exactly like the stats exercise β€” is always safe. That's the +corner of the kitchen to play in first. + +--- + +That's the whole machine, both halves. You came in as "the frontend person" and now you can +read a request from the button a guest clicks all the way down to the pantry shelf and back. +That's genuinely full-stack β€” well done. + +Any question, however small, come find me. Welcome to the kitchen. πŸš€ diff --git a/FRONTEND_GUIDE.md b/FRONTEND_GUIDE.md new file mode 100644 index 0000000..6bfbc93 --- /dev/null +++ b/FRONTEND_GUIDE.md @@ -0,0 +1,334 @@ +# Frontend guide β€” start here (Part 1 of 2) + +Hey! πŸ‘‹ Welcome to the MAC Hackathon platform. You're taking over the **frontend** β€” the part +people actually see, click, and (fingers crossed) enjoy using. This guide gets you from +"I've just cloned this repo" to "I understand how it works and I've changed real code," and +then turns you loose on the redesign. + +You genuinely don't need to know the backend to make the frontend beautiful. But I don't want +any of this to feel like magic you're afraid to touch, so this guide explains the whole shape +of the thing, and its companion β€” **[`BACKEND_GUIDE.md`](./BACKEND_GUIDE.md)** (Part 2) β€” walks +the other half when you're curious. Read them in order; Part 2 literally picks up the story +where this one leaves off. + +Here's the plan for your first day or two: +1. Read Β§1–§2 to get the mental model (10 minutes, no typing). +2. Get it running on your laptop (Β§4). +3. Do the two small **exercises** (Β§6) β€” this is where it clicks. +4. Start redesigning (Β§7). + +> **Brand new to git?** Skip to the **cheat-sheet in Β§8** and read that first β€” it's the one +> tool you'll use constantly, and a little confidence there makes everything else calmer. + +--- + +## 1. The big picture (a restaurant) + +The single most useful thing to understand up front: **the website doesn't make up its own +data.** Every price, name, and date on the screen came from somewhere else and travelled to +the browser. Once you can picture that journey, every page makes sense. + +The easiest way to hold it in your head is a restaurant: + +- **The dining room** is the **frontend** (React, in `web/`) β€” the tables, the menus, the + stuff guests see and touch. **This is your patch.** +- **The waiter** is one small file, **`web/src/api.ts`** β€” they carry your order to the + kitchen and bring the food back. Guests don't wander into the kitchen themselves. +- **The kitchen** is the **backend** (Express, in `src/server/`) β€” it does the actual work. + Guests never go in, but every dish comes from there. +- **The pantry** is the **database** (Postgres) β€” stocked shelves the kitchen cooks from. + It's right there, so it's fast. +- **The suppliers** are **Notion** and **Humanitix** β€” they deliver fresh stock on a + schedule. The kitchen keeps the pantry stocked so it never has to phone a supplier in the + middle of dinner service. + +Drawn out, a plate of data travels like this: + +``` + Notion (a shared doc) Humanitix (ticket sales) Organisers (admin panel) + prizes, judges, FAQ, who bought a ticket create the event, + schedule, sponsors… for the hackathon press "sync now" + β”‚ β”‚ β”‚ + β”‚ delivered on a timer β”‚ delivered on a timer β”‚ saved directly + β–Ό β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ THE PANTRY β€” Postgres (our database) β”‚ + β”‚ one fast, local copy of everything the site needs β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–² + β”‚ the KITCHEN reads the pantry and plates up JSON + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ THE KITCHEN β€” backend API (Express, src/server/) β”‚ + β”‚ e.g. GET /api/public/event β†’ { event: {…}, content: {…} } β”‚ ← Part 2 + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–² + β”‚ the WAITER (web/src/api.ts) carries the order and brings JSON back + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ THE DINING ROOM β€” frontend (React, web/) ← YOU ARE HERE β”‚ + β”‚ turns JSON into buttons, cards, and text on the page β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Why keep a *copy* in the pantry instead of asking Notion every time someone loads the page? +Same reason a kitchen keeps stock: it's faster, and if a supplier's truck is late (Notion is +down), you can still serve dinner from what's on the shelf. That "copy it on a schedule" job +is the backend's world β€” it's the whole second half of the story, so don't worry about it yet. + +**The one thing to take away:** by the time data reaches your React code, you don't care +whether it started in Notion or Humanitix. It's just JSON, handed to you by the waiter. + +--- + +## 2. The loop you'll repeat all day + +Almost every screen you build is the same four steps. Learn this rhythm once and the rest is +detail: + +``` +1. A page loads ──▢ 2. it asks the waiter for data (a function in web/src/api.ts) + β”‚ + β–Ό + 3. the waiter fetches it from the kitchen ( /api/… ) + β”‚ + β–Ό kitchen reads the pantry, returns JSON +4. the page saves that JSON and draws it on screen β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +When the user *changes* something β€” joins a team, ticks a box β€” it's the same loop with one +extra beat: **send the change, then ask for the data again** so the screen matches reality. +(That "ask again" habit saves you a world of confusing bugs. More on it in Exercise 2.) + +**One rule that matters:** never call `fetch("/api/…")` straight from a page. All the kitchen +orders live in one place β€” **`web/src/api.ts`**, our waiter β€” as tidy named functions like +`api.pastEvents()`. Your pages call *those*. It keeps every network detail (the URL, the +sign-in token, error handling) in one file instead of scattered everywhere. Need something the +waiter doesn't offer yet? Add the function to `api.ts` first, then use it in your page. + +> That `api.ts` file is exactly the seam where this guide hands off to Part 2: the waiter is +> the last thing on *your* side of the kitchen door. What happens after they push through it +> is the backend guide's job. + +--- + +## 3. The files you'll live in + +Everything you own is under `web/`. You can happily ignore `src/server/` for now β€” that's the +kitchen, and Part 2 gives you the tour. + +``` +web/ + index.html the single HTML page everything loads into + src/ + main.tsx the ROUTER: which URL shows which page. A good first read. + api.ts the WAITER: every call to the backend lives here + auth.ts sign-in / token plumbing β€” you'll rarely touch this + format.ts date/time helpers (fmtDateRange, fmtTime) + styles.css Tailwind theme: our colours + shared classes (buttons, cards…) + pages/ + Landing.tsx public homepage βœ… WORKS β€” your best worked example + Dashboard.tsx "am I in the hackathon?" βœ… WORKS β€” the read-and-write example + Admin.tsx organiser control panel βœ… WORKS + Past.tsx archive of old events 🚧 EXERCISE 1 (stubbed for you) + FindTeam.tsx the teammate pool 🚧 EXERCISE 2 (stubbed for you) + components/ + SignInPanel.tsx the "please sign in" box + ClaimForm.tsx ticket-claiming form + CustomFieldsForm.tsx extra event questions +``` + +The two 🚧 pages have been **deliberately hollowed out into guided stubs** β€” you're going to +rebuild them, and that's how the whole loop from Β§2 stops being theory. The βœ… pages are +finished and working, and they're your safety net: whenever you're unsure how to do +something, open `Landing.tsx` or `Dashboard.tsx` and copy how *they* did it. Reading working +code is not cheating β€” it's most of the job. + +--- + +## 4. Getting it running + +You'll need [Node 22](https://nodejs.org) and +[Docker Desktop](https://www.docker.com/products/docker-desktop/) installed (Docker just runs +the pantry β€” the Postgres database β€” so you don't have to install it by hand). Then, from the +project folder: + +```bash +npm install # once β€” downloads the project's dependencies +cp .env.example .env # then open .env and set two things: + # DATABASE_URL=...@localhost:5433/mac_hackathon + # DEV_AUTH=1 +docker compose up -d db # start just the database (in Docker) +npm run db:migrate # create the empty tables +npm run db:seed # stock the pantry with sample data (see the note below) +npm run dev # start the kitchen (:3000) and the dining room (:5173) +``` + +Now open **http://localhost:5173**. That's the Vite dev server, and its superpower is +**hot reload**: save a `.tsx` file and the page updates in the browser instantly, no refresh. +This is where you'll spend all your time. + +**A fresh database is empty**, so without that `npm run db:seed` step the pages look blank β€” +which is expected, not a bug you caused. The seed command stocks the pantry with realistic +sample data (one upcoming event, two past ones, plus prizes/judges/schedule/FAQ) so every +page has something to show and to restyle. It's safe to re-run any time. + +**Signing in on your laptop.** Real sign-in only works on the live `monashcoding.com` site, so +locally there's a stand-in: because you set `DEV_AUTH=1`, a little dev sign-in panel lets you +type any name/email (and tick "organiser" if you want to see the admin pages). This shortcut +only exists in dev β€” it physically can't be switched on in production, so don't worry about +it leaking. + +**Your two best friends when something looks broken:** +- **Browser DevTools** (press F12). The **Console** tab shows React errors in red; the + **Network** tab lets you watch each `/api/…` call and click it to see exactly what JSON came + back. When a page misbehaves, look here *first* β€” it usually tells you whether the problem + is your React or the data it received. +- **http://localhost:3000/api/health** should say `{"status":"ok"}`. If it does, the kitchen + is alive and the problem is on your side of the door. + +--- + +## 5. Styling: Tailwind + +We style with **Tailwind CSS**. Instead of writing a separate stylesheet, you put small +utility classes right on the element: + +```tsx +
…
+// ^rounded ^a border ^our bg colour ^padding +``` + +Our brand colours live in one place β€” `web/src/styles.css` β€” as named tokens you can use +anywhere: `bg-bg`, `bg-panel`, `text-text`, `text-muted`, `text-accent`, `border-border`, +`text-danger`, `text-ok`. So `text-accent` is our blue, `bg-panel` is the card background, and +so on. Using the tokens (instead of hard-coding a colour) keeps the whole site consistent and +makes a future theme change a one-file edit. + +That same file defines a few **shortcut classes** built from those utilities β€” `.wrap` (a +centered page column), `.panel` (a card), `.muted` (grey sub-text), `.topnav`, `.btn`, +`.card`. You'll spot them all over the existing pages. Redesigning them is fair game β€” making +it look good is literally your job β€” but they're a comfortable place to start. + +New to Tailwind? The [docs](https://tailwindcss.com/docs) have a search box β€” type what you +want ("padding", "flex", "rounded corners") and it shows you the class. You'll memorise the +common ones within a week. + +--- + +## 6. Your two exercises (do these before redesigning) + +These are small on purpose. They walk you through the whole Β§2 loop on real code, so that by +the end you're not *reading* about how the app works β€” you've done it. Both files are already +open-able with detailed comments inside; this section is the friendly version. + +### Exercise 1 β€” the Past Events page (`web/src/pages/Past.tsx`) + +**What you're building:** a read-only page that lists past hackathons. No writing data yet, +just fetching and drawing β€” the gentlest possible version of the loop. + +The waiter already knows this order: `api.pastEvents()` hands you `{ events: [...] }` (or +`null` if there aren't any). Your job is the React around it: fetch when the page loads, show +a "Loading…" line while you wait, then map over the events and draw each one (name; dates via +the `fmtDateRange` helper; venue; tagline; a Devpost link if there is one). + +**A gentle way in:** +1. Open `web/src/pages/Landing.tsx` and look at its shape β€” a `useState` to hold the data, a + `useEffect` that calls the waiter once when the page loads, and some JSX that draws the + result. That shape *is* the trick. You're copying it. +2. Peek at `api.pastEvents()` and the `PublicEvent` type in `web/src/api.ts` so you know what + fields you're getting (hover them in your editor β€” the types tell you). +3. Build it, handling three moments: still loading, loaded-but-empty, and loaded-with-events. + Real pages always think about all three. + +**You'll know it worked** when the seeded past events show up at `/past` in your browser. πŸŽ‰ + +### Exercise 2 β€” the Find-a-Team page (`web/src/pages/FindTeam.tsx`) + +**What you're building:** a page that both *reads and writes*. This is the real skill, and +it's the boss level of the loop β€” take your time. + +It shows a pool of people looking for a team, lets you tick "I'm looking for a team" (which +**saves** to the server), and β€” if you lead a team with a spare seat β€” lets you invite +someone from the pool. + +**A gentle way in:** +1. This time, read `web/src/pages/Dashboard.tsx` as your model. It does the full dance: + **load β†’ let the user do something β†’ send the change β†’ ask for the data again.** +2. The waiter already has every order you need (they're listed in the stub's comments): + `api.findTeam()` to load, `api.updateProfile(...)` to opt in/out, `api.inviteFromPool(...)` + to invite. +3. Two things that trip everyone up the first time β€” and how the reference page handles them: + - **Not signed in?** `api.findTeam()` throws a `NotSignedInError`. Catch it and show the + `` component instead of letting the page crash. + - **After you save a change, re-fetch.** Resist the urge to hand-edit the on-screen data to + match what you just sent. Just call your load function again and let fresh data redraw the + page. It's less code and it's never wrong. + +**You'll know it worked** when ticking the box and reloading keeps it ticked (proof it saved), +and the pool updates after you invite someone. + +> **Stuck, and want to see how it's done?** The original working versions of both pages are +> still in git on the `mac-hackathon-mvp` branch. Have a real go first β€” struggling for a bit +> is where the learning happens β€” but when you want to check your thinking: +> `git show mac-hackathon-mvp:web/src/pages/Past.tsx`. Reading a solution *after* you've +> attempted it is a genuine skill; that's the way to use it. +> +> **Curious what happens after the waiter disappears into the kitchen?** That exact question +> is Part 2 β€” **[`BACKEND_GUIDE.md`](./BACKEND_GUIDE.md)** β€” and it has a matching little +> exercise that builds the *other* end of an `api.…` call. + +--- + +## 7. The redesign + +Once those two work, you understand the frontend β€” really. Now go make it lovely. A sensible +order: + +1. **`Landing.tsx`** first β€” the public homepage. Most eyes land here, and it's the most fun + to design. +2. **`Dashboard.tsx`** next β€” but read the comment at the top of that file before you start. + It's the most important page in the whole app: a participant should never leave it unsure + whether they're actually in the hackathon. Restyle the *look* all you like; keep every + piece of *information* it currently shows. +3. Design **mobile-first** β€” a lot of people open this on their phone between classes. + +The golden rule: change how a page *looks*, not what it *says*. If you find yourself wanting +data that isn't there, that's a backend change β€” jot it down and talk to me rather than faking +it in the frontend. (And if you're curious how you'd add it yourself, that's Part 2. πŸ˜‰) + +--- + +## 8. Git cheat-sheet (if this is new) + +You're working on a branch called `frontend-redesign` β€” think of it as your own copy of the +project where you can experiment freely without breaking anyone else's work. The everyday +rhythm: + +```bash +git status # what have I changed? +git add -A # stage all my changes, ready to save +git commit -m "Rebuild Past page" # save a snapshot, with a short message +git push # upload your branch to GitHub +``` + +**Commit little and often** β€” every time something works, save it. Future-you will thank +present-you. Good messages say what you did ("Add loading state to Find a Team"), not "stuff" +or "wip". + +When a piece of work is ready for me to look at, open a **Pull Request** on GitHub from your +branch β€” that's the "hey, please review this" button. Don't commit straight to the main +branch. + +And if you ever end up in a tangle: **stop, don't force anything, and ask.** Almost nothing in +git is truly unrecoverable, but the fixes are far easier *before* trying random commands you +found online. Getting stuck is completely normal β€” reaching out early is the pro move, not the +beginner one. + +--- + +That's everything you need to start. When you're comfortable here and want to see the other +half of the machine, **[`BACKEND_GUIDE.md`](./BACKEND_GUIDE.md)** is waiting. + +Any questions at all, ask me β€” no question is too small. Have fun with it; real people are +going to use what you build. πŸŽ‰ diff --git a/README.md b/README.md index e775b31..b9eada9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ MAC's hackathon platform: a public info site (Notion-driven) plus team registrat Read [`SPEC_hackathon.md`](./SPEC_hackathon.md) β€” it is the source of truth. This README covers running the thing. +> **New here / redesigning the frontend?** There's a two-part, beginner-friendly walkthrough +> that reads as one story. Start with **[`FRONTEND_GUIDE.md`](./FRONTEND_GUIDE.md)** (Part 1) β€” +> how the frontend works, how it talks to the backend, and two hands-on exercises β€” then +> **[`BACKEND_GUIDE.md`](./BACKEND_GUIDE.md)** (Part 2) picks up where it leaves off and walks +> the server side (Express + Drizzle + Postgres) with a matching exercise. + ## Stack Node 22 Β· TypeScript Β· Express Β· React + Vite (built, served same-origin by Express) Β· diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..b0071c4 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,74 @@ +# Staging compose for Dokploy β€” a persistent PREVIEW of the frontend-redesign +# branch at https://staging.hackathons.monashcoding.com, kept completely separate +# from production. See docs/deploy-dokploy.md β†’ "Staging preview". +# +# Differences from docker-compose.dokploy.yml (production): +# β€’ Its own domain + Traefik router/service NAMES (must be unique on the host). +# β€’ Its own Postgres + db_data volume β€” Dokploy scopes these per compose stack, +# so staging data never mixes with production. +# β€’ ALLOW_SEED=1 β†’ the entrypoint auto-seeds demo content on boot, so the +# preview is never blank. Real production never sets this. +# β€’ No Humanitix / Notion keys β€” staging shows seeded content, touches nothing +# real. (Add them later only if you deliberately want live sync in staging.) +# +# Still NODE_ENV=production: that's what makes the app serve the built SPA. It is +# "production mode running throwaway data", not a dev server. +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: mac_hackathon + POSTGRES_PASSWORD: mac_hackathon + POSTGRES_DB: mac_hackathon + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mac_hackathon -d mac_hackathon"] + interval: 5s + timeout: 5s + retries: 20 + + app: + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgres://mac_hackathon:mac_hackathon@db:5432/mac_hackathon + NODE_ENV: production + PORT: "3000" + PUBLIC_URL: https://staging.hackathons.monashcoding.com + + # Auto-seed demo content on every deploy (idempotent). Staging only. + ALLOW_SEED: "1" + + # mac-auth is shared with production. Public pages (landing, past) work + # without sign-in; auth-gated pages (dashboard/find-team/admin) only work + # if this origin is in mac-auth's TRUSTED_ORIGINS β€” see the runbook note. + MAC_AUTH_URL: https://auth.monashcoding.com + MAC_AUTH_JWKS_URL: https://auth.monashcoding.com/api/auth/jwks + JWT_AUDIENCE: mac-suite + ORGANISER_ROLES: ${ORGANISER_ROLES:-committee,exec,admin} + + # Deliberately no HUMANITIX/NOTION keys β€” staging never hits real services. + networks: + - default # reach the db + - dokploy-network # be reachable by Dokploy's Traefik + # Router/service names are SUFFIXED "-staging" so they never collide with the + # production stack's Traefik config on the same host. + labels: + - traefik.enable=true + - traefik.docker.network=dokploy-network + - traefik.http.routers.mac-hackathon-staging.rule=Host(`staging.hackathons.monashcoding.com`) + - traefik.http.routers.mac-hackathon-staging.entrypoints=websecure + - traefik.http.routers.mac-hackathon-staging.tls.certresolver=letsencrypt + - traefik.http.services.mac-hackathon-staging.loadbalancer.server.port=3000 + +networks: + dokploy-network: + external: true + +volumes: + db_data: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 7e3c6e5..ca88c4a 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -8,5 +8,14 @@ set -e echo "[entrypoint] running migrations…" npm run db:migrate +# Staging-only: auto-populate demo content on boot so the preview site isn't +# blank. Gated behind ALLOW_SEED=1 (set only on the staging service) β€” the seed +# script itself also refuses to run in production without it, so real production +# is never touched. Idempotent (upserts), so re-seeding every deploy is fine. +if [ "$ALLOW_SEED" = "1" ]; then + echo "[entrypoint] ALLOW_SEED=1 β†’ seeding demo content…" + npm run db:seed +fi + echo "[entrypoint] starting server…" exec npm start diff --git a/docs/deploy-dokploy.md b/docs/deploy-dokploy.md index bd4a05a..6f09f87 100644 --- a/docs/deploy-dokploy.md +++ b/docs/deploy-dokploy.md @@ -100,6 +100,40 @@ Then check: --- +## Staging preview (persistent link for frontend work) + +A **separate, throwaway** Dokploy service that runs the `frontend-redesign` branch at +`https://staging.hackathons.monashcoding.com`, so work-in-progress can be previewed on a +real URL **without ever touching production**. It uses its own compose file +([`docker-compose.staging.yml`](../docker-compose.staging.yml)), its own database, and +auto-seeds demo content on boot β€” so it's never blank and never hits Humanitix/Notion. + +1. **DNS (once).** Add an A record for `staging.hackathons.monashcoding.com` β†’ the same + Oracle VM IP. +2. **Create a second Compose service** in Dokploy (name it e.g. `hackathons-staging`): + - Provider = GitHub, repo `monashcoding/hackathons`, branch **`frontend-redesign`**. + - **Compose Path**: `docker-compose.staging.yml`. +3. **Domain**: add `staging.hackathons.monashcoding.com`, HTTPS on, Let's Encrypt, port 3000. +4. **Environment**: none required β€” the staging compose hard-sets everything, including + `ALLOW_SEED=1` (which makes the entrypoint seed demo content on every deploy) and + `NODE_ENV=production` (so the built SPA is served). Leave the env box empty. +5. **Auto-deploy on push**: enable Dokploy's native **Auto Deploy** on this staging service. + Then every push to `frontend-redesign` redeploys the preview automatically. (The CI-gated + `deploy.yml` only fires for the production branch, so it won't interfere.) +6. **Deploy.** Watch the logs for `ALLOW_SEED=1 β†’ seeding demo content…` then the usual + `listening on :3000`. Visit the staging URL. + +> **Public pages just work** on staging (landing, `/past`) β€” no sign-in needed, so that's the +> whole redesign surface covered. The **auth-gated pages** (`/dashboard`, `/find-team`, +> `/admin`) additionally need `staging.hackathons.monashcoding.com` added to **mac-auth's** +> `TRUSTED_ORIGINS`; until then they'll fail to sign in on staging (they still work locally +> via dev sign-in). Ask whoever administers mac-auth if she needs those pages live. + +**Tearing it down** when the redesign lands: delete the staging service in Dokploy and remove +the `staging.` DNS record. `docker-compose.staging.yml` can stay in the repo for next time. + +--- + ## Operating notes - **The database volume `db_data` is the only stateful thing.** Back it up before major diff --git a/package.json b/package.json index 6b813a8..3defa7e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "start": "tsx src/server/index.ts", "db:generate": "drizzle-kit generate", "db:migrate": "tsx --env-file-if-exists=.env src/server/db/migrate.ts", + "db:seed": "tsx --env-file-if-exists=.env src/server/db/seed.ts", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest" diff --git a/src/server/db/seed.ts b/src/server/db/seed.ts new file mode 100644 index 0000000..2d0caa4 --- /dev/null +++ b/src/server/db/seed.ts @@ -0,0 +1,145 @@ +// --------------------------------------------------------------------------- +// Dev seed β€” fills an empty local database with a realistic-looking event so the +// public site, past-events page, and dashboard aren't blank while you work on +// the frontend. +// +// npm run db:seed +// +// DEV ONLY. It writes plausible fake data, never touches Notion/Humanitix, and +// is safe to run repeatedly β€” it upserts by a stable key (event slug / a fake +// "notion page id"), so re-running just refreshes the same rows rather than +// piling up duplicates. It refuses to run when NODE_ENV=production UNLESS +// ALLOW_SEED=1 is explicitly set β€” that override is how the throwaway STAGING +// deploy (which runs NODE_ENV=production so it serves the built SPA) seeds +// itself. Real production never sets ALLOW_SEED, so its database is safe. +// +// This is NOT how real content gets in β€” that comes from Notion via the sync +// (see BACKEND_GUIDE.md Β§4). This is scaffolding so you have something to style. +// --------------------------------------------------------------------------- +import { db, closeDb } from "./index.ts"; +import { contentBlocks, events } from "./schema.ts"; +import { isProduction } from "../env.ts"; + +async function upsertEvent(row: typeof events.$inferInsert) { + const [saved] = await db + .insert(events) + .values(row) + .onConflictDoUpdate({ target: events.slug, set: row }) + .returning(); + return saved!; +} + +async function upsertBlock(row: typeof contentBlocks.$inferInsert) { + await db + .insert(contentBlocks) + .values(row) + .onConflictDoUpdate({ target: contentBlocks.notionPageId, set: row }); +} + +async function main() { + if (isProduction && process.env.ALLOW_SEED !== "1") { + throw new Error( + "Refusing to seed: NODE_ENV=production. Set ALLOW_SEED=1 to override (staging only).", + ); + } + + // --- The current event (published, not archived β†’ this is what the landing + // page and dashboard show). Dates are in the near future. --- + const current = await upsertEvent({ + slug: "2026", + name: "MACATHON 2026", + tagline: "48 hours. One idea. Build something that matters.", + startsAt: new Date("2026-09-19T09:00:00+10:00"), + endsAt: new Date("2026-09-21T17:00:00+10:00"), + venue: "Monash University, Clayton", + registrationOpensAt: new Date("2026-08-01T00:00:00+10:00"), + registrationClosesAt: new Date("2026-09-15T23:59:00+10:00"), + minTeamSize: 2, + maxTeamSize: 4, + devpostUrl: "https://macathon-2026.devpost.com", + isPublished: true, + isArchived: false, + }); + + // --- Past events (published AND archived β†’ these show on /past). --- + await upsertEvent({ + slug: "2025", + name: "MACATHON 2025", + tagline: "Where it all came together.", + startsAt: new Date("2025-09-20T09:00:00+10:00"), + endsAt: new Date("2025-09-22T17:00:00+10:00"), + venue: "Monash University, Clayton", + minTeamSize: 2, + maxTeamSize: 4, + devpostUrl: "https://macathon-2025.devpost.com", + isPublished: true, + isArchived: true, + }); + await upsertEvent({ + slug: "2024", + name: "MACATHON 2024", + tagline: "The one that started the streak.", + startsAt: new Date("2024-09-21T09:00:00+10:00"), + endsAt: new Date("2024-09-23T17:00:00+10:00"), + venue: "Monash University, Caulfield", + minTeamSize: 2, + maxTeamSize: 4, + isPublished: true, + isArchived: true, + }); + + // --- Content for the current event. In production these rows come from Notion + // via the sync; here we fake a handful so every section on the landing + // page has something to render. `payload` matches the ContentItem shape + // the frontend expects (see web/src/api.ts). --- + const blocks: Array<{ + kind: (typeof contentBlocks.$inferInsert)["kind"]; + id: string; + sort: number; + payload: Record; + }> = [ + { kind: "prize", id: "seed-prize-1", sort: 0, payload: { title: "Best Overall", subtitle: "$2,000 + interviews", bodyHtml: "

The team that best nails idea, execution, and demo.

" } }, + { kind: "prize", id: "seed-prize-2", sort: 1, payload: { title: "Best Use of AI", subtitle: "$1,000", bodyHtml: "

Most thoughtful application of AI to a real problem.

" } }, + { kind: "prize", id: "seed-prize-3", sort: 2, payload: { title: "People's Choice", subtitle: "$500", bodyHtml: "

Voted by fellow hackers at the expo.

" } }, + + { kind: "judge", id: "seed-judge-1", sort: 0, payload: { title: "Dr Alex Chen", subtitle: "Senior Engineer, Atlassian", bodyHtml: "

Distributed systems and developer tools.

" } }, + { kind: "judge", id: "seed-judge-2", sort: 1, payload: { title: "Priya Nair", subtitle: "Founder, Northlight AI", bodyHtml: "

Building applied ML products.

" } }, + + { kind: "schedule_item", id: "seed-sched-1", sort: 0, payload: { title: "Doors open & check-in", time: "2026-09-19T09:00:00+10:00" } }, + { kind: "schedule_item", id: "seed-sched-2", sort: 1, payload: { title: "Opening ceremony & team forming", time: "2026-09-19T10:00:00+10:00" } }, + { kind: "schedule_item", id: "seed-sched-3", sort: 2, payload: { title: "Hacking begins", time: "2026-09-19T12:00:00+10:00" } }, + { kind: "schedule_item", id: "seed-sched-4", sort: 3, payload: { title: "Submissions due & expo", time: "2026-09-21T12:00:00+10:00" } }, + + { kind: "sponsor", id: "seed-sponsor-1", sort: 0, payload: { title: "Atlassian", subtitle: "Platinum sponsor", url: "https://atlassian.com" } }, + { kind: "sponsor", id: "seed-sponsor-2", sort: 1, payload: { title: "AWS", subtitle: "Cloud credits", url: "https://aws.amazon.com" } }, + + { kind: "faq", id: "seed-faq-1", sort: 0, payload: { question: "Do I need a team to sign up?", answerHtml: "

No β€” come solo and use the Find a Team page. Teams are 2–4 people.

" } }, + { kind: "faq", id: "seed-faq-2", sort: 1, payload: { question: "How much does it cost?", answerHtml: "

A small ticket via Humanitix, which covers food for the whole weekend.

" } }, + { kind: "faq", id: "seed-faq-3", sort: 2, payload: { question: "Who can attend?", answerHtml: "

Anyone, from any university. All skill levels welcome.

" } }, + ]; + + for (const b of blocks) { + await upsertBlock({ + eventId: current.id, + kind: b.kind, + notionPageId: b.id, + payload: b.payload, + sortOrder: b.sort, + isPublished: true, + isPresent: true, + }); + } + + console.log( + `[seed] done β€” current event "${current.name}", 2 past events, ${blocks.length} content blocks.`, + ); + console.log("[seed] open http://localhost:5173 (landing) and /past to see it."); +} + +main() + .then(() => closeDb()) + .catch(async (err) => { + console.error("[seed] failed:", err); + await closeDb(); + process.exit(1); + }); diff --git a/src/server/index.ts b/src/server/index.ts index 4a9d0d6..a0e78e0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,6 +6,7 @@ import { healthRouter } from "./routes/health.ts"; import { meRouter } from "./routes/me.ts"; import { eventsRouter } from "./routes/events.ts"; import { publicRouter } from "./routes/public.ts"; +import { statsRouter } from "./routes/stats.ts"; // BACKEND_GUIDE.md exercise import { contentRouter } from "./routes/content.ts"; import { ticketsRouter } from "./routes/tickets.ts"; import { dashboardRouter } from "./routes/dashboard.ts"; @@ -23,6 +24,7 @@ app.use(express.json()); app.use("/api", healthRouter); app.use("/api", meRouter); app.use("/api", publicRouter); +app.use("/api", statsRouter); // BACKEND_GUIDE.md exercise β€” GET /api/public/stats app.use("/api", contentRouter); app.use("/api", dashboardRouter); app.use("/api/organiser", organiserRouter); diff --git a/src/server/routes/stats.ts b/src/server/routes/stats.ts new file mode 100644 index 0000000..3c43e58 --- /dev/null +++ b/src/server/routes/stats.ts @@ -0,0 +1,31 @@ +import { Router } from "express"; +// You'll need these once you start writing the real query β€” uncomment as you go: +// import { and, eq } from "drizzle-orm"; +// import { db } from "../db/index.ts"; +// import { events } from "../db/schema.ts"; + +export const statsRouter = Router(); + +// πŸ‘‰ BACKEND EXERCISE β€” see BACKEND_GUIDE.md ("Exercise: your first endpoint"). +// +// Build a public, read-only endpoint that reports a couple of simple counts the +// homepage could show off (e.g. "12 past events"). This teaches the whole +// backend loop end-to-end: a route β†’ a Drizzle query on Postgres β†’ JSON out. +// +// Make GET /api/public/stats return something like: +// { "pastEventCount": 12 } +// +// Steps (all the pieces already exist elsewhere in this file tree): +// 1. Query the `events` table for rows that are published AND archived β€” that's +// what "a past event" means. Copy the WHERE clause from the /public/past +// handler in src/server/routes/public.ts (it does exactly this filter). +// 2. Count them and return the number as JSON. +// 3. Keep it PUBLIC β€” no requireAuth here. Anyone can hit the homepage. +// +// Stretch goal: also return `publishedEventCount` (published, not archived). +// +// Replace the placeholder below with your real implementation. +statsRouter.get("/public/stats", async (_req, res) => { + // TODO: run the real query and return real numbers. + res.json({ pastEventCount: 0 }); +}); diff --git a/web/src/pages/FindTeam.tsx b/web/src/pages/FindTeam.tsx index 36ae068..14dd747 100644 --- a/web/src/pages/FindTeam.tsx +++ b/web/src/pages/FindTeam.tsx @@ -1,57 +1,31 @@ -import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import { api, NotSignedInError, type FindTeamResponse } from "../api.ts"; -import { SignInPanel } from "../components/SignInPanel.tsx"; -// The looking-for-a-team pool (spec Β§9). A browsable list of verified, teamless -// participants who opted in. NO chat β€” the conversation continues on Discord. -// Leads with an open slot get an "invite to my team" button per person. +// πŸ‘‰ EXERCISE 2 β€” see FRONTEND_GUIDE.md ("Exercise 2: the Find-a-Team page"). +// +// A step up from Exercise 1: this page both READS and WRITES data, so you'll +// practise the full loop β€” load data, let the user change something, send that +// change to the server, then re-load so the screen matches reality. +// +// Rebuild it so it: +// 1. Loads the pool of people looking for a team: api.findTeam() +// 2. Handles being signed out: if the fetch throws a NotSignedInError, +// show the component instead of the pool. +// 3. Has a checkbox to opt in / out of the pool: +// api.updateProfile({ lookingForTeam: true|false }) +// 4. If the signed-in user leads a team with a free slot, shows an +// "Invite to my team" button next to each person: +// api.inviteFromPool(myTeamId, participantId) +// 5. After ANY write, re-fetches so the UI reflects the new state. +// +// Things you'll use (all already built): +// β€’ api.findTeam / updateProfile / inviteFromPool, the FindTeamResponse type, +// and NotSignedInError β†’ web/src/api.ts +// β€’ β†’ web/src/components/SignInPanel.tsx +// β€’ The "load β†’ mutate β†’ refresh" pattern, done in full: +// web/src/pages/Dashboard.tsx +// +// Delete this placeholder and the TODO note once your version works. export function FindTeam() { - const [data, setData] = useState(null); - const [signedOut, setSignedOut] = useState(false); - const [error, setError] = useState(""); - const [busy, setBusy] = useState(false); - - async function refresh() { - setError(""); - try { - setData(await api.findTeam()); - setSignedOut(false); - } catch (e) { - if (e instanceof NotSignedInError) { - setSignedOut(true); - } else { - setData(null); - setError((e as Error).message); - } - } - } - useEffect(() => { - void refresh(); - }, []); - - async function toggleLooking(v: boolean) { - setBusy(true); - try { - await api.updateProfile({ lookingForTeam: v }); - await refresh(); - } finally { - setBusy(false); - } - } - async function invite(participantId: string) { - if (!data?.myTeamId) return; - setBusy(true); - try { - await api.inviteFromPool(data.myTeamId, participantId); - await refresh(); - } catch (e) { - alert((e as Error).message); - } finally { - setBusy(false); - } - } - return (

Find a team

-

- Teams need at least 2 people. Opt in below to appear in the pool, browse others looking - for a team, and keep the conversation going in the MAC Discord. -

- - {signedOut && } - {error &&

{error}

} - - {data && ( - <> -
- -

- You only appear once your ticket is verified. -

-
- -
-

Looking for a team ({data.pool.length})

- {data.pool.length === 0 &&

Nobody's in the pool right now.

} - {data.pool.map((p) => ( -
-
- {p.displayName ?? "(no name)"} -
- {[p.university, p.studyLevel, p.githubHandle ? `github: ${p.githubHandle}` : null] - .filter(Boolean) - .join(" Β· ") || "β€”"} -
-
- {data.myTeamId && data.hasOpenSlot && ( - - )} -
- ))} - {data.myTeamId && !data.hasOpenSlot && ( -

Your team is full β€” no open slots to invite into.

- )} -
- - )} +

🚧 TODO: build this page β€” see FRONTEND_GUIDE.md (Exercise 2).

); diff --git a/web/src/pages/Past.tsx b/web/src/pages/Past.tsx index 3d4b397..d7ec95d 100644 --- a/web/src/pages/Past.tsx +++ b/web/src/pages/Past.tsx @@ -1,16 +1,24 @@ -import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import { api, type PublicEvent } from "../api.ts"; -import { fmtDateRange } from "../format.ts"; -// Archive of previous events. Grows for free every year. +// πŸ‘‰ EXERCISE 1 β€” see FRONTEND_GUIDE.md ("Exercise 1: the Past Events page"). +// +// This is your warm-up: a read-only page. Rebuild it so it: +// 1. Fetches the list of past events when the page loads. +// 2. Shows a "Loading…" message while the request is in flight. +// 3. Renders each event: name, dates, venue, tagline, and (if present) a +// link to its Devpost. +// 4. Says something friendly if there are no past events yet. +// +// Everything you need already exists β€” you are only writing the React part: +// β€’ Data: api.pastEvents() β†’ { events: PublicEvent[] } | null +// (defined in web/src/api.ts β€” go read it) +// β€’ Dates: fmtDateRange(startsAt, endsAt) +// (defined in web/src/format.ts) +// β€’ A page that already does exactly this shape of fetch-then-render: +// web/src/pages/Landing.tsx ← copy this pattern +// +// Delete this placeholder and the TODO note once your version works. export function Past() { - const [events, setEvents] = useState(null); - - useEffect(() => { - api.pastEvents().then((d) => setEvents(d?.events ?? [])).catch(() => setEvents([])); - }, []); - return (

Past events

- {events === null &&

Loading…

} - {events && events.length === 0 &&

No past events yet.

} - {events?.map((e) => ( -
- {e.name} /{e.slug} -
{fmtDateRange(e.startsAt, e.endsAt)}{e.venue ? ` Β· ${e.venue}` : ""}
- {e.tagline &&
{e.tagline}
} - {e.devpostUrl && ( - - Projects on Devpost - - )} -
- ))} +

🚧 TODO: build this page β€” see FRONTEND_GUIDE.md (Exercise 1).

);