| @@ -0,0 +1 @@ | ||
| + | TID=e19f38d2-5e60-4f66-abf7-777ccf97c4d7 |
| @@ -0,0 +1,109 @@ | ||
| + | # Chicago Stepping App - Dad's Plan, Transcribed & Sequenced | |
| + | ||
| + | *Source: 5 voice-memo videos dad recorded (`Plan-Transcribe/IMG_2444-2448.MOV`).* | |
| + | *Transcribed 2026-06-28 with Whisper large-v3-turbo on the homelab RTX 3060.* | |
| + | *Verbatim source of truth: `Plan-Transcribe/transcripts/_ALL_TRANSCRIPTS.md`.* | |
| + | ||
| + | --- | |
| + | ||
| + | ## 0. THE BIG CORRECTION (read first) | |
| + | ||
| + | This app is for **CHICAGO STEPPING** - the partner dance - **NOT** Greek / Divine Nine / NPHC stepping. | |
| + | ||
| + | Dad is explicit (IMG_2446): *"it's a difference… between Chicago stepping and Greek stepping. So this here is Chicago stepping."* | |
| + | ||
| + | > **Chicago stepping** (a.k.a. "steppin'") originated in the African American community of Chicago's West and South sides. It evolved during the 1940s-60s from the jitterbug and the 1950s glide-and-stride dance known as **the bop** - drawing on two-step, swing, bebop, six-count, eight-count, ballroom, and the jitterbug/scooter-bug. (Dad's words, IMG_2446 + IMG_2447.) | |
| + | ||
| + | **Every existing doc in this repo is mis-themed** (`STEPPING_APP_IDEAS.md`, `STEPPING_APP_IMPLEMENTATION_PLAN.md`, parts of the scaffold copy all assume Divine Nine / HBCU step shows). They must be re-pointed to Chicago stepping. The good news: dad's actual feature list (promoters, *sets*, DJs, classes, per-city travel) was always Chicago-stepping shaped - "sets" = partner-dance social gatherings in a city, not step shows. | |
| + | ||
| + | **Dad's core goal, in his words (IMG_2446):** | |
| + | > *"I'm just trying to provide an app that people in different cities… can just share, so people [are] abreast of what's going on, besides having to go to YouTube and Facebook Live to see content. That's my goal."* | |
| + | ||
| + | So the north star is: **one place, organized by city, where the Chicago-stepping community posts and finds what's going on** - replacing scattered YouTube/Facebook-Live discovery. | |
| + | ||
| + | --- | |
| + | ||
| + | ## 1. The whole app, by dad's own structure | |
| + | ||
| + | Dad's mental model is **city-first**: nearly every feature is "in each city / in certain cities." The app is a directory of **cities**, and inside each city sit the modules below. | |
| + | ||
| + | ### Per-city modules dad asked for | |
| + | | # | Feature | Dad's words / intent | Source | | |
| + | |---|---------|----------------------|--------| | |
| + | | A | **Promotions / promotional events** | Anything in that city, or anything an event's host wants to add, as a promo | 2444, 2445 | | |
| + | | B | **Classes** | "A space for classes in each city" | 2444 | | |
| + | | C | **DJs** | Stepping DJs / Chicago-stepping music DJs in the city | 2444 | | |
| + | | D | **Videos** | Users upload videos from their city ("somebody in Houston can send me the video, we upload to the app") | 2444 | | |
| + | | E | **Dancing sets - locations** | "Location of dancing sets in certain cities" - find the sets | 2444 | | |
| + | | F | **Special events** | Special / cause events (he names domestic-violence awareness as an example) | 2445 | | |
| + | | G | **Live feature** | "Go live" in-app | 2445 | | |
| + | | H | **Comment section** | So people can give feedback / discuss | 2445 | | |
| + | | I | **Event locations / event centers** | The actual venues per city ("different event centers in that location") | 2445 | | |
| + | | J | **City main attraction(s)** | "One or two main attractions" per city where the event is held | 2445 | | |
| + | | K | **Stepping groups** | Each city shows the stepping group(s) in that city | 2445 | | |
| + | | L | **Restaurants near the set/hotel** | A couple of restaurants featured near the set or host hotel | 2445 | | |
| + | | M | **Mall / shopping near the event** | "If you're in Charlotte, what's the closest mall to the event?" | 2445 | | |
| + | | N | **Hotels (incl. additional hotels)** | Host hotel + additional nearby hotels | 2445 | | |
| + | | O | **Hospitals near the venue** | Closest hospital "just in case," for safety/medical | 2445 | | |
| + | | P | **Entertainment** | Other entertainment in the city tied to Chicago stepping | 2445 | | |
| + | | Q | **Monetization for upkeep** | Ads, paid download, or free - to cover maintenance/edits/updates (every-few-days to weekly), since the dev office maintains it | 2448 | | |
| + | ||
| + | Items I-P together are dad's **"traveling stepper" city guide** - for people going out of town for a set/weekender (he repeats "when you go out of town to certain cities"). | |
| + | ||
| + | --- | |
| + | ||
| + | ## 2. SEQUENTIAL BUILD ORDER (what to do, in order) | |
| + | ||
| + | This is the order to actually build it - foundation first, then dad's features grouped so each phase ships something usable. Lettered features above are referenced as `[A]…[Q]`. | |
| + | ||
| + | ### Phase 0 - Re-base the project onto Chicago Stepping *(do this before any feature work)* | |
| + | 1. Rewrite `STEPPING_APP_IDEAS.md` and `STEPPING_APP_IMPLEMENTATION_PLAN.md` to Chicago stepping; strip all Divine Nine / HBCU / NPHC / "step show / stroll / fraternity" framing. | |
| + | 2. Fix the authoritative definition + scope copy (use §0 above). | |
| + | 3. Re-pick launch cities for **Chicago stepping** culture, not D9 density. Chicago is the home market; strong steppin' scenes include **Chicago (flagship), Atlanta, Detroit, Dallas/Houston, Charlotte, DC/DMV** (dad himself name-drops Houston and Charlotte). Confirm with dad. | |
| + | 4. Keep the existing stack/monorepo (Next.js + tRPC + Drizzle + Supabase + Mux) - it's fine; only the *theme/content* was wrong. | |
| + | ||
| + | ### Phase 1 - The spine: Cities + content posting + discovery | |
| + | 5. **City model & directory** - the app's backbone; everything is city-scoped. | |
| + | 6. **Open posting + accounts** (dad: anyone can contribute / "somebody in Houston can send me the video"). | |
| + | 7. **Sets `[E]`** - the core object: a dancing set/event with city, venue, date/time, host. | |
| + | 8. **Events & promotions `[A]` `[F]`** - upcoming events, promotional posts, special/cause events. | |
| + | 9. **Videos `[D]`** - per-city user-uploaded video (upload → playback). *(Mux.)* | |
| + | 10. **Comment section `[H]`** - on sets/events/videos. | |
| + | ||
| + | > Ships dad's #1 goal: a city-organized feed of sets, events, promos, and videos that replaces hunting on YouTube/Facebook Live. | |
| + | ||
| + | ### Phase 2 - The people & the music | |
| + | 11. **Promoters / hosts** - profiles, their events (implied throughout; the people who throw sets). | |
| + | 12. **Stepping groups `[K]`** - per-city crews/groups. | |
| + | 13. **DJs `[C]`** - per-city stepping DJs (who's spinning, where). | |
| + | 14. **Classes `[B]`** - per-city lessons (teacher, schedule, location). | |
| + | ||
| + | ### Phase 3 - The traveling-stepper city guide | |
| + | 15. **Event venues / centers `[I]`** + **city main attractions `[J]`**. | |
| + | 16. **Hotels `[N]`** (host + additional nearby). | |
| + | 17. **Restaurants `[L]`**, **malls/shopping `[M]`**, **entertainment `[P]`** near the set/hotel. | |
| + | 18. **Hospitals `[O]`** - nearest hospital to the venue (safety). | |
| + | *(Curated entries first; can later pull from Google Places. Flag: data-entry/API effort.)* | |
| + | ||
| + | ### Phase 4 - Live | |
| + | 19. **Live feature `[G]`** - go live in-app for sets/events. *(Mux live; flag: most technically involved.)* | |
| + | ||
| + | ### Phase 5 - Sustainability | |
| + | 20. **Monetization for upkeep `[Q]`** - design schema for it from day one, switch it on here: ads and/or paid download and/or free. Goal is covering maintenance (frequent edits/updates by the dev office), per dad. Decide model with dad/Zion. | |
| + | ||
| + | --- | |
| + | ||
| + | ## 3. Flag list (clarify with dad / scope carefully) | |
| + | - **Live streaming `[G]`** and **video upload/transcode `[D]`** are the heaviest pieces - Mux handles both, but they need real config + moderation. | |
| + | - **Per-city travel data `[I]-[P]`** - curated by hand at first vs. pulled from APIs (Google Places). How "steppin'-specific" vs. generic should it be? | |
| + | - **Open posting** → needs minimum moderation (spam/abuse) to survive. | |
| + | - **Monetization `[Q]`** - ads vs. paid download vs. free is still open; dad wants *something* to fund upkeep. | |
| + | - **Launch cities** - confirm the Chicago-stepping city list with dad. | |
| + | ||
| + | --- | |
| + | ||
| + | ## 4. Handoff to the wireframing / integration agent | |
| + | ||
| + | Next step: an Opus 4.x agent wireframes these screens and integrates the plan into the existing `apps/web` scaffold, building in the Phase order above. Suggested kickoff prompt: | |
| + | ||
| + | > You are integrating dad's plan into the Chicago Stepping app (`/shared/projects/SteppingApp`). Read `DADS_PLAN_SEQUENCED.md` (this file) and `Plan-Transcribe/transcripts/_ALL_TRANSCRIPTS.md` (dad's verbatim words) FIRST. This is **Chicago stepping (partner dance), NOT Divine Nine** - re-theme any D9 content you touch. Use RAG-first (`homelab_context`) before reading scaffold files. Wireframe the city-first IA (city directory → per-city modules A-P) and the Phase-0/Phase-1 screens, then integrate into `apps/web`. Keep the existing Next.js + tRPC + Drizzle + Supabase + Mux stack. Honor the design system in `DESIGN_SYSTEM.md`. Ship Phase 0 (re-base) + Phase 1 (cities, sets, events/promos, videos, comments) first. |
| @@ -0,0 +1,567 @@ | ||
| + | <!DOCTYPE html> | |
| + | <html lang="en"> | |
| + | <head> | |
| + | <meta charset="UTF-8"> | |
| + | <title>Stepping - Questions for You</title> | |
| + | <style> | |
| + | * { box-sizing: border-box; margin: 0; padding: 0; } | |
| + | ||
| + | body { | |
| + | font-family: Georgia, 'Times New Roman', serif; | |
| + | font-size: 13.5pt; | |
| + | line-height: 1.72; | |
| + | color: #111; | |
| + | background: #fff; | |
| + | } | |
| + | ||
| + | .doc-header { | |
| + | padding-bottom: 1em; | |
| + | margin-bottom: 0.5em; | |
| + | border-bottom: 3px solid #2E4BFF; | |
| + | } | |
| + | ||
| + | .doc-header h1 { | |
| + | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; | |
| + | font-size: 30pt; | |
| + | font-weight: 700; | |
| + | color: #111; | |
| + | letter-spacing: -0.02em; | |
| + | line-height: 1.15; | |
| + | } | |
| + | ||
| + | .doc-header .sub { | |
| + | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; | |
| + | font-size: 13pt; | |
| + | color: #2E4BFF; | |
| + | font-weight: 600; | |
| + | margin-top: 0.3em; | |
| + | letter-spacing: 0.02em; | |
| + | } | |
| + | ||
| + | .intro-box { | |
| + | background: #f1f4ff; | |
| + | border-left: 5px solid #2E4BFF; | |
| + | padding: 1em 1.3em; | |
| + | margin: 1.4em 0 2.2em 0; | |
| + | border-radius: 0 6px 6px 0; | |
| + | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; | |
| + | font-size: 12.5pt; | |
| + | line-height: 1.7; | |
| + | color: #222; | |
| + | } | |
| + | ||
| + | .section-label { | |
| + | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; | |
| + | font-size: 15.5pt; | |
| + | font-weight: 700; | |
| + | color: #2E4BFF; | |
| + | margin-top: 2.4em; | |
| + | margin-bottom: 0.7em; | |
| + | padding-bottom: 0.35em; | |
| + | border-bottom: 2px solid #d8e2ff; | |
| + | page-break-after: avoid; | |
| + | } | |
| + | ||
| + | .section-note { | |
| + | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; | |
| + | font-size: 11.5pt; | |
| + | color: #555; | |
| + | font-style: italic; | |
| + | margin-bottom: 1.2em; | |
| + | line-height: 1.6; | |
| + | } | |
| + | ||
| + | .q-block { | |
| + | margin-bottom: 1.9em; | |
| + | page-break-inside: avoid; | |
| + | } | |
| + | ||
| + | .q-num { | |
| + | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; | |
| + | font-size: 10.5pt; | |
| + | font-weight: 700; | |
| + | color: #2E4BFF; | |
| + | text-transform: uppercase; | |
| + | letter-spacing: 0.08em; | |
| + | margin-bottom: 0.2em; | |
| + | } | |
| + | ||
| + | .q-text { | |
| + | font-size: 13.5pt; | |
| + | color: #111; | |
| + | margin-bottom: 0.3em; | |
| + | line-height: 1.55; | |
| + | } | |
| + | ||
| + | .q-example { | |
| + | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; | |
| + | font-size: 11pt; | |
| + | color: #555; | |
| + | font-style: italic; | |
| + | margin-bottom: 0.4em; | |
| + | line-height: 1.5; | |
| + | } | |
| + | ||
| + | .answer-line { | |
| + | border-bottom: 1px solid #c0c0c0; | |
| + | margin-top: 0.8em; | |
| + | height: 1.85em; | |
| + | } | |
| + | ||
| + | /* Meeting prep section */ | |
| + | .meeting-box { | |
| + | background: #f8f8f8; | |
| + | border: 1.5px solid #d8e2ff; | |
| + | border-left: 5px solid #2E4BFF; | |
| + | border-radius: 0 6px 6px 0; | |
| + | padding: 1.1em 1.4em; | |
| + | margin-top: 1em; | |
| + | } | |
| + | ||
| + | .meeting-box h3 { | |
| + | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; | |
| + | font-size: 12.5pt; | |
| + | font-weight: 700; | |
| + | color: #2E4BFF; | |
| + | margin-bottom: 0.6em; | |
| + | } | |
| + | ||
| + | .meeting-box ul { | |
| + | list-style: none; | |
| + | padding: 0; | |
| + | margin: 0; | |
| + | } | |
| + | ||
| + | .meeting-box ul li { | |
| + | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; | |
| + | font-size: 12pt; | |
| + | color: #222; | |
| + | line-height: 1.6; | |
| + | padding: 0.45em 0 0.45em 1.4em; | |
| + | border-bottom: 1px solid #e4e8f8; | |
| + | position: relative; | |
| + | } | |
| + | ||
| + | .meeting-box ul li:last-child { | |
| + | border-bottom: none; | |
| + | } | |
| + | ||
| + | .meeting-box ul li::before { | |
| + | content: "\2610"; | |
| + | position: absolute; | |
| + | left: 0; | |
| + | color: #2E4BFF; | |
| + | font-size: 12pt; | |
| + | } | |
| + | ||
| + | .doc-footer { | |
| + | margin-top: 3.5em; | |
| + | padding-top: 1em; | |
| + | border-top: 1px solid #ddd; | |
| + | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; | |
| + | font-size: 9.5pt; | |
| + | color: #aaa; | |
| + | text-align: center; | |
| + | } | |
| + | </style> | |
| + | </head> | |
| + | <body> | |
| + | ||
| + | <div class="doc-header"> | |
| + | <h1>Stepping</h1> | |
| + | <div class="sub">Questions for You — Your Vision for the Site</div> | |
| + | </div> | |
| + | ||
| + | <div class="intro-box"> | |
| + | Hey Dad, thank you for everything you have put into this. These questions are just Zion's way of making sure he builds this site the right way, around your vision and your community. There are no wrong answers here. Just press record on your phone, say the question number out loud (like “Q1, my answer is...”), and talk freely. You do not have to do everything in one sitting. Take as many breaks as you need. If a question does not make sense, just skip it and come back later. And if something comes to mind that is not on this list, say that too. | |
| + | </div> | |
| + | ||
| + | ||
| + | <!-- ==================== SECTION 1 ==================== --> | |
| + | <div class="section-label">Section 1: The Big Picture</div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q1</div> | |
| + | <div class="q-text">In your own words, what is the Stepping site for? If someone who had never heard of Chicago stepping asked you about it, what would you tell them?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q2</div> | |
| + | <div class="q-text">When somebody opens the site for the very first time, what is the one thing you most want them to be able to do right away?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q3</div> | |
| + | <div class="q-text">Imagine showing this to someone you really respect in the stepping world. What would make you proudest to point to and say, “We built this for our people”?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | ||
| + | <!-- ==================== SECTION 2 ==================== --> | |
| + | <div class="section-label">Section 2: Your Friend in Memphis and His Events</div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q4</div> | |
| + | <div class="q-text">Tell us about your friend. What is his name, and what does he do in the stepping world?</div> | |
| + | <div class="q-example">For example: does he run weekenders, annual balls, weekly sets, or all of the above?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q5</div> | |
| + | <div class="q-text">Does he have a name for his organization or his event series? Something people in the community already know him by?</div> | |
| + | <div class="q-example">For example: “Unique Steppers of Memphis” or a specific event name like “The Black Tie Extravaganza”</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q6</div> | |
| + | <div class="q-text">Right now, how does he sell tickets to his events?</div> | |
| + | <div class="q-example">For example: cash at the door, Zelle, Eventbrite, his own website, or word of mouth?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q7</div> | |
| + | <div class="q-text">Is he open to selling tickets through our site? And if so, which of his events would he want to start with?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q8</div> | |
| + | <div class="q-text">What kinds of tickets does he usually offer?</div> | |
| + | <div class="q-example">For example: single person entry, a couple price, VIP table, weekend pass, early-bird special?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q9</div> | |
| + | <div class="q-text">What do his tickets cost? Even a rough ballpark helps us build it right.</div> | |
| + | <div class="q-example">For example: does a general admission ticket run around $20, $50, $100, or more?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | ||
| + | <!-- ==================== SECTION 3 ==================== --> | |
| + | <div class="section-label">Section 3: Bringing His Content Over to Our Site</div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q10</div> | |
| + | <div class="q-text">He has event flyers, the kind of images you print out or post on Facebook to promote a set. Do you want those flyers to show up big and bold on each event page on our site?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q11</div> | |
| + | <div class="q-text">Do you want a photo gallery for each event, so after the night is over people can scroll through pictures from the party? Think of it like a Facebook album built right into the site.</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q12</div> | |
| + | <div class="q-text">He has a Facebook page and a YouTube channel. Do you want stepping videos built into the site so people can watch without having to leave?</div> | |
| + | <div class="q-example">For example: are his videos mostly on YouTube, Facebook, or both?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q13</div> | |
| + | <div class="q-text">Does he ever put out a printed program or pamphlet for his big events? If so, do you want people to be able to download it as a PDF right from the event page?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q14</div> | |
| + | <div class="q-text">Does he work with vendors at his events?</div> | |
| + | <div class="q-example">For example: food sellers, clothing, jewelry, stepping shoes, or business sponsors? Should we have a vendor section where those people are listed?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q15</div> | |
| + | <div class="q-text">For his bigger weekenders, does he recommend a hotel to out-of-town steppers? Do you want a travel section that lists the hotel, nearby restaurants, and things like that?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q16</div> | |
| + | <div class="q-text">Looking at his website overall, was there anything on it that you really liked or that his audience seemed to love? Anything you want us to do the same or do even better?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | ||
| + | <!-- ==================== SECTION 4 ==================== --> | |
| + | <div class="section-label">Section 4: The Cities We Launch In</div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q17</div> | |
| + | <div class="q-text">We are planning to launch in six cities right now: Chicago, Atlanta, Detroit, Houston, Charlotte, and Washington DC. Are there any you want to take off that list? And are there any cities NOT on the list that you think we should add?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q18</div> | |
| + | <div class="q-text">Memphis is not on the current launch list. Should it be? And would your friend be the right person to represent the Memphis stepping scene on the site?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | ||
| + | <!-- ==================== SECTION 5 ==================== --> | |
| + | <div class="section-label">Section 5: Real Events and Real People to List First</div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q19</div> | |
| + | <div class="q-text">Are there any real upcoming sets or weekenders, with a name, a date, and a venue, that you want listed on the site right now? Give us as many as you can think of.</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q20</div> | |
| + | <div class="q-text">Which DJs in the stepping world should have a profile on the site? Give us their names and what cities they are known in.</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q21</div> | |
| + | <div class="q-text">Are there any stepping groups or dance crews that should have their own page on the site?</div> | |
| + | <div class="q-example">For example: well-known clubs or crews from Chicago, Memphis, Atlanta, or anywhere else in the stepping world.</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q22</div> | |
| + | <div class="q-text">Are there instructors or teachers who should be listed? People who teach stepping classes in their city?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q23</div> | |
| + | <div class="q-text">Are there other event promoters, besides your friend, who you already know and trust? People who put on events and might want to use the site?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | ||
| + | <!-- ==================== SECTION 6 ==================== --> | |
| + | <div class="section-label">Section 6: The Money Side</div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q24</div> | |
| + | <div class="q-text">Our plan is to let everyone browse the site for free. Scroll events, watch videos, look up DJs, all of it. Does that feel right to you?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q25</div> | |
| + | <div class="q-text">For promoters who want to BOOST their event to the top of the list or get a featured spotlight, we might charge a small fee, like a sponsored listing. Does that feel fair?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q26</div> | |
| + | <div class="q-text">When tickets are sold through our site, we would keep a small cut of each one, the same way Eventbrite or StubHub takes a small fee per ticket. Does that feel fair to you and the people in your community?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q27</div> | |
| + | <div class="q-text">Is there anything about the money side that does not sit right with you? Anything you would want to handle differently to keep it fair to the community?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | ||
| + | <!-- ==================== SECTION 7 ==================== --> | |
| + | <div class="section-label">Section 7: Design Direction</div> | |
| + | <div class="section-note">These questions are all about the look and feel of the site. Do not overthink them. Just say what comes to mind.</div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q28</div> | |
| + | <div class="q-text">Do you like the current look of the site? Right now it has a dark black background, a blue accent color, and a spinning disco ball. Is that the right feel, or do you want something different?</div> | |
| + | <div class="q-example">For example: warmer with gold tones, brighter and more colorful, more elegant and classy, more like a nightclub, or more like a magazine with big beautiful photos?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q29</div> | |
| + | <div class="q-text">What colors feel like “stepping” to you? Are there any colors that immediately make you think of the stepping world and the culture?</div> | |
| + | <div class="q-example">For example: gold and black, deep purple, midnight blue, red and black, something bold and glamorous?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q30</div> | |
| + | <div class="q-text">When someone first lands on the site, what is the very first thing you want them to see? What is the image or feeling that stops them from scrolling past and makes them pay attention?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q31</div> | |
| + | <div class="q-text">Do you want big event photos and flyers front and center, right when someone opens the app? Or is the current clean, simple look the better first impression?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q32</div> | |
| + | <div class="q-text">Should the wording on the site feel formal and classy, or fun and casual? Is this site mostly for grown folks, for younger people, or for both?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q33</div> | |
| + | <div class="q-text">The spinning disco ball is our signature image right now. Would you keep it, or would you rather have something different as the main symbol of the site?</div> | |
| + | <div class="q-example">For example: a stepping couple mid-dance, a vinyl record, a city skyline, something else?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q34</div> | |
| + | <div class="q-text">Are most of the people who will use this site going to be on their phones?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q35</div> | |
| + | <div class="q-text">Is there any website, app, or even a flyer you love the look of? Something where you thought, “That's clean, that's sharp”? We can use it as a reference.</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q36</div> | |
| + | <div class="q-text">The site is called “Stepping” right now. Is that the right name? Would you want something that feels bigger or more official?</div> | |
| + | <div class="q-example">For example: “The Stepping Network,” “Steppers,” “StepCity,” something else entirely?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q37</div> | |
| + | <div class="q-text">Is there a tagline or phrase that should go under the site name? Something that says what this is all about in one short line?</div> | |
| + | <div class="q-example">For example: “Find Your Set,” “Where the Culture Lives,” “Chicago Stepping. Your City.”</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q38</div> | |
| + | <div class="q-text">We use the words “Sets,” “The Floor,” “The Tape,” and “The Road” for different sections. Do those words feel natural to steppers? Or are there better words the community already uses?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q39</div> | |
| + | <div class="q-text">Is there a logo, image, or symbol you already picture when you think about this site? Anything that says “stepping” to you the moment you see it?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | ||
| + | <!-- ==================== SECTION 8 ==================== --> | |
| + | <div class="section-label">Section 8: What to Build First</div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q40</div> | |
| + | <div class="q-text">Of everything we have talked about today, what is the single most important thing you want us to build or fix first? What would make this site ready to show to your friend and his community?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q41</div> | |
| + | <div class="q-text">After that first thing, what comes next? If you had to put it in order, what are your top three priorities?</div> | |
| + | <div class="q-example">Just talk through them one at a time. “First I want... then second... then third...”</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q42</div> | |
| + | <div class="q-text">Besides your friend, is there anyone else you want to show this to soon? A stepping legend, a well-known DJ, or a club president whose opinion really matters in the community?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | ||
| + | <!-- ==================== SECTION 9 ==================== --> | |
| + | <div class="section-label">Section 9: Anything Else on Your Mind</div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q43</div> | |
| + | <div class="q-text">Is there anything you want on this site that we have not asked about yet? Any feature, section, or idea that has been in your head?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | <div class="q-block"> | |
| + | <div class="q-num">Q44</div> | |
| + | <div class="q-text">Anything else you want to say, anything at all?</div> | |
| + | <div class="answer-line"></div> | |
| + | <div class="answer-line"></div> | |
| + | </div> | |
| + | ||
| + | ||
| + | <!-- ==================== SECTION 10 ==================== --> | |
| + | <div class="section-label">Section 10: For Your Meeting With Your Friend</div> | |
| + | <div class="section-note">This last part is not for recording. It is a checklist of things to find out when you sit down with your friend, so you two can talk through it together. Check each one off as you cover it.</div> | |
| + | ||
| + | <div class="meeting-box"> | |
| + | <h3>Things to Find Out at the Meeting</h3> | |
| + | <ul> | |
| + | <li>Ask him to walk you through how he currently sells tickets, step by step. Who handles the money?</li> | |
| + | <li>Would he want his events and tickets featured on our site? Is he willing to try it for one event first?</li> | |
| + | <li>Does he have event flyers, photos, and videos we could use? Where are they stored (phone, Facebook, Google Drive)?</li> | |
| + | <li>Which of his events are coming up in the next 60 to 90 days that should be listed right away?</li> | |
| + | <li>What does he like about his current website? What does he wish it did better?</li> | |
| + | <li>Would he be willing to be the first promoter listed on the Memphis city page?</li> | |
| + | <li>Does he have a hotel or venue he already partners with for out-of-town guests?</li> | |
| + | <li>Are there vendors he works with at his events who might want to be listed on the site?</li> | |
| + | <li>Does he know other promoters in other cities who might want to join?</li> | |
| + | <li>What do his regular attendees complain about when it comes to finding events or buying tickets?</li> | |
| + | <li>Is he on a nonprofit status? Does that change how he thinks about ticket fees?</li> | |
| + | <li>What would make him trust our site enough to send his community there?</li> | |
| + | </ul> | |
| + | </div> | |
| + | ||
| + | ||
| + | <div class="doc-footer"> | |
| + | Stepping • Questions for Dad • Prepared by Zion | |
| + | </div> | |
| + | ||
| + | </body> | |
| + | </html> |
| @@ -1,10 +1,12 @@ | ||
| - | # Stepping: Design System - "ON BEAT" | |
| + | # Stepping Design System ("IN THE POCKET") | |
| - | A bespoke visual system for the Divine Nine stepping app. Built from first | |
| - | principles, grounded in step-show culture. It deliberately does NOT use the | |
| - | `ui-ux-pro-max` skill or any generic component-kit look. Every choice is | |
| - | sourced from (a) Zion's reference portfolios and (b) license-clean open-source | |
| - | technique, then bent toward the culture. | |
| + | A bespoke visual system for the **Chicago Stepping** app. Built from first | |
| + | principles, grounded in Chicago-stepping culture: the smooth, gliding, soulful | |
| + | 6-/8-count partner dance that came out of Chicago's West and South sides in the | |
| + | 1940s-60s, evolved from the jitterbug and the 1950s glide-and-stride "bop." It | |
| + | deliberately does NOT use the `ui-ux-pro-max` skill or any generic | |
| + | component-kit look. Every choice is sourced from (a) Zion's reference portfolios | |
| + | and (b) license-clean open-source technique, then bent toward the floor. | |
| ## Sourcing (provenance, so this stays defensible and original) | ||
| @@ -20,67 +22,77 @@ technique, then bent toward the culture. | ||
| `olivierlarose` staggered-reveal / zoom-parallax repos are no-license, so we | ||
| REIMPLEMENT their technique from scratch, never copy their files. | ||
| - | ## Concept spine: ON BEAT | |
| + | ## Concept spine: IN THE POCKET | |
| - | The site runs like a step show that never stops. Stepping is body-as-instrument: | |
| - | stomps, claps, spoken word, synchronized lines. So the interface keeps time. The | |
| - | page is a show with a run of acts: | |
| + | The site runs like a **set that never stops**. Chicago stepping is not a stomp | |
| + | or a clap; it is a glide. Smooth, in-the-pocket, soulful. Lead and follow on a | |
| + | 6-/8-count, the dancers locked into the music. So the interface keeps the count | |
| + | and rides the pocket. The page is a set with a run of acts: | |
| - | 1. **STAGE** - the hero. Blackout, spotlight, the name hits on a beat. | |
| - | 2. **THE LINE-UP** - events (step shows, strolls, competitions). | |
| - | 3. **THE YARD** - community: chapters, step teams, DJs, promoters, classes. | |
| - | 4. **THE ROUTE** - per-city travel for traveling to a show. | |
| + | 1. **THE SPOTLIGHT**: the hero. The lights drop, the cut comes in, the name | |
| + | glides in on the one. | |
| + | 2. **THE SETS**: sets, weekenders, classes, workshops, competitions, special, | |
| + | social. | |
| + | 3. **THE FLOOR**: stepping groups, DJs, promoters/hosts, classes. | |
| + | 4. **THE ROAD**: per-city travel guide for the traveling stepper (out-of-town | |
| + | sets and weekenders). | |
| - | ## Color - two-tone + one heritage accent (warm) | |
| + | The old "THE NINE / Greek-letter" framing is gone entirely. This is the | |
| + | partner dance: the count, the glide, the pocket, the spin, partner lead/follow, | |
| + | soulful sets. | |
| + | ||
| + | ## Color: two-tone + one heritage accent (warm) | |
| | Token | Hex | Role | | ||
| |---|---|---| | ||
| - | | `--stage` | `#0F0E0C` | ground (warm near-black, a stage blackout / brick at night) | | |
| + | | `--stage` | `#0F0E0C` | ground (warm near-black, the floor after dark / brick at night) | | |
| | `--bone` | `#F2ECE0` | ink (warm spotlight bone, never pure white) | | ||
| - | | `--gold` | `#C8A24C` | the only accent: heritage prestige gold, neutral across all nine orgs | | |
| + | | `--gold` | `#C8A24C` | the only accent: heritage gold, warm and dignified | | |
| | `--gold-bright` | `#E4C572` | gold hover / active | | ||
| | `--ash` | `#8A8275` | warm grey for secondary mono metadata | | ||
| | `--line` | `rgba(242,236,224,0.14)` | hairlines | | ||
| - | Discipline: no other hues. We never adopt a single fraternity/sorority's colors | |
| - | (the app serves all nine); gold is the shared dignifier. | |
| + | Discipline: no other hues. Gold is the one warm dignifier across every city and | |
| + | every set. | |
| - | ## Type - percussive + editorial duality (call-and-response) | |
| + | ## Type: smooth + editorial duality (lead and follow) | |
| - | - **Display / impact:** `Bricolage Grotesque` - oversized, tight, declarative. | |
| - | The "stomp." Used huge for act titles and the name. | |
| - | - **Editorial accent:** `Instrument Serif` - high-contrast serif for the | |
| - | storytelling lines (the definition of stepping is literally storytelling). | |
| - | - **Metadata:** `Space Mono` - every label, index, year, set time, and city | |
| - | coordinate. Maps the "art-tier mono micro-type" law onto real step data: | |
| - | `EST. 1906`, `ΑΦΑ`, `ATL · 33.749, -84.388`, `01 / 06`. | |
| + | - **Display / impact:** `Bricolage Grotesque`, oversized, tight, declarative. | |
| + | The "glide." Used huge for act titles and the name. | |
| + | - **Editorial accent:** `Instrument Serif`, high-contrast serif for the | |
| + | storytelling lines (the history of this dance is literally storytelling, born | |
| + | on Chicago's West/South sides, 1940s-60s, out of the bop and the jitterbug). | |
| + | - **Metadata:** `Space Mono`, every label, index, set time, and city | |
| + | coordinate. Maps the "art-tier mono micro-type" law onto real steppin' data: | |
| + | `CHI · 41.878, -87.629`, `6-COUNT · 8-COUNT`, `SET · 9:00 PM`, `01 / 06`. | |
| Type rule: on any screen, either the display type is loud or the imagery is. | ||
| Never both. | ||
| - | ## Motion - "on beat" | |
| + | ## Motion: "in the pocket" | |
| - | - **Cadence:** reveals are staggered like a stomp-clap, not a uniform fade. | |
| - | Short, snappy, slightly syncopated timing. | |
| + | - **Cadence:** reveals ease in like a glide, staggered on the count (the | |
| + | 6-/8-count), in the pocket, not a uniform fade and not a percussive stomp. | |
| + | Smooth, slightly behind-the-beat soulful timing. | |
| - **Velocity-reactive:** display type reacts to scroll speed (skew/weight), | ||
| reimplemented with Lenis + transform mapping (not copied). | ||
| - | - **Smooth scroll:** Lenis (MIT). | |
| - | - **Signature object / cursor:** a custom cursor that leaves a clap-ripple on | |
| - | click and a soft trailing dot - percussion you can feel. This is our "one | |
| + | - **Smooth scroll:** Lenis (MIT). The whole page glides; nothing jumps. | |
| + | - **Signature object / cursor:** a custom cursor that leaves a glide-ripple on | |
| + | click and a soft trailing dot, the pocket you can feel. This is our "one | |
| signature interactive object," lightweight (canvas/DOM), no heavy WebGL. | ||
| - **Reduced motion:** all of the above gate on `prefers-reduced-motion`. | ||
| ## Layout | ||
| Editorial and asymmetric. Generous negative space. Numbered/lettered systems | ||
| - | everywhere (founding years, chapter designations, `01/06` indices). Hairline | |
| - | rules and mono kickers frame each act. Mobile-first; the show reflows to a single | |
| - | column without losing the beat. | |
| + | everywhere (set times, city tiers, `01/06` indices). Hairline rules and mono | |
| + | kickers frame each act. Mobile-first; the set reflows to a single column | |
| + | without losing the count. | |
| ## Components (first set) | ||
| - | - `BeatCursor` - custom cursor + clap-ripple on click. | |
| - | - `SmoothScroll` - Lenis provider. | |
| - | - `Marquee` - a running mono ticker of founding years / city names (the "roll"). | |
| - | - Act section shell - mono kicker + huge display title + content. | |
| - | - Event card, profile card - bone-on-stage, gold for the one thing that matters. | |
| + | - `GlideCursor`: custom cursor + glide-ripple on click. | |
| + | - `SmoothScroll`: Lenis provider. | |
| + | - `Marquee`: a running mono ticker of set times / city names (the glide-by). | |
| + | - Act section shell: mono kicker + huge display title + content. | |
| + | - Event/set card, profile card: bone-on-stage, gold for the one thing that matters. |
| @@ -0,0 +1,82 @@ | ||
| + | # Dad's July 2nd Answers - The Spec (source of truth for the next push) | |
| + | ||
| + | *47 voice memos transcribed 2026-07-04 (Whisper large-v3-turbo, 3060). Verbatim: | |
| + | `Plan-Transcribe/TranscribeJuly2nd/transcripts/_JULY2ND_ALL_TRANSCRIPTS.md`. | |
| + | These are Dad's answers to `DAD_QUESTIONS.pdf` (Q1-Q44). Message N = memo N; | |
| + | memos 1-9 were duplicate exports of 10-18. Q18 was skipped; Q22 was answered | |
| + | twice and the later answer (msg 35: yes) supersedes the earlier hold-off (msg 30).* | |
| + | ||
| + | --- | |
| + | ||
| + | ## 1. Non-negotiable design changes (Dad's exact words) | |
| + | ||
| + | | # | Decision | Source | | |
| + | |---|----------|--------| | |
| + | | D1 | **The disco ball GOES.** "It's disco, but we're doing Chicago stepping... it sends a conflicted message... the ball, it has to go." | Q28 (38), Q33 (44) | | |
| + | | D2 | **New emblem: a stepping COUPLE.** "It's a couple's dance... a man and a woman... maybe twirling... holding hands... some elegant, classy ballroom type of form in that emblem." Silhouette imagery; also evocative: DJ booth, stiletto heels (ladies), sharp suits (men) - "stepping sharp, coming clean." | Q33 (44), Q39 (50) | | |
| + | | D3 | **Readable, older-people-friendly type.** "Lettering, letterhead, fonts at normal height or size where people can read it... I don't want stuff to be small." | Q2 (11), Q28 (38) | | |
| + | | D4 | **Professional like Apple / BlackRock / Fidelity.** "Just as long as it looks professional and it works and it's functional and it's user-friendly... secure and safe." Color can stay - "a good color that pops but not too far bright... I don't want to make it cheesy... still professional." | Q30 (40), Q28 (38) | | |
| + | | D5 | **Grown-up voice.** Audience is grown folks. "Grown up, concise and straight to the point... no fluff and no vagueness." | Q32 (42, 43) | | |
| + | | D6 | **Imagery: mixed, clean, respectful** - "sprinkle a few big photos here or there," not over-the-top. | Q31 (41) | | |
| + | | D7 | **Name: lead with "Chicago Stepping"** said in a clever/unique/classy way (final wording subject to a family vote). | Q36 (47) | | |
| + | | D8 | **Tagline LOCKED: "Chicago Stepping. Your City."** - "I like that tagline... run with that." | Q37 (48) | | |
| + | | D9 | **Mobile-first / the app is the goal.** "I was trying to gear toward the cell phone." Website + app must "sync and be uniform." | Q34 (45), Q41 (52) | | |
| + | | D10 | No one specific "stepping color" exists - cities theme differently (camo, all-white, cream, purple, pink, all-black). Color choice is ours per D4. | Q29 (39) | | |
| + | ||
| + | ## 2. Features Dad said YES to (build/finish) | |
| + | ||
| + | | # | Feature | Detail | Source | | |
| + | |---|---------|--------|--------| | |
| + | | F1 | **Event flyers on the site** | Flyers/brochures shown on events. Needs image upload + storage. | Q10 (19) | | |
| + | | F2 | **Photo albums per event** | "Comparable to Facebook" scroll-through galleries, plus people can SEND photos/videos in to the site/app. | Q11 (20) | | |
| + | | F3 | **Rich built-in media** | "From literature to vocals to videos" built into the site. | Q12 (21) | | |
| + | | F4 | **Downloadable PDF programs** | Event pamphlet/program as a PDF on the event page - "that would be something unique." | Q13 (22) | | |
| + | | F5 | **Vendor section** | Greg has vendors (glasses, purses, CBD oil, wide range). List vendors per event/city. | Q14 (23) | | |
| + | | F6 | **Host hotels / travel** | He recommends host hotels for out-of-towners - keep/finish The Road. | Q15 (24) | | |
| + | | F7 | **Instructor listings per city** | "We can advertise instructors in different cities that people can go and get lessons from." (Capability now; real names later.) | Q22 (35) | | |
| + | ||
| + | ## 3. Explicit HOLDS (do NOT do) | |
| + | ||
| + | | # | Hold | Source | | |
| + | |---|------|--------| | |
| + | | H1 | **NO real events, DJs, groups, promoters, or instructors by name** until permissions/contracts exist - name/likeness + copyright worry, "so we won't infringe on anybody's rights." Build the slots, keep them "ready to add," seed only clearly-labeled sample data. | Q19-Q23 (27-32) | | |
| + | | H2 | **NO promoter boost/featured fees.** "No. We shouldn't charge a fee for promoters." | Q25 (34) | | |
| + | | H3 | **Ticket-cut fee: PINNED / off the table** for now. | Q26 (36) | | |
| + | | H4 | **Not showing anyone outside yet** - wants licensing/copyright protection first (idea-theft worry). Keep it private-ish; security matters. | Q42 (53) | | |
| + | | H5 | Greg selling through our site: **unconfirmed** - he has his own site + designer; Dad needs a sit-down. Keep ticketing capable but don't build Greg-specific flows. | Q7 (16) | | |
| + | ||
| + | ## 4. Money model (Dad's current position) | |
| + | ||
| + | - Browse free (logged in) - yes. **App + website are the two monetization vehicles.** (Q24, msg 33) | |
| + | - Leaning: **quality worth a $1-2 app download** - "worth paying for or worth them downloading." No community-alienating fees. (Q27, msg 37) | |
| + | - Everything else pinned (H2/H3). | |
| + | ||
| + | ## 5. Cities | |
| + | ||
| + | - Current six (Chicago, Atlanta, Detroit, Houston, Charlotte, DC) = **good soft launch**. (Q17, msg 26) | |
| + | - Future list: **Little Rock, St. Louis, Huntsville, Montgomery, New Orleans, Las Vegas.** Surface as "more cities coming." | |
| + | - Memphis (Q18) unanswered directly - Greg is Memphis; leave off until Dad confirms. | |
| + | ||
| + | ## 6. Greg Dixon facts (for later onboarding, H5 pending) | |
| + | ||
| + | Greg Dixon · 50+ years in stepping · Chicago-stepping promoter + dance instructor · | |
| + | org: **Unique Steppers of Memphis** · sells tickets online via his own website + | |
| + | in person city-to-city · ticket types: early-bird, group/whole-city specials | |
| + | (10-12+ couples), full slate · average price **$75-$125/person**, varies by | |
| + | season/event. Nothing on his current site Dad wants copied (Q16). | |
| + | ||
| + | ## 7. Dad's priority order (Q40/Q41, msgs 51-52) | |
| + | ||
| + | 1. **Website + app synced and uniform** - connecting Chicago Stepping to the people. | |
| + | 2. **Monetization** that benefits the family without alienating the community. | |
| + | 3. **Fun without fear** - people trust it: secure, safe, classy, and **true to the | |
| + | roots of Chicago stepping** (never mixed up with Greek stepping). | |
| + | ||
| + | What he wants to be able to show Greg: security, classiness, continuity across | |
| + | cities, cultural fidelity. (Q40, msg 51) | |
| + | ||
| + | ## 8. Overall verdict | |
| + | ||
| + | "All the work that's been done up into this point thus far, I'm pleased with... | |
| + | Everything seems to be on point." (Q43-44, msgs 54-55) - finish and elevate, | |
| + | don't restart. |
| @@ -0,0 +1,41 @@ | ||
| + | # Stepping - Next Push To-Do (gated on Dad's answers) | |
| + | ||
| + | Pinned 2026-06-28. **Do NOT start until Dad's voice answers come back and are transcribed.** | |
| + | The question PDF for Dad: `DAD_QUESTIONS.pdf` (in this repo). His transcribed answers drive everything below. | |
| + | ||
| + | ## A. Content migration (the "one-to-one but more polished" upgrade) | |
| + | Modeled on his friend's site (uniquesteppersofmemphis.com). Each item is gated on Dad confirming he wants it + sending the assets. | |
| + | - [ ] **Event flyers/images** - add an image field to events; show the flyer on event cards + the event detail page. Needs **image upload + storage** (today the app has NO image upload; videos are embed-only). Decide storage: local disk on the host vs an object store. | |
| + | - [ ] **Photo galleries** - per-event and/or per-city galleries from past events. Also needs upload/storage. | |
| + | - [ ] **Videos** - add the specific videos Dad names (YouTube/Facebook) to The Tape. | |
| + | - [ ] **Pamphlets / printed programs** - let an event carry a downloadable PDF (event program). File upload/storage. | |
| + | - [ ] **Vendors / sponsors** - vendor directory per event/city if their events have vendors (we already have a sponsors table; may extend for event-level vendors). | |
| + | - [ ] **Anything else** Dad flags from the friend's site (open mic answers). | |
| + | ||
| + | ## B. Real data to seed (from Dad) | |
| + | - [ ] Confirm/adjust **launch cities** (now: Chicago, Atlanta, Detroit, Houston, Charlotte, DC). | |
| + | - [ ] Real **upcoming sets/weekenders** (names, dates, venues, cities). | |
| + | - [ ] Real **DJs, stepping groups, promoters, instructors** to list first. | |
| + | ||
| + | ## C. The friend / ticketing partnership | |
| + | - [ ] Onboard the friend as a **promoter** (his profile + his events). | |
| + | - [ ] His **ticket types + prices** (single / couple / table / VIP / weekend pass). | |
| + | - [ ] How he wants to sell (through our site once payments are on). | |
| + | ||
| + | ## D. Direction / branding (from Dad) | |
| + | - [ ] Any **name** change (is "Stepping" final?), logo, tagline. | |
| + | - [ ] Any **color/wording** changes he wants. | |
| + | - [ ] **Monetization model + pricing** he's comfortable with (free browse; promoter feature fee; per-ticket fee). | |
| + | - [ ] **First priorities** - what to build first. | |
| + | ||
| + | ## E. Technical (Zion's backend - next session, fresh context; NOT gated on Dad) | |
| + | - [ ] Security audit: login rate-limiting, CSP, full review before go-live. | |
| + | - [ ] Wire **Stripe** payments (ticketing + boosts). | |
| + | - [ ] **Real buyer accounts** (so My Tickets is per-person; today reserve issues to the system user). | |
| + | - [ ] Self-serve **pro accounts** + **sponsor** purchase (today admin-only). | |
| + | - [ ] Dedicated host + `STEPPING_SECURE_COOKIES=1`; Zion sets his own admin password. | |
| + | - [ ] Regenerate the **promo video** on the new Chrome Noir look. | |
| + | ||
| + | ## Notes | |
| + | - The biggest NEW engineering piece in this push is **image/file upload + storage** (flyers, galleries, pamphlets). Everything else is content + the already-scoped payment/security work. | |
| + | - Demo stays live at https://stepping.zionboggan.com while we gather answers. |
| @@ -0,0 +1,135 @@ | ||
| + | Bills and To Buy | |
| + | ◦ Pay rent $599.67 June 1st✅ June 18th | |
| + | ◦ Phone bill & Internet done ? | |
| + | ◦ Utilities $100 | |
| + | ◦ Car insurance $228.87 | |
| + | ◦ Turo ticket $170 | |
| + | ◦ Plane ticket to Chattanooga ✅ | |
| + | ◦ Short layover in Chicago inbetween | |
| + | ◦ Plane ticket to SFB from Chattanooga ✅ | |
| + | ◦ Plane ticket to MEM from MCO ✅ | |
| + | ||
| + | ◦ Time off from work Saturday August 15th? Or call in sick ? | |
| + | ||
| + | ◦ Dominic Fike concert ticket ✅ | |
| + | ◦ MEM to ATL flight august 15th (Sat)✅ | |
| + | ◦ Hotel/AirBnB ATL August 15th-17th✅ | |
| + | ||
| + | ||
| + | ||
| + | Opencode creds | |
| + | root | |
| + | ||
| + | tzBPiOf6y0lOQBme | |
| + | ||
| + | ---- | |
| + | ||
| + | claude code pw (zion&root) | |
| + | ||
| + | @dlXaEpSXo3i#HgbO4 | |
| + | ||
| + | --- | |
| + | ||
| + | Change resume on dice , Robert half, USA jobs, linkedin, indeed to the blue pdf themed of resume . And apply to USAjobs | |
| + | ||
| + | ||
| + | ||
| + | ||
| + | ||
| + | Told her changes are done . And I got the photos and headshot from her . They are in the folder B in revisions folder. | |
| + | ||
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | 🙏🏾🙏🏾 | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  | |
| + | ||
| + |  |
| @@ -0,0 +1,143 @@ | ||
| + | # July 2nd Voice Answers - verbatim Whisper transcripts (messages 10-56 = the 47 unique memos; 1-9 were duplicates of 10-18) | |
| + | ||
| + | ## Message 10 | |
| + | What is the stepping site for where in my mind the stepping site was for connection To make it easy and accessible for those that are already in the stepping community To grow the stepping community and to monetize off the stepping community That was question one, and it was an add to question one, part B. If a newcomer asked me about stepping, what would I say? I would say stepping, Chicago stepping to me is more of a fun way for adults to get out and network and get out and socialize and get out and get exercise in all while having fun listening to great old school music. um it's just all about having fun that's it just relaxing and having fun that's what i will say to a newcomer question one | |
| + | ||
| + | ## Message 11 | |
| + | Question two, if somebody open the site, what do I want somebody to do first thing to open the site? Well, first off, if they find the site or they're blessed enough or lucky enough to find it, I want them to be able to navigate through it easily and it be refreshing where it pops out and it grabs their attention and it just makes them want to navigate through things. And I don't want it to be, I want it to be. Older people friendly. I know young people navigate through the internet and stuff easily, but I want to be Easy and navigational friendly I don't want stuff to be small where they can't read it and it's just be trivial Trying to go through different things question two | |
| + | ||
| + | ## Message 12 | |
| + | Question three, if I had to pitch it to someone well-respected in the Stepping community, something just like this built for us, what would I say? My first thing would be trying to pitch the legacy that I'm trying to build for Stepping, Chicago Stepping, to continue to try to build that not only national but global brand of Stepping. But sometimes people in the stepping community don't want to grow it like that. They want to keep it still underground because they don't want people to take from it and monetize off of it. Because it was really an underground dance, Chicago stepping. So I would just want to be excited to see that they have a space that they can get on that's high tech because we are in a high tech area now. with so many different avenues and streaming and just different people using their phone so um that somebody's in the high up i'll be like hey we're getting into the high tech space uh even though we're underground uh type of um genre | |
| + | ||
| + | ## Message 13 | |
| + | Question four. You asked me about the friend. Question four. His name is Greg Dixon, and he's been in the stepping community for over 50 years. He's a local promoter and a local dance instructor in the Chicago stepping. | |
| + | ||
| + | ## Message 14 | |
| + | Question five. Yes, he does have an organization, Unique Steppers of Memphis. That's the organization he's known by. | |
| + | ||
| + | ## Message 15 | |
| + | Question six, you asked me, how does he sell tickets to his events? Right now he sells his tickets online. He has his own website along with he goes to different events around the United States, city to city, and he sells tickets that way. And that's how he sells his tickets online through his website. And he goes personally to different cities, to different Chicago stepping events. | |
| + | ||
| + | ## Message 16 | |
| + | Question seven. You asked me, was he available or interested in selling through our site? I haven't met with him yet, but he does have his own website and his own designer, so I'm not sure. I'm going to have to have a sit-down and a meeting with him on that. | |
| + | ||
| + | ## Message 17 | |
| + | Question eight, what type of tickets? He has a variety of early bird specials. He has a whole city specials, like if you have a group of 10 or 12 or more individual couples. He has the whole slew of different options on buying tickets on his website. | |
| + | ||
| + | ## Message 18 | |
| + | Question nine, the price of his tickets varies from different events. So some events be for summer, some events be for the fall, the main event. So it all depends, but the average ticket is between $75 a person to $125. So that's the price range. | |
| + | ||
| + | ## Message 19 | |
| + | Question 10, do I want some of the brochures or flyers that he has over on our site? Yes, that would be good to add that to our site. | |
| + | ||
| + | ## Message 20 | |
| + | Question 11. Do I want a photo album so people can scroll? Yes, a photo album would be good comparable to Facebook and also add a photo live where sometimes people can bring videos and send videos to the website and to the app. So yes, I would like that. | |
| + | ||
| + | ## Message 21 | |
| + | Question 12, yes, I would like for the site to have different things built into the website on the question that you asked on question 12, meaning from literature to vocals to videos to whatever to make it more, yes, I would like that. | |
| + | ||
| + | ## Message 22 | |
| + | Question 13. He does print out different pictures, and that would be something unique to have the PDF file added to the website. So, yes. | |
| + | ||
| + | ## Message 23 | |
| + | Question 14. Does he work with vendors? Yes. He do have vendors at his site. Yes, he does. Maybe glasses, purses, just a wide range of things. CBD oil, just a whole bunch of different things. Yes. | |
| + | ||
| + | ## Message 24 | |
| + | Question 15, he does have host hotels and he does recommend hotels for people that come out of town. | |
| + | ||
| + | ## Message 25 | |
| + | number 16 of his website i didn't see anything that we need to duplicate other than um making a website and an app itself that's about it | |
| + | ||
| + | ## Message 26 | |
| + | Question 17. The six cities you have are sufficient, and there will be other cities like Little Rock, St. Louis, Huntsville, Alabama, Montgomery, New Orleans, Las Vegas. There's other cities, but the six cities you have right now is a soft launch would be good. | |
| + | ||
| + | ## Message 27 | |
| + | Number 19, right now there's nothing else that I would want on the site right now, other vendors or steppers or any other groups right now because I want to test it out first so that we won't infringe on anybody's rights. So at the moment, no. | |
| + | ||
| + | ## Message 28 | |
| + | Number 20, there are a lot of DJs that I would like to put up, but with copyright infringement and people brand and name and likeness, they be going to monetize it. So I don't want to put body names on it and then have to come back and pay them because we didn't solicit them or got a contract or something like that. So I want to hold off on this right now, but we could implement something to have it ready to add to it. | |
| + | ||
| + | ## Message 29 | |
| + | Number 21, are there any other groups that should have their own page on our site? Again, with copyright infringement of these different groups and LLCs and whatnot, I want to at least meet with some people and, you know, get their permission first, even though it's trying to uplift the stepping community and all that, but I want to hold off on adding people to the site for right now. | |
| + | ||
| + | ## Message 30 | |
| + | Number 22, are there any instructors? Man, it's a lot of good instructors out there. But once again, you know, this is, these people are fickle. So you don't want to put people on there without their consent. So adding different groups, adding different people, instructors, DJs, all that will be added later. So we're going to hold off on that for right now. | |
| + | ||
| + | ## Message 31 | |
| + | you | |
| + | ||
| + | ## Message 32 | |
| + | For number 23 there's nobody else to add to the site right now. | |
| + | ||
| + | ## Message 33 | |
| + | Number 24, yes, that is the initial goal for people to be logged in and browse for free, and then we can monetize it in another form or fashion as we go forward. But I was trying to see could we have the app along with the website, one of the main two things to monetize off of it. | |
| + | ||
| + | ## Message 34 | |
| + | For number 25, no. We shouldn't charge a fee for promoters to come and boast on the site. No. | |
| + | ||
| + | ## Message 35 | |
| + | Number 22, are there any instructors that need to be listed in different cities? Yes. And we can advertise instructors in different cities that people can go and get lessons from or those that are throwing sets. Yes, they can be added. | |
| + | ||
| + | ## Message 36 | |
| + | 26 getting a fee or percentage of a cut of somebody else's ticket sales I'm not sure yet put a pin in that if we can find another way to get money that'd be good but I want to take it off the table for right now | |
| + | ||
| + | ## Message 37 | |
| + | Number 27, is anything that scares me or I'm hesitant on the money side? I'm up in the air on different things, but I like to monetize it, but I don't want to alienate people, but I want to put something out there that's quality that would be worth paying for or worth them downloading in. Saying, you know what, I'll pay the dollar or two dollars for the app, so. | |
| + | ||
| + | ## Message 38 | |
| + | number 28 the website is good the disco ball we can probably do away with the disco ball because it's disco but we're doing chicago stepping so it sends a kind of conflicted message so and then as far as um the coloring as long as lettering letterhead fonts are at normal height or size where people can read it i don't have a problem with the color but i do want to be a good color that pops but not too far bright because i don't want to make it cheesy i want to still make it professional | |
| + | ||
| + | ## Message 39 | |
| + | 29, there is no Pacific stepping colors that represent stepping, Chicago stepping as a whole because different cities have different themes. Sometimes it's camouflage. Sometimes it's all white. Sometimes it's crushing cream. Sometimes it's purple. Sometimes it's pink supporting breast cancer. It's all different type of all black. It's just different colors. So there's not one Pacific color that makes me think of Chicago stepping. | |
| + | ||
| + | ## Message 40 | |
| + | Number third is an interesting question. When people land on the site, what is one of the first things that I would like for them to see? I would like for them to see that it's just as professional as you see in Apple site or BlackRock site or Fidelia site. It doesn't matter. Just as long as it looks professional and it works and it's functional and it's user-friendly. I just wanted to be professional and user-friendly and secure and safe. So that's what I want when they log on. | |
| + | ||
| + | ## Message 41 | |
| + | On 31, do I want the site to have crisp and clean pictures or large up front big pictures? I want it to be kind of mixed a little bit, but I don't want it to be too ghetto fabulous or too out there. I want it to be clean, respectful, and then maybe sprinkle a few big photos here or there. | |
| + | ||
| + | ## Message 42 | |
| + | Number 32, the site mainly is for grown-ups. There's a lot of grown-ups. There's not any very, very, very few teenagers or people in their 20s or 30s. Most of this is grown-ups, so I want it to be grown-up. Yeah. | |
| + | ||
| + | ## Message 43 | |
| + | Grown up grown up concise and straight to the point a lot of times no fluff and no vagueness and stuff like that I don't want that | |
| + | ||
| + | ## Message 44 | |
| + | Number 33 the disco ball. I would like to have it removed. I don't want that as a staple for the ideal Hitting for the site because it's a couple's dance So in Chicago stepping is a man and a woman So I would like it to be at least some type of way world a man has a woman maybe twirling or like you see them holding hands they trying to do the salsa that type of form or some elegant classy ballroom type of form in that emblem if you're going to put something but the ball it has to go | |
| + | ||
| + | ## Message 45 | |
| + | Number 34, that was not my ideal goal. Are most of the people going to be on the site on their phones? That was my targeted goal for the app. There's something that people easy access, download, and still be able to conduct business. I don't think too many people are going to be on desktop or on the tablets. But they may, but I don't think it's most of it I was trying to gear toward the cell phone since a lot of people use their cell phones. | |
| + | ||
| + | ## Message 46 | |
| + | Number 35, number 35, this is a unique space, so there's nobody else's website that I see that's comparable, compatible, or leading the way right now at this moment, and there's nobody with an app, nobody. | |
| + | ||
| + | ## Message 47 | |
| + | Number 36, the only thing I can think of number 36 is it's called Chicago Stepping. And then you do have, you know, line dancing, you know, ballroom dancing, but mainly it's Chicago Stepping. So if we can say Chicago Stepping in some type of way that's clever or unique or classy. we may run with it but we'd have to look at it and take some votes thank you | |
| + | ||
| + | ## Message 48 | |
| + | number 37 i like that tagline chicago stepping in your city i like that i like that tagline so we playing around with that that sounds good | |
| + | ||
| + | ## Message 49 | |
| + | number 38 different phrases and code words like step and set or classes or lanes or you know things of that nature or some of the things you know | |
| + | ||
| + | ## Message 50 | |
| + | Number 39 is there something that I see that immediately Remind me of stepping it'd be a couple things one would be On you a man and a woman Like a silhouette of a man of a woman and they're holding getting ready to start the initial which is the beginning of a dance or DJ booth, you know or you know just People getting ready to end the dance or they're embracing each other. So a lot of different things you see when it comes to Chicago stepping in for ladies is shoes, high heel shoes, stiletto shoes, and men is suits, you know, clean, you know, stepping sharp, coming clean, you know, being clean, your attire, being real clean. So those are the things that you see that remind you immediately of stepping. | |
| + | ||
| + | ## Message 51 | |
| + | Number 40, some of the important things I would like to have to show my friend would be the security of it, the aspect of it, the classiness of the website and the app, the continuity of us putting things together that can be functional for different cities, that we stay true to the roots of Chicago, step in. You know we didn't cross the line and mix it up with greek stepping or anything others just stands out on its own alone It's quite a few things so | |
| + | ||
| + | ## Message 52 | |
| + | Number 41. First thing I want would be for the website and the app to sync and be uniform in a way that it connects Chicago Stepping to the people. And secondly, I would like to monetize it where I can make money. off of it, but still inform people, enlighten people, and people get something and gain something from it. And third, I still want people to have fun and not be fearful of using the website or the app. Those would be the main three things I would like. And staying true to the culture and the history of Chicago Steppin'. | |
| + | ||
| + | ## Message 53 | |
| + | Number 42 right now in this infancy stage I have a few people in mind. I would like to share it with but since I don't have it You know, I don't have it licensed and have all my stuff working nobody copyrighted and Take it over and monetize it and take my idea. I'm gonna hold off. I don't have anybody else to show it to right now | |
| + | ||
| + | ## Message 54 | |
| + | Number 43, we've covered a lot of bases. At the moment, there's nothing I can think of at the moment. Everything seems to be on point. There's different things that we're going to revisit and different things we're going to add to, different things we're going to take down. But like I said, I just wanted to stay true to Chicago Steppin' and the history and the legacy of Steppin' and want to be able to monitorize this in a way that benefits me and my family. by way of the website and by way of the app, Chicago Step In app for your phone. | |
| + | ||
| + | ## Message 55 | |
| + | Number 44, when I started thinking about this in this infancy stage, I was hesitant. I wasn't sure. But all the work that's been done up into this point thus far, I'm pleased with. And I'm excited to see how we go forth and move forward in this new journey and adventure. | |
| + | ||
| + | ## Message 56 | |
| + | Zion, I want to say thank you once again for helping me and creating this and doing this and putting your touch on it. You've become a master at it, and I really, hey, man, I have respect to you, man, so I really appreciate it. Safe travels, and I salute you, and again, thank you, man. So I finished all the questionnaires. If there's any more questions, just send in. I'll do the same thing in our video recorded. All right, love you, man. Love you, Zai. | |
| + |
| @@ -0,0 +1 @@ | ||
| + | What is the stepping site for where in my mind the stepping site was for connection To make it easy and accessible for those that are already in the stepping community To grow the stepping community and to monetize off the stepping community That was question one, and it was an add to question one, part B. If a newcomer asked me about stepping, what would I say? I would say stepping, Chicago stepping to me is more of a fun way for adults to get out and network and get out and socialize and get out and get exercise in all while having fun listening to great old school music. um it's just all about having fun that's it just relaxing and having fun that's what i will say to a newcomer question one |
| @@ -0,0 +1 @@ | ||
| + | Question two, if somebody open the site, what do I want somebody to do first thing to open the site? Well, first off, if they find the site or they're blessed enough or lucky enough to find it, I want them to be able to navigate through it easily and it be refreshing where it pops out and it grabs their attention and it just makes them want to navigate through things. And I don't want it to be, I want it to be. Older people friendly. I know young people navigate through the internet and stuff easily, but I want to be Easy and navigational friendly I don't want stuff to be small where they can't read it and it's just be trivial Trying to go through different things question two |
| @@ -0,0 +1 @@ | ||
| + | Question three, if I had to pitch it to someone well-respected in the Stepping community, something just like this built for us, what would I say? My first thing would be trying to pitch the legacy that I'm trying to build for Stepping, Chicago Stepping, to continue to try to build that not only national but global brand of Stepping. But sometimes people in the stepping community don't want to grow it like that. They want to keep it still underground because they don't want people to take from it and monetize off of it. Because it was really an underground dance, Chicago stepping. So I would just want to be excited to see that they have a space that they can get on that's high tech because we are in a high tech area now. with so many different avenues and streaming and just different people using their phone so um that somebody's in the high up i'll be like hey we're getting into the high tech space uh even though we're underground uh type of um genre |
| @@ -0,0 +1 @@ | ||
| + | Question four. You asked me about the friend. Question four. His name is Greg Dixon, and he's been in the stepping community for over 50 years. He's a local promoter and a local dance instructor in the Chicago stepping. |
| @@ -0,0 +1 @@ | ||
| + | Question five. Yes, he does have an organization, Unique Steppers of Memphis. That's the organization he's known by. |
| @@ -0,0 +1 @@ | ||
| + | Question six, you asked me, how does he sell tickets to his events? Right now he sells his tickets online. He has his own website along with he goes to different events around the United States, city to city, and he sells tickets that way. And that's how he sells his tickets online through his website. And he goes personally to different cities, to different Chicago stepping events. |
| @@ -0,0 +1 @@ | ||
| + | Question seven. You asked me, was he available or interested in selling through our site? I haven't met with him yet, but he does have his own website and his own designer, so I'm not sure. I'm going to have to have a sit-down and a meeting with him on that. |
| @@ -0,0 +1 @@ | ||
| + | Question eight, what type of tickets? He has a variety of early bird specials. He has a whole city specials, like if you have a group of 10 or 12 or more individual couples. He has the whole slew of different options on buying tickets on his website. |
| @@ -0,0 +1 @@ | ||
| + | Question nine, the price of his tickets varies from different events. So some events be for summer, some events be for the fall, the main event. So it all depends, but the average ticket is between $75 a person to $125. So that's the price range. |
| @@ -0,0 +1 @@ | ||
| + | Question 10, do I want some of the brochures or flyers that he has over on our site? Yes, that would be good to add that to our site. |
| @@ -0,0 +1 @@ | ||
| + | Question 11. Do I want a photo album so people can scroll? Yes, a photo album would be good comparable to Facebook and also add a photo live where sometimes people can bring videos and send videos to the website and to the app. So yes, I would like that. |
| @@ -0,0 +1 @@ | ||
| + | Question 12, yes, I would like for the site to have different things built into the website on the question that you asked on question 12, meaning from literature to vocals to videos to whatever to make it more, yes, I would like that. |
| @@ -0,0 +1 @@ | ||
| + | Question 13. He does print out different pictures, and that would be something unique to have the PDF file added to the website. So, yes. |
| @@ -0,0 +1 @@ | ||
| + | Question 14. Does he work with vendors? Yes. He do have vendors at his site. Yes, he does. Maybe glasses, purses, just a wide range of things. CBD oil, just a whole bunch of different things. Yes. |
| @@ -0,0 +1 @@ | ||
| + | Question 15, he does have host hotels and he does recommend hotels for people that come out of town. |
| @@ -0,0 +1 @@ | ||
| + | number 16 of his website i didn't see anything that we need to duplicate other than um making a website and an app itself that's about it |
| @@ -0,0 +1 @@ | ||
| + | Question 17. The six cities you have are sufficient, and there will be other cities like Little Rock, St. Louis, Huntsville, Alabama, Montgomery, New Orleans, Las Vegas. There's other cities, but the six cities you have right now is a soft launch would be good. |
| @@ -0,0 +1 @@ | ||
| + | Number 19, right now there's nothing else that I would want on the site right now, other vendors or steppers or any other groups right now because I want to test it out first so that we won't infringe on anybody's rights. So at the moment, no. |
| @@ -0,0 +1 @@ | ||
| + | Number 20, there are a lot of DJs that I would like to put up, but with copyright infringement and people brand and name and likeness, they be going to monetize it. So I don't want to put body names on it and then have to come back and pay them because we didn't solicit them or got a contract or something like that. So I want to hold off on this right now, but we could implement something to have it ready to add to it. |
| @@ -0,0 +1 @@ | ||
| + | Number 21, are there any other groups that should have their own page on our site? Again, with copyright infringement of these different groups and LLCs and whatnot, I want to at least meet with some people and, you know, get their permission first, even though it's trying to uplift the stepping community and all that, but I want to hold off on adding people to the site for right now. |
| @@ -0,0 +1 @@ | ||
| + | Number 22, are there any instructors? Man, it's a lot of good instructors out there. But once again, you know, this is, these people are fickle. So you don't want to put people on there without their consent. So adding different groups, adding different people, instructors, DJs, all that will be added later. So we're going to hold off on that for right now. |
| @@ -0,0 +1 @@ | ||
| + | you |
| @@ -0,0 +1 @@ | ||
| + | For number 23 there's nobody else to add to the site right now. |
| @@ -0,0 +1 @@ | ||
| + | Number 24, yes, that is the initial goal for people to be logged in and browse for free, and then we can monetize it in another form or fashion as we go forward. But I was trying to see could we have the app along with the website, one of the main two things to monetize off of it. |
| @@ -0,0 +1 @@ | ||
| + | For number 25, no. We shouldn't charge a fee for promoters to come and boast on the site. No. |
| @@ -0,0 +1 @@ | ||
| + | Number 22, are there any instructors that need to be listed in different cities? Yes. And we can advertise instructors in different cities that people can go and get lessons from or those that are throwing sets. Yes, they can be added. |
| @@ -0,0 +1 @@ | ||
| + | 26 getting a fee or percentage of a cut of somebody else's ticket sales I'm not sure yet put a pin in that if we can find another way to get money that'd be good but I want to take it off the table for right now |
| @@ -0,0 +1 @@ | ||
| + | Number 27, is anything that scares me or I'm hesitant on the money side? I'm up in the air on different things, but I like to monetize it, but I don't want to alienate people, but I want to put something out there that's quality that would be worth paying for or worth them downloading in. Saying, you know what, I'll pay the dollar or two dollars for the app, so. |
| @@ -0,0 +1 @@ | ||
| + | number 28 the website is good the disco ball we can probably do away with the disco ball because it's disco but we're doing chicago stepping so it sends a kind of conflicted message so and then as far as um the coloring as long as lettering letterhead fonts are at normal height or size where people can read it i don't have a problem with the color but i do want to be a good color that pops but not too far bright because i don't want to make it cheesy i want to still make it professional |
| @@ -0,0 +1 @@ | ||
| + | 29, there is no Pacific stepping colors that represent stepping, Chicago stepping as a whole because different cities have different themes. Sometimes it's camouflage. Sometimes it's all white. Sometimes it's crushing cream. Sometimes it's purple. Sometimes it's pink supporting breast cancer. It's all different type of all black. It's just different colors. So there's not one Pacific color that makes me think of Chicago stepping. |
| @@ -0,0 +1 @@ | ||
| + | Number third is an interesting question. When people land on the site, what is one of the first things that I would like for them to see? I would like for them to see that it's just as professional as you see in Apple site or BlackRock site or Fidelia site. It doesn't matter. Just as long as it looks professional and it works and it's functional and it's user-friendly. I just wanted to be professional and user-friendly and secure and safe. So that's what I want when they log on. |
| @@ -0,0 +1 @@ | ||
| + | On 31, do I want the site to have crisp and clean pictures or large up front big pictures? I want it to be kind of mixed a little bit, but I don't want it to be too ghetto fabulous or too out there. I want it to be clean, respectful, and then maybe sprinkle a few big photos here or there. |
| @@ -0,0 +1 @@ | ||
| + | Number 32, the site mainly is for grown-ups. There's a lot of grown-ups. There's not any very, very, very few teenagers or people in their 20s or 30s. Most of this is grown-ups, so I want it to be grown-up. Yeah. |
| @@ -0,0 +1 @@ | ||
| + | Grown up grown up concise and straight to the point a lot of times no fluff and no vagueness and stuff like that I don't want that |
| @@ -0,0 +1 @@ | ||
| + | Number 33 the disco ball. I would like to have it removed. I don't want that as a staple for the ideal Hitting for the site because it's a couple's dance So in Chicago stepping is a man and a woman So I would like it to be at least some type of way world a man has a woman maybe twirling or like you see them holding hands they trying to do the salsa that type of form or some elegant classy ballroom type of form in that emblem if you're going to put something but the ball it has to go |
| @@ -0,0 +1 @@ | ||
| + | Number 34, that was not my ideal goal. Are most of the people going to be on the site on their phones? That was my targeted goal for the app. There's something that people easy access, download, and still be able to conduct business. I don't think too many people are going to be on desktop or on the tablets. But they may, but I don't think it's most of it I was trying to gear toward the cell phone since a lot of people use their cell phones. |
| @@ -0,0 +1 @@ | ||
| + | Number 35, number 35, this is a unique space, so there's nobody else's website that I see that's comparable, compatible, or leading the way right now at this moment, and there's nobody with an app, nobody. |
| @@ -0,0 +1 @@ | ||
| + | Number 36, the only thing I can think of number 36 is it's called Chicago Stepping. And then you do have, you know, line dancing, you know, ballroom dancing, but mainly it's Chicago Stepping. So if we can say Chicago Stepping in some type of way that's clever or unique or classy. we may run with it but we'd have to look at it and take some votes thank you |
| @@ -0,0 +1 @@ | ||
| + | number 37 i like that tagline chicago stepping in your city i like that i like that tagline so we playing around with that that sounds good |
| @@ -0,0 +1 @@ | ||
| + | number 38 different phrases and code words like step and set or classes or lanes or you know things of that nature or some of the things you know |
| @@ -0,0 +1 @@ | ||
| + | Number 39 is there something that I see that immediately Remind me of stepping it'd be a couple things one would be On you a man and a woman Like a silhouette of a man of a woman and they're holding getting ready to start the initial which is the beginning of a dance or DJ booth, you know or you know just People getting ready to end the dance or they're embracing each other. So a lot of different things you see when it comes to Chicago stepping in for ladies is shoes, high heel shoes, stiletto shoes, and men is suits, you know, clean, you know, stepping sharp, coming clean, you know, being clean, your attire, being real clean. So those are the things that you see that remind you immediately of stepping. |
| @@ -0,0 +1 @@ | ||
| + | Number 40, some of the important things I would like to have to show my friend would be the security of it, the aspect of it, the classiness of the website and the app, the continuity of us putting things together that can be functional for different cities, that we stay true to the roots of Chicago, step in. You know we didn't cross the line and mix it up with greek stepping or anything others just stands out on its own alone It's quite a few things so |
| @@ -0,0 +1 @@ | ||
| + | Number 41. First thing I want would be for the website and the app to sync and be uniform in a way that it connects Chicago Stepping to the people. And secondly, I would like to monetize it where I can make money. off of it, but still inform people, enlighten people, and people get something and gain something from it. And third, I still want people to have fun and not be fearful of using the website or the app. Those would be the main three things I would like. And staying true to the culture and the history of Chicago Steppin'. |
| @@ -0,0 +1 @@ | ||
| + | Number 42 right now in this infancy stage I have a few people in mind. I would like to share it with but since I don't have it You know, I don't have it licensed and have all my stuff working nobody copyrighted and Take it over and monetize it and take my idea. I'm gonna hold off. I don't have anybody else to show it to right now |
| @@ -0,0 +1 @@ | ||
| + | Number 43, we've covered a lot of bases. At the moment, there's nothing I can think of at the moment. Everything seems to be on point. There's different things that we're going to revisit and different things we're going to add to, different things we're going to take down. But like I said, I just wanted to stay true to Chicago Steppin' and the history and the legacy of Steppin' and want to be able to monitorize this in a way that benefits me and my family. by way of the website and by way of the app, Chicago Step In app for your phone. |
| @@ -0,0 +1 @@ | ||
| + | Number 44, when I started thinking about this in this infancy stage, I was hesitant. I wasn't sure. But all the work that's been done up into this point thus far, I'm pleased with. And I'm excited to see how we go forth and move forward in this new journey and adventure. |
| @@ -0,0 +1 @@ | ||
| + | Zion, I want to say thank you once again for helping me and creating this and doing this and putting your touch on it. You've become a master at it, and I really, hey, man, I have respect to you, man, so I really appreciate it. Safe travels, and I salute you, and again, thank you, man. So I finished all the questionnaires. If there's any more questions, just send in. I'll do the same thing in our video recorded. All right, love you, man. Love you, Zai. |
| @@ -0,0 +1 @@ | ||
| + | Step an app for Zion. Okay, number one, promotion events. I was talking about having promotional events on the app. I guess what I mean is like anything in that particular city or anything that the people that store in the event want to add as a promotional, add to the app. A space for classes in each city for the app. Each city information on the app for like DJs in the city, stepping DJs for stepping music, Chicago stepping music app, DJs. And then to be able to look at videos from the app. So people have an app and they have videos from that particular city. Let's say if it's Houston, somebody in Houston can send me the video we uploaded to the app. location of dancing sets in the certain cities. So, you know, the app won't be able to find dancing sets in the city. |
| @@ -0,0 +1 @@ | ||
| + | Also on the app, can the app show content for promotions on the app? And then also can the app show special events? So like if there's a special event on the app, can it show that? Anything special, like if something dealing with domestic violence or something dealing with just any type of specialty. And then also, can the app have a live feature where you can go live? And then also, I want the app to be able to have a comment section in case somebody said, well, certain things I don't like about it, they can comment on it. Then also, I would like the app to be able to have event locations in each city, certain cities that we represent, you know, like say, in Chicago, the different event centers in that location. Then also I would like the app to have maybe one or two main attractions in certain cities, wherever the event is being held. And then also the app, each city have a little spot on the app where it can say whatever stepping group is in that particular city. Like when you go out of town to certain cities. each city may have restaurants so we have a couple of restaurants that it can feature nearby whatever set we host or hotel we host you know locations and then also um like most cities throw out of you know different sets in the city like let's say if you're in charlotte what would the closest mall be located to the stepping event or location uh but then um Maybe additional hotels, if that's going to be put on the app. And then I was just throwing out there, each city near wherever the stepping event is, if we can have on the app, like a hospital where it's the closest hospital, just in case, or not in medical. And then anything else dealing with entertainment in the Chicago stepping, we put that on there. So I'm just trying to get it together. That's all right now. Love you, God. |
| @@ -0,0 +1 @@ | ||
| + | One more thing. Also Zion, it's a difference, like I was saying, between Chicago stepping and Greek stepping. So this here is Chicago stepping. That's what it's called. Yeah, so it's Chicago dance, Chicago stepping. It's derived from the two-step swing, bebop. six count, eight count, and ballroom dancing. So it's all that evolved from, you know, the schedule, what is it? The scooter bug. The jitter bug is also in there. So it's an old dance back in the 60s. I was trying to remember, so please forgive me. but uh i don't want a lot of that history of it because i don't know a lot of the history i'm just trying to provide an app that people in different cities if they want to just share it so people just be abreast of what's going on besides having to go to youtube and facebook live to see content that's that's what that's my goal all right |
| @@ -0,0 +1 @@ | ||
| + | One more other thing Zion, here's the thing talking about Chicago stepping. And it says here, Chicago style stepping, often called stepping, originated in the African American community of Chicago's west and south side. And it says during the 40s to 60s, it evolved from the jitterbug and it's popular 50s glide and stride. dance known as the bop so it has history to it but uh this is what i got off the internet but i just wanted to let you know that okay |
| @@ -0,0 +1 @@ | ||
| + | Last but not least, Zion, it's the last thing on the Chicago Step In app. I want to be able to also have on the app where if it's something where I can monetize it, where it can cover maintenance costs of the app, like edits, updates. It might be every other day update or once a week update. And since your office will be doing that, I need to kind of some kind of way. have that included in if people download the app or if it's charged with advertisers or don't charge it if it's free i don't know but i want to add that to the app as well all right thank you that's the last one love you |
| @@ -0,0 +1,35 @@ | ||
| + | # Dad's Plan Videos - Verbatim Transcripts (source of truth) | |
| + | ||
| + | Transcribed 2026-06-28 via Whisper large-v3-turbo on the homelab RTX 3060 (GPU-STT sidecar). | |
| + | Files in recording order. These are dad's exact words - do not paraphrase when citing. | |
| + | ||
| + | --- | |
| + | ||
| + | ## IMG_2444.MOV | |
| + | ||
| + | Step an app for Zion. Okay, number one, promotion events. I was talking about having promotional events on the app. I guess what I mean is like anything in that particular city or anything that the people that store in the event want to add as a promotional, add to the app. A space for classes in each city for the app. Each city information on the app for like DJs in the city, stepping DJs for stepping music, Chicago stepping music app, DJs. And then to be able to look at videos from the app. So people have an app and they have videos from that particular city. Let's say if it's Houston, somebody in Houston can send me the video we uploaded to the app. location of dancing sets in the certain cities. So, you know, the app won't be able to find dancing sets in the city. | |
| + | ||
| + | --- | |
| + | ||
| + | ## IMG_2445.MOV | |
| + | ||
| + | Also on the app, can the app show content for promotions on the app? And then also can the app show special events? So like if there's a special event on the app, can it show that? Anything special, like if something dealing with domestic violence or something dealing with just any type of specialty. And then also, can the app have a live feature where you can go live? And then also, I want the app to be able to have a comment section in case somebody said, well, certain things I don't like about it, they can comment on it. Then also, I would like the app to be able to have event locations in each city, certain cities that we represent, you know, like say, in Chicago, the different event centers in that location. Then also I would like the app to have maybe one or two main attractions in certain cities, wherever the event is being held. And then also the app, each city have a little spot on the app where it can say whatever stepping group is in that particular city. Like when you go out of town to certain cities. each city may have restaurants so we have a couple of restaurants that it can feature nearby whatever set we host or hotel we host you know locations and then also um like most cities throw out of you know different sets in the city like let's say if you're in charlotte what would the closest mall be located to the stepping event or location uh but then um Maybe additional hotels, if that's going to be put on the app. And then I was just throwing out there, each city near wherever the stepping event is, if we can have on the app, like a hospital where it's the closest hospital, just in case, or not in medical. And then anything else dealing with entertainment in the Chicago stepping, we put that on there. So I'm just trying to get it together. That's all right now. Love you, God. | |
| + | ||
| + | --- | |
| + | ||
| + | ## IMG_2446.MOV | |
| + | ||
| + | One more thing. Also Zion, it's a difference, like I was saying, between Chicago stepping and Greek stepping. So this here is Chicago stepping. That's what it's called. Yeah, so it's Chicago dance, Chicago stepping. It's derived from the two-step swing, bebop. six count, eight count, and ballroom dancing. So it's all that evolved from, you know, the schedule, what is it? The scooter bug. The jitter bug is also in there. So it's an old dance back in the 60s. I was trying to remember, so please forgive me. but uh i don't want a lot of that history of it because i don't know a lot of the history i'm just trying to provide an app that people in different cities if they want to just share it so people just be abreast of what's going on besides having to go to youtube and facebook live to see content that's that's what that's my goal all right | |
| + | ||
| + | --- | |
| + | ||
| + | ## IMG_2447.MOV | |
| + | ||
| + | One more other thing Zion, here's the thing talking about Chicago stepping. And it says here, Chicago style stepping, often called stepping, originated in the African American community of Chicago's west and south side. And it says during the 40s to 60s, it evolved from the jitterbug and it's popular 50s glide and stride. dance known as the bop so it has history to it but uh this is what i got off the internet but i just wanted to let you know that okay | |
| + | ||
| + | --- | |
| + | ||
| + | ## IMG_2448.MOV | |
| + | ||
| + | Last but not least, Zion, it's the last thing on the Chicago Step In app. I want to be able to also have on the app where if it's something where I can monetize it, where it can cover maintenance costs of the app, like edits, updates. It might be every other day update or once a week update. And since your office will be doing that, I need to kind of some kind of way. have that included in if people download the app or if it's charged with advertisers or don't charge it if it's free i don't know but i want to add that to the app as well all right thank you that's the last one love you | |
| + |
| @@ -1,9 +1,11 @@ | ||
| # Stepping | ||
| - | A community app for Divine Nine (NPHC) stepping: step shows, strolls, and | |
| - | competitions; classes, DJs, and promoters; chapters and step teams; live | |
| - | streaming; and per-city travel info for traveling to shows. Web first, then | |
| - | iOS and Android. Launching in Atlanta and Washington, DC. | |
| + | A community app for Chicago stepping, the smooth soulful partner dance from | |
| + | Chicago's West and South sides. One place, organized by city, where steppers | |
| + | post and find what's going on: sets, weekenders, classes, DJs, promoters, | |
| + | stepping groups, videos, and per-city travel info for steppers heading out of | |
| + | town. Web first, then iOS and Android. Chicago is the flagship; also launching | |
| + | in Atlanta, Detroit, Houston, Charlotte, and Washington, DC. | |
| - Idea capture: `STEPPING_APP_IDEAS.md` | ||
| - Full plan and roadmap: `STEPPING_APP_IMPLEMENTATION_PLAN.md` |
| @@ -1,6 +1,6 @@ | ||
| # Stepping App: Setup | ||
| - | Monorepo for the Stepping (Divine Nine step-show) community app. Web app first, | |
| + | Monorepo for the Stepping (Chicago stepping) community app. Web app first, | |
| native apps later. This guide is the path from a fresh clone to a running dev | ||
| server, then to a deploy. | ||
| @@ -28,7 +28,7 @@ pnpm install | ||
| ```bash | ||
| pnpm db:generate # build SQL migrations from packages/db/src/schema.ts | ||
| pnpm db:migrate # apply them (or: pnpm db:push for a quick first sync) | ||
| - | pnpm db:seed # load the Divine Nine + Atlanta and DC cities | |
| + | pnpm db:seed # load the dev user + launch cities (Chicago flagship + 5) | |
| ``` | ||
| ## 4. Auth | ||
| @@ -57,7 +57,7 @@ apps/web Next.js 16 app (App Router, Tailwind v4) | ||
| packages/db Drizzle schema + client + seed (Postgres/Supabase) | ||
| packages/api tRPC routers + Zod inputs + service seams | ||
| packages/types Shared string-union types (mirror the DB enums) | ||
| - | packages/config App constants (launch cities, Divine Nine reference) | |
| + | packages/config App constants (launch cities, dev user) | |
| packages/ui Shared UI helpers (shadcn/ui lands here) | ||
| ``` | ||
| @@ -68,12 +68,13 @@ Done in this scaffold (plan checklist items 1, 3, 7, 15): | ||
| - Monorepo + Next.js web app, builds clean. | ||
| - Full Drizzle schema: Ownership + Moderatable mixins, all entities, dormant | ||
| role / trust / monetization columns. | ||
| - | - tRPC layer: city, event, video, comment, flag, promoter, dj, stepTeam, | |
| - | chapter, class, and a minimal admin moderation router. Open posting with | |
| + | - tRPC layer: city, event, video, comment, flag, promoter, dj, steppingGroup, | |
| + | class, and a minimal admin moderation router. Open posting with | |
| ownership stamping and optimistic publish. | ||
| - `MediaService` and `ModerationService` seams (no-op today; Mux is a Phase 2 | ||
| adapter, not a refactor). | ||
| - | - Reference seed (Divine Nine, Atlanta, DC). | |
| + | - Reference seed (launch cities: Chicago flagship + Atlanta, Detroit, Houston, | |
| + | Charlotte, DC). | |
| Pending (need your accounts or are later checklist items): | ||
| @@ -1,42 +1,48 @@ | ||
| - | # Stepping App - Dad's Idea List (structured) | |
| + | # Chicago Stepping App: Dad's Idea List (structured) | |
| *Source: dad's handwritten list (`IMG_1055.jpg`), transcribed 2026-06-24.* | ||
| - | *Verbatim items preserved; structured by theme below to seed idea-mapping.* | |
| + | *Authoritative definition and scope corrected 2026-06-28 from dad's voice memos* | |
| + | *(`Plan-Transcribe/IMG_2444-2448.MOV`); see `DADS_PLAN_SEQUENCED.md`.* | |
| + | *Verbatim items preserved; structured by theme below to seed feature-mapping.* | |
| --- | ||
| ## What stepping is (authoritative definition) | ||
| - | Stepping is a percussive dance art form in which the performer uses their entire body as an instrument to create complex, layered rhythms through synchronized movements like stomps, claps, strikes, and spoken word. Developed by African American students at Historically Black Colleges and Universities (HBCUs) in the early 1900s, it serves as a means of expressing pride, unity, and storytelling within Black fraternities and sororities, particularly the Divine Nine. | |
| - | > Scope note: this is **HBCU / Divine Nine (NPHC) stepping** - step shows, strolls, and step competitions - NOT Chicago "steppin'" partner dancing. Read every feature below through that lens. "Sets" / "events" map to **step shows, strolls, homecoming showcases, and competitions** (e.g. Atlanta Greek Picnic, HBCU homecoming step shows). | |
| + | **Chicago stepping** (often just "steppin'") is a smooth, gliding partner dance that originated in the African American community of Chicago's West and South sides. It evolved during the 1940s-60s out of the jitterbug and the 1950s glide-and-stride dance known as **the bop**, drawing on the two-step, swing, bebop, six-count, eight-count, and ballroom. It is danced in the pocket of soulful music, lead and follow, on a 6-/8-count. (Dad's words, `IMG_2446` + `IMG_2447`.) | |
| + | ||
| + | > Scope note: this is **Chicago stepping**, the partner dance, NOT Greek / Divine Nine (NPHC) stepping. There are no fraternities, sororities, chapters, step shows, strolls, Greek letters, or stomp-clap percussion here. Read every feature through that lens. The core social object is a **"set"**: a partner-dance social gathering in a city. Dad's words (`IMG_2446`): *"it's a difference... between Chicago stepping and Greek stepping. So this here is Chicago stepping."* | |
| ## One-liner (what this app is) | ||
| - | A **Divine Nine stepping community app**: discover step shows, strolls, and competitions; find classes/workshops, DJs, and promoters; connect with fraternity/sorority step teams and chapters in each city; watch and **live-stream** performances; and get city travel info (hotels, restaurants, malls, attractions, hospitals) for traveling to out-of-town shows and homecomings. Open community posting with comments. | |
| + | ||
| + | A **Chicago stepping community app**: find sets, weekenders, classes, and workshops in each city; see who's DJing and who's promoting; connect with the stepping groups, DJs, promoters/hosts, and classes in town; watch and **live-stream** sets; and get city travel info (hotels, restaurants, malls, attractions, hospitals) for traveling to out-of-town sets and weekenders. Open community posting with comments. Dad's north star (`IMG_2446`): *"one place, organized by city, where the Chicago-stepping community posts and finds what's going on,"* replacing scattered YouTube and Facebook Live. | |
| --- | ||
| - | ## Decisions & Scope (confirmed 2026-06-24) | |
| + | ## Decisions & Scope (confirmed 2026-06-24; cities re-confirmed 2026-06-28) | |
| + | ||
| - **Platform / sequencing:** **Web app first** (responsive web / PWA), then native **iOS (App Store)** and **Android (Google Play)**. Build the web app first; package native afterward. | ||
| - | - **Live feature:** **Live streaming** (live video of step shows / events). | |
| + | - **Live feature:** **Live streaming** (live video of sets / weekenders / events). | |
| - **Content posting:** **Anyone can post for now** (open contribution, no gated roles in MVP). Moderation/roles deferred. | ||
| - | - **Monetization:** **Undetermined for now** - design so it can be added later (promoter listings, ticketing, ads), but no paywall in MVP. | |
| - | - **Launch cities:** start with the strongest Divine Nine / step-culture markets (see research below). | |
| + | - **Monetization:** **Undetermined for now**: design so it can be added later (promoter listings, ticketing, ads), but no paywall in MVP. | |
| + | - **Launch cities:** **Chicago (flagship), Atlanta, Detroit, Houston, Charlotte, Washington-DC/DMV**: strong steppin' scenes; dad himself names Houston and Charlotte. | |
| ## Launch-city research (for "look into it for me") | ||
| - | Ranked by Divine Nine density + scale of recurring step-show/stroll events: | |
| - | 1. **Atlanta, GA - #1 launch market.** The epicenter of step culture. Atlanta Greek Picnic (AGP) is billed as the largest step competition in the Southeast and has drawn 350,000+ Divine Nine members with an estimated ~$25M annual economic impact. Home to the Atlanta University Center (Morehouse, Spelman, Clark Atlanta) and the densest D9 alumni base in the country. | |
| - | 2. **Washington, DC (DMV) - #2.** Howard University held the first formal Greek step show (1976) and remains a flagship; dense Divine Nine population across the DMV. | |
| - | 3. **Houston, TX.** Texas Southern University + Prairie View A&M nearby; large metro and big D9 alumni presence. | |
| - | 4. **Greensboro / Durham, NC (Triangle).** NC A&T (largest HBCU in the U.S.) and NC Central - major step/stroll culture, big homecomings. | |
| + | Chicago stepping's center of gravity is **Chicago**, where the dance was born and where the deepest sets, weekenders, and classes still run. From there the scene spreads to a handful of cities with active steppin' communities and recurring out-of-town weekenders: | |
| - | **Phase-2 expansion candidates:** New Orleans (Southern, Dillard, Xavier), Nashville (Tennessee State), Jackson MS (Jackson State), and **Memphis, TN** - useful as the home/test market (LeMoyne-Owen + strong local D9 alumni), even if smaller than the top tier. | |
| + | 1. **Chicago, IL: flagship and home market.** The dance originated here; the densest sets, DJs, instructors, and weekenders are here. Seed this city deepest. | |
| + | 2. **Atlanta, GA.** Large, active steppin' community and a regular stop for traveling weekenders. | |
| + | 3. **Detroit, MI.** Strong Midwest steppin' scene with its own set culture. | |
| + | 4. **Houston, TX.** Dad names Houston directly (`IMG_2444`); active sets and an out-of-town draw. | |
| + | 5. **Charlotte, NC.** Dad names Charlotte directly (`IMG_2445`, "if you're in Charlotte"); a real traveling-stepper market. | |
| + | 6. **Washington, DC / DMV.** Long-running steppin' presence and a weekender destination. | |
| - | **Recommendation:** launch with **Atlanta + DC** (highest density + the two biggest event anchors), add **Houston** and **NC Triangle** shortly after, and use **Memphis** as the local pilot/test market for the web app. | |
| + | **Recommendation:** launch with all six, with **Chicago** as the flagship and primary dogfood market. Seed Chicago first and fullest; stand the other five up as their event/promoter/DJ data is gathered. (City launch is data-entry-bound, not engineering-bound.) | |
| --- | ||
| - | ## Dad's list - verbatim (two columns as written) | |
| + | ## Dad's list: verbatim (two columns as written) | |
| | Left column | Right column | | ||
| |---|---| | ||
| @@ -52,82 +58,85 @@ Ranked by Divine Nine density + scale of recurring step-show/stroll events: | ||
| --- | ||
| - | ## Structured by theme (for feature mapping) | |
| + | ## Structured by theme (mapped to dad's A-Q feature list) | |
| + | ||
| + | Dad's full feature list (A-Q) is the source of truth and lives in `DADS_PLAN_SEQUENCED.md`. The themes below carry the A-Q tags so nothing drops. | |
| **1. Core dance content** | ||
| - | - Classes - steppin' lessons (find/schedule/list) | |
| - | - Steppin' groups in each city - local communities/crews | |
| - | - Where to find dancing sets (locations) - the actual dance gatherings | |
| + | - **Classes `[B]`**: steppin' lessons in each city (instructor, schedule, location). | |
| + | - **Stepping groups `[K]`** in each city: local crews/communities (not chapters, not "step teams"). | |
| + | - **Sets / dancing-set locations `[E]`**: where to find the sets (the partner-dance gatherings). | |
| **2. Events** | ||
| - | - Events | |
| - | - Special events | |
| - | - Promoters - the people/orgs who throw sets & weekenders | |
| - | - Concert promotions - *(dad's open question: "Can the app show concert promotions?")* | |
| - | - Event locations | |
| + | - **Events / promotions `[A]`**: upcoming sets, promotional posts. | |
| + | - **Special events `[F]`**: special / cause events (dad names domestic-violence awareness as an example). | |
| + | - **Promoters / hosts**: the people who throw sets and weekenders (implied throughout; they are the profiles behind the events). | |
| + | - **Concert promotions**: *(dad's open question: "Can the app show concert promotions?")* | |
| + | - **Event locations / venues `[I]`**: the actual event centers in that city. | |
| **3. Music** | ||
| - | - DJ's - stepping DJs (rosters, who's playing where) | |
| + | - **DJs `[C]`**: stepping DJs / Chicago-stepping music DJs (who's spinning, where). | |
| **4. Media / Live** | ||
| - | - Look @ videos - tutorials, event recaps, performances | |
| - | - App should have a live feature - live-streamed sets / live event coverage | |
| + | - **Videos `[D]`**: per-city user-shared video ("somebody in Houston can send me the video"), tutorials, recaps, set footage. | |
| + | - **Live `[G]`**: go live in-app for sets / event coverage. | |
| **5. Community** | ||
| - | - Comment section - under events/videos/sets | |
| + | - **Comments `[H]`**: under sets / events / videos. | |
| - | **6. Per-city travel guide (for traveling steppers / weekenders)** | |
| - | - Cities' main attraction | |
| - | - Restaurants in each city | |
| - | - Mall / shopping each city | |
| - | - Additional hotels (location) | |
| - | - Hospitals / near hotel set (event) - safety/medical near the venue | |
| - | - Entertainment (per city) | |
| + | **6. Per-city travel guide (the "traveling stepper", items I-P)** | |
| + | Dad, repeated: *"when you go out of town to certain cities."* | |
| + | - **City main attraction(s) `[J]`**: "one or two main attractions" per city. | |
| + | - **Restaurants `[L]`**: a couple featured near the set / host hotel. | |
| + | - **Mall / shopping `[M]`**: "if you're in Charlotte, what's the closest mall to the event?" | |
| + | - **Hotels `[N]`**: host hotel + additional nearby hotels. | |
| + | - **Hospitals `[O]`**: closest hospital "just in case" (safety/medical near the venue). | |
| + | - **Entertainment `[P]`**: other entertainment in the city tied to Chicago stepping. | |
| - | **7. General / uncategorized** | |
| - | - Entertainment | |
| + | **7. Sustainability** | |
| + | - **Monetization for upkeep `[Q]`**: ads, paid download, or free, to cover maintenance/edits/updates (frequent, every-few-days to weekly, run by the dev office). | |
| --- | ||
| - | ## Open questions - resolved vs remaining | |
| + | ## Open questions: resolved vs remaining | |
| - | **Resolved (2026-06-24):** | |
| - | - Live feature → **live streaming**. | |
| - | - Who posts content → **anyone, for now** (open posting). | |
| - | - Monetization → **undetermined for now** (deferrable). | |
| - | - Platform → **web app first**, then iOS + Android. | |
| - | - Launch cities → **Atlanta + DC first**, then Houston + NC Triangle; Memphis as pilot (see research above). | |
| + | **Resolved (2026-06-24; cities 2026-06-28):** | |
| + | - Live feature -> **live streaming**. | |
| + | - Who posts content -> **anyone, for now** (open posting). | |
| + | - Monetization -> **undetermined for now** (deferrable, but modeled from day one). | |
| + | - Platform -> **web app first**, then iOS + Android. | |
| + | - Launch cities -> **Chicago (flagship), Atlanta, Detroit, Houston, Charlotte, Washington-DC/DMV.** | |
| **Still open (for the planner / dad to decide):** | ||
| - | 1. **Concert/event promotions** - aggregate/display promos, or let posters add their own? (default to open posting per the decision above) | |
| - | 2. **Per-city travel info** - curated guide vs pulled live from APIs (Google Places, hotels). How much steppin'-specific vs general? | |
| - | 3. **Comment moderation** - open posting means abuse/spam risk; what is the minimum moderation for MVP? | |
| - | 4. **Identity/verification** - any verification for chapters/step teams/promoters, or fully open at launch? | |
| + | 1. **Concert/event promotions**: aggregate/display promos, or let posters add their own? (Default to open posting per the decision above.) | |
| + | 2. **Per-city travel info**: curated guide vs pulled live from APIs (Google Places, hotels). How much steppin'-specific vs general? | |
| + | 3. **Comment moderation**: open posting means abuse/spam risk; what is the minimum moderation for MVP? | |
| + | 4. **Identity/verification**: any verification for promoters/stepping groups/DJs, or fully open at launch? | |
| --- | ||
| ## Seed feature map (drop into Claude to expand) | ||
| ``` | ||
| - | STEPPING APP | |
| + | CHICAGO STEPPING APP | |
| ├─ DISCOVER (per city) | ||
| - | │ ├─ Sets / dance gatherings (locations, when, who) | |
| - | │ ├─ Classes (teachers, schedule, price) | |
| - | │ ├─ Steppin' groups / crews | |
| + | │ ├─ Sets / dancing-set locations (where, when, who) | |
| + | │ ├─ Classes (instructor, schedule, price) | |
| + | │ ├─ Stepping groups / crews | |
| │ └─ DJs (who's spinning, where) | ||
| ├─ EVENTS | ||
| - | │ ├─ Upcoming events & special events | |
| - | │ ├─ Promoters (profiles, their events) | |
| - | │ ├─ Concert promotions (?) | |
| - | │ └─ Event locations | |
| + | │ ├─ Sets, weekenders, special events | |
| + | │ ├─ Promotions / concert promos | |
| + | │ ├─ Promoters / hosts (profiles, their events) | |
| + | │ └─ Event locations / venues | |
| ├─ MEDIA | ||
| - | │ ├─ Videos (tutorials, recaps, performances) | |
| + | │ ├─ Videos (tutorials, recaps, set footage) | |
| │ └─ LIVE feature (sets / event coverage) | ||
| - | ├─ TRAVEL (per city, for weekenders) | |
| - | │ ├─ Hotels (+ additional hotels near set) | |
| + | ├─ TRAVEL (per city, for the traveling stepper) | |
| + | │ ├─ Hotels (+ additional hotels near the set) | |
| │ ├─ Restaurants | ||
| │ ├─ Malls / shopping | ||
| │ ├─ City main attraction + entertainment | ||
| │ └─ Hospitals (near venue/hotel) | ||
| └─ COMMUNITY | ||
| - | └─ Comment sections (events / sets / videos / classes) | |
| + | └─ Comments (sets / events / videos / classes) | |
| ``` |
| @@ -1,6 +1,8 @@ | ||
| - | # Stepping App - Implementation Plan | |
| + | # Chicago Stepping App: Implementation Plan | |
| - | The Stepping App is a city-first community platform for HBCU / Divine Nine (NPHC) stepping - the percussive step-show art form (step shows, strolls, competitions, classes, teams, DJs, promoters) - shipping web-first as an installable PWA, then native iOS/Android, with live streaming of step shows as the headline feature. The build is one TypeScript spine (Next.js + tRPC + Zod + Drizzle over Supabase Postgres) with the hard, undifferentiated media work (transcode, adaptive delivery, low-latency live, auto-VOD) bought from a single managed provider behind a swappable interface. Everything users browse is scoped by `city`, anyone with an account can post (optimistic publish, soft moderation), and roles/verification/monetization are pre-modeled as dormant columns and pluggable services so they switch on later without a migration or rewrite. Launch sequence is Atlanta + DC first, then Houston + NC Triangle, with Memphis as the home-market pilot. | |
| + | The Chicago Stepping App is a city-first community platform for **Chicago stepping**, the smooth, gliding, soulful 6-/8-count partner dance that originated on Chicago's West and South sides (sets, weekenders, classes, workshops, competitions, DJs, promoters/hosts, stepping groups). It ships web-first as an installable PWA, then native iOS/Android, with **live streaming of sets** as the headline feature. The build is one TypeScript spine (Next.js + tRPC + Zod + Drizzle over Supabase Postgres) with the hard, undifferentiated media work (transcode, adaptive delivery, low-latency live, auto-VOD) bought from a single managed provider behind a swappable interface. Everything users browse is scoped by `city`, anyone with an account can post (optimistic publish, soft moderation), and roles/verification/monetization are pre-modeled as dormant columns and pluggable services so they switch on later without a migration or rewrite. Launch cities are **Chicago (flagship), Atlanta, Detroit, Houston, Charlotte, and Washington-DC/DMV**, with Chicago seeded deepest as the home of the dance and the primary dogfood market. | |
| + | ||
| + | This is **Chicago stepping, not Divine Nine / Greek / NPHC stepping**. There are no fraternities, sororities, chapters, step shows, strolls, or Greek letters anywhere in the product. The core social object is a **"set"**: a partner-dance social gathering in a city. See `DADS_PLAN_SEQUENCED.md` and `Plan-Transcribe/transcripts/_ALL_TRANSCRIPTS.md` for dad's verbatim words and the A-Q feature list. | |
| --- | ||
| @@ -10,12 +12,12 @@ One decisive stack. No optionality at the top level; optionality lives behind in | ||
| | Layer | Choice | Why | | ||
| |---|---|---| | ||
| - | | **Web / PWA** | **Next.js 15 (App Router) + React + TypeScript + Tailwind + shadcn/ui**, installable PWA via `next-pwa` | Server-rendered discovery pages are Google-indexable (critical for "Atlanta step show 2026" search), fast on mobile data, app-like on day one | | |
| + | | **Web / PWA** | **Next.js 15 (App Router) + React + TypeScript + Tailwind + shadcn/ui**, installable PWA via `next-pwa` | Server-rendered discovery pages are Google-indexable (critical for "Chicago steppin' set 2026" / "Atlanta stepping this weekend" search), fast on mobile data, app-like on day one | | |
| | **API** | **tRPC** colocated in the Next app, with **Zod** schemas in a shared package | End-to-end type safety into web and future native; no separate API service to run for MVP | | ||
| | **Async / queue** | **Inngest** (or Upstash QStash) | Serverless, scale-to-zero; handles webhooks fan-out, push, scheduled city-data refresh | | ||
| | **Database** | **Supabase Postgres + PostGIS**, **Drizzle ORM** | DB + Auth + Storage + RLS + real geo ("near venue") in one managed service; typed migrations from the shared package | | ||
| | **Auth** | **Supabase Auth** (email magic-link + Google + Apple OAuth) | Apple sign-in is mandatory for the future iOS build; JWTs flow straight into tRPC and RLS | | ||
| - | | **Image storage** | **Supabase Storage** (S3-compatible, behind CDN) | Flyers, avatars, team/profile photos; RLS-aware, cheap | | |
| + | | **Image storage** | **Supabase Storage** (S3-compatible, behind CDN) | Flyers, avatars, profile/group photos; RLS-aware, cheap | | |
| | **Video + Live (VOD, transcode, RTMP/SRT/WHIP ingest, LL-HLS, auto-record)** | **Mux** | See decision below | | ||
| | **Live chat / realtime** | **Supabase Realtime** | Already on Supabase; live chat, viewer counts, reaction bursts with no websocket server to run | | ||
| | **Text moderation** | **OpenAI Moderation API** (free) + slur/profanity denylist | Comments, captions, descriptions, chat | | ||
| @@ -24,17 +26,17 @@ One decisive stack. No optionality at the top level; optionality lives behind in | ||
| | **Web hosting** | **Vercel** | First-party Next.js host: preview deploys, edge CDN, image optimization, zero-config | | ||
| | **Data plane** | **Supabase Cloud** | Don't self-host the DB for an always-on consumer app | | ||
| | **Homelab role** | Staging, scheduled **city-data ingest** job, backups, documented self-host fallback | Keep production user-facing infra in the cloud so it survives a homelab reboot | | ||
| - | | **Native (Phase 2)** | **Expo (React Native)** in a **Turborepo monorepo** sharing `packages/` | Share the logic, fork the shell - see Architecture | | |
| + | | **Native (later)** | **Expo (React Native)** in a **Turborepo monorepo** sharing `packages/` | Share the logic, fork the shell, see Architecture | | |
| ### Decision: Mux for the media + live plane (resolving Mux vs Cloudflare Stream) | ||
| The architecture contributor chose **Mux**; the media contributor leaned **Cloudflare Stream** on predictable per-minute pricing. **We pick Mux**, decisively, for three reasons: | ||
| - | 1. **DX for a solo operator is the binding constraint, not the per-minute bill.** Pre-monetization volume is low; the cost delta between providers is single-digit dollars per show. Mux's API ergonomics (clean asset IDs, signed playback, `stream.active`/`asset.ready` webhooks, auto-VOD on stream end, storyboards/thumbnails) save real build-and-debug time, which is the scarce resource here. | |
| - | 2. **One provider covers VOD + Live + auto-record + WHIP browser ingest + RTMP/SRT pro ingest** - the same single integration both contributors wanted, just landing on the better-DX vendor. | |
| + | 1. **DX for a solo operator is the binding constraint, not the per-minute bill.** Pre-monetization volume is low; the cost delta between providers is single-digit dollars per set. Mux's API ergonomics (clean asset IDs, signed playback, `stream.active`/`asset.ready` webhooks, auto-VOD on stream end, storyboards/thumbnails) save real build-and-debug time, which is the scarce resource here. | |
| + | 2. **One provider covers VOD + Live + auto-record + WHIP browser ingest + RTMP/SRT pro ingest**: the same single integration both contributors wanted, just landing on the better-DX vendor. | |
| 3. **The cost concern is fully neutralized by the seam.** All media access goes through one internal `MediaService` interface. Mux today; Cloudflare Stream (cheaper at scale) or a homelab MediaMTX/FFmpeg fallback tomorrow is a one-adapter change, not an app change. We document Cloudflare as the at-scale swap target the moment delivery cost becomes the dominant line item (which lands after ticketing/ads revenue exists). | ||
| - | Cost posture is unchanged either way: **zero idle cost**, usage-based, no always-on encoding fleet - perfect for a spiky homecoming-night events calendar. | |
| + | Cost posture is unchanged either way: **zero idle cost**, usage-based, no always-on encoding fleet, perfect for a spiky weekender-night events calendar. | |
| --- | ||
| @@ -43,142 +45,134 @@ Cost posture is unchanged either way: **zero idle cost**, usage-based, no always | ||
| **Shape:** a single TypeScript codebase that is web-first now and native-ready later, with managed cloud for everything user-facing and the homelab reserved for staging, scheduled jobs, and documented fallback. | ||
| ``` | ||
| - | ┌─────────────────────────────────────────────┐ | |
| + | ┌─────────────────────────────────────────────┐ | |
| Browser / PWA ───────▶│ Next.js 15 on Vercel (SSR discovery pages) │ | ||
| (Expo RN later) ─────▶│ + tRPC API routes + shared packages/ core │ | ||
| - | └───────┬───────────────┬───────────────┬──────┘ | |
| - | │ Drizzle/RLS │ MediaService │ events | |
| - | ▼ ▼ ▼ | |
| - | ┌────────────────────┐ ┌───────────┐ ┌────────────┐ | |
| - | │ Supabase Postgres │ │ Mux │ │ Inngest │ | |
| - | │ +PostGIS +Auth │ │ Video+Live│ │ queue/cron │ | |
| - | │ +Storage +Realtime │ │ (RTMP/SRT │ │ (webhooks, │ | |
| - | └────────────────────┘ │ /WHIP → │ │ push, │ | |
| - | ▲ │ LL-HLS, │ │ ingest) │ | |
| - | │ │ auto-VOD) │ └─────┬──────┘ | |
| - | ┌─────────┴────────┐ └─────┬─────┘ │ | |
| - | │ Homelab CT (215) │ │ │ | |
| - | │ staging · city │◀── Google Places ───────┘ | |
| - | │ ingest · backups │ (cached → Postgres) | |
| - | │ video fallback │ | |
| - | └──────────────────┘ | |
| + | └───────┬───────────────┬───────────────┬──────┘ | |
| + | │ Drizzle/RLS │ MediaService │ events | |
| + | ▼ ▼ ▼ | |
| + | ┌────────────────────┐ ┌───────────┐ ┌────────────┐ | |
| + | │ Supabase Postgres │ │ Mux │ │ Inngest │ | |
| + | │ +PostGIS +Auth │ │ Video+Live│ │ queue/cron │ | |
| + | │ +Storage +Realtime │ │ (RTMP/SRT │ │ (webhooks, │ | |
| + | └────────────────────┘ │ /WHIP → │ │ push, │ | |
| + | ▲ │ LL-HLS, │ │ ingest) │ | |
| + | │ │ auto-VOD) │ └─────┬──────┘ | |
| + | ┌─────────┴────────┐ └─────┬─────┘ │ | |
| + | │ Homelab │ │ │ | |
| + | │ staging · city │◀── Google Places ───────┘ | |
| + | │ ingest · backups │ (cached → Postgres) | |
| + | │ video fallback │ | |
| + | └──────────────────┘ | |
| ``` | ||
| ### Key architectural decisions | ||
| - | - **Monorepo from day one (Turborepo).** `apps/web` (Next.js) ships now. `packages/` holds the API client, types, Zod schemas, Drizzle schema, business logic, and design tokens. `apps/mobile` (Expo) is added in Phase 2 and consumes the **same** `packages/` core. **We share the logic, not the literal UI** - no React Native Web / Solito / Flutter compromise, because the web app's SSR/SEO value is too high to sacrifice for pixel-for-pixel code share. Two view layers, one brain. | |
| - | - **Read-heavy discovery surfaces are React Server Components** (events, promoters, city guides, team/DJ profiles) so they are crawlable and fast. Interactive surfaces (post forms, live player + chat, search) are client components. | |
| - | - **The public API stays serverless** so it scales to zero between events and absorbs homecoming-night spikes without capacity planning. Heavy/async work (Mux webhooks → asset state transitions, push fan-out, scheduled city-data refresh) runs through Inngest, promoted to a dedicated homelab worker only if volume ever demands it. | |
| - | - **Live streaming is fully managed.** A broadcaster goes live via (a) **pro path:** an issued RTMP/SRT stream key into OBS / a venue encoder, or (b) **casual path:** a browser/phone **WHIP (WebRTC)** "Go Live" button so any attendee can stream without OBS. Mux transcodes both to a low-latency **LL-HLS** ladder (3-8s glass-to-glass, the right tier for watch-and-chat). Viewers play LL-HLS with **hls.js** (native HLS on Safari/iOS). On disconnect, Mux **auto-records to a VOD asset** that drops into the Videos feed through the normal moderation pipeline. Live chat runs over Supabase Realtime keyed to the stream id, not a hand-built websocket server. | |
| + | - **Monorepo from day one (Turborepo).** `apps/web` (Next.js) ships now. `packages/` holds the API client, types, Zod schemas, Drizzle schema, business logic, and design tokens. `apps/mobile` (Expo) is added later and consumes the **same** `packages/` core. **We share the logic, not the literal UI**: no React Native Web / Solito / Flutter compromise, because the web app's SSR/SEO value is too high to sacrifice for pixel-for-pixel code share. Two view layers, one brain. | |
| + | - **Read-heavy discovery surfaces are React Server Components** (sets/events, promoters, city guides, stepping-group/DJ profiles) so they are crawlable and fast. Interactive surfaces (post forms, live player + chat, search) are client components. | |
| + | - **The public API stays serverless** so it scales to zero between sets and absorbs weekender-night spikes without capacity planning. Heavy/async work (Mux webhooks → asset state transitions, push fan-out, scheduled city-data refresh) runs through Inngest, promoted to a dedicated homelab worker only if volume ever demands it. | |
| + | - **Live streaming is fully managed (Phase 4).** A broadcaster goes live via (a) **pro path:** an issued RTMP/SRT stream key into OBS / a venue encoder, or (b) **casual path:** a browser/phone **WHIP (WebRTC)** "Go Live" button so any attendee can stream without OBS. Mux transcodes both to a low-latency **LL-HLS** ladder (3-8s glass-to-glass, the right tier for watch-and-chat). Viewers play LL-HLS with **hls.js** (native HLS on Safari/iOS). On disconnect, Mux **auto-records to a VOD asset** that drops into the Videos feed through the normal moderation pipeline. Live chat runs over Supabase Realtime keyed to the stream id, not a hand-built websocket server. | |
| - **Provider-agnostic seams** isolate every buy-vs-build vendor: `MediaService` (upload, asset, live, terminate, webhook, sign token) and `ModerationService` (checkText, checkAsset, recordReport, evaluateThresholds). Swapping Mux → Cloudflare/self-host, or one moderation vendor for another, is a single adapter. | ||
| - | - **Roles, moderation, and monetization are pre-wired, dormant.** RLS policies and `role`/`status`/`verified_at` columns exist now (allow-all today); Stripe and listing/ticketing tables slot into tRPC later. They are switches, not migrations. | |
| + | - **Roles, moderation, and monetization are pre-wired, dormant.** RLS policies and `role`/`status`/`verifiedAt` columns exist now (allow-all today); Stripe and listing/ticketing tables slot into tRPC later. They are switches, not migrations. | |
| --- | ||
| ## Data Model | ||
| - | Consolidated from the data-model and roadmap contributions. Postgres (Supabase), Drizzle, PostGIS. The reconciliation: we keep **explicit typed entities** (Event, Video, Promoter, etc.) rather than one generic `listing` blob - but every user-creatable entity shares two **mixins** so they behave uniformly (this captures the roadmap's "one content model" intent without losing query clarity and relational integrity). | |
| + | Matches the schema actually in `packages/db/src/schema.ts` and the unions in `packages/types`. Postgres (Supabase), Drizzle, PostGIS. We keep **explicit typed entities** (Event, Video, Promoter, SteppingGroup, etc.) rather than one generic `listing` blob, but every user-creatable entity shares two **mixins** so they behave uniformly. | |
| + | ||
| + | > Note on what was removed: Chicago stepping has no Greek / NPHC affiliation, so the old `Organization`, `Chapter`, `StepTeam`, and `ChapterMembership` tables (and `ORG_TYPE`) were **deleted**. What used to be `stepTeam(s)` is now **`steppingGroup(s)`**. There is no separate `Instructor` table; an instructor is a text field on `Class`. Live streaming ships in Phase 4, so a `LiveStream` entity is added then; in MVP, `Video` is embed-only. | |
| ### Conventions (every entity) | ||
| - | - **PK:** `id` UUID v7 (time-sortable). **Audit:** `created_at`, `updated_at` (timestamptz). | |
| - | - **Ownership mixin** (every user-creatable entity): `created_by` (FK User, nullable for seed/system rows) + transferable `owner_id` (FK User; `= created_by` at creation). Lets a fan stub a Promoter/DJ/Chapter and the real party claim it later. | |
| - | - **Moderatable mixin** (every user-creatable entity): `status` enum(`active`,`pending`,`hidden`,`removed`) default `active` (optimistic publish), `is_claimed` bool, `verified_at` (nullable), `flag_count` int (denormalized), `quality_score` numeric (nullable, future ranking). Soft-delete only - never hard delete. | |
| - | - **City scoping:** `city_id` (FK City) on every browseable entity. User and Video are *optionally* scoped (nullable). This is the single axis the whole Discover experience filters on. | |
| - | - **Geo:** entities with a physical location carry `lat`, `lng` for "near venue" queries (PostGIS). | |
| + | - **PK:** `id` UUID (default random). **Audit:** `createdAt`, `updatedAt` (timestamptz). | |
| + | - **Ownership mixin** (every user-creatable entity): `ownerId` (FK User, nullable). Lets a fan stub a Promoter/DJ/SteppingGroup and the real party claim it later via ownership transfer. | |
| + | - **Moderatable mixin** (every user-creatable entity): `status` enum(`active`,`pending`,`hidden`,`removed`) default `active` (optimistic publish), `verifiedAt` (nullable), `hiddenAt` (nullable). Soft-hide only; never hard delete. | |
| + | - **City scoping:** `cityId` (FK City) on every browseable entity. This is the single axis the whole Discover experience filters on. | |
| + | - **Geo:** venues and travel places carry `lat`, `lng` for "near venue" queries (PostGIS). | |
| ### Identity & accounts | ||
| - | - **User** - `display_name`, `handle` (unique), `email` (unique, nullable for OAuth-only), `auth_provider`, `password_hash` (nullable), `avatar_url`, `bio`, `home_city_id` (nullable), `role` enum(`member`,`moderator`,`admin`) default `member`, `trust_tier` int (drives rate limits + auto-publish vs queue), `affiliation_org_id` (nullable), `is_active`, `last_seen_at`. *(MVP: everyone is `member`; the columns exist so roles/verification drop in with zero migration.)* | |
| - | - **Organization** (reference data, seeded with the Divine Nine, **not** user-posted) - `name`, `nickname`, `kind` enum(`fraternity`,`sorority`), `letters`, `founded_year`, `colors`, `crest_url`. | |
| - | ||
| - | ### Community orgs (city-scoped, claimable) | |
| - | - **Chapter** - `organization_id`, `city_id`, `name`, `campus`, `chapter_type` enum(`undergrad`,`alumni`), `description`, `logo_url`, `socials` (jsonb). *Moderatable.* | |
| - | - **StepTeam** - `name`, `city_id`, `organization_id` (nullable - independent teams exist), `chapter_id` (nullable), `description`, `logo_url`, `socials` (jsonb), `team_type` enum(`greek`,`independent`,`youth`,`collegiate`). *Moderatable.* | |
| - | - **ChapterMembership** (join User↔Chapter) - `user_id`, `chapter_id`, `member_role` enum(`member`,`leader`,`historian`), `status` enum(`pending`,`active`). | |
| + | - **User**: `handle` (unique), `displayName`, `avatarUrl`, `role` enum(`member`,`moderator`,`admin`) default `member`, `trustTier` int default 0 (drives rate limits + auto-publish vs queue). *(Mirrors Supabase `auth.users`; everyone is `member` in MVP, the columns exist so roles/verification drop in with zero migration.)* | |
| - | ### Industry profiles (city-scoped, claimable) | |
| - | - **Promoter** - `name`, `city_id`, `bio`, `logo_url`, `website`, `socials` (jsonb), `contact_email`, `contact_phone`. *Moderatable.* | |
| - | - **DJ** - `name`, `city_id` (nullable; DJs travel), `bio`, `avatar_url`, `genres` (text[]), `socials` (jsonb), `booking_contact`. *Moderatable.* | |
| - | - **Instructor** - `name`, `city_id`, `bio`, `avatar_url`, `credentials`, `contact`. *Moderatable.* | |
| + | ### Geography (reference, drives all scoping) | |
| + | - **City**: `name`, `slug` (unique, used in URLs: `/chicago/sets`), `metroLabel` (e.g. "DMV"), `state`, `lat`, `lng`, `tier` (launch ordering), `isLaunched` bool (gates public browsability). | |
| + | - **Venue**: `cityId`, `name`, `address`, `lat`, `lng`. *Anchor for "near venue" travel queries.* | |
| - | ### Events & places | |
| - | - **Venue** - `name`, `city_id`, `address`, `lat`, `lng`, `capacity` (nullable), `venue_type` enum(`arena`,`club`,`campus`,`outdoor`,`studio`,`other`). *Moderatable.* Anchor for "near venue" travel queries. | |
| - | - **Event** (central entity) - `title`, `description`, `event_type` enum(`step_show`,`stroll`,`competition`,`homecoming_showcase`,`special_event`,`concert_promo`,`set`,`other`), `city_id`, `venue_id` (nullable), `venue_freetext` (nullable), `starts_at`, `ends_at`, `timezone`, `is_recurring`, `recurrence_rule` (RRULE, nullable), `promoter_id` (nullable), `host_chapter_id` (nullable), `cover_image_url`, `ticket_url` (external for now), `price_text`, `is_free`, `is_live_eligible`. *Moderatable.* | |
| - | - **EventParticipant** (join Event↔StepTeam) - `event_id`, `step_team_id`, `role` enum(`performer`,`competitor`,`host`), `placement` (nullable int). | |
| - | - **EventDJ** (join Event↔DJ) - `event_id`, `dj_id`, `slot` (nullable). | |
| - | - **Class** - `title`, `description`, `city_id`, `venue_id` (nullable), `instructor_id` (nullable), `step_team_id` (nullable host), `skill_level` enum(`beginner`,`intermediate`,`advanced`,`all`), `schedule_text`, `starts_at`, `recurrence_rule`, `price_text`, `is_free`, `signup_url`. *Moderatable.* | |
| + | ### Community (city-scoped, claimable): dad's A-Q vocabulary | |
| + | - **SteppingGroup** (dad `[K]`): `cityId`, `name`, `bio`, `links`. The per-city crews/groups. *Moderatable.* | |
| + | - **Promoter** (the hosts who throw sets): `cityId`, `name`, `bio`, `links`. *Moderatable.* | |
| + | - **DJ** (dad `[C]`): `cityId`, `name`, `bio`, `links`. Per-city stepping DJs. *Moderatable.* | |
| + | - **Class** (dad `[B]`): `cityId`, `title`, `instructor` (text), `schedule`, `venueId` (nullable), `priceText`, `isFree`. *Moderatable.* | |
| - | ### Media & live | |
| - | - **MediaAsset / Video** - `title`, `description`, `city_id` (nullable), `source_type` enum(`upload`,`live_vod`,`youtube`,`vimeo`,`other`), `provider_asset_id` / `source_url`, `thumbnail_url`, `duration_sec`, `category` enum(`tutorial`,`recap`,`performance`,`stroll`,`other`), `event_id` (nullable), `step_team_id` (nullable), `view_count`, plus media state machine field `media_state` enum(`pending_upload`,`uploaded`,`transcoded`,`in_review`,`published`,`rejected`,`taken_down`). *Moderatable.* (Bytes live with Mux; only metadata + provider IDs live in Postgres.) | |
| - | - **LiveStream** - `title`, `event_id` (nullable - standalone sets allowed), `city_id`, `status` enum(`scheduled`,`idle`,`live`,`ended`,`canceled`), `scheduled_at`, `started_at`, `ended_at`, `provider_input_id`, `ingest_url`, `stream_key` (write-only secret), `playback_url` (LL-HLS), `provider` enum(`mux`,`cloudflare`,`self`,`other`), `recorded_video_id` (nullable VOD), `viewer_peak`. *Moderatable.* | |
| + | ### Events & sets | |
| + | - **Event** (dad `[E]` sets, `[A]` promotions, `[F]` special events; central entity): `cityId`, `venueId` (nullable), `title`, `type` enum(`set`,`weekender`,`class`,`workshop`,`competition`,`special`,`social`) (default `set`), `description`, `startsAt`, `endsAt`, `externalUrl`, `ticketUrl` (dormant), `priceText`, `isFree`. *Moderatable.* A **"set"** is the core object: a partner-dance social gathering in a city. A **"weekender"** is an out-of-town multi-day stepping event. | |
| - | ### Travel guide (per-city, per-venue) | |
| - | - **TravelPlace** - `name`, `city_id`, `place_type` enum(`hotel`,`restaurant`,`mall`,`attraction`,`entertainment`,`hospital`), `address`, `lat`, `lng`, `phone`, `website`, `price_tier` (nullable), `notes`, `external_source` enum(`manual`,`google_places`,`other`), `external_ref` (nullable), `near_venue_id` (nullable). *Moderatable.* (Same table holds curated and Places-pulled rows.) | |
| + | ### Media | |
| + | - **Video** (dad `[D]`): `cityId`, `title`, `embedUrl`, `platform` enum(`youtube`,`instagram`,`tiktok`,`other`), `subjectType` + `subjectId` (polymorphic link back to what the video is about). *Moderatable.* **Embed-only in MVP** (YouTube/IG/TikTok); native upload/transcode arrives with the `MediaService` in Phase 4. | |
| - | ### Geography (reference, drives all scoping) | |
| - | - **City** - `name`, `slug` (unique, used in URLs: `/atlanta/events`), `state`, `metro_label` (e.g. "DMV", "NC Triangle"), `lat`, `lng`, `timezone`, `is_launched` bool (gates public browsability), `tier` int (launch ordering). *(A `Metro` parent table is deferred; `metro_label` text covers DMV/Triangle grouping until a real join is needed.)* | |
| + | ### Travel guide (per-city: dad's `[I]`,`[J]`,`[L]`,`[M]`,`[N]`,`[O]`) | |
| + | - **TravelPlace**: `cityId`, `category` enum(`hotel`,`restaurant`,`mall`,`attraction`,`hospital`), `name`, `address`, `lat`, `lng`, `source` enum(`manual`,`google`), `placeRef` (external id, e.g. Google `place_id`). *Moderatable.* Same table holds curated and Places-pulled rows. Entertainment `[P]` and city main attraction `[J]` roll up under `attraction`. | |
| ### Cross-cutting: moderation & engagement | ||
| - | - **Comment** (polymorphic) - `user_id`, `body`, `target_type` enum + `target_id`, `parent_comment_id` (nullable threading). *Moderatable.* | |
| - | - **Flag** - `reporter_id`, `target_type` + `target_id` (any Moderatable, incl. Comment/LiveStream), `reason` enum(`spam`,`abuse`,`wrong_info`,`sexual`,`violence`,`copyright`,`duplicate`,`off_topic`,`other`), `note`, `status` enum(`open`,`reviewed`,`dismissed`,`actioned`). Increments target `flag_count`; distinct-reporter + reporter-trust weighting drives auto-hide thresholds. | |
| - | - **ClaimRequest** - `requester_id`, `target_type` + `target_id` (Promoter/DJ/Chapter/StepTeam/Venue), `evidence`, `status` enum(`pending`,`approved`,`rejected`). On approval: transfer `owner_id`, set `is_claimed`/`verified_at`. | |
| - | - **AuditLog** - `actor_id`, `action`, `target_type` + `target_id`, `reason`, `created_at`. Every moderation action writes here (appeals + DMCA records). | |
| - | ||
| - | ### Future-proofing hooks (defined, unused in MVP) | |
| - | - **Monetization-ready:** `ticket_url`/`price_text`/`is_free` on Event and Class (external links now); future `Listing`/`Order`/`Subscription` tables attach to Promoter via `owner_id`; a `featured` flag + `stripe_account_id` slot onto profiles. No paywall logic in MVP. | |
| + | - **Comment** (polymorphic, dad `[H]`): `subjectType` enum(`event`,`video`,`promoter`,`dj`,`stepping_group`,`class`) + `subjectId`, `body`. *Moderatable.* | |
| + | - **Flag**: `subjectType` + `subjectId` (any moderatable, incl. Comment), `reporterId`, `reason`, `status` enum(`open`,`reviewed`,`actioned`,`dismissed`). Distinct-reporter + reporter-trust weighting drives auto-hide thresholds. | |
| + | - **AuditLog**: `actorId`, `action`, `subjectType`, `subjectId`, `meta` (jsonb). Every moderation action writes here (appeals + DMCA records). | |
| + | ||
| + | ### Joins | |
| + | - **EventParticipant** (Event ↔ SteppingGroup): `eventId`, `steppingGroupId`. | |
| + | - **EventDJ** (Event ↔ DJ): `eventId`, `djId`. | |
| + | - **GroupMembership** (User ↔ SteppingGroup): `steppingGroupId`, `userId`, `roleInGroup`. | |
| + | ||
| + | ### Future-proofing hooks (defined, dormant in MVP) | |
| + | - **Monetization-ready:** `ticketUrl`/`priceText`/`isFree` on Event and Class (external links now); future `Listing`/`Order`/`Subscription` tables attach to Promoter via `ownerId` in Phase 5. No paywall logic in MVP. | |
| + | - **Live-ready (Phase 4):** a `LiveStream` entity (provider input, ingest, LL-HLS playback, auto-VOD) is added when live ships; the `MediaService` seam is stubbed now so it is an adapter, not a refactor. | |
| - **Native-ready:** all entities API-first (UUID PKs, no web-only assumptions); media payloads carry provider/source IDs that serve web and native players identically. | ||
| --- | ||
| ## MVP Scope & Phased Roadmap | ||
| - | Guiding rule: **the smallest thing that makes a traveling stepper or a local D9 member open the app twice is an events feed they trust.** Everything else hangs off that. Live streaming is sequenced *last among features* despite being the headline - it's the most operationally heavy (cost, abuse, bandwidth) and must not gate the rest of the product. | |
| - | ||
| - | ### Phase 0 - Web MVP (ship in weeks, Atlanta + DC only) | |
| - | **Goal:** be the place to find the next step show in ATL/DC; live and shareable fast. | |
| - | ||
| - | **In:** | |
| - | - City switcher hardcoded to **Atlanta + DC**. | |
| - | - **Events feed** per city (step shows, strolls, competitions, special/concert events): title, date/time, venue + address, flyer, description, type tag. | |
| - | - **Event detail** with a Google Maps deep-link (not embedded SDK yet) and share link. | |
| - | - **Open posting** via simple forms (lightweight account: magic-link/OAuth): event, video link, promoter/DJ/team/class listing. | |
| - | - **Videos** = embedded YouTube/IG/TikTok links with title + city tag (no native upload/hosting yet - cheap, high perceived value). | |
| - | - **Comments** under events and videos. | |
| - | - **Directories** (flat lists, real typed entities): Promoters, DJs, Step teams/chapters, Classes. | |
| - | - **Minimal moderation:** single admin (Zion) view of new posts; soft-hide; "report" link. (Required because posting is open.) | |
| - | - **PWA basics:** installable, responsive, mobile-first, offline shell. | |
| - | ||
| - | **Invisible must-dos (prevent rewrites):** the Ownership + Moderatable mixins, dormant `role`/`trust_tier`/`status` columns, the shared Venue/TravelPlace geo structure, and the `MediaService`/`ModerationService` seams stubbed. | |
| - | ||
| - | **Deferred:** native video upload/hosting, native live, ticketing/RSVP, interactive maps, verified roles, push, monetization. | |
| - | ||
| - | ### Phase 1 - Community depth + travel guide (web; expand cities) | |
| - | **Goal:** make it sticky and worth traveling with; expand once the model is proven. | |
| - | - **Per-city travel guide** (hotels, restaurants, malls, attractions/entertainment, hospitals near venue) - **curated/manual** seed per city, optional Google Places enrichment. | |
| - | - **Real profiles** for promoters/DJs/teams/chapters with their events auto-listed. | |
| - | - **Search + filters** across events/videos/listings (city, type, date). | |
| - | - **Proper accounts:** saved/favorited events, "my city," basic profile. | |
| - | - **City expansion:** add **Houston** + **NC Triangle (Greensboro/Durham)**; stand up **Memphis** as pilot/home market. (Data-entry bound, not engineering.) | |
| - | - **Light moderation tooling:** proper admin queue, hide/restore, spam heuristics, grant a trusted "city editor" hide power (flips on the dormant `role`). | |
| - | - **Comment threading** + report → queue. | |
| - | ||
| - | **Deferred:** live streaming, native apps, payments. | |
| - | ||
| - | ### Phase 2 - Live streaming + native apps (iOS + Android) | |
| - | **Goal:** deliver the headline live feature and reach the app stores. | |
| - | - **Native video upload + transcode** via Mux (`MediaService` already stubbed): direct-to-Mux upload, auto-HLS, the asset state machine goes live. | |
| - | - **Live streaming** of step shows: pro RTMP/SRT key path + casual WHIP browser path; LL-HLS playback; **auto-VOD** into the Videos feed. | |
| - | - **Live chat** over Supabase Realtime; viewer counts; reactions. | |
| - | - **Native iOS + Android via Expo** sharing `packages/` core - re-skin screens, reuse logic; add push, camera/stream access, store presence. | |
| - | - **Push notifications:** "a show just went live in your city," "new event near you." | |
| - | - **Stream moderation:** report-threshold auto-pause, kill-switch endpoint, mandatory account to go live, post-hoc VOD review. | |
| - | ||
| - | ### Phase 3 - Monetization (switch-flipping on a monetization-ready schema) | |
| - | - **Promoter / featured listings** (Stripe boost/featured placement). | |
| - | - **Ticketing:** sell or affiliate-link tickets; RSVP → paid. | |
| - | - **Ads / sponsorships:** clearly-labeled city-sponsor + sponsored travel-guide slots. | |
| - | - **Optional:** PPV / tip-the-team on live; pro promoter/team subscriptions (analytics, scheduling). | |
| - | ||
| - | **Gating summary:** Phase 0 is the only phase with hard schedule pressure. Phase 1 is mostly data + admin work. Phase 2 is the single biggest engineering investment, correctly placed after product-market signal. Phase 3 is additive flags + payment rails, no re-architecture. | |
| + | Guiding rule, from dad: **the smallest thing that makes a traveling stepper or a local dancer open the app twice is a feed of sets and events they trust, organized by city.** Everything else hangs off that. The phases below follow dad's own build order from `DADS_PLAN_SEQUENCED.md` (his A-Q feature list). Live streaming is sequenced *last among features* despite being the headline, it's the most operationally heavy (cost, abuse, bandwidth) and must not gate the rest of the product. | |
| + | ||
| + | ### Phase 0: Re-base the project onto Chicago Stepping *(do before any feature work)* | |
| + | 1. Re-theme all docs and scaffold copy to Chicago stepping; strip every Divine Nine / HBCU / NPHC / fraternity / "step show" / "stroll" / Greek-letter reference. | |
| + | 2. Authoritative definition + scope copy uses the §0 correction (partner dance, born Chicago West/South sides, 1940s-60s, from the bop/jitterbug, 6-/8-count). | |
| + | 3. Confirm the six launch cities for Chicago-stepping culture: **Chicago (flagship), Atlanta, Detroit, Houston, Charlotte, Washington-DC/DMV** (dad names Houston and Charlotte). | |
| + | 4. Keep the existing stack/monorepo (Next.js + tRPC + Drizzle + Supabase + Mux): only the theme/content was wrong. | |
| + | ||
| + | ### Phase 1: The spine: Cities + content posting + discovery | |
| + | **Goal:** ship dad's #1 goal, a city-organized feed of sets, events, promos, and videos that replaces hunting on YouTube/Facebook Live. | |
| + | - **City model & directory**: the backbone; everything is city-scoped. City picker locked to the six launch cities. | |
| + | - **Open posting + accounts** (dad: anyone can contribute; "somebody in Houston can send me the video"). | |
| + | - **Sets `[E]`**: the core object, a set/event with city, venue, date/time, host. Event `type` defaults to `set`. | |
| + | - **Events & promotions `[A]` `[F]`**: upcoming sets, weekenders, promotions, special/cause events. | |
| + | - **Videos `[D]`**: per-city video, embed-only in MVP (YouTube/IG/TikTok) with title + city tag. | |
| + | - **Comments `[H]`**: on sets/events/videos. | |
| + | ||
| + | ### Phase 2: The people & the music | |
| + | **Goal:** make it the directory of who's who in each city. | |
| + | - **Promoters / hosts**: profiles, their sets auto-listed (implied throughout; the people who throw sets). | |
| + | - **Stepping groups `[K]`**: per-city crews/groups. | |
| + | - **DJs `[C]`**: per-city stepping DJs (who's spinning, where). | |
| + | - **Classes `[B]`**: per-city lessons (instructor, schedule, location). | |
| + | ||
| + | ### Phase 3: The traveling-stepper city guide | |
| + | **Goal:** make it worth traveling with (dad, repeated: *"when you go out of town to certain cities"*). | |
| + | - **Event venues / centers `[I]`** + **city main attractions `[J]`**. | |
| + | - **Hotels `[N]`** (host + additional nearby). | |
| + | - **Restaurants `[L]`**, **malls/shopping `[M]`**, **entertainment `[P]`** near the set/hotel. | |
| + | - **Hospitals `[O]`**: nearest hospital to the venue (safety). | |
| + | *(Curated/manual entries first; optional Google Places enrichment later, cached into `TravelPlace`.)* | |
| + | ||
| + | ### Phase 4: Live | |
| + | **Goal:** deliver the headline. | |
| + | - **Live `[G]`**: go live in-app for sets/weekenders, pro RTMP/SRT key path + casual WHIP browser path; LL-HLS playback; **auto-VOD** into the Videos feed. Live chat over Supabase Realtime. | |
| + | *(Most technically involved; Mux Live. Native video upload/transcode via `MediaService` lands here too.)* | |
| + | ||
| + | ### Phase 5: Sustainability | |
| + | **Goal:** fund upkeep. | |
| + | - **Monetization `[Q]`**: design schema for it from day one (already dormant on Event/Class), switch it on here: ads and/or paid download and/or free; later promoter/featured listings, ticketing/RSVP, sponsorships. Goal is covering maintenance (frequent edits/updates by the dev office), per dad. Decide model with dad/Zion. | |
| + | ||
| + | **Gating summary:** Phase 0 is documentation/theme (and is already underway). Phase 1 is the only phase with hard schedule pressure (it ships dad's north star). Phases 2-3 are mostly data + admin work. Phase 4 is the single biggest engineering investment, correctly placed after product-market signal. Phase 5 is additive flags + payment rails, no re-architecture. | |
| --- | ||
| @@ -188,11 +182,11 @@ Guiding rule: **the smallest thing that makes a traveling stepper or a local D9 | ||
| - **Buy the hard, undifferentiated parts** (transcode ladders, edge delivery, low-latency live) from Mux; spend Zion's time on the community product. | ||
| - **Provider-agnostic seam:** all media touches go through `MediaService`; all trust-and-safety through `ModerationService`. | ||
| - **Moderation is a property of the pipeline, not a bolt-on.** Every piece of user media flows through one state machine; later roles/verification just change *who* fast-paths through it. | ||
| - | - **Cost scales with usage, not ambition** - no always-on encode fleet. | |
| + | - **Cost scales with usage, not ambition**: no always-on encode fleet. | |
| - | ### Stored video (upload → transcode → playback) | |
| + | ### Stored video (Phase 4: upload → transcode → playback) | |
| 1. Client → `POST /api/media/uploads` `{ kind, context }`. | ||
| - | 2. API authenticates, runs **pre-upload gating** (rate limit + trust check), creates `media_asset` in `pending_upload`, asks Mux for a **one-time direct upload URL**. Browser uploads straight to Mux - bytes never transit our backend. | |
| + | 2. API authenticates, runs **pre-upload gating** (rate limit + trust check), creates `media_asset` in `pending_upload`, asks Mux for a **one-time direct upload URL**. Browser uploads straight to Mux: bytes never transit our backend. | |
| 3. Mux transcodes async → webhook `video.asset.ready` to `POST /api/media/webhooks/provider`. We verify signature, move to `transcoded`, kick off the moderation pre-check. | ||
| 4. On pass → `published`, visible in feeds. | ||
| @@ -204,21 +198,21 @@ pending_upload → uploaded → transcoded → in_review → published | ||
| ``` | ||
| **Playback:** LL-HLS / HLS over Mux CDN, `hls.js` on web (native on Safari/iOS). Use **signed playback tokens from day one** (free now; ready for gated/ticketed content). Auto poster + storyboard hover-scrub. Let Mux auto-generate the ladder (240→1080p, H.264); store vertical phone video as-is and let the player letterbox. **Only metadata + provider IDs live in Postgres.** | ||
| - | ### Live streaming | |
| - | - **Provider: Mux Live** (same vendor/integration as VOD; one bill). RTMP(S)/SRT ingest + WHIP (WebRTC) browser ingest → LL-HLS out (3-8s) → auto-record to VOD. | |
| + | ### Live streaming (Phase 4) | |
| + | - **Provider: Mux Live** (same vendor/integration as VOD; one bill). RTMP(S)/SRT ingest + WHIP (WebRTC) browser ingest → LL-HLS out (3-8s) → auto-record to VOD. | |
| - **Pro path:** issued RTMP/SRT stream key into OBS / venue encoder. | ||
| - **Casual path:** in-app "Go Live" → phone camera via WHIP → Mux down-transcodes to the same HLS ladder. This is the open-contribution path. | ||
| - | - **Flow:** `POST /api/live/streams` → trust/rate gate → Mux creates a Live Input → store `live_stream` (`status=idle`). Broadcaster connects → webhook → `live` → appears in "Live now" rail. Viewers play LL-HLS; chat over Supabase Realtime keyed to the stream id. Disconnect → `ended`; auto-VOD enters the **same** `transcoded → in_review → published` path (live content is **not** exempt from moderation - reviewed post-hoc, takedownable). | |
| + | - **Flow:** `POST /api/live/streams` → trust/rate gate → Mux creates a Live Input → store `live_stream` (`status=idle`). Broadcaster connects → webhook → `live` → appears in "Live now" rail. Viewers play LL-HLS; chat over Supabase Realtime keyed to the stream id. Disconnect → `ended`; auto-VOD enters the **same** `transcoded → in_review → published` path (live content is **not** exempt from moderation: reviewed post-hoc, takedownable). | |
| - **Live moderation reality:** you cannot pre-review a live broadcast. So: (a) gate *who can start* via trust/rate limits, (b) prominent report button → past a low distinct-reporter threshold, **auto-pause** the input pending review, (c) auto-record everything so there's always an artifact, (d) a `POST /api/live/streams/:id/terminate` kill-switch the operator can hit from any device. | ||
| ### Minimum-viable trust & safety (makes "anyone can post" survivable) | ||
| - | - **Identity floor:** account required to post (email/Google/Apple); anonymous read OK, anonymous write never. Per-account **trust score** (rises with age + clean history + verified email/phone) gates rate limits and auto-publish-vs-queue - this is exactly where verification badges plug in later (verified = higher tier). | |
| + | - **Identity floor:** account required to post (email/Google/Apple); anonymous read OK, anonymous write never. Per-account **trust score** (rises with age + clean history + verified email/phone) gates rate limits and auto-publish-vs-queue: this is exactly where verification badges plug in later (verified = higher tier). | |
| - **Rate limits (primary spam defense):** posts per account per hour/day scaled by trust tier (new accounts tight: ~3 videos/day, 20 comments/hr); per-IP/device caps; upload size/duration caps (~10 min) to bound transcode cost; exponential backoff on repeat trips. | ||
| - **Automated pre-filters:** **text** via OpenAI Moderation (free) + slur denylist; **visual** via Hive/Rekognition scoring each transcoded asset before it leaves `in_review`; spam/link heuristics + perceptual-hash dedup. | ||
| - **Community reporting:** report action on every video/comment/event/stream; distinct-reporter-weighted thresholds → auto-hide (soft takedown) / auto-pause live; false-reporters lose report weight. | ||
| - **Operator console:** one mobile-friendly **moderation queue** (auto-flagged + reported-by-weight + `in_review`); actions = approve / reject / take down / warn / suspend / ban / terminate live; every action writes **AuditLog**; takedown is a reversible state, not a hard delete. | ||
| - | - **Legal baseline (not optional even in MVP):** designated **DMCA** agent + takedown endpoint (step footage carries copyrighted music); **CSAM** hits are auto-block + preserve + report to NCMEC (legal obligation); ToS + Community Guidelines scoped to "Divine Nine / NPHC stepping" so off-topic has a stated removal basis; age gate at registration. | |
| - | - **Deferred-but-designed roles:** `trust_tier` + `role` exist from day one (default `member`). Verification later = a manual/document-backed tier bump that grants auto-publish, a badge, and optional self-moderation of one's own event comments. No pipeline rework. | |
| + | - **Legal baseline (not optional even in MVP):** designated **DMCA** agent + takedown endpoint (set footage carries copyrighted music); **CSAM** hits are auto-block + preserve + report to NCMEC (legal obligation); ToS + Community Guidelines scoped to **Chicago stepping** so off-topic has a stated removal basis; age gate at registration. | |
| + | - **Deferred-but-designed roles:** `trustTier` + `role` exist from day one (default `member`). Verification later = a manual/document-backed tier bump that grants auto-publish, a badge, and optional self-moderation of one's own event comments. No pipeline rework. | |
| ### Provider-agnostic interfaces | ||
| ``` | ||
| @@ -235,75 +229,75 @@ Mux today; Cloudflare Stream / Bunny / self-hosted homelab MediaMTX+FFmpeg tomor | ||
| ## UX & Key Screens | ||
| ### Principles | ||
| - | - **City-first, not feed-first.** A persistent city picker in the top nav scopes everything below it. Stepping is hyper-local and event-driven. | |
| + | - **City-first, not feed-first.** A persistent city picker in the top nav scopes everything below it. Stepping is hyper-local and set-driven. | |
| - **Discovery over creation.** Home + city pages are browse surfaces; posting is one tap away but never in the way. | ||
| - | - **Live is a first-class destination** - a red LIVE badge in the nav + a hero slot on Home when a show is streaming in the selected city. | |
| + | - **Live is a first-class destination**: a red LIVE badge in the nav + a hero slot on Home when a set is streaming in the selected city. | |
| - **Open posting, role-ready.** Every post is attributed; `role`/`verified` render as badges now, gate features later. | ||
| - **PWA-shaped:** bottom tab bar on mobile, left rail on desktop, same routes so native wrappers reuse the shell. | ||
| ### Primary navigation | ||
| - **Global top bar:** Logo · **City picker** · Search · LIVE badge (only when streaming in the selected city) · Profile/Post. | ||
| - | - **5 primary destinations** (bottom tabs / left rail): **Home** · **Events** · **Watch** (videos + live) · **City** (per-city hub) · **Post (+)**. Account lives behind the top-bar avatar. | |
| + | - **5 primary destinations** (bottom tabs / left rail): **Home** · **Sets/Events** · **Watch** (videos + live) · **City** (per-city hub) · **Post (+)**. Account lives behind the top-bar avatar. | |
| ### Screens | ||
| - | - **Home** - hero = a current LIVE stream *or* the next big event in the selected city; rails: Live now, This weekend in {City}, Trending clips, New step shows, Featured promoters, Switch city. Defaults to Atlanta, surfaces the picker if no city chosen. | |
| - | - **Events List** - filter by city, date range, type. Card: flyer, title, date/time, venue, host/promoter, free/price badge, Going count. | |
| - | - **Event Detail** - flyer, description, date/time, venue + map + directions, host/promoter link, lineup (teams, DJs), ticket/RSVP (external now), "Watch live" CTA if streamed, comments, **"Plan your trip"** deep-link into the City Guide pre-filtered to that venue. | |
| - | - **Watch Home** - tabs **Live** (current/scheduled streams + viewer counts) and **Videos** (grid of recaps/performances/tutorials, filter by city + tag). | |
| - | - **Live Stream** - player, title, host, viewer count, **live chat** rail, "going to next event" CTA, share. | |
| - | - **Video Detail** - player, title, uploader (badge), city/tags, description, comments, related clips. | |
| - | - **City Home** - city header + hero, quick tiles to sub-sections, "Happening in {City}" strip, map of step locations. | |
| - | - **City sub-sections** - Step Teams & Chapters (→ profile), Classes & Workshops (→ detail), DJs (→ profile), Promoters (→ profile), Step Locations/Map, and the **City Travel Guide** (Hotels · Restaurants · Malls · Attractions/Entertainment · Hospitals, each with distance-to-venue when arrived via an event, map pins). | |
| - | - **Post (sheet)** - pick a type (Event · Video · Live · Class · DJ · Promoter · Team/Chapter · Guide tip); minimal type-specific form; city pre-filled from the active picker. | |
| - | - **Profile / Search / Auth** - my posts, saved/going, follows, uploads/streams (role badge slot reserved); unified search across all entities; lightweight sign-up (email + Google + Apple). | |
| + | - **Home**: hero = a current LIVE stream *or* the next big set in the selected city; rails: Live now, This weekend in {City}, Trending clips, New sets, Featured promoters, Switch city. Defaults to Chicago, surfaces the picker if no city chosen. | |
| + | - **Sets/Events List**: filter by city, date range, type (`set`,`weekender`,`class`,`workshop`,`competition`,`special`,`social`). Card: flyer, title, date/time, venue, host/promoter, free/price badge, Going count. | |
| + | - **Event/Set Detail**: flyer, description, date/time, venue + map + directions, host/promoter link, lineup (stepping groups, DJs), ticket/RSVP (external now), "Watch live" CTA if streamed, comments, **"Plan your trip"** deep-link into the City Guide pre-filtered to that venue. | |
| + | - **Watch Home**: tabs **Live** (current/scheduled streams + viewer counts) and **Videos** (grid of recaps/set footage/tutorials, filter by city + tag). | |
| + | - **Live Stream**: player, title, host, viewer count, **live chat** rail, "going to next set" CTA, share. | |
| + | - **Video Detail**: player, title, uploader (badge), city/tags, description, comments, related clips. | |
| + | - **City Home**: city header + hero, quick tiles to sub-sections, "Happening in {City}" strip, map of set locations. | |
| + | - **City sub-sections**: **Stepping Groups** (→ profile), **Classes & Workshops** (→ detail), **DJs** (→ profile), **Promoters** (→ profile), **Set Locations/Map**, and the **City Travel Guide** (Hotels · Restaurants · Malls · Attractions · Hospitals, each with distance-to-venue when arrived via an event, map pins). | |
| + | - **Post (sheet)**: pick a type (Set/Event · Video · Live · Class · DJ · Promoter · Stepping Group · Guide tip); minimal type-specific form; city pre-filled from the active picker. | |
| + | - **Profile / Search / Auth**: my posts, saved/going, follows, uploads/streams (role badge slot reserved); unified search across all entities; lightweight sign-up (email + Google + Apple). | |
| ### Routing | ||
| - | `/` · `/events` · `/events/:id` · `/watch` · `/watch/live/:id` · `/watch/video/:id` · `/city/:city` · `/city/:city/{teams|classes|djs|promoters|map|guide}` · `/post` · `/u/:user` · `/search`. City is a route param + persisted in the picker. | |
| + | `/` · `/events` · `/events/:id` · `/watch` · `/watch/live/:id` · `/watch/video/:id` · `/city/:city` · `/city/:city/{groups|classes|djs|promoters|map|guide}` · `/post` · `/u/:user` · `/search`. City is a route param + persisted in the picker. | |
| ### Build notes | ||
| - | - **One reusable card component** (image, title, meta row, action) with typed variants covers events, videos, profiles, guide entries. | |
| - | - **Badge/role system is purely presentational at MVP** - reads optional `role`/`verified`; turning on moderation later = populating fields + adding the review queue, not restructuring screens. | |
| - | - Travel guide ships **curated** (hand-seed the launch cities), Google Places as optional enrichment, kept steppin-relevant by anchoring entries to event venues. | |
| + | - **One reusable card component** (image, title, meta row, action) with typed variants covers events/sets, videos, profiles, guide entries. | |
| + | - **Badge/role system is purely presentational at MVP**: reads optional `role`/`verified`; turning on moderation later = populating fields + adding the review queue, not restructuring screens. | |
| + | - Travel guide ships **curated** (hand-seed the launch cities), Google Places as optional enrichment, kept steppin'-relevant by anchoring entries to set venues. | |
| --- | ||
| ## City Launch Plan | ||
| - | City rollout is data-entry-bound, not engineering-bound - `City.is_launched` and `City.tier` gate visibility, so launching a city = seeding its rows and flipping a flag. | |
| + | City rollout is data-entry-bound, not engineering-bound, `City.isLaunched` and `City.tier` gate visibility, so launching a city = seeding its rows and flipping a flag. | |
| | Wave | Cities | `tier` | Role | Seed checklist before flipping `is_launched` | | ||
| |---|---|---|---|---| | ||
| - | | **1 (Phase 0)** | **Atlanta**, **Washington DC** (DMV) | 1 | Primary launch markets | 5-10 real upcoming events; venues geocoded; 3-5 promoters; known DJs/teams/chapters; embedded video links | | |
| - | | **2 (Phase 1)** | **Houston**, **NC Triangle (Greensboro/Durham)** | 2 | First expansion | Same seed bar + curated travel guide rows (hotels/restaurants/malls/attractions/hospitals near key venues) | | |
| - | | **Pilot** | **Memphis** (home market) | 3 | Home pilot - dogfood + relationships | Lowest-risk testbed; Zion's local network seeds events/teams; used to validate moderation + posting UX with friendly users | | |
| + | | **1** | **Chicago** (flagship), **Atlanta** | 1 | Primary launch markets | 5-10 real upcoming sets/events; venues geocoded; 3-5 promoters; known DJs/stepping groups; embedded video links | | |
| + | | **2** | **Detroit**, **Washington-DC/DMV** | 2 | First expansion | Same seed bar + curated travel-guide rows (hotels/restaurants/malls/attractions/hospitals near key venues) | | |
| + | | **3** | **Houston**, **Charlotte** | 3 | Dad-named markets | Same seed bar; both named directly by dad as traveling-stepper destinations | | |
| - | - **DMV / Triangle grouping** is handled by the `metro_label` string (no `Metro` table yet): DC = "DMV", Greensboro/Durham = "NC Triangle". | |
| - | - **Per-city seeding is the launch gate, not code.** Each new city needs: real upcoming events, geocoded venues, a handful of promoters/DJs/teams/chapters, and (Phase 1+) curated travel-guide rows anchored to its main venues. | |
| - | - **Scheduled city-data ingest** (Google Places → hotels/restaurants/malls/hospitals/attractions) runs as the homelab cron job (CT 215), caching results into the `TravelPlace` table to control API spend; manual curation always overrides. | |
| - | - **Memphis-first dogfooding:** even though Memphis is `tier 3` for public launch, use it earliest internally to exercise open-posting and moderation with a friendly local crowd before ATL/DC go fully public. | |
| + | - **DMV grouping** is handled by the `metroLabel` string (no `Metro` table yet): DC = "DMV". | |
| + | - **Per-city seeding is the launch gate, not code.** Each new city needs: real upcoming sets/events, geocoded venues, a handful of promoters/DJs/stepping groups, and (Phase 3+) curated travel-guide rows anchored to its main venues. | |
| + | - **Scheduled city-data ingest** (Google Places → hotels/restaurants/malls/hospitals/attractions) runs as the homelab cron job, caching results into the `TravelPlace` table to control API spend; manual curation always overrides. | |
| + | - **Chicago-first dogfooding:** Chicago is `tier 1` and the home of the dance; use it earliest and deepest to exercise open-posting and moderation with a real crowd before the other cities go fully public. | |
| --- | ||
| - | ## Build Checklist (first sprint - start the web MVP) | |
| + | ## Build Checklist (first sprint: ship the Phase 1 spine) | |
| - | Ordered, concrete. Goal of sprint 1: a deployed, installable PWA where you can post and browse Atlanta + DC events with comments. | |
| + | Ordered, concrete. Goal of sprint 1: a deployed, installable PWA where you can post and browse sets/events in Chicago + Atlanta with comments and embed videos. | |
| 1. **Repo + monorepo scaffold.** `create-turbo`; `apps/web` (Next.js 15 App Router + TS + Tailwind + shadcn/ui); `packages/{db,api,types,config,ui}`. Add `next-pwa` (manifest + service worker). | ||
| 2. **Supabase project.** Provision Postgres + Auth + Storage; enable **PostGIS**. Wire env into Vercel + local `.env`. | ||
| - | 3. **Drizzle schema (in `packages/db`).** Implement the Ownership + Moderatable mixins, then **City, User, Organization, Venue, Event, Promoter, DJ, StepTeam, Chapter, Class, Video, Comment, Flag** + the joins (EventParticipant, EventDJ, ChapterMembership). Include the dormant `role`/`trust_tier`/`status`/`verified_at` columns now. Generate + run migrations. | |
| + | 3. **Drizzle schema (in `packages/db`, matches `schema.ts`).** Implement the timestamps + Ownership + Moderatable mixins, then **City, User, Venue, SteppingGroup, Promoter, DJ, Class, Event, Video, Comment, Flag, TravelPlace, AuditLog** + the joins (**EventParticipant, EventDJ, GroupMembership**). Include the dormant `role`/`trustTier`/`status`/`verifiedAt` columns now. Event `type` enum = `set`/`weekender`/`class`/`workshop`/`competition`/`special`/`social`. Generate + run migrations. | |
| 4. **RLS policies (allow-all today, role-shaped).** Authenticated users get write on their own rows; reads are public where `status='active'`. Policies reference `role`/`status` so tightening later is a policy edit. | ||
| - | 5. **Seed reference data.** Divine Nine Organizations; City rows for Atlanta + DC (`is_launched=true`, `tier=1`, `metro_label`); a few real Venues geocoded. | |
| + | 5. **Seed reference data.** Six **City** rows: Chicago (`isLaunched=true`, `tier=1`, flagship), Atlanta (`tier=1`), Detroit + DC/DMV (`tier=2`), Houston + Charlotte (`tier=3`), with `metroLabel` where applicable; a few real **Venues** geocoded. *(No org/chapter seed, Chicago stepping has no Greek affiliation.)* | |
| 6. **Auth.** Supabase Auth with email magic-link + Google + Apple; session → tRPC context; lightweight sign-up screen. | ||
| - | 7. **tRPC layer (in `packages/api`).** Routers: `city`, `event`, `promoter`, `dj`, `stepTeam`, `chapter`, `class`, `video`, `comment`, `flag`. Zod input schemas shared. CRUD with optimistic publish (`status='active'` on create) + ownership stamping. | |
| - | 8. **City picker + scoping.** Top-bar picker locked to Atlanta + DC; route param `/city/:city`; every list query filters `city_id` + `status='active'`. | |
| - | 9. **Events surface.** SSR Events List (filter by type + date) and Event Detail (Google Maps deep-link, share link) as Server Components. | |
| - | 10. **Open posting forms.** Post sheet with type picker → minimal forms for Event, Video (embed URL), Promoter/DJ/Team/Class; city pre-filled. | |
| + | 7. **tRPC layer (in `packages/api`, matches `root.ts`).** Routers: `city`, `event`, `promoter`, `dj`, **`steppingGroup`**, `class`, `video`, `comment`, `flag`. Zod input schemas shared. CRUD with optimistic publish (`status='active'` on create) + ownership stamping. | |
| + | 8. **City picker + scoping.** Top-bar picker locked to the six launch cities; route param `/city/:city`; every list query filters `cityId` + `status='active'`. | |
| + | 9. **Sets/Events surface.** SSR Events List (filter by type + date) and Event Detail (Google Maps deep-link, share link) as Server Components. Event type defaults to `set`. | |
| + | 10. **Open posting forms.** Post sheet with type picker → minimal forms for Set/Event, Video (embed URL), Promoter/DJ/SteppingGroup/Class; city pre-filled. | |
| 11. **Videos (embed-only).** Render YouTube/IG/TikTok embeds with title + city tag; Watch grid. | ||
| 12. **Comments.** Polymorphic comment component under Event + Video; create/list via tRPC. | ||
| - | 13. **Directories.** Flat list pages for Promoters, DJs, Step teams/chapters, Classes (reuse the one card component, typed variants). | |
| + | 13. **Directories.** Flat list pages for Promoters, DJs, **Stepping Groups**, Classes (reuse the one card component, typed variants). | |
| 14. **Minimal moderation.** Admin-only view of recent posts; soft-hide (`status='hidden'`) action; "report" → creates a Flag; AuditLog write on hide. | ||
| - | 15. **Stub the seams.** Define `MediaService` + `ModerationService` interfaces in `packages/api` (no Mux/Hive wiring yet) so Phase 2 is an adapter, not a refactor. | |
| - | 16. **PWA polish + deploy.** Manifest, icons, offline shell, mobile-first responsive pass. Deploy `apps/web` to Vercel; homelab CT 215 as staging. Smoke-test install-to-home-screen on a real phone. | |
| + | 15. **Stub the seams.** Define `MediaService` + `ModerationService` interfaces in `packages/api` (no Mux/Hive wiring yet) so Phase 4 is an adapter, not a refactor. | |
| + | 16. **PWA polish + deploy.** Manifest, icons, offline shell, mobile-first responsive pass. Deploy `apps/web` to Vercel; homelab as staging. Smoke-test install-to-home-screen on a real phone. | |
| --- | ||
| @@ -312,14 +306,14 @@ Ordered, concrete. Goal of sprint 1: a deployed, installable PWA where you can p | ||
| | Item | Status | Resolution / mitigation | | ||
| |---|---|---| | ||
| | **Mux per-minute cost at scale** | Accepted pre-monetization | Zero idle cost; `MediaService` seam → swap to Cloudflare Stream / homelab the moment delivery cost dominates (post-ticketing). | | ||
| - | | **Two view layers (Next web + Expo native)** | Decided tradeoff | Share `packages/` logic, fork the shell. Worth it for SSR/SEO on discovery pages - the way a city events directory actually gets found. | | |
| + | | **Two view layers (Next web + Expo native)** | Decided tradeoff | Share `packages/` logic, fork the shell. Worth it for SSR/SEO on discovery pages, the way a city sets directory actually gets found. | | |
| | **Open posting → spam/abuse with no human pre-review** | Designed-for | Identity floor + trust-tiered rate limits + free text moderation + per-asset visual moderation + report thresholds + a mobile operator queue. "Survivable, not perfect." | | ||
| - | | **Live moderation can't be pre-reviewed** | Designed-for | Gate who can go live, report-threshold auto-pause, auto-record everything, operator kill-switch. | | |
| - | | **Legal: DMCA (copyrighted music in step footage) + CSAM** | Must ship in MVP | Designated DMCA agent + takedown endpoint; CSAM = auto-block + preserve + NCMEC report; ToS/guidelines + age gate at signup. | | |
| - | | **City seeding is the real launch bottleneck** | Operational, not technical | Each city needs hand-seeded events/venues/profiles before `is_launched`; Memphis pilot dogfoods the flow first. | | |
| + | | **Live moderation can't be pre-reviewed** | Designed-for (Phase 4) | Gate who can go live, report-threshold auto-pause, auto-record everything, operator kill-switch. | | |
| + | | **Legal: DMCA (copyrighted music in set/stepping footage) + CSAM** | Must ship in MVP | Designated DMCA agent + takedown endpoint; CSAM = auto-block + preserve + NCMEC report; ToS/guidelines scoped to Chicago stepping + age gate at signup. | | |
| + | | **City seeding is the real launch bottleneck** | Operational, not technical | Each city needs hand-seeded sets/venues/profiles before `isLaunched`; Chicago dogfoods the flow first and deepest. | | |
| | **Generic listing vs typed entities** | Resolved → typed | Keep explicit typed entities for query clarity + relational integrity; the shared mixins deliver the "one content model" uniformity without a blob table. | | ||
| - | | **Monetization model undetermined** | Deferred, pre-wired | `ticket_url`/`price_text`/`is_free` + `owner_id` exist; Stripe + Listing/Order/Subscription tables slot into tRPC in Phase 3. No paywall in MVP. | | |
| + | | **Monetization model undetermined** | Deferred, pre-wired | `ticketUrl`/`priceText`/`isFree` + `ownerId` exist; Stripe + Listing/Order/Subscription tables slot into tRPC in Phase 5. No paywall in MVP. | | |
| | **Google Places API spend** | Controlled | Cache aggressively into `TravelPlace`; manual curation overrides; scheduled homelab ingest, not live per-request calls. | | ||
| | **Live chat scale (Comment vs dedicated table)** | Watch item | Start on Supabase Realtime + capped chat tail; split to a dedicated `LiveChatMessage` table only if volume demands. | | ||
| - | | **Apple sign-in dependency for future iOS** | Pre-empted | Apple OAuth enabled in Supabase Auth from day one so the Phase 2 App Store build has no auth blocker. | | |
| + | | **Apple sign-in dependency for future iOS** | Pre-empted | Apple OAuth enabled in Supabase Auth from day one so the later App Store build has no auth blocker. | | |
| | **Production must survive homelab reboots** | Decided | All user-facing infra in cloud (Vercel + Supabase + Mux); homelab is staging, scheduled jobs, backups, and documented fallback only. | |
| @@ -1,5 +1,15 @@ | ||
| import type { NextConfig } from "next"; | ||
| + | // Baseline security headers applied to every response. (A tuned CSP is a | |
| + | // follow-up for the public HTTPS deploy.) | |
| + | const securityHeaders = [ | |
| + | { key: "X-Frame-Options", value: "DENY" }, | |
| + | { key: "X-Content-Type-Options", value: "nosniff" }, | |
| + | { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, | |
| + | { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, | |
| + | { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" }, | |
| + | ]; | |
| + | ||
| const nextConfig: NextConfig = { | ||
| transpilePackages: [ | ||
| "@stepping/api", | ||
| @@ -8,6 +18,9 @@ const nextConfig: NextConfig = { | ||
| "@stepping/config", | ||
| "@stepping/ui", | ||
| ], | ||
| + | async headers() { | |
| + | return [{ source: "/:path*", headers: securityHeaders }]; | |
| + | }, | |
| }; | ||
| export default nextConfig; |
| @@ -1,6 +1,19 @@ | ||
| - | <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 width="512" height="512" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Stepping"> | |
| + | <defs> | |
| + | <linearGradient id="ball" x1="0" y1="0" x2="0" y2="1"> | |
| + | <stop offset="0" stop-color="#f4f1ea"/> | |
| + | <stop offset="0.5" stop-color="#aab4c6"/> | |
| + | <stop offset="1" stop-color="#3d5afe"/> | |
| + | </linearGradient> | |
| + | <clipPath id="c"><circle cx="32" cy="34" r="20"/></clipPath> | |
| + | </defs> | |
| + | <rect width="64" height="64" rx="14" fill="#0a0a0b"/> | |
| + | <rect x="31" y="6" width="2" height="9" fill="#8c93a3"/> | |
| + | <circle cx="32" cy="14" r="3" fill="#c9cad0"/> | |
| + | <circle cx="32" cy="34" r="20" fill="url(#ball)"/> | |
| + | <g clip-path="url(#c)" stroke="#0a0a0b" stroke-width="1.6" opacity="0.85" fill="none"> | |
| + | <path d="M12 20 H52 M12 27 H52 M12 34 H52 M12 41 H52 M12 48 H52"/> | |
| + | <path d="M16 16 V52 M24 14 V54 M32 14 V54 M40 14 V54 M48 16 V52"/> | |
| + | </g> | |
| + | <circle cx="25" cy="27" r="4" fill="#ffffff" opacity="0.7"/> | |
| </svg> |
| @@ -1,11 +1,11 @@ | ||
| { | ||
| "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.", | |
| + | "description": "The home for Chicago stepping. The sets, the classes, the DJs, the people, and the road to every set, city by city.", | |
| "start_url": "/", | ||
| "display": "standalone", | ||
| - | "background_color": "#0F0E0C", | |
| - | "theme_color": "#0F0E0C", | |
| + | "background_color": "#0a0a0b", | |
| + | "theme_color": "#0a0a0b", | |
| "icons": [ | ||
| { | ||
| "src": "/icon.svg", |
| @@ -3,7 +3,7 @@ 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.", | |
| + | "A plain-language guide to the Chicago stepping app: find a city, see the sets, meet the people, and plan the road to every city.", | |
| }; | ||
| const STEPS = [ | ||
| @@ -14,18 +14,18 @@ const STEPS = [ | ||
| }, | ||
| { | ||
| 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.", | |
| + | title: "See the sets", | |
| + | body: "Inside a city you will find The Sets: every set, weekender, class, and workshop 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.", | |
| + | title: "Step onto The Floor", | |
| + | body: "The Floor is the community for that city: the stepping groups, the DJs, the promoters who host the sets, and the classes where you can learn the count. 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.", | |
| + | title: "Plan The Road", | |
| + | body: "Traveling for a set or a weekender? The Road helps you get there. Pick a city and find the hotels, food, and the nearest hospital, just in case.", | |
| }, | ||
| ]; | ||
| @@ -44,15 +44,19 @@ export default function AboutPage() { | ||
| </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. | |
| + | Chicago stepping is a partner dance. Smooth, gliding, soulful, danced on | |
| + | a count. It came up in the African American community on Chicago's | |
| + | West and South sides, and from the 1940s to the 60s it grew out of the | |
| + | jitterbug and the 50s glide-and-stride dance they called the bop. This | |
| + | is Chicago stepping, not Greek stepping. | |
| </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. | |
| + | This app is one simple thing. In dad's words, it is so "people | |
| + | in different cities can just share, so people [are] abreast of what's | |
| + | going on, besides having to go to YouTube and Facebook Live to see | |
| + | content." One place, organized by city, where the community posts | |
| + | and finds the sets, the classes, the DJs, and the people. | |
| </p> | ||
| <section className="mt-16 border-t border-[var(--line)] pt-12"> | ||
| @@ -99,7 +103,7 @@ export default function AboutPage() { | ||
| <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> | |
| + | <span className="kicker text-ash">In the Pocket · Built for the culture</span> | |
| </footer> | ||
| </main> | ||
| ); |
| @@ -0,0 +1,62 @@ | ||
| + | import { redirect } from "next/navigation"; | |
| + | import { login, getCurrentUser, isAdmin } from "@/lib/auth"; | |
| + | ||
| + | export const dynamic = "force-dynamic"; | |
| + | ||
| + | export const metadata = { title: "Admin - Stepping", robots: { index: false } }; | |
| + | ||
| + | export default async function AdminLogin({ | |
| + | searchParams, | |
| + | }: { | |
| + | searchParams: Promise<{ error?: string }>; | |
| + | }) { | |
| + | const { error } = await searchParams; | |
| + | if (isAdmin(await getCurrentUser())) redirect("/admin"); | |
| + | ||
| + | async function doLogin(formData: FormData) { | |
| + | "use server"; | |
| + | const email = String(formData.get("email") ?? ""); | |
| + | const password = String(formData.get("password") ?? ""); | |
| + | const ok = await login(email, password); | |
| + | redirect(ok ? "/admin" : "/admin/login?error=1"); | |
| + | } | |
| + | ||
| + | return ( | |
| + | <main className="mx-auto flex min-h-[100svh] w-full max-w-md flex-col justify-center px-6"> | |
| + | <p className="kicker text-ash">Stepping</p> | |
| + | <h1 className="display mt-2 text-5xl">Admin</h1> | |
| + | <p className="serif mt-3 text-lg text-ash">Sign in to manage the platform.</p> | |
| + | ||
| + | {error ? ( | |
| + | <p className="kicker mt-6 border border-gold px-4 py-3 text-gold"> | |
| + | Wrong email or password. | |
| + | </p> | |
| + | ) : null} | |
| + | ||
| + | <form action={doLogin} className="mt-8 flex flex-col gap-4"> | |
| + | <input | |
| + | name="email" | |
| + | type="email" | |
| + | required | |
| + | autoComplete="username" | |
| + | placeholder="Email" | |
| + | className="serif border-b border-[var(--line)] bg-transparent py-3 text-xl text-bone outline-none placeholder:text-ash focus:border-gold" | |
| + | /> | |
| + | <input | |
| + | name="password" | |
| + | type="password" | |
| + | required | |
| + | autoComplete="current-password" | |
| + | placeholder="Password" | |
| + | className="serif border-b border-[var(--line)] bg-transparent py-3 text-xl text-bone outline-none placeholder:text-ash focus:border-gold" | |
| + | /> | |
| + | <button | |
| + | type="submit" | |
| + | className="kicker mt-4 bg-gold px-6 py-4 text-stage hover:bg-gold-bright" | |
| + | > | |
| + | Sign in | |
| + | </button> | |
| + | </form> | |
| + | </main> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,254 @@ | ||
| + | import Link from "next/link"; | |
| + | import { redirect } from "next/navigation"; | |
| + | import { revalidatePath } from "next/cache"; | |
| + | import { getCurrentUser, isAdmin, logout } from "@/lib/auth"; | |
| + | import { sessionCaller } from "@/lib/trpc/server"; | |
| + | ||
| + | export const dynamic = "force-dynamic"; | |
| + | ||
| + | export const metadata = { title: "Admin - Stepping", robots: { index: false } }; | |
| + | ||
| + | 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 AdminPage() { | |
| + | // Server-side gate: real session + admin/moderator role, or bounce to login. | |
| + | if (!isAdmin(await getCurrentUser())) redirect("/admin/login"); | |
| + | ||
| + | const { caller } = await sessionCaller(); | |
| + | const recent = await caller.moderation.recentEvents(); | |
| + | const ordersLog = await caller.order.adminRecent(); | |
| + | const sponsorsList = await caller.sponsor.adminList(); | |
| + | const proList = await caller.proAccount.adminList(); | |
| + | const launchedCities = await caller.city.listLaunched(); | |
| + | ||
| + | const money = (c: number) => (c === 0 ? "Free" : `$${(c / 100).toFixed(2)}`); | |
| + | ||
| + | async function doLogout() { | |
| + | "use server"; | |
| + | await logout(); | |
| + | redirect("/"); | |
| + | } | |
| + | ||
| + | async function hideEvent(formData: FormData) { | |
| + | "use server"; | |
| + | const id = String(formData.get("id") ?? ""); | |
| + | if (!id) return; | |
| + | const { caller } = await sessionCaller(); | |
| + | await caller.moderation.hideEvent({ id }); | |
| + | revalidatePath("/admin"); | |
| + | } | |
| + | ||
| + | async function createSponsor(formData: FormData) { | |
| + | "use server"; | |
| + | const cityId = String(formData.get("cityId") ?? ""); | |
| + | const name = String(formData.get("name") ?? "").trim(); | |
| + | const blurb = String(formData.get("blurb") ?? "").trim(); | |
| + | const linkUrl = String(formData.get("linkUrl") ?? "").trim(); | |
| + | if (!cityId || !name) return; | |
| + | const { caller } = await sessionCaller(); | |
| + | await caller.sponsor.adminCreate({ | |
| + | cityId, | |
| + | name, | |
| + | blurb: blurb || undefined, | |
| + | linkUrl: linkUrl || undefined, | |
| + | status: "active", | |
| + | }); | |
| + | revalidatePath("/admin"); | |
| + | } | |
| + | ||
| + | async function setSponsorStatus(formData: FormData) { | |
| + | "use server"; | |
| + | const id = String(formData.get("id") ?? ""); | |
| + | const status = String(formData.get("status") ?? "") as "pending" | "active" | "ended"; | |
| + | if (!id) return; | |
| + | const { caller } = await sessionCaller(); | |
| + | await caller.sponsor.adminSetStatus({ id, status }); | |
| + | revalidatePath("/admin"); | |
| + | } | |
| + | ||
| + | async function setProStatus(formData: FormData) { | |
| + | "use server"; | |
| + | const id = String(formData.get("id") ?? ""); | |
| + | const status = String(formData.get("status") ?? "") as | |
| + | | "pending" | |
| + | | "active" | |
| + | | "expired" | |
| + | | "cancelled"; | |
| + | if (!id) return; | |
| + | const { caller } = await sessionCaller(); | |
| + | await caller.proAccount.adminSetStatus({ id, status }); | |
| + | revalidatePath("/admin"); | |
| + | } | |
| + | ||
| + | return ( | |
| + | <main className="mx-auto w-full max-w-4xl px-5 py-12 sm:px-8"> | |
| + | <div className="flex items-center justify-between"> | |
| + | <div> | |
| + | <p className="kicker text-ash">Stepping · Admin</p> | |
| + | <h1 className="display mt-2 text-5xl sm:text-6xl">Control Room</h1> | |
| + | </div> | |
| + | <form action={doLogout}> | |
| + | <button type="submit" className="kicker text-ash hover:text-bone"> | |
| + | Sign out → | |
| + | </button> | |
| + | </form> | |
| + | </div> | |
| + | ||
| + | {/* Moderation queue */} | |
| + | <section className="mt-14 border-t border-[var(--line)] pt-8"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">Moderation</h2> | |
| + | <span className="kicker text-ash">Recent events</span> | |
| + | </div> | |
| + | <ul className="mt-6"> | |
| + | {recent.length === 0 ? ( | |
| + | <li className="kicker text-ash">Nothing posted yet.</li> | |
| + | ) : ( | |
| + | recent.map((e) => ( | |
| + | <li | |
| + | key={e.id} | |
| + | className="flex items-center gap-4 border-b border-[var(--line)] py-4" | |
| + | > | |
| + | <span | |
| + | className="kicker w-20" | |
| + | style={{ color: e.status === "active" ? "var(--ash)" : "var(--gold)" }} | |
| + | > | |
| + | {e.status.toUpperCase()} | |
| + | </span> | |
| + | <span className="display flex-1 text-xl text-bone">{e.title}</span> | |
| + | <span className="kicker hidden text-ash sm:block">{stamp(e.createdAt)}</span> | |
| + | {e.status === "active" ? ( | |
| + | <form action={hideEvent}> | |
| + | <input type="hidden" name="id" value={e.id} /> | |
| + | <button type="submit" className="kicker text-gold hover:text-gold-bright"> | |
| + | Hide | |
| + | </button> | |
| + | </form> | |
| + | ) : null} | |
| + | </li> | |
| + | )) | |
| + | )} | |
| + | </ul> | |
| + | </section> | |
| + | ||
| + | {/* Revenue log (orders: tickets + boosts) */} | |
| + | <section className="mt-14 border-t border-[var(--line)] pt-8"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">Revenue</h2> | |
| + | <span className="kicker text-ash"> | |
| + | Recent orders · payments off | |
| + | </span> | |
| + | </div> | |
| + | <ul className="mt-6"> | |
| + | {ordersLog.length === 0 ? ( | |
| + | <li className="kicker text-ash">No orders yet.</li> | |
| + | ) : ( | |
| + | ordersLog.map((o) => ( | |
| + | <li | |
| + | key={o.id} | |
| + | className="flex items-center gap-4 border-b border-[var(--line)] py-3" | |
| + | > | |
| + | <span className="kicker w-20 text-gold">{o.purpose.toUpperCase()}</span> | |
| + | <span className="kicker flex-1 text-ash"> | |
| + | {o.quantity} × · {o.status.toUpperCase()} | |
| + | </span> | |
| + | <span className="display text-lg">{money(o.amountCents)}</span> | |
| + | <span className="kicker hidden w-24 text-right text-ash sm:block"> | |
| + | {stamp(o.createdAt)} | |
| + | </span> | |
| + | </li> | |
| + | )) | |
| + | )} | |
| + | </ul> | |
| + | </section> | |
| + | ||
| + | {/* City Sponsors */} | |
| + | <section className="mt-14 border-t border-[var(--line)] pt-8"> | |
| + | <h2 className="display text-3xl">City Sponsors</h2> | |
| + | <form action={createSponsor} className="mt-5 flex flex-wrap items-end gap-3"> | |
| + | <select | |
| + | name="cityId" | |
| + | required | |
| + | defaultValue="" | |
| + | className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold" | |
| + | > | |
| + | <option value="" disabled>CITY</option> | |
| + | {launchedCities.map((c) => ( | |
| + | <option key={c.id} value={c.id} className="bg-stage"> | |
| + | {c.name.toUpperCase()} | |
| + | </option> | |
| + | ))} | |
| + | </select> | |
| + | <input name="name" required placeholder="Sponsor name" className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold" /> | |
| + | <input name="blurb" placeholder="Tagline (opt)" className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold" /> | |
| + | <input name="linkUrl" placeholder="https:// (opt)" className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold" /> | |
| + | <button type="submit" className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright">Add</button> | |
| + | </form> | |
| + | <ul className="mt-6"> | |
| + | {sponsorsList.length === 0 ? ( | |
| + | <li className="kicker text-ash">No sponsors yet.</li> | |
| + | ) : ( | |
| + | sponsorsList.map((s) => ( | |
| + | <li key={s.id} className="flex items-center gap-4 border-b border-[var(--line)] py-3"> | |
| + | <span className="kicker w-20" style={{ color: s.status === "active" ? "var(--gold)" : "var(--ash)" }}> | |
| + | {s.status.toUpperCase()} | |
| + | </span> | |
| + | <span className="display flex-1 text-lg">{s.name}</span> | |
| + | <form action={setSponsorStatus} className="flex gap-3"> | |
| + | <input type="hidden" name="id" value={s.id} /> | |
| + | <button name="status" value={s.status === "active" ? "ended" : "active"} className="kicker text-gold hover:text-gold-bright"> | |
| + | {s.status === "active" ? "End" : "Activate"} | |
| + | </button> | |
| + | </form> | |
| + | </li> | |
| + | )) | |
| + | )} | |
| + | </ul> | |
| + | </section> | |
| + | ||
| + | {/* Pro Accounts */} | |
| + | <section className="mt-14 border-t border-[var(--line)] pt-8"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">Pro Accounts</h2> | |
| + | <span className="kicker text-ash">Promoter / DJ / instructor plans</span> | |
| + | </div> | |
| + | <ul className="mt-6"> | |
| + | {proList.length === 0 ? ( | |
| + | <li className="kicker text-ash"> | |
| + | No pro accounts yet. They appear here once members upgrade. | |
| + | </li> | |
| + | ) : ( | |
| + | proList.map((p) => ( | |
| + | <li key={p.id} className="flex items-center gap-4 border-b border-[var(--line)] py-3"> | |
| + | <span className="kicker w-20" style={{ color: p.status === "active" ? "var(--gold)" : "var(--ash)" }}> | |
| + | {p.status.toUpperCase()} | |
| + | </span> | |
| + | <span className="display flex-1 text-lg">{p.tier.toUpperCase()}</span> | |
| + | <form action={setProStatus} className="flex gap-3"> | |
| + | <input type="hidden" name="id" value={p.id} /> | |
| + | <button name="status" value={p.status === "active" ? "cancelled" : "active"} className="kicker text-gold hover:text-gold-bright"> | |
| + | {p.status === "active" ? "Cancel" : "Activate"} | |
| + | </button> | |
| + | </form> | |
| + | </li> | |
| + | )) | |
| + | )} | |
| + | </ul> | |
| + | </section> | |
| + | ||
| + | <footer className="mt-16 border-t border-[var(--line)] pt-6"> | |
| + | <Link href="/" className="kicker text-ash hover:text-bone"> | |
| + | ← Back to site | |
| + | </Link> | |
| + | </footer> | |
| + | </main> | |
| + | ); | |
| + | } |
| @@ -1,32 +1,27 @@ | ||
| import Link from "next/link"; | ||
| - | import { notFound } from "next/navigation"; | |
| + | import { SiteFooter } from "@/components/site-footer"; | |
| + | import { notFound, redirect } from "next/navigation"; | |
| import { revalidatePath } from "next/cache"; | ||
| import { serverCaller } from "@/lib/trpc/server"; | ||
| + | import { cityTimeZone, fmtEventTime } from "@/lib/format"; | |
| export const dynamic = "force-dynamic"; | ||
| const TYPE_LABEL: Record<string, string> = { | ||
| - | step_show: "Step show", | |
| - | stroll: "Stroll", | |
| - | competition: "Competition", | |
| + | set: "Set", | |
| + | weekender: "Weekender", | |
| class: "Class", | ||
| + | workshop: "Workshop", | |
| + | competition: "Competition", | |
| + | special: "Special event", | |
| 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 money(cents: number) { | |
| + | return cents === 0 ? "Free" : `$${(cents / 100).toFixed(2)}`; | |
| } | ||
| + | ||
| function stamp(d: Date | null) { | ||
| if (!d) return ""; | ||
| return new Date(d) | ||
| @@ -55,11 +50,48 @@ export default async function EventPage({ | ||
| const event = await caller.event.byId({ id }); | ||
| if (!event) notFound(); | ||
| + | const tz = cityTimeZone(city.state); | |
| + | ||
| + | const ticketTypes = await caller.ticketType.list({ eventId: id }); | |
| + | const featured = | |
| + | event.isPromoted && | |
| + | (!event.boostedUntil || new Date(event.boostedUntil) > new Date()); | |
| + | ||
| const comments = await caller.comment.list({ | ||
| subjectType: "event", | ||
| subjectId: id, | ||
| }); | ||
| + | async function reserve(formData: FormData) { | |
| + | "use server"; | |
| + | const ticketTypeId = String(formData.get("ticketTypeId") ?? ""); | |
| + | const quantity = Math.max(1, Math.min(10, Number(formData.get("quantity") ?? 1))); | |
| + | if (!ticketTypeId) return; | |
| + | await serverCaller().ticket.reserve({ ticketTypeId, quantity }); | |
| + | redirect("/tickets"); | |
| + | } | |
| + | ||
| + | async function addTicketType(formData: FormData) { | |
| + | "use server"; | |
| + | const name = String(formData.get("name") ?? "").trim(); | |
| + | const dollars = Number(formData.get("price") ?? 0); | |
| + | const capRaw = String(formData.get("capacity") ?? "").trim(); | |
| + | if (!name) return; | |
| + | await serverCaller().ticketType.create({ | |
| + | eventId: id, | |
| + | name, | |
| + | priceCents: Math.max(0, Math.round(dollars * 100)), | |
| + | capacity: capRaw ? Math.max(1, Number(capRaw)) : undefined, | |
| + | }); | |
| + | revalidatePath(`/city/${slug}/event/${id}`); | |
| + | } | |
| + | ||
| + | async function boost() { | |
| + | "use server"; | |
| + | await serverCaller().event.boost({ id, days: 14 }); | |
| + | revalidatePath(`/city/${slug}/event/${id}`); | |
| + | } | |
| + | ||
| async function addComment(formData: FormData) { | ||
| "use server"; | ||
| const body = String(formData.get("body") ?? "").trim(); | ||
| @@ -90,7 +122,7 @@ export default async function EventPage({ | ||
| </Link> | ||
| <p className="kicker mt-10 text-ash"> | ||
| - | {(TYPE_LABEL[event.type] ?? event.type).toUpperCase()} · {fmt(event.startsAt)} ·{" "} | |
| + | {(TYPE_LABEL[event.type] ?? event.type).toUpperCase()} · {fmtEventTime(event.startsAt, tz)} ·{" "} | |
| {city.name} | ||
| </p> | ||
| <h1 className="display mt-3 text-6xl sm:text-7xl">{event.title}</h1> | ||
| @@ -126,6 +158,104 @@ export default async function EventPage({ | ||
| </div> | ||
| ) : null} | ||
| + | {/* Tickets */} | |
| + | <section className="mt-14 border-t border-[var(--line)] pt-10"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">Tickets</h2> | |
| + | {featured ? ( | |
| + | <span className="kicker text-gold">★ Featured</span> | |
| + | ) : ( | |
| + | <form action={boost}> | |
| + | <button type="submit" className="kicker text-ash hover:text-gold"> | |
| + | Boost this event → | |
| + | </button> | |
| + | </form> | |
| + | )} | |
| + | </div> | |
| + | ||
| + | {ticketTypes.length === 0 ? ( | |
| + | <p className="serif mt-6 text-xl text-ash"> | |
| + | No tickets yet. Add a ticket type below, or it's free at the door. | |
| + | </p> | |
| + | ) : ( | |
| + | <ul className="mt-6 flex flex-col gap-3"> | |
| + | {ticketTypes.map((t) => { | |
| + | const left = t.capacity != null ? t.capacity - t.sold : null; | |
| + | const soldOut = left != null && left <= 0; | |
| + | return ( | |
| + | <li | |
| + | key={t.id} | |
| + | className="flex items-center gap-4 border border-[var(--line)] px-5 py-4" | |
| + | > | |
| + | <div className="flex-1"> | |
| + | <p className="display text-2xl">{t.name}</p> | |
| + | <p className="kicker mt-1 text-ash"> | |
| + | {money(t.priceCents)} | |
| + | {left != null ? ` · ${left} LEFT` : ""} | |
| + | </p> | |
| + | </div> | |
| + | {soldOut ? ( | |
| + | <span className="kicker text-ash">Sold out</span> | |
| + | ) : ( | |
| + | <form action={reserve} className="flex items-center gap-2"> | |
| + | <input type="hidden" name="ticketTypeId" value={t.id} /> | |
| + | <input | |
| + | type="number" | |
| + | name="quantity" | |
| + | defaultValue={1} | |
| + | min={1} | |
| + | max={Math.min(10, left ?? 10)} | |
| + | className="kicker w-16 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold" | |
| + | /> | |
| + | <button | |
| + | type="submit" | |
| + | className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright" | |
| + | > | |
| + | Reserve | |
| + | </button> | |
| + | </form> | |
| + | )} | |
| + | </li> | |
| + | ); | |
| + | })} | |
| + | </ul> | |
| + | )} | |
| + | ||
| + | {/* Host: add a ticket type */} | |
| + | <form action={addTicketType} className="mt-6 flex flex-wrap items-end gap-3"> | |
| + | <input | |
| + | name="name" | |
| + | required | |
| + | placeholder="Ticket name (e.g. General, VIP)" | |
| + | className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold" | |
| + | /> | |
| + | <input | |
| + | type="number" | |
| + | name="price" | |
| + | step="1" | |
| + | min="0" | |
| + | placeholder="Price $" | |
| + | className="kicker w-24 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold" | |
| + | /> | |
| + | <input | |
| + | type="number" | |
| + | name="capacity" | |
| + | min="1" | |
| + | placeholder="Qty (opt)" | |
| + | className="kicker w-24 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold" | |
| + | /> | |
| + | <button | |
| + | type="submit" | |
| + | className="kicker border border-[var(--line)] px-5 py-2 text-gold hover:border-gold" | |
| + | > | |
| + | Add ticket | |
| + | </button> | |
| + | </form> | |
| + | <p className="kicker mt-3 text-ash"> | |
| + | Reserving is free while we turn on payments. | |
| + | </p> | |
| + | </section> | |
| + | ||
| {/* Comments */} | ||
| <section className="mt-16 border-t border-[var(--line)] pt-10"> | ||
| <div className="flex items-end justify-between"> | ||
| @@ -139,7 +269,7 @@ export default async function EventPage({ | ||
| <input | ||
| name="body" | ||
| required | ||
| - | placeholder="Say something about the show" | |
| + | placeholder="Say something about the set" | |
| 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"> | ||
| @@ -191,6 +321,7 @@ export default async function EventPage({ | ||
| </button> | ||
| </form> | ||
| </section> | ||
| + | <SiteFooter /> | |
| </main> | ||
| ); | ||
| } |
| @@ -1,11 +1,12 @@ | ||
| import Link from "next/link"; | ||
| + | import { SiteFooter } from "@/components/site-footer"; | |
| import { notFound } from "next/navigation"; | ||
| import { revalidatePath } from "next/cache"; | ||
| import { serverCaller } from "@/lib/trpc/server"; | ||
| export const dynamic = "force-dynamic"; | ||
| - | export default async function YardPage({ | |
| + | export default async function FloorPage({ | |
| params, | ||
| }: { | ||
| params: Promise<{ slug: string }>; | ||
| @@ -16,48 +17,24 @@ export default async function YardPage({ | ||
| if (!city) notFound(); | ||
| const cityId = city.id; | ||
| - | const [stepTeams, chapters, djs, promoters, classes, organizations] = | |
| - | await Promise.all([ | |
| - | caller.stepTeam.list({ cityId }), | |
| - | caller.chapter.list({ cityId }), | |
| - | caller.dj.list({ cityId }), | |
| - | caller.promoter.list({ cityId }), | |
| - | caller.class.list({ cityId }), | |
| - | caller.organization.list(), | |
| - | ]); | |
| + | const [steppingGroups, djs, promoters, classes] = await Promise.all([ | |
| + | caller.steppingGroup.list({ cityId }), | |
| + | caller.dj.list({ cityId }), | |
| + | caller.promoter.list({ cityId }), | |
| + | caller.class.list({ cityId }), | |
| + | ]); | |
| - | const orgById = new Map(organizations.map((o) => [o.id, o])); | |
| - | ||
| - | async function addStepTeam(formData: FormData) { | |
| + | async function addSteppingGroup(formData: FormData) { | |
| "use server"; | ||
| const name = String(formData.get("name") ?? "").trim(); | ||
| - | const affiliationOrgId = String(formData.get("affiliationOrgId") ?? ""); | |
| const bio = String(formData.get("bio") ?? "").trim(); | ||
| if (!name) return; | ||
| - | await serverCaller().stepTeam.create({ | |
| + | await serverCaller().steppingGroup.create({ | |
| cityId: city!.id, | ||
| name, | ||
| - | affiliationOrgId: affiliationOrgId || undefined, | |
| bio: bio || undefined, | ||
| }); | ||
| - | revalidatePath(`/city/${slug}/yard`); | |
| - | } | |
| - | ||
| - | async function addChapter(formData: FormData) { | |
| - | "use server"; | |
| - | const organizationId = String(formData.get("organizationId") ?? ""); | |
| - | const name = String(formData.get("name") ?? "").trim(); | |
| - | const school = String(formData.get("school") ?? "").trim(); | |
| - | const bio = String(formData.get("bio") ?? "").trim(); | |
| - | if (!name || !organizationId) return; | |
| - | await serverCaller().chapter.create({ | |
| - | organizationId, | |
| - | cityId: city!.id, | |
| - | name, | |
| - | school: school || undefined, | |
| - | bio: bio || undefined, | |
| - | }); | |
| - | revalidatePath(`/city/${slug}/yard`); | |
| + | revalidatePath(`/city/${slug}/floor`); | |
| } | ||
| async function addDj(formData: FormData) { | ||
| @@ -70,7 +47,7 @@ export default async function YardPage({ | ||
| name, | ||
| bio: bio || undefined, | ||
| }); | ||
| - | revalidatePath(`/city/${slug}/yard`); | |
| + | revalidatePath(`/city/${slug}/floor`); | |
| } | ||
| async function addPromoter(formData: FormData) { | ||
| @@ -83,7 +60,7 @@ export default async function YardPage({ | ||
| name, | ||
| bio: bio || undefined, | ||
| }); | ||
| - | revalidatePath(`/city/${slug}/yard`); | |
| + | revalidatePath(`/city/${slug}/floor`); | |
| } | ||
| async function addClass(formData: FormData) { | ||
| @@ -100,7 +77,7 @@ export default async function YardPage({ | ||
| schedule: schedule || undefined, | ||
| isFree, | ||
| }); | ||
| - | revalidatePath(`/city/${slug}/yard`); | |
| + | revalidatePath(`/city/${slug}/floor`); | |
| } | ||
| return ( | ||
| @@ -110,186 +87,74 @@ export default async function YardPage({ | ||
| </Link> | ||
| <p className="kicker mt-10 text-ash"> | ||
| - | The Yard · {city.metroLabel} | |
| + | The Floor · {city.metroLabel} | |
| </p> | ||
| - | <h1 className="display mt-3 text-7xl sm:text-8xl">The Yard</h1> | |
| + | <h1 className="display mt-3 text-7xl sm:text-8xl">The Floor</h1> | |
| <p className="serif mt-5 max-w-xl text-xl text-ash"> | ||
| - | The community behind the show - step teams, chapters, DJs, promoters, and | |
| - | classes keeping {city.name} on beat. | |
| + | The community on the floor - stepping groups, DJs, promoters, and | |
| + | classes keeping {city.name} in the pocket. | |
| </p> | ||
| - | {/* Step Teams */} | |
| + | {/* Stepping Groups */} | |
| <section className="mt-16 border-t border-[var(--line)] pt-10"> | ||
| <div className="flex items-end justify-between"> | ||
| <div> | ||
| <p className="kicker text-gold">Section 01</p> | ||
| - | <h2 className="display mt-2 text-4xl">Step Teams</h2> | |
| + | <h2 className="display mt-2 text-4xl">Stepping Groups</h2> | |
| </div> | ||
| <span className="kicker text-ash"> | ||
| - | {String(stepTeams.length).padStart(2, "0")} teams | |
| + | {String(steppingGroups.length).padStart(2, "0")} groups | |
| </span> | ||
| </div> | ||
| - | {stepTeams.length === 0 ? ( | |
| + | {steppingGroups.length === 0 ? ( | |
| <p className="serif mt-8 text-xl text-ash"> | ||
| - | No step teams yet. Put the first line on the yard below. | |
| + | No stepping groups yet. Put the first crew on the floor below. | |
| </p> | ||
| ) : ( | ||
| <ul className="mt-6"> | ||
| - | {stepTeams.map((t, i) => { | |
| - | const org = t.affiliationOrgId | |
| - | ? orgById.get(t.affiliationOrgId) | |
| - | : null; | |
| - | return ( | |
| - | <li | |
| - | key={t.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"> | |
| - | <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} | |
| - | </div> | |
| - | {org ? ( | |
| - | <span className="kicker border border-[var(--line)] px-3 py-1"> | |
| - | {org.letters ?? org.name} | |
| - | </span> | |
| + | {steppingGroups.map((g, i) => ( | |
| + | <li | |
| + | key={g.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"> | |
| + | <Link | |
| + | href={`/city/${slug}/profile/stepping-group/${g.id}`} | |
| + | className="display text-2xl text-bone hover:text-gold sm:text-3xl" | |
| + | > | |
| + | {g.name} | |
| + | </Link> | |
| + | {g.bio ? ( | |
| + | <p className="serif mt-1 text-lg text-ash">{g.bio}</p> | |
| ) : null} | ||
| - | </li> | |
| - | ); | |
| - | })} | |
| + | </div> | |
| + | </li> | |
| + | ))} | |
| </ul> | ||
| )} | ||
| - | <form action={addStepTeam} className="mt-8 flex flex-col gap-3"> | |
| + | <form action={addSteppingGroup} className="mt-8 flex flex-col gap-3"> | |
| <input | ||
| name="name" | ||
| required | ||
| - | placeholder="Team name (e.g. Vanguard Steppers)" | |
| + | placeholder="Group name (e.g. Smooth City Steppers)" | |
| 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="bio" | ||
| - | placeholder="One line about the team (optional)" | |
| - | 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="affiliationOrgId" | |
| - | defaultValue="" | |
| - | className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold" | |
| - | > | |
| - | <option value="" className="bg-stage"> | |
| - | NO AFFILIATION | |
| - | </option> | |
| - | {organizations.map((o) => ( | |
| - | <option key={o.id} value={o.id} className="bg-stage"> | |
| - | {((o.letters ? `${o.letters} ` : "") + o.name).toUpperCase()} | |
| - | </option> | |
| - | ))} | |
| - | </select> | |
| - | <button | |
| - | type="submit" | |
| - | className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" | |
| - | > | |
| - | Add team | |
| - | </button> | |
| - | </div> | |
| - | </form> | |
| - | </section> | |
| - | ||
| - | {/* Chapters */} | |
| - | <section className="mt-16 border-t border-[var(--line)] pt-10"> | |
| - | <div className="flex items-end justify-between"> | |
| - | <div> | |
| - | <p className="kicker text-gold">Section 02</p> | |
| - | <h2 className="display mt-2 text-4xl">Chapters</h2> | |
| - | </div> | |
| - | <span className="kicker text-ash"> | |
| - | {String(chapters.length).padStart(2, "0")} chapters | |
| - | </span> | |
| - | </div> | |
| - | ||
| - | {chapters.length === 0 ? ( | |
| - | <p className="serif mt-8 text-xl text-ash"> | |
| - | No chapters yet. Register the first Divine Nine chapter below. | |
| - | </p> | |
| - | ) : ( | |
| - | <ul className="mt-6"> | |
| - | {chapters.map((c, i) => { | |
| - | const org = orgById.get(c.organizationId); | |
| - | return ( | |
| - | <li | |
| - | key={c.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"> | |
| - | <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> | |
| - | </div> | |
| - | {org ? ( | |
| - | <span className="kicker border border-[var(--line)] px-3 py-1"> | |
| - | {org.letters ?? org.name} | |
| - | </span> | |
| - | ) : null} | |
| - | </li> | |
| - | ); | |
| - | })} | |
| - | </ul> | |
| - | )} | |
| - | ||
| - | <form action={addChapter} className="mt-8 flex flex-col gap-3"> | |
| - | <input | |
| - | name="name" | |
| - | required | |
| - | placeholder="Chapter name (e.g. Alpha Beta Chapter)" | |
| - | 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="school" | |
| - | placeholder="School or post (optional)" | |
| + | placeholder="One line about the group (optional)" | |
| 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="organizationId" | |
| - | required | |
| - | defaultValue="" | |
| - | className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold" | |
| - | > | |
| - | <option value="" disabled className="bg-stage"> | |
| - | SELECT ORGANIZATION | |
| - | </option> | |
| - | {organizations.map((o) => ( | |
| - | <option key={o.id} value={o.id} className="bg-stage"> | |
| - | {((o.letters ? `${o.letters} ` : "") + o.name).toUpperCase()} | |
| - | </option> | |
| - | ))} | |
| - | </select> | |
| + | <div className="mt-2"> | |
| <button | ||
| type="submit" | ||
| className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" | ||
| > | ||
| - | Add chapter | |
| + | Add group | |
| </button> | ||
| </div> | ||
| </form> | ||
| @@ -299,7 +164,7 @@ export default async function YardPage({ | ||
| <section className="mt-16 border-t border-[var(--line)] pt-10"> | ||
| <div className="flex items-end justify-between"> | ||
| <div> | ||
| - | <p className="kicker text-gold">Section 03</p> | |
| + | <p className="kicker text-gold">Section 02</p> | |
| <h2 className="display mt-2 text-4xl">DJs</h2> | ||
| </div> | ||
| <span className="kicker text-ash"> | ||
| @@ -344,7 +209,7 @@ export default async function YardPage({ | ||
| <input | ||
| name="name" | ||
| required | ||
| - | placeholder="DJ name (e.g. DJ Cadence)" | |
| + | placeholder="DJ name (e.g. DJ Smooth)" | |
| className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold" | ||
| /> | ||
| <input | ||
| @@ -367,7 +232,7 @@ export default async function YardPage({ | ||
| <section className="mt-16 border-t border-[var(--line)] pt-10"> | ||
| <div className="flex items-end justify-between"> | ||
| <div> | ||
| - | <p className="kicker text-gold">Section 04</p> | |
| + | <p className="kicker text-gold">Section 03</p> | |
| <h2 className="display mt-2 text-4xl">Promoters</h2> | ||
| </div> | ||
| <span className="kicker text-ash"> | ||
| @@ -377,7 +242,7 @@ export default async function YardPage({ | ||
| {promoters.length === 0 ? ( | ||
| <p className="serif mt-8 text-xl text-ash"> | ||
| - | No promoters yet. Name the first one putting on the show below. | |
| + | No promoters yet. Name the first one hosting the set below. | |
| </p> | ||
| ) : ( | ||
| <ul className="mt-6"> | ||
| @@ -412,7 +277,7 @@ export default async function YardPage({ | ||
| <input | ||
| name="name" | ||
| required | ||
| - | placeholder="Promoter or brand (e.g. Yardline Events)" | |
| + | placeholder="Promoter or brand (e.g. Westside Sets)" | |
| className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold" | ||
| /> | ||
| <input | ||
| @@ -435,7 +300,7 @@ export default async function YardPage({ | ||
| <section className="mt-16 border-t border-[var(--line)] pt-10"> | ||
| <div className="flex items-end justify-between"> | ||
| <div> | ||
| - | <p className="kicker text-gold">Section 05</p> | |
| + | <p className="kicker text-gold">Section 04</p> | |
| <h2 className="display mt-2 text-4xl">Classes</h2> | ||
| </div> | ||
| <span className="kicker text-ash"> | ||
| @@ -445,7 +310,7 @@ export default async function YardPage({ | ||
| {classes.length === 0 ? ( | ||
| <p className="serif mt-8 text-xl text-ash"> | ||
| - | No classes yet. Teach the first line below. | |
| + | No classes yet. Teach the first count below. | |
| </p> | ||
| ) : ( | ||
| <ul className="mt-6"> | ||
| @@ -485,7 +350,7 @@ export default async function YardPage({ | ||
| <input | ||
| name="title" | ||
| required | ||
| - | placeholder="Class title (e.g. Intro to Stepping)" | |
| + | placeholder="Class title (e.g. Intro to Chicago Stepping)" | |
| 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"> | ||
| @@ -516,6 +381,7 @@ export default async function YardPage({ | ||
| </div> | ||
| </form> | ||
| </section> | ||
| + | <SiteFooter /> | |
| </main> | ||
| ); | ||
| } |
| @@ -0,0 +1,195 @@ | ||
| + | import Link from "next/link"; | |
| + | import { SiteFooter } from "@/components/site-footer"; | |
| + | import { notFound } from "next/navigation"; | |
| + | import { revalidatePath } from "next/cache"; | |
| + | import { serverCaller } from "@/lib/trpc/server"; | |
| + | ||
| + | export const dynamic = "force-dynamic"; | |
| + | ||
| + | // Pull a YouTube id out of watch / youtu.be / embed / live urls so the stream | |
| + | // embeds inline; anything else just gets a "watch live" link. | |
| + | 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 m = u.pathname.match(/^\/(?:embed|shorts|live)\/([a-zA-Z0-9_-]{11})/); | |
| + | if (m) return m[1]; | |
| + | } | |
| + | return null; | |
| + | } catch { | |
| + | return null; | |
| + | } | |
| + | } | |
| + | ||
| + | export default async function LivePage({ | |
| + | 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 }); | |
| + | const live = events.filter((e) => e.isLive); | |
| + | ||
| + | async function goLive(formData: FormData) { | |
| + | "use server"; | |
| + | const id = String(formData.get("eventId") ?? ""); | |
| + | const liveUrl = String(formData.get("liveUrl") ?? "").trim(); | |
| + | if (!id || !liveUrl) return; | |
| + | await serverCaller().event.setLive({ id, isLive: true, liveUrl }); | |
| + | revalidatePath(`/city/${slug}/live`); | |
| + | revalidatePath(`/city/${slug}`); | |
| + | } | |
| + | ||
| + | async function endLive(formData: FormData) { | |
| + | "use server"; | |
| + | const id = String(formData.get("eventId") ?? ""); | |
| + | if (!id) return; | |
| + | await serverCaller().event.setLive({ id, isLive: false }); | |
| + | revalidatePath(`/city/${slug}/live`); | |
| + | revalidatePath(`/city/${slug}`); | |
| + | } | |
| + | ||
| + | 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 flex items-center gap-3 text-gold"> | |
| + | <span className="inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-gold" /> | |
| + | Live · {city.metroLabel} | |
| + | </p> | |
| + | <h1 className="display mt-3 text-7xl sm:text-8xl">Live</h1> | |
| + | <p className="serif mt-5 max-w-xl text-xl text-ash"> | |
| + | Catch a set live from {city.name}, or put your own set on. Drop a | |
| + | YouTube or Facebook live link and it shows up here. | |
| + | </p> | |
| + | ||
| + | {/* Live now */} | |
| + | <section className="mt-16 border-t border-[var(--line)] pt-10"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">On Now</h2> | |
| + | <span className="kicker text-ash"> | |
| + | {String(live.length).padStart(2, "0")} live | |
| + | </span> | |
| + | </div> | |
| + | ||
| + | {live.length === 0 ? ( | |
| + | <p className="serif mt-8 text-xl text-ash"> | |
| + | Nobody is live right now. Start a stream below and the set goes on | |
| + | here. | |
| + | </p> | |
| + | ) : ( | |
| + | <ul className="mt-8 flex flex-col gap-12"> | |
| + | {live.map((e) => { | |
| + | const ytId = e.liveUrl ? youtubeId(e.liveUrl) : null; | |
| + | return ( | |
| + | <li key={e.id}> | |
| + | <div className="flex items-end justify-between gap-5"> | |
| + | <h3 className="display text-2xl sm:text-3xl">{e.title}</h3> | |
| + | <span className="kicker flex items-center gap-2 text-gold"> | |
| + | <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-gold" /> | |
| + | LIVE | |
| + | </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={e.title} | |
| + | allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" | |
| + | allowFullScreen | |
| + | className="h-full w-full" | |
| + | /> | |
| + | </div> | |
| + | ) : e.liveUrl ? ( | |
| + | <a | |
| + | href={e.liveUrl} | |
| + | target="_blank" | |
| + | rel="noopener noreferrer" | |
| + | className="kicker mt-5 inline-block bg-gold px-6 py-3 text-stage hover:bg-gold-bright" | |
| + | > | |
| + | Watch live → | |
| + | </a> | |
| + | ) : null} | |
| + | ||
| + | <form action={endLive} className="mt-4"> | |
| + | <input type="hidden" name="eventId" value={e.id} /> | |
| + | <button | |
| + | type="submit" | |
| + | className="kicker text-ash hover:text-bone" | |
| + | > | |
| + | End stream | |
| + | </button> | |
| + | </form> | |
| + | </li> | |
| + | ); | |
| + | })} | |
| + | </ul> | |
| + | )} | |
| + | </section> | |
| + | ||
| + | {/* Go live */} | |
| + | <section className="mt-16 border-t border-[var(--line)] pt-10"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">Go Live</h2> | |
| + | <span className="kicker">Put a set on</span> | |
| + | </div> | |
| + | ||
| + | {events.length === 0 ? ( | |
| + | <p className="serif mt-8 text-xl text-ash"> | |
| + | Post a set on the city page first, then come back to take it live. | |
| + | </p> | |
| + | ) : ( | |
| + | <form action={goLive} className="mt-6 flex flex-col gap-3"> | |
| + | <select | |
| + | name="eventId" | |
| + | required | |
| + | defaultValue="" | |
| + | className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold" | |
| + | > | |
| + | <option value="" disabled className="bg-stage"> | |
| + | CHOOSE A SET | |
| + | </option> | |
| + | {events.map((e) => ( | |
| + | <option key={e.id} value={e.id} className="bg-stage"> | |
| + | {e.title.toUpperCase()} | |
| + | </option> | |
| + | ))} | |
| + | </select> | |
| + | <input | |
| + | name="liveUrl" | |
| + | required | |
| + | type="url" | |
| + | placeholder="Live stream URL (YouTube or Facebook live)" | |
| + | className="serif border-b border-[var(--line)] bg-transparent py-3 text-lg 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" | |
| + | > | |
| + | Go live | |
| + | </button> | |
| + | </div> | |
| + | </form> | |
| + | )} | |
| + | </section> | |
| + | <SiteFooter /> | |
| + | </main> | |
| + | ); | |
| + | } |
| @@ -1,32 +1,30 @@ | ||
| import Link from "next/link"; | ||
| + | import { SiteFooter } from "@/components/site-footer"; | |
| import { notFound } from "next/navigation"; | ||
| import { revalidatePath } from "next/cache"; | ||
| import { EVENT_TYPE } from "@stepping/types"; | ||
| import { serverCaller } from "@/lib/trpc/server"; | ||
| + | import { cityTimeZone, fmtEventTime } from "@/lib/format"; | |
| export const dynamic = "force-dynamic"; | ||
| const TYPE_LABEL: Record<string, string> = { | ||
| - | step_show: "Step show", | |
| - | stroll: "Stroll", | |
| - | competition: "Competition", | |
| + | set: "Set", | |
| + | weekender: "Weekender", | |
| class: "Class", | ||
| + | workshop: "Workshop", | |
| + | competition: "Competition", | |
| + | special: "Special event", | |
| 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(); | |
| - | } | |
| + | const MODULES = [ | |
| + | { seg: "floor", label: "The Floor", note: "Stepping groups, DJs, promoters, classes" }, | |
| + | { seg: "videos", label: "The Tape", note: "Sets and footwork on camera" }, | |
| + | { seg: "road", label: "The Road", note: "Event centers, hotels, food, the city guide" }, | |
| + | { seg: "live", label: "Live", note: "Watch a set live, or go live" }, | |
| + | ]; | |
| + | ||
| export default async function CityPage({ | ||
| params, | ||
| @@ -38,19 +36,30 @@ export default async function CityPage({ | ||
| const city = await caller.city.bySlug({ slug }); | ||
| if (!city) notFound(); | ||
| + | const tz = cityTimeZone(city.state); | |
| + | ||
| const events = await caller.event.list({ cityId: city.id }); | ||
| + | const citySponsors = await caller.sponsor.listActive({ cityId: city.id }); | |
| + | const liveNow = events.filter((e) => e.isLive); | |
| + | const promotions = events.filter((e) => e.isPromoted && !e.isLive); | |
| + | const specials = events.filter((e) => e.type === "special"); | |
| + | const sets = events.filter((e) => !e.isLive); | |
| async function addEvent(formData: FormData) { | ||
| "use server"; | ||
| const title = String(formData.get("title") ?? "").trim(); | ||
| - | const type = String(formData.get("type") ?? "step_show"); | |
| + | const type = String(formData.get("type") ?? "set"); | |
| + | const description = String(formData.get("description") ?? "").trim(); | |
| const startsAtRaw = String(formData.get("startsAt") ?? ""); | ||
| + | const isPromoted = formData.get("isPromoted") != null; | |
| if (!title) return; | ||
| await serverCaller().event.create({ | ||
| cityId: city!.id, | ||
| title, | ||
| type: type as (typeof EVENT_TYPE)[number], | ||
| + | description: description || undefined, | |
| startsAt: startsAtRaw ? new Date(startsAtRaw) : undefined, | ||
| + | isPromoted, | |
| }); | ||
| revalidatePath(`/city/${slug}`); | ||
| } | ||
| @@ -65,42 +74,146 @@ export default async function CityPage({ | ||
| {city.metroLabel} · {city.lat?.toFixed(3)}, {city.lng?.toFixed(3)} | ||
| </p> | ||
| <h1 className="display mt-3 text-7xl sm:text-8xl">{city.name}</h1> | ||
| + | <p className="serif mt-5 max-w-xl text-xl text-ash"> | |
| + | Everything Chicago steppin' in {city.name} - the sets, the people, | |
| + | the music, and the road in. All in one place. | |
| + | </p> | |
| - | <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> | |
| + | {/* Live now */} | |
| + | {liveNow.length > 0 ? ( | |
| <Link | ||
| - | href={`/city/${slug}/route`} | |
| - | className="kicker inline-block text-gold hover:text-gold-bright" | |
| + | href={`/city/${slug}/live`} | |
| + | className="mt-10 flex items-center justify-between border border-gold bg-gold/10 px-5 py-4 hover:bg-gold/20" | |
| > | ||
| - | The Route → | |
| + | <span className="kicker flex items-center gap-3 text-gold"> | |
| + | <span className="inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-gold" /> | |
| + | Live now · {liveNow.length} streaming | |
| + | </span> | |
| + | <span className="kicker text-gold">Watch →</span> | |
| </Link> | ||
| - | </div> | |
| + | ) : null} | |
| + | ||
| + | {/* Module nav */} | |
| + | <nav className="mt-10 grid gap-px sm:grid-cols-2"> | |
| + | {MODULES.map((m) => ( | |
| + | <Link | |
| + | key={m.seg} | |
| + | href={`/city/${slug}/${m.seg}`} | |
| + | className="group border-t border-[var(--line)] py-6 pr-6" | |
| + | > | |
| + | <div className="flex items-center justify-between"> | |
| + | <h2 className="display text-3xl group-hover:text-gold">{m.label}</h2> | |
| + | <span className="kicker text-gold opacity-0 group-hover:opacity-100"> | |
| + | → | |
| + | </span> | |
| + | </div> | |
| + | <p className="serif mt-1 text-lg text-ash">{m.note}</p> | |
| + | </Link> | |
| + | ))} | |
| + | </nav> | |
| + | ||
| + | {/* Sponsor slot (admin-placed) */} | |
| + | {citySponsors.length > 0 ? ( | |
| + | <div className="mt-10 border-t border-[var(--line)] pt-5"> | |
| + | <p className="kicker text-ash">Sponsored in {city.name}</p> | |
| + | <div className="mt-3 flex flex-wrap gap-x-8 gap-y-3"> | |
| + | {citySponsors.map((s) => { | |
| + | const inner = ( | |
| + | <span className="group inline-flex items-baseline gap-2"> | |
| + | <span className="display text-2xl text-bone group-hover:text-gold"> | |
| + | {s.name} | |
| + | </span> | |
| + | {s.blurb ? ( | |
| + | <span className="serif text-lg text-ash">{s.blurb}</span> | |
| + | ) : null} | |
| + | </span> | |
| + | ); | |
| + | return s.linkUrl ? ( | |
| + | <a key={s.id} href={s.linkUrl} target="_blank" rel="noopener noreferrer nofollow"> | |
| + | {inner} | |
| + | </a> | |
| + | ) : ( | |
| + | <span key={s.id}>{inner}</span> | |
| + | ); | |
| + | })} | |
| + | </div> | |
| + | </div> | |
| + | ) : null} | |
| + | ||
| + | {/* Promotions */} | |
| + | {promotions.length > 0 ? ( | |
| + | <section className="mt-16 border-t border-[var(--line)] pt-10"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">Promotions</h2> | |
| + | <span className="kicker text-ash">Featured</span> | |
| + | </div> | |
| + | <ul className="mt-6 grid gap-px sm:grid-cols-2"> | |
| + | {promotions.map((e) => ( | |
| + | <li key={e.id} className="border-t border-gold/40 py-5 pr-5"> | |
| + | <Link href={`/city/${slug}/event/${e.id}`} className="group block"> | |
| + | <span className="kicker text-gold"> | |
| + | {(TYPE_LABEL[e.type] ?? e.type).toUpperCase()} · {fmtEventTime(e.startsAt, tz)} | |
| + | </span> | |
| + | <p className="display mt-1 text-2xl text-bone group-hover:text-gold"> | |
| + | {e.title} | |
| + | </p> | |
| + | </Link> | |
| + | </li> | |
| + | ))} | |
| + | </ul> | |
| + | </section> | |
| + | ) : null} | |
| - | {/* Add an event */} | |
| - | <section className="mt-14 border-t border-[var(--line)] pt-10"> | |
| + | {/* Special events */} | |
| + | {specials.length > 0 ? ( | |
| + | <section className="mt-16 border-t border-[var(--line)] pt-10"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">Special Events</h2> | |
| + | <span className="kicker text-ash">Causes & specials</span> | |
| + | </div> | |
| + | <ul className="mt-6"> | |
| + | {specials.map((e) => ( | |
| + | <li | |
| + | key={e.id} | |
| + | className="flex items-center gap-5 border-b border-[var(--line)] py-6" | |
| + | > | |
| + | <div className="flex-1"> | |
| + | <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">{fmtEventTime(e.startsAt, tz)}</p> | |
| + | </div> | |
| + | <span className="kicker border border-[var(--line)] px-3 py-1"> | |
| + | SPECIAL | |
| + | </span> | |
| + | </li> | |
| + | ))} | |
| + | </ul> | |
| + | </section> | |
| + | ) : null} | |
| + | ||
| + | {/* Post a set */} | |
| + | <section className="mt-16 border-t border-[var(--line)] pt-10"> | |
| <div className="flex items-end justify-between"> | ||
| - | <h2 className="display text-3xl">Post a show</h2> | |
| - | <span className="kicker">Add to the line-up</span> | |
| + | <h2 className="display text-3xl">Post a set</h2> | |
| + | <span className="kicker">Anyone can add to the city</span> | |
| </div> | ||
| <form action={addEvent} className="mt-6 flex flex-col gap-3"> | ||
| <input | ||
| name="title" | ||
| required | ||
| - | placeholder="Event name (e.g. Homecoming Step Show)" | |
| + | placeholder="Event name (e.g. Saturday Night Set)" | |
| 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 gap-4"> | |
| + | <input | |
| + | name="description" | |
| + | placeholder="Add details, dress code, or a promo line (optional)" | |
| + | 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 items-center gap-4"> | |
| <select | ||
| name="type" | ||
| className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold" | ||
| @@ -116,6 +229,10 @@ export default async function CityPage({ | ||
| name="startsAt" | ||
| className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold" | ||
| /> | ||
| + | <label className="kicker flex items-center gap-2 text-ash"> | |
| + | <input type="checkbox" name="isPromoted" className="h-4 w-4 accent-gold" /> | |
| + | Promote | |
| + | </label> | |
| <button | ||
| type="submit" | ||
| className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" | ||
| @@ -126,21 +243,21 @@ export default async function CityPage({ | ||
| </form> | ||
| </section> | ||
| - | {/* The line-up */} | |
| + | {/* The sets */} | |
| <section className="mt-16 border-t border-[var(--line)] pt-10"> | ||
| <div className="flex items-end justify-between"> | ||
| - | <h2 className="display text-3xl">The Line-Up</h2> | |
| + | <h2 className="display text-3xl">The Sets</h2> | |
| <span className="kicker text-ash"> | ||
| - | {String(events.length).padStart(2, "0")} shows | |
| + | {String(sets.length).padStart(2, "0")} listed | |
| </span> | ||
| </div> | ||
| - | {events.length === 0 ? ( | |
| + | {sets.length === 0 ? ( | |
| <p className="serif mt-8 text-xl text-ash"> | ||
| - | No shows yet. Post the first one above. | |
| + | No sets yet. Post the first one above. | |
| </p> | ||
| ) : ( | ||
| <ul className="mt-6"> | ||
| - | {events.map((e, i) => ( | |
| + | {sets.map((e, i) => ( | |
| <li | ||
| key={e.id} | ||
| className="flex items-center gap-5 border-b border-[var(--line)] py-6" | ||
| @@ -155,7 +272,7 @@ export default async function CityPage({ | ||
| > | ||
| {e.title} | ||
| </Link> | ||
| - | <p className="kicker mt-1 text-ash">{fmt(e.startsAt)}</p> | |
| + | <p className="kicker mt-1 text-ash">{fmtEventTime(e.startsAt, tz)}</p> | |
| </div> | ||
| <span className="kicker border border-[var(--line)] px-3 py-1"> | ||
| {(TYPE_LABEL[e.type] ?? e.type).toUpperCase()} | ||
| @@ -165,6 +282,7 @@ export default async function CityPage({ | ||
| </ul> | ||
| )} | ||
| </section> | ||
| + | <SiteFooter /> | |
| </main> | ||
| ); | ||
| } |
| @@ -7,8 +7,7 @@ export const dynamic = "force-dynamic"; | ||
| const KIND_LABEL: Record<string, string> = { | ||
| dj: "DJ", | ||
| promoter: "Promoter", | ||
| - | "step-team": "Step Team", | |
| - | chapter: "Chapter", | |
| + | "stepping-group": "Stepping Group", | |
| class: "Class", | ||
| }; | ||
| @@ -58,39 +57,15 @@ export default async function ProfilePage({ | ||
| if (!promoter) notFound(); | ||
| title = promoter.name; | ||
| body = promoter.bio ?? null; | ||
| - | emptyBody = "No bio yet. This promoter lets the shows speak."; | |
| + | emptyBody = "No bio yet. This promoter lets the sets 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 if (kind === "stepping-group") { | |
| + | const group = await caller.steppingGroup.byId({ id }); | |
| + | if (!group) notFound(); | |
| + | title = group.name; | |
| + | body = group.bio ?? null; | |
| + | emptyBody = "No bio yet. This group lets the glide do the talking."; | |
| + | createdAt = group.createdAt; | |
| } else { | ||
| const klass = await caller.class.byId({ id }); | ||
| if (!klass) notFound(); | ||
| @@ -105,8 +80,8 @@ export default async function ProfilePage({ | ||
| 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 href={`/city/${slug}/floor`} className="kicker hover:text-gold"> | |
| + | ← The Floor | |
| </Link> | ||
| <p className="kicker mt-10 text-ash"> |
| @@ -1,4 +1,5 @@ | ||
| import Link from "next/link"; | ||
| + | import { SiteFooter } from "@/components/site-footer"; | |
| import { notFound } from "next/navigation"; | ||
| import { revalidatePath } from "next/cache"; | ||
| import { TRAVEL_CATEGORY } from "@stepping/types"; | ||
| @@ -17,13 +18,22 @@ const SECTIONS: { | ||
| field: string; | ||
| cta: string; | ||
| }[] = [ | ||
| + | { | |
| + | category: "event_center", | |
| + | heading: "Event Centers", | |
| + | count: "venues", | |
| + | badge: "VENUE", | |
| + | empty: "No event centers listed yet. Add the first venue that hosts sets below.", | |
| + | field: "Event center (e.g. The Promontory)", | |
| + | cta: "Add event center", | |
| + | }, | |
| { | ||
| 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)", | |
| + | empty: "No hotels yet. Drop the host hotel or a place to stay below.", | |
| + | field: "Hotel name (e.g. Hyatt Regency)", | |
| cta: "Add hotel", | ||
| }, | ||
| { | ||
| @@ -32,7 +42,7 @@ const SECTIONS: { | ||
| 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)", | |
| + | field: "Restaurant name (e.g. Pearl's Place)", | |
| cta: "Add restaurant", | ||
| }, | ||
| { | ||
| @@ -40,8 +50,8 @@ const SECTIONS: { | ||
| 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)", | |
| + | empty: "No stops yet. Add the closest mall or shop to the set below.", | |
| + | field: "Mall or shop (e.g. Block 37)", | |
| cta: "Add shopping", | ||
| }, | ||
| { | ||
| @@ -49,17 +59,26 @@ const SECTIONS: { | ||
| 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)", | |
| + | empty: "No sights yet. Mark a main attraction worth seeing in town below.", | |
| + | field: "Attraction (e.g. DuSable Black History Museum)", | |
| cta: "Add attraction", | ||
| }, | ||
| + | { | |
| + | category: "entertainment", | |
| + | heading: "Entertainment", | |
| + | count: "spots", | |
| + | badge: "PLAY", | |
| + | empty: "No entertainment yet. Add a lounge, club, or show near the set below.", | |
| + | field: "Entertainment (e.g. jazz lounge, comedy show)", | |
| + | cta: "Add entertainment", | |
| + | }, | |
| { | ||
| 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)", | |
| + | field: "Hospital (e.g. University of Chicago Medical Center)", | |
| cta: "Add hospital", | ||
| }, | ||
| ]; | ||
| @@ -91,7 +110,7 @@ export default async function RoutePage({ | ||
| name, | ||
| address: address || undefined, | ||
| }); | ||
| - | revalidatePath(`/city/${slug}/route`); | |
| + | revalidatePath(`/city/${slug}/road`); | |
| } | ||
| return ( | ||
| @@ -100,11 +119,12 @@ export default async function RoutePage({ | ||
| ← {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="kicker mt-10 text-ash">The Road · {city.metroLabel}</p> | |
| + | <h1 className="display mt-3 text-7xl sm:text-8xl">The Road</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. | |
| + | In town for a set or a weekender. The event centers, hotels, food, | |
| + | shopping, attractions, entertainment, and the nearest hospital just in | |
| + | case. Everything you need to do {city.name} right. | |
| </p> | ||
| {SECTIONS.map((section, idx) => { | ||
| @@ -179,6 +199,7 @@ export default async function RoutePage({ | ||
| </section> | ||
| ); | ||
| })} | ||
| + | <SiteFooter /> | |
| </main> | ||
| ); | ||
| } |
| @@ -1,8 +1,10 @@ | ||
| import Link from "next/link"; | ||
| + | import { SiteFooter } from "@/components/site-footer"; | |
| import { notFound } from "next/navigation"; | ||
| import { revalidatePath } from "next/cache"; | ||
| import { VIDEO_PLATFORM } from "@stepping/types"; | ||
| import { serverCaller } from "@/lib/trpc/server"; | ||
| + | import { VideoEmbed } from "@/components/video-embed"; | |
| export const dynamic = "force-dynamic"; | ||
| @@ -73,7 +75,7 @@ export default async function VideosPage({ | ||
| </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}. | |
| + | Sets, classes, and footwork caught on camera across {city.name}. | |
| </p> | ||
| {/* Add a video */} | ||
| @@ -86,7 +88,7 @@ export default async function VideosPage({ | ||
| <input | ||
| name="title" | ||
| required | ||
| - | placeholder="Clip title (e.g. Spring Yardfest 2025)" | |
| + | placeholder="Clip title (e.g. Saturday Night Set 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 | ||
| @@ -151,14 +153,8 @@ export default async function VideosPage({ | ||
| </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 className="mt-5"> | |
| + | <VideoEmbed id={ytId} title={v.title} /> | |
| </div> | ||
| ) : null} | ||
| @@ -176,6 +172,7 @@ export default async function VideosPage({ | ||
| </ul> | ||
| )} | ||
| </section> | ||
| + | <SiteFooter /> | |
| </main> | ||
| ); | ||
| } |
| @@ -1,12 +1,15 @@ | ||
| @import "tailwindcss"; | ||
| + | /* Chrome Noir - black ground, ivory ink, one cobalt accent, steel metadata. | |
| + | The accent token is still named --gold so every existing utility (text-gold, | |
| + | bg-gold, the cursor, selection) reskins from this one place. */ | |
| :root { | ||
| - | --stage: #0f0e0c; | |
| - | --bone: #f2ece0; | |
| - | --gold: #c8a24c; | |
| - | --gold-bright: #e4c572; | |
| - | --ash: #8a8275; | |
| - | --line: rgba(242, 236, 224, 0.14); | |
| + | --stage: #0a0a0b; | |
| + | --bone: #f4f1ea; | |
| + | --gold: #3d5afe; | |
| + | --gold-bright: #6e86ff; | |
| + | --ash: #8c93a3; | |
| + | --line: rgba(244, 241, 234, 0.12); | |
| } | ||
| @theme inline { | ||
| @@ -41,15 +44,6 @@ body { | ||
| color: var(--stage); | ||
| } | ||
| - | @media (pointer: fine) { | |
| - | .beat-cursor-on, | |
| - | .beat-cursor-on a, | |
| - | .beat-cursor-on button, | |
| - | .beat-cursor-on * { | |
| - | cursor: none; | |
| - | } | |
| - | } | |
| - | ||
| .kicker { | ||
| font-family: var(--font-space-mono), monospace; | ||
| font-size: 0.72rem; | ||
| @@ -73,51 +67,13 @@ body { | ||
| background: | ||
| radial-gradient( | ||
| 120% 90% at 70% 45%, | ||
| - | rgba(200, 162, 76, 0.16) 0%, | |
| - | rgba(200, 162, 76, 0.05) 30%, | |
| + | rgba(61, 90, 254, 0.18) 0%, | |
| + | rgba(61, 90, 254, 0.05) 30%, | |
| transparent 60% | ||
| ), | ||
| var(--stage); | ||
| } | ||
| - | .beat-dot { | |
| - | position: fixed; | |
| - | top: 0; | |
| - | left: 0; | |
| - | width: 11px; | |
| - | height: 11px; | |
| - | border-radius: 9999px; | |
| - | background: var(--gold); | |
| - | pointer-events: none; | |
| - | z-index: 60; | |
| - | will-change: transform; | |
| - | } | |
| - | ||
| - | .clap-ripple { | |
| - | position: fixed; | |
| - | width: 10px; | |
| - | height: 10px; | |
| - | border: 1.5px solid var(--gold); | |
| - | border-radius: 9999px; | |
| - | transform: translate(-50%, -50%); | |
| - | pointer-events: none; | |
| - | z-index: 59; | |
| - | animation: clap 0.6s ease-out forwards; | |
| - | } | |
| - | ||
| - | @keyframes clap { | |
| - | 0% { | |
| - | opacity: 0.9; | |
| - | width: 10px; | |
| - | height: 10px; | |
| - | } | |
| - | 100% { | |
| - | opacity: 0; | |
| - | width: 96px; | |
| - | height: 96px; | |
| - | } | |
| - | } | |
| - | ||
| @keyframes marquee { | ||
| from { | ||
| transform: translateX(0); |
| @@ -3,7 +3,6 @@ import { Bricolage_Grotesque, Instrument_Serif, Space_Mono } from "next/font/goo | ||
| import "./globals.css"; | ||
| import { TRPCProvider } from "@/lib/trpc/provider"; | ||
| import { SmoothScroll } from "@/components/smooth-scroll"; | ||
| - | import { BeatCursor } from "@/components/beat-cursor"; | |
| const bricolage = Bricolage_Grotesque({ | ||
| subsets: ["latin"], | ||
| @@ -26,10 +25,14 @@ const spaceMono = Space_Mono({ | ||
| display: "swap", | ||
| }); | ||
| + | const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://stepping.zionboggan.com"; | |
| + | const DESCRIPTION = | |
| + | "The home for Chicago stepping. Find the sets, the people, the music, and the road to every city."; | |
| + | ||
| 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.", | |
| + | metadataBase: new URL(SITE_URL), | |
| + | title: "Stepping - In the Pocket", | |
| + | description: DESCRIPTION, | |
| applicationName: "Stepping", | ||
| manifest: "/manifest.webmanifest", | ||
| icons: { | ||
| @@ -37,10 +40,24 @@ export const metadata: Metadata = { | ||
| shortcut: "/icon.svg", | ||
| apple: "/icon.svg", | ||
| }, | ||
| + | openGraph: { | |
| + | title: "Stepping - Chicago Stepping, city by city", | |
| + | description: DESCRIPTION, | |
| + | siteName: "Stepping", | |
| + | url: SITE_URL, | |
| + | images: [{ url: `${SITE_URL}/og-image.png`, width: 1200, height: 630 }], | |
| + | type: "website", | |
| + | }, | |
| + | twitter: { | |
| + | card: "summary_large_image", | |
| + | title: "Stepping - Chicago Stepping, city by city", | |
| + | description: DESCRIPTION, | |
| + | images: [`${SITE_URL}/og-image.png`], | |
| + | }, | |
| }; | ||
| export const viewport: Viewport = { | ||
| - | themeColor: "#0F0E0C", | |
| + | themeColor: "#0a0a0b", | |
| }; | ||
| export default function RootLayout({ | ||
| @@ -51,10 +68,9 @@ export default function RootLayout({ | ||
| return ( | ||
| <html | ||
| lang="en" | ||
| - | className={`${bricolage.variable} ${instrument.variable} ${spaceMono.variable} beat-cursor-on h-full`} | |
| + | className={`${bricolage.variable} ${instrument.variable} ${spaceMono.variable} h-full`} | |
| > | ||
| <body className="min-h-full bg-stage text-bone"> | ||
| - | <BeatCursor /> | |
| <SmoothScroll> | ||
| <TRPCProvider>{children}</TRPCProvider> | ||
| </SmoothScroll> |
| @@ -1,19 +1,18 @@ | ||
| import Link from "next/link"; | ||
| import { serverCaller } from "@/lib/trpc/server"; | ||
| import { Marquee } from "@/components/marquee"; | ||
| - | import { HeroCanvas } from "@/components/hero-canvas"; | |
| + | import { ChromeDiscoCanvas } from "@/components/chrome-disco-canvas"; | |
| import { MobileNav } from "@/components/mobile-nav"; | ||
| export const dynamic = "force-dynamic"; | ||
| - | const NINE = ["ΑΦΑ", "ΑΚΑ", "ΚΑΨ", "ΩΨΦ", "ΔΣΘ", "ΦΒΣ", "ΖΦΒ", "ΣΓΡ", "ΙΦΘ"]; | |
| + | const COUNT = ["6", "·", "COUNT", "·", "8"]; | |
| const YARD = [ | ||
| - | { k: "Chapters", d: "Fraternity and sorority chapters in each city." }, | |
| - | { k: "Step teams", d: "The crews putting in work for the next show." }, | |
| + | { k: "Stepping groups", d: "The crews and clubs holding it down in each city." }, | |
| { k: "DJs", d: "Who is spinning the set, and where." }, | ||
| - | { k: "Promoters", d: "The people who throw the shows and strolls." }, | |
| - | { k: "Classes", d: "Learn the craft. Stomp, clap, spoken word." }, | |
| + | { k: "Promoters", d: "The people who host the sets and weekenders." }, | |
| + | { k: "Classes", d: "Learn the count. Smooth, in the pocket, on time." }, | |
| ]; | ||
| function fmt(d: Date | null) { | ||
| @@ -51,71 +50,82 @@ export default async function Home() { | ||
| STEPPING | ||
| </Link> | ||
| <nav className="hidden gap-7 sm:flex"> | ||
| - | <a href="#lineup" className="kicker hover:text-bone"> | |
| - | The Line-Up | |
| + | <a href="#sets" className="kicker hover:text-bone"> | |
| + | The Sets | |
| </a> | ||
| - | <a href="#yard" className="kicker hover:text-bone"> | |
| - | The Yard | |
| + | <a href="#floor" className="kicker hover:text-bone"> | |
| + | The Floor | |
| </a> | ||
| - | <a href="#route" className="kicker hover:text-bone"> | |
| - | The Route | |
| + | <a href="#road" className="kicker hover:text-bone"> | |
| + | The Road | |
| </a> | ||
| </nav> | ||
| <MobileNav /> | ||
| </header> | ||
| - | {/* STAGE */} | |
| - | <section className="relative flex min-h-[100svh] flex-col justify-center overflow-hidden px-5 pt-24 sm:px-8"> | |
| - | <HeroCanvas /> | |
| - | <div className="relative z-10"> | |
| - | <p className="kicker beat-in b1">On Beat / Divine Nine Stepping</p> | |
| - | <h1 className="display mt-6 text-[20vw] leading-[0.82] sm:text-[16vw]"> | |
| - | <span className="block beat-in b1">STOMP.</span> | |
| - | <span className="block text-gold beat-in b2">CLAP.</span> | |
| - | <span className="serif block italic font-normal beat-in b3">speak.</span> | |
| - | </h1> | |
| - | <p className="serif mt-8 max-w-xl text-2xl leading-snug text-bone/80 beat-in b4"> | |
| - | The home for step show culture. The body is the instrument. Find the | |
| - | shows, the lines, the people, and the road to every set. | |
| + | {/* HERO - type-as-art + the hanging disco ball */} | |
| + | <section className="relative flex min-h-[100svh] flex-col justify-center px-5 sm:px-10"> | |
| + | <div className="pointer-events-none relative order-1 mx-auto mt-24 h-[34vh] w-[84%] max-w-[360px] sm:absolute sm:inset-y-0 sm:right-[-3%] sm:order-none sm:mx-0 sm:mt-0 sm:h-auto sm:w-[46%] sm:max-w-none"> | |
| + | <div | |
| + | className="absolute left-1/2 top-1/2 h-[78%] w-[78%] -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl" | |
| + | style={{ background: "radial-gradient(circle, rgba(61,90,254,0.16) 0%, transparent 62%)" }} | |
| + | /> | |
| + | <ChromeDiscoCanvas /> | |
| + | </div> | |
| + | ||
| + | <p className="kicker beat-in b1 order-2 mt-6 sm:absolute sm:left-10 sm:top-24 sm:mt-0"> | |
| + | In the Pocket <span className="text-gold">/</span> Chicago Stepping | |
| + | </p> | |
| + | ||
| + | <div className="relative z-10 order-3 mt-3 select-none sm:mt-0 sm:-ml-[2vw]"> | |
| + | <h1 className="display" style={{ lineHeight: 0.82, letterSpacing: "-0.04em", fontSize: "clamp(3rem, 13.5vw, 17rem)" }}> | |
| + | <span className="block beat-in b1">STEP.</span> | |
| + | <span className="block text-gold beat-in b2">GLIDE.</span> | |
| + | <span className="serif block italic font-normal beat-in b3">spin.</span> | |
| + | </h1> | |
| + | </div> | |
| + | ||
| + | <p className="serif beat-in b4 relative z-10 order-4 mt-6 max-w-md text-lg leading-snug text-bone/80 sm:mt-8 sm:text-2xl"> | |
| + | The home for Chicago stepping. Smooth, on the count, in the pocket. | |
| + | Find the sets, the people, and the road to every city. | |
| </p> | ||
| - | <div className="mt-10 flex flex-wrap items-center gap-x-6 gap-y-2 beat-in b5"> | |
| + | ||
| + | <div className="beat-in b5 order-5 mt-8 flex flex-wrap items-center gap-x-6 gap-y-2"> | |
| {cities.map((c) => ( | ||
| - | <Link | |
| - | key={c.id} | |
| - | href={`/city/${c.slug}`} | |
| - | className="kicker text-bone hover:text-gold" | |
| - | > | |
| - | {c.name} · {c.lat?.toFixed(3)}, {c.lng?.toFixed(3)} | |
| + | <Link key={c.id} href={`/city/${c.slug}`} className="kicker text-bone hover:text-gold"> | |
| + | {c.name} | |
| </Link> | ||
| ))} | ||
| <span className="kicker text-ash">More cities loading</span> | ||
| </div> | ||
| - | </div> | |
| </section> | ||
| <Marquee /> | ||
| - | {/* THE NINE */} | |
| - | <section className="flex flex-wrap items-baseline justify-between gap-4 px-5 py-10 sm:px-8"> | |
| - | {NINE.map((g) => ( | |
| - | <span key={g} className="display text-3xl text-bone/70 sm:text-4xl"> | |
| + | {/* THE COUNT */} | |
| + | <section className="flex flex-wrap items-baseline justify-center gap-6 px-5 py-10 sm:px-8"> | |
| + | {COUNT.map((g, i) => ( | |
| + | <span | |
| + | key={i} | |
| + | className="display text-3xl text-bone/70 sm:text-4xl" | |
| + | > | |
| {g} | ||
| </span> | ||
| ))} | ||
| </section> | ||
| - | {/* THE LINE-UP */} | |
| + | {/* THE SETS */} | |
| <section | ||
| - | id="lineup" | |
| + | id="sets" | |
| className="border-t border-[var(--line)] px-5 py-16 sm:px-8" | ||
| > | ||
| <div className="flex items-end justify-between"> | ||
| - | <h2 className="display text-5xl sm:text-7xl">The Line-Up</h2> | |
| - | <span className="kicker">01 / Shows</span> | |
| + | <h2 className="display text-5xl sm:text-7xl">The Sets</h2> | |
| + | <span className="kicker">01 / Sets</span> | |
| </div> | ||
| <ul className="mt-10"> | ||
| {lineup.length === 0 && ( | ||
| - | <li className="kicker text-ash">No shows posted yet.</li> | |
| + | <li className="kicker text-ash">No sets posted yet.</li> | |
| )} | ||
| {lineup.map((e, i) => ( | ||
| <li key={e.id}> | ||
| @@ -141,13 +151,13 @@ export default async function Home() { | ||
| </ul> | ||
| </section> | ||
| - | {/* THE YARD */} | |
| + | {/* THE FLOOR */} | |
| <section | ||
| - | id="yard" | |
| + | id="floor" | |
| className="border-t border-[var(--line)] px-5 py-16 sm:px-8" | ||
| > | ||
| <div className="flex items-end justify-between"> | ||
| - | <h2 className="display text-5xl sm:text-7xl">The Yard</h2> | |
| + | <h2 className="display text-5xl sm:text-7xl">The Floor</h2> | |
| <span className="kicker">02 / Community</span> | ||
| </div> | ||
| <div className="mt-10 grid gap-px sm:grid-cols-2 lg:grid-cols-3"> | ||
| @@ -160,13 +170,13 @@ export default async function Home() { | ||
| </div> | ||
| </section> | ||
| - | {/* THE ROUTE */} | |
| + | {/* THE ROAD */} | |
| <section | ||
| - | id="route" | |
| + | id="road" | |
| className="border-t border-[var(--line)] px-5 py-16 sm:px-8" | ||
| > | ||
| <div className="flex items-end justify-between"> | ||
| - | <h2 className="display text-5xl sm:text-7xl">The Route</h2> | |
| + | <h2 className="display text-5xl sm:text-7xl">The Road</h2> | |
| <span className="kicker">03 / Cities</span> | ||
| </div> | ||
| <div className="mt-10 grid gap-px sm:grid-cols-2"> | ||
| @@ -195,7 +205,7 @@ export default async function Home() { | ||
| About | ||
| </Link> | ||
| <span className="kicker text-ash"> | ||
| - | On Beat · Built for the culture | |
| + | In the Pocket · Built for the culture | |
| </span> | ||
| </div> | ||
| </footer> |
| @@ -0,0 +1,67 @@ | ||
| + | import Link from "next/link"; | |
| + | import { SiteFooter } from "@/components/site-footer"; | |
| + | import { serverCaller } from "@/lib/trpc/server"; | |
| + | ||
| + | export const dynamic = "force-dynamic"; | |
| + | export const metadata = { title: "My Tickets - Stepping" }; | |
| + | ||
| + | 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(); | |
| + | } | |
| + | ||
| + | export default async function TicketsPage() { | |
| + | const tickets = await serverCaller().ticket.mine(); | |
| + | ||
| + | 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-10 text-ash">Your passes</p> | |
| + | <h1 className="display mt-3 text-6xl sm:text-7xl">My Tickets</h1> | |
| + | ||
| + | {tickets.length === 0 ? ( | |
| + | <p className="serif mt-8 text-xl text-ash"> | |
| + | No tickets yet. Reserve a set or weekender and it shows up here, ready | |
| + | at the door. | |
| + | </p> | |
| + | ) : ( | |
| + | <ul className="mt-10 flex flex-col gap-5"> | |
| + | {tickets.map((t) => ( | |
| + | <li | |
| + | key={t.id} | |
| + | className="flex items-stretch overflow-hidden border border-[var(--line)]" | |
| + | > | |
| + | <div className="flex-1 p-5"> | |
| + | <p className="kicker text-gold">{(t.typeName ?? "TICKET").toUpperCase()}</p> | |
| + | <p className="display mt-1 text-2xl">{t.eventTitle}</p> | |
| + | <p className="kicker mt-2 text-ash">{fmt(t.startsAt)}</p> | |
| + | </div> | |
| + | <div className="flex w-40 flex-col items-center justify-center gap-1 border-l border-dashed border-[var(--line)] bg-gold/5 p-4"> | |
| + | <span className="kicker text-ash">{t.status.toUpperCase()}</span> | |
| + | <span | |
| + | className="text-center text-lg font-bold tracking-widest text-gold" | |
| + | style={{ fontFamily: "var(--font-space-mono), monospace" }} | |
| + | > | |
| + | {t.code} | |
| + | </span> | |
| + | <span className="kicker text-ash">Show at door</span> | |
| + | </div> | |
| + | </li> | |
| + | ))} | |
| + | </ul> | |
| + | )} | |
| + | <SiteFooter /> | |
| + | </main> | |
| + | ); | |
| + | } |
| @@ -1,57 +0,0 @@ | ||
| - | "use client"; | |
| - | ||
| - | import { useEffect, useRef, useState } from "react"; | |
| - | ||
| - | // Our one signature interactive object: a gold cursor dot that trails the | |
| - | // pointer and leaves a clap-ripple on click. Percussion you can feel. | |
| - | // Disabled on touch and when reduced motion is requested. | |
| - | export function BeatCursor() { | |
| - | const dot = useRef<HTMLDivElement>(null); | |
| - | const [enabled, setEnabled] = useState(false); | |
| - | ||
| - | useEffect(() => { | |
| - | const fine = window.matchMedia("(pointer: fine)").matches; | |
| - | const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; | |
| - | if (!fine || reduce) return; | |
| - | setEnabled(true); | |
| - | ||
| - | let x = window.innerWidth / 2; | |
| - | let y = window.innerHeight / 2; | |
| - | let tx = x; | |
| - | let ty = y; | |
| - | let raf = 0; | |
| - | ||
| - | const loop = () => { | |
| - | x += (tx - x) * 0.2; | |
| - | y += (ty - y) * 0.2; | |
| - | const d = dot.current; | |
| - | if (d) d.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`; | |
| - | raf = requestAnimationFrame(loop); | |
| - | }; | |
| - | raf = requestAnimationFrame(loop); | |
| - | ||
| - | const move = (e: MouseEvent) => { | |
| - | tx = e.clientX; | |
| - | ty = e.clientY; | |
| - | }; | |
| - | const clap = (e: MouseEvent) => { | |
| - | const r = document.createElement("span"); | |
| - | r.className = "clap-ripple"; | |
| - | r.style.left = `${e.clientX}px`; | |
| - | r.style.top = `${e.clientY}px`; | |
| - | document.body.appendChild(r); | |
| - | window.setTimeout(() => r.remove(), 600); | |
| - | }; | |
| - | ||
| - | window.addEventListener("mousemove", move); | |
| - | window.addEventListener("click", clap); | |
| - | return () => { | |
| - | cancelAnimationFrame(raf); | |
| - | window.removeEventListener("mousemove", move); | |
| - | window.removeEventListener("click", clap); | |
| - | }; | |
| - | }, []); | |
| - | ||
| - | if (!enabled) return null; | |
| - | return <div ref={dot} className="beat-dot" aria-hidden="true" />; | |
| - | } |
| @@ -0,0 +1,70 @@ | ||
| + | "use client"; | |
| + | ||
| + | import dynamic from "next/dynamic"; | |
| + | import { useEffect, useRef, useState } from "react"; | |
| + | ||
| + | const ChromeDisco = dynamic(() => import("./chrome-disco"), { ssr: false }); | |
| + | ||
| + | // Renders the spinning disco ball only when it is on screen, the tab is visible, | |
| + | // and the device is capable. Reduced-motion / low-core devices get a static | |
| + | // chrome-ball image fallback so the hero never blanks. | |
| + | export function ChromeDiscoCanvas() { | |
| + | const wrap = useRef<HTMLDivElement>(null); | |
| + | const [enabled, setEnabled] = useState(false); | |
| + | const [active, setActive] = useState(true); | |
| + | ||
| + | useEffect(() => { | |
| + | // Only fall back when WebGL is genuinely missing. Don't gate on core count: | |
| + | // phones report few cores but render this fine (and that wrongly downgraded | |
| + | // real iPhones to the static ball). | |
| + | let webgl = false; | |
| + | try { | |
| + | const c = document.createElement("canvas"); | |
| + | webgl = !!(c.getContext("webgl2") || c.getContext("webgl")); | |
| + | } catch { | |
| + | webgl = false; | |
| + | } | |
| + | if (!webgl) return; | |
| + | setEnabled(true); | |
| + | ||
| + | const el = wrap.current; | |
| + | if (!el) return; | |
| + | let inView = true; | |
| + | let hidden = false; | |
| + | const update = () => setActive(inView && !hidden); | |
| + | ||
| + | const io = new IntersectionObserver( | |
| + | ([e]) => { | |
| + | inView = e.isIntersecting; | |
| + | update(); | |
| + | }, | |
| + | { threshold: 0.01 }, | |
| + | ); | |
| + | io.observe(el); | |
| + | const onVis = () => { | |
| + | hidden = document.hidden; | |
| + | update(); | |
| + | }; | |
| + | document.addEventListener("visibilitychange", onVis); | |
| + | return () => { | |
| + | io.disconnect(); | |
| + | document.removeEventListener("visibilitychange", onVis); | |
| + | }; | |
| + | }, []); | |
| + | ||
| + | return ( | |
| + | <div ref={wrap} className="absolute inset-0"> | |
| + | {enabled ? ( | |
| + | <ChromeDisco active={active} /> | |
| + | ) : ( | |
| + | // Only for the rare device with no WebGL: a real rendered disco ball, | |
| + | // never the plain gradient. The signature object always shows. | |
| + | <img | |
| + | src="/disco-ball.png" | |
| + | alt="Chicago stepping disco ball" | |
| + | className="absolute inset-0 h-full w-full object-contain" | |
| + | /> | |
| + | )} | |
| + | </div> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,226 @@ | ||
| + | "use client"; | |
| + | ||
| + | import { Canvas, useFrame } from "@react-three/fiber"; | |
| + | import { useEffect, useMemo, useRef } from "react"; | |
| + | import * as THREE from "three"; | |
| + | ||
| + | // Procedural chrome matcap - sky/horizon/ground baked into a 2D texture with a | |
| + | // tight bright specular, tinted cobalt at the rim. MeshMatcapMaterial reads it | |
| + | // directly, so the chrome look needs no HDR/env asset (self-contained, MIT). | |
| + | function useChromeMatcap() { | |
| + | return useMemo(() => { | |
| + | const s = 256; | |
| + | const c = document.createElement("canvas"); | |
| + | c.width = c.height = s; | |
| + | const ctx = c.getContext("2d")!; | |
| + | ||
| + | const g = ctx.createLinearGradient(0, 0, 0, s); | |
| + | g.addColorStop(0.0, "#ffffff"); | |
| + | g.addColorStop(0.22, "#eef2f8"); | |
| + | g.addColorStop(0.46, "#9aa6ba"); | |
| + | g.addColorStop(0.5, "#6f7a8e"); | |
| + | g.addColorStop(0.515, "#ffffff"); // horizon glint | |
| + | g.addColorStop(0.55, "#48515f"); | |
| + | g.addColorStop(0.74, "#161922"); | |
| + | g.addColorStop(1.0, "#020203"); | |
| + | ctx.fillStyle = g; | |
| + | ctx.fillRect(0, 0, s, s); | |
| + | ||
| + | // tight, hot specular (upper-left) for real chrome shine | |
| + | const hot = ctx.createRadialGradient(s * 0.34, s * 0.24, 0, s * 0.34, s * 0.24, s * 0.26); | |
| + | hot.addColorStop(0, "rgba(255,255,255,1)"); | |
| + | hot.addColorStop(0.35, "rgba(255,255,255,0.7)"); | |
| + | hot.addColorStop(1, "rgba(255,255,255,0)"); | |
| + | ctx.fillStyle = hot; | |
| + | ctx.fillRect(0, 0, s, s); | |
| + | ||
| + | // second smaller glint (lower-right) so tiles flash twice as they turn | |
| + | const hot2 = ctx.createRadialGradient(s * 0.7, s * 0.4, 0, s * 0.7, s * 0.4, s * 0.16); | |
| + | hot2.addColorStop(0, "rgba(255,255,255,0.95)"); | |
| + | hot2.addColorStop(1, "rgba(255,255,255,0)"); | |
| + | ctx.fillStyle = hot2; | |
| + | ctx.fillRect(0, 0, s, s); | |
| + | ||
| + | // cobalt bounce off the ground | |
| + | const cob = ctx.createRadialGradient(s * 0.58, s * 0.76, 0, s * 0.58, s * 0.76, s * 0.55); | |
| + | cob.addColorStop(0, "rgba(61,90,254,0.85)"); | |
| + | cob.addColorStop(0.4, "rgba(61,90,254,0.3)"); | |
| + | cob.addColorStop(1, "rgba(61,90,254,0)"); | |
| + | ctx.fillStyle = cob; | |
| + | ctx.fillRect(0, 0, s, s); | |
| + | ||
| + | const tex = new THREE.CanvasTexture(c); | |
| + | tex.colorSpace = THREE.SRGBColorSpace; | |
| + | return tex; | |
| + | }, []); | |
| + | } | |
| + | ||
| + | // soft round additive sprite for the twinkle sparkles | |
| + | function useSparkTexture() { | |
| + | return useMemo(() => { | |
| + | const s = 64; | |
| + | const c = document.createElement("canvas"); | |
| + | c.width = c.height = s; | |
| + | const ctx = c.getContext("2d")!; | |
| + | const g = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2); | |
| + | g.addColorStop(0, "rgba(255,255,255,1)"); | |
| + | g.addColorStop(0.25, "rgba(255,255,255,0.9)"); | |
| + | g.addColorStop(0.5, "rgba(220,230,255,0.35)"); | |
| + | g.addColorStop(1, "rgba(255,255,255,0)"); | |
| + | ctx.fillStyle = g; | |
| + | ctx.fillRect(0, 0, s, s); | |
| + | const tex = new THREE.CanvasTexture(c); | |
| + | tex.colorSpace = THREE.SRGBColorSpace; | |
| + | return tex; | |
| + | }, []); | |
| + | } | |
| + | ||
| + | const R = 1.5; | |
| + | const TILE = 0.12; | |
| + | ||
| + | function DiscoBall({ pointer }: { pointer: React.RefObject<{ x: number; y: number }> }) { | |
| + | const rig = useRef<THREE.Group>(null); // hangs + sways; cord stays vertical | |
| + | const spinner = useRef<THREE.Group>(null); // the ball spins underneath | |
| + | const inst = useRef<THREE.InstancedMesh>(null); | |
| + | const sparks = useRef<THREE.Group>(null); | |
| + | const matcap = useChromeMatcap(); | |
| + | const sparkTex = useSparkTexture(); | |
| + | ||
| + | const tiles = useMemo(() => { | |
| + | const pts: THREE.Vector3[] = []; | |
| + | const rows = 28; | |
| + | for (let i = 1; i < rows; i++) { | |
| + | const theta = (Math.PI * i) / rows; | |
| + | const y = R * Math.cos(theta); | |
| + | const ring = R * Math.sin(theta); | |
| + | const cols = Math.max(3, Math.round((2 * Math.PI * ring) / (TILE * 1.22))); | |
| + | for (let j = 0; j < cols; j++) { | |
| + | const phi = (2 * Math.PI * j) / cols + (i % 2) * (Math.PI / cols); | |
| + | pts.push(new THREE.Vector3(ring * Math.cos(phi), y, ring * Math.sin(phi))); | |
| + | } | |
| + | } | |
| + | return pts; | |
| + | }, []); | |
| + | ||
| + | // twinkle sparkles: sit just above random tiles; the opaque core occludes the | |
| + | // ones on the back, so additive blending only shows on the visible face. | |
| + | const sparkData = useMemo(() => { | |
| + | const out: { pos: THREE.Vector3; phase: number; speed: number }[] = []; | |
| + | for (let i = 0; i < 18; i++) { | |
| + | const t = tiles[Math.floor((i / 18) * tiles.length)]; | |
| + | out.push({ | |
| + | pos: t.clone().multiplyScalar(1.02), | |
| + | phase: (i * 2.399) % (Math.PI * 2), | |
| + | speed: 1.6 + (i % 5) * 0.5, | |
| + | }); | |
| + | } | |
| + | return out; | |
| + | }, [tiles]); | |
| + | ||
| + | useEffect(() => { | |
| + | const m = inst.current; | |
| + | if (!m) return; | |
| + | const dummy = new THREE.Object3D(); | |
| + | tiles.forEach((p, i) => { | |
| + | dummy.position.copy(p); | |
| + | dummy.lookAt(p.clone().multiplyScalar(2)); | |
| + | dummy.scale.setScalar(TILE); | |
| + | dummy.updateMatrix(); | |
| + | m.setMatrixAt(i, dummy.matrix); | |
| + | }); | |
| + | m.instanceMatrix.needsUpdate = true; | |
| + | }, [tiles]); | |
| + | ||
| + | useFrame((state, dt) => { | |
| + | const t = state.clock.elapsedTime; | |
| + | const rg = rig.current; // bob + sway; cord hangs with it but stays vertical | |
| + | if (rg) { | |
| + | const p = pointer.current; | |
| + | rg.position.y = Math.sin(t * 0.6) * 0.06; | |
| + | rg.position.x += ((p ? p.x * 0.4 : 0) - rg.position.x) * 0.04; | |
| + | rg.rotation.z = Math.sin(t * 0.5) * 0.02; // faint pendulum sway | |
| + | } | |
| + | const sp = spinner.current; | |
| + | if (sp) { | |
| + | sp.rotation.y += dt * 0.3; | |
| + | sp.rotation.x = Math.sin(t * 0.3) * 0.05; | |
| + | } | |
| + | // twinkle | |
| + | const sg = sparks.current; | |
| + | if (sg) { | |
| + | sg.children.forEach((child, i) => { | |
| + | const d = sparkData[i]; | |
| + | const tw = Math.max(0, Math.sin(t * d.speed + d.phase)); | |
| + | const s = child as THREE.Sprite; | |
| + | s.scale.setScalar(0.12 + tw * tw * 0.5); | |
| + | (s.material as THREE.SpriteMaterial).opacity = tw * tw; | |
| + | }); | |
| + | } | |
| + | }); | |
| + | ||
| + | return ( | |
| + | <group ref={rig}> | |
| + | {/* cord dropping from the ceiling (long, so it always exits the top) */} | |
| + | <mesh position={[0, R + 15, 0]}> | |
| + | <cylinderGeometry args={[0.012, 0.012, 30, 8]} /> | |
| + | <meshMatcapMaterial matcap={matcap} /> | |
| + | </mesh> | |
| + | {/* eyelet ring where the cord meets the cap */} | |
| + | <mesh position={[0, R + 0.17, 0]} rotation={[Math.PI / 2, 0, 0]}> | |
| + | <torusGeometry args={[0.05, 0.017, 8, 20]} /> | |
| + | <meshMatcapMaterial matcap={matcap} /> | |
| + | </mesh> | |
| + | {/* chrome mount cap sitting on the top of the ball */} | |
| + | <mesh position={[0, R + 0.01, 0]}> | |
| + | <cylinderGeometry args={[0.07, 0.13, 0.18, 20]} /> | |
| + | <meshMatcapMaterial matcap={matcap} /> | |
| + | </mesh> | |
| + | ||
| + | {/* the ball spins under the fixed cord */} | |
| + | <group ref={spinner}> | |
| + | <mesh> | |
| + | <sphereGeometry args={[R * 0.965, 48, 48]} /> | |
| + | <meshBasicMaterial color="#0a0b10" /> | |
| + | </mesh> | |
| + | <instancedMesh ref={inst} args={[undefined, undefined, tiles.length]}> | |
| + | <planeGeometry args={[1, 1]} /> | |
| + | <meshMatcapMaterial matcap={matcap} side={THREE.DoubleSide} /> | |
| + | </instancedMesh> | |
| + | <group ref={sparks}> | |
| + | {sparkData.map((s, i) => ( | |
| + | <sprite key={i} position={s.pos} scale={0.1}> | |
| + | <spriteMaterial | |
| + | map={sparkTex} | |
| + | blending={THREE.AdditiveBlending} | |
| + | transparent | |
| + | depthWrite={false} | |
| + | opacity={0} | |
| + | /> | |
| + | </sprite> | |
| + | ))} | |
| + | </group> | |
| + | </group> | |
| + | </group> | |
| + | ); | |
| + | } | |
| + | ||
| + | export default function ChromeDisco({ active = true }: { active?: boolean }) { | |
| + | const pointer = useRef({ x: 0, y: 0 }); | |
| + | return ( | |
| + | <Canvas | |
| + | dpr={[1, 1.8]} | |
| + | frameloop={active ? "always" : "never"} | |
| + | camera={{ position: [0, 0, 5.6], fov: 40 }} | |
| + | gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }} | |
| + | style={{ width: "100%", height: "100%" }} | |
| + | onPointerMove={(e) => { | |
| + | const r = (e.target as HTMLElement).getBoundingClientRect?.(); | |
| + | if (!r) return; | |
| + | pointer.current.x = ((e.clientX - r.left) / r.width) * 2 - 1; | |
| + | pointer.current.y = ((e.clientY - r.top) / r.height) * 2 - 1; | |
| + | }} | |
| + | > | |
| + | <DiscoBall pointer={pointer} /> | |
| + | </Canvas> | |
| + | ); | |
| + | } |
| @@ -1,57 +0,0 @@ | ||
| - | "use client"; | |
| - | ||
| - | import dynamic from "next/dynamic"; | |
| - | import { useEffect, useRef, useState } from "react"; | |
| - | ||
| - | const HeroScene = dynamic(() => import("./hero-scene"), { ssr: false }); | |
| - | ||
| - | // Renders the WebGL hero only when it is on screen and the tab is visible, on | |
| - | // capable devices. Reduced-motion and low-core machines get a static CSS glow. | |
| - | export function HeroCanvas() { | |
| - | const wrap = useRef<HTMLDivElement>(null); | |
| - | const [enabled, setEnabled] = useState(false); | |
| - | const [active, setActive] = useState(true); | |
| - | ||
| - | useEffect(() => { | |
| - | const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; | |
| - | const lowPower = (navigator.hardwareConcurrency || 8) <= 4; | |
| - | if (reduce || lowPower) return; | |
| - | setEnabled(true); | |
| - | ||
| - | const el = wrap.current; | |
| - | if (!el) return; | |
| - | let inView = true; | |
| - | let hidden = false; | |
| - | const update = () => setActive(inView && !hidden); | |
| - | ||
| - | const io = new IntersectionObserver( | |
| - | ([e]) => { | |
| - | inView = e.isIntersecting; | |
| - | update(); | |
| - | }, | |
| - | { threshold: 0.01 }, | |
| - | ); | |
| - | io.observe(el); | |
| - | ||
| - | const onVis = () => { | |
| - | hidden = document.hidden; | |
| - | update(); | |
| - | }; | |
| - | document.addEventListener("visibilitychange", onVis); | |
| - | ||
| - | return () => { | |
| - | io.disconnect(); | |
| - | document.removeEventListener("visibilitychange", onVis); | |
| - | }; | |
| - | }, []); | |
| - | ||
| - | return ( | |
| - | <div ref={wrap} className="pointer-events-none absolute inset-0 z-0"> | |
| - | {enabled ? ( | |
| - | <HeroScene active={active} /> | |
| - | ) : ( | |
| - | <div className="hero-fallback absolute inset-0" /> | |
| - | )} | |
| - | </div> | |
| - | ); | |
| - | } |
| @@ -1,113 +0,0 @@ | ||
| - | "use client"; | |
| - | ||
| - | import { Canvas, useFrame, useThree } from "@react-three/fiber"; | |
| - | import { useEffect, useMemo, useRef } from "react"; | |
| - | import * as THREE from "three"; | |
| - | ||
| - | const vertex = /* glsl */ ` | |
| - | varying vec2 vUv; | |
| - | void main() { | |
| - | vUv = uv; | |
| - | gl_Position = vec4(position.x, position.y, 0.0, 1.0); | |
| - | } | |
| - | `; | |
| - | ||
| - | // Stomp shockwave stage-floor. Concentric gold rings emanate on a beat and | |
| - | // react to the cursor, on the warm-black stage. Kept cheap: 3 bands, no grain. | |
| - | const fragment = /* glsl */ ` | |
| - | precision mediump float; | |
| - | varying vec2 vUv; | |
| - | uniform float uTime; | |
| - | uniform vec2 uMouse; | |
| - | uniform float uAspect; | |
| - | ||
| - | const vec3 STAGE = vec3(0.059, 0.055, 0.047); | |
| - | const vec3 GOLD = vec3(0.784, 0.635, 0.298); | |
| - | ||
| - | void main() { | |
| - | vec2 p = vUv - 0.5; | |
| - | p.x *= uAspect; | |
| - | vec2 m = uMouse - 0.5; | |
| - | m.x *= uAspect; | |
| - | ||
| - | float d = length(p); | |
| - | float beat = 0.82 + 0.18 * sin(uTime * 100.0 / 60.0 * 3.14159); | |
| - | ||
| - | float rings = 0.0; | |
| - | float t = uTime * 0.42; | |
| - | for (int i = 0; i < 3; i++) { | |
| - | float phase = fract(t + float(i) * 0.333); | |
| - | float radius = phase * 1.05; | |
| - | rings += smoothstep(0.02, 0.0, abs(d - radius)) * (1.0 - phase); | |
| - | } | |
| - | rings *= beat; | |
| - | ||
| - | float md = length(p - m); | |
| - | float cursor = smoothstep(0.32, 0.0, md); | |
| - | float cursorRing = smoothstep(0.012, 0.0, abs(md - 0.14)) * 0.6; | |
| - | ||
| - | float energy = rings * 0.55 + cursor * 0.28 + cursorRing; | |
| - | vec3 col = STAGE + GOLD * energy * 0.6; | |
| - | col *= smoothstep(1.25, 0.15, d); | |
| - | ||
| - | gl_FragColor = vec4(col, 1.0); | |
| - | } | |
| - | `; | |
| - | ||
| - | function Floor() { | |
| - | const mat = useRef<THREE.ShaderMaterial>(null); | |
| - | const target = useRef(new THREE.Vector2(0.5, 0.5)); | |
| - | const { size } = useThree(); | |
| - | ||
| - | const uniforms = useMemo( | |
| - | () => ({ | |
| - | uTime: { value: 0 }, | |
| - | uMouse: { value: new THREE.Vector2(0.5, 0.5) }, | |
| - | uAspect: { value: 1 }, | |
| - | }), | |
| - | [], | |
| - | ); | |
| - | ||
| - | useEffect(() => { | |
| - | const onMove = (e: MouseEvent) => { | |
| - | target.current.set(e.clientX / window.innerWidth, 1 - e.clientY / window.innerHeight); | |
| - | }; | |
| - | window.addEventListener("mousemove", onMove, { passive: true }); | |
| - | return () => window.removeEventListener("mousemove", onMove); | |
| - | }, []); | |
| - | ||
| - | useFrame((_, dt) => { | |
| - | const u = mat.current?.uniforms; | |
| - | if (!u) return; | |
| - | u.uTime.value += Math.min(dt, 0.05); | |
| - | u.uAspect.value = size.width / size.height; | |
| - | const m = u.uMouse.value as THREE.Vector2; | |
| - | m.x += (target.current.x - m.x) * 0.06; | |
| - | m.y += (target.current.y - m.y) * 0.06; | |
| - | }); | |
| - | ||
| - | return ( | |
| - | <mesh> | |
| - | <planeGeometry args={[2, 2]} /> | |
| - | <shaderMaterial | |
| - | ref={mat} | |
| - | uniforms={uniforms} | |
| - | vertexShader={vertex} | |
| - | fragmentShader={fragment} | |
| - | /> | |
| - | </mesh> | |
| - | ); | |
| - | } | |
| - | ||
| - | export default function HeroScene({ active = true }: { active?: boolean }) { | |
| - | return ( | |
| - | <Canvas | |
| - | dpr={0.75} | |
| - | frameloop={active ? "always" : "never"} | |
| - | gl={{ antialias: false, powerPreference: "low-power" }} | |
| - | style={{ width: "100%", height: "100%" }} | |
| - | > | |
| - | <Floor /> | |
| - | </Canvas> | |
| - | ); | |
| - | } |
| @@ -1,20 +1,17 @@ | ||
| - | // A running mono ticker (the "roll"): founding years and launch cities. | |
| + | // A running mono ticker (the "roll"): the count motif and the stepping cities. | |
| const ROLL = [ | ||
| - | "EST. 1906", | |
| + | "CHICAGO", | |
| + | "6 · COUNT · 8", | |
| "ATLANTA", | ||
| - | "EST. 1908", | |
| + | "IN THE POCKET", | |
| + | "DETROIT", | |
| + | "STEP · GLIDE · SPIN", | |
| + | "HOUSTON", | |
| + | "OFF THE BOP", | |
| + | "CHARLOTTE", | |
| + | "SMOOTH", | |
| "WASHINGTON DC", | ||
| - | "EST. 1911", | |
| - | "STEP SHOWS", | |
| - | "EST. 1913", | |
| - | "STROLLS", | |
| - | "EST. 1914", | |
| - | "THE YARD", | |
| - | "EST. 1920", | |
| - | "ON BEAT", | |
| - | "EST. 1922", | |
| - | "DIVINE NINE", | |
| - | "EST. 1963", | |
| + | "ON TIME", | |
| ]; | ||
| export function Marquee() { |
| @@ -4,9 +4,9 @@ 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" }, | |
| + | { href: "#sets", label: "The Sets", index: "01" }, | |
| + | { href: "#floor", label: "The Floor", index: "02" }, | |
| + | { href: "#road", label: "The Road", index: "03" }, | |
| ]; | ||
| export function MobileNav() { | ||
| @@ -81,7 +81,7 @@ export function MobileNav() { | ||
| </Link> | ||
| </nav> | ||
| - | <p className="kicker mt-8 text-ash">On Beat · Divine Nine Stepping</p> | |
| + | <p className="kicker mt-8 text-ash">In the Pocket · Chicago Stepping</p> | |
| </div> | ||
| )} | ||
| </div> |
| @@ -0,0 +1,23 @@ | ||
| + | import Link from "next/link"; | |
| + | ||
| + | export function SiteFooter() { | |
| + | return ( | |
| + | <footer className="mt-24 flex flex-col gap-4 border-t border-[var(--line)] pt-8 sm:flex-row sm:items-center sm:justify-between"> | |
| + | <Link href="/" className="display text-xl"> | |
| + | STEPPING | |
| + | </Link> | |
| + | <nav className="flex flex-wrap gap-x-6 gap-y-2"> | |
| + | <Link href="/" className="kicker text-bone hover:text-gold"> | |
| + | Cities | |
| + | </Link> | |
| + | <Link href="/tickets" className="kicker text-bone hover:text-gold"> | |
| + | My Tickets | |
| + | </Link> | |
| + | <Link href="/about" className="kicker text-bone hover:text-gold"> | |
| + | About | |
| + | </Link> | |
| + | </nav> | |
| + | <span className="kicker text-ash">In the Pocket · Chicago Stepping</span> | |
| + | </footer> | |
| + | ); | |
| + | } |
| @@ -40,7 +40,7 @@ export function CitySkeleton() { | ||
| 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> | |
| + | <span className="kicker text-ash">Loading The Floor</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"> | ||
| @@ -58,7 +58,7 @@ export function YardSkeleton() { | ||
| 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> | |
| + | <span className="kicker text-ash">Loading the set</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" /> |
| @@ -0,0 +1,45 @@ | ||
| + | "use client"; | |
| + | ||
| + | import { useState } from "react"; | |
| + | ||
| + | // Lazy YouTube facade: shows the thumbnail + play button, loads the real player | |
| + | // only on click. Keeps the page fast (no stacked iframes) and never blanks. | |
| + | export function VideoEmbed({ id, title }: { id: string; title: string }) { | |
| + | const [play, setPlay] = useState(false); | |
| + | ||
| + | if (play) { | |
| + | return ( | |
| + | <div className="aspect-video w-full overflow-hidden border border-[var(--line)]"> | |
| + | <iframe | |
| + | src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1`} | |
| + | title={title} | |
| + | allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" | |
| + | allowFullScreen | |
| + | className="h-full w-full" | |
| + | /> | |
| + | </div> | |
| + | ); | |
| + | } | |
| + | ||
| + | return ( | |
| + | <button | |
| + | type="button" | |
| + | onClick={() => setPlay(true)} | |
| + | aria-label={`Play ${title}`} | |
| + | className="group relative block aspect-video w-full overflow-hidden border border-[var(--line)]" | |
| + | > | |
| + | <img | |
| + | src={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`} | |
| + | alt="" | |
| + | className="h-full w-full object-cover opacity-70 transition duration-300 group-hover:opacity-100" | |
| + | /> | |
| + | <span className="absolute inset-0 grid place-items-center"> | |
| + | <span className="flex h-16 w-16 items-center justify-center rounded-full bg-gold text-stage transition group-hover:bg-gold-bright"> | |
| + | <svg width="20" height="22" viewBox="0 0 20 22" fill="currentColor" aria-hidden="true"> | |
| + | <path d="M0 0l20 11L0 22z" /> | |
| + | </svg> | |
| + | </span> | |
| + | </span> | |
| + | </button> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,45 @@ | ||
| + | import { cookies } from "next/headers"; | |
| + | import { getDb } from "@stepping/db"; | |
| + | import { | |
| + | SESSION_COOKIE, | |
| + | verifyCredentials, | |
| + | createSession, | |
| + | destroySession, | |
| + | validateSession, | |
| + | type SessionUser, | |
| + | } from "@stepping/api"; | |
| + | ||
| + | // Reads the current signed-in user (with role) from the session cookie. | |
| + | export async function getCurrentUser(): Promise<SessionUser | null> { | |
| + | const token = (await cookies()).get(SESSION_COOKIE)?.value ?? null; | |
| + | return validateSession(getDb(), token); | |
| + | } | |
| + | ||
| + | export function isAdmin(u: SessionUser | null): boolean { | |
| + | return u?.role === "admin" || u?.role === "moderator"; | |
| + | } | |
| + | ||
| + | // Verifies credentials, opens a session, sets the cookie. Returns false on bad | |
| + | // login (caller shows a generic error - never reveals which field was wrong). | |
| + | export async function login(email: string, password: string): Promise<boolean> { | |
| + | const db = getDb(); | |
| + | const userId = await verifyCredentials(db, email, password); | |
| + | if (!userId) return false; | |
| + | ||
| + | const { token, expiresAt } = await createSession(db, userId); | |
| + | (await cookies()).set(SESSION_COOKIE, token, { | |
| + | httpOnly: true, | |
| + | secure: process.env.STEPPING_SECURE_COOKIES === "1", | |
| + | sameSite: "lax", | |
| + | path: "/", | |
| + | expires: expiresAt, | |
| + | }); | |
| + | return true; | |
| + | } | |
| + | ||
| + | export async function logout(): Promise<void> { | |
| + | const jar = await cookies(); | |
| + | const token = jar.get(SESSION_COOKIE)?.value; | |
| + | await destroySession(getDb(), token); | |
| + | jar.delete(SESSION_COOKIE); | |
| + | } |
| @@ -0,0 +1,32 @@ | ||
| + | // Event times are stored as the city's LOCAL wall-clock (what the host typed). | |
| + | // We render that wall-clock unchanged and tag it with the city's zone, so a | |
| + | // Houston set reads "8:00 PM CT" and a DC set reads "8:00 PM ET" without ever | |
| + | // shifting the number across timezones. | |
| + | const STATE_ZONE: Record<string, string> = { | |
| + | IL: "CT", | |
| + | TX: "CT", | |
| + | GA: "ET", | |
| + | MI: "ET", | |
| + | NC: "ET", | |
| + | DC: "ET", | |
| + | }; | |
| + | ||
| + | // Named cityTimeZone for back-compat with callers; returns a short zone label. | |
| + | export function cityTimeZone(state: string | null | undefined): string { | |
| + | return STATE_ZONE[state ?? ""] ?? "CT"; | |
| + | } | |
| + | ||
| + | export function fmtEventTime(d: Date | null, zoneLabel: string): string { | |
| + | if (!d) return "DATE TBD"; | |
| + | const base = new Date(d) | |
| + | .toLocaleString("en-US", { | |
| + | weekday: "short", | |
| + | month: "short", | |
| + | day: "numeric", | |
| + | hour: "numeric", | |
| + | minute: "2-digit", | |
| + | timeZone: "UTC", // render the stored wall-clock unchanged | |
| + | }) | |
| + | .toUpperCase(); | |
| + | return `${base} ${zoneLabel}`; | |
| + | } |
| @@ -1,9 +1,22 @@ | ||
| - | import { appRouter } from "@stepping/api"; | |
| + | import { cookies } from "next/headers"; | |
| + | import { appRouter, validateSession, SESSION_COOKIE } 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. | |
| + | // Public / system caller: handles reads and the POC's open "anyone can post" | |
| + | // writes as a MEMBER (never admin). | |
| export function serverCaller() { | ||
| - | return appRouter.createCaller({ db: getDb(), user: { id: DEV_USER_ID } }); | |
| + | return appRouter.createCaller({ | |
| + | db: getDb(), | |
| + | user: { id: DEV_USER_ID, role: "member" }, | |
| + | }); | |
| + | } | |
| + | ||
| + | // Caller bound to the real signed-in user from the session cookie. Used by the | |
| + | // admin area; admin procedures enforce the role. Returns the resolved user too. | |
| + | export async function sessionCaller() { | |
| + | const db = getDb(); | |
| + | const token = (await cookies()).get(SESSION_COOKIE)?.value ?? null; | |
| + | const user = await validateSession(db, token); | |
| + | return { caller: appRouter.createCaller({ db, user }), user }; | |
| } |
| @@ -0,0 +1,96 @@ | ||
| + | import { | |
| + | createHash, | |
| + | randomBytes, | |
| + | scryptSync, | |
| + | timingSafeEqual, | |
| + | } from "node:crypto"; | |
| + | import { and, eq, gt } from "drizzle-orm"; | |
| + | import { sessions, users, type Database } from "@stepping/db"; | |
| + | import type { UserRole } from "@stepping/types"; | |
| + | ||
| + | const SESSION_DAYS = 30; | |
| + | export const SESSION_COOKIE = "sid"; | |
| + | ||
| + | // --- Passwords (scrypt; stored as scheme$salt$hash) ------------------------- | |
| + | export function hashPassword(pw: string): string { | |
| + | const salt = randomBytes(16).toString("hex"); | |
| + | const hash = scryptSync(pw, salt, 64).toString("hex"); | |
| + | return `scrypt$${salt}$${hash}`; | |
| + | } | |
| + | ||
| + | export function verifyPassword(pw: string, stored: string | null | undefined): boolean { | |
| + | if (!stored) return false; | |
| + | const [scheme, salt, hash] = stored.split("$"); | |
| + | if (scheme !== "scrypt" || !salt || !hash) return false; | |
| + | const test = scryptSync(pw, salt, 64); | |
| + | const ref = Buffer.from(hash, "hex"); | |
| + | return test.length === ref.length && timingSafeEqual(test, ref); | |
| + | } | |
| + | ||
| + | // --- Sessions (cookie holds the token; DB stores only its SHA-256) ---------- | |
| + | function hashToken(token: string): string { | |
| + | return createHash("sha256").update(token).digest("hex"); | |
| + | } | |
| + | ||
| + | export async function createSession( | |
| + | db: Database, | |
| + | userId: string, | |
| + | ): Promise<{ token: string; expiresAt: Date }> { | |
| + | const token = randomBytes(32).toString("hex"); | |
| + | const expiresAt = new Date(Date.now() + SESSION_DAYS * 86_400_000); | |
| + | await db.insert(sessions).values({ tokenHash: hashToken(token), userId, expiresAt }); | |
| + | return { token, expiresAt }; | |
| + | } | |
| + | ||
| + | // Looks up a user by email and checks the password (timing-safe). Lives here so | |
| + | // the web layer never imports drizzle directly. | |
| + | export async function verifyCredentials( | |
| + | db: Database, | |
| + | email: string, | |
| + | password: string, | |
| + | ): Promise<string | null> { | |
| + | const [u] = await db | |
| + | .select({ id: users.id, passwordHash: users.passwordHash }) | |
| + | .from(users) | |
| + | .where(eq(users.email, email.trim().toLowerCase())); | |
| + | if (!u || !verifyPassword(password, u.passwordHash)) return null; | |
| + | return u.id; | |
| + | } | |
| + | ||
| + | export interface SessionUser { | |
| + | id: string; | |
| + | role: UserRole; | |
| + | } | |
| + | ||
| + | export async function validateSession( | |
| + | db: Database, | |
| + | token: string | undefined | null, | |
| + | ): Promise<SessionUser | null> { | |
| + | if (!token) return null; | |
| + | const [row] = await db | |
| + | .select({ id: users.id, role: users.role, expiresAt: sessions.expiresAt }) | |
| + | .from(sessions) | |
| + | .innerJoin(users, eq(users.id, sessions.userId)) | |
| + | .where( | |
| + | and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, new Date())), | |
| + | ); | |
| + | return row ? { id: row.id, role: row.role } : null; | |
| + | } | |
| + | ||
| + | export async function destroySession( | |
| + | db: Database, | |
| + | token: string | undefined | null, | |
| + | ): Promise<void> { | |
| + | if (!token) return; | |
| + | await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token))); | |
| + | } | |
| + | ||
| + | // Reads the session cookie out of a raw Cookie header. | |
| + | export function readSessionCookie(cookieHeader: string | null | undefined): string | null { | |
| + | if (!cookieHeader) return null; | |
| + | for (const part of cookieHeader.split(";")) { | |
| + | const [k, ...v] = part.trim().split("="); | |
| + | if (k === SESSION_COOKIE) return decodeURIComponent(v.join("=")); | |
| + | } | |
| + | return null; | |
| + | } |
| @@ -1,39 +1,27 @@ | ||
| import { getDb, type Database } from "@stepping/db"; | ||
| import { DEV_USER_ID } from "@stepping/config"; | ||
| - | import { createClient } from "@supabase/supabase-js"; | |
| - | ||
| - | export interface SessionUser { | |
| - | id: string; | |
| - | } | |
| + | import { readSessionCookie, validateSession, type SessionUser } from "./auth"; | |
| export interface Context { | ||
| db: Database; | ||
| user: SessionUser | null; | ||
| } | ||
| - | // Resolves the Supabase user from a Bearer token, if present. | |
| - | // Connects nothing at import time - safe for build. | |
| + | // Resolves the signed-in user from the session cookie. Falls back to the | |
| + | // open-posting system user (member role only) when STEPPING_DEV_AUTH is on, so | |
| + | // the POC keeps "anyone can post" without ever granting admin. | |
| export async function createContext({ | ||
| headers, | ||
| }: { | ||
| headers: Headers; | ||
| }): Promise<Context> { | ||
| const db = getDb(); | ||
| - | let user: SessionUser | null = null; | |
| - | const token = headers.get("authorization")?.replace(/^Bearer\s+/i, ""); | |
| - | const url = process.env.NEXT_PUBLIC_SUPABASE_URL; | |
| - | const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; | |
| - | ||
| - | if (token && url && anon) { | |
| - | const supabase = createClient(url, anon); | |
| - | const { data } = await supabase.auth.getUser(token); | |
| - | if (data.user) user = { id: data.user.id }; | |
| - | } | |
| + | const token = readSessionCookie(headers.get("cookie")); | |
| + | let user = await validateSession(db, token); | |
| - | // 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 }; | |
| + | user = { id: DEV_USER_ID, role: "member" }; | |
| } | ||
| return { db, user }; |
| @@ -1,5 +1,16 @@ | ||
| export { appRouter, type AppRouter } from "./root"; | ||
| - | export { createContext, type Context, type SessionUser } from "./context"; | |
| + | export { createContext, type Context } from "./context"; | |
| + | export { | |
| + | type SessionUser, | |
| + | SESSION_COOKIE, | |
| + | hashPassword, | |
| + | verifyPassword, | |
| + | verifyCredentials, | |
| + | createSession, | |
| + | validateSession, | |
| + | destroySession, | |
| + | readSessionCookie, | |
| + | } from "./auth"; | |
| export { | ||
| type MediaService, | ||
| type ModerationService, |
| @@ -1,16 +1,20 @@ | ||
| - | import { and, desc, eq } from "drizzle-orm"; | |
| + | import { randomBytes } from "node:crypto"; | |
| + | import { and, desc, eq, sql } from "drizzle-orm"; | |
| import { z } from "zod"; | ||
| import { | ||
| - | chapters, | |
| cities, | ||
| classes, | ||
| comments, | ||
| djs, | ||
| events, | ||
| flags, | ||
| - | organizations, | |
| + | orders, | |
| + | proAccounts, | |
| promoters, | ||
| - | stepTeams, | |
| + | sponsors, | |
| + | steppingGroups, | |
| + | ticketTypes, | |
| + | tickets, | |
| travelPlaces, | ||
| videos, | ||
| } from "@stepping/db"; | ||
| @@ -49,13 +53,6 @@ export const appRouter = router({ | ||
| ), | ||
| }), | ||
| - | // ---- Organizations (Divine Nine reference set) --------------------------- | |
| - | organization: router({ | |
| - | list: publicProcedure.query(({ ctx }) => | |
| - | ctx.db.select().from(organizations), | |
| - | ), | |
| - | }), | |
| - | ||
| // ---- Events -------------------------------------------------------------- | ||
| event: router({ | ||
| list: publicProcedure | ||
| @@ -95,6 +92,7 @@ export const appRouter = router({ | ||
| externalUrl: z.string().url().optional(), | ||
| ticketUrl: z.string().url().optional(), | ||
| isFree: z.boolean().optional(), | ||
| + | isPromoted: z.boolean().optional(), | |
| }), | ||
| ) | ||
| .mutation(async ({ ctx, input }) => { | ||
| @@ -104,6 +102,204 @@ export const appRouter = router({ | ||
| .returning(); | ||
| return row; | ||
| }), | ||
| + | // [G] go-live: flip a set live (or off) and set the stream embed. | |
| + | setLive: protectedProcedure | |
| + | .input( | |
| + | z.object({ | |
| + | id: uuid, | |
| + | isLive: z.boolean(), | |
| + | liveUrl: z.string().url().optional(), | |
| + | }), | |
| + | ) | |
| + | .mutation(async ({ ctx, input }) => { | |
| + | const [row] = await ctx.db | |
| + | .update(events) | |
| + | .set({ isLive: input.isLive, liveUrl: input.liveUrl ?? null }) | |
| + | .where(eq(events.id, input.id)) | |
| + | .returning(); | |
| + | return row; | |
| + | }), | |
| + | // Featured listing: boost an event so it ranks first for a window. Records | |
| + | // a boost order (payment stubbed - free for now). | |
| + | boost: protectedProcedure | |
| + | .input(z.object({ id: uuid, days: z.number().int().min(1).max(60).default(14) })) | |
| + | .mutation(async ({ ctx, input }) => { | |
| + | const until = new Date(Date.now() + input.days * 86_400_000); | |
| + | const [row] = await ctx.db | |
| + | .update(events) | |
| + | .set({ isPromoted: true, boostedUntil: until }) | |
| + | .where(eq(events.id, input.id)) | |
| + | .returning(); | |
| + | await ctx.db.insert(orders).values({ | |
| + | purpose: "boost", | |
| + | buyerId: ctx.user.id, | |
| + | eventId: input.id, | |
| + | amountCents: 0, | |
| + | status: "reserved", | |
| + | meta: { days: input.days }, | |
| + | }); | |
| + | return row; | |
| + | }), | |
| + | }), | |
| + | ||
| + | // ---- Ticketing ----------------------------------------------------------- | |
| + | ticketType: router({ | |
| + | list: publicProcedure.input(z.object({ eventId: uuid })).query(({ ctx, input }) => | |
| + | ctx.db | |
| + | .select() | |
| + | .from(ticketTypes) | |
| + | .where(and(eq(ticketTypes.eventId, input.eventId), eq(ticketTypes.isActive, true))) | |
| + | .orderBy(ticketTypes.priceCents), | |
| + | ), | |
| + | create: protectedProcedure | |
| + | .input( | |
| + | z.object({ | |
| + | eventId: uuid, | |
| + | name: z.string().min(1), | |
| + | priceCents: z.number().int().min(0).max(1_000_000), | |
| + | capacity: z.number().int().min(1).max(100_000).optional(), | |
| + | }), | |
| + | ) | |
| + | .mutation(async ({ ctx, input }) => { | |
| + | const [row] = await ctx.db | |
| + | .insert(ticketTypes) | |
| + | .values({ ...input, ownerId: ctx.user.id }) | |
| + | .returning(); | |
| + | return row; | |
| + | }), | |
| + | }), | |
| + | ||
| + | ticket: router({ | |
| + | // Reserve tickets (no charge yet): creates an order + issues coded tickets. | |
| + | reserve: protectedProcedure | |
| + | .input(z.object({ ticketTypeId: uuid, quantity: z.number().int().min(1).max(10).default(1) })) | |
| + | .mutation(async ({ ctx, input }) => { | |
| + | const [tt] = await ctx.db | |
| + | .select() | |
| + | .from(ticketTypes) | |
| + | .where(and(eq(ticketTypes.id, input.ticketTypeId), eq(ticketTypes.isActive, true))); | |
| + | if (!tt) throw new Error("Ticket type not available."); | |
| + | if (tt.capacity != null && tt.sold + input.quantity > tt.capacity) { | |
| + | throw new Error("Not enough tickets left."); | |
| + | } | |
| + | const [order] = await ctx.db | |
| + | .insert(orders) | |
| + | .values({ | |
| + | purpose: "ticket", | |
| + | buyerId: ctx.user.id, | |
| + | eventId: tt.eventId, | |
| + | ticketTypeId: tt.id, | |
| + | quantity: input.quantity, | |
| + | amountCents: tt.priceCents * input.quantity, | |
| + | status: "reserved", | |
| + | }) | |
| + | .returning(); | |
| + | if (!order) throw new Error("Could not create order."); | |
| + | const rows = Array.from({ length: input.quantity }, () => ({ | |
| + | orderId: order.id, | |
| + | eventId: tt.eventId, | |
| + | ticketTypeId: tt.id, | |
| + | holderId: ctx.user.id, | |
| + | code: randomBytes(5).toString("hex").toUpperCase(), | |
| + | })); | |
| + | const issued = await ctx.db.insert(tickets).values(rows).returning(); | |
| + | await ctx.db | |
| + | .update(ticketTypes) | |
| + | .set({ sold: sql`${ticketTypes.sold} + ${input.quantity}` }) | |
| + | .where(eq(ticketTypes.id, tt.id)); | |
| + | return { orderId: order.id, tickets: issued }; | |
| + | }), | |
| + | mine: protectedProcedure.query(({ ctx }) => | |
| + | ctx.db | |
| + | .select({ | |
| + | id: tickets.id, | |
| + | code: tickets.code, | |
| + | status: tickets.status, | |
| + | createdAt: tickets.createdAt, | |
| + | eventId: tickets.eventId, | |
| + | eventTitle: events.title, | |
| + | startsAt: events.startsAt, | |
| + | typeName: ticketTypes.name, | |
| + | }) | |
| + | .from(tickets) | |
| + | .innerJoin(events, eq(events.id, tickets.eventId)) | |
| + | .leftJoin(ticketTypes, eq(ticketTypes.id, tickets.ticketTypeId)) | |
| + | .where(eq(tickets.holderId, ctx.user.id)) | |
| + | .orderBy(desc(tickets.createdAt)), | |
| + | ), | |
| + | }), | |
| + | ||
| + | // ---- Sponsors (public read; admin-managed) ------------------------------- | |
| + | sponsor: router({ | |
| + | listActive: publicProcedure.input(z.object({ cityId: uuid })).query(({ ctx, input }) => | |
| + | ctx.db | |
| + | .select() | |
| + | .from(sponsors) | |
| + | .where(and(eq(sponsors.cityId, input.cityId), eq(sponsors.status, "active"))) | |
| + | .orderBy(desc(sponsors.createdAt)), | |
| + | ), | |
| + | adminList: adminProcedure.query(({ ctx }) => | |
| + | ctx.db.select().from(sponsors).orderBy(desc(sponsors.createdAt)).limit(100), | |
| + | ), | |
| + | adminCreate: adminProcedure | |
| + | .input( | |
| + | z.object({ | |
| + | cityId: uuid, | |
| + | name: z.string().min(1), | |
| + | blurb: z.string().optional(), | |
| + | linkUrl: z.string().url().optional(), | |
| + | status: z.enum(["pending", "active", "ended"]).default("active"), | |
| + | }), | |
| + | ) | |
| + | .mutation(async ({ ctx, input }) => { | |
| + | const [row] = await ctx.db | |
| + | .insert(sponsors) | |
| + | .values({ ...input, ownerId: ctx.user.id }) | |
| + | .returning(); | |
| + | return row; | |
| + | }), | |
| + | adminSetStatus: adminProcedure | |
| + | .input(z.object({ id: uuid, status: z.enum(["pending", "active", "ended"]) })) | |
| + | .mutation(async ({ ctx, input }) => { | |
| + | const [row] = await ctx.db | |
| + | .update(sponsors) | |
| + | .set({ status: input.status }) | |
| + | .where(eq(sponsors.id, input.id)) | |
| + | .returning(); | |
| + | return row; | |
| + | }), | |
| + | }), | |
| + | ||
| + | // ---- Pro accounts (admin-managed) ---------------------------------------- | |
| + | proAccount: router({ | |
| + | adminList: adminProcedure.query(({ ctx }) => | |
| + | ctx.db.select().from(proAccounts).orderBy(desc(proAccounts.createdAt)).limit(100), | |
| + | ), | |
| + | adminSetStatus: adminProcedure | |
| + | .input( | |
| + | z.object({ | |
| + | id: uuid, | |
| + | status: z.enum(["pending", "active", "expired", "cancelled"]), | |
| + | }), | |
| + | ) | |
| + | .mutation(async ({ ctx, input }) => { | |
| + | const [row] = await ctx.db | |
| + | .update(proAccounts) | |
| + | .set({ | |
| + | status: input.status, | |
| + | startedAt: input.status === "active" ? new Date() : undefined, | |
| + | }) | |
| + | .where(eq(proAccounts.id, input.id)) | |
| + | .returning(); | |
| + | return row; | |
| + | }), | |
| + | }), | |
| + | ||
| + | // ---- Orders (admin view of the money record) ----------------------------- | |
| + | order: router({ | |
| + | adminRecent: adminProcedure.query(({ ctx }) => | |
| + | ctx.db.select().from(orders).orderBy(desc(orders.createdAt)).limit(50), | |
| + | ), | |
| }), | ||
| // ---- Videos (embed-only in MVP) ------------------------------------------ | ||
| @@ -186,7 +382,7 @@ export const appRouter = router({ | ||
| }), | ||
| }), | ||
| - | // ---- Directories: promoters / DJs / step teams / chapters / classes ------ | |
| + | // ---- Directories: promoters / DJs / stepping groups / classes ------------ | |
| promoter: router({ | ||
| list: publicProcedure.input(z.object({ cityId: uuid })).query(({ ctx, input }) => | ||
| ctx.db | ||
| @@ -235,61 +431,34 @@ export const appRouter = router({ | ||
| }), | ||
| }), | ||
| - | stepTeam: router({ | |
| + | steppingGroup: router({ | |
| list: publicProcedure.input(z.object({ cityId: uuid })).query(({ ctx, input }) => | ||
| ctx.db | ||
| .select() | ||
| - | .from(stepTeams) | |
| - | .where(and(eq(stepTeams.cityId, input.cityId), eq(stepTeams.status, "active"))), | |
| + | .from(steppingGroups) | |
| + | .where( | |
| + | and(eq(steppingGroups.cityId, input.cityId), eq(steppingGroups.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({ | |
| - | cityId: uuid, | |
| - | name: z.string().min(1), | |
| - | affiliationOrgId: uuid.optional(), | |
| - | bio: z.string().optional(), | |
| - | links, | |
| - | }), | |
| - | ) | |
| - | .mutation(async ({ ctx, input }) => { | |
| - | const [row] = await ctx.db | |
| - | .insert(stepTeams) | |
| - | .values({ ...input, ownerId: ctx.user.id }) | |
| - | .returning(); | |
| - | return row; | |
| - | }), | |
| - | }), | |
| - | ||
| - | chapter: router({ | |
| - | list: publicProcedure.input(z.object({ cityId: uuid })).query(({ ctx, input }) => | |
| - | ctx.db | |
| + | const [row] = await ctx.db | |
| .select() | ||
| - | .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)); | |
| + | .from(steppingGroups) | |
| + | .where(eq(steppingGroups.id, input.id)); | |
| return row ?? null; | ||
| }), | ||
| create: protectedProcedure | ||
| .input( | ||
| z.object({ | ||
| - | organizationId: uuid, | |
| cityId: uuid, | ||
| name: z.string().min(1), | ||
| - | school: z.string().optional(), | |
| bio: z.string().optional(), | ||
| links, | ||
| }), | ||
| ) | ||
| .mutation(async ({ ctx, input }) => { | ||
| const [row] = await ctx.db | ||
| - | .insert(chapters) | |
| + | .insert(steppingGroups) | |
| .values({ ...input, ownerId: ctx.user.id }) | ||
| .returning(); | ||
| return row; |
| @@ -17,9 +17,12 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { | ||
| return next({ ctx: { ...ctx, user: ctx.user } }); | ||
| }); | ||
| - | // Moderation surface - admin/moderator only (role is a dormant column today). | |
| + | // Moderation + admin surface - requires a real session whose user role is | |
| + | // moderator or admin (resolved from the DB in createContext). | |
| export const adminProcedure = t.procedure.use(({ ctx, next }) => { | ||
| if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" }); | ||
| - | // TODO(phase-2): check ctx user's role from the users table. | |
| + | if (ctx.user.role !== "admin" && ctx.user.role !== "moderator") { | |
| + | throw new TRPCError({ code: "FORBIDDEN", message: "Admins only." }); | |
| + | } | |
| return next({ ctx: { ...ctx, user: ctx.user } }); | ||
| }); |
| @@ -7,18 +7,14 @@ 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; | |
| - | export type LaunchCitySlug = (typeof LAUNCH_CITY_SLUGS)[number]; | |
| - | ||
| - | // The Divine Nine (NPHC) reference set, seeded into `organizations`. | |
| - | export const DIVINE_NINE = [ | |
| - | { name: "Alpha Phi Alpha", letters: "ΑΦΑ", type: "fraternity", founded: 1906 }, | |
| - | { name: "Alpha Kappa Alpha", letters: "ΑΚΑ", type: "sorority", founded: 1908 }, | |
| - | { name: "Kappa Alpha Psi", letters: "ΚΑΨ", type: "fraternity", founded: 1911 }, | |
| - | { name: "Omega Psi Phi", letters: "ΩΨΦ", type: "fraternity", founded: 1911 }, | |
| - | { name: "Delta Sigma Theta", letters: "ΔΣΘ", type: "sorority", founded: 1913 }, | |
| - | { name: "Phi Beta Sigma", letters: "ΦΒΣ", type: "fraternity", founded: 1914 }, | |
| - | { name: "Zeta Phi Beta", letters: "ΖΦΒ", type: "sorority", founded: 1920 }, | |
| - | { name: "Sigma Gamma Rho", letters: "ΣΓΡ", type: "sorority", founded: 1922 }, | |
| - | { name: "Iota Phi Theta", letters: "ΙΦΘ", type: "fraternity", founded: 1963 }, | |
| + | // Chicago is the home market of Chicago stepping; the rest are strong steppin' | |
| + | // scenes (dad name-drops Houston + Charlotte). Confirm the final list with dad. | |
| + | export const LAUNCH_CITY_SLUGS = [ | |
| + | "chicago", | |
| + | "atlanta", | |
| + | "detroit", | |
| + | "houston", | |
| + | "charlotte", | |
| + | "washington-dc", | |
| ] as const; | ||
| + | export type LaunchCitySlug = (typeof LAUNCH_CITY_SLUGS)[number]; |
| @@ -0,0 +1,344 @@ | ||
| + | CREATE TYPE "public"."content_status" AS ENUM('active', 'pending', 'hidden', 'removed');--> statement-breakpoint | |
| + | CREATE TYPE "public"."event_type" AS ENUM('set', 'weekender', 'class', 'workshop', 'competition', 'special', 'social');--> statement-breakpoint | |
| + | CREATE TYPE "public"."flag_status" AS ENUM('open', 'reviewed', 'actioned', 'dismissed');--> statement-breakpoint | |
| + | CREATE TYPE "public"."order_purpose" AS ENUM('ticket', 'boost', 'pro', 'sponsor');--> statement-breakpoint | |
| + | CREATE TYPE "public"."order_status" AS ENUM('reserved', 'paid', 'refunded', 'cancelled');--> statement-breakpoint | |
| + | CREATE TYPE "public"."pro_status" AS ENUM('pending', 'active', 'expired', 'cancelled');--> statement-breakpoint | |
| + | CREATE TYPE "public"."sponsor_status" AS ENUM('pending', 'active', 'ended');--> statement-breakpoint | |
| + | CREATE TYPE "public"."subject_type" AS ENUM('event', 'video', 'promoter', 'dj', 'stepping_group', 'class');--> statement-breakpoint | |
| + | CREATE TYPE "public"."ticket_status" AS ENUM('valid', 'used', 'void');--> statement-breakpoint | |
| + | CREATE TYPE "public"."travel_category" AS ENUM('event_center', 'hotel', 'restaurant', 'mall', 'attraction', 'entertainment', 'hospital');--> statement-breakpoint | |
| + | CREATE TYPE "public"."user_role" AS ENUM('member', 'moderator', 'admin');--> statement-breakpoint | |
| + | CREATE TYPE "public"."video_platform" AS ENUM('youtube', 'instagram', 'tiktok', 'other');--> statement-breakpoint | |
| + | CREATE TABLE "audit_log" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "actor_id" uuid, | |
| + | "action" text NOT NULL, | |
| + | "subject_type" text, | |
| + | "subject_id" uuid, | |
| + | "meta" jsonb, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "cities" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "slug" text NOT NULL, | |
| + | "name" text NOT NULL, | |
| + | "metro_label" text, | |
| + | "state" text, | |
| + | "tier" integer DEFAULT 2 NOT NULL, | |
| + | "is_launched" boolean DEFAULT false NOT NULL, | |
| + | "lat" double precision, | |
| + | "lng" double precision, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | CONSTRAINT "cities_slug_unique" UNIQUE("slug") | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "classes" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "city_id" uuid NOT NULL, | |
| + | "title" text NOT NULL, | |
| + | "instructor" text, | |
| + | "schedule" text, | |
| + | "venue_id" uuid, | |
| + | "price_text" text, | |
| + | "is_free" boolean DEFAULT false NOT NULL, | |
| + | "owner_id" uuid, | |
| + | "status" "content_status" DEFAULT 'active' NOT NULL, | |
| + | "verified_at" timestamp with time zone, | |
| + | "hidden_at" timestamp with time zone, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "comments" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "subject_type" "subject_type" NOT NULL, | |
| + | "subject_id" uuid NOT NULL, | |
| + | "body" text NOT NULL, | |
| + | "owner_id" uuid, | |
| + | "status" "content_status" DEFAULT 'active' NOT NULL, | |
| + | "verified_at" timestamp with time zone, | |
| + | "hidden_at" timestamp with time zone, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "djs" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "city_id" uuid NOT NULL, | |
| + | "name" text NOT NULL, | |
| + | "bio" text, | |
| + | "links" jsonb, | |
| + | "owner_id" uuid, | |
| + | "status" "content_status" DEFAULT 'active' NOT NULL, | |
| + | "verified_at" timestamp with time zone, | |
| + | "hidden_at" timestamp with time zone, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "event_djs" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "event_id" uuid NOT NULL, | |
| + | "dj_id" uuid NOT NULL, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "event_participants" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "event_id" uuid NOT NULL, | |
| + | "stepping_group_id" uuid, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "events" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "city_id" uuid NOT NULL, | |
| + | "venue_id" uuid, | |
| + | "title" text NOT NULL, | |
| + | "type" "event_type" NOT NULL, | |
| + | "description" text, | |
| + | "starts_at" timestamp with time zone, | |
| + | "ends_at" timestamp with time zone, | |
| + | "external_url" text, | |
| + | "is_promoted" boolean DEFAULT false NOT NULL, | |
| + | "boosted_until" timestamp with time zone, | |
| + | "is_live" boolean DEFAULT false NOT NULL, | |
| + | "live_url" text, | |
| + | "ticket_url" text, | |
| + | "price_text" text, | |
| + | "is_free" boolean DEFAULT false NOT NULL, | |
| + | "owner_id" uuid, | |
| + | "status" "content_status" DEFAULT 'active' NOT NULL, | |
| + | "verified_at" timestamp with time zone, | |
| + | "hidden_at" timestamp with time zone, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "flags" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "subject_type" "subject_type" NOT NULL, | |
| + | "subject_id" uuid NOT NULL, | |
| + | "reporter_id" uuid, | |
| + | "reason" text, | |
| + | "status" "flag_status" DEFAULT 'open' NOT NULL, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "group_memberships" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "stepping_group_id" uuid NOT NULL, | |
| + | "user_id" uuid NOT NULL, | |
| + | "role_in_group" text, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "orders" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "purpose" "order_purpose" NOT NULL, | |
| + | "buyer_id" uuid, | |
| + | "event_id" uuid, | |
| + | "ticket_type_id" uuid, | |
| + | "quantity" integer DEFAULT 1 NOT NULL, | |
| + | "amount_cents" integer DEFAULT 0 NOT NULL, | |
| + | "status" "order_status" DEFAULT 'reserved' NOT NULL, | |
| + | "meta" jsonb, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "pro_accounts" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "user_id" uuid NOT NULL, | |
| + | "tier" text DEFAULT 'pro' NOT NULL, | |
| + | "status" "pro_status" DEFAULT 'pending' NOT NULL, | |
| + | "started_at" timestamp with time zone, | |
| + | "expires_at" timestamp with time zone, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "promoters" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "city_id" uuid NOT NULL, | |
| + | "name" text NOT NULL, | |
| + | "bio" text, | |
| + | "links" jsonb, | |
| + | "owner_id" uuid, | |
| + | "status" "content_status" DEFAULT 'active' NOT NULL, | |
| + | "verified_at" timestamp with time zone, | |
| + | "hidden_at" timestamp with time zone, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "sessions" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "token_hash" text NOT NULL, | |
| + | "user_id" uuid NOT NULL, | |
| + | "expires_at" timestamp with time zone NOT NULL, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | CONSTRAINT "sessions_token_hash_unique" UNIQUE("token_hash") | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "sponsors" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "city_id" uuid NOT NULL, | |
| + | "name" text NOT NULL, | |
| + | "blurb" text, | |
| + | "link_url" text, | |
| + | "image_url" text, | |
| + | "status" "sponsor_status" DEFAULT 'pending' NOT NULL, | |
| + | "starts_at" timestamp with time zone, | |
| + | "ends_at" timestamp with time zone, | |
| + | "owner_id" uuid, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "stepping_groups" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "city_id" uuid NOT NULL, | |
| + | "name" text NOT NULL, | |
| + | "bio" text, | |
| + | "links" jsonb, | |
| + | "owner_id" uuid, | |
| + | "status" "content_status" DEFAULT 'active' NOT NULL, | |
| + | "verified_at" timestamp with time zone, | |
| + | "hidden_at" timestamp with time zone, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "ticket_types" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "event_id" uuid NOT NULL, | |
| + | "name" text NOT NULL, | |
| + | "price_cents" integer DEFAULT 0 NOT NULL, | |
| + | "currency" text DEFAULT 'usd' NOT NULL, | |
| + | "capacity" integer, | |
| + | "sold" integer DEFAULT 0 NOT NULL, | |
| + | "is_active" boolean DEFAULT true NOT NULL, | |
| + | "owner_id" uuid, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "tickets" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "order_id" uuid NOT NULL, | |
| + | "event_id" uuid NOT NULL, | |
| + | "ticket_type_id" uuid, | |
| + | "holder_id" uuid, | |
| + | "code" text NOT NULL, | |
| + | "status" "ticket_status" DEFAULT 'valid' NOT NULL, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | CONSTRAINT "tickets_code_unique" UNIQUE("code") | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "travel_places" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "city_id" uuid NOT NULL, | |
| + | "category" "travel_category" NOT NULL, | |
| + | "name" text NOT NULL, | |
| + | "address" text, | |
| + | "lat" double precision, | |
| + | "lng" double precision, | |
| + | "source" text DEFAULT 'manual' NOT NULL, | |
| + | "place_ref" text, | |
| + | "status" "content_status" DEFAULT 'active' NOT NULL, | |
| + | "verified_at" timestamp with time zone, | |
| + | "hidden_at" timestamp with time zone, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "users" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "email" text, | |
| + | "password_hash" text, | |
| + | "handle" text, | |
| + | "display_name" text, | |
| + | "avatar_url" text, | |
| + | "role" "user_role" DEFAULT 'member' NOT NULL, | |
| + | "trust_tier" integer DEFAULT 0 NOT NULL, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | CONSTRAINT "users_email_unique" UNIQUE("email"), | |
| + | CONSTRAINT "users_handle_unique" UNIQUE("handle") | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "venues" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "city_id" uuid NOT NULL, | |
| + | "name" text NOT NULL, | |
| + | "address" text, | |
| + | "lat" double precision, | |
| + | "lng" double precision, | |
| + | "owner_id" uuid, | |
| + | "status" "content_status" DEFAULT 'active' NOT NULL, | |
| + | "verified_at" timestamp with time zone, | |
| + | "hidden_at" timestamp with time zone, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | CREATE TABLE "videos" ( | |
| + | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| + | "city_id" uuid NOT NULL, | |
| + | "title" text NOT NULL, | |
| + | "embed_url" text NOT NULL, | |
| + | "platform" "video_platform" DEFAULT 'other' NOT NULL, | |
| + | "subject_type" "subject_type", | |
| + | "subject_id" uuid, | |
| + | "owner_id" uuid, | |
| + | "status" "content_status" DEFAULT 'active' NOT NULL, | |
| + | "verified_at" timestamp with time zone, | |
| + | "hidden_at" timestamp with time zone, | |
| + | "created_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| + | "updated_at" timestamp with time zone DEFAULT now() NOT NULL | |
| + | ); | |
| + | --> statement-breakpoint | |
| + | ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "classes" ADD CONSTRAINT "classes_city_id_cities_id_fk" FOREIGN KEY ("city_id") REFERENCES "public"."cities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "classes" ADD CONSTRAINT "classes_venue_id_venues_id_fk" FOREIGN KEY ("venue_id") REFERENCES "public"."venues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "classes" ADD CONSTRAINT "classes_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "comments" ADD CONSTRAINT "comments_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "djs" ADD CONSTRAINT "djs_city_id_cities_id_fk" FOREIGN KEY ("city_id") REFERENCES "public"."cities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "djs" ADD CONSTRAINT "djs_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "event_djs" ADD CONSTRAINT "event_djs_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "event_djs" ADD CONSTRAINT "event_djs_dj_id_djs_id_fk" FOREIGN KEY ("dj_id") REFERENCES "public"."djs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "event_participants" ADD CONSTRAINT "event_participants_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "event_participants" ADD CONSTRAINT "event_participants_stepping_group_id_stepping_groups_id_fk" FOREIGN KEY ("stepping_group_id") REFERENCES "public"."stepping_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "events" ADD CONSTRAINT "events_city_id_cities_id_fk" FOREIGN KEY ("city_id") REFERENCES "public"."cities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "events" ADD CONSTRAINT "events_venue_id_venues_id_fk" FOREIGN KEY ("venue_id") REFERENCES "public"."venues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "events" ADD CONSTRAINT "events_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "flags" ADD CONSTRAINT "flags_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "group_memberships" ADD CONSTRAINT "group_memberships_stepping_group_id_stepping_groups_id_fk" FOREIGN KEY ("stepping_group_id") REFERENCES "public"."stepping_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "group_memberships" ADD CONSTRAINT "group_memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "orders" ADD CONSTRAINT "orders_buyer_id_users_id_fk" FOREIGN KEY ("buyer_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "orders" ADD CONSTRAINT "orders_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "orders" ADD CONSTRAINT "orders_ticket_type_id_ticket_types_id_fk" FOREIGN KEY ("ticket_type_id") REFERENCES "public"."ticket_types"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "pro_accounts" ADD CONSTRAINT "pro_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "promoters" ADD CONSTRAINT "promoters_city_id_cities_id_fk" FOREIGN KEY ("city_id") REFERENCES "public"."cities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "promoters" ADD CONSTRAINT "promoters_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "sponsors" ADD CONSTRAINT "sponsors_city_id_cities_id_fk" FOREIGN KEY ("city_id") REFERENCES "public"."cities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "sponsors" ADD CONSTRAINT "sponsors_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "stepping_groups" ADD CONSTRAINT "stepping_groups_city_id_cities_id_fk" FOREIGN KEY ("city_id") REFERENCES "public"."cities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "stepping_groups" ADD CONSTRAINT "stepping_groups_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "ticket_types" ADD CONSTRAINT "ticket_types_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "ticket_types" ADD CONSTRAINT "ticket_types_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "tickets" ADD CONSTRAINT "tickets_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "tickets" ADD CONSTRAINT "tickets_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "tickets" ADD CONSTRAINT "tickets_ticket_type_id_ticket_types_id_fk" FOREIGN KEY ("ticket_type_id") REFERENCES "public"."ticket_types"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "tickets" ADD CONSTRAINT "tickets_holder_id_users_id_fk" FOREIGN KEY ("holder_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "travel_places" ADD CONSTRAINT "travel_places_city_id_cities_id_fk" FOREIGN KEY ("city_id") REFERENCES "public"."cities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "venues" ADD CONSTRAINT "venues_city_id_cities_id_fk" FOREIGN KEY ("city_id") REFERENCES "public"."cities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "venues" ADD CONSTRAINT "venues_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "videos" ADD CONSTRAINT "videos_city_id_cities_id_fk" FOREIGN KEY ("city_id") REFERENCES "public"."cities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| + | ALTER TABLE "videos" ADD CONSTRAINT "videos_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; |
| @@ -0,0 +1,2442 @@ | ||
| + | { | |
| + | "id": "026fc771-73ac-46a5-ad08-15b9b4a3f17a", | |
| + | "prevId": "00000000-0000-0000-0000-000000000000", | |
| + | "version": "7", | |
| + | "dialect": "postgresql", | |
| + | "tables": { | |
| + | "public.audit_log": { | |
| + | "name": "audit_log", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "actor_id": { | |
| + | "name": "actor_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "action": { | |
| + | "name": "action", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "subject_type": { | |
| + | "name": "subject_type", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "subject_id": { | |
| + | "name": "subject_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "meta": { | |
| + | "name": "meta", | |
| + | "type": "jsonb", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "audit_log_actor_id_users_id_fk": { | |
| + | "name": "audit_log_actor_id_users_id_fk", | |
| + | "tableFrom": "audit_log", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "actor_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.cities": { | |
| + | "name": "cities", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "slug": { | |
| + | "name": "slug", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "name": { | |
| + | "name": "name", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "metro_label": { | |
| + | "name": "metro_label", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "state": { | |
| + | "name": "state", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "tier": { | |
| + | "name": "tier", | |
| + | "type": "integer", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": 2 | |
| + | }, | |
| + | "is_launched": { | |
| + | "name": "is_launched", | |
| + | "type": "boolean", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": false | |
| + | }, | |
| + | "lat": { | |
| + | "name": "lat", | |
| + | "type": "double precision", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "lng": { | |
| + | "name": "lng", | |
| + | "type": "double precision", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": {}, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": { | |
| + | "cities_slug_unique": { | |
| + | "name": "cities_slug_unique", | |
| + | "nullsNotDistinct": false, | |
| + | "columns": [ | |
| + | "slug" | |
| + | ] | |
| + | } | |
| + | }, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.classes": { | |
| + | "name": "classes", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "city_id": { | |
| + | "name": "city_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "title": { | |
| + | "name": "title", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "instructor": { | |
| + | "name": "instructor", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "schedule": { | |
| + | "name": "schedule", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "venue_id": { | |
| + | "name": "venue_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "price_text": { | |
| + | "name": "price_text", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "is_free": { | |
| + | "name": "is_free", | |
| + | "type": "boolean", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": false | |
| + | }, | |
| + | "owner_id": { | |
| + | "name": "owner_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "content_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'active'" | |
| + | }, | |
| + | "verified_at": { | |
| + | "name": "verified_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "hidden_at": { | |
| + | "name": "hidden_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "classes_city_id_cities_id_fk": { | |
| + | "name": "classes_city_id_cities_id_fk", | |
| + | "tableFrom": "classes", | |
| + | "tableTo": "cities", | |
| + | "columnsFrom": [ | |
| + | "city_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "classes_venue_id_venues_id_fk": { | |
| + | "name": "classes_venue_id_venues_id_fk", | |
| + | "tableFrom": "classes", | |
| + | "tableTo": "venues", | |
| + | "columnsFrom": [ | |
| + | "venue_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "classes_owner_id_users_id_fk": { | |
| + | "name": "classes_owner_id_users_id_fk", | |
| + | "tableFrom": "classes", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "owner_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.comments": { | |
| + | "name": "comments", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "subject_type": { | |
| + | "name": "subject_type", | |
| + | "type": "subject_type", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "subject_id": { | |
| + | "name": "subject_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "body": { | |
| + | "name": "body", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "owner_id": { | |
| + | "name": "owner_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "content_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'active'" | |
| + | }, | |
| + | "verified_at": { | |
| + | "name": "verified_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "hidden_at": { | |
| + | "name": "hidden_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "comments_owner_id_users_id_fk": { | |
| + | "name": "comments_owner_id_users_id_fk", | |
| + | "tableFrom": "comments", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "owner_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.djs": { | |
| + | "name": "djs", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "city_id": { | |
| + | "name": "city_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "name": { | |
| + | "name": "name", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "bio": { | |
| + | "name": "bio", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "links": { | |
| + | "name": "links", | |
| + | "type": "jsonb", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "owner_id": { | |
| + | "name": "owner_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "content_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'active'" | |
| + | }, | |
| + | "verified_at": { | |
| + | "name": "verified_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "hidden_at": { | |
| + | "name": "hidden_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "djs_city_id_cities_id_fk": { | |
| + | "name": "djs_city_id_cities_id_fk", | |
| + | "tableFrom": "djs", | |
| + | "tableTo": "cities", | |
| + | "columnsFrom": [ | |
| + | "city_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "djs_owner_id_users_id_fk": { | |
| + | "name": "djs_owner_id_users_id_fk", | |
| + | "tableFrom": "djs", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "owner_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.event_djs": { | |
| + | "name": "event_djs", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "event_id": { | |
| + | "name": "event_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "dj_id": { | |
| + | "name": "dj_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "event_djs_event_id_events_id_fk": { | |
| + | "name": "event_djs_event_id_events_id_fk", | |
| + | "tableFrom": "event_djs", | |
| + | "tableTo": "events", | |
| + | "columnsFrom": [ | |
| + | "event_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "event_djs_dj_id_djs_id_fk": { | |
| + | "name": "event_djs_dj_id_djs_id_fk", | |
| + | "tableFrom": "event_djs", | |
| + | "tableTo": "djs", | |
| + | "columnsFrom": [ | |
| + | "dj_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.event_participants": { | |
| + | "name": "event_participants", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "event_id": { | |
| + | "name": "event_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "stepping_group_id": { | |
| + | "name": "stepping_group_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "event_participants_event_id_events_id_fk": { | |
| + | "name": "event_participants_event_id_events_id_fk", | |
| + | "tableFrom": "event_participants", | |
| + | "tableTo": "events", | |
| + | "columnsFrom": [ | |
| + | "event_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "event_participants_stepping_group_id_stepping_groups_id_fk": { | |
| + | "name": "event_participants_stepping_group_id_stepping_groups_id_fk", | |
| + | "tableFrom": "event_participants", | |
| + | "tableTo": "stepping_groups", | |
| + | "columnsFrom": [ | |
| + | "stepping_group_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.events": { | |
| + | "name": "events", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "city_id": { | |
| + | "name": "city_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "venue_id": { | |
| + | "name": "venue_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "title": { | |
| + | "name": "title", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "type": { | |
| + | "name": "type", | |
| + | "type": "event_type", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "description": { | |
| + | "name": "description", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "starts_at": { | |
| + | "name": "starts_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "ends_at": { | |
| + | "name": "ends_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "external_url": { | |
| + | "name": "external_url", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "is_promoted": { | |
| + | "name": "is_promoted", | |
| + | "type": "boolean", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": false | |
| + | }, | |
| + | "boosted_until": { | |
| + | "name": "boosted_until", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "is_live": { | |
| + | "name": "is_live", | |
| + | "type": "boolean", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": false | |
| + | }, | |
| + | "live_url": { | |
| + | "name": "live_url", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "ticket_url": { | |
| + | "name": "ticket_url", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "price_text": { | |
| + | "name": "price_text", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "is_free": { | |
| + | "name": "is_free", | |
| + | "type": "boolean", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": false | |
| + | }, | |
| + | "owner_id": { | |
| + | "name": "owner_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "content_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'active'" | |
| + | }, | |
| + | "verified_at": { | |
| + | "name": "verified_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "hidden_at": { | |
| + | "name": "hidden_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "events_city_id_cities_id_fk": { | |
| + | "name": "events_city_id_cities_id_fk", | |
| + | "tableFrom": "events", | |
| + | "tableTo": "cities", | |
| + | "columnsFrom": [ | |
| + | "city_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "events_venue_id_venues_id_fk": { | |
| + | "name": "events_venue_id_venues_id_fk", | |
| + | "tableFrom": "events", | |
| + | "tableTo": "venues", | |
| + | "columnsFrom": [ | |
| + | "venue_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "events_owner_id_users_id_fk": { | |
| + | "name": "events_owner_id_users_id_fk", | |
| + | "tableFrom": "events", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "owner_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.flags": { | |
| + | "name": "flags", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "subject_type": { | |
| + | "name": "subject_type", | |
| + | "type": "subject_type", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "subject_id": { | |
| + | "name": "subject_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "reporter_id": { | |
| + | "name": "reporter_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "reason": { | |
| + | "name": "reason", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "flag_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'open'" | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "flags_reporter_id_users_id_fk": { | |
| + | "name": "flags_reporter_id_users_id_fk", | |
| + | "tableFrom": "flags", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "reporter_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.group_memberships": { | |
| + | "name": "group_memberships", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "stepping_group_id": { | |
| + | "name": "stepping_group_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "user_id": { | |
| + | "name": "user_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "role_in_group": { | |
| + | "name": "role_in_group", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "group_memberships_stepping_group_id_stepping_groups_id_fk": { | |
| + | "name": "group_memberships_stepping_group_id_stepping_groups_id_fk", | |
| + | "tableFrom": "group_memberships", | |
| + | "tableTo": "stepping_groups", | |
| + | "columnsFrom": [ | |
| + | "stepping_group_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "group_memberships_user_id_users_id_fk": { | |
| + | "name": "group_memberships_user_id_users_id_fk", | |
| + | "tableFrom": "group_memberships", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "user_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.orders": { | |
| + | "name": "orders", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "purpose": { | |
| + | "name": "purpose", | |
| + | "type": "order_purpose", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "buyer_id": { | |
| + | "name": "buyer_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "event_id": { | |
| + | "name": "event_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "ticket_type_id": { | |
| + | "name": "ticket_type_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "quantity": { | |
| + | "name": "quantity", | |
| + | "type": "integer", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": 1 | |
| + | }, | |
| + | "amount_cents": { | |
| + | "name": "amount_cents", | |
| + | "type": "integer", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": 0 | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "order_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'reserved'" | |
| + | }, | |
| + | "meta": { | |
| + | "name": "meta", | |
| + | "type": "jsonb", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "orders_buyer_id_users_id_fk": { | |
| + | "name": "orders_buyer_id_users_id_fk", | |
| + | "tableFrom": "orders", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "buyer_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "orders_event_id_events_id_fk": { | |
| + | "name": "orders_event_id_events_id_fk", | |
| + | "tableFrom": "orders", | |
| + | "tableTo": "events", | |
| + | "columnsFrom": [ | |
| + | "event_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "orders_ticket_type_id_ticket_types_id_fk": { | |
| + | "name": "orders_ticket_type_id_ticket_types_id_fk", | |
| + | "tableFrom": "orders", | |
| + | "tableTo": "ticket_types", | |
| + | "columnsFrom": [ | |
| + | "ticket_type_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.pro_accounts": { | |
| + | "name": "pro_accounts", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "user_id": { | |
| + | "name": "user_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "tier": { | |
| + | "name": "tier", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'pro'" | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "pro_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'pending'" | |
| + | }, | |
| + | "started_at": { | |
| + | "name": "started_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "expires_at": { | |
| + | "name": "expires_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "pro_accounts_user_id_users_id_fk": { | |
| + | "name": "pro_accounts_user_id_users_id_fk", | |
| + | "tableFrom": "pro_accounts", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "user_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.promoters": { | |
| + | "name": "promoters", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "city_id": { | |
| + | "name": "city_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "name": { | |
| + | "name": "name", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "bio": { | |
| + | "name": "bio", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "links": { | |
| + | "name": "links", | |
| + | "type": "jsonb", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "owner_id": { | |
| + | "name": "owner_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "content_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'active'" | |
| + | }, | |
| + | "verified_at": { | |
| + | "name": "verified_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "hidden_at": { | |
| + | "name": "hidden_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "promoters_city_id_cities_id_fk": { | |
| + | "name": "promoters_city_id_cities_id_fk", | |
| + | "tableFrom": "promoters", | |
| + | "tableTo": "cities", | |
| + | "columnsFrom": [ | |
| + | "city_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "promoters_owner_id_users_id_fk": { | |
| + | "name": "promoters_owner_id_users_id_fk", | |
| + | "tableFrom": "promoters", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "owner_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.sessions": { | |
| + | "name": "sessions", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "token_hash": { | |
| + | "name": "token_hash", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "user_id": { | |
| + | "name": "user_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "expires_at": { | |
| + | "name": "expires_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "sessions_user_id_users_id_fk": { | |
| + | "name": "sessions_user_id_users_id_fk", | |
| + | "tableFrom": "sessions", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "user_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": { | |
| + | "sessions_token_hash_unique": { | |
| + | "name": "sessions_token_hash_unique", | |
| + | "nullsNotDistinct": false, | |
| + | "columns": [ | |
| + | "token_hash" | |
| + | ] | |
| + | } | |
| + | }, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.sponsors": { | |
| + | "name": "sponsors", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "city_id": { | |
| + | "name": "city_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "name": { | |
| + | "name": "name", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "blurb": { | |
| + | "name": "blurb", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "link_url": { | |
| + | "name": "link_url", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "image_url": { | |
| + | "name": "image_url", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "sponsor_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'pending'" | |
| + | }, | |
| + | "starts_at": { | |
| + | "name": "starts_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "ends_at": { | |
| + | "name": "ends_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "owner_id": { | |
| + | "name": "owner_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "sponsors_city_id_cities_id_fk": { | |
| + | "name": "sponsors_city_id_cities_id_fk", | |
| + | "tableFrom": "sponsors", | |
| + | "tableTo": "cities", | |
| + | "columnsFrom": [ | |
| + | "city_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "sponsors_owner_id_users_id_fk": { | |
| + | "name": "sponsors_owner_id_users_id_fk", | |
| + | "tableFrom": "sponsors", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "owner_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.stepping_groups": { | |
| + | "name": "stepping_groups", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "city_id": { | |
| + | "name": "city_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "name": { | |
| + | "name": "name", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "bio": { | |
| + | "name": "bio", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "links": { | |
| + | "name": "links", | |
| + | "type": "jsonb", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "owner_id": { | |
| + | "name": "owner_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "content_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'active'" | |
| + | }, | |
| + | "verified_at": { | |
| + | "name": "verified_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "hidden_at": { | |
| + | "name": "hidden_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "stepping_groups_city_id_cities_id_fk": { | |
| + | "name": "stepping_groups_city_id_cities_id_fk", | |
| + | "tableFrom": "stepping_groups", | |
| + | "tableTo": "cities", | |
| + | "columnsFrom": [ | |
| + | "city_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "stepping_groups_owner_id_users_id_fk": { | |
| + | "name": "stepping_groups_owner_id_users_id_fk", | |
| + | "tableFrom": "stepping_groups", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "owner_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.ticket_types": { | |
| + | "name": "ticket_types", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "event_id": { | |
| + | "name": "event_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "name": { | |
| + | "name": "name", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "price_cents": { | |
| + | "name": "price_cents", | |
| + | "type": "integer", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": 0 | |
| + | }, | |
| + | "currency": { | |
| + | "name": "currency", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'usd'" | |
| + | }, | |
| + | "capacity": { | |
| + | "name": "capacity", | |
| + | "type": "integer", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "sold": { | |
| + | "name": "sold", | |
| + | "type": "integer", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": 0 | |
| + | }, | |
| + | "is_active": { | |
| + | "name": "is_active", | |
| + | "type": "boolean", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": true | |
| + | }, | |
| + | "owner_id": { | |
| + | "name": "owner_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "ticket_types_event_id_events_id_fk": { | |
| + | "name": "ticket_types_event_id_events_id_fk", | |
| + | "tableFrom": "ticket_types", | |
| + | "tableTo": "events", | |
| + | "columnsFrom": [ | |
| + | "event_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "ticket_types_owner_id_users_id_fk": { | |
| + | "name": "ticket_types_owner_id_users_id_fk", | |
| + | "tableFrom": "ticket_types", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "owner_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.tickets": { | |
| + | "name": "tickets", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "order_id": { | |
| + | "name": "order_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "event_id": { | |
| + | "name": "event_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "ticket_type_id": { | |
| + | "name": "ticket_type_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "holder_id": { | |
| + | "name": "holder_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "code": { | |
| + | "name": "code", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "ticket_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'valid'" | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "tickets_order_id_orders_id_fk": { | |
| + | "name": "tickets_order_id_orders_id_fk", | |
| + | "tableFrom": "tickets", | |
| + | "tableTo": "orders", | |
| + | "columnsFrom": [ | |
| + | "order_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "tickets_event_id_events_id_fk": { | |
| + | "name": "tickets_event_id_events_id_fk", | |
| + | "tableFrom": "tickets", | |
| + | "tableTo": "events", | |
| + | "columnsFrom": [ | |
| + | "event_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "tickets_ticket_type_id_ticket_types_id_fk": { | |
| + | "name": "tickets_ticket_type_id_ticket_types_id_fk", | |
| + | "tableFrom": "tickets", | |
| + | "tableTo": "ticket_types", | |
| + | "columnsFrom": [ | |
| + | "ticket_type_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "tickets_holder_id_users_id_fk": { | |
| + | "name": "tickets_holder_id_users_id_fk", | |
| + | "tableFrom": "tickets", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "holder_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": { | |
| + | "tickets_code_unique": { | |
| + | "name": "tickets_code_unique", | |
| + | "nullsNotDistinct": false, | |
| + | "columns": [ | |
| + | "code" | |
| + | ] | |
| + | } | |
| + | }, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.travel_places": { | |
| + | "name": "travel_places", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "city_id": { | |
| + | "name": "city_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "category": { | |
| + | "name": "category", | |
| + | "type": "travel_category", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "name": { | |
| + | "name": "name", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "address": { | |
| + | "name": "address", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "lat": { | |
| + | "name": "lat", | |
| + | "type": "double precision", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "lng": { | |
| + | "name": "lng", | |
| + | "type": "double precision", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "source": { | |
| + | "name": "source", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'manual'" | |
| + | }, | |
| + | "place_ref": { | |
| + | "name": "place_ref", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "content_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'active'" | |
| + | }, | |
| + | "verified_at": { | |
| + | "name": "verified_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "hidden_at": { | |
| + | "name": "hidden_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "travel_places_city_id_cities_id_fk": { | |
| + | "name": "travel_places_city_id_cities_id_fk", | |
| + | "tableFrom": "travel_places", | |
| + | "tableTo": "cities", | |
| + | "columnsFrom": [ | |
| + | "city_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.users": { | |
| + | "name": "users", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "email": { | |
| + | "name": "email", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "password_hash": { | |
| + | "name": "password_hash", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "handle": { | |
| + | "name": "handle", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "display_name": { | |
| + | "name": "display_name", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "avatar_url": { | |
| + | "name": "avatar_url", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "role": { | |
| + | "name": "role", | |
| + | "type": "user_role", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'member'" | |
| + | }, | |
| + | "trust_tier": { | |
| + | "name": "trust_tier", | |
| + | "type": "integer", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": 0 | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": {}, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": { | |
| + | "users_email_unique": { | |
| + | "name": "users_email_unique", | |
| + | "nullsNotDistinct": false, | |
| + | "columns": [ | |
| + | "email" | |
| + | ] | |
| + | }, | |
| + | "users_handle_unique": { | |
| + | "name": "users_handle_unique", | |
| + | "nullsNotDistinct": false, | |
| + | "columns": [ | |
| + | "handle" | |
| + | ] | |
| + | } | |
| + | }, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.venues": { | |
| + | "name": "venues", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "city_id": { | |
| + | "name": "city_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "name": { | |
| + | "name": "name", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "address": { | |
| + | "name": "address", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "lat": { | |
| + | "name": "lat", | |
| + | "type": "double precision", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "lng": { | |
| + | "name": "lng", | |
| + | "type": "double precision", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "owner_id": { | |
| + | "name": "owner_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "content_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'active'" | |
| + | }, | |
| + | "verified_at": { | |
| + | "name": "verified_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "hidden_at": { | |
| + | "name": "hidden_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "venues_city_id_cities_id_fk": { | |
| + | "name": "venues_city_id_cities_id_fk", | |
| + | "tableFrom": "venues", | |
| + | "tableTo": "cities", | |
| + | "columnsFrom": [ | |
| + | "city_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "venues_owner_id_users_id_fk": { | |
| + | "name": "venues_owner_id_users_id_fk", | |
| + | "tableFrom": "venues", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "owner_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | }, | |
| + | "public.videos": { | |
| + | "name": "videos", | |
| + | "schema": "", | |
| + | "columns": { | |
| + | "id": { | |
| + | "name": "id", | |
| + | "type": "uuid", | |
| + | "primaryKey": true, | |
| + | "notNull": true, | |
| + | "default": "gen_random_uuid()" | |
| + | }, | |
| + | "city_id": { | |
| + | "name": "city_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "title": { | |
| + | "name": "title", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "embed_url": { | |
| + | "name": "embed_url", | |
| + | "type": "text", | |
| + | "primaryKey": false, | |
| + | "notNull": true | |
| + | }, | |
| + | "platform": { | |
| + | "name": "platform", | |
| + | "type": "video_platform", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'other'" | |
| + | }, | |
| + | "subject_type": { | |
| + | "name": "subject_type", | |
| + | "type": "subject_type", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "subject_id": { | |
| + | "name": "subject_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "owner_id": { | |
| + | "name": "owner_id", | |
| + | "type": "uuid", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "status": { | |
| + | "name": "status", | |
| + | "type": "content_status", | |
| + | "typeSchema": "public", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "'active'" | |
| + | }, | |
| + | "verified_at": { | |
| + | "name": "verified_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "hidden_at": { | |
| + | "name": "hidden_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": false | |
| + | }, | |
| + | "created_at": { | |
| + | "name": "created_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | }, | |
| + | "updated_at": { | |
| + | "name": "updated_at", | |
| + | "type": "timestamp with time zone", | |
| + | "primaryKey": false, | |
| + | "notNull": true, | |
| + | "default": "now()" | |
| + | } | |
| + | }, | |
| + | "indexes": {}, | |
| + | "foreignKeys": { | |
| + | "videos_city_id_cities_id_fk": { | |
| + | "name": "videos_city_id_cities_id_fk", | |
| + | "tableFrom": "videos", | |
| + | "tableTo": "cities", | |
| + | "columnsFrom": [ | |
| + | "city_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "cascade", | |
| + | "onUpdate": "no action" | |
| + | }, | |
| + | "videos_owner_id_users_id_fk": { | |
| + | "name": "videos_owner_id_users_id_fk", | |
| + | "tableFrom": "videos", | |
| + | "tableTo": "users", | |
| + | "columnsFrom": [ | |
| + | "owner_id" | |
| + | ], | |
| + | "columnsTo": [ | |
| + | "id" | |
| + | ], | |
| + | "onDelete": "set null", | |
| + | "onUpdate": "no action" | |
| + | } | |
| + | }, | |
| + | "compositePrimaryKeys": {}, | |
| + | "uniqueConstraints": {}, | |
| + | "policies": {}, | |
| + | "checkConstraints": {}, | |
| + | "isRLSEnabled": false | |
| + | } | |
| + | }, | |
| + | "enums": { | |
| + | "public.content_status": { | |
| + | "name": "content_status", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "active", | |
| + | "pending", | |
| + | "hidden", | |
| + | "removed" | |
| + | ] | |
| + | }, | |
| + | "public.event_type": { | |
| + | "name": "event_type", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "set", | |
| + | "weekender", | |
| + | "class", | |
| + | "workshop", | |
| + | "competition", | |
| + | "special", | |
| + | "social" | |
| + | ] | |
| + | }, | |
| + | "public.flag_status": { | |
| + | "name": "flag_status", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "open", | |
| + | "reviewed", | |
| + | "actioned", | |
| + | "dismissed" | |
| + | ] | |
| + | }, | |
| + | "public.order_purpose": { | |
| + | "name": "order_purpose", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "ticket", | |
| + | "boost", | |
| + | "pro", | |
| + | "sponsor" | |
| + | ] | |
| + | }, | |
| + | "public.order_status": { | |
| + | "name": "order_status", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "reserved", | |
| + | "paid", | |
| + | "refunded", | |
| + | "cancelled" | |
| + | ] | |
| + | }, | |
| + | "public.pro_status": { | |
| + | "name": "pro_status", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "pending", | |
| + | "active", | |
| + | "expired", | |
| + | "cancelled" | |
| + | ] | |
| + | }, | |
| + | "public.sponsor_status": { | |
| + | "name": "sponsor_status", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "pending", | |
| + | "active", | |
| + | "ended" | |
| + | ] | |
| + | }, | |
| + | "public.subject_type": { | |
| + | "name": "subject_type", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "event", | |
| + | "video", | |
| + | "promoter", | |
| + | "dj", | |
| + | "stepping_group", | |
| + | "class" | |
| + | ] | |
| + | }, | |
| + | "public.ticket_status": { | |
| + | "name": "ticket_status", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "valid", | |
| + | "used", | |
| + | "void" | |
| + | ] | |
| + | }, | |
| + | "public.travel_category": { | |
| + | "name": "travel_category", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "event_center", | |
| + | "hotel", | |
| + | "restaurant", | |
| + | "mall", | |
| + | "attraction", | |
| + | "entertainment", | |
| + | "hospital" | |
| + | ] | |
| + | }, | |
| + | "public.user_role": { | |
| + | "name": "user_role", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "member", | |
| + | "moderator", | |
| + | "admin" | |
| + | ] | |
| + | }, | |
| + | "public.video_platform": { | |
| + | "name": "video_platform", | |
| + | "schema": "public", | |
| + | "values": [ | |
| + | "youtube", | |
| + | "instagram", | |
| + | "tiktok", | |
| + | "other" | |
| + | ] | |
| + | } | |
| + | }, | |
| + | "schemas": {}, | |
| + | "sequences": {}, | |
| + | "roles": {}, | |
| + | "policies": {}, | |
| + | "views": {}, | |
| + | "_meta": { | |
| + | "columns": {}, | |
| + | "schemas": {}, | |
| + | "tables": {} | |
| + | } | |
| + | } |
| @@ -0,0 +1,13 @@ | ||
| + | { | |
| + | "version": "7", | |
| + | "dialect": "postgresql", | |
| + | "entries": [ | |
| + | { | |
| + | "idx": 0, | |
| + | "version": "7", | |
| + | "when": 1782679833285, | |
| + | "tag": "0000_dusty_dreaming_celestial", | |
| + | "breakpoints": true | |
| + | } | |
| + | ] | |
| + | } |
| @@ -1,77 +1,158 @@ | ||
| + | -- Demo content for the local POC (Chicago flagship + Atlanta, Detroit). | |
| + | -- Chicago stepping: smooth partner-dance "sets", weekenders, classes, DJs, | |
| + | -- a live stream, a promotion, a special event, and the traveling-stepper guide. | |
| + | -- Cities are seeded by seed.ts; this loads sample content against them. | |
| + | -- Run once against a seeded DB. | |
| \set dev '00000000-0000-0000-0000-000000000001' | ||
| begin; | ||
| + | -- Venues ------------------------------------------------------------------- | |
| 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') | |
| + | ('The Promontory', '5311 S Lake Park Ave W, Chicago, IL'), | |
| + | ('Parkway Ballroom', '4455 S King Dr, Chicago, IL') | |
| + | ) as v(name, addr) where slug = 'chicago'; | |
| + | ||
| + | insert into venues (city_id, name, address, owner_id) | |
| + | select id, v.name, v.addr, :'dev' from cities, (values | |
| + | ('Cafe Circa', '464 Edgewood Ave SE, 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'; | |
| + | ('Bert''s Marketplace', '2727 Russell St, Detroit, MI') | |
| + | ) as v(name, addr) where slug = 'detroit'; | |
| + | ||
| + | -- Events ------------------------------------------------------------------- | |
| + | -- Chicago: one live now, one promoted weekender, a contest, a special event. | |
| + | insert into events (city_id, title, type, description, starts_at, is_live, live_url, is_promoted, owner_id) | |
| + | select id, e.title, e.type::event_type, e.descr, e.ts::timestamptz, e.live, e.lurl, e.promo, :'dev' | |
| + | from cities, (values | |
| + | ('Friday Night Smooth Set', 'set', | |
| + | 'Weekly grown-and-sexy steppers set on the South Side. Live DJ, dress code in effect.', | |
| + | '2026-07-10 21:00', true, 'https://www.youtube.com/watch?v=M3SlJHI_zYU', false), | |
| + | ('World Steppers Weekend', 'weekender', | |
| + | 'Three-day Chicago steppin'' weekender: sets, workshops, and a contest. Hotel block open now.', | |
| + | '2026-08-14 18:00', false, null, true), | |
| + | ('Saturday Steppin'' Contest', 'competition', | |
| + | 'Open floor contest judged on timing, turns, and smoothness.', | |
| + | '2026-09-05 19:30', false, null, false), | |
| + | ('Steppin'' Against Domestic Violence', 'special', | |
| + | 'A benefit set raising awareness and funds for domestic violence survivors. All proceeds donated.', | |
| + | '2026-07-26 17:00', false, null, false) | |
| + | ) as e(title, type, descr, ts, live, lurl, promo) where slug = 'chicago'; | |
| + | ||
| + | insert into events (city_id, title, type, description, starts_at, owner_id) | |
| + | select id, e.title, e.type::event_type, e.descr, e.ts::timestamptz, :'dev' from cities, (values | |
| + | ('Sunday Steppers Social', 'social', 'Laid-back Sunday set for steppers visiting or based in the ATL.', '2026-07-19 18:00') | |
| + | ) as e(title, type, descr, ts) where slug = 'atlanta'; | |
| + | ||
| + | insert into events (city_id, title, type, description, starts_at, owner_id) | |
| + | select id, e.title, e.type::event_type, e.descr, e.ts::timestamptz, :'dev' from cities, (values | |
| + | ('Motor City Steppers Set', 'set', 'Detroit''s monthly steppin'' set with a guest Chicago DJ.', '2026-07-25 21:00') | |
| + | ) as e(title, type, descr, ts) where slug = 'detroit'; | |
| + | -- DJs ---------------------------------------------------------------------- | |
| + | insert into djs (city_id, name, bio, owner_id) | |
| + | select id, t.n, t.b, :'dev' from cities, (values | |
| + | ('DJ Smooth Glide', 'South Side steppers'' DJ, smooth soul and steppin'' cuts.'), | |
| + | ('DJ Eight Count', 'Keeps the floor on the count all night.') | |
| + | ) t(n, b) where slug = 'chicago'; | |
| + | insert into djs (city_id, name, bio, owner_id) | |
| + | select id, 'DJ Peachtree Steppin''', 'Atlanta''s go-to for Chicago-style sets.', :'dev' from cities where slug = 'atlanta'; | |
| + | ||
| + | -- Promoters / hosts -------------------------------------------------------- | |
| + | insert into promoters (city_id, name, bio, owner_id) | |
| + | select id, t.n, t.b, :'dev' from cities, (values | |
| + | ('Windy City Steppers Events', 'Throwing South Side sets and weekenders since the 90s.'), | |
| + | ('South Side Smooth Productions', 'Sets, contests, and the annual steppers weekend.') | |
| + | ) t(n, b) where slug = 'chicago'; | |
| + | insert into promoters (city_id, name, bio, owner_id) | |
| + | select id, 'ATL Steppers Connection', 'Bringing Chicago steppin'' to the ATL.', :'dev' from cities where slug = 'atlanta'; | |
| + | ||
| + | -- Stepping groups (city-scoped crews) -------------------------------------- | |
| + | insert into stepping_groups (city_id, name, bio, owner_id) | |
| + | select id, g.name, g.bio, :'dev' from cities, (values | |
| + | ('Smooth Steppers Society', 'Longtime South Side steppin'' crew, sets and classes citywide.'), | |
| + | ('Windy City Steppers', 'Community group keeping Chicago steppin'' culture moving.') | |
| + | ) as g(name, bio) where slug = 'chicago'; | |
| + | insert into stepping_groups (city_id, name, bio, owner_id) | |
| + | select id, 'Motor City Steppers', 'Detroit''s home for Chicago-style steppin''.', :'dev' | |
| + | from cities where slug = 'detroit'; | |
| + | ||
| + | -- Classes ------------------------------------------------------------------ | |
| + | insert into classes (city_id, title, instructor, schedule, is_free, owner_id) | |
| + | select id, c.title, c.inst, c.sched, c.free, :'dev' from cities, (values | |
| + | ('Beginner Chicago Steppin''', 'Coach Renee', 'Tuesdays 7:00pm', false), | |
| + | ('Intermediate Footwork & Turns', 'Mr. Williams', 'Thursdays 7:30pm', false), | |
| + | ('Free Community Steppin'' Practice', 'Smooth Steppers Society', 'Sundays 4:00pm', true) | |
| + | ) as c(title, inst, sched, free) where slug = 'chicago'; | |
| + | ||
| + | -- Per-city travel guide (traveling-stepper info) --------------------------- | |
| 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'), | |
| + | ('event_center', 'The Promontory', '5311 S Lake Park Ave W, Chicago, IL'), | |
| + | ('event_center', 'Parkway Ballroom', '4455 S King Dr, Chicago, IL'), | |
| + | ('hotel', 'Hyatt Regency McCormick Place', '2233 S Dr Martin Luther King Jr Dr, Chicago, IL'), | |
| + | ('hotel', 'Sophy Hyde Park', '1411 E 53rd St, Chicago, IL'), | |
| + | ('restaurant', 'Pearl''s Place', '3901 S Michigan Ave, Chicago, IL'), | |
| + | ('restaurant', 'Lem''s Bar-B-Q', '311 E 75th St, Chicago, IL'), | |
| + | ('mall', 'Block 37', '108 N State St, Chicago, IL'), | |
| + | ('attraction', 'DuSable Black History Museum', '740 E 56th Pl, Chicago, IL'), | |
| + | ('entertainment', 'Buddy Guy''s Legends', '700 S Wabash Ave, Chicago, IL'), | |
| + | ('hospital', 'University of Chicago Medical Center', '5841 S Maryland Ave, Chicago, IL') | |
| + | ) as t(cat, name, addr) where slug = 'chicago'; | |
| + | ||
| + | insert into travel_places (city_id, category, name, address) | |
| + | select id, t.cat::travel_category, t.name, t.addr from cities, (values | |
| + | ('event_center', 'Cafe Circa', '464 Edgewood Ave SE, 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'; | |
| - | ||
| + | -- Videos (per-city, real embeds) ------------------------------------------- | |
| 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'; | |
| + | ('Chicago Steppin Is Freedom on the Dance Floor', 'https://www.youtube.com/watch?v=M3SlJHI_zYU', 'youtube'), | |
| + | ('The History of Chicago Steppin', 'https://www.youtube.com/watch?v=9UEqba30cCE', 'youtube'), | |
| + | ('Gliding Moves of the South Side Scene', 'https://www.youtube.com/watch?v=-Y4iTvplmmQ', 'youtube') | |
| + | ) as v(title, url, plat) where slug = 'chicago'; | |
| + | ||
| + | insert into videos (city_id, title, embed_url, platform, owner_id) | |
| + | select id, 'Chicago Step Lessons with Linda & Pete Frazier', 'https://www.youtube.com/watch?v=HUbSuIPJiKc', 'youtube', :'dev' | |
| + | from cities where slug = 'atlanta'; | |
| + | -- Comments (polymorphic over subject) -------------------------------------- | |
| 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; | |
| + | select 'event', id, 'Who all coming out Friday? The floor was packed last week.', :'dev' | |
| + | from events where title = 'Friday Night Smooth Set' limit 1; | |
| + | ||
| + | insert into comments (subject_type, subject_id, body, owner_id) | |
| + | select 'event', id, 'Good cause and a good time. We will be there.', :'dev' | |
| + | from events where title = 'Steppin'' Against Domestic Violence' limit 1; | |
| + | ||
| + | -- Ticket types (demo) ------------------------------------------------------ | |
| + | insert into ticket_types (event_id, name, price_cents, capacity, owner_id) | |
| + | select e.id, t.name, t.cents, t.cap, :'dev' | |
| + | from events e, (values | |
| + | ('Weekend Pass', 7500, 200), | |
| + | ('Single Night', 2500, NULL) | |
| + | ) as t(name, cents, cap) | |
| + | where e.title = 'World Steppers Weekend'; | |
| + | ||
| + | insert into ticket_types (event_id, name, price_cents, owner_id) | |
| + | select id, 'Cover', 1500, :'dev' from events where title = 'Friday Night Smooth Set'; | |
| + | ||
| + | -- Sponsor (demo, admin-placed) --------------------------------------------- | |
| + | insert into sponsors (city_id, name, blurb, link_url, status) | |
| + | select id, 'Smooth Threads', 'Steppers-sharp menswear on 79th', 'https://example.com', 'active' | |
| + | from cities where slug = 'chicago'; | |
| commit; | ||
| select 'venues' c, count(*) n from venues | ||
| + | union all select 'events', count(*) from events | |
| 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; | |
| + | union all select 'comments', count(*) from comments; |
| @@ -13,8 +13,12 @@ import { | ||
| CONTENT_STATUS, | ||
| EVENT_TYPE, | ||
| FLAG_STATUS, | ||
| - | ORG_TYPE, | |
| + | ORDER_PURPOSE, | |
| + | ORDER_STATUS, | |
| + | PRO_STATUS, | |
| + | SPONSOR_STATUS, | |
| SUBJECT_TYPE, | ||
| + | TICKET_STATUS, | |
| TRAVEL_CATEGORY, | ||
| USER_ROLE, | ||
| VIDEO_PLATFORM, | ||
| @@ -29,11 +33,15 @@ const tuple = <T extends readonly [string, ...string[]]>(arr: T): T => arr; | ||
| export const contentStatus = pgEnum("content_status", tuple(CONTENT_STATUS)); | ||
| export const userRole = pgEnum("user_role", tuple(USER_ROLE)); | ||
| export const eventType = pgEnum("event_type", tuple(EVENT_TYPE)); | ||
| - | export const orgType = pgEnum("org_type", tuple(ORG_TYPE)); | |
| export const videoPlatform = pgEnum("video_platform", tuple(VIDEO_PLATFORM)); | ||
| export const subjectType = pgEnum("subject_type", tuple(SUBJECT_TYPE)); | ||
| export const travelCategory = pgEnum("travel_category", tuple(TRAVEL_CATEGORY)); | ||
| export const flagStatus = pgEnum("flag_status", tuple(FLAG_STATUS)); | ||
| + | export const orderPurpose = pgEnum("order_purpose", tuple(ORDER_PURPOSE)); | |
| + | export const orderStatus = pgEnum("order_status", tuple(ORDER_STATUS)); | |
| + | export const ticketStatus = pgEnum("ticket_status", tuple(TICKET_STATUS)); | |
| + | export const proStatus = pgEnum("pro_status", tuple(PRO_STATUS)); | |
| + | export const sponsorStatus = pgEnum("sponsor_status", tuple(SPONSOR_STATUS)); | |
| // --------------------------------------------------------------------------- | ||
| // Mixins - spread into table definitions so every content row shares shape. | ||
| @@ -62,7 +70,9 @@ type LinkRef = { label: string; url: string }; | ||
| // Identity (mirrors Supabase auth.users; dormant role/trust columns live here) | ||
| // --------------------------------------------------------------------------- | ||
| export const users = pgTable("users", { | ||
| - | id: uuid("id").primaryKey(), // === auth.users.id | |
| + | id: uuid("id").primaryKey().defaultRandom(), | |
| + | email: text("email").unique(), | |
| + | passwordHash: text("password_hash"), // scrypt; null for the open-posting system user | |
| handle: text("handle").unique(), | ||
| displayName: text("display_name"), | ||
| avatarUrl: text("avatar_url"), | ||
| @@ -71,6 +81,18 @@ export const users = pgTable("users", { | ||
| ...timestamps, | ||
| }); | ||
| + | // Server-side sessions. The cookie holds a random token; we store only its | |
| + | // SHA-256 hash, so a DB leak never exposes a live session. | |
| + | export const sessions = pgTable("sessions", { | |
| + | id: uuid("id").primaryKey().defaultRandom(), | |
| + | tokenHash: text("token_hash").unique().notNull(), | |
| + | userId: uuid("user_id") | |
| + | .references(() => users.id, { onDelete: "cascade" }) | |
| + | .notNull(), | |
| + | expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), | |
| + | createdAt: timestamps.createdAt, | |
| + | }); | |
| + | ||
| // --------------------------------------------------------------------------- | ||
| // Geography - reference data that scopes everything else. | ||
| // --------------------------------------------------------------------------- | ||
| @@ -87,16 +109,6 @@ export const cities = pgTable("cities", { | ||
| ...timestamps, | ||
| }); | ||
| - | // The Divine Nine (NPHC) - seeded reference set. | |
| - | export const organizations = pgTable("organizations", { | |
| - | id: uuid("id").primaryKey().defaultRandom(), | |
| - | name: text("name").notNull(), | |
| - | letters: text("letters"), | |
| - | type: orgType("type").notNull(), | |
| - | foundedYear: integer("founded_year"), | |
| - | createdAt: timestamps.createdAt, | |
| - | }); | |
| - | ||
| export const venues = pgTable("venues", { | ||
| id: uuid("id").primaryKey().defaultRandom(), | ||
| cityId: uuid("city_id") | ||
| @@ -112,34 +124,15 @@ export const venues = pgTable("venues", { | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| - | // Community orgs (city-scoped, claimable) | |
| + | // Stepping groups (city-scoped crews/groups, claimable) - dad's "[K] stepping | |
| + | // groups in each city". Chicago stepping has no Greek/NPHC affiliation. | |
| // --------------------------------------------------------------------------- | ||
| - | export const chapters = pgTable("chapters", { | |
| + | export const steppingGroups = pgTable("stepping_groups", { | |
| id: uuid("id").primaryKey().defaultRandom(), | ||
| - | organizationId: uuid("organization_id") | |
| - | .references(() => organizations.id, { onDelete: "cascade" }) | |
| - | .notNull(), | |
| cityId: uuid("city_id") | ||
| .references(() => cities.id, { onDelete: "cascade" }) | ||
| .notNull(), | ||
| name: text("name").notNull(), | ||
| - | school: text("school"), | |
| - | bio: text("bio"), | |
| - | links: jsonb("links").$type<LinkRef[]>(), | |
| - | ...ownership, | |
| - | ...moderatable, | |
| - | ...timestamps, | |
| - | }); | |
| - | ||
| - | export const stepTeams = pgTable("step_teams", { | |
| - | id: uuid("id").primaryKey().defaultRandom(), | |
| - | cityId: uuid("city_id") | |
| - | .references(() => cities.id, { onDelete: "cascade" }) | |
| - | .notNull(), | |
| - | name: text("name").notNull(), | |
| - | affiliationOrgId: uuid("affiliation_org_id").references(() => organizations.id, { | |
| - | onDelete: "set null", | |
| - | }), | |
| bio: text("bio"), | ||
| links: jsonb("links").$type<LinkRef[]>(), | ||
| ...ownership, | ||
| @@ -193,7 +186,7 @@ export const classes = pgTable("classes", { | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| - | // Events (step shows / strolls / competitions) - monetization columns dormant. | |
| + | // Events (sets / weekenders / classes / special events) - monetization dormant. | |
| // --------------------------------------------------------------------------- | ||
| export const events = pgTable("events", { | ||
| id: uuid("id").primaryKey().defaultRandom(), | ||
| @@ -207,7 +200,14 @@ export const events = pgTable("events", { | ||
| startsAt: timestamp("starts_at", { withTimezone: true }), | ||
| endsAt: timestamp("ends_at", { withTimezone: true }), | ||
| externalUrl: text("external_url"), | ||
| - | // dormant monetization hooks (Phase 3) | |
| + | // [A] promotions: a host can flag an event as a promotional post. | |
| + | isPromoted: boolean("is_promoted").default(false).notNull(), | |
| + | // Featured-listing boost: while in the future, the event ranks first. | |
| + | boostedUntil: timestamp("boosted_until", { withTimezone: true }), | |
| + | // [G] go-live: mark a set live now + the stream embed to watch it. | |
| + | isLive: boolean("is_live").default(false).notNull(), | |
| + | liveUrl: text("live_url"), | |
| + | // dormant monetization hooks (Phase 5) | |
| ticketUrl: text("ticket_url"), | ||
| priceText: text("price_text"), | ||
| isFree: boolean("is_free").default(false).notNull(), | ||
| @@ -285,8 +285,7 @@ export const eventParticipants = pgTable("event_participants", { | ||
| eventId: uuid("event_id") | ||
| .references(() => events.id, { onDelete: "cascade" }) | ||
| .notNull(), | ||
| - | chapterId: uuid("chapter_id").references(() => chapters.id, { onDelete: "cascade" }), | |
| - | stepTeamId: uuid("step_team_id").references(() => stepTeams.id, { | |
| + | steppingGroupId: uuid("stepping_group_id").references(() => steppingGroups.id, { | |
| onDelete: "cascade", | ||
| }), | ||
| createdAt: timestamps.createdAt, | ||
| @@ -303,18 +302,97 @@ export const eventDjs = pgTable("event_djs", { | ||
| createdAt: timestamps.createdAt, | ||
| }); | ||
| - | export const chapterMemberships = pgTable("chapter_memberships", { | |
| + | export const groupMemberships = pgTable("group_memberships", { | |
| id: uuid("id").primaryKey().defaultRandom(), | ||
| - | chapterId: uuid("chapter_id") | |
| - | .references(() => chapters.id, { onDelete: "cascade" }) | |
| + | steppingGroupId: uuid("stepping_group_id") | |
| + | .references(() => steppingGroups.id, { onDelete: "cascade" }) | |
| .notNull(), | ||
| userId: uuid("user_id") | ||
| .references(() => users.id, { onDelete: "cascade" }) | ||
| .notNull(), | ||
| - | roleInChapter: text("role_in_chapter"), | |
| + | roleInGroup: text("role_in_group"), | |
| createdAt: timestamps.createdAt, | ||
| }); | ||
| + | // --------------------------------------------------------------------------- | |
| + | // Monetization - ticket types, orders (the money record), tickets, sponsors, | |
| + | // pro accounts. Payments are stubbed for now (orders default to "reserved"). | |
| + | // --------------------------------------------------------------------------- | |
| + | export const ticketTypes = pgTable("ticket_types", { | |
| + | id: uuid("id").primaryKey().defaultRandom(), | |
| + | eventId: uuid("event_id") | |
| + | .references(() => events.id, { onDelete: "cascade" }) | |
| + | .notNull(), | |
| + | name: text("name").notNull(), | |
| + | priceCents: integer("price_cents").default(0).notNull(), | |
| + | currency: text("currency").default("usd").notNull(), | |
| + | capacity: integer("capacity"), // null = unlimited | |
| + | sold: integer("sold").default(0).notNull(), | |
| + | isActive: boolean("is_active").default(true).notNull(), | |
| + | ...ownership, | |
| + | ...timestamps, | |
| + | }); | |
| + | ||
| + | export const orders = pgTable("orders", { | |
| + | id: uuid("id").primaryKey().defaultRandom(), | |
| + | purpose: orderPurpose("purpose").notNull(), | |
| + | buyerId: uuid("buyer_id").references(() => users.id, { onDelete: "set null" }), | |
| + | eventId: uuid("event_id").references(() => events.id, { onDelete: "set null" }), | |
| + | ticketTypeId: uuid("ticket_type_id").references(() => ticketTypes.id, { | |
| + | onDelete: "set null", | |
| + | }), | |
| + | quantity: integer("quantity").default(1).notNull(), | |
| + | amountCents: integer("amount_cents").default(0).notNull(), | |
| + | status: orderStatus("status").default("reserved").notNull(), | |
| + | meta: jsonb("meta").$type<Record<string, unknown>>(), | |
| + | ...timestamps, | |
| + | }); | |
| + | ||
| + | export const tickets = pgTable("tickets", { | |
| + | id: uuid("id").primaryKey().defaultRandom(), | |
| + | orderId: uuid("order_id") | |
| + | .references(() => orders.id, { onDelete: "cascade" }) | |
| + | .notNull(), | |
| + | eventId: uuid("event_id") | |
| + | .references(() => events.id, { onDelete: "cascade" }) | |
| + | .notNull(), | |
| + | ticketTypeId: uuid("ticket_type_id").references(() => ticketTypes.id, { | |
| + | onDelete: "set null", | |
| + | }), | |
| + | holderId: uuid("holder_id").references(() => users.id, { onDelete: "set null" }), | |
| + | code: text("code").unique().notNull(), | |
| + | status: ticketStatus("status").default("valid").notNull(), | |
| + | ...timestamps, | |
| + | }); | |
| + | ||
| + | export const sponsors = pgTable("sponsors", { | |
| + | id: uuid("id").primaryKey().defaultRandom(), | |
| + | cityId: uuid("city_id") | |
| + | .references(() => cities.id, { onDelete: "cascade" }) | |
| + | .notNull(), | |
| + | name: text("name").notNull(), | |
| + | blurb: text("blurb"), | |
| + | linkUrl: text("link_url"), | |
| + | imageUrl: text("image_url"), | |
| + | status: sponsorStatus("status").default("pending").notNull(), | |
| + | startsAt: timestamp("starts_at", { withTimezone: true }), | |
| + | endsAt: timestamp("ends_at", { withTimezone: true }), | |
| + | ...ownership, | |
| + | ...timestamps, | |
| + | }); | |
| + | ||
| + | export const proAccounts = pgTable("pro_accounts", { | |
| + | id: uuid("id").primaryKey().defaultRandom(), | |
| + | userId: uuid("user_id") | |
| + | .references(() => users.id, { onDelete: "cascade" }) | |
| + | .notNull(), | |
| + | tier: text("tier").default("pro").notNull(), | |
| + | status: proStatus("status").default("pending").notNull(), | |
| + | startedAt: timestamp("started_at", { withTimezone: true }), | |
| + | expiresAt: timestamp("expires_at", { withTimezone: true }), | |
| + | ...timestamps, | |
| + | }); | |
| + | ||
| // --------------------------------------------------------------------------- | ||
| // Audit log (every moderation action writes here) | ||
| // --------------------------------------------------------------------------- |
| @@ -1,65 +1,57 @@ | ||
| import "dotenv/config"; | ||
| - | import { DEV_USER_ID, DIVINE_NINE } from "@stepping/config"; | |
| + | import { randomBytes, scryptSync } from "node:crypto"; | |
| + | import { DEV_USER_ID } from "@stepping/config"; | |
| import { getDb } from "./index"; | ||
| - | import { cities, organizations, users } from "./schema"; | |
| + | import { cities, users } from "./schema"; | |
| + | ||
| + | // scrypt password hash (kept inline to avoid a db -> api import cycle). | |
| + | function hashPassword(pw: string): string { | |
| + | const salt = randomBytes(16).toString("hex"); | |
| + | return `scrypt$${salt}$${scryptSync(pw, salt, 64).toString("hex")}`; | |
| + | } | |
| + | ||
| + | const LAUNCH_CITIES = [ | |
| + | { slug: "chicago", name: "Chicago", metroLabel: "Chicago, IL", state: "IL", lat: 41.8781, lng: -87.6298 }, | |
| + | { slug: "atlanta", name: "Atlanta", metroLabel: "Atlanta, GA", state: "GA", lat: 33.749, lng: -84.388 }, | |
| + | { slug: "detroit", name: "Detroit", metroLabel: "Detroit, MI", state: "MI", lat: 42.3314, lng: -83.0458 }, | |
| + | { slug: "houston", name: "Houston", metroLabel: "Houston, TX", state: "TX", lat: 29.7604, lng: -95.3698 }, | |
| + | { slug: "charlotte", name: "Charlotte", metroLabel: "Charlotte, NC", state: "NC", lat: 35.2271, lng: -80.8431 }, | |
| + | { slug: "washington-dc", name: "Washington", metroLabel: "Washington, DC (DMV)", state: "DC", lat: 38.9072, lng: -77.0369 }, | |
| + | ]; | |
| - | // 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..."); | |
| + | // Open-posting system user (member only - never admin). | |
| + | console.log("Seeding system member user..."); | |
| await db | ||
| .insert(users) | ||
| - | .values({ | |
| - | id: DEV_USER_ID, | |
| - | handle: "dev", | |
| - | displayName: "Dev User", | |
| - | role: "admin", | |
| - | }) | |
| + | .values({ id: DEV_USER_ID, handle: "community", displayName: "Community", role: "member" }) | |
| .onConflictDoNothing(); | ||
| - | console.log("Seeding Divine Nine organizations..."); | |
| + | // Real admin account for the admin panel. | |
| + | const adminEmail = (process.env.STEPPING_ADMIN_EMAIL || "admin@steppingapp.com").toLowerCase(); | |
| + | const adminPw = process.env.STEPPING_ADMIN_PASSWORD || "St3pp!n_Cr0wn#2026$kX9qVz7m"; | |
| + | console.log("Seeding admin account..."); | |
| await db | ||
| - | .insert(organizations) | |
| - | .values( | |
| - | DIVINE_NINE.map((o) => ({ | |
| - | name: o.name, | |
| - | letters: o.letters, | |
| - | type: o.type, | |
| - | foundedYear: o.founded, | |
| - | })), | |
| - | ) | |
| + | .insert(users) | |
| + | .values({ | |
| + | email: adminEmail, | |
| + | passwordHash: hashPassword(adminPw), | |
| + | handle: "admin", | |
| + | displayName: "Admin", | |
| + | role: "admin", | |
| + | }) | |
| .onConflictDoNothing(); | ||
| - | console.log("Seeding launch cities (Atlanta + DC)..."); | |
| + | console.log("Seeding launch cities (Chicago flagship + 5)..."); | |
| await db | ||
| .insert(cities) | ||
| - | .values([ | |
| - | { | |
| - | slug: "atlanta", | |
| - | name: "Atlanta", | |
| - | metroLabel: "Atlanta, GA", | |
| - | state: "GA", | |
| - | tier: 1, | |
| - | isLaunched: true, | |
| - | lat: 33.749, | |
| - | lng: -84.388, | |
| - | }, | |
| - | { | |
| - | slug: "washington-dc", | |
| - | name: "Washington", | |
| - | metroLabel: "Washington, DC (DMV)", | |
| - | state: "DC", | |
| - | tier: 1, | |
| - | isLaunched: true, | |
| - | lat: 38.9072, | |
| - | lng: -77.0369, | |
| - | }, | |
| - | ]) | |
| + | .values(LAUNCH_CITIES.map((c) => ({ ...c, tier: 1, isLaunched: true }))) | |
| .onConflictDoNothing(); | ||
| console.log("Seed complete."); | ||
| + | console.log(`ADMIN LOGIN -> ${adminEmail} / ${adminPw}`); | |
| process.exit(0); | ||
| } | ||
| @@ -7,19 +7,20 @@ export type ContentStatus = (typeof CONTENT_STATUS)[number]; | ||
| export const USER_ROLE = ["member", "moderator", "admin"] as const; | ||
| export type UserRole = (typeof USER_ROLE)[number]; | ||
| + | // Chicago stepping event taxonomy. A "set" is the core object: a social | |
| + | // partner-dance gathering in a city. Weekenders are out-of-town multi-day | |
| + | // stepping events. (No "step show"/"stroll"; those are Divine Nine / Greek.) | |
| export const EVENT_TYPE = [ | ||
| - | "step_show", | |
| - | "stroll", | |
| - | "competition", | |
| + | "set", | |
| + | "weekender", | |
| "class", | ||
| - | "social", | |
| + | "workshop", | |
| + | "competition", | |
| "special", | ||
| + | "social", | |
| ] as const; | ||
| export type EventType = (typeof EVENT_TYPE)[number]; | ||
| - | export const ORG_TYPE = ["fraternity", "sorority"] as const; | |
| - | export type OrgType = (typeof ORG_TYPE)[number]; | |
| - | ||
| export const VIDEO_PLATFORM = ["youtube", "instagram", "tiktok", "other"] as const; | ||
| export type VideoPlatform = (typeof VIDEO_PLATFORM)[number]; | ||
| @@ -29,20 +30,41 @@ export const SUBJECT_TYPE = [ | ||
| "video", | ||
| "promoter", | ||
| "dj", | ||
| - | "step_team", | |
| - | "chapter", | |
| + | "stepping_group", | |
| "class", | ||
| ] as const; | ||
| export type SubjectType = (typeof SUBJECT_TYPE)[number]; | ||
| + | // Dad's traveling-stepper city guide [I]-[P]: event centers, hotels, | |
| + | // restaurants, malls/shopping, main attractions, entertainment, hospitals. | |
| export const TRAVEL_CATEGORY = [ | ||
| + | "event_center", | |
| "hotel", | ||
| "restaurant", | ||
| "mall", | ||
| "attraction", | ||
| + | "entertainment", | |
| "hospital", | ||
| ] as const; | ||
| export type TravelCategory = (typeof TRAVEL_CATEGORY)[number]; | ||
| export const FLAG_STATUS = ["open", "reviewed", "actioned", "dismissed"] as const; | ||
| export type FlagStatus = (typeof FLAG_STATUS)[number]; | ||
| + | ||
| + | // --- Monetization ----------------------------------------------------------- | |
| + | // Orders are the single money record (tickets, boosts, pro, sponsor). Payments | |
| + | // are stubbed for now: "reserved" = issued without a charge. | |
| + | export const ORDER_PURPOSE = ["ticket", "boost", "pro", "sponsor"] as const; | |
| + | export type OrderPurpose = (typeof ORDER_PURPOSE)[number]; | |
| + | ||
| + | export const ORDER_STATUS = ["reserved", "paid", "refunded", "cancelled"] as const; | |
| + | export type OrderStatus = (typeof ORDER_STATUS)[number]; | |
| + | ||
| + | export const TICKET_STATUS = ["valid", "used", "void"] as const; | |
| + | export type TicketStatus = (typeof TICKET_STATUS)[number]; | |
| + | ||
| + | export const PRO_STATUS = ["pending", "active", "expired", "cancelled"] as const; | |
| + | export type ProStatus = (typeof PRO_STATUS)[number]; | |
| + | ||
| + | export const SPONSOR_STATUS = ["pending", "active", "ended"] as const; | |
| + | export type SponsorStatus = (typeof SPONSOR_STATUS)[number]; |