Skip to content

Market Landing Pages

A single tour-driven landing page template, scoped to a market (city / region — LocationGroup), used for non-branded paid acquisition. Replaces the per-location /lp/cherry-city pattern. The hero is a tour-request form that opens the existing TourSchedulerModal directly with the visitor's info prefilled, no account creation. A "locations near you" section uses Vercel IP geo to surface the closest options. A 3-fork "flexible solutions" block links to the unified product pages (/monthly, /hourly, /groups) — hourly carries a secondary "Book now" that opens the existing HourlyBookingContent accordion with a new optional studio-picker first step. Salem ships first; Portland and any future market get the template for free, no ad spend yet.

Why

Search intent is market-level ("rehearsal studios portland"), not location-level. The existing /lp/cherry-city works for Salem (single-location market) but doesn't generalize, and per-location LPs are dead weight when paid traffic targets a city. Industrious-style pattern: market hero → nearest locations → product fork → tour. We keep the pain-led brand voice in the headline; we just stop making the visitor pick monthly-vs-hourly before they've seen the studios.

The existing apps/web/src/app/(marketing)/[locationSlug]/ pages stay — they're the home for branded paid traffic and organic location SEO. Market LPs are non-branded acquisition only.

URL + slug

  • Route: apps/web/src/app/(landing)/lp/[marketSlug]/page.tsx
  • Examples: /lp/salem, /lp/portland
  • Unknown slug → 404 (clean SEO signal, no soft-redirect noise).
  • /lp/cherry-city → 301 → /lp/salem immediately on launch (the old LP is a strict subset of the new one). Track in posthog for any conversion regression.

Schema change (Prisma)

Owned by apps/api/prisma/schema.prisma. New fields on LocationGroup:

model LocationGroup {
  // ... existing fields
  slug          String?  @unique           // e.g. "salem", "portland"
  tagline       String?                    // hero subtitle, e.g. "Salem's rehearsal studios"
  description   String?                    // longer marketing copy if needed
  heroImageUrl  String?  @map("hero_image_url")  // poster for the hero video
}

slug is nullable for backward compat with existing rows; the LP route requires it. Migration name: add_location_group_landing_fields.

Pre-launch seed (run via apps/api/scripts/, not by hand in prod):

  • Create LocationGroup{ name: "Salem", slug: "salem", groupType: CITY, tagline: ... } with LocationGroupLocationcherry-city.
  • Create LocationGroup{ name: "Portland", slug: "portland", groupType: CITY, tagline: ... } with the 10 PDX locations.

Page structure

Reference: Industrious /lp/free-week-pd (visual model — copy layout, not copy). ASCII wireframe (desktop, top-to-bottom):

┌──────────────────────────────────────────────────────────────────────────────┐
│ [logo]                                              [nav]   [sign in] [tour] │  ← existing site nav
├──────────────────────────────────────────────────────────────────────────────┤
│ ╔═════════════════════════════ HERO (video bg) ═══════════════════════════╗ │
│ ║                                                                         ║ │
│ ║   Band practice at home                  ┌───────────────────────────┐  ║ │
│ ║   sucks. We fixed that.                  │  BOOK YOUR FREE TOUR      │  ║ │
│ ║                                          │                           │  ║ │
│ ║   {market.tagline}                       │  Location  ▾ Pick one…    │  ║ │
│ ║                                          │  First name [           ] │  ║ │
│ ║                                          │  Last name  [           ] │  ║ │
│ ║                                          │  Email      [           ] │  ║ │
│ ║                                          │  Phone (opt)[           ] │  ║ │
│ ║                                          │                           │  ║ │
│ ║                                          │   [  Book your tour  →  ] │  ║ │
│ ║                                          └───────────────────────────┘  ║ │
│ ╚═════════════════════════════════════════════════════════════════════════╝ │
│                                                                              │
│   LOCATIONS NEAREST YOU                                                      │
│                                       ┌────────────────────────────────┐   │
│                                       │ [ Static map — all pins,       │   │
│   ┌──────────────┐  ┌──────────────┐──┤   sold-out dimmed; entire      │   │
│   │ [photo]      │  │ [photo]      │  │   surface = clickable link to  │   │
│   │ Slabtown   ⚲│  │ Buckman    ⚲│  │   /locations?city=Portland ]   │   │
│   │ NW Portland  │  │ SE Portland  │  │       •                        │   │
│   │ 555 NW…      │  │ 222 SE…      │  │  •  ⚲  •      •                │   │
│   │ Tour · View  │  │ Tour · View  │  │      •     ⚲    (pin hover    │   │
│   └──────────────┘  └──────────────┘──┤  •           •   highlights    │   │
│   See all 10 locations in Portland →  │       •           card)        │   │
│   (links to /locations?city=Portland) └────────────────────────────────┘   │
│                                                                              │
│   cards overlap the map's left edge by ~64px on desktop;                    │
│   on mobile the map renders ABOVE the cards, full-width.                    │
│                                                                              │
│   ALL-INCLUSIVE MEMBERSHIPS              ←  BenefitsGridSection (existing) │
│   • 24/7 keycode access  • Climate controlled   • No noise restrictions     │
│   • Soundproofed rooms   • Storage included    • Free parking               │
│   • Musician-owned       • Month-to-month      • WiFi + power               │
│                                                                              │
│   FLEXIBLE SOLUTIONS FOR BANDS OF ALL SIZES                                  │
│   ┌────────────────┐  ┌────────────────┐  ┌────────────────┐               │
│   │ [photo]        │  │ [photo]        │  │ [photo]        │               │
│   │ Monthly lockout│  │ Hourly studios │  │ Groups         │               │
│   │ From $285/mo   │  │ From $15/hr    │  │ From $250/mo   │               │
│   │ [Learn more →] │  │ [Learn more →] │  │ [Learn more →] │               │
│   │                │  │ [Book now →]   │  │                │               │
│   └────────────────┘  └────────────────┘  └────────────────┘               │
│                                                                              │
│   WHAT MEMBERS ARE SAYING                                                    │
│   ┌────────────┐  ┌────────────┐  ┌────────────┐  ←  TestimonialsSection   │
│   │ "..."      │  │ "..."      │  │ "..."      │     (horiz scroll)        │
│   └────────────┘  └────────────┘  └────────────┘                            │
│                                                                              │
│   HOW IT WORKS                            ←  StepsSection (existing)        │
│   ① Book your tour    ② Choose your solution    ③ Get to work               │
│                                                                              │
│   FAQ                                                                        │
│   ▸ How much does it cost?                                                  │
│   ▸ Is there a contract?                                                    │
│   ▸ …                                                                        │
│                                                                              │
│ ╔═══════════════════════════ CTA banner (bg image) ═══════════════════════╗ │
│ ║   You ready?                            ┌───────────────────────────┐   ║ │
│ ║                                         │  Book your tour (form)    │   ║ │  ← second instance
│ ║                                         │  …same fields as hero…    │   ║ │     of TourRequestForm
│ ║                                         │  [  Book your tour  →  ] │   ║ │
│ ║                                         └───────────────────────────┘   ║ │
│ ╚═════════════════════════════════════════════════════════════════════════╝ │
├──────────────────────────────────────────────────────────────────────────────┤
│ [footer / subscribe]                                                         │
└──────────────────────────────────────────────────────────────────────────────┘

Mobile collapses to single column. Two non-obvious bits per the Industrious reference:

  • Hero form is teased, not fully expanded. Headline sits above; form card sits below in a short, vertically-clipped state showing the "Book your tour" header + the first field, with a fade-to-transparent gradient at the bottom hinting "more below." Tap anywhere on the card → expands to full height with all fields visible. Keeps the hero from being form-dominated on first paint while still surfacing the primary CTA. Implementation: max-h-[clamped] + bottom fade overlay + aria-expanded toggle on tap; expanded state persists for the rest of the session.
  • Locations section: map ABOVE cards, not below. Map renders first (full-width, shorter than desktop), cards stack underneath it. Same non-interactive / click-through-to-/locations?city= behavior; pin↔card hover linkage drops on mobile (no hover anyway).

Sections in order:

  1. Hero (full-bleed, h-safe-screen) — video bg /video/hero.mp4 via existing VideoHeroSection pattern (poster fallback, idle-mounted, reduced-motion-aware). Left col: brand H1 + market-specific subhead. H1 is fixed across markets ("Band practice at home sucks. We fixed that.") — keeps the differentiator we paid to establish on cherry-city. Subhead comes from LocationGroup.tagline (e.g., "Salem's rehearsal studios" / "Portland's rehearsal studios"). Right col: TourRequestForm (variant="hero").
  2. Locations near you — see Locations-near-you behavior. Cards capped at 2, all market pins on map, "See all N →" links to /locations?city=<cityName>.
  3. All-inclusive memberships — feature-bullet grid via existing BenefitsGridSection (apps/web/src/components/organisms/marketing/sections, used on /monthly). Reuse the musician-relevant bullets already curated for /monthly (24/7 access, climate control, no noise restrictions, etc.). Keeps the LP a self-contained sell — visitor doesn't have to bounce to /monthly to see what's included.
  4. Flexible solutions — 3 cards, all with visible "from $X" pricing for parity with the cherry-city LP and the Industrious reference. See 3-fork content below. Hourly card has a secondary "Book now" → opens existing ModalWrapper hosting HourlyBookingContent (no resourceId preset → drives the new studio-picker step). requireAuth=true on the modal, matching the cherry-city LP — auth wall fires only at checkout, not on picker/date/time steps.
  5. What members say — existing TestimonialsSection.
  6. How it works — 3-step process via existing StepsSection (used on /hourly and /monthly, but with new market-LP-specific copy, not the hourly-onboarding copy used on /hourly). Steps: 1. Book your tour, 2. Choose your solution, 3. Get to work. Different rhetorical job from FAQ — process clarity vs anxiety reduction; both earn space.
  7. FAQ — existing FAQSection, market-aware copy.
  8. Closing CTA — bg-image banner with second instance of TourRequestForm (variant="closing"). Matches reference's repeated form pattern — captures visitors who scrolled past the hero.

3-fork content

Card CTA target Pricing line Source
Monthly lockout /monthly "From $285/mo" (or LocationGroup's lowest startingMonthlyRate across its locations) matches /monthly page
Hourly studios /hourly (primary) + "Book now" modal (secondary) "From $15/hr" (or lowest startingHourlyRate across the market) matches cherry-city LP
Groups /groups (post-flip) "From $250/mo" (dedicated group rooms) — the simpler of the two pricing models on /group-memberships; credit-pool pricing is detail for the destination page matches GroupMembershipsPageContent.tsx line 108

Pricing values are looked up from LocationGroup.locationGroupLocations[].location.starting{Monthly,Hourly}Rate server-side, taking the min across the market's active locations. If none exist (shouldn't happen in v1 markets), fall back to the static defaults shown above.

Tour form contract

Component: new TourRequestForm under apps/web/src/components/organisms/marketing/.

Fields:

Field Required Notes
First name yes
Last name yes
Email yes dedup key on backend
Phone no microcopy: "for faster scheduling"
Location yes Select of locations in the market. Starts empty, no IP-geo prefill — visitor must pick explicitly (IP geo isn't reliable enough for a silent default; the "Locations near you" cards above the form do the soft suggestion via sort order). If the market has only 1 location: field hidden + auto-set.

Submit behavior — direct hand-off, no auth, no extra round-trip:

  1. Validate client-side, do NOT POST anywhere yet.
  2. Open existing TourSchedulerModal (apps/web/src/components/organisms/modals/TourSchedulerModal.tsx) with locationId from the form and visitor info prefilled into editedUserInfo / persistedUserInfo.
  3. Visitor picks a date/time.
  4. Modal POSTs to /tours (already public, dedupes by email, creates stub User server-side — no auth wall to the visitor; see apps/api/src/app/api/tours/route.ts).
  5. Confirmation panel + calendar invite, same as today.

If the visitor abandons before scheduling: no record created (today's behavior is that the email-only LeadCaptureForm creates a lead immediately — we are intentionally giving that up because tour-scheduling is the primary conversion). Acceptable because abandoned tour-form fills are low-signal compared to scheduled tours.

Tracking: existing trackTourScheduled covers the success event. Add a trackTourFormStarted (form first interaction) so we can measure abandonment in PostHog.

Hourly booking modal — studio-picker first step

Implemented as a thin wrapper component HourlyBookingWithPicker (apps/web/src/components/organisms/booking/HourlyBookingWithPicker.tsx) rather than a refactor of HourlyBookingContent's required resourceId prop. Behavior:

  • When invoked with no resourceId (market-LP "Book now"): renders a studio picker fetching /locations/{locationId}/resources/public?resourceType=STUDIO_HOURLY&limit=100. On pick, mounts HourlyBookingContent unchanged with the chosen resourceId.
  • When invoked with a preset resourceId (any future caller): bypasses the picker, mounts HourlyBookingContent directly.
  • Existing callers of HourlyBookingContent (/[locationSlug], /cherry-city/tour redirect target, etc.) are unaffected — the original component's prop contract is unchanged.

Why the wrapper instead of an internal first step: HourlyBookingContent is large (~1450 lines) and treats resourceId as a required, deeply-threaded value. A wrapper keeps the new code small and isolated, and the existing test surface stays untouched.

Locations-near-you behavior

Section renders 3 things:

  1. Up to 2 location cards (the "near you" picks).
  2. A map with markers for every location in the market, regardless of card count.
  3. "See all N locations in →" link below the cards when marketLocations.length > 2. Target: /locations?city=<LocationGroup.name> — the existing public locations directory already supports the ?city= searchParam (apps/web/src/app/(marketing-fullscreen)/locations/LocationsContentSection.tsx). We may upgrade this URL to /m/<marketSlug> later, not in scope.

For Salem (1 location): single card, no map, no "see all" link.

Card sort order

Server-side in the route segment, two-key sort:

  1. Primary key — availability. Locations with availableMonthlyCount > 0 rank above locations with availableMonthlyCount === 0. This field is already returned by /locations/public (apps/api/src/app/api/locations/public/route.ts). Rationale: a tour-driven LP shouldn't surface a sold-out location as the lead choice.
  2. Secondary key — proximity (haversine distance to visitor IP geo, ascending).

After sort, take the first 2 for cards.

Map behavior

  • Non-interactive. No zoom, no pan, no marker click handlers, no info windows. Disable Google Maps gestureHandling, zoomControl, mapTypeControl, streetViewControl, fullscreenControl. The map is a static visual.
  • Entire map surface is one big link wrapping the canvas → /locations?city=<cityName>. Same destination as the "See all" link below the cards. A click anywhere on the map navigates.
  • All market locations as markers, with sold-out (availableMonthlyCount === 0) rendered dim/grey, available rendered in brand color. Reuse InlineLocationsMap + LocationMarker; add a disabled / dim style prop and a nonInteractive mode.
  • Pin hover → card highlight. When the visitor hovers a pin whose location matches one of the 2 visible cards, that card gets a highlight state (border + slight elevation). When they hover a pin without a card match (the other 8 in Portland), nothing on the cards changes — the pin itself can still show a small name tooltip if the existing marker supports it.
  • Card hover → pin highlight (the inverse). Hovering a card scales / brand-colors its associated pin. Implementation: cards and markers share a stable locationId; CSS hover state on the card targets the pin via a parent class or a small bit of state in the section component.

Cards overlay (desktop) / stack (mobile)

  • Desktop (lg+): the card column overlaps the map's left edge by ~48–64px (matches Industrious reference). Cards carry a subtle shadow so the overlap reads as layered, not clipped.
  • Mobile (<lg): single column. Map first (full-width, reduced height), then the 2 stacked cards, then the "See all N →" link. No overlap. Pin↔card hover linkage doesn't apply on mobile.

Geo source

Vercel auto-injects geo into request headers on Edge / Serverless. Approach:

  1. Read headers() for x-vercel-ip-latitude / x-vercel-ip-longitude (works on Node runtime too; request.geo is Edge-only).
  2. If present and inside the market's bounding box (or just: closer than X km to any location in the group), use coordinates as the proximity origin.
  3. If absent or out-of-market, fall back to the market's centroid (mean lat/lng across its locations) — gives a deterministic, sensible "near you" within the market even without geo.

No browser geolocation prompt. No client JS for sort.

Dev override

In dev only, accept ?lat=<n>&lng=<n> searchParams to override the geo source. Lets us preview Portland-from-Beaverton, Portland-from-PDX-airport, etc. without VPN. Gate behind process.env.NODE_ENV !== 'production'.

Groups redirect flip

Today: /groups → 301 → /group-memberships (apps/web/src/app/(marketing)/groups/page.tsx).

Flip to: /group-memberships → 301 → /groups. The full marketing page lives at /groups (rename GroupMembershipsPageContent.tsxGroupsPageContent.tsx, move route file).

Grep + update internal links across apps/web/src (mostly nav menus, footers, /lp/cherry-city, marketing copy). Internal anchors and existing tests will need updates.

File map

apps/api/prisma/schema.prisma                                         schema add
apps/api/prisma/migrations/<ts>_add_location_group_landing_fields/    new migration
apps/api/scripts/seed-market-location-groups.ts                       one-off seed (Salem + Portland)
apps/api/src/app/api/locations/groups/[slug]/route.ts                 NEW: public lookup by slug → group + ordered locations
apps/web/src/app/(landing)/lp/[marketSlug]/page.tsx                   NEW: server component, ISR + geo + JSON-LD
apps/web/src/app/(landing)/lp/[marketSlug]/MarketLandingContent.tsx   NEW: client shell
apps/web/src/app/(landing)/lp/cherry-city/                            DELETED (entire directory; 301 in next.config)
apps/web/src/components/organisms/marketing/TourRequestForm.tsx       NEW
apps/web/src/components/organisms/marketing/LocationsNearYouSection.tsx NEW
apps/web/src/components/organisms/booking/HourlyBookingWithPicker.tsx NEW (wrapper, not in-place refactor)
apps/web/src/components/organisms/maps/InlineLocationsMap.tsx         EDIT: nonInteractive, wrapHref, hover linkage props
apps/web/src/components/molecules/maps/LocationMarker.tsx             EDIT: forceDim, onHoverChange props
apps/web/src/components/organisms/modals/TourSchedulerModal.tsx       EDIT: initialUserInfo prop
apps/web/src/lib/tracking.ts                                          EDIT: trackMarketLandingViewed, trackTourFormStarted; trackLandingFunnelChoice now accepts 'groups'
packages/shared-schemas/src/api/meta.ts                               EDIT: funnelChoiceValues includes 'groups'
apps/web/src/app/(marketing)/groups/{page.tsx,GroupsPageContent.tsx}  EDIT: full page (was redirect); content moved from group-memberships
apps/web/src/app/(marketing)/group-memberships/                       DELETED (now handled by next.config redirect)
apps/web/src/app/(marketing)/commercial-account/page.tsx              EDIT: redirect target → /groups
apps/web/src/app/(marketing)/page.tsx                                 EDIT: bento link /group-memberships → /groups
apps/web/next.config.ts                                               EDIT: redirects /lp/cherry-city → /lp/salem; /group-memberships → /groups; /cherry-city/tour|hourly → /lp/salem

Reuse, don't reinvent

Need Use
Hero video bg mirror VideoHeroSection mount/idle/reduced-motion logic
Map + markers InlineLocationsMap + LocationMarker
Tour scheduler modal TourSchedulerModal (no changes — already accepts editedUserInfo via props)
Hourly booking HourlyBookingContent + ModalWrapper (requireAuth=true, same as cherry-city LP — auth wall fires only at checkout, not on picker/date/time steps)
FAQ, testimonials, benefits grid, steps FAQSection, TestimonialsSection, BenefitsGridSection, StepsSection
Tracking extend apps/web/src/lib/tracking.ts with trackMarketLandingViewed, trackTourFormStarted
Address formatting formatAddress from @mg/shared-utils

Tracking

Add to apps/web/src/lib/tracking.ts:

  • trackMarketLandingViewed({ marketSlug, marketName, locationCount })
  • trackTourFormStarted({ marketSlug })

Existing trackTourScheduled keeps working unchanged. trackLandingFunnelChoice is also still fired on the 3-fork CTA clicks (monthly / hourly / groups) so cross-LP funnel comparisons stay valid in PostHog. On the new market LP these CTAs navigate to the dedicated product pages rather than scrolling to in-page anchors as on cherry-city.

JSON-LD on the page: LocalBusiness-array (one entry per location in the market), plus a FAQPage schema for the FAQ section. SEO canonical = https://www.metrognome.com/lp/<marketSlug>.

Out of scope (v1)

  • Editing LocationGroup landing fields (slug/tagline/description/heroImageUrl) from staff UI — DB-only for v1, staff form added later if needed.
  • Per-market hero video override (everyone gets /video/hero.mp4 for now).
  • Browser geolocation permission prompt (IP-only).
  • Multi-language.
  • A/B testing the form vs current pain-led hero — just ship it; the unified-cherry-city-lp + cherry-city-ppc-prospecting baselines stay queryable in PostHog if we need to compare later.

Rollout

  1. Schema + migration + seed (Salem + Portland groups).
  2. Build template + Salem content.
  3. Verify /lp/salem matches or beats /lp/cherry-city in a soft-launch window (link in dev only; no ad change).
  4. Flip Cherry City Meta ads' destination from /lp/cherry-city to /lp/salem.
  5. Activate the 301. Monitor PostHog funnel + Sentry for 48h.
  6. Portland goes live with the same template, no ad spend (organic + future paid).

Known gaps

Implemented. Initial build complete — see plan.md. Items still to do before flipping ad spend:

  1. Visual QA in browser. Hero overlap of the cards on the map, mobile teased form fade, hover linkage feel between cards and pins, hero video bg on real screen — none of this was verified by Claude during build (terminal-only). Aaron to spot-check /lp/salem and /lp/portland?lat=&lng= locally before deploy.
  2. Production seed. apps/api/scripts/seed-market-location-groups.ts ran on local DB only. Production seed is gated on Aaron's explicit "yes, run on prod".
  3. HourlyBookingWithPicker test coverage. No new tests written for the wrapper or the studio-picker step — relying on type safety + manual QA for v1. Worth adding a basic vitest if the picker becomes a long-term fixture.
  4. Hero video poster image. Currently uses /images/marketing/hero.jpg as poster. If we want a market-specific poster later, route the value off LocationGroup.heroImageUrl (field already exists, just isn't read by the LP yet).
  5. /m/<marketSlug> index page — out of scope; "See all N →" links to /locations?city=<name> for now.