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/salemimmediately 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: ... }withLocationGroupLocation→cherry-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-expandedtoggle 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:
- Hero (full-bleed,
h-safe-screen) — video bg/video/hero.mp4via existingVideoHeroSectionpattern (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 fromLocationGroup.tagline(e.g., "Salem's rehearsal studios" / "Portland's rehearsal studios"). Right col:TourRequestForm(variant="hero"). - 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>. - 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/monthlyto see what's included. - 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
ModalWrapperhostingHourlyBookingContent(noresourceIdpreset → drives the new studio-picker step).requireAuth=trueon the modal, matching the cherry-city LP — auth wall fires only at checkout, not on picker/date/time steps. - What members say — existing
TestimonialsSection. - How it works — 3-step process via existing
StepsSection(used on/hourlyand/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. - FAQ — existing
FAQSection, market-aware copy. - 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 | |
| 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:
- Validate client-side, do NOT POST anywhere yet.
- Open existing
TourSchedulerModal(apps/web/src/components/organisms/modals/TourSchedulerModal.tsx) withlocationIdfrom the form and visitor info prefilled intoeditedUserInfo/persistedUserInfo. - Visitor picks a date/time.
- Modal POSTs to
/tours(already public, dedupes by email, creates stubUserserver-side — no auth wall to the visitor; seeapps/api/src/app/api/tours/route.ts). - 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, mountsHourlyBookingContentunchanged with the chosenresourceId. - When invoked with a preset
resourceId(any future caller): bypasses the picker, mountsHourlyBookingContentdirectly. - Existing callers of
HourlyBookingContent(/[locationSlug],/cherry-city/tourredirect 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:
- Up to 2 location cards (the "near you" picks).
- A map with markers for every location in the market, regardless of card count.
- "See all N locations in
→" link below the cards whenmarketLocations.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:
- Primary key — availability. Locations with
availableMonthlyCount > 0rank above locations withavailableMonthlyCount === 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. - 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. ReuseInlineLocationsMap+LocationMarker; add adisabled/dimstyle prop and anonInteractivemode. - 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:
- Read
headers()forx-vercel-ip-latitude/x-vercel-ip-longitude(works on Node runtime too;request.geois Edge-only). - 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.
- 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.tsx → GroupsPageContent.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
LocationGrouplanding 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.mp4for 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
- Schema + migration + seed (Salem + Portland groups).
- Build template + Salem content.
- Verify
/lp/salemmatches or beats/lp/cherry-cityin a soft-launch window (link in dev only; no ad change). - Flip Cherry City Meta ads' destination from
/lp/cherry-cityto/lp/salem. - Activate the 301. Monitor PostHog funnel + Sentry for 48h.
- 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:
- 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/salemand/lp/portland?lat=&lng=locally before deploy. - Production seed.
apps/api/scripts/seed-market-location-groups.tsran on local DB only. Production seed is gated on Aaron's explicit "yes, run on prod". HourlyBookingWithPickertest 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.- Hero video poster image. Currently uses
/images/marketing/hero.jpgas poster. If we want a market-specific poster later, route the value offLocationGroup.heroImageUrl(field already exists, just isn't read by the LP yet). /m/<marketSlug>index page — out of scope; "See all N →" links to/locations?city=<name>for now.