● Zion Boggan zionboggan.com ↗

Wire local Postgres POC: city pages, event posting, dev-auth bypass

bdfc49e   Zion Boggan committed on Jun 24, 2026 (1 month ago)
apps/web/src/app/city/[slug]/page.tsx +135 -0
@@ -0,0 +1,135 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { revalidatePath } from "next/cache";
+import { EVENT_TYPE } from "@stepping/types";
+import { serverCaller } from "@/lib/trpc/server";
+
+export const dynamic = "force-dynamic";
+
+const TYPE_LABEL: Record<string, string> = {
+ step_show: "Step show",
+ stroll: "Stroll",
+ competition: "Competition",
+ class: "Class",
+ social: "Social",
+ special: "Special",
+};
+
+function fmtDate(d: Date | null) {
+ if (!d) return "Date TBD";
+ return new Date(d).toLocaleString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+export default async function CityPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ const caller = serverCaller();
+ const city = await caller.city.bySlug({ slug });
+ if (!city) notFound();
+
+ const events = await caller.event.list({ cityId: city.id });
+
+ async function addEvent(formData: FormData) {
+ "use server";
+ const title = String(formData.get("title") ?? "").trim();
+ const type = String(formData.get("type") ?? "step_show");
+ const startsAtRaw = String(formData.get("startsAt") ?? "");
+ if (!title) return;
+ await serverCaller().event.create({
+ cityId: city!.id,
+ title,
+ type: type as (typeof EVENT_TYPE)[number],
+ startsAt: startsAtRaw ? new Date(startsAtRaw) : undefined,
+ });
+ revalidatePath(`/city/${slug}`);
+ }
+
+ return (
+ <main className="mx-auto w-full max-w-2xl flex-1 px-6 py-12">
+ <Link
+ href="/"
+ className="text-sm text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200"
+ >
+ &larr; All cities
+ </Link>
+
+ <h1 className="mt-3 text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
+ {city.name}
+ </h1>
+ <p className="text-zinc-500 dark:text-zinc-400">{city.metroLabel}</p>
+
+ <section className="mt-8">
+ <h2 className="text-sm font-medium uppercase tracking-widest text-amber-600">
+ Add an event
+ </h2>
+ <form action={addEvent} className="mt-3 flex flex-col gap-3">
+ <input
+ name="title"
+ required
+ placeholder="Event name (e.g. Homecoming Step Show)"
+ className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-black dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
+ />
+ <div className="flex gap-3">
+ <select
+ name="type"
+ className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-black dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
+ >
+ {EVENT_TYPE.map((t) => (
+ <option key={t} value={t}>
+ {TYPE_LABEL[t] ?? t}
+ </option>
+ ))}
+ </select>
+ <input
+ type="datetime-local"
+ name="startsAt"
+ className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-black dark:border-zinc-700 dark:bg-zinc-950 dark:text-white"
+ />
+ </div>
+ <button
+ type="submit"
+ className="self-start rounded-full bg-amber-600 px-5 py-2 font-medium text-white hover:bg-amber-700"
+ >
+ Post event
+ </button>
+ </form>
+ </section>
+
+ <section className="mt-10">
+ <h2 className="text-sm font-medium uppercase tracking-widest text-zinc-400">
+ Events
+ </h2>
+ {events.length === 0 ? (
+ <p className="mt-3 text-zinc-500">
+ No events yet. Add the first one above.
+ </p>
+ ) : (
+ <ul className="mt-3 divide-y divide-zinc-200 dark:divide-zinc-800">
+ {events.map((e) => (
+ <li key={e.id} className="flex items-center justify-between py-4">
+ <div>
+ <p className="font-medium text-black dark:text-zinc-50">
+ {e.title}
+ </p>
+ <p className="text-sm text-zinc-500">{fmtDate(e.startsAt)}</p>
+ </div>
+ <span className="rounded-full bg-zinc-100 px-3 py-1 text-xs font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
+ {TYPE_LABEL[e.type] ?? e.type}
+ </span>
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+ </main>
+ );
+}
apps/web/src/app/page.tsx +12 -6
@@ -1,4 +1,9 @@
-const LAUNCH_CITIES = ["Atlanta", "Washington, DC"];
+import Link from "next/link";
+
+const LAUNCH_CITIES = [
+ { slug: "atlanta", name: "Atlanta" },
+ { slug: "washington-dc", name: "Washington, DC" },
+];
const FEATURES = [
"Step shows, strolls, and competitions near you",
@@ -41,12 +46,13 @@ export default function Home() {
</p>
<div className="mt-2 flex flex-wrap gap-2">
{LAUNCH_CITIES.map((c) => (
- <span
- key={c}
- className="rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-900 dark:bg-amber-950 dark:text-amber-200"
+ <Link
+ key={c.slug}
+ href={`/city/${c.slug}`}
+ className="rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-900 hover:bg-amber-200 dark:bg-amber-950 dark:text-amber-200 dark:hover:bg-amber-900"
>
- {c}
- </span>
+ {c.name}
+ </Link>
))}
</div>
</div>
apps/web/src/lib/trpc/server.ts +9 -0
@@ -0,0 +1,9 @@
+import { appRouter } from "@stepping/api";
+import { getDb } from "@stepping/db";
+import { DEV_USER_ID } from "@stepping/config";
+
+// Server-side caller for Server Components and Server Actions in the POC.
+// Uses the seeded dev user so posting works without a login screen.
+export function serverCaller() {
+ return appRouter.createCaller({ db: getDb(), user: { id: DEV_USER_ID } });
+}
packages/api/package.json +1 -0
@@ -12,6 +12,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@stepping/config": "workspace:*",
"@stepping/db": "workspace:*",
"@stepping/types": "workspace:*",
"@supabase/supabase-js": "^2.47.10",
packages/api/src/context.ts +6 -0
@@ -1,4 +1,5 @@
import { getDb, type Database } from "@stepping/db";
+import { DEV_USER_ID } from "@stepping/config";
import { createClient } from "@supabase/supabase-js";
export interface SessionUser {
@@ -30,5 +31,10 @@ export async function createContext({
if (data.user) user = { id: data.user.id };
}
+ // Local POC bypass (explicit opt-in via env). Never trips in production.
+ if (!user && process.env.STEPPING_DEV_AUTH === "1") {
+ user = { id: DEV_USER_ID };
+ }
+
return { db, user };
}
packages/config/src/index.ts +3 -0
@@ -2,6 +2,9 @@
export const APP_NAME = "Stepping";
+// Fixed dev user for the local POC (no login wall). Seeded into `users`.
+export const DEV_USER_ID = "00000000-0000-0000-0000-000000000001";
+
// Cities live in the DB, but the MVP UI is locked to the launched tier-1 set.
// These slugs gate the city picker until more cities are seeded + flagged.
export const LAUNCH_CITY_SLUGS = ["atlanta", "washington-dc"] as const;
packages/db/src/seed.ts +13 -2
@@ -1,13 +1,24 @@
import "dotenv/config";
-import { DIVINE_NINE } from "@stepping/config";
+import { DEV_USER_ID, DIVINE_NINE } from "@stepping/config";
import { getDb } from "./index";
-import { cities, organizations } from "./schema";
+import { cities, organizations, users } from "./schema";
// Seeds reference data (checklist step 5): the Divine Nine + launched cities.
// Idempotent-ish: run against a fresh DB after migrations.
async function main() {
const db = getDb();
+ console.log("Seeding dev user...");
+ await db
+ .insert(users)
+ .values({
+ id: DEV_USER_ID,
+ handle: "dev",
+ displayName: "Dev User",
+ role: "admin",
+ })
+ .onConflictDoNothing();
+
console.log("Seeding Divine Nine organizations...");
await db
.insert(organizations)
pnpm-lock.yaml +3 -0
@@ -96,6 +96,9 @@ importers:
packages/api:
dependencies:
+ '@stepping/config':
+ specifier: workspace:*
+ version: link:../config
'@stepping/db':
specifier: workspace:*
version: link:../db