# Backlog

Source of truth for the product wishlist. The admin dashboard at
`/admin-dashboard` renders this file. When a PR closes a wishlist item,
move that item to the "🚀 Shipped" section with `**PR:** #<N>` (or
`**Commit:** <hash>` for direct-to-main work) in the same PR. Prune
Shipped items older than 90 days.

Each item: `## Title`, then `**Why:**`, `**Sketch:**`, `**Size:**`
(and `**PR:**` / `**Commit:**` once shipped).

---

## 🔨 In progress

<!-- items currently being worked, with branch or PR# if open -->

## Stop non-estimator Stripe subs from creating accounts + drips

**Why:** Subscriptions for Jake's other Stripe products are still creating estimator accounts and receiving the onboarding emails and one-month check-ins. The `NEM_ESTIMATOR_PRODUCT_IDS` filter and `lifecycle_eligible` gate exist but something leaks. Actively emailing the wrong people.

**Size:** half-day (trace + fix + report affected accounts).
**PR:** #204

## Admin: estimates stream + per-day chart

**PR:** #205

**Why:** Jake never uses the dashboard because it isn't plugged into the live product. The core want: one scrollable newest-to-oldest feed of every estimate (funnel + estimator) and every cockpit quote across all customers, with a total-per-day chart and click-through to view any estimate.

**Size:** 1 day.

## Admin: full activity log

**PR:** #207

**Why:** "So-and-so logged in", "so-and-so's homeowner completed an estimate (view it)", "so-and-so updated pricing", "so-and-so sent a quote" - one stream of every event plus activity charts, so Jake can see whether the software is getting used more without asking anyone. Keeps the existing customer-activity charts.

**Size:** 1-2 days (needs an events table + instrumentation in the RPC/functions layer).

## Admin UI cleanup

**PR:** #208 (this one)

**Why:** Split-tests section is never used and not wired right; the software map isn't usable. Rip both out and reorganize the page around the stream, activity, and per-customer drill-ins Jake actually uses.

**Size:** half-day.

---

## 🎯 Up next

<!-- top 1–3 items queued for the next sprint -->

## Install-blocker audit for the 7 at-risk paying customers

**Why:** Retention is a single-variable problem. Of ~26 accounts that
ever paid, every one that crossed 5 leads is still here (11 of 11, 0%
churn); of the 15 that stayed at or below 5 leads, 7 left (47%). Seven
current payers are still below that line, four of them with zero leads
and zero activity ever recorded. Phillips has paid since 2026-05-05
and has never logged in. That is roughly $1,000/mo of revenue sitting
on a coin flip, and it is worth more than 125 more freemium signups.

**The seven:** phillips-fencing-storage (0 leads, no activity, paying
since 5/5), 1-stop-fence-inc (0, none, 5/30),
morales-fence-installation-repair (0, none, 6/11), juan / Vargas
Brothers (0, none, 7/16), exclusive-fence-and-repairs (2, 4/29, 5/5),
acr-fence-llc (2, 7/24, 6/12), northeast-fence-llc (5, 7/22, 7/21).

**Sketch:** for each, determine what is actually blocking an install
rather than guessing. Does the account have a website or Facebook
page on file (currently we hold neither for anyone); is `request_mode`
set so `/e/<slug>` renders at all; does the config return non-NULL
from `get_customer_config`; is there a logo/branding; has the owner
ever signed in. Output is seven concrete install jobs with a named
blocker each, not seven names. Feeds the install-appointment motion.

**Size:** ~2 hours to audit and write up; installs are separate.

---

## Churned win-back email: "your account is now free"

**Why:** Every churned paid customer is now automatically downgraded to the freemium tier (Quote Builder on, request-mode homeowner link on, their own pricing kept) with `lifecycle_eligible=false` so nothing emails them yet. The 27-tenant backfill ran 2026-07-21. The whole point is telling them "your old account came back as a free account" once the freemium experience is polished; drafted win-back copy already exists in the launch-comms session (2026-07-13, booking link included). Jake's call on timing: not now, once everything is nice.

**Sketch:** Approve copy in chat (per the strategize-first rule), send as a manual one-off via Resend to the churned-was-paid cohort (`plan='freemium' AND churned_at IS NOT NULL`, skip Resend bounces), and flip `branding_json.lifecycle_eligible` to true per tenant as they're emailed so nudges and weekly recaps take over. No drip enrollment.

**Size:** Half a day (copy approval is the long pole).

## Per-section (per-piece) pricing as a self-serve choice

**Why:** A real chunk of fence contractors bid by the piece/section, not per linear foot. Nick Harris (Top Notch Fence Co, 2026-07-21) is the live case: he loves the drawing, is lukewarm on the rest, and said flat out "I've got to have a per foot price, I stick per piece price all my jobs." The `pricing_mode: "per_section"` + `section_size_ft` capability ALREADY exists and does exactly this (bills whole sections, shows homeowners a section count), but only Jake can turn it on with a SQL patch, so every per-piece contractor sees per-foot defaults, decides the tool prices wrong, and quietly churns. Invisible capability = same as no capability.

**Sketch:** (1) ask the question during `/free-tool` signup and in `customer-setup.html` ("do you price by the foot or by the section?"), storing the answer straight into `pricing_json`; (2) add a self-serve toggle + section width + price-per-section fields to `/my/updates`, converting to the per-foot rate the engine stores; (3) make the Products & options page in the cockpit show whichever unit the tenant chose so the numbers read like their own quotes. No engine change, the math already works (`funnel-app.js` `computeQuotes` billLf, `estimate-page.html` `sectionInfo()`).

**Size:** 1-2 days.

## Admin: traffic + UTM intel per customer

**Why:** Jake wants to see which estimator pages are getting unusual traffic, each lead's UTM source/referrer, and to spot which customers are running Facebook ads (vs the ones NEM ran ads for). Richer per-customer data behind the existing click-through cards.

**Sketch:** stamp utm_*/referrer into estimates.attribution on every funnel submit (partially exists), aggregate per slug per day, flag spikes; surface a per-customer traffic panel on /admin.

**Size:** 1-2 days.

## Homepage overhaul + premium brand refresh

**Why:** The homepage doesn't reflect the current business: free cockpit lead magnet, no more 1-week trial, custom websites and custom builds, "boutique online tool that separates fence companies from the competition, sell on the spot." Current aesthetic reads bland/contractor-y (grid paper look); Jake wants a premium, sexy, homeowner-grade look. Use Claude design tooling; evolve from the current brand, don't keep the drafting motif on public pages.

**Size:** 2-3 days including design iteration with Jake.

## Survey upload: "measure it for me" phone option

**Why:** Clarity session on Dynamic Fence shows a user visibly frustrated trying to upload/scale a survey. Offer an escape hatch: upload it and choose "have us measure it with you over the phone" instead of self-serve scaling.

**Sketch:** option on the survey step; posts the upload + phone intent to the lead payload/GHL so the contractor calls them. Capability-flagged per tenant if needed.

**Size:** 1 day.

## Multi-fence-style options in estimator

**Why:** Jake's domain expertise from years in fence sales — homeowners
convert better when shown 2-3 fence-style options side-by-side
(comparison shopping) vs. a single recommendation. Currently the
estimator only supports a single `selectedFence` string; this is about
price-shopping ("show me wood vs. aluminum vs. chain link"), not about
modeling real mixed-style yards.

**Sketch:**
- Tier 1 (preferred): multi-select up to 3 fence types, auto-split the
  total footage evenly, render a per-type row in the estimate output.
  ~4 to 6 hours.
- Tier 2: per-type ft allocation via sliders/inputs. ~1 to 2 days.
- Tier 3: per-section assignment with a color-coded canvas. ~2 to 3 days.
- Per Jake's clarification, lean Tier 1 — the goal is "give people
  options to choose from," not accurately model mixed fences.
- Touches: `selectedFence` (single string) → `selectedFences` (array
  of `{key, ft}`), `selectFence()` and tile click handlers (toggle
  in/out instead of swap), `generateEstimate()` and pricing math (loop
  over selected), estimate output display (per-type breakdown), lead
  form / GHL webhook payload (array of types).
- Per-customer config flag possible: only multi-product accounts get
  multi-select; 2-product accounts keep single-pick UI.

**Size:** Tier 1 = 4–6 hours; Tier 3 = 2–3 days.

---

## 📋 Wishlist

## Model "paused" as a real customer status

**Why:** A manually paused Stripe subscription is invisible to the app. AAGS Solutions (`aags-solutions-llc`) was paused by Jake around June 2026 and still reads `status='active'`, `plan='pro'`, with `payment_failed_at`, `grace_period_ends_at`, and `churned_at` all NULL. Nothing in `public.customers` distinguishes "paying" from "paused," so paused accounts are counted as active everywhere: the admin dashboard, any MRR total computed off that table, and the health/at-risk views. They are also still eligible for lifecycle and drip email as active customers, which means a paused account can keep receiving "here's what's new on your estimator" mail while paying nothing. MRR reported from the DB is overstated by however many rows are in this state, and Jake is currently reconciling MRR by hand as a result.

**Sketch:**
- Add a `paused_at timestamptz` column (NULL = not paused), rather than overloading `status`, so existing `active`/`churned` logic keeps working unchanged.
- Decide the intended semantics: does a paused account still serve `/e/<slug>`? Today it does. Probably yes (do not break a live estimator link for a customer who plans to come back), but state it explicitly.
- Have the Stripe webhook set/clear `paused_at` on `customer.subscription.paused` / `.resumed` so it stops depending on Jake remembering. Confirm whether pause is done via Stripe's pause_collection or by cancelling; the handler differs.
- Exclude `paused_at IS NOT NULL` from MRR counts, the at-risk/health views, and the four lifecycle senders (same guard pattern as the shared test-tenant filter in `netlify/functions/_lib/test-tenant.js`).
- One-time audit: how many other rows are paused-but-active right now? That number is the actual MRR correction.

**Size:** ~2 to 3 hours including the webhook handler and the sender guards. The audit query alone is 10 minutes and worth doing first.

---

## Phone quoting pass 2: packages built on the drawing, obvious gates, honest default margins

**Why:** Jake's real-phone test of the step flow (2026-07-20). Three findings. (1) Packages: adding one should take you back to the map, because in the field a second package is usually the SAME drawn fence varied: skip the back line, swap the back line to black chain link, upgrade the gates to arched, add stain, or wood vs vinyl on the same layout. The per-run remap machinery already does this but is buried and unclear; the tabs read as labels. Consider a prompt ("Want to offer a second option?") and copy that says both options quote the same drawing. (2) Gates: no visible way to add one in step mode; needs an obvious Add gate button on the Draw screen with coached placement (tap the run, slide to position, set width and price). (3) Pricing honesty: preload every default material with a cost breakdown from standard assumptions (30% margin; of the cost, 40% material and 60% labor) so the internal panel shows real profit per job from day one; seeing the margin is what will push people to customize their pricing. Also: the component list for a material must be editable (add/remove components) because chain link is tie-wires and tension bars, not post/rail/picket, and wood may add post caps or stain.

**Sketch:** three chunks that can ship separately. A) Options screen package flow: add-package prompt, return-to-Draw with a per-package remap UI that reads as "change the fence for this option" (per-run material swap, exclude-a-run, gate upgrade). One drawing per quote stays the law; packages vary products, never geometry. B) Draw screen Add gate: button on the runs sheet, coach lines for tap-run then slide, inline width and price. C) Pricing defaults: derived cost_breakdown for the 6 default catalog products from the 30/40 assumptions (internal-only; nothing anon-readable), plus add/remove component rows in the products editor. Needs its own mega-plan before building; A and B are phone step-mode work, C touches the engine and catalog seeds.

**Size:** L (a week-class wave; mega-plan first).

## Customer MCP server for premium tier

**Why:** Jake's direction (repeated 2026-07-19): premium ($397 tier) customers get an MCP server so they can talk to Claude or ChatGPT to manage their account: add products and photos, pull lead data, polish quotes. The white-glove setup call is the demo. No in-product chat assistant; MCP-only.

**Size:** XL (needs auth model per tenant, scoped tools, and a mega-plan).

## Cockpit: carry a satellite/property-line drawing onto the grid

**Why:** Asked on the 2026-07-17 Beaches Fence call: after measuring on satellite or property-line view, reps sometimes want the same shape on grid scratch paper to tidy it up for the homeowner. Today the answer is redraw it by hand (fast, but a repeated ask would justify a real transfer).

**Size:** 1-2 days (projection from lat/lng or plan pixels onto grid squares plus scale choice).

## Record demo Loom for AI SMS sales agent

**Why:** The AI SMS sales agent (Make-based v3 architecture, in build as of 2026-05-28) was originally planned to drop a Loom video link into outbound SMS conversations as a soft-touch demo asset alongside the booking link and prescription URL. Deferred from initial launch — Jake's not recording a Loom pre-go-live. Without the video the agent flow leans harder on the prescription URL + booking link, which is workable but loses a conversion path for SMS recipients who'd rather watch than book or click through. Worth adding back once the agent is live and we can measure whether SMS replies asking for "show me" make it worth the production effort.

**Sketch:**
- Record a 2 to 4 minute Loom showing: homeowner lands on `/e/<slug>`, draws a fence on a property, sees a real price, contractor receives the lead. Lean concrete (real customer site), not abstract.
- Host the Loom on Jake's Loom account; capture the public share URL.
- Add `loom_link` to the Make scenario's link bank alongside `prescription_url` and `booking_link`.
- Update SMS templates that currently text-only-fallback to insert the video link in 1-2 high-fit moments (e.g., after the recipient asks "what does it look like" or after a booking decline).
- Consider a fallback: if the Loom 404s in 6 months, the agent should degrade gracefully to text + booking link.

**Size:** 1 hour to record + edit + host. Maybe 30 minutes on the Make-scenario side to thread it into the templates.

## Auto-detect survey scale on upload

**Why:** Today when a homeowner uploads a survey to measure, they have to manually mark two points and type the real-world distance between them. Friction step that confuses non-technical users and is easy to do wrong (mark the wrong segment, mistype the distance, leading to a silently wrong estimate). Most surveys already have the scale on them somewhere — printed ratio ("1" = 30'"), a scale bar graphic, or dimensional callouts on property lines.

**Sketch:**
- On survey upload, send the image to a vision model (Claude vision) with a structured prompt: "Find the scale bar, ratio statement, or any dimension label. Return pixel coordinates of the segment endpoints and the real-world distance it represents." JSON out.
- Compute pixels-per-foot from that, auto-fill the scale fields.
- Confirmation UX is non-negotiable: overlay the detected segment on the image with a "We measured this section at X ft, looks right?" one-click confirm. Wrong silent scale = whole estimate off by a fixed multiplier.
- Fallback to existing manual flow if confidence is low or no scale found.
- Edge case: phone photos of paper surveys at an angle have perspective distortion that breaks pixel-based scaling. Best accuracy on PDFs / flat scans.

**Size:** 2 to 3 days for MVP that pre-fills correctly on ~70-80% of clean surveys.

## Per-product per-width gate price overrides + editor

**Why:** Today (PR #75 + linear-scaling follow-up) multi-width gate prices are derived at runtime by scaling each product's anchor price linearly per foot. That covers the typical case where 5 ft costs 25% more than 4 ft. Customers will eventually want explicit per-cell overrides — manufacturer step pricing, specialty hardware tiers, or non-linear breaks (e.g., 10 ft drive jumps disproportionately because of a wider track kit). Right now there's no way for a contractor to say "5 ft black vinyl-coated chain link is $400, not the $465 the formula gives."

**Sketch:**
- Schema: add `gates_by_width` on each product (mirror of `gates_by_height`, keyed by width → `{ "4": {single, double}, "5": {single, double}, ... }`).
- Resolver: new Step 0 in `gatePriceFor` checks `prod.gates_by_width[width][type]` before falling through to scaled `gates_by_height`. Existing scaling stays as the fallback.
- Editor: extend the per-product "Custom gate pricing" disclosure in `NEMProductsEditor` with a second table keyed by width (rows = enabled widths, cols = single/double). Blank cell = use scaled default.
- Customer-setup wizard: defer (most customers will land via the runtime-scaling default and only need overrides later via /my/updates).

**Size:** 1 day.

## Per-customer pixel trackers in estimator header

**Why:** Customers running their own paid ads need their FB Pixel / GA4 / GTM / TikTok Pixel firing on the homeowner-facing estimator pages so their attribution works. Today we only fire NEM's tracking; customer ads are flying blind on conversions from `/e/<slug>`.

**Sketch:**
- Per-customer config columns (or `pricing_json` extension): `fb_pixel_id`, `ga4_measurement_id`, `gtm_container_id`, `tiktok_pixel_id`. Whitelisted so customers paste only the ID, not arbitrary `<script>` — eliminates XSS risk.
- Template injects each vendor's standard snippet into `estimate.html` at runtime when the corresponding ID is set.
- Surface a "Tracking" tab on /my/updates with one input per pixel, plus a docs link to "where do I find my Pixel ID?" for each vendor.
- Fire standard events: PageView on load, Lead on estimate submit (with lead value if available).

**Size:** 1 day. Mostly the per-vendor snippet research + the /my/updates UI.

## Per-customer Microsoft Clarity drill-down on /admin

**Why:** Right now Clarity gives us aggregate behavior across all customers. To diagnose churn or onboarding friction per customer, we need to filter Clarity by their `/e/<slug>` or `/my/updates` traffic in one click from /admin.

**Sketch:**
- Confirm Clarity's filtered-dashboard URL syntax (path filter + time range). Likely a deep-link like `https://clarity.microsoft.com/projects/view/<project-id>/dashboard?date=Last%2030%20days&Path=%2Fe%2F<slug>`.
- Add a "View in Clarity" button to each customer row / detail panel on /admin and to the per-customer activity view on /admin-dashboard (already shipped).
- Two buttons per customer: homeowner traffic (`/e/<slug>`) and customer-side traffic (`/my/updates` filtered by user).
- No API integration needed — pure deep-link.

**Size:** 2 hours. Mostly verifying the URL filter syntax works.

## Compose-and-send customer emails from /admin

**Why:** Manual customer outreach today means opening Gmail, composing from scratch, finding the customer's email, and forgetting which email was sent when. A compose pane on /admin scoped to one customer speeds up support / save-flow outreach and creates an audit trail.

**Sketch:**
- "Email customer" button on each customer row / detail panel on /admin.
- Compose UI: subject + body, From `jake@nextestimateagency.com`, plain text or simple rich text. Templates dropdown (e.g., "follow-up after first login", "post-trial check-in").
- POST to a new Netlify function `admin-send-email.js` that calls Resend with the customer's email as recipient and writes a row to a new `customer_emails_sent` audit table (customer_id, subject, body, sent_at, sent_by).
- Surface "Last contacted: N days ago" on the customer row as a derived field from this table.
- Follow-up: reply tracking via Resend webhooks → thread on /admin.

**Size:** half-day for v1 (compose + send + audit row). Reply tracking is a follow-up.

## Drop the post-setup scheduling-call step

**Why:** Post-setup pages and emails currently ask the customer to
schedule a call with Jake. That made sense when Jake had to manually
configure their estimator. Wave 5's self-serve editor removes that
need; the only manual step that remains is the GHL webhook setup,
which Jake can do without a sync call.

**Sketch:**
- Audit `thank-you-fence.html`, post-customer-setup pages, and the
  onboarding email sequence for "schedule a call" copy and Calendly
  links. Replace with a self-serve walkthrough video or written
  checklist.
- For GHL: confirm Jake gets an internal alert when a new customer
  needs the webhook configured. If not, add one.

**Size:** 2 to 3 hours, mostly content.

## Re-evaluate pre-filled defaults on setup and /my/updates

**Why:** Both `customer-setup.html` and `/my/updates` ship with
defaults inherited from the seed template. Some are appropriate
(industry-standard warranty badges); some may be misleading or
encourage customers to keep generic copy. Worth a deliberate audit
on what should be empty vs. pre-filled vs. example-as-placeholder.

**Sketch:**
- Walk every form field. Categorize each default as:
  (a) safe pre-fill,
  (b) placeholder hint not pre-filled,
  (c) actively misleading - replace with empty or smart default.
- Some defaults should become smart-defaulted from the customer's
  website (overlaps with Auto-pull branding from URL below).

**Size:** half-day.

## Buyer's-remorse / churn-reduction research and implementation

**Why:** Open question on what specifically to do, but the goal is
to reduce post-purchase regret in the 0-30 day window and lock in
early engagement. The drip-email work shipped (days 1-6) addresses
part of this; the remaining piece is a deliberate research pass on
retention literature to identify the next 3-5 changes to ship.

**Sketch:**
- Research pass on SaaS retention literature for the 0-30-day window.
  Common patterns: explicit "you're now a member" reinforcement
  emails, quick-win activation tasks, money-back-guarantee
  positioning, founder-personal-touch outreach.
- Output: a short concrete plan of 3-5 specific changes to ship.
- Likely changes touch the post-Stripe-checkout flow, the first-week
  email cadence (extending the existing drip), and possibly a "your
  first 7 days" checklist on /my/updates that nudges activation.

**Size:** half-day research, then variable implementation.

## Auto-pull branding from a customer's website URL

**Why:** Jake flagged this as "needed soon" on 2026-04-25. High-leverage
for trial conversion: customer pastes their site URL and the form
prefills logo + brand color, dropping setup time from ~10 minutes to
under 2.

**Sketch:**
- Customer pastes their website URL on the setup form (already
  collected as `website_url` on `customer-setup.html`).
- Netlify function fetches the URL server-side and parses:
  - logo: `<meta property="og:image">`, then `<link rel="icon">`,
    then the first `<img>` near the header
  - brand color: dominant non-grey color from the logo (k-means or
    similar), with a fallback to the first hex value in inline
    style attributes near the header
- Returns JSON to the form, which prefills `logo_url` and
  `brand_color`. Customer can override either before submit.
- The same endpoint can also populate the /my/updates branding tab
  (Wave 5 self-edit, shipped) when an existing customer hits an
  "auto-fill from my website" button later.

**Size:** 1 day.

## Service area polygon → competitor research email

**Why:** Bonus value during the trial. Customer draws their service
area, a background job runs deep research on competitors in that area,
and they get an email summarizing what they're up against.

**Sketch:**
- When the polygon save RPC succeeds (Wave 3 Phase A path), enqueue
  a job. Storage option: a `competitor_research_jobs` table with a
  `status` enum, processed by a scheduled Netlify function.
- Job payload: polygon centroid + bounding radius, customer email,
  business name, slug.
- Worker calls Maps Places API for fence contractors in the area,
  optionally augments with web search.
- LLM (Claude API) summarizes top 3-5 competitors: pricing signals,
  review themes, service gaps the customer could exploit.
- Resend email to the customer with the summary, signed off by Jake.

**Size:** 2 to 3 days, mostly the LLM/research integration.

## GoHighLevel two-way integration

**Why:** The long-term platform play: view a lead in GHL from the cockpit and vice versa, trigger GHL automations on quote events (quote sent → pipeline move), everything plugged into GHL.

**Sketch:**
- Store `ghl_contact_id` on leads/estimates; outbound webhooks on quote lifecycle events (created, sent, viewed, accepted).
- "Open in GHL" deep link in cockpit; inbound link from GHL custom field back to the cockpit quote.
- Per-customer GHL credentials/config in the customer row; capability-flagged.

**Size:** Epic; phase it (contact-id sync → outbound events → deep links). Needs its own plan.

---

## 💤 Parked

## iOS app for the Quote Builder

**Why:** Jake's big-swing idea (2026-07-19). The web app is already installable (S3 manifest and A2HS), which covers the home-screen icon and full-screen feel; a native app would add push notifications (quote viewed, new lead), camera-native photo flows, and App Store presence. Parked until the web activation numbers prove the funnel, since native doubles every UI change. The step-by-step phone mode in the Wishlist is the prerequisite either way.

**Size:** XXL (separate codebase or wrapper strategy decision first).

---

## 🚀 Shipped (last 90 days)

## Quote Builder phone mode: step-by-step screens

On phone widths (720px and under) the builder is now four step screens, the way Jake quoted at Superior Fence and Rail: 1 Draw (full-screen map with the reticle, coach, compact mode menu, and a collapsible runs-and-gates sheet), 2 Homeowner details, 3 Packages and options (advanced controls behind More options, media and notes as collapsed sections), 4 Review and send (homeowner-facing summary, live blocker checklist with tap-backs, preview, send). Every account gets it on a phone with a per-user Full view escape hatch in the new overflow menu; desktop is byte-identical, proven each session with DOM-geometry diffs. The tour never runs in step mode; the steps plus per-screen helper lines are the onboarding, and Show me around starts a practice quote instead. Verified twice end to end on disposable live tenants (lead to sent quote to homeowner page, then retired). Fixtures gained a one-click force toggle so any viewport can sandbox step mode.

**Size:** L (six dispatch sessions).
**PR:** #261, #262, #263, #264, #265, #266

## Cockpit height picker (per-height pricing made reachable)

Multi-height products grow height toggle pills on the material palette and one option per height in every material select (seg table, Inspect retag, manual rows), so a rep can finally arm an 8 ft brush and quote off `prices_by_height['8']` + `costs_by_height['8']` instead of the default height. One height per product per quote (switching re-embeds and reprices every run of that product, with a warn when several follow); single-height products and pre-picker drafts are proven byte-identical. No engine, projection, or send changes. Completes the Beaches Fence per-height wave (#240, #241).

**Size:** Half a day.
**PR:** #242

## Cockpit: product costs that follow fence height

Products gained an optional cockpit-only `costs_by_height` map (sibling of `cost_breakdown`, same per-section shape per height; `panel_width_ft` stays shared), resolved everywhere through one new engine helper: the entry at the material's height, else the flat breakdown (byte-identical for every existing customer), else "Margin unknown". The products editor gives each offered height its own row (price low/high into `prices_by_height` plus a per-height cost with a live margin hint), and the quote-local override modal pre-fills the cost at the material's current height. Grep-proven to never reach a projection, send payload, or homeowner page. From the 2026-07-17 Beaches Fence call: 8 ft jobs were silently margined off 6 ft costs. 215/215 engine tests plus two offline sandboxes. Follow-ups spun out: a cockpit height picker (reps currently embed at the default height only) and stripping cost fields from the anon `get_customer_config` RPC (pre-existing leak).

**Size:** 2-3 days including the design pass.
**PR:** #240

## Print-quality quote PDF + crew sheet with no financials

The shared estimate PDF got a full print pass (branded letterhead with logo chip, prepared-for panel, job-facts line, warranty bullets, review-proof line, numbered next steps, branded footer with page numbers) so a contractor can hand it to a homeowner at the door — built for Beaches Fence's neighborhood pre-quoting plan from the 2026-07-17 call. A `?crew=1` variant (cockpit "Crew PDF" button in the send modal + a post-send link) builds the same document with every dollar figure stripped and a per-material/per-side "Job specs" section for the install crew; it is a strict subset of the homeowner document, so the discoverable URL param only ever removes information. Verified by a 6-config jsPDF-intercept QA matrix (plain, hide_cash, funnel, each in both variants): zero dollar signs in every crew text op, and rent-to-own `hide_cash` proven intact.

**Size:** 1-2 days.
**PR:** #239

## Cockpit: delete a fence run, delete a mid-run corner, readable text notes

Reps can finally fix a drawing without starting over: select-tap a run for a "Delete run" action (gates on it go with a warn, gates elsewhere keep their exact offsets), and tap a mid-run corner dot to fuse its two legs into one straight line (the "I let off mid-draw and got 15 + 90 instead of one 105 ft side" case from the 2026-07-17 Beaches Fence call). Both rebuild geometry from a legs list so materials, grid overrides, breaks, and gate anchors stay coherent in all three point modes. Also fixed the text-note editor showing near-white text on a white input since the dark reskin: the input now pins its own dark background. Verified by 301 sandbox checks (57 new) with zero failures.

**Size:** 1 day.
**PR:** #238

## Plan-aware /my/updates for cockpit-only tenants

**Why:** Free cockpit-only tenants (plan freemium, no homeowner funnel)
now have products and job options preloaded, but the only place to edit
fence types is /my/updates, which shows homeowner-funnel panes that do
not apply to them (share card, leads, service area) and buries the
product editor. This becomes the simple edit/replace/delete surface
Jake wants for contractors, without building a duplicate editor inside
the cockpit.

**Sketch:** detect plan + missing funnel config on load; cockpit-only
tenants see products, job options, identity, and account panes only.
Also surfaces the existing self-serve editors for Why-us and reviews
(flags from PR #187) when relevant.

**Size:** 0.5 to 1 day, front-end only.
**PR:** #195

<!-- newest first; prune items older than 90 days -->

## Media uploads: QR phone-to-cockpit + homeowner job-site photos/videos

Two upload flows on one foundation: the cockpit Photos card shows a QR that opens a camera-first phone upload page scoped to the open quote (arrivals appear in the cockpit within seconds, internal by default), and the homeowner estimate page gains a "Show us where the fence goes" section for photos or a walkthrough video, which land in a cockpit "Homeowner media" panel. Short-lived HMAC tokens, direct-to-storage signed uploads (100MB videos fine), new estimate_media table locked to the service role. Migration applied and the estimate-media bucket created.

**Size:** 2 to 3 days.
**PR:** #182

## Funnel attribution: UTM capture + "interested in" options step

First-touch utm_* params, referrer, and landing page are captured at funnel load, survive refreshes and later ad clicks, ride the lead into a new estimates.attribution column, show as a "Source:" line in the cockpit lead view, and pass to the GHL webhook. The "interested in" upsell step (arched gate, post caps, stain, lights and more, plus Other free text) ships behind the branding_json.interested_in capability flag, default off, registered in CAPABILITIES.md; flag-off funnels are proven unchanged. Migration applied.

**Size:** 1 to 2 days.
**PR:** #181

## Estimate page: photo lightbox, per-package photos, website link, natural photo colors

Tap any job photo for a full-screen swipeable lightbox (reduced-motion safe). Photos can be scoped to specific packages from the cockpit ("Show on..." checkboxes; default stays all packages). The header company name/logo links to the customer's site when branding_json.website_url is set. And the photo brightness bug is fixed: the page no longer desaturates unselected package photos, and the upload resize pipeline is color-managed so iPhone wide-gamut photos keep their colors. Projection migration applied.

**Size:** 2 days combined.
**PR:** #180, #179

## Cockpit annotation rotation (V2-19)

Boxes and icons (dog, tree, bush, good side) can now be rotated in 15-degree steps from the selection chip; boxes rotate about their center with hit-testing, shaded fill, and resize handles following. Rotation rides the full identity pipeline: live canvas, baked snapshot, engine overlays whitelist, projection SQL, estimate-page vector renderer, and the PDF paint. Rotation-free docs proven byte-identical at every stage (PGlite projection proof, snapshot byte A/B, jsPDF op-stream). Text labels do not rotate in v1; lines and arrows are already any-angle. Projection migration 20260710130000 applied to prod with a clean before/after md5 check.

**Size:** 1 to 2 days.
**PR:** #168

## Cockpit line-item control: exact price, hide from homeowner, internal notes

Range-priced materials take an exact quote-only price (embedded-copy override with a $/ft vs $/panel unit selector and an optional material+labor+margin calculator). A per-line "hide from homeowner" toggle keeps an item's dollars in the package total while stripping it from the homeowner page, PDF, and send preview (projection migration 20260710120000, byte-identity proven and applied to prod with a clean md5 check). Per-line internal notes hold supplier links, rep-only, proven never to project.

**Size:** 1 day.
**PR:** #166

## Cockpit gate UX: visual swing picker + Inspect gate slider

Gate swing is picked from picture thumbnails (generated from the same glyph geometry the canvas paints, so the thumbnail and the drawing can never disagree) instead of dropdown words; single gates model left/right hinge x in/out, doubles model in/out. Inspect mode: tapping a run opens a panel with the run's specs, a fence-type retag, and "Add a gate"; the gate gets a left-right position slider driving the existing `offset_ft` through the same clamp/fit path as the "at N ft" input, live glyph re-render, and the swing picker inline. Verified in the one-click no-auth canvas sandbox (69-check union suite).

**Size:** 1 to 2 days.
**PR:** #167

## Cockpit drawing quick wins: grid scales, address pin, brush = new run, fullscreen, Street View

Five small drawing-surface upgrades in one pass: grid mode gains 25/50/100 ft-per-square scale chips (`canvas.meta.grid.fps`, engine/projection-inert; typed length overrides survive a scale change); the geocoded address drops a standard map pin that clears when the first fence corner lands; switching the material brush mid-draw finalizes the current run and arms a new one instead of retagging; a fullscreen toggle fixes the Measure card over the whole viewport (Esc exits, map/canvas resize correctly); and a Street View button opens the property in a new tab. Verified via a new one-click no-auth canvas sandbox fixture (38 checks) plus 147/147 `node --test`.

**Size:** Half a day each or less.
**PR:** #165

## Internal quoting cockpit (rep-operated estimator, multi-tenant)

A rep-operated estimator at `/internal/quote` where a tenant's inside-sales staff builds a fence quote on a customer's behalf: mixed-material drawing (satellite / survey / grid scratch-paper / manual), multiple named packages built on one drawing (per-package material remap), catalog + ad-hoc line items with a per-item show-price toggle, a new-material creator with a cost-per-section calculator (`$160/8 ft/30% = $28.57/ft`) and an SVG cost pie, and a rep-only internal panel showing true cost, margin, commission, and cash totals live from the shared engine. Draft-until-sent lifecycle with crash-proof autosave (2 s debounce + tab-hide flush, optimistic-concurrency conflict banner). Send is a JWT-authenticated Netlify function (`send-quote.js`) that validates the quote server-side, uploads the drawing snapshot only at send, flips the row to `status='sent' source='cockpit'`, fires the tenant GHL webhook (carrying the true cash total for the CRM), and emails the homeowner a premium `/estimate/<uuid>` page, all idempotent against double-clicks. The pricing math is extracted into one shared `quote-engine.js` (the funnel now delegates to it, proven byte-identical to recorded baselines). Multi-tenant from day one behind the default-off `pricing_json.internal_cockpit` flag; Holden Fence is tenant #1, with `rent_to_own.hide_cash` stripping every cash figure from the customer page, PDF, email, and RPC payload so homeowners see down payment + bi-weekly only. Built + verified end-to-end against a Supabase dev branch under `netlify dev`: full access-probe matrix, the send + revision lifecycle, an XSS gauntlet across the rep UI, customer page, and PDF, a 150-segment stress test, and visual-qa at 375/1280 with zero blocking defects.

**PR:** #133

## Quiz funnel: GHL integration + resume URL + paid guard

`/funnel` quiz-to-prescription flow is now wired end-to-end into Jake's CRM. Stripe paid handler hardened against duplicate fires (idempotency guard on `maybeMarkFunnelLeadPaid`); GHL dispatch refactored from one branched URL to three event-specific URLs (`FUNNEL_GHL_WEBHOOK_LEAD/PAID/ABANDONED`) since GHL inbound webhooks on Jake's plan are 1:1 with workflows; quiz answers ship as flat `q1..q7` top-level fields for direct GHL Update-Contact mapping; new public `get-funnel-lead` GET endpoint + `?r=<contact_id>` hydration in `funnel.html` lets the AI appointment setter drop personalized resume links into outbound SMS/email without orphaning the original contact_id. End-to-end smoke-tested against production: LEAD POST + resume URL + direct-fire PAID/ABANDONED webhooks + Supabase status transitions all green.

**PR:** #85

## apply_customer_setup merges instead of replaces branding/pricing

The `apply_customer_setup` RPC now shallow-merges `branding_json` and `pricing_json` (`existing || incoming`) instead of full-replacing them, mirroring the pattern already used by `update_my_customer_data`. Closes the latent clobber bug where every field outside the slim 5-stage wizard schema (gate_widths_*, gate_label_*, company_phones, financing_url, products list, etc.) would be wiped on resubmit. Verified end-to-end against AAGS: all 21 branding keys + 6 pricing keys survived a sentinel wizard call, sentinels overrode existing values, then restored the originals via a second RPC call.

**PR:** #76

## Multi-width gate offerings (contractor enable + homeowner pick)

Contractors can now enable any combination of single + double gate widths via `branding_json.gate_widths_single / _double` (e.g. `[3, 4, 6]` singles, `[6, 8, 10]` doubles). When 2+ widths are enabled, tapping the type button reveals a chip row of widths; homeowner picks one to enter placement. Single-width customers (every legacy customer + AAGS today) see no UI change — the button still reads "+ Single gate (4 ft)" and skips the chip step. Lead payload ships a per-width `widths` array so GHL workflows can see exactly which sizes the homeowner picked. Per-width prices via `gate_prices_single / _double` override the flat `gate_cost / gate_double` defaults. `gate_label_single / _double` from PR #66 stays in place; deferred to a separate cleanup PR once AAGS data migrates to the new fields.

**PR:** #75

## Product photo + spec bullets on lead PDF

Selected fence type's photo and on-page spec bullets now render on a dedicated page 2 of both the homeowner-download and lead-email PDFs, so the contractor reads specs and sees the actual fence alongside the estimate. Triggered by AAGS feedback ("the estimate you sent did not include the image").

**PR:** #64

## Split-test infra + /admin-split-tests dashboard

Variant-tracking RPC + admin scoreboard so /lp/v3 changes ship as A/B tests instead of direct edits to the canonical file.

**PR:** #37

## Estimate map: commit polyline before entering gate mode

Bug fix where the draw polyline would not persist when transitioning into gate-placement mode.

**PR:** #33

## Reddit Pixel + Conversions API

Server + client tracking for paid Reddit traffic, matched to Stripe events.

**PR:** #32

## /lp/v3 demo walkthrough on first scenario pick

4-step interactive walkthrough fires the first time a homeowner picks a scenario, then never again.

**PR:** #31

## /lp/v3 trial modal + dual-webhook submit

Trial CTAs now route through a lead modal that fires both the lead webhook and an InitiateCheckout event.

**PR:** #27

## /lp/v3 book-a-call flow

One-piece-per-card capture (first name → email → phone) using Typeform best practices; pinned progress bar.

**PR:** #26

## Field Manual visual overhaul (Phase 1) on the estimator

Typography, contrast, and tap-target refresh across estimate.html.

**PR:** #24

## /lp/v2 drawing UX overhaul

Tap-coach finger animation, modal clip fix, touch-only overlay corrections, dead-click cleanup in the property picker.

**PR:** #22

## /lp/v2 cold-traffic landing page

WebP photos, mobile-first layout, real-mode photos, pain-card fix; deployed at /lp/v2.

**PR:** #17

## Customer journey overhaul: trial-aware thank-you + dashboard tour + lead self-hide

Trial-state-aware thank-you pages, an in-product dashboard tour, and a way for customers to hide test/junk leads from /my/updates.

**PR:** #14

## Field Manual visual refresh on customer-setup + /my/updates

Visual upgrade aligning customer-facing dashboards with the new design system.

**PR:** #11

## Auto-resize customer-uploaded images on upload

Client-side canvas resize on every customer-upload site (branding logo, per-product photos in both customer-setup and my-updates), with a defensive shim if the resize script fails to load.

**Commit:** e77f7b6

## Estimate PDF generation + lead-email attachment

Server-side branded PDF for every lead, attached to the customer email; "Download PDF" button on /my/updates past leads; three real bugs fixed post-rollout.

**Commit:** 77f4a16

## Customer-setup 5-stage wizard

Rebuilt the one-long-form intake into a multi-step wizard with localStorage persistence per stage and tailored instructions per stage.

**Commit:** bd5f254

## Onboarding drip emails (days 1-6 of trial)

Trial-only drip with rebuilt voice + structure per the retention playbook; first-name personalization, yearly-buyer welcome variant, preview-inbox mode for QA.

**Commit:** ed151ca

## Support-ticket lifecycle emails

Acknowledge + resolution emails on /my/updates ticket status changes; resolution note inlined.

**Commit:** d49d735

## Trial end-date column on /admin

Stripe webhook writes `trial_end_at` to customers; visible per-customer column, color-coded if within 3 days.

**Commit:** 49e2e1e

## Admin per-customer activity view

Last-30-day drill-down on /admin: sign-ins, customer edits with editor role, and lead counts per customer.

**Commit:** a51c16b

## Self-serve trial signup with Stripe + Resend (Wave 3 Phase D)

Customer can sign up for a 7-day trial without Jake's involvement; Stripe creates the subscription, Resend sends the welcome.

**Commit:** 74d5cd2
