● Zion Boggan zionboggan.com ↗

Add profiles, travel guide, About, mobile nav, PWA, loading states, and content seed

f134c56   Zion Boggan committed on Jun 25, 2026 (1 month ago)
apps/web/public/icon.svg +6 -0
@@ -0,0 +1,6 @@
+<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Stepping">
+ <rect width="512" height="512" rx="112" fill="#0F0E0C"/>
+ <circle cx="256" cy="256" r="176" stroke="#C8A24C" stroke-opacity="0.18" stroke-width="6"/>
+ <circle cx="256" cy="256" r="140" stroke="#C8A24C" stroke-opacity="0.34" stroke-width="6"/>
+ <text x="256" y="256" fill="#C8A24C" font-family="Bricolage Grotesque, Arial, sans-serif" font-size="300" font-weight="800" letter-spacing="-12" text-anchor="middle" dominant-baseline="central">S</text>
+</svg>
apps/web/public/manifest.webmanifest +17 -0
@@ -0,0 +1,17 @@
+{
+ "name": "Stepping",
+ "short_name": "Stepping",
+ "description": "The home for Divine Nine step show culture. Shows, strolls, the people, and the road to every set, city by city.",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#0F0E0C",
+ "theme_color": "#0F0E0C",
+ "icons": [
+ {
+ "src": "/icon.svg",
+ "type": "image/svg+xml",
+ "sizes": "any",
+ "purpose": "any"
+ }
+ ]
+}
apps/web/src/app/about/page.tsx +106 -0
@@ -0,0 +1,106 @@
+import Link from "next/link";
+
+export const metadata = {
+ title: "What Is This - Stepping",
+ description:
+ "A plain-language guide to Stepping: find a city, see the shows, meet the people, and plan the road to every set.",
+};
+
+const STEPS = [
+ {
+ index: "01",
+ title: "Find your city",
+ body: "On the home page you will see a list of cities. Tap the one nearest to you. That is your starting point.",
+ },
+ {
+ index: "02",
+ title: "See the shows",
+ body: "Inside a city you will find The Line-Up: every step show, stroll, and competition coming up. Tap any one to see the date, the time, and the details.",
+ },
+ {
+ index: "03",
+ title: "Enter The Yard",
+ body: "The Yard is the community for that city: the chapters, the step teams, the DJs, the people who throw the shows, and the classes where you can learn. It is who's who and what's what.",
+ },
+ {
+ index: "04",
+ title: "Plan The Route",
+ body: "Traveling to a show? The Route helps you get there. Pick a city, see what's on, and make a plan for the road.",
+ },
+];
+
+export default function AboutPage() {
+ return (
+ <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
+ <Link href="/" className="kicker hover:text-gold">
+ ← Stepping
+ </Link>
+
+ <p className="kicker mt-12 text-ash">What is this</p>
+ <h1 className="display mt-3 text-6xl leading-[0.9] sm:text-8xl">
+ A guide to
+ <br />
+ <span className="text-gold">Stepping</span>
+ </h1>
+
+ <p className="serif mt-10 text-2xl leading-snug text-bone/85 sm:text-3xl">
+ Stepping is an art form. It is body-as-instrument: stomps, claps,
+ spoken word, and lines moving as one. The Divine Nine Black fraternities
+ and sororities have carried it for over a hundred years.
+ </p>
+
+ <p className="serif mt-6 text-2xl leading-snug text-bone/85 sm:text-3xl">
+ This app is one simple thing: a home for that culture. It helps you find
+ the shows near you, meet the people behind them, and get to every set.
+ No accounts to figure out, no clutter. Just the show.
+ </p>
+
+ <section className="mt-16 border-t border-[var(--line)] pt-12">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-4xl sm:text-5xl">How to use it</h2>
+ <span className="kicker text-ash">Four steps</span>
+ </div>
+
+ <div className="mt-10 flex flex-col">
+ {STEPS.map((s) => (
+ <div
+ key={s.index}
+ className="flex flex-col gap-3 border-b border-[var(--line)] py-8 sm:flex-row sm:gap-8"
+ >
+ <span className="kicker pt-2 text-gold sm:w-12">{s.index}</span>
+ <div className="flex-1">
+ <h3 className="display text-3xl sm:text-4xl">{s.title}</h3>
+ <p className="serif mt-3 text-xl leading-relaxed text-bone/75 sm:text-2xl">
+ {s.body}
+ </p>
+ </div>
+ </div>
+ ))}
+ </div>
+ </section>
+
+ <section className="mt-16 border-t border-[var(--line)] pt-12">
+ <h2 className="display text-4xl sm:text-5xl">
+ That&apos;s all there is
+ </h2>
+ <p className="serif mt-6 text-xl leading-relaxed text-bone/75 sm:text-2xl">
+ Take your time. Tap around. If you ever feel lost, the word
+ <span className="text-gold"> STEPPING</span> in the top corner always
+ brings you back to the start.
+ </p>
+
+ <Link
+ href="/"
+ className="kicker mt-12 inline-block border border-gold px-8 py-5 text-gold hover:bg-gold hover:text-stage"
+ >
+ Start here →
+ </Link>
+ </section>
+
+ <footer className="mt-20 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--line)] pt-8">
+ <span className="display text-xl">STEPPING</span>
+ <span className="kicker text-ash">On Beat · Built for the culture</span>
+ </footer>
+ </main>
+ );
+}
apps/web/src/app/city/[slug]/event/[id]/loading.tsx +5 -0
@@ -0,0 +1,5 @@
+import { EventSkeleton } from "@/components/skeletons";
+
+export default function Loading() {
+ return <EventSkeleton />;
+}
apps/web/src/app/city/[slug]/event/[id]/page.tsx +193 -0
@@ -0,0 +1,193 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { revalidatePath } from "next/cache";
+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 fmt(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",
+ })
+ .toUpperCase();
+}
+
+function stamp(d: Date | null) {
+ if (!d) return "";
+ return new Date(d)
+ .toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ })
+ .toUpperCase();
+}
+
+export default async function EventPage({
+ params,
+}: {
+ params: Promise<{ slug: string; id: string }>;
+}) {
+ const { slug, id } = await params;
+ const caller = serverCaller();
+ const city = await caller.city.bySlug({ slug });
+ if (!city) notFound();
+
+ const event = await caller.event.byId({ id });
+ if (!event) notFound();
+
+ const comments = await caller.comment.list({
+ subjectType: "event",
+ subjectId: id,
+ });
+
+ async function addComment(formData: FormData) {
+ "use server";
+ const body = String(formData.get("body") ?? "").trim();
+ if (!body) return;
+ await serverCaller().comment.create({
+ subjectType: "event",
+ subjectId: id,
+ body,
+ });
+ revalidatePath(`/city/${slug}/event/${id}`);
+ }
+
+ async function reportEvent(formData: FormData) {
+ "use server";
+ const reason = String(formData.get("reason") ?? "").trim();
+ await serverCaller().flag.create({
+ subjectType: "event",
+ subjectId: id,
+ reason: reason || undefined,
+ });
+ revalidatePath(`/city/${slug}/event/${id}`);
+ }
+
+ return (
+ <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
+ <Link href={`/city/${slug}`} className="kicker hover:text-gold">
+ ← {city.name}
+ </Link>
+
+ <p className="kicker mt-10 text-ash">
+ {(TYPE_LABEL[event.type] ?? event.type).toUpperCase()} · {fmt(event.startsAt)} ·{" "}
+ {city.name}
+ </p>
+ <h1 className="display mt-3 text-6xl sm:text-7xl">{event.title}</h1>
+
+ {event.description ? (
+ <p className="serif mt-6 max-w-xl text-xl text-bone">
+ {event.description}
+ </p>
+ ) : null}
+
+ {event.externalUrl || event.ticketUrl ? (
+ <div className="mt-7 flex flex-wrap gap-4">
+ {event.ticketUrl ? (
+ <a
+ href={event.ticketUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
+ >
+ Tickets →
+ </a>
+ ) : null}
+ {event.externalUrl ? (
+ <a
+ href={event.externalUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="kicker border border-[var(--line)] px-6 py-3 text-gold hover:border-gold hover:text-gold-bright"
+ >
+ Details →
+ </a>
+ ) : null}
+ </div>
+ ) : null}
+
+ {/* Comments */}
+ <section className="mt-16 border-t border-[var(--line)] pt-10">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-3xl">The Crowd</h2>
+ <span className="kicker text-ash">
+ {String(comments.length).padStart(2, "0")} comments
+ </span>
+ </div>
+
+ <form action={addComment} className="mt-6 flex flex-col gap-3">
+ <input
+ name="body"
+ required
+ placeholder="Say something about the show"
+ className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold"
+ />
+ <div className="mt-2">
+ <button
+ type="submit"
+ className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
+ >
+ Post comment
+ </button>
+ </div>
+ </form>
+
+ {comments.length === 0 ? (
+ <p className="serif mt-8 text-xl text-ash">
+ No comments yet. Be the first to speak on it.
+ </p>
+ ) : (
+ <ul className="mt-8">
+ {comments.map((c) => (
+ <li
+ key={c.id}
+ className="border-b border-[var(--line)] py-6"
+ >
+ <p className="serif text-xl text-bone">{c.body}</p>
+ <p className="kicker mt-2 text-ash">{stamp(c.createdAt)}</p>
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+
+ {/* Report */}
+ <section className="mt-16 border-t border-[var(--line)] pt-10">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-3xl">Report</h2>
+ <span className="kicker text-ash">Flag for review</span>
+ </div>
+ <form action={reportEvent} className="mt-6 flex flex-wrap items-center gap-4">
+ <input
+ name="reason"
+ placeholder="What's wrong with this listing? (optional)"
+ className="kicker flex-1 border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none placeholder:text-ash focus:border-gold"
+ />
+ <button
+ type="submit"
+ className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
+ >
+ Report
+ </button>
+ </form>
+ </section>
+ </main>
+ );
+}
apps/web/src/app/city/[slug]/loading.tsx +5 -0
@@ -0,0 +1,5 @@
+import { CitySkeleton } from "@/components/skeletons";
+
+export default function Loading() {
+ return <CitySkeleton />;
+}
apps/web/src/app/city/[slug]/page.tsx +26 -7
@@ -66,12 +66,26 @@ export default async function CityPage({
</p>
<h1 className="display mt-3 text-7xl sm:text-8xl">{city.name}</h1>
- <Link
- href={`/city/${slug}/yard`}
- className="kicker mt-6 inline-block text-gold hover:text-gold-bright"
- >
- Enter The Yard →
- </Link>
+ <div className="mt-6 flex flex-wrap gap-6">
+ <Link
+ href={`/city/${slug}/yard`}
+ className="kicker inline-block text-gold hover:text-gold-bright"
+ >
+ Enter The Yard →
+ </Link>
+ <Link
+ href={`/city/${slug}/videos`}
+ className="kicker inline-block text-gold hover:text-gold-bright"
+ >
+ Watch videos →
+ </Link>
+ <Link
+ href={`/city/${slug}/route`}
+ className="kicker inline-block text-gold hover:text-gold-bright"
+ >
+ The Route →
+ </Link>
+ </div>
{/* Add an event */}
<section className="mt-14 border-t border-[var(--line)] pt-10">
@@ -135,7 +149,12 @@ export default async function CityPage({
{String(i + 1).padStart(2, "0")}
</span>
<div className="flex-1">
- <p className="display text-2xl sm:text-3xl">{e.title}</p>
+ <Link
+ href={`/city/${slug}/event/${e.id}`}
+ className="display text-2xl text-bone hover:text-gold sm:text-3xl"
+ >
+ {e.title}
+ </Link>
<p className="kicker mt-1 text-ash">{fmt(e.startsAt)}</p>
</div>
<span className="kicker border border-[var(--line)] px-3 py-1">
apps/web/src/app/city/[slug]/profile/[kind]/[id]/page.tsx +135 -0
@@ -0,0 +1,135 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { serverCaller } from "@/lib/trpc/server";
+
+export const dynamic = "force-dynamic";
+
+const KIND_LABEL: Record<string, string> = {
+ dj: "DJ",
+ promoter: "Promoter",
+ "step-team": "Step Team",
+ chapter: "Chapter",
+ class: "Class",
+};
+
+function stamp(d: Date | null | undefined) {
+ if (!d) return "";
+ return new Date(d)
+ .toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ .toUpperCase();
+}
+
+export default async function ProfilePage({
+ params,
+}: {
+ params: Promise<{ slug: string; kind: string; id: string }>;
+}) {
+ const { slug, kind, id } = await params;
+ const caller = serverCaller();
+ const city = await caller.city.bySlug({ slug });
+ if (!city) notFound();
+
+ const label = KIND_LABEL[kind];
+ if (!label) notFound();
+
+ let title = "";
+ let typeLine = label.toUpperCase();
+ let body: string | null = null;
+ let emptyBody = "Nothing on record yet.";
+ let createdAt: Date | null = null;
+ const meta: string[] = [];
+
+ if (kind === "dj") {
+ const dj = await caller.dj.byId({ id });
+ if (!dj) notFound();
+ title = dj.name;
+ body = dj.bio ?? null;
+ emptyBody = "No bio yet. This DJ keeps the set list close.";
+ createdAt = dj.createdAt;
+ } else if (kind === "promoter") {
+ const promoter = await caller.promoter.byId({ id });
+ if (!promoter) notFound();
+ title = promoter.name;
+ body = promoter.bio ?? null;
+ emptyBody = "No bio yet. This promoter lets the shows speak.";
+ createdAt = promoter.createdAt;
+ } else if (kind === "step-team") {
+ const team = await caller.stepTeam.byId({ id });
+ if (!team) notFound();
+ title = team.name;
+ body = team.bio ?? null;
+ emptyBody = "No bio yet. This line lets the stomp do the talking.";
+ createdAt = team.createdAt;
+ if (team.affiliationOrgId) {
+ const organizations = await caller.organization.list();
+ const org = organizations.find((o) => o.id === team.affiliationOrgId);
+ if (org) {
+ typeLine = `${label.toUpperCase()} · ${
+ org.letters ? `${org.letters} ` : ""
+ }${org.name}`.toUpperCase();
+ }
+ }
+ } else if (kind === "chapter") {
+ const chapter = await caller.chapter.byId({ id });
+ if (!chapter) notFound();
+ title = chapter.name;
+ body = chapter.bio ?? chapter.school ?? null;
+ emptyBody = "No detail yet. This chapter is still writing its history.";
+ createdAt = chapter.createdAt;
+ const organizations = await caller.organization.list();
+ const org = organizations.find((o) => o.id === chapter.organizationId);
+ if (org) {
+ typeLine = `${label.toUpperCase()} · ${
+ org.letters ? `${org.letters} ` : ""
+ }${org.name}`.toUpperCase();
+ }
+ if (chapter.school) meta.push(chapter.school.toUpperCase());
+ } else {
+ const klass = await caller.class.byId({ id });
+ if (!klass) notFound();
+ title = klass.title;
+ body = klass.instructor ? `Taught by ${klass.instructor}.` : null;
+ emptyBody = "No detail yet. The first line gets taught soon.";
+ createdAt = klass.createdAt;
+ if (klass.instructor) meta.push(`WITH ${klass.instructor.toUpperCase()}`);
+ if (klass.schedule) meta.push(klass.schedule.toUpperCase());
+ meta.push(klass.isFree ? "FREE" : "PAID");
+ }
+
+ return (
+ <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
+ <Link href={`/city/${slug}/yard`} className="kicker hover:text-gold">
+ ← The Yard
+ </Link>
+
+ <p className="kicker mt-10 text-ash">
+ {typeLine} · {city.name}
+ </p>
+ <h1 className="display mt-3 text-6xl sm:text-7xl">{title}</h1>
+
+ {body ? (
+ <p className="serif mt-6 max-w-xl text-xl text-bone">{body}</p>
+ ) : (
+ <p className="serif mt-6 max-w-xl text-xl text-ash">{emptyBody}</p>
+ )}
+
+ <section className="mt-16 border-t border-[var(--line)] pt-10">
+ <p className="kicker text-gold">On the record</p>
+ <p className="kicker mt-4 text-ash">
+ {[
+ label.toUpperCase(),
+ (city.metroLabel ?? city.name).toUpperCase(),
+ ...meta,
+ createdAt ? `EST. ${stamp(createdAt)}` : null,
+ ]
+ .filter(Boolean)
+ .join(" · ")}
+ </p>
+ </section>
+ </main>
+ );
+}
apps/web/src/app/city/[slug]/route/page.tsx +184 -0
@@ -0,0 +1,184 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { revalidatePath } from "next/cache";
+import { TRAVEL_CATEGORY } from "@stepping/types";
+import { serverCaller } from "@/lib/trpc/server";
+
+export const dynamic = "force-dynamic";
+
+type Category = (typeof TRAVEL_CATEGORY)[number];
+
+const SECTIONS: {
+ category: Category;
+ heading: string;
+ count: string;
+ badge: string;
+ empty: string;
+ field: string;
+ cta: string;
+}[] = [
+ {
+ category: "hotel",
+ heading: "Hotels",
+ count: "rooms",
+ badge: "STAY",
+ empty: "No rooms booked yet. Drop the first place to lay your head below.",
+ field: "Hotel name (e.g. The Peabody)",
+ cta: "Add hotel",
+ },
+ {
+ category: "restaurant",
+ heading: "Restaurants",
+ count: "tables",
+ badge: "EAT",
+ empty: "No tables set yet. Name the first plate worth the trip below.",
+ field: "Restaurant name (e.g. The Four Way)",
+ cta: "Add restaurant",
+ },
+ {
+ category: "mall",
+ heading: "Shopping",
+ count: "stops",
+ badge: "SHOP",
+ empty: "No stops yet. Add the first place to fit before the show below.",
+ field: "Mall or shop (e.g. Wolfchase Galleria)",
+ cta: "Add shopping",
+ },
+ {
+ category: "attraction",
+ heading: "Attractions",
+ count: "sights",
+ badge: "SEE",
+ empty: "No sights yet. Mark the first thing worth seeing in town below.",
+ field: "Attraction (e.g. National Civil Rights Museum)",
+ cta: "Add attraction",
+ },
+ {
+ category: "hospital",
+ heading: "Hospitals",
+ count: "nearest",
+ badge: "CARE",
+ empty: "No hospital listed yet. Add the nearest one below, just in case.",
+ field: "Hospital (e.g. Methodist University)",
+ cta: "Add hospital",
+ },
+];
+
+export default async function RoutePage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ const caller = serverCaller();
+ const city = await caller.city.bySlug({ slug });
+ if (!city) notFound();
+
+ const cityId = city.id;
+ const grouped = await Promise.all(
+ SECTIONS.map((s) => caller.travelPlace.list({ cityId, category: s.category })),
+ );
+
+ async function addPlace(formData: FormData) {
+ "use server";
+ const category = String(formData.get("category") ?? "");
+ const name = String(formData.get("name") ?? "").trim();
+ const address = String(formData.get("address") ?? "").trim();
+ if (!name || !TRAVEL_CATEGORY.includes(category as Category)) return;
+ await serverCaller().travelPlace.create({
+ cityId: city!.id,
+ category: category as Category,
+ name,
+ address: address || undefined,
+ });
+ revalidatePath(`/city/${slug}/route`);
+ }
+
+ return (
+ <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
+ <Link href={`/city/${slug}`} className="kicker hover:text-gold">
+ ← {city.name}
+ </Link>
+
+ <p className="kicker mt-10 text-ash">The Route · {city.metroLabel}</p>
+ <h1 className="display mt-3 text-7xl sm:text-8xl">The Route</h1>
+ <p className="serif mt-5 max-w-xl text-xl text-ash">
+ In town for the show - where to stay, eat, shop, and play, plus the
+ nearest hospital. Everything you need to do {city.name} right.
+ </p>
+
+ {SECTIONS.map((section, idx) => {
+ const places = grouped[idx];
+ return (
+ <section
+ key={section.category}
+ className="mt-16 border-t border-[var(--line)] pt-10"
+ >
+ <div className="flex items-end justify-between">
+ <div>
+ <p className="kicker text-gold">Stop {String(idx + 1).padStart(2, "0")}</p>
+ <h2 className="display mt-2 text-4xl">{section.heading}</h2>
+ </div>
+ <span className="kicker text-ash">
+ {String(places.length).padStart(2, "0")} {section.count}
+ </span>
+ </div>
+
+ {places.length === 0 ? (
+ <p className="serif mt-8 text-xl text-ash">{section.empty}</p>
+ ) : (
+ <ul className="mt-6">
+ {places.map((p, i) => (
+ <li
+ key={p.id}
+ className="flex items-center gap-5 border-b border-[var(--line)] py-6"
+ >
+ <span className="kicker w-8 text-gold">
+ {String(i + 1).padStart(2, "0")}
+ </span>
+ <div className="flex-1">
+ <p className="display text-2xl text-bone sm:text-3xl">
+ {p.name}
+ </p>
+ {p.address ? (
+ <p className="kicker mt-1 text-ash">
+ {p.address.toUpperCase()}
+ </p>
+ ) : null}
+ </div>
+ <span className="kicker border border-[var(--line)] px-3 py-1">
+ {section.badge}
+ </span>
+ </li>
+ ))}
+ </ul>
+ )}
+
+ <form action={addPlace} className="mt-8 flex flex-col gap-3">
+ <input type="hidden" name="category" value={section.category} />
+ <input
+ name="name"
+ required
+ placeholder={section.field}
+ className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold"
+ />
+ <div className="mt-2 flex flex-wrap items-center gap-4">
+ <input
+ name="address"
+ placeholder="Address (optional)"
+ className="kicker flex-1 border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none placeholder:text-ash focus:border-gold"
+ />
+ <button
+ type="submit"
+ className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
+ >
+ {section.cta}
+ </button>
+ </div>
+ </form>
+ </section>
+ );
+ })}
+ </main>
+ );
+}
apps/web/src/app/city/[slug]/videos/loading.tsx +5 -0
@@ -0,0 +1,5 @@
+import { VideosSkeleton } from "@/components/skeletons";
+
+export default function Loading() {
+ return <VideosSkeleton />;
+}
apps/web/src/app/city/[slug]/videos/page.tsx +181 -0
@@ -0,0 +1,181 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { revalidatePath } from "next/cache";
+import { VIDEO_PLATFORM } from "@stepping/types";
+import { serverCaller } from "@/lib/trpc/server";
+
+export const dynamic = "force-dynamic";
+
+const PLATFORM_LABEL: Record<string, string> = {
+ youtube: "YouTube",
+ instagram: "Instagram",
+ tiktok: "TikTok",
+ other: "Other",
+};
+
+function youtubeId(url: string): string | null {
+ try {
+ const u = new URL(url);
+ const host = u.hostname.replace(/^www\./, "");
+ if (host === "youtu.be") {
+ const id = u.pathname.slice(1);
+ return /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null;
+ }
+ if (host === "youtube.com" || host === "m.youtube.com") {
+ if (u.pathname === "/watch") {
+ const id = u.searchParams.get("v");
+ return id && /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null;
+ }
+ const embed = u.pathname.match(/^\/(?:embed|shorts)\/([a-zA-Z0-9_-]{11})/);
+ if (embed) return embed[1];
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+export default async function VideosPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ const caller = serverCaller();
+ const city = await caller.city.bySlug({ slug });
+ if (!city) notFound();
+
+ const videos = await caller.video.list({ cityId: city.id });
+
+ async function addVideo(formData: FormData) {
+ "use server";
+ const title = String(formData.get("title") ?? "").trim();
+ const embedUrl = String(formData.get("embedUrl") ?? "").trim();
+ const platform = String(formData.get("platform") ?? "other");
+ if (!title || !embedUrl) return;
+ await serverCaller().video.create({
+ cityId: city!.id,
+ title,
+ embedUrl,
+ platform: platform as (typeof VIDEO_PLATFORM)[number],
+ });
+ revalidatePath(`/city/${slug}/videos`);
+ }
+
+ return (
+ <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
+ <Link href={`/city/${slug}`} className="kicker hover:text-gold">
+ ← {city.name}
+ </Link>
+
+ <p className="kicker mt-10 text-ash">
+ Tapes · {city.metroLabel}
+ </p>
+ <h1 className="display mt-3 text-7xl sm:text-8xl">The Tape</h1>
+ <p className="serif mt-5 max-w-xl text-xl text-ash">
+ Step shows, strolls, and sets caught on camera across {city.name}.
+ </p>
+
+ {/* Add a video */}
+ <section className="mt-14 border-t border-[var(--line)] pt-10">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-3xl">Drop a clip</h2>
+ <span className="kicker">Add to the tape</span>
+ </div>
+ <form action={addVideo} className="mt-6 flex flex-col gap-3">
+ <input
+ name="title"
+ required
+ placeholder="Clip title (e.g. Spring Yardfest 2025)"
+ className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold"
+ />
+ <input
+ name="embedUrl"
+ required
+ type="url"
+ placeholder="Video URL (e.g. https://youtu.be/…)"
+ className="serif border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold"
+ />
+ <div className="mt-2 flex flex-wrap gap-4">
+ <select
+ name="platform"
+ defaultValue="youtube"
+ className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold"
+ >
+ {VIDEO_PLATFORM.map((p) => (
+ <option key={p} value={p} className="bg-stage">
+ {(PLATFORM_LABEL[p] ?? p).toUpperCase()}
+ </option>
+ ))}
+ </select>
+ <button
+ type="submit"
+ className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
+ >
+ Add clip
+ </button>
+ </div>
+ </form>
+ </section>
+
+ {/* The tape */}
+ <section className="mt-16 border-t border-[var(--line)] pt-10">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-3xl">The Reel</h2>
+ <span className="kicker text-ash">
+ {String(videos.length).padStart(2, "0")} clips
+ </span>
+ </div>
+
+ {videos.length === 0 ? (
+ <p className="serif mt-8 text-xl text-ash">
+ No clips yet. Drop the first one above.
+ </p>
+ ) : (
+ <ul className="mt-8 flex flex-col gap-12">
+ {videos.map((v, i) => {
+ const ytId =
+ v.platform === "youtube" ? youtubeId(v.embedUrl) : null;
+ return (
+ <li key={v.id}>
+ <div className="flex items-end justify-between gap-5">
+ <div className="flex items-end gap-5">
+ <span className="kicker w-8 text-gold">
+ {String(i + 1).padStart(2, "0")}
+ </span>
+ <h3 className="display text-2xl sm:text-3xl">{v.title}</h3>
+ </div>
+ <span className="kicker border border-[var(--line)] px-3 py-1">
+ {(PLATFORM_LABEL[v.platform] ?? v.platform).toUpperCase()}
+ </span>
+ </div>
+
+ {ytId ? (
+ <div className="mt-5 aspect-video w-full overflow-hidden border border-[var(--line)]">
+ <iframe
+ src={`https://www.youtube-nocookie.com/embed/${ytId}`}
+ title={v.title}
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowFullScreen
+ className="h-full w-full"
+ />
+ </div>
+ ) : null}
+
+ <a
+ href={v.embedUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="kicker mt-5 inline-block text-gold hover:text-gold-bright"
+ >
+ Watch →
+ </a>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </section>
+ </main>
+ );
+}
apps/web/src/app/city/[slug]/yard/loading.tsx +5 -0
@@ -0,0 +1,5 @@
+import { YardSkeleton } from "@/components/skeletons";
+
+export default function Loading() {
+ return <YardSkeleton />;
+}
apps/web/src/app/city/[slug]/yard/page.tsx +30 -5
@@ -149,7 +149,12 @@ export default async function YardPage({
{String(i + 1).padStart(2, "0")}
</span>
<div className="flex-1">
- <p className="display text-2xl sm:text-3xl">{t.name}</p>
+ <Link
+ href={`/city/${slug}/profile/step-team/${t.id}`}
+ className="display text-2xl text-bone hover:text-gold sm:text-3xl"
+ >
+ {t.name}
+ </Link>
{t.bio ? (
<p className="serif mt-1 text-lg text-ash">{t.bio}</p>
) : null}
@@ -231,7 +236,12 @@ export default async function YardPage({
{String(i + 1).padStart(2, "0")}
</span>
<div className="flex-1">
- <p className="display text-2xl sm:text-3xl">{c.name}</p>
+ <Link
+ href={`/city/${slug}/profile/chapter/${c.id}`}
+ className="display text-2xl text-bone hover:text-gold sm:text-3xl"
+ >
+ {c.name}
+ </Link>
<p className="kicker mt-1 text-ash">
{c.school ? c.school.toUpperCase() : "CHAPTER"}
</p>
@@ -312,7 +322,12 @@ export default async function YardPage({
{String(i + 1).padStart(2, "0")}
</span>
<div className="flex-1">
- <p className="display text-2xl sm:text-3xl">{d.name}</p>
+ <Link
+ href={`/city/${slug}/profile/dj/${d.id}`}
+ className="display text-2xl text-bone hover:text-gold sm:text-3xl"
+ >
+ {d.name}
+ </Link>
{d.bio ? (
<p className="serif mt-1 text-lg text-ash">{d.bio}</p>
) : null}
@@ -375,7 +390,12 @@ export default async function YardPage({
{String(i + 1).padStart(2, "0")}
</span>
<div className="flex-1">
- <p className="display text-2xl sm:text-3xl">{p.name}</p>
+ <Link
+ href={`/city/${slug}/profile/promoter/${p.id}`}
+ className="display text-2xl text-bone hover:text-gold sm:text-3xl"
+ >
+ {p.name}
+ </Link>
{p.bio ? (
<p className="serif mt-1 text-lg text-ash">{p.bio}</p>
) : null}
@@ -438,7 +458,12 @@ export default async function YardPage({
{String(i + 1).padStart(2, "0")}
</span>
<div className="flex-1">
- <p className="display text-2xl sm:text-3xl">{c.title}</p>
+ <Link
+ href={`/city/${slug}/profile/class/${c.id}`}
+ className="display text-2xl text-bone hover:text-gold sm:text-3xl"
+ >
+ {c.title}
+ </Link>
<p className="kicker mt-1 text-ash">
{[
c.instructor ? `WITH ${c.instructor.toUpperCase()}` : null,
apps/web/src/app/globals.css +23 -1
@@ -152,9 +152,31 @@ body {
.b4 { animation-delay: 0.34s; }
.b5 { animation-delay: 0.46s; }
+.skeleton {
+ background: linear-gradient(
+ 90deg,
+ rgba(242, 236, 224, 0.05) 0%,
+ rgba(242, 236, 224, 0.12) 50%,
+ rgba(242, 236, 224, 0.05) 100%
+ );
+ background-size: 200% 100%;
+ border-radius: 2px;
+ animation: skeletonShimmer 1.4s ease-in-out infinite;
+}
+
+@keyframes skeletonShimmer {
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
+}
+
@media (prefers-reduced-motion: reduce) {
.marquee-track,
- .beat-in {
+ .beat-in,
+ .skeleton {
animation: none;
}
}
apps/web/src/app/layout.tsx +12 -1
@@ -1,4 +1,4 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import { Bricolage_Grotesque, Instrument_Serif, Space_Mono } from "next/font/google";
import "./globals.css";
import { TRPCProvider } from "@/lib/trpc/provider";
@@ -30,6 +30,17 @@ export const metadata: Metadata = {
title: "Stepping - On Beat",
description:
"The home for Divine Nine step show culture. Step shows, strolls, classes, DJs and the people, city by city.",
+ applicationName: "Stepping",
+ manifest: "/manifest.webmanifest",
+ icons: {
+ icon: "/icon.svg",
+ shortcut: "/icon.svg",
+ apple: "/icon.svg",
+ },
+};
+
+export const viewport: Viewport = {
+ themeColor: "#0F0E0C",
};
export default function RootLayout({
apps/web/src/app/page.tsx +10 -3
@@ -2,6 +2,7 @@ import Link from "next/link";
import { serverCaller } from "@/lib/trpc/server";
import { Marquee } from "@/components/marquee";
import { HeroCanvas } from "@/components/hero-canvas";
+import { MobileNav } from "@/components/mobile-nav";
export const dynamic = "force-dynamic";
@@ -60,6 +61,7 @@ export default async function Home() {
The Route
</a>
</nav>
+ <MobileNav />
</header>
{/* STAGE */}
@@ -188,9 +190,14 @@ export default async function Home() {
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--line)] px-5 py-8 sm:px-8">
<span className="display text-xl">STEPPING</span>
- <span className="kicker text-ash">
- On Beat · Built for the culture
- </span>
+ <div className="flex items-center gap-6">
+ <Link href="/about" className="kicker text-bone hover:text-gold">
+ About
+ </Link>
+ <span className="kicker text-ash">
+ On Beat · Built for the culture
+ </span>
+ </div>
</footer>
</div>
);
apps/web/src/components/mobile-nav.tsx +89 -0
@@ -0,0 +1,89 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+
+const LINKS = [
+ { href: "#lineup", label: "The Line-Up", index: "01" },
+ { href: "#yard", label: "The Yard", index: "02" },
+ { href: "#route", label: "The Route", index: "03" },
+];
+
+export function MobileNav() {
+ const [open, setOpen] = useState(false);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
+ };
+ document.documentElement.style.overflow = "hidden";
+ window.addEventListener("keydown", onKey);
+ return () => {
+ document.documentElement.style.overflow = "";
+ window.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+
+ return (
+ <div className="sm:hidden">
+ <button
+ type="button"
+ aria-label="Open menu"
+ aria-expanded={open}
+ onClick={() => setOpen(true)}
+ className="mobile-nav-trigger flex h-12 w-12 flex-col items-end justify-center gap-[6px] text-gold"
+ >
+ <span className="block h-[2px] w-7 bg-gold" />
+ <span className="block h-[2px] w-7 bg-gold" />
+ <span className="block h-[2px] w-5 bg-gold" />
+ </button>
+
+ {open && (
+ <div className="mobile-nav-sheet fixed inset-0 z-[70] flex flex-col bg-stage px-5 pb-12 pt-4">
+ <div className="flex items-center justify-between">
+ <span className="display text-lg tracking-tight text-bone">
+ STEPPING
+ </span>
+ <button
+ type="button"
+ aria-label="Close menu"
+ onClick={() => setOpen(false)}
+ className="flex h-12 w-12 items-center justify-center text-gold"
+ >
+ <span className="display text-3xl leading-none">×</span>
+ </button>
+ </div>
+
+ <nav className="mt-16 flex flex-1 flex-col justify-center gap-2">
+ {LINKS.map((l) => (
+ <a
+ key={l.href}
+ href={l.href}
+ onClick={() => setOpen(false)}
+ className="group flex items-baseline gap-5 border-b border-[var(--line)] py-6"
+ >
+ <span className="kicker text-gold">{l.index}</span>
+ <span className="display text-5xl text-bone group-active:text-gold">
+ {l.label}
+ </span>
+ </a>
+ ))}
+ <Link
+ href="/about"
+ onClick={() => setOpen(false)}
+ className="group flex items-baseline gap-5 border-b border-[var(--line)] py-6"
+ >
+ <span className="kicker text-gold">04</span>
+ <span className="display text-5xl text-bone group-active:text-gold">
+ What is this?
+ </span>
+ </Link>
+ </nav>
+
+ <p className="kicker mt-8 text-ash">On Beat · Divine Nine Stepping</p>
+ </div>
+ )}
+ </div>
+ );
+}
apps/web/src/components/skeletons.tsx +90 -0
@@ -0,0 +1,90 @@
+function Bar({ className = "" }: { className?: string }) {
+ return <span className={`skeleton block ${className}`} aria-hidden="true" />;
+}
+
+function SkeletonRows({ count = 4 }: { count?: number }) {
+ return (
+ <ul className="mt-10">
+ {Array.from({ length: count }).map((_, i) => (
+ <li
+ key={i}
+ className="flex items-center gap-5 border-b border-[var(--line)] py-6"
+ >
+ <Bar className="h-4 w-8" />
+ <Bar className="h-7 flex-1 max-w-[60%]" />
+ <Bar className="hidden h-4 w-24 sm:block" />
+ </li>
+ ))}
+ </ul>
+ );
+}
+
+export function CitySkeleton() {
+ return (
+ <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
+ <span className="kicker text-ash">Loading the city</span>
+ <Bar className="mt-10 h-4 w-48" />
+ <Bar className="mt-4 h-16 w-3/4 sm:h-24" />
+ <div className="mt-6 flex gap-6">
+ <Bar className="h-4 w-32" />
+ <Bar className="h-4 w-28" />
+ </div>
+ <div className="mt-16 border-t border-[var(--line)] pt-10">
+ <Bar className="h-7 w-40" />
+ <SkeletonRows count={4} />
+ </div>
+ </main>
+ );
+}
+
+export function YardSkeleton() {
+ return (
+ <main className="mx-auto w-full max-w-4xl px-5 py-16 sm:px-8">
+ <span className="kicker text-ash">Loading The Yard</span>
+ <Bar className="mt-10 h-4 w-40" />
+ <Bar className="mt-4 h-14 w-2/3 sm:h-20" />
+ <div className="mt-14 grid gap-px sm:grid-cols-2">
+ {Array.from({ length: 4 }).map((_, i) => (
+ <div key={i} className="border-t border-[var(--line)] py-8">
+ <Bar className="h-6 w-1/2" />
+ <Bar className="mt-3 h-4 w-3/4" />
+ </div>
+ ))}
+ </div>
+ </main>
+ );
+}
+
+export function EventSkeleton() {
+ return (
+ <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
+ <span className="kicker text-ash">Loading the show</span>
+ <Bar className="mt-10 h-4 w-40" />
+ <Bar className="mt-4 h-16 w-3/4 sm:h-24" />
+ <Bar className="mt-6 h-4 w-56" />
+ <div className="mt-14 border-t border-[var(--line)] pt-10">
+ <Bar className="h-5 w-2/3" />
+ <Bar className="mt-4 h-5 w-1/2" />
+ <Bar className="mt-4 h-5 w-3/5" />
+ </div>
+ </main>
+ );
+}
+
+export function VideosSkeleton() {
+ return (
+ <main className="mx-auto w-full max-w-4xl px-5 py-16 sm:px-8">
+ <span className="kicker text-ash">Loading the videos</span>
+ <Bar className="mt-10 h-4 w-40" />
+ <Bar className="mt-4 h-14 w-2/3 sm:h-20" />
+ <div className="mt-14 grid gap-6 sm:grid-cols-2">
+ {Array.from({ length: 4 }).map((_, i) => (
+ <div key={i}>
+ <Bar className="aspect-video w-full" />
+ <Bar className="mt-3 h-5 w-3/4" />
+ </div>
+ ))}
+ </div>
+ </main>
+ );
+}
packages/api/src/root.ts +53 -0
@@ -11,11 +11,13 @@ import {
organizations,
promoters,
stepTeams,
+ travelPlaces,
videos,
} from "@stepping/db";
import {
EVENT_TYPE,
SUBJECT_TYPE,
+ TRAVEL_CATEGORY,
VIDEO_PLATFORM,
} from "@stepping/types";
import {
@@ -192,6 +194,10 @@ export const appRouter = router({
.from(promoters)
.where(and(eq(promoters.cityId, input.cityId), eq(promoters.status, "active"))),
),
+ byId: publicProcedure.input(z.object({ id: uuid })).query(async ({ ctx, input }) => {
+ const [row] = await ctx.db.select().from(promoters).where(eq(promoters.id, input.id));
+ return row ?? null;
+ }),
create: protectedProcedure
.input(
z.object({ cityId: uuid, name: z.string().min(1), bio: z.string().optional(), links }),
@@ -212,6 +218,10 @@ export const appRouter = router({
.from(djs)
.where(and(eq(djs.cityId, input.cityId), eq(djs.status, "active"))),
),
+ byId: publicProcedure.input(z.object({ id: uuid })).query(async ({ ctx, input }) => {
+ const [row] = await ctx.db.select().from(djs).where(eq(djs.id, input.id));
+ return row ?? null;
+ }),
create: protectedProcedure
.input(
z.object({ cityId: uuid, name: z.string().min(1), bio: z.string().optional(), links }),
@@ -232,6 +242,10 @@ export const appRouter = router({
.from(stepTeams)
.where(and(eq(stepTeams.cityId, input.cityId), eq(stepTeams.status, "active"))),
),
+ byId: publicProcedure.input(z.object({ id: uuid })).query(async ({ ctx, input }) => {
+ const [row] = await ctx.db.select().from(stepTeams).where(eq(stepTeams.id, input.id));
+ return row ?? null;
+ }),
create: protectedProcedure
.input(
z.object({
@@ -258,6 +272,10 @@ export const appRouter = router({
.from(chapters)
.where(and(eq(chapters.cityId, input.cityId), eq(chapters.status, "active"))),
),
+ byId: publicProcedure.input(z.object({ id: uuid })).query(async ({ ctx, input }) => {
+ const [row] = await ctx.db.select().from(chapters).where(eq(chapters.id, input.id));
+ return row ?? null;
+ }),
create: protectedProcedure
.input(
z.object({
@@ -285,6 +303,10 @@ export const appRouter = router({
.from(classes)
.where(and(eq(classes.cityId, input.cityId), eq(classes.status, "active"))),
),
+ byId: publicProcedure.input(z.object({ id: uuid })).query(async ({ ctx, input }) => {
+ const [row] = await ctx.db.select().from(classes).where(eq(classes.id, input.id));
+ return row ?? null;
+ }),
create: protectedProcedure
.input(
z.object({
@@ -306,6 +328,37 @@ export const appRouter = router({
}),
}),
+ // ---- Travel guide (per-city: hotels, restaurants, attractions, hospitals) -
+ travelPlace: router({
+ list: publicProcedure
+ .input(z.object({ cityId: uuid, category: z.enum(TRAVEL_CATEGORY).optional() }))
+ .query(({ ctx, input }) =>
+ ctx.db
+ .select()
+ .from(travelPlaces)
+ .where(
+ and(
+ eq(travelPlaces.cityId, input.cityId),
+ eq(travelPlaces.status, "active"),
+ input.category ? eq(travelPlaces.category, input.category) : undefined,
+ ),
+ ),
+ ),
+ create: protectedProcedure
+ .input(
+ z.object({
+ cityId: uuid,
+ category: z.enum(TRAVEL_CATEGORY),
+ name: z.string().min(1),
+ address: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const [row] = await ctx.db.insert(travelPlaces).values(input).returning();
+ return row;
+ }),
+ }),
+
// ---- Minimal moderation (admin) ------------------------------------------
moderation: router({
recentEvents: adminProcedure.query(({ ctx }) =>
packages/db/seed-content.sql +77 -0
@@ -0,0 +1,77 @@
+-- Demo content for the local POC (Atlanta + DC). Run once against a seeded DB.
+\set dev '00000000-0000-0000-0000-000000000001'
+begin;
+
+insert into venues (city_id, name, address, owner_id)
+select id, v.name, v.addr, :'dev' from cities, (values
+ ('Georgia World Congress Center', '285 Andrew Young Intl Blvd NW, Atlanta, GA'),
+ ('Forbes Arena (Morehouse College)', '830 Westview Dr SW, Atlanta, GA')
+) as v(name, addr) where slug = 'atlanta';
+
+insert into venues (city_id, name, address, owner_id)
+select id, v.name, v.addr, :'dev' from cities, (values
+ ('Cramton Auditorium (Howard University)', '2455 6th St NW, Washington, DC'),
+ ('Burr Gymnasium (Howard University)', '2400 6th St NW, Washington, DC')
+) as v(name, addr) where slug = 'washington-dc';
+
+insert into events (city_id, title, type, starts_at, owner_id)
+select id, e.title, e.type::event_type, e.ts::timestamptz, :'dev' from cities, (values
+ ('Howard Homecoming Step Show', 'step_show', '2026-10-17 19:30-04'),
+ ('DMV Greek Stroll-Off', 'stroll', '2026-08-15 18:00-04'),
+ ('Yardfest Showcase', 'special', '2026-10-16 20:00-04')
+) as e(title, type, ts) where slug = 'washington-dc';
+
+insert into djs (city_id, name, owner_id)
+select id, n, :'dev' from cities, (values ('DJ Capitol'), ('DJ Metro Sound')) t(n) where slug = 'washington-dc';
+insert into promoters (city_id, name, owner_id)
+select id, n, :'dev' from cities, (values ('Bison Entertainment'), ('DMV Greek Events')) t(n) where slug = 'washington-dc';
+insert into step_teams (city_id, name, owner_id)
+select id, n, :'dev' from cities, (values ('Bison Steppers'), ('Capital City Line')) t(n) where slug = 'washington-dc';
+insert into classes (city_id, title, instructor, owner_id)
+select id, 'Beginner Steppin Workshop', 'Coach Dawkins', :'dev' from cities where slug = 'washington-dc';
+
+insert into chapters (organization_id, city_id, name, school, owner_id)
+select o.id, c.id, 'Beta Chapter', 'Howard University', :'dev'
+from cities c, organizations o where c.slug = 'washington-dc' and o.name = 'Alpha Kappa Alpha';
+insert into chapters (organization_id, city_id, name, school, owner_id)
+select o.id, c.id, 'Xi Chapter', 'Howard University', :'dev'
+from cities c, organizations o where c.slug = 'washington-dc' and o.name = 'Omega Psi Phi';
+
+insert into travel_places (city_id, category, name, address)
+select id, t.cat::travel_category, t.name, t.addr from cities, (values
+ ('hotel', 'Signia by Hilton Atlanta', '210 Peachtree St NW, Atlanta, GA'),
+ ('hotel', 'AC Hotel Atlanta Downtown', '166 Carnegie Way NW, Atlanta, GA'),
+ ('restaurant', 'Paschal''s', '180b Northside Dr SW, Atlanta, GA'),
+ ('restaurant', 'Busy Bee Cafe', '810 M.L.K. Jr Dr SW, Atlanta, GA'),
+ ('mall', 'Atlantic Station', '1380 Atlantic Dr NW, Atlanta, GA'),
+ ('attraction', 'Centennial Olympic Park', '265 Park Ave W NW, Atlanta, GA'),
+ ('hospital', 'Grady Memorial Hospital', '80 Jesse Hill Jr Dr SE, Atlanta, GA')
+) as t(cat, name, addr) where slug = 'atlanta';
+
+insert into travel_places (city_id, category, name, address)
+select id, t.cat::travel_category, t.name, t.addr from cities, (values
+ ('hotel', 'The LINE DC', '1770 Euclid St NW, Washington, DC'),
+ ('hotel', 'Hotel RL Washington DC', '1823 L St NW, Washington, DC'),
+ ('restaurant', 'Ben''s Chili Bowl', '1213 U St NW, Washington, DC'),
+ ('restaurant', 'Oohh''s & Aahh''s', '1005 U St NW, Washington, DC'),
+ ('mall', 'CityCenterDC', '825 10th St NW, Washington, DC'),
+ ('attraction', 'Howard University Yard', '2400 6th St NW, Washington, DC'),
+ ('hospital', 'Howard University Hospital', '2041 Georgia Ave NW, Washington, DC')
+) as t(cat, name, addr) where slug = 'washington-dc';
+
+insert into videos (city_id, title, embed_url, platform, owner_id)
+select id, v.title, v.url, v.plat::video_platform, :'dev' from cities, (values
+ ('Atlanta Greek Picnic - Step Highlights', 'https://www.youtube.com/results?search_query=atlanta+greek+picnic+step+show', 'youtube'),
+ ('AUC Homecoming Stroll-Off', 'https://www.tiktok.com/search?q=auc%20homecoming%20stroll', 'tiktok')
+) as v(title, url, plat) where slug = 'atlanta';
+
+insert into comments (subject_type, subject_id, body, owner_id)
+select 'event', id, 'Who all pulling up this year? The AUC chapters always bring it.', :'dev'
+from events where title = 'Atlanta Greek Picnic Step Show' limit 1;
+
+commit;
+select 'venues' c, count(*) n from venues
+union all select 'travel', count(*) from travel_places
+union all select 'videos', count(*) from videos
+union all select 'comments', count(*) from comments
+union all select 'events', count(*) from events;