Skip to content

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. LocationGroup schema + migration
  • [x] 2. Seed Salem + Portland location groups
  • [x] 3. Public group-by-slug API endpoint
  • [x] 4. TourRequestForm component (hero + closing variants, mobile teased state)
  • [x] 5. InlineLocationsMap non-interactive mode + dimmed pin style
  • [x] 6. LocationsNearYouSection (cards + map + see-all + hover linkage)
  • [x] 7. HourlyBookingContent optional studio-picker first step (implemented as HourlyBookingWithPicker wrapper)
  • [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-city doesn't carry to other markets.
  • Spec Tour form contract — direct hand-off to existing public /tours endpoint, 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: add slug String? @unique, tagline String?, description String?, heroImageUrl String? @map("hero_image_url") to the LocationGroup model.
  • Generate migration: add_location_group_landing_fields.
  • pnpm --filter @mg/api typecheck:quick clean.
  • Spec coverage: Schema change.

2. Seed Salem + Portland location groups

  • New script apps/api/scripts/seed-market-location-groups.ts using loadEnv() from apps/api/scripts/lib/load-env.ts.
  • Creates LocationGroup{ name: "Salem", slug: "salem", groupType: CITY, tagline, ... } + LocationGroupLocation join to cherry-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 in withErrorHandling.
  • Returns: group fields (name, slug, tagline, description, heroImageUrl, locationCount), plus an ordered locations[] array — each with id, slug, name, city, neighborhood, address, latitude, longitude, primaryImageUrl, availableMonthlyCount, availableHourlyCount.
  • Initial sort: availableMonthlyCount > 0 desc, then LocationGroupLocation.createdAt asc (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). When marketLocations.length === 1, the location field is hidden and pre-set.
  • Submit: validate client-side, then open TourSchedulerModal with locationId from form + visitor info passed via the modal's editedUserInfo mechanism. Do NOT POST to /tours directly — the modal handles that on schedule confirmation.
  • Two visual variants via prop: variant="hero" (white card on dark hero) and variant="closing" (banner CTA at page bottom). Same fields, different styling.
  • Mobile teased state: max-h clamp + bottom fade gradient + aria-expanded toggle. 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: add nonInteractive?: boolean prop that disables gestureHandling, zoomControl, mapTypeControl, streetViewControl, fullscreenControl, and removes marker click handlers.
  • Edit apps/web/src/components/organisms/maps/LocationMarker.tsx: add dim?: boolean style prop for sold-out pins (lower opacity + grey palette).
  • wrapHref?: string prop on InlineLocationsMap: 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 nonInteractive and wrapHref={/locations?city=${cityForLink}} from item 5.
  • Pin↔card hover linkage: shared locationId between card and marker. Cards add hover state classes; markers receive a "highlighted" prop driven by section-level useState(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 resourceId prop is undefined AND locationId is provided, render a new first accordion step studio that fetches /locations/{locationId}/resources/public?resourceType=STUDIO_HOURLY&limit=100 and lists studios as cards (name, image, hourly rate, capacity).
  • On pick, set resourceId via existing internal state and advance to date step. Auto-collapse with summary "Studio B — selected" for back-edits.
  • When resourceId IS preset (every existing caller), the step is hidden, accordion behaves exactly as today.
  • Update HourlyBookingContent.test.tsx to 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: add trackMarketLandingViewed({ marketSlug, marketName, locationCount }) and trackTourFormStarted({ 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, ISR revalidate = 30 (matches cherry-city LP).
  • Fetches /api/locations/groups/{slug} (item 3). 404 on missing.
  • Reads headers() for x-vercel-ip-latitude / x-vercel-ip-longitude. Re-sorts locations[] 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 of LocalBusiness-like schemas (one per location) plus a FAQPage.
  • 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 — min startingMonthlyRate / startingHourlyRate across 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 with TourRequestForm (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 helper buildMarketFaqItems(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 in MarketLandingContent for v1 (don't bother extracting a shared component — VideoHeroSection is structured for the homepage, our hero needs different overlay content). H1 fixed across markets ("Band practice at home sucks. We fixed that."); subhead from LocationGroup.tagline.
  • Fires trackMarketLandingViewed once on mount.
  • Hourly card's "Book now" opens existing ModalWrapper hosting HourlyBookingContent with no resourceId preset (drives item 7's picker step), requireAuth=true matching 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' and searchParams.lat + searchParams.lng are 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.tsxapps/web/src/app/(marketing)/groups/GroupsPageContent.tsx. Update apps/web/src/app/(marketing)/groups/page.tsx to render the new content (currently a redirect).
  • Add { source: '/group-memberships', destination: '/groups', permanent: true } to next.config.ts redirects(). Next handles redirects before route resolution, so the page-level /group-memberships/page.tsx becomes redundant — delete the entire apps/web/src/app/(marketing)/group-memberships/ directory.
  • Grep /group-memberships across apps/web/src and 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 } to next.config.ts redirects().
  • Delete apps/web/src/app/(landing)/lp/cherry-city/page.tsx and UnifiedLandingContent.tsx.
  • Grep for any remaining internal links to /lp/cherry-city and either remove or rewrite.
  • Spec coverage: URL + slug, Rollout.

13. Spec follow-up

  • Update docs/features/market-landing-pages/spec.md Known 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.md row 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 LocationGroup landing 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.