name: site-community-page
description: >
Scaffolds or updates the canonical per-resort-community landing page at
/lp/
- site:community_page_create
- site:community_page_update output_type: web-page target_platforms: [] asset_destination: app/lp/[community]/page.tsx auto_inputs: ['market_stats_cache', 'listings', 'boundaries', 'data/resort-communities.json'] required_inputs: ['community_slug'] optional_inputs: ['sections_to_update (for updates)', 'hero_image_override'] estimated_runtime_min: 35 cost_usd_estimate: $0-$2 thumbnail_uri: public/lp/tetherow/img/tetherow-aerial-course.jpg example_outputs:
- label: Tetherow exemplar (static HTML, ported by this skill to Next.js) surface: website path: public/lp/tetherow/index.html
site-community-page
Scope. Creates or updates a per-resort-community SEO + AEO landing page at
/lp/<community>/. Each page is a Next.js dynamic route with Incremental Static
Regeneration (ISR) so market data stays fresh without manual edits or full
rebuilds. The page is the resort's authoritative search-result page: Google,
Bing, ChatGPT, Perplexity, and Claude should cite this page when a user asks
"what is Tetherow?" or "homes for sale in Pronghorn." Every figure on the page
traces to a live Supabase query verified at request time.
This producer ports the just-shipped static Tetherow LP at
public/lp/tetherow/index.html (the visual + content template, 2,495 lines)
into a dynamic Next.js route. The Tetherow port is the first exemplar; the
remaining 13 resort communities run through this same skill.
Does NOT handle: in-city neighborhood pages (that is site-neighborhood-page),
subdivision pages inside a master plan like Heath inside Tetherow (that is
site-subdivision-page), per-listing detail pages (that is site-listing-page),
or city-level pages like /lp/bend/ (that is site-city-page). Does NOT edit
the global homepage, header, footer, or /sell and /buy (those are
site-edit).
Status: Canonical
Locked: 2026-05-18
Exemplar output: GitHub PR at site-community/<action_id_prefix> branch.
The first executed exemplar is the Tetherow port to
app/lp/tetherow/page.tsx.
1. Scope
In scope
site:community_page_create: net-new dynamic route atapp/lp/<slug>/page.tsxsite:community_page_update: targeted section refresh on an existing route- Route registration in
app/sitemap.tswithpriority: 0.8,changeFrequency: 'daily' - JSON-LD
Place+RealEstateAgentschemas server-rendered into<script type="application/ld+json"> - ISR configuration:
export const revalidate = 21600(6 hours) at the route level, with shortercache: 'no-store'directives on the truly live data fetches (active inventory, pending count) - Live Supabase fetches in the server component for every figure: market_stats_cache rolling-365d row, active listings, recent closings
- Static content authored as TypeScript constants in the route module: architect bio, HOA table, builder roster, signature hole spec, course rankings, recognition, lifestyle / amenities, membership tiers, build timeline
- Static config sourced from
data/resort-communities.json(slug, sub-neighborhoods, hero asset path, drive-time anchors, etc.) - Map asset generation: Google Static Maps URL computed from
boundaries.polygoncentroid at the right zoom for the community size, written topublic/lp/<slug>/img/<slug>-location-map.pngat build time - Sub-neighborhood horizontal-scroll carousel with snap-points, prev/next arrows, scroll-progress bar, "1 OF N" position hint, keyboard navigation, edge fade indicator
- Per-listing "Schedule a showing" button on every active inventory card, anchoring to the buyer track section with property pre-fill
- Buyer track section with three forms: Schedule a Showing, Custom Alerts (delegates to
listing-alertsproducer for delivery), Buyer's Guide (delegates tobuyers-guideproducer for the actual asset) - Seller CMA form (existing
/api/cmaendpoint, FUB-tagged) - Sticky scroll CTA bar that appears after hero scroll-past
- Exit-intent modal (mouseleave at top OR mobile tab-hidden after 60% scroll, fires once per session)
- Dynamic dates:
data-dyn-month-yearspans server-rendered with the current month/year at request time, no client-side flash - Analytics: GA4
G-ST40W4WM6T, Meta Pixel1546878946032105, FUB pixel (when ID is provisioned by the analytics-unification work) - Topbar with horizontal white Ryan Realty logo (
/brand/logo-header-white.png) - Brand voice validation on all generated copy before file write
- TypeScript compile verification before PR opens
Out of scope
- Authoring the resort-community static facts. Those live in
data/resort-communities.jsonand are edited by hand or by a separate research producer. This skill READS that JSON; it does not WRITE it. - Building the listing-alerts backend or sending email digests. The Custom
Alerts form submits to the
listing-alertsproducer's queue. This skill only renders the form. - Building the Tetherow / Pronghorn / etc. buyer's guide PDF. That is the
buyers-guideproducer. This skill only renders the form that requests it. - Sub-neighborhood inner pages (use
site-subdivision-page). - Per-listing inner pages (use
site-listing-page). - City-level pages above the community tier (use
site-city-page). - Mass content rewrites or off-brand experiments.
2. Action types handled
| action_type | payload fields required | notes |
|---|---|---|
site:community_page_create |
community_slug, community_name, hero_headline, meta_description |
Route must not already exist. Slug must match a row in data/resort-communities.json. |
site:community_page_update |
community_slug, sections_to_update[], reason |
Route must exist at app/lp/<slug>/page.tsx. |
Payload schema
interface SiteCommunityPagePayload {
community_slug: string; // 'tetherow' | 'pronghorn' | 'broken-top' | 'sunriver' | 'caldera-springs' | etc.
// Must match a row in data/resort-communities.json AND a boundaries.geo_slug.
community_name: string; // Display name, e.g. 'Tetherow', 'Pronghorn / Juniper Preserve'.
hero_headline: string; // Playfair Display H1. Sentence case. No clichés. Includes a dynamic-date span.
// e.g. 'The Tetherow market, [data-dyn-month-year].'
meta_description: string; // 150-160 chars. Must include community name + 'Bend, OR' + a verified figure.
hero_image_override?: string; // Optional asset path if not the default in resort-communities.json.
sections_to_update?: Array< // For update action only.
| 'kpi_grid'
| 'active_inventory'
| 'notable_transactions'
| 'comparison_row'
| 'hoa_table'
| 'membership'
| 'lifestyle'
| 'builders'
| 'sub_neighborhoods'
| 'happenings'
| 'pipeline'
| 'methodology'
| 'meta'
| 'jsonld'
>;
reason?: string; // For updates only: why this section needs refresh.
}
3. Full action row schema
interface SiteCommunityPageActionRow {
id: string;
action_type: 'site:community_page_create' | 'site:community_page_update';
target: string; // e.g. 'community:tetherow'
assigned_producer: 'marketing_brain_skills/producers/site-community-page';
payload: SiteCommunityPagePayload;
data_evidence: {
audit_source?: string; // e.g. 'audit-website'
opportunity_area?: string; // e.g. 'GSC: 1,240 monthly impressions for "pronghorn bend or homes"'
signal_evidence?: string;
};
generation_reason: string;
status: 'pending';
}
4. The recipe
Step 1 --EMDASH-- Read the action row and claim it.
UPDATE marketing_brain_actions
SET status = 'in_production', executed_at = now()
WHERE id = '<id>' AND status = 'pending';
If the UPDATE affected zero rows, halt silently (another producer beat us to it).
Step 2 --EMDASH-- Load mandatory references.
In order:
CLAUDE.md§0 --EMDASH-- Data Accuracy mandate (every figure traces to live Supabase)CLAUDE.md§0.5 --EMDASH-- Draft-First, Commit-Last (open PR, never push to main directly)CLAUDE.md"Design System Rules: MANDATORY" --EMDASH-- shadcn/ui onlyCLAUDE.md"Design System v2: Heritage + Web Registers" --EMDASH-- Web registerdesign_system/ryan-realty/SKILL.md--EMDASH-- color tokens, type families, shadow ladder, radiidesign_system/ryan-realty/colors_and_type.css--EMDASH-- CSS variable definitionsmarketing_brain_skills/brand-voice/voice_guidelines.md--EMDASH-- voice enforcement (the hard fail list)marketing_brain_skills/research/asset-library-map.md--EMDASH-- hero asset pathsdata/resort-communities.json--EMDASH-- find the row forpayload.community_slugpublic/lp/tetherow/index.html--EMDASH-- visual + content reference (2,495 lines, the gold-standard exemplar)app/sitemap.ts--EMDASH-- sitemap structure to extendapp/lp/tetherow/page.tsxIF IT EXISTS (the Tetherow port is the architectural exemplar after this skill executes once)
Step 3 --EMDASH-- Route check.
For create: confirm app/lp/<slug>/page.tsx does NOT exist. If it does,
status='killed' with response:
Route already exists at app/lp/<slug>/page.tsx.
Use site:community_page_update to refresh sections.
For update: confirm the file DOES exist. If missing, status='killed' and
suggest site:community_page_create.
Step 4 --EMDASH-- Resolve community config.
Read the matching row in data/resort-communities.json. Required fields:
interface ResortCommunityConfig {
slug: string; // matches payload.community_slug
name: string; // display name
geo_slug: string; // matches a row in `boundaries` table for the centroid
acres: number; // master plan size
founded: number; // year of master plan inception
architect?: string; // 'David McLay Kidd', 'Tom Doak', etc., or null if no signature golf course
sub_neighborhoods: Array<{
slug: string; // 'heath' | 'tartan-druim' | etc.
name: string; // 'Heath' | 'Tartan Druim'
type: string; // 'Single-family golf homes' | 'Townhomes' | 'Gated custom'
hoa_annual_estimate: number; // a single number, even if a range exists
description: string; // 1-sentence character note
image_hint?: string; // photo lookup hint, e.g. an MLS thumbnail or asset library entry
}>;
amenities: Array<{
category: string; // 'Dining', 'Wellness', 'Fitness', 'Racquet', 'Winter', 'Trails'
name: string;
description: string;
access: string; // 'Open to public' | 'Members + Lodge guests' | etc.
}>;
membership_tiers: Array<{
label: string; // 'Golf', 'Sport', 'Social'
eyebrow: string; // 'Full club', 'Limited access', 'Social'
description: string;
waitlist_status: string; // 'Typically waitlisted' | 'Generally open'
}>;
builders: Array<{
name: string;
role: string; // 'Bend custom luxury'
description: string;
website?: string;
}>;
signature_hole?: {
number: number;
par: number;
yardage: number;
elevation_drop_ft?: number;
description: string;
};
course_specs?: {
par: number;
yardage: number;
rating: number;
slope: number;
};
course_rankings?: Array<{
rank: string; // '#57', '#1', etc.
publication: string;
description: string;
}>;
drive_times: Array<{
minutes: number;
destination: string; // 'Old Mill District'
note: string;
}>;
hero_image: string; // '/lp/<slug>/img/<slug>-aerial-course.jpg'
brand_lockup_hex_primary: string; // '#102742' for Ryan Realty navy
build_timeline: Array<{
year: number;
label: string;
}>;
happenings: Array<{
date: string; // '2025 · CONDE NAST 8TH CONSECUTIVE YEAR'
headline: string;
body: string;
sources: Array<{label: string; url: string}>;
}>;
}
If the slug is not in resort-communities.json, surface to Matt with:
Community '<slug>' not registered in data/resort-communities.json.
Add the row first, then re-run this action.
Don't fabricate community facts.
Step 5 --EMDASH-- Pull live market data.
Always re-pull. Never use values cached in the action row payload. Per CLAUDE.md §0, every figure on the page traces to a live query in this session.
Six queries, in this order:
a. Rolling-365d market stats:
SELECT
sold_count, median_sale_price, median_dom, avg_sale_to_list_ratio,
median_ppsf, end_of_period_inventory, methodology_version,
period_start, period_end, computed_at
FROM market_stats_cache
WHERE geo_slug = '<community_geo_slug>'
AND period_type = 'rolling_365d'
ORDER BY period_end DESC
LIMIT 1;
b. Active inventory (live count + grid):
SELECT
"ListingId", "ListPrice", "StreetNumber", "StreetName",
"BedroomsTotal", "BathroomsTotal", "TotalLivingAreaSqFt",
"CumulativeDaysOnMarket", "StandardStatus", "PhotoURL",
"SubdivisionName"
FROM listings
WHERE "StandardStatus" IN ('Active', 'ActiveUnderContract', 'Pending')
AND "PropertyType" = 'A'
AND "SubdivisionName" = ANY('<resort_communities.json mls_aliases>'::text[])
ORDER BY "ListPrice" DESC
LIMIT 12;
c. Notable transactions (last 90 days):
SELECT
"CloseDate", "ClosePrice", "ListPrice", "OriginalListPrice",
"BedroomsTotal", "BathroomsTotal", "TotalLivingAreaSqFt",
"SubdivisionName",
ROUND(("ClosePrice" / NULLIF("TotalLivingAreaSqFt", 0))::numeric, 0) AS price_per_sqft,
ROUND((("ClosePrice" / NULLIF("ListPrice", 0)) * 100)::numeric, 1) AS sale_to_list_pct
FROM listings
WHERE "StandardStatus" IN ('Closed', 'Sold')
AND "PropertyType" = 'A'
AND "SubdivisionName" = ANY('<aliases>'::text[])
AND "CloseDate" >= NOW() - INTERVAL '90 days'
ORDER BY "CloseDate" DESC
LIMIT 8;
d. Comparison row vs peer communities (configurable per resort):
SELECT
geo_slug, geo_label,
sold_count, median_sale_price, median_dom,
avg_sale_to_list_ratio, median_ppsf, end_of_period_inventory
FROM market_stats_cache
WHERE geo_slug = ANY('<peer_slugs>'::text[])
AND period_type = 'rolling_365d'
ORDER BY median_sale_price DESC NULLS LAST;
Peer slugs are listed in data/resort-communities.json under each community's
comparison_peers array (typically 3-4 peer resorts at similar price tier).
e. Boundary centroid for the map:
SELECT
ST_X(ST_Centroid(polygon))::numeric(10,6) AS centroid_lng,
ST_Y(ST_Centroid(polygon))::numeric(10,6) AS centroid_lat,
ROUND((ST_Area(polygon::geography) / 4046.86)::numeric, 1) AS acres,
source, source_url
FROM boundaries
WHERE geo_slug = '<community_geo_slug>'
ORDER BY ST_Area(polygon) ASC
LIMIT 1;
Use the smallest authoritative polygon if multiple match (for Tetherow this is "Tetherow Phase 1" at 599.7 acres, not the 5,717-acre name-match convex hull). The City-of-Bend GIS or Deschutes County GIS sub-plat is preferred over the Spark MLS alias-derived polygon.
f. Reconciliation gate. If any figure pulled here differs from
resort-communities.json by more than 5%, halt and surface to Matt with the
delta. Don't auto-update the JSON.
Step 6 --EMDASH-- Generate map asset.
MAP_URL="https://maps.googleapis.com/maps/api/staticmap?\
center=<lat>,<lng>&zoom=13&size=720x520&scale=2&maptype=roadmap\
&style=feature:poi|element:labels|visibility:off\
&style=feature:landscape|element:geometry|color:0xf2ebdd\
&style=feature:water|element:geometry|color:0xb8d4dc\
&style=feature:road|element:geometry|color:0xfaf8f4\
&markers=color:0x102742%7Csize:mid%7Clabel:T%7C<lat>,<lng>\
&key=$NEXT_PUBLIC_GOOGLE_MAPS_API_KEY"
curl -s -o public/lp/<slug>/img/<slug>-location-map.png "$MAP_URL"
Zoom heuristic by community size:
- < 200 acres: zoom 14
- 200 to 1,000 acres: zoom 13
1,000 acres: zoom 12
If the master plan straddles a wide range (e.g. Sunriver at 3,300 acres),
pull the bounding box from boundaries and compute the right zoom from the
size of the box, not the centroid.
Step 7 --EMDASH-- Voice-validate all generated copy.
Before writing the route file, run payload.hero_headline,
payload.meta_description, and every paragraph that this skill generates
(not the content read verbatim from resort-communities.json, which is
assumed pre-validated) through the voice guardrail:
Banned words: stunning, nestled, boasts, charming, pristine, gorgeous, breathtaking, must-see, dream home, meticulously maintained, tucked away, hidden gem, truly, spacious, cozy, luxurious, updated throughout, turnkey, immaculate, captivating, exquisite, delve, leverage, tapestry, navigate, robust, seamless, comprehensive, elevate, unlock, holistic, dynamic, vibrant, bustling, eclectic, curated, bespoke, foster, premier, approximately, polygon (jargon --EMDASH-- homeowners do not care), don't miss, act now, won't last.
Banned punctuation in body copy: em-dash, en-dash (except numeric ranges where it's swapped to "to"), semicolon, exclamation mark, dramatic colon.
If a violation persists after 2 auto-fix iterations, kill the action with the specific banned token and rule cited.
Step 8 --EMDASH-- Scaffold the route file.
For create, write app/lp/<slug>/page.tsx. Page structure mirrors the
Tetherow exemplar (public/lp/tetherow/index.html) but as TSX with server
components.
ISR config at the top:
export const revalidate = 21600 // 6 hours
Critical sections of the page:
- metadata via Next.js Metadata API: title, meta description, canonical, og:image (hero), JSON-LD Place + RealEstateAgent.
- Analytics layer in
app/layout.tsx(already present): GA4G-ST40W4WM6T, Meta Pixel1546878946032105, FUB pixel (when ID lands). - Topbar with the white horizontal logo (
/brand/logo-header-white.png, 400x80) + license text + "What's my home worth?" pill. - Sticky scroll CTA that reveals on scroll-past-hero.
- Hero with course aerial bg image, eyebrow, dynamic-month-year H1, subhead, four-stat bar.
- About Community overview (rich SEO + AEO content + sticky facts panel
- anchor TOC).
- Architect / signature angle (from
architect+course_rankingsin config). - Location with the Google Static Map + drive-time anchors.
- Live market pulse KPI grid (8 cards, all from
market_stats_cache). - HOA table by sub-neighborhood (from
sub_neighborhoodsin config) + HOA meta cards + board roster (board roster lives in a separate JSON if available, otherwise omitted with a "Not publicly disclosed" note). - Mid-page CTA linking to #cma.
- The course + rankings + build timeline + signature hole (from config).
- Lifestyle / Amenities grid (from
amenitiesin config). - Membership tiers (from
membership_tiersin config) with the "Confirmed at office" placeholders for figures the resort doesn't publish publicly. - What's happening (from
happeningsin config, most recent first). - Active inventory grid (live from
listingsquery, 12 cards max). Each card gets a<button>"Schedule a showing" withdata-propertyset from the live query result. - Buyer track section (three cards: showing, alerts, guide). "Schedule a showing" form's property dropdown is populated server-side from the same active inventory query so it matches the cards above.
- Notable transactions strip (live from
listingsquery, 6-8 rows). - Sub-neighborhoods horizontal-scroll carousel (from
sub_neighborhoodsin config). Each card href links to/lp/<community>/<sub_slug>/(subdivision pages produced bysite-subdivision-page--EMDASH-- those are likely placeholders at create time). - Builder roster (from
buildersin config). - Pipeline (from a
pipelinearray in config if present, else omitted). - Comparison row (live from the comparison query).
- Our work / If you list broker block + headshot.
- CMA seller form (existing /api/cma endpoint, FUB tags from config).
- Methodology footer with one bullet per data source.
- Footer with brand block + reach + resort + community pages list + legal.
- JSON-LD Place + RealEstateAgent server-rendered.
- Exit-intent modal (component import).
- Inline scripts for the scroller, multi-step CMA form, exit-intent, sticky CTA, listing-showing click handlers, dynamic-date injection fallback (server-render handles primary).
Design system rules (Web register):
bg-backgroundfor page background (cream#faf8f4)bg-primary text-primary-foregroundfor CTAs (navy#102742)- All containers use
<Card>from@/components/ui/card--EMDASH-- no raw divs font-displayclass (Playfair Display) for hero H1, section H2s --EMDASH-- sentence case body, Title Case only the hero- Geist for everything else
- Radii:
rounded-xl(14px) for cards,rounded-lg(10px) for buttons - No gold. No off-brand hex. No custom CSS classes outside shadcn/ui token system.
- Shadows:
shadow-smresting,shadow-mdhover.
Hero asset: from resort-communities.json row's hero_image field, mirrored
under public/lp/<slug>/img/.
For update: read the existing route file, identify which sections live in
payload.sections_to_update, replace only those sections, preserve all others
verbatim. Re-run live queries only for the sections being updated.
Step 9 --EMDASH-- Update sitemap.
For creates only:
{
url: `${siteUrl}/lp/<slug>/`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.8,
}
Step 10 --EMDASH-- TypeScript compile check.
cd /Users/matthewryan/RyanRealty && npx tsc --noEmit 2>&1
Zero errors required. If errors, fix within 2 iterations. If unfixable, kill with the tsc output in the response.
Step 11 --EMDASH-- Branch, commit, push, open PR.
Branch: site-community/<slug>-<first-8-of-action-id>
git checkout -b site-community/<slug>-<prefix>
git add app/lp/<slug>/page.tsx app/sitemap.ts public/lp/<slug>/
git commit -m "site-community-page(<slug>): <create|update> resort community LP
Action row: <id>
Community: <community_name>
Route: /lp/<slug>/
Rationale: <generation_reason>
ISR: revalidate every 6 hours.
Live data sources: market_stats_cache, listings (active + recent
closings), boundaries (centroid for map), peer communities for
comparison row.
Static config sourced from data/resort-communities.json.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git push origin site-community/<slug>-<prefix>
gh pr create --title "site-community-page(<slug>): <create|update> /lp/<slug>/" \
--body "<PR body --EMDASH-- see Output format section>"
Step 12 --EMDASH-- Write citations.json.
{
"produced_at": "<iso>",
"community_slug": "<slug>",
"route": "/lp/<slug>/",
"data_sources": [
{
"section": "kpi_grid",
"source": "market_stats_cache",
"filter": "geo_slug='<slug>' AND period_type='rolling_365d'",
"fetched_at": "<iso>",
"methodology_version": "<from cache row>"
},
{
"section": "active_inventory",
"source": "listings",
"filter": "StandardStatus IN ('Active','Pending','ActiveUnderContract') AND SubdivisionName = ANY(<aliases>)",
"fetched_at": "<iso>",
"row_count": "<n>"
},
{
"section": "notable_transactions",
"source": "listings",
"filter": "CloseDate >= NOW() - INTERVAL '90 days'",
"fetched_at": "<iso>",
"row_count": "<n>"
},
{
"section": "comparison_row",
"source": "market_stats_cache",
"filter": "geo_slug = ANY(<peers>) AND period_type='rolling_365d'",
"fetched_at": "<iso>"
},
{
"section": "map",
"source": "boundaries",
"filter": "geo_slug='<slug>' ORDER BY ST_Area ASC LIMIT 1",
"centroid_lat": "<lat>",
"centroid_lng": "<lng>",
"polygon_source": "<source>",
"polygon_source_url": "<url>"
},
{
"section": "static_content",
"source": "data/resort-communities.json",
"row": "<slug>",
"last_modified": "<mtime>"
}
]
}
Store at out/site-community/<slug>/citations.json.
Step 13 --EMDASH-- Update action row to ready.
UPDATE marketing_brain_actions
SET status = 'ready',
executor_response = '{
"branch_name": "site-community/<slug>-<prefix>",
"pr_url": "<pr_url>",
"files_changed": [
"app/lp/<slug>/page.tsx",
"app/sitemap.ts",
"public/lp/<slug>/img/<slug>-location-map.png"
],
"page_route": "/lp/<slug>/",
"isr_revalidate_seconds": 21600,
"voice_validated": true,
"tsc_clean": true,
"jsonld_schemas": ["Place", "RealEstateAgent"],
"citations_path": "out/site-community/<slug>/citations.json"
}'::jsonb
WHERE id = '<id>';
Step 14 --EMDASH-- Surface draft to Matt.
Use the format in §6.
5. Tools used
| tool | purpose | env var / path |
|---|---|---|
| Supabase MCP | market_stats_cache + listings + boundaries queries; action row updates | NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY |
| Read | bend-market-bible, resort-communities.json, brand voice, existing page | repo paths |
| Write / Edit | app/lp/<slug>/page.tsx, app/sitemap.ts, public/lp/<slug>/img/* |
repo working tree |
Bash: curl |
Google Static Maps fetch | NEXT_PUBLIC_GOOGLE_MAPS_API_KEY |
Bash: npx tsc --noEmit |
TypeScript compile check | /Users/matthewryan/RyanRealty |
Bash: git, gh |
branch, commit, push, PR open | active gh session |
6. Output format
Draft lands at: GitHub PR (Web register page on branch)
app/lp/<slug>/page.tsx (new or updated)
app/sitemap.ts (updated for creates)
public/lp/<slug>/img/<slug>-location-map.png (new for creates)
public/lp/<slug>/img/<slug>-aerial-hero.jpg (if not yet in repo)
out/site-community/<slug>/citations.json (always)
PR body template:
## site-community-page(<slug>): <create|update>
**Action row:** `<id>`
**Community:** <community_name>
**Route:** `/lp/<slug>/`
**Branch:** `site-community/<slug>-<prefix>`
### Files
- `app/lp/<slug>/page.tsx` --EMDASH-- <new | updated sections: X, Y, Z>
- `app/sitemap.ts` --EMDASH-- <added entry | unchanged>
- `public/lp/<slug>/img/<slug>-location-map.png` --EMDASH-- <new | refreshed>
### Live data sources (ISR revalidate = 6h)
| Section | Source | Filter | Rows |
|---|---|---|---|
| KPI grid | market_stats_cache | geo_slug='<slug>' AND period_type='rolling_365d' | 1 |
| Active inventory | listings | StandardStatus IN ('Active','Pending') AND SubdivisionName = ANY(<aliases>) | <n> |
| Notable transactions | listings | CloseDate >= 90d AND SubdivisionName = ANY(<aliases>) | <n> |
| Comparison row | market_stats_cache | geo_slug = ANY(<peers>) | <n> |
| Map centroid | boundaries | geo_slug='<slug>' | 1 |
### Static config
`data/resort-communities.json` row `<slug>`, mtime <date>.
<N> sub-neighborhoods, <N> amenities, <N> builders, <N> happenings.
### Validation
- [x] Voice: PASS (no banned words or punctuation in generated copy)
- [x] TypeScript: PASS (zero errors)
- [x] Design tokens: PASS (shadcn/ui only, no custom CSS classes)
- [x] JSON-LD: Place + RealEstateAgent schemas included
- [x] Analytics: GA4 + Meta Pixel + FUB pixel placeholder
- [x] Data accuracy: every figure traces to a live Supabase query (citations.json)
### Approval gate
Matt merges this PR in GitHub to make the page live. Vercel deploys
automatically after merge.
Surface format (chat reply to Matt):
Draft ready: site-community-page: /lp/<slug>/
PR
URL: <pr_url>
Branch: site-community/<slug>-<prefix>
PAGE
Route: /lp/<slug>/
Community: <community_name>
H1: <hero_headline> (with dynamic month-year)
KPI section sourced from market_stats_cache (refreshes every 6h via ISR)
Active inventory at render: <n> homes
Recent closings (90d): <n>
Sub-neighborhoods: <n>
Comparison peers: <peer list>
VERIFICATION TRACE
Live queries:
• market_stats_cache geo_slug='<slug>' period='rolling_365d' -> 1 row
• listings Active/Pending where SubdivisionName ANY <aliases> -> <n>
• listings Closed last 90d -> <n>
• market_stats_cache peers (<peers>) -> <n>
• boundaries geo_slug='<slug>' centroid -> (<lat>, <lng>)
VALIDATION
Voice: PASS
TypeScript: PASS
Design tokens: PASS (shadcn/ui only)
JSON-LD: Place + RealEstateAgent
citations.json: out/site-community/<slug>/citations.json
Matt merges the PR in GitHub to ship.
Then stop. Do not push to main. Wait for Matt to merge.
7. Approval gate
| approval_type | what it means | who can grant |
|---|---|---|
matt-review-PR |
Matt merges the GitHub PR | Matt only, via GitHub UI |
This producer uses: matt-review-PR.
A draft is never considered approved by a passing TypeScript build, a passing
voice check, or a complete render. Those are necessary, not sufficient. Only
Matt clicking Merge on the PR transitions the action row from ready to
approved.
8. Status flow
pending <- producer reads row here
|
v
in_production <- set immediately on pickup; executed_at=now()
|
+-- Route conflict (create) -> killed
+-- Route missing (update) -> killed
+-- Slug not in resort-communities.json -> killed (surface to Matt)
+-- Data reconciliation delta > 5% -> killed (surface to Matt)
+-- Voice fail after 2 iterations -> killed
+-- TypeScript fail after 2 iterations -> killed
+-- Boundaries query returns 0 rows -> killed (community has no registered polygon)
|
v (PR open)
ready <- executor_response populated with branch_name, pr_url, etc.
|
v (Matt merges PR)
approved
|
v (Vercel deploy completes)
executed
|
v (48h: audit-website captures first impressions and clicks)
measured
9. Failure modes
| failure | symptoms | recovery |
|---|---|---|
| Route already exists (create) | app/lp/<slug>/page.tsx found on disk |
Kill; suggest site:community_page_update |
| Route missing (update) | File not found | Kill; suggest site:community_page_create |
| Slug not in resort-communities.json | No matching row | Kill; surface to Matt with instruction to add the row first |
| Boundaries query returns 0 rows | Community has no registered polygon | Kill; surface to Matt with the data accuracy rule (GIS authoritative only --EMDASH-- no fabricated coordinates) |
| Data reconciliation delta > 5% | Figure in resort-communities.json differs from live cache by more than 5% | Kill; surface specific figure, cache value, JSON value, delta % |
| Insufficient closed sales for comparison row | Peer community has < 3 closings in window | Render row with peer's column showing "--EMDASH--" and footnote |
| Voice fail after 2 iterations | Banned word persists | Kill; surface specific violation and rule |
| TypeScript error after 2 iterations | Persistent type error | Kill; surface tsc output |
| Google Static Maps quota exceeded | curl returns 403 / 429 | Kill; surface to Matt with the key billing dashboard URL |
| ListingPhotoURL has dead/dangling URL | One or more active listing cards have missing images | Render card with a gray placeholder; note in PR; do not kill |
| Comparison peers list empty for a community | No peers configured in resort-communities.json | Omit the comparison row entirely; note in PR |
| Sub-neighborhood subdivision pages don't exist yet | Cards point to /lp/ |
Render with hover text "(coming soon)" on the card label; do not kill |
10. Related skills and references
Required reading before executing:
CLAUDE.md§0 --EMDASH-- Data AccuracyCLAUDE.md§0.5 --EMDASH-- Draft-First, Commit-LastCLAUDE.md"Design System Rules: MANDATORY" --EMDASH-- shadcn/ui onlyCLAUDE.md"Design System v2: Heritage + Web Registers"design_system/ryan-realty/SKILL.md--EMDASH-- color tokens, type families, radii, shadowsdesign_system/ryan-realty/colors_and_type.css--EMDASH-- CSS variable definitionsmarketing_brain_skills/brand-voice/voice_guidelines.md--EMDASH-- voice enforcementautomation_skills/content_engine/SKILL.md--EMDASH-- content routing bussocial_media_skills/platform-best-practices/SKILL.md--EMDASH-- 2026 platform rule layer for the on-page CTAsmarketing_brain_skills/research/asset-library-map.md--EMDASH-- hero asset pathsmarketing_brain_skills/research/bend-market-bible.md--EMDASH-- community fact references where the data is sparse in resort-communities.jsondata/resort-communities.json--EMDASH-- the canonical community configpublic/lp/tetherow/index.html--EMDASH-- the visual + content exemplar (gold standard)app/sitemap.ts--EMDASH-- sitemap structure to extendapp/actions/lead-capture.ts--EMDASH-- FUB lead routing (CMA + buyer forms)marketing_brain_skills/research/platform-bible.md§24 --EMDASH-- fair housing + real estate compliance
Sibling producers in the same tier system:
marketing_brain_skills/producers/site-neighborhood-page/SKILL.md--EMDASH-- Tier 2 producer for in-city neighborhoods that are NOT master-planned resorts (NW Crossing, Old Bend, etc.). Use that instead of this skill for those.marketing_brain_skills/producers/site-subdivision-page/SKILL.md--EMDASH-- Tier 3 (lives one level deeper inside a community page; the Heath exemplar inside Tetherow). Not yet authored --EMDASH-- pending.marketing_brain_skills/producers/site-listing-page/SKILL.md--EMDASH-- Tier 4 (per-property). Not yet authored --EMDASH-- pending.marketing_brain_skills/producers/site-city-page/SKILL.md--EMDASH-- Tier 1 (/lp/bend/). Not yet authored --EMDASH-- pending.
Producers this skill delegates the work of:
marketing_brain_skills/producers/listing-alerts/SKILL.md--EMDASH-- receives the Custom Alerts form submissions (saved-search backend). Not yet authored --EMDASH-- pending.marketing_brain_skills/producers/buyers-guide/SKILL.md--EMDASH-- receives the Buyer's Guide form submissions (PDF + content). Not yet authored --EMDASH-- pending.marketing_brain_skills/producers/cma/SKILL.md--EMDASH-- receives the seller CMA form submissions (existing /api/cma endpoint).
Registry entry:
marketing_brain_skills/producers/REGISTRY.md--EMDASH-- Section D (site-* producers), rowsite-community-page. Action types:site:community_page_create,site:community_page_update. Approval:matt-review-PR. Estimated runtime: 30-45 min.
11. Tool gap suggestions
What would make this skill 10x better:
Auto-discover peer communities. Today
comparison_peersis a hand-curated list inresort-communities.json. A query againstmarket_stats_cacheclustering by median price + acres + amenity profile could surface peers automatically. This would also keep the comparison row consistent across all 14 resorts.Resort-communities.json schema validation. Author a JSON schema and a precommit hook so a malformed config row is caught before it reaches this skill. Today a missing field in the config will surface as a TypeScript error mid-build --EMDASH-- the schema check should run first.
Hero-asset auto-source. Today the hero image is hand-placed at
public/lp/<slug>/img/<slug>-aerial-hero.jpg. A small assist that pulls the canonical aerial from the resort's own marketing page (with editorial attribution) and writes it into place would unblock 13 resort builds without manual Asset Library curation.Sub-neighborhood drill-down propagation. When the
site-subdivision-pageproducer creates a real page at/lp/<community>/<sub>/, this skill should be triggered withsections_to_update: ['sub_neighborhoods']to remove the "(coming soon)" hover text from the matching card. A small audit job that runs after every subdivision page merge could handle this without manual triggering.Comparison row data freshness ping. The comparison row pulls peer data once per ISR cycle. If a peer community's data is more than 14 days stale (i.e., the brain hasn't refreshed
market_stats_cachefor that peer), surface that in the page footnote with a "Peer stats as of" timestamp so the reader knows which comparison reflects current data.
Mandatory references (validator-required)
CLAUDE.md §0 (Data Accuracy)CLAUDE.md §0.5 (Draft-First, Commit-Last)design_system/ryan-realty/SKILL.mdmarketing_brain_skills/brand-voice/voice_guidelines.mdmarketing_brain_skills/research/tool-inventory.mdmarketing_brain_skills/research/platform-bible.mdmarketing_brain_skills/research/asset-library-map.mdmarketing_brain_skills/research/bend-market-bible.mdautomation_skills/content_engine/SKILL.mdsocial_media_skills/platform-best-practices/SKILL.mddata/resort-communities.jsonpublic/lp/tetherow/index.html(visual exemplar)