Market Landing Pages — Implementation Plan
Status: done
Implements spec.md. Closes the divergence noted in Known gaps.
Goal
Implement the market-landing-pages spec from scratch — Salem ships first as a single-location market, Portland gets the template for free with no ad spend yet.
Status
- [x] 1.
LocationGroupschema + migration - [x] 2. Seed Salem + Portland location groups
- [x] 3. Public group-by-slug API endpoint
- [x] 4.
TourRequestFormcomponent (hero + closing variants, mobile teased state) - [x] 5.
InlineLocationsMapnon-interactive mode + dimmed pin style - [x] 6.
LocationsNearYouSection(cards + map + see-all + hover linkage) - [x] 7.
HourlyBookingContentoptional studio-picker first step (implemented asHourlyBookingWithPickerwrapper) - [x] 8. Tracking events
- [x] 9.
/lp/[marketSlug]route + content shell - [x] 10. Dev geo override (
?lat=&lng=) - [x] 11. Groups redirect flip
- [x] 12. Sunset
/lp/cherry-city(301 →/lp/salem) - [x] 13. Spec follow-up (close Known gaps, drop (target) annotations)
Driving citations
- Spec Why — search intent is market-level; per-location LPs don't generalize, current
/lp/cherry-citydoesn't carry to other markets. - Spec Tour form contract — direct hand-off to existing public
/toursendpoint, no auth wall, dropdown starts empty. - Spec Locations-near-you behavior — cap-2 cards with availability-first sort, all pins on map, click-through to
/locations?city=.
Work breakdown
1. LocationGroup schema + migration
- Edit
apps/api/prisma/schema.prisma: addslug String? @unique,tagline String?,description String?,heroImageUrl String? @map("hero_image_url")to theLocationGroupmodel. - Generate migration:
add_location_group_landing_fields. pnpm --filter @mg/api typecheck:quickclean.- Spec coverage: Schema change.
2. Seed Salem + Portland location groups
- New script
apps/api/scripts/seed-market-location-groups.tsusingloadEnv()fromapps/api/scripts/lib/load-env.ts. - Creates
LocationGroup{ name: "Salem", slug: "salem", groupType: CITY, tagline, ... }+LocationGroupLocationjoin tocherry-city. - Creates
LocationGroup{ name: "Portland", slug: "portland", groupType: CITY, tagline, ... }+ joins to all 10 PDX locations. - Idempotent: upsert by
slug. Dry-run flag. - Manual run locally; production run gated on user permission per CLAUDE.md.
- Spec coverage: Schema change.
3. Public group-by-slug API endpoint
- New
apps/api/src/app/api/locations/groups/[slug]/route.ts, no-auth, wraps inwithErrorHandling. - Returns: group fields (name, slug, tagline, description, heroImageUrl, locationCount), plus an ordered
locations[]array — each withid, slug, name, city, neighborhood, address, latitude, longitude, primaryImageUrl, availableMonthlyCount, availableHourlyCount. - Initial sort:
availableMonthlyCount > 0desc, thenLocationGroupLocation.createdAtasc (stable). Final proximity re-sort happens server-side in the page route once geo headers are read. - 404 on unknown slug.
- Spec coverage: Schema change, Locations-near-you behavior.
4. TourRequestForm component
- New
apps/web/src/components/organisms/marketing/TourRequestForm.tsx. - Fields: firstName, lastName, email (required), phone (optional), location
Select(required, starts empty, no IP-geo prefill). WhenmarketLocations.length === 1, the location field is hidden and pre-set. - Submit: validate client-side, then open
TourSchedulerModalwithlocationIdfrom form + visitor info passed via the modal'seditedUserInfomechanism. Do NOT POST to/toursdirectly — the modal handles that on schedule confirmation. - Two visual variants via prop:
variant="hero"(white card on dark hero) andvariant="closing"(banner CTA at page bottom). Same fields, different styling. - Mobile teased state:
max-hclamp + bottom fade gradient +aria-expandedtoggle. First tap expands; expanded state persists for the rest of the page-mount via component-local React state (no storage). Re-navigating to the LP starts re-collapsed — fine, expected. - Tracking: fire
trackTourFormStarted({ marketSlug })on first input focus. - Tests: validation, single-location auto-set, modal hand-off shape, teased→expanded toggle.
- Spec coverage: Tour form contract, Page structure mobile.
5. InlineLocationsMap non-interactive mode + dimmed pin style
- Edit
apps/web/src/components/organisms/maps/InlineLocationsMap.tsx: addnonInteractive?: booleanprop that disablesgestureHandling,zoomControl,mapTypeControl,streetViewControl,fullscreenControl, and removes marker click handlers. - Edit
apps/web/src/components/organisms/maps/LocationMarker.tsx: adddim?: booleanstyle prop for sold-out pins (lower opacity + grey palette). wrapHref?: stringprop onInlineLocationsMap: when set, the canvas is wrapped in an<a>so the entire surface is one click target. Item 6 passes the city href.- Existing callers must keep working unchanged — these props are additive.
- Spec coverage: Map behavior.
6. LocationsNearYouSection
- New
apps/web/src/components/organisms/marketing/LocationsNearYouSection.tsx. - Props:
marketName,cityForLink,locations[](already sorted by item 9's geo logic), small inline data only. - Renders top 2 cards + map. Map uses
nonInteractiveandwrapHref={/locations?city=${cityForLink}}from item 5. - Pin↔card hover linkage: shared
locationIdbetween card and marker. Cards add hover state classes; markers receive a "highlighted" prop driven by section-leveluseState(hoveredLocationId). - "See all N locations in {marketName} →" link below cards when
locations.length > 2. Hidden if ≤ 2. - Salem case:
locations.length === 1→ single full-width card, no map, no see-all. - Mobile layout: map first, then stacked cards, then see-all. No hover linkage on touch (CSS media query
(hover: hover)). - Tests: cap-at-2, see-all visibility threshold, single-location collapse, hover state propagation.
- Spec coverage: Locations-near-you behavior, Cards overlay.
7. HourlyBookingContent optional studio-picker first step
- Edit
apps/web/src/components/organisms/booking/HourlyBookingContent.tsx. - When
resourceIdprop isundefinedANDlocationIdis provided, render a new first accordion stepstudiothat fetches/locations/{locationId}/resources/public?resourceType=STUDIO_HOURLY&limit=100and lists studios as cards (name, image, hourly rate, capacity). - On pick, set
resourceIdvia existing internal state and advance todatestep. Auto-collapse with summary "Studio B — selected" for back-edits. - When
resourceIdIS preset (every existing caller), the step is hidden, accordion behaves exactly as today. - Update
HourlyBookingContent.test.tsxto cover both modes. Confirm existing tests still pass. - Spec coverage: Hourly booking modal — studio-picker first step.
8. Tracking events
- Edit
apps/web/src/lib/tracking.ts: addtrackMarketLandingViewed({ marketSlug, marketName, locationCount })andtrackTourFormStarted({ marketSlug }). - Mirror existing tracking helper conventions (PostHog + any active mirror).
- Spec coverage: Tracking.
9. /lp/[marketSlug] route + content shell
- New
apps/web/src/app/(landing)/lp/[marketSlug]/page.tsx. Server component, ISRrevalidate = 30(matches cherry-city LP). - Fetches
/api/locations/groups/{slug}(item 3). 404 on missing. - Reads
headers()forx-vercel-ip-latitude/x-vercel-ip-longitude. Re-sortslocations[]by haversine distance (already-availability-sorted from API). Falls back to market centroid (mean of location coords) if headers are absent. - Generates
Metadata(title, description, canonical, OpenGraph, Twitter) and JSON-LD: array ofLocalBusiness-like schemas (one per location) plus aFAQPage. - New
apps/web/src/app/(landing)/lp/[marketSlug]/MarketLandingContent.tsx(client) composes, in order: hero +TourRequestForm(variant="hero"),LocationsNearYouSection,BenefitsGridSection("All-inclusive memberships"), 3-fork "Flexible solutions" cards (with "from $X" pricing — minstartingMonthlyRate/startingHourlyRateacross the market's active locations; groups card "From $250/mo"),TestimonialsSection,StepsSection("How it works" — Book your tour / Choose your solution / Get to work),FAQSection, closing CTA banner withTourRequestForm(variant="closing"). - FAQ items: clone the existing 13 from
apps/web/src/app/(landing)/lp/cherry-city/page.tsx, parameterize on market name + min monthly/hourly rate + community manager first name (from any location in the market — fall back to "our team"). Move into a small helperbuildMarketFaqItems(market)co-located with the route. - 3-fork CTA clicks fire
trackLandingFunnelChoice({ choice: 'monthly'|'hourly'|'groups', locationId: <first location in market>.id })so PostHog funnel comparisons across LPs stay valid. - Hero uses
VideoHeroSection-style video bg (/video/hero.mp4, idle-mounted, reduced-motion-aware). Inline the mount/idle/reduced-motion logic inMarketLandingContentfor v1 (don't bother extracting a shared component —VideoHeroSectionis structured for the homepage, our hero needs different overlay content). H1 fixed across markets ("Band practice at home sucks. We fixed that."); subhead fromLocationGroup.tagline. - Fires
trackMarketLandingViewedonce on mount. - Hourly card's "Book now" opens existing
ModalWrapperhostingHourlyBookingContentwith noresourceIdpreset (drives item 7's picker step),requireAuth=truematching the cherry-city LP pattern. - Spec coverage: URL + slug, Page structure, 3-fork content, Tracking.
10. Dev geo override (?lat=&lng=)
- Inside the route segment from item 9, when
process.env.NODE_ENV !== 'production'andsearchParams.lat+searchParams.lngare both present and parse as numbers, use those instead of the geo headers. - No prod path runs this code.
- Spec coverage: Dev override.
11. Groups redirect flip
- Move marketing copy: rename
apps/web/src/app/(marketing)/group-memberships/GroupMembershipsPageContent.tsx→apps/web/src/app/(marketing)/groups/GroupsPageContent.tsx. Updateapps/web/src/app/(marketing)/groups/page.tsxto render the new content (currently a redirect). - Add
{ source: '/group-memberships', destination: '/groups', permanent: true }tonext.config.tsredirects(). Next handles redirects before route resolution, so the page-level/group-memberships/page.tsxbecomes redundant — delete the entireapps/web/src/app/(marketing)/group-memberships/directory. - Grep
/group-membershipsacrossapps/web/srcand update internal links (nav, footer, marketing pages, the cherry-city LP fork). Update any anchors/tests. - Spec coverage: Groups redirect flip.
12. Sunset /lp/cherry-city
- Add
{ source: '/lp/cherry-city', destination: '/lp/salem', permanent: true }tonext.config.tsredirects(). - Delete
apps/web/src/app/(landing)/lp/cherry-city/page.tsxandUnifiedLandingContent.tsx. - Grep for any remaining internal links to
/lp/cherry-cityand either remove or rewrite. - Spec coverage: URL + slug, Rollout.
13. Spec follow-up
- Update
docs/features/market-landing-pages/spec.mdKnown gaps section with implementation notes (file paths confirmed, anything that drifted during build). - Drop any (target) annotations from the File map.
- Update
docs/features/README.mdrow for this feature: status →done(linking to plan.md). - Spec coverage: forces spec/code parity per ADR-016 authority chain.
Out of scope
- "Get current location" button (browser geolocation prompt). IP geo above the form does enough; revisit if PostHog shows visitors picking non-nearest at meaningful rates.
- Editing
LocationGrouplanding fields from staff UI — DB-only for v1. - Per-market hero video override — single shared video.
- Market index page at
/m/[marketSlug]—/locations?city=<city>is the directory for now.