// RunhappyROI.tsx — Framer code component
// -----------------------------------------------------------------------------
// The Runhappy ROI calculator: three inputs (monthly parties per location,
// average order value, number of locations) + an uplift scenario, computing net
// new revenue with Runhappy. Sibling of RunhappyToken.tsx / RunhappySprinkles.tsx:
// paste it in as a code file, drop it on a frame, done.
//
// ZERO external dependencies and ZERO network at runtime. React and the
// `framer` property-control helpers are built into Framer. Fonts are NOT
// imported here — the component uses the site's Geist (already loaded by the
// Runhappy Framer project) with system fallbacks.
//
// EVERY model variable is a property control: the top-level Mode (net vs
// gross headline), the initial input values, the slider ranges (min/max per
// input — Framer has no range control, so they're separate number inputs),
// the pricing model (base fee, revenue quota, commission %), the per-scenario
// parties/AOV lift percentages, and the optional labor savings. The panel
// defaults match roi-core.js (DEFAULT_MODEL / SCENARIOS) — the unit-tested
// engine in the customer-apps repo. Labor savings defaults to $0; when set,
// it renders as its own disclosed "Labor savings (est.)" row so the headline
// stays auditable from the card (the old embed hid it inside net new revenue).
// In gross mode the headline is the pure revenue delta and the cost/labor
// rows are hidden.
//
// The math block below is a VERBATIM copy of static/roi-core.js (Framer code
// files must be self-contained); src/lib/roi-parity.test.ts fails if the two
// ever diverge. Change roi-core.js first, mirror it here.
//
// HOW TO USE IN FRAMER
//   1. Assets ▸ Code ▸ New Code File ▸ paste this whole file.
//   2. Drag "RunhappyROI" onto the canvas; give it width, height sizes to fit.
//   3. Tune the model in the properties panel — initial values first, then
//      pricing, labor savings, and the scenario lift percentages.
// -----------------------------------------------------------------------------

import * as React from "react"
import { addPropertyControls, ControlType } from "framer"

// ═════════════════════════════════════════════════════════════════════════════
// ══  MATH ENGINE — verbatim copy of static/roi-core.js  ═════════════════════
// ══  (parity-tested; edit roi-core.js first, then mirror here)  ═════════════
// ═════════════════════════════════════════════════════════════════════════════

export const SCENARIOS = {
  conservative: { label: 'Conservative', partiesLift: 0.15, aovLift: 0.15 },
  moderate: { label: 'Moderate', partiesLift: 0.22, aovLift: 0.22 },
  best: { label: 'Best case', partiesLift: 0.3, aovLift: 0.3 },
};

// Shipped defaults are a zero-cost model (site shows gross by default) — the
// pricing knobs exist for instances that want the net story.
export const DEFAULT_MODEL = {
  basePerLocation: 0, // $/mo per location
  revenueQuota: 0, // per-location monthly revenue included in base
  commissionRate: 0, // on per-location revenue above the quota
  laborSavingsPerLocation: 0, // $/mo per location; disclosed row when > 0
};

export function computeRoi({
  parties,
  aov,
  locations,
  partiesLift,
  aovLift,
  basePerLocation = DEFAULT_MODEL.basePerLocation,
  revenueQuota = DEFAULT_MODEL.revenueQuota,
  commissionRate = DEFAULT_MODEL.commissionRate,
  laborSavingsPerLocation = DEFAULT_MODEL.laborSavingsPerLocation,
}) {
  const liftedParties = parties * (1 + partiesLift);
  const liftedAov = aov * (1 + aovLift);
  const todayMonthly = parties * aov * locations;
  const withMonthly = liftedParties * liftedAov * locations;
  const perLocationRevenue = liftedParties * liftedAov;
  const costMonthly =
    locations *
    (basePerLocation + commissionRate * Math.max(0, perLocationRevenue - revenueQuota));
  const laborMonthly = laborSavingsPerLocation * locations;
  const grossMonthly = withMonthly - todayMonthly;
  const netMonthly = withMonthly - todayMonthly - costMonthly + laborMonthly;
  return {
    todayMonthly,
    withMonthly,
    costMonthly,
    laborMonthly,
    grossMonthly,
    netMonthly,
    todayAnnual: todayMonthly * 12,
    withAnnual: withMonthly * 12,
    costAnnual: costMonthly * 12,
    laborAnnual: laborMonthly * 12,
    grossAnnual: grossMonthly * 12,
    netAnnual: netMonthly * 12,
  };
}

// `|| 0` folds Math.round's -0 (e.g. Math.round(-0.4)) back to 0 so near-zero
// negatives format as "+$0", never "-$0".
export function fmtMoney(n) {
  return '$' + (Math.round(n) || 0).toLocaleString('en-US');
}

export function fmtMoneyPlus(n) {
  const r = Math.round(n) || 0;
  if (r < 0) return '-$' + Math.abs(r).toLocaleString('en-US');
  return '+$' + r.toLocaleString('en-US');
}

// Slider semantics for typed values too: clamp into [min, max], snap to the
// step, trim float noise from the division.
export function clampToRange(n, min, max, step) {
  if (n < min) n = min;
  if (n > max) n = max;
  n = Math.round(n / step) * step;
  n = Math.round(n * 1e6) / 1e6;
  return n;
}

// "$1,250" → 1250 · "1.2.3" → 1.2 · "" / "-" / "abc" → null
export function parseInputValue(raw) {
  const cleaned = String(raw).replace(/[^0-9.\-]/g, '');
  if (!cleaned) return null;
  const n = parseFloat(cleaned);
  return Number.isNaN(n) ? null : n;
}

// ═════════════════════════════════════════════════════════════════════════════
// ══  END verbatim copy  ══════════════════════════════════════════════════════
// ═════════════════════════════════════════════════════════════════════════════

// Input definitions — default ranges mirror the original embed; min/max are
// overridable per instance via the property controls.
const FIELD_DEFS = [
    { key: "parties", label: "Monthly parties / location", prefix: "", step: 5, defMin: 10, defMax: 500 },
    { key: "aov", label: "Average order value", prefix: "$", step: 10, defMin: 200, defMax: 2000 },
    { key: "locations", label: "Number of locations", prefix: "", step: 1, defMin: 1, defMax: 100 },
]

const GREEN = "#25C17B"

// Class-scoped stylesheet (ported from the original embed; #id → .class so
// multiple instances can coexist, generic class names are all prefixed so the
// host page's CSS can't collide). No @import — fonts come from the host site.
const CSS = `
.tildei-roi, .tildei-roi *, .tildei-roi *::before, .tildei-roi *::after { box-sizing: border-box; }
.tildei-roi h1, .tildei-roi h2, .tildei-roi h3, .tildei-roi p, .tildei-roi label,
.tildei-roi input, .tildei-roi span, .tildei-roi div { margin: 0; }

.tildei-roi {
  --brand: #EEB600;
  --brand-soft: #FED54E;
  --brand-bg: rgba(254, 213, 78, 0.18);
  --text: #202020;
  --muted: rgba(32, 32, 32, 0.5);
  --border: rgba(32, 32, 32, 0.1);
  --surface: #FFFFFF;
  --font: 'Geist', -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
  --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

  container-type: inline-size;
  container-name: roi-calc;

  font-family: var(--font);
  font-feature-settings: 'ss01' on, 'cv11' on;
  color: var(--text);
  background: var(--surface);
  width: 100%;
  margin: 0 auto;
}

.tildei-roi .roi-grid {
  display: grid;
  grid-template-columns: minmax(280px, 0.85fr) minmax(0, 1.15fr);
  gap: 40px;
  align-items: stretch;
}

.tildei-roi .roi-inputs { display: flex; flex-direction: column; gap: 36px; justify-content: center; min-width: 0; }
.tildei-roi .roi-input { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.tildei-roi .roi-input-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-width: 0; }

.tildei-roi .roi-label {
  font-size: 14px; font-weight: 500; color: var(--text);
  flex: 1 1 auto; min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

.tildei-roi input.roi-value {
  font-family: var(--font-mono);
  font-size: 15px; font-weight: 600; color: var(--brand);
  font-variant-numeric: tabular-nums;
  background: transparent;
  border: 1px solid var(--border);
  border-radius: 6px; padding: 5px 8px; text-align: center;
  width: 80px !important; min-width: 80px !important; max-width: 80px !important;
  flex: 0 0 80px; cursor: text; line-height: 1.2;
  transition: background 0.15s ease, border-color 0.15s ease;
}
.tildei-roi input.roi-value:hover { border-color: var(--brand); }
.tildei-roi input.roi-value:focus { outline: none; border-color: var(--brand); background: var(--brand-bg); }
.tildei-roi input.roi-value::-webkit-outer-spin-button,
.tildei-roi input.roi-value::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

.tildei-roi input.roi-slider {
  -webkit-appearance: none; appearance: none;
  width: 100% !important; max-width: 100%; height: 6px; border-radius: 3px;
  outline: none; padding: 0; margin: 0; border: none;
  background: var(--brand-bg);
  cursor: pointer;
}
.tildei-roi .roi-slider::-webkit-slider-thumb {
  -webkit-appearance: none; width: 22px; height: 22px;
  background: var(--surface); border: 2px solid var(--brand); border-radius: 50%;
  cursor: grab; box-shadow: 0 2px 6px rgba(238, 182, 0, 0.3);
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.tildei-roi .roi-slider::-webkit-slider-thumb:hover { transform: scale(1.1); box-shadow: 0 3px 10px rgba(238, 182, 0, 0.45); }
.tildei-roi .roi-slider::-webkit-slider-thumb:active { cursor: grabbing; }
.tildei-roi .roi-slider::-moz-range-thumb {
  width: 22px; height: 22px; background: var(--surface);
  border: 2px solid var(--brand); border-radius: 50%;
  cursor: grab; box-shadow: 0 2px 6px rgba(238, 182, 0, 0.3);
}

.tildei-roi .roi-results { display: flex; flex-direction: column; min-height: 0; height: 100%; }
.tildei-roi .roi-cards {
  display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr;
  gap: 16px; flex: 1; height: 100%; min-height: 0;
}
.tildei-roi .roi-card {
  border-radius: 16px; padding: 32px 28px;
  display: flex; flex-direction: column; gap: 24px;
  min-width: 0; min-height: 0; justify-content: space-between;
}
.tildei-roi .roi-card.monthly { background: #FFFFFF; color: var(--text); border: 1px solid var(--border); }
.tildei-roi .roi-card.annual { background: #DFF5EB; color: var(--text); }

.tildei-roi .roi-card-header { display: flex; flex-direction: column; gap: 10px; }
.tildei-roi .roi-card-label {
  font-family: var(--font-mono);
  font-size: 11px; font-weight: 500; text-transform: uppercase;
  letter-spacing: 0.08em; opacity: 0.7;
}
.tildei-roi .roi-card-value {
  font-size: 44px; font-weight: 700; letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums; line-height: 1.0;
  white-space: nowrap !important; overflow-wrap: normal !important; word-break: normal !important;
  color: ${GREEN};
}
.tildei-roi .roi-card-value.is-negative { color: var(--text); }
.tildei-roi .roi-card.annual .roi-card-label { color: var(--muted); }

.tildei-roi .roi-compare {
  display: flex; flex-direction: column; gap: 10px;
  padding-top: 20px; border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.tildei-roi .roi-compare-row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.tildei-roi .roi-compare-label { font-size: 13px; font-weight: 500; opacity: 0.7; white-space: nowrap; }
.tildei-roi .roi-compare-value {
  font-family: var(--font-mono);
  font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums;
  white-space: nowrap; color: var(--text);
}
.tildei-roi .roi-compare-row.is-status .roi-compare-label,
.tildei-roi .roi-compare-row.is-status .roi-compare-value {
  color: var(--muted); font-weight: 400; opacity: 1;
}
.tildei-roi .roi-compare-row.is-tildei .roi-compare-value { font-weight: 600; }

.tildei-roi .roi-input-scenarios { margin-top: 16px; }
.tildei-roi .roi-scenarios { display: flex; justify-content: flex-start; gap: 8px; flex-wrap: wrap; }
.tildei-roi .roi-scenario-btn {
  font-family: var(--font);
  font-size: 13px; font-weight: 500; padding: 9px 18px; border-radius: 999px;
  border: 1px solid rgba(238, 182, 0, 0.4); background: rgba(254, 213, 78, 0.15);
  color: var(--text); cursor: pointer; line-height: 1.2; letter-spacing: 0.005em;
  -webkit-appearance: none; appearance: none; white-space: nowrap;
  transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.tildei-roi .roi-scenario-btn:hover { border-color: var(--brand); background: rgba(254, 213, 78, 0.3); }
.tildei-roi .roi-scenario-btn.active { background: var(--brand-soft); border-color: var(--brand); }
.tildei-roi .roi-scenario-btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

.tildei-roi .roi-footer { margin-top: 48px; }
.tildei-roi .roi-footnote { font-size: 12px; color: var(--muted); line-height: 1.55; text-align: left; }

@container roi-calc (max-width: 880px) {
  .tildei-roi .roi-grid { grid-template-columns: 1fr; gap: 32px; }
  .tildei-roi .roi-inputs { gap: 24px; justify-content: flex-start; }
  .tildei-roi .roi-card { padding: 24px 20px; gap: 16px; }
  .tildei-roi .roi-card-value { font-size: 38px; }
  .tildei-roi .roi-footer { margin-top: 40px; }
}
@container roi-calc (max-width: 520px) {
  .tildei-roi .roi-grid { gap: 24px; }
  .tildei-roi .roi-inputs { gap: 20px; }
  .tildei-roi .roi-cards { grid-template-columns: 1fr; gap: 10px; }
  .tildei-roi .roi-card { padding: 20px 16px; gap: 14px; }
  .tildei-roi .roi-card-value { font-size: 32px; }
  .tildei-roi .roi-label { font-size: 13px; }
  .tildei-roi .roi-footer { margin-top: 32px; }
  .tildei-roi .roi-scenario-btn { font-size: 12px; padding: 8px 14px; }
  .tildei-roi input.roi-value {
    font-size: 13px;
    width: 70px !important; min-width: 70px !important; max-width: 70px !important; flex: 0 0 70px;
  }
}
@media (max-width: 880px) {
  .tildei-roi .roi-grid { grid-template-columns: 1fr; gap: 32px; }
}
`

// SSR-safe layout effect (Framer renders thumbnails without a DOM).
const useIsoLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect

function scenarioKey(key: any): string {
    return Object.prototype.hasOwnProperty.call(SCENARIOS, key) ? key : "conservative"
}

function clampField(field: any, n: any) {
    return clampToRange(Number(n) || field.min, field.min, field.max, field.step)
}

function formatField(field: any, n: number) {
    return field.prefix + Number(n).toLocaleString("en-US")
}

// Panel values arrive as numbers; anything missing/odd falls back to the
// repo-tested default.
function num(v: any, fallback: number) {
    const n = Number(v)
    return Number.isFinite(n) ? n : fallback
}

// Resolve each field's min/max from props; a degenerate pair (min >= max)
// falls back to the field's defaults so the sliders never break.
function buildFields(props: any) {
    const mins = [props.partiesMin, props.aovMin, props.locationsMin]
    const maxes = [props.partiesMax, props.aovMax, props.locationsMax]
    return FIELD_DEFS.map((d, i) => {
        let min = num(mins[i], d.defMin)
        let max = num(maxes[i], d.defMax)
        if (!(min < max)) {
            min = d.defMin
            max = d.defMax
        }
        return { ...d, min, max }
    })
}

// =============================================================================
// ====  FRAMER REACT COMPONENT  ===============================================
// =============================================================================

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 560
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight auto
 */
export default function RunhappyROI(props: any) {
    const {
        mode: modeProp,
        initialParties, initialAov, initialLocations, initialScenario,
        basePerLocation, revenueQuota, commissionPct, laborSavings,
        conservativePartiesPct, conservativeAovPct,
        moderatePartiesPct, moderateAovPct,
        bestPartiesPct, bestAovPct,
        style,
    } = props

    // Top-level mode: what the headline means. "net" subtracts Runhappy cost
    // (and adds labor savings, if set); "gross" is the pure revenue delta.
    // Gross is the shipped default.
    const mode = modeProp === "net" ? "net" : "gross"

    const fields = buildFields(props)

    const initial = () => ({
        parties: clampField(fields[0], initialParties),
        aov: clampField(fields[1], initialAov),
        locations: clampField(fields[2], initialLocations),
    })

    const [values, setValues] = React.useState<any>(initial)
    const [scenario, setScenario] = React.useState<string>(() => scenarioKey(initialScenario))
    // Raw text per field while it has focus; null = show the formatted value.
    const [drafts, setDrafts] = React.useState<any>({})

    // Keep the render in sync with the properties panel. Props never change on
    // the live site (Framer passes them once), so this only fires while
    // designing on the canvas — where it must, or panel edits look dead.
    // Range edits also re-seed so values always sit inside the new bounds.
    React.useEffect(() => {
        setValues(initial())
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [
        initialParties, initialAov, initialLocations,
        props.partiesMin, props.partiesMax, props.aovMin, props.aovMax,
        props.locationsMin, props.locationsMax,
    ])
    React.useEffect(() => {
        setScenario(scenarioKey(initialScenario))
    }, [initialScenario])

    // Scenario lifts from the panel (percent), defaulting to the repo presets.
    const scenarios: any = {
        conservative: {
            label: SCENARIOS.conservative.label,
            partiesLift: num(conservativePartiesPct, SCENARIOS.conservative.partiesLift * 100) / 100,
            aovLift: num(conservativeAovPct, SCENARIOS.conservative.aovLift * 100) / 100,
        },
        moderate: {
            label: SCENARIOS.moderate.label,
            partiesLift: num(moderatePartiesPct, SCENARIOS.moderate.partiesLift * 100) / 100,
            aovLift: num(moderateAovPct, SCENARIOS.moderate.aovLift * 100) / 100,
        },
        best: {
            label: SCENARIOS.best.label,
            partiesLift: num(bestPartiesPct, SCENARIOS.best.partiesLift * 100) / 100,
            aovLift: num(bestAovPct, SCENARIOS.best.aovLift * 100) / 100,
        },
    }
    const active = scenarios[scenario]

    const r = computeRoi({
        parties: values.parties,
        aov: values.aov,
        locations: values.locations,
        partiesLift: active.partiesLift,
        aovLift: active.aovLift,
        basePerLocation: num(basePerLocation, DEFAULT_MODEL.basePerLocation),
        revenueQuota: num(revenueQuota, DEFAULT_MODEL.revenueQuota),
        commissionRate: num(commissionPct, DEFAULT_MODEL.commissionRate * 100) / 100,
        laborSavingsPerLocation: num(laborSavings, DEFAULT_MODEL.laborSavingsPerLocation),
    })
    const headlineMonthly = mode === "gross" ? r.grossMonthly : r.netMonthly
    const headlineAnnual = mode === "gross" ? r.grossAnnual : r.netAnnual
    const negative = (Math.round(headlineMonthly) || 0) <= 0
    // Hide the cost row when the displayed cost would be $0 (e.g. base fee and
    // commission both zeroed) — an all-zero line just invites questions.
    const showCost = mode === "net" && (Math.round(r.costMonthly) || 0) > 0
    const showLabor = mode === "net" && r.laborMonthly > 0
    const partiesPct = Math.round(active.partiesLift * 100)
    const aovPct = Math.round(active.aovLift * 100)
    const reflects = [showCost ? "Runhappy cost" : "", showLabor ? "estimated labor savings" : ""]
        .filter(Boolean)
        .join(" and ")
    const footnoteTail =
        mode === "gross"
            ? "Gross new revenue shown before Runhappy cost."
            : reflects
              ? `Net new revenue reflects ${reflects}.`
              : ""

    // ---- fit-to-width text (headlines min 22px, compare values min 10px) ----
    const rootRef = React.useRef<HTMLDivElement | null>(null)
    const fitRefs = React.useRef<Record<string, HTMLElement | null>>({})
    const fitEl = (key: string) => (el: HTMLElement | null) => {
        fitRefs.current[key] = el
    }

    const runFit = React.useCallback(() => {
        Object.entries(fitRefs.current).forEach(([key, el]) => {
            if (!el || !el.parentElement) return
            const minSize = key.startsWith("headline") ? 22 : 10
            el.style.fontSize = ""
            let iterations = 0
            while (iterations++ < 40) {
                const parent = el.parentElement as HTMLElement
                let available: number
                if (parent.classList.contains("roi-compare-row")) {
                    const label = parent.querySelector(".roi-compare-label") as HTMLElement | null
                    available = parent.clientWidth - (label ? label.offsetWidth : 0) - 12
                } else {
                    available = parent.clientWidth
                }
                if (el.scrollWidth <= available) break
                const current = parseFloat(getComputedStyle(el).fontSize)
                if (current <= minSize) break
                el.style.fontSize = current - 1 + "px"
            }
        })
    }, [])

    // Refit on every committed render (numbers changed) …
    useIsoLayoutEffect(() => {
        runFit()
    })

    // … when the component's own frame resizes (canvas drags, breakpoints —
    // window.resize alone misses those) …
    React.useEffect(() => {
        if (!rootRef.current || typeof ResizeObserver === "undefined") return
        const ro = new ResizeObserver(() => runFit())
        ro.observe(rootRef.current)
        return () => ro.disconnect()
    }, [runFit])

    // … and once the host site's fonts finish loading (metrics change).
    React.useEffect(() => {
        const fonts: any = typeof document !== "undefined" && (document as any).fonts
        if (fonts && fonts.ready && typeof fonts.ready.then === "function") {
            let alive = true
            fonts.ready.then(() => alive && runFit())
            return () => {
                alive = false
            }
        }
    }, [runFit])

    // ---- input handlers -----------------------------------------------------
    const onSlider = (field: any) => (e: any) => {
        const n = Number(e.target.value)
        setValues((v: any) => ({ ...v, [field.key]: n }))
    }

    // While typing: show the raw text, and live-update the value when the text
    // parses (clamped) — same feel as the original embed.
    const onTextChange = (field: any) => (e: any) => {
        const raw = e.target.value
        setDrafts((d: any) => ({ ...d, [field.key]: raw }))
        const n = parseInputValue(raw)
        if (n !== null) setValues((v: any) => ({ ...v, [field.key]: clampField(field, n) }))
    }

    const onTextFocus = (field: any) => (e: any) => {
        const input = e.target
        setDrafts((d: any) => ({ ...d, [field.key]: input.value }))
        setTimeout(() => input.select(), 0)
    }

    // Commit on blur (Enter just blurs): parse, fall back to the current value,
    // clamp + snap, drop the draft so the formatted value shows again.
    const onTextBlur = (field: any) => (e: any) => {
        const n = parseInputValue(e.target.value)
        setValues((v: any) => ({
            ...v,
            [field.key]: clampField(field, n === null ? v[field.key] : n),
        }))
        setDrafts((d: any) => ({ ...d, [field.key]: null }))
    }

    const onTextKeyDown = (e: any) => {
        if (e.key === "Enter") e.currentTarget.blur()
    }

    const sliderStyle = (field: any) => {
        const pct = ((values[field.key] - field.min) / (field.max - field.min)) * 100
        return {
            background: `linear-gradient(to right, var(--brand) 0%, var(--brand) ${pct}%, var(--brand-bg) ${pct}%, var(--brand-bg) 100%)`,
        }
    }

    const compareRows = (period: "Monthly" | "Annual") => {
        const suffix = period === "Monthly" ? "Monthly" : "Annual"
        const today = period === "Monthly" ? r.todayMonthly : r.todayAnnual
        const withRh = period === "Monthly" ? r.withMonthly : r.withAnnual
        const cost = period === "Monthly" ? r.costMonthly : r.costAnnual
        const labor = period === "Monthly" ? r.laborMonthly : r.laborAnnual
        return (
            <div className="roi-compare">
                <div className="roi-compare-row is-status">
                    <span className="roi-compare-label">Current revenue</span>
                    <span className="roi-compare-value" ref={fitEl(`today${suffix}`)}>
                        {fmtMoney(today)}
                    </span>
                </div>
                <div className="roi-compare-row is-tildei">
                    <span className="roi-compare-label">With Runhappy</span>
                    <span className="roi-compare-value" ref={fitEl(`with${suffix}`)}>
                        {fmtMoney(withRh)}
                    </span>
                </div>
                {showCost && (
                    <div className="roi-compare-row is-status">
                        <span className="roi-compare-label">Runhappy cost (est.)</span>
                        <span className="roi-compare-value" ref={fitEl(`cost${suffix}`)}>
                            {fmtMoney(cost)}
                        </span>
                    </div>
                )}
                {showLabor && (
                    <div className="roi-compare-row is-status">
                        <span className="roi-compare-label">Labor savings (est.)</span>
                        <span className="roi-compare-value" ref={fitEl(`labor${suffix}`)}>
                            {fmtMoney(labor)}
                        </span>
                    </div>
                )}
            </div>
        )
    }

    // ---- render ---------------------------------------------------------------
    return (
        <div ref={rootRef} className="tildei-roi" style={{ width: "100%", ...style }}>
            <style>{CSS}</style>

            <div className="roi-grid">
                <div className="roi-inputs">
                    {fields.map((field) => (
                        <div className="roi-input" key={field.key}>
                            <div className="roi-input-row">
                                <label className="roi-label" htmlFor={`roi-${field.key}`}>
                                    {field.label}
                                </label>
                                <input
                                    type="text"
                                    className="roi-value"
                                    inputMode="numeric"
                                    aria-label={field.label}
                                    value={drafts[field.key] ?? formatField(field, values[field.key])}
                                    onChange={onTextChange(field)}
                                    onFocus={onTextFocus(field)}
                                    onBlur={onTextBlur(field)}
                                    onKeyDown={onTextKeyDown}
                                />
                            </div>
                            <input
                                type="range"
                                className="roi-slider"
                                id={`roi-${field.key}`}
                                min={field.min}
                                max={field.max}
                                step={field.step}
                                value={values[field.key]}
                                style={sliderStyle(field)}
                                aria-label={field.label}
                                onChange={onSlider(field)}
                            />
                        </div>
                    ))}

                    <div className="roi-input roi-input-scenarios">
                        <label className="roi-label">Uplift scenario</label>
                        <div className="roi-scenarios" role="radiogroup" aria-label="Lift scenario">
                            {Object.entries(scenarios).map(([key, s]: any) => (
                                <button
                                    type="button"
                                    key={key}
                                    className={"roi-scenario-btn" + (scenario === key ? " active" : "")}
                                    role="radio"
                                    aria-checked={scenario === key}
                                    onClick={() => setScenario(key)}
                                >
                                    {s.label}
                                </button>
                            ))}
                        </div>
                    </div>
                </div>

                <div className="roi-results">
                    <div className="roi-cards">
                        <div className="roi-card monthly">
                            <div className="roi-card-header">
                                <div className="roi-card-label">Monthly {mode} new revenue</div>
                                <div
                                    className={"roi-card-value" + (negative ? " is-negative" : "")}
                                    ref={fitEl("headlineMonthly")}
                                >
                                    {fmtMoneyPlus(headlineMonthly)}
                                </div>
                            </div>
                            {compareRows("Monthly")}
                        </div>

                        <div className="roi-card annual">
                            <div className="roi-card-header">
                                <div className="roi-card-label">Annual {mode} new revenue</div>
                                <div
                                    className={"roi-card-value" + (negative ? " is-negative" : "")}
                                    ref={fitEl("headlineAnnual")}
                                >
                                    {fmtMoneyPlus(headlineAnnual)}
                                </div>
                            </div>
                            {compareRows("Annual")}
                        </div>
                    </div>
                </div>
            </div>

            <div className="roi-footer">
                <p className="roi-footnote">
                    Modeled on a {partiesPct}% lift in monthly parties (recovered after-hours leads, faster
                    response) and a {aovPct}% lift in average order value (upsells on add-ons, premium
                    packages). {footnoteTail}
                </p>
            </div>
        </div>
    )
}

RunhappyROI.defaultProps = {
    mode: "gross",
    initialParties: 125,
    initialAov: 420,
    initialLocations: 1,
    initialScenario: "conservative",
    partiesMin: 10,
    partiesMax: 500,
    aovMin: 200,
    aovMax: 2000,
    locationsMin: 1,
    locationsMax: 100,
    basePerLocation: 0,
    revenueQuota: 0,
    commissionPct: 0,
    laborSavings: 0,
    conservativePartiesPct: 15,
    conservativeAovPct: 15,
    moderatePartiesPct: 22,
    moderateAovPct: 22,
    bestPartiesPct: 30,
    bestAovPct: 30,
}

// Panel defaults mirror roi-core.js (DEFAULT_MODEL / SCENARIOS) — the values
// the unit tests pin. Labor savings is off by default; setting it adds a
// disclosed "Labor savings (est.)" row and includes it in the headline.
addPropertyControls(RunhappyROI, {
    // ---- Mode (top-level: what the headline means) ----
    mode: {
        type: ControlType.Enum, title: "Mode",
        options: ["gross", "net"],
        optionTitles: ["Gross", "Net"],
        defaultValue: "gross",
        displaySegmentedControl: true,
    },

    // ---- Initial values ----
    initialParties: {
        type: ControlType.Number, title: "Parties / mo", min: 10, max: 500, step: 5, defaultValue: 125,
    },
    initialAov: {
        type: ControlType.Number, title: "Avg order ($)", min: 200, max: 2000, step: 10, defaultValue: 420,
    },
    initialLocations: {
        type: ControlType.Number, title: "Locations", min: 1, max: 400, step: 1, defaultValue: 1,
    },
    initialScenario: {
        type: ControlType.Enum, title: "Scenario",
        options: ["conservative", "moderate", "best"],
        optionTitles: ["Conservative", "Moderate", "Best case"],
        defaultValue: "conservative",
    },

    // ---- Input ranges (Framer has no range control — min/max are separate) ----
    partiesMin: {
        type: ControlType.Number, title: "Parties min", min: 0, max: 10000, step: 5, defaultValue: 10,
    },
    partiesMax: {
        type: ControlType.Number, title: "Parties max", min: 0, max: 10000, step: 5, defaultValue: 500,
    },
    aovMin: {
        type: ControlType.Number, title: "AOV min ($)", min: 0, max: 100000, step: 10, defaultValue: 200,
    },
    aovMax: {
        type: ControlType.Number, title: "AOV max ($)", min: 0, max: 100000, step: 10, defaultValue: 2000,
    },
    locationsMin: {
        type: ControlType.Number, title: "Locations min", min: 1, max: 10000, step: 1, defaultValue: 1,
    },
    locationsMax: {
        type: ControlType.Number, title: "Locations max", min: 1, max: 10000, step: 1, defaultValue: 100,
    },

    // ---- Pricing model ----
    basePerLocation: {
        type: ControlType.Number, title: "Base $/loc/mo", min: 0, max: 20000, step: 50, defaultValue: 0,
    },
    revenueQuota: {
        type: ControlType.Number, title: "Quota $/loc/mo", min: 0, max: 100000, step: 500, defaultValue: 0,
    },
    commissionPct: {
        type: ControlType.Number, title: "Commission", unit: "%", min: 0, max: 100, step: 0.5, defaultValue: 0,
    },
    laborSavings: {
        type: ControlType.Number, title: "Labor save $/loc", min: 0, max: 20000, step: 100, defaultValue: 0,
    },

    // ---- Scenario lifts ----
    conservativePartiesPct: {
        type: ControlType.Number, title: "Cons. parties", unit: "%", min: 0, max: 100, step: 1, defaultValue: 15,
    },
    conservativeAovPct: {
        type: ControlType.Number, title: "Cons. AOV", unit: "%", min: 0, max: 100, step: 1, defaultValue: 15,
    },
    moderatePartiesPct: {
        type: ControlType.Number, title: "Mod. parties", unit: "%", min: 0, max: 100, step: 1, defaultValue: 22,
    },
    moderateAovPct: {
        type: ControlType.Number, title: "Mod. AOV", unit: "%", min: 0, max: 100, step: 1, defaultValue: 22,
    },
    bestPartiesPct: {
        type: ControlType.Number, title: "Best parties", unit: "%", min: 0, max: 100, step: 1, defaultValue: 30,
    },
    bestAovPct: {
        type: ControlType.Number, title: "Best AOV", unit: "%", min: 0, max: 100, step: 1, defaultValue: 30,
    },
})
