import { useState, useEffect, useLayoutEffect, useRef } from "react"
import { addPropertyControls, ControlType } from "framer"
// ─── Palette (matches CVThankYouPage) ──────────────────────────────────────────
const COLORS = {
red: "#E53E2F",
redHover: "#CF3526",
redTint: "#FBEEED",
sage: "#E8F2EF",
sageText: "#3D8C7A",
dark: "#111111",
muted: "#6B6B6B",
border: "rgba(0,0,0,0.1)",
paper: "#FAFAF7",
surface: "#F7F7F5",
}
// ─── Container-based mobile detection (same as thank-you page) ──────────────────
function useContainerIsMobile(ref) {
const [isMobile, setIsMobile] = useState(false)
useLayoutEffect(() => {
if (!ref.current) return
const width = ref.current.getBoundingClientRect().width
if (width > 0) setIsMobile(width < 768)
}, [ref])
useEffect(() => {
if (!ref.current) return
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setIsMobile(entry.contentRect.width < 768)
}
})
observer.observe(ref.current)
return () => observer.disconnect()
}, [ref])
return isMobile
}
// ─── UTM passthrough (same storage key as the rest of the funnel) ───────────────
const UTM_KEYS = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_content",
"utm_term",
"gclid",
"fbclid",
]
const UTM_STORAGE_KEY = "fsd_utm_data"
function getStoredUTMs() {
if (typeof window === "undefined") return {}
try {
const raw = sessionStorage.getItem(UTM_STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch (err) {
return {}
}
}
// ─── GTM dataLayer helper ───────────────────────────────────────────────────────
function pushDataLayer(event, payload) {
if (typeof window === "undefined") return
window.dataLayer = window.dataLayer || []
window.dataLayer.push({
event,
...getStoredUTMs(), // pass through all stored UTM params
...payload,
})
}
// ─── Optional email prefill from ?email= (if the main form passes it through) ───
function getEmailParam() {
if (typeof window === "undefined") return ""
try {
return new URLSearchParams(window.location.search).get("email") || ""
} catch (err) {
return ""
}
}
function isValidEmail(v) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((v || "").trim())
}
// ─── Small checkmark (matches thank-you page style, scaled down) ───────────────
function MiniCheck({ size = 56 }) {
return (
<div
style={{
width: size,
height: size,
borderRadius: "50%",
background: COLORS.red,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: size * 0.5,
fontWeight: 600,
margin: "0 auto",
boxShadow: "0 8px 32px rgba(229,62,47,0.18)",
}}
>
✓
</div>
)
}
// ─── Main component ────────────────────────────────────────────────────────────
export default function CVBudgetGate({
// Question step
questionEyebrow,
questionText,
yesLabel,
noLabel,
bookingUrl,
// Downsell step
downsellHeadline,
downsellBody,
emailPlaceholder,
downsellButton,
reconsiderText,
reassurance,
// Success step
successHeadline,
successBody,
// Plumbing
submitEndpoint,
qualifiedValue,
downsellValue,
}) {
const containerRef = useRef(null)
const isMobile = useContainerIsMobile(containerRef)
const [step, setStep] = useState("question") // question | downsell | success
const [email, setEmail] = useState("")
const [error, setError] = useState("")
const [submitting, setSubmitting] = useState(false)
const [hovered, setHovered] = useState("")
// Prefill email if the main form passed ?email= through
useEffect(() => {
const e = getEmailParam()
if (e) setEmail(e)
}, [])
// ── Handlers ──
function handleYes() {
pushDataLayer("budget_qualifier_answered", {
qualified: true,
qualifier_answer: "yes",
conversion_value: qualifiedValue,
conversion_currency: "USD",
})
if (typeof window !== "undefined" && bookingUrl) {
window.location.href = bookingUrl
}
}
function handleNo() {
pushDataLayer("budget_qualifier_answered", {
qualified: false,
qualifier_answer: "no",
})
setStep("downsell")
}
async function handleSubmit() {
const clean = email.trim()
if (!isValidEmail(clean)) {
setError("Enter a valid email so we can send the teardown.")
return
}
setError("")
setSubmitting(true)
const payload = {
email: clean,
source: "90day_budget_downsell",
offer: "free_teardown",
...getStoredUTMs(),
}
// Optional POST to an ESP / Zapier / webhook endpoint.
// If no endpoint is set, we still fire the dataLayer event and
// proceed — GTM becomes the capture path of record.
if (submitEndpoint) {
try {
await fetch(submitEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
} catch (err) {
// Don't block the user on a failed POST — capture via GTM instead.
console.error("Downsell endpoint POST failed:", err)
}
}
// Distinct event — intentionally NOT the main Contact conversion,
// so the downsell never pollutes the campaign's purchase/Contact signal.
pushDataLayer("downsell_teardown_requested", {
conversion_value: downsellValue,
conversion_currency: "USD",
invitee_email: clean,
})
setSubmitting(false)
setStep("success")
}
// ── Shared shells ──
const outer = {
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
width: "100%",
boxSizing: "border-box",
padding: isMobile ? "8px" : "12px",
display: "flex",
justifyContent: "center",
}
const card = {
width: "100%",
maxWidth: 600,
background: COLORS.paper,
border: `0.5px solid ${COLORS.border}`,
borderRadius: 16,
padding: isMobile ? "28px 22px" : "40px 36px",
boxSizing: "border-box",
}
const eyebrow = {
display: "inline-flex",
alignItems: "center",
gap: 8,
background: COLORS.sage,
color: COLORS.sageText,
fontSize: 11,
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
padding: "6px 14px",
borderRadius: 100,
marginBottom: 18,
}
// ── QUESTION STEP ──
if (step === "question") {
return (
<div ref={containerRef} style={outer}>
<div style={card}>
<div style={eyebrow}>{questionEyebrow}</div>
<h2
style={{
fontSize: isMobile ? 22 : 26,
fontWeight: 700,
lineHeight: 1.25,
color: COLORS.dark,
letterSpacing: "-0.02em",
margin: "0 0 24px",
}}
>
{questionText}
</h2>
<div
style={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
gap: 12,
}}
>
{/* Yes — primary */}
<button
onClick={handleYes}
onMouseEnter={() => setHovered("yes")}
onMouseLeave={() => setHovered("")}
style={{
flex: 1,
cursor: "pointer",
border: "none",
borderRadius: 10,
padding: "16px 20px",
fontSize: 15,
fontWeight: 600,
fontFamily: "inherit",
color: "#fff",
background:
hovered === "yes"
? COLORS.redHover
: COLORS.red,
transition: "background 0.15s, transform 0.15s",
transform:
hovered === "yes"
? "translateY(-1px)"
: "none",
}}
>
{yesLabel}
</button>
{/* No — quiet secondary */}
<button
onClick={handleNo}
onMouseEnter={() => setHovered("no")}
onMouseLeave={() => setHovered("")}
style={{
flex: 1,
cursor: "pointer",
borderRadius: 10,
padding: "16px 20px",
fontSize: 15,
fontWeight: 600,
fontFamily: "inherit",
color: COLORS.muted,
background:
hovered === "no" ? COLORS.surface : "#fff",
border: `1px solid ${COLORS.border}`,
transition: "background 0.15s",
}}
>
{noLabel}
</button>
</div>
</div>
</div>
)
}
// ── DOWNSELL STEP ──
if (step === "downsell") {
return (
<div ref={containerRef} style={outer}>
<div style={card}>
<div style={eyebrow}>Let's start smaller</div>
<h2
style={{
fontSize: isMobile ? 24 : 30,
fontWeight: 700,
lineHeight: 1.15,
color: COLORS.dark,
letterSpacing: "-0.02em",
margin: "0 0 14px",
}}
>
{downsellHeadline}
</h2>
<p
style={{
fontSize: isMobile ? 15 : 16,
color: COLORS.muted,
lineHeight: 1.55,
margin: "0 0 24px",
}}
>
{downsellBody}
</p>
<div
style={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
gap: 10,
}}
>
<input
type="email"
value={email}
placeholder={emailPlaceholder}
onChange={(e) => {
setEmail(e.target.value)
if (error) setError("")
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit()
}}
style={{
flex: 1,
fontSize: 15,
fontFamily: "inherit",
color: COLORS.dark,
padding: "15px 16px",
borderRadius: 10,
border: `1px solid ${
error ? COLORS.red : COLORS.border
}`,
outline: "none",
background: "#fff",
boxSizing: "border-box",
}}
/>
<button
onClick={handleSubmit}
disabled={submitting}
onMouseEnter={() => setHovered("send")}
onMouseLeave={() => setHovered("")}
style={{
cursor: submitting ? "default" : "pointer",
border: "none",
borderRadius: 10,
padding: "15px 24px",
fontSize: 15,
fontWeight: 600,
fontFamily: "inherit",
color: "#fff",
whiteSpace: "nowrap",
opacity: submitting ? 0.7 : 1,
background:
hovered === "send" && !submitting
? COLORS.redHover
: COLORS.red,
transition: "background 0.15s",
}}
>
{submitting ? "Sending…" : `${downsellButton} →`}
</button>
</div>
{error && (
<div
style={{
fontSize: 13,
color: COLORS.red,
marginTop: 10,
}}
>
{error}
</div>
)}
{reassurance ? (
<div
style={{
fontSize: 12.5,
color: COLORS.muted,
marginTop: 14,
}}
>
{reassurance}
</div>
) : null}
{/* Recovery link for the "reflexive No" who could actually pay */}
{reconsiderText ? (
<div
style={{
marginTop: 22,
paddingTop: 18,
borderTop: `0.5px solid ${COLORS.border}`,
textAlign: "center",
}}
>
<span
onClick={handleYes}
style={{
fontSize: 13,
fontWeight: 600,
color: COLORS.muted,
cursor: "pointer",
borderBottom: `1px solid ${COLORS.border}`,
paddingBottom: 1,
}}
>
{reconsiderText} →
</span>
</div>
) : null}
</div>
</div>
)
}
// ── SUCCESS STEP ──
return (
<div ref={containerRef} style={outer}>
<div style={{ ...card, textAlign: "center" }}>
<div style={{ marginBottom: 20 }}>
<MiniCheck size={isMobile ? 52 : 56} />
</div>
<h2
style={{
fontSize: isMobile ? 24 : 28,
fontWeight: 700,
lineHeight: 1.15,
color: COLORS.dark,
letterSpacing: "-0.02em",
margin: "0 0 12px",
}}
>
{successHeadline}
</h2>
<p
style={{
fontSize: isMobile ? 15 : 16,
color: COLORS.muted,
lineHeight: 1.55,
margin: "0 auto",
maxWidth: 420,
}}
>
{successBody}
</p>
</div>
</div>
)
}
CVBudgetGate.defaultProps = {
questionEyebrow: "One last thing",
questionText:
"Our management fee starts at $5,000/month. Does that work for you?",
yesLabel: "Yes, that works",
noLabel: "Not right now",
bookingUrl: "/90-day/book",
downsellHeadline: "No problem. Let's start smaller.",
downsellBody:
"A $5K/month retainer isn't the right move for every brand, and forcing it would be a bad fit for both of us. But you came here because your CPA is a problem, so let's still help. We'll do a free teardown of your current ad account and creative, the same audit we run before taking on any client, and send you the first three things we'd change. No pitch, no obligation.",
emailPlaceholder: "you@yourbrand.com",
downsellButton: "Send me the teardown",
reconsiderText: "Actually, $5K works for us. Book the call",
reassurance: "We'll send it over within 2-3 business days.",
successHeadline: "On its way.",
successBody:
"Keep an eye on your inbox over the next 2-3 business days. We'll send the teardown and the first three things we'd change.",
submitEndpoint: "",
qualifiedValue: 7500,
downsellValue: 0,
}
addPropertyControls(CVBudgetGate, {
// ─── Question step ───
questionEyebrow: {
type: ControlType.String,
title: "Q · Eyebrow",
defaultValue: "One last thing",
},
questionText: {
type: ControlType.String,
title: "Q · Question",
defaultValue:
"Our management fee starts at $5,000/month. Does that work for you?",
displayTextArea: true,
},
yesLabel: {
type: ControlType.String,
title: "Q · Yes label",
defaultValue: "Yes, that works",
},
noLabel: {
type: ControlType.String,
title: "Q · No label",
defaultValue: "Not right now",
},
bookingUrl: {
type: ControlType.String,
title: "Q · Booking URL (Yes)",
description: "Where 'Yes' sends them — your Calendly/booking step",
defaultValue: "/90-day/book",
},
// ─── Downsell step ───
downsellHeadline: {
type: ControlType.String,
title: "DS · Headline",
defaultValue: "No problem. Let's start smaller.",
},
downsellBody: {
type: ControlType.String,
title: "DS · Body",
defaultValue:
"A $5K/month retainer isn't the right move for every brand, and forcing it would be a bad fit for both of us. But you came here because your CPA is a problem, so let's still help. We'll do a free teardown of your current ad account and creative, the same audit we run before taking on any client, and send you the first three things we'd change. No pitch, no obligation.",
displayTextArea: true,
},
emailPlaceholder: {
type: ControlType.String,
title: "DS · Email placeholder",
defaultValue: "you@yourbrand.com",
},
downsellButton: {
type: ControlType.String,
title: "DS · Button",
defaultValue: "Send me the teardown",
},
reconsiderText: {
type: ControlType.String,
title: "DS · Reconsider link",
description: "Recovery link for a reflexive 'No'. Leave blank to hide.",
defaultValue: "Actually, $5K works for us. Book the call",
},
reassurance: {
type: ControlType.String,
title: "DS · Reassurance line",
defaultValue: "We'll send it over within 2-3 business days.",
},
// ─── Success step ───
successHeadline: {
type: ControlType.String,
title: "OK · Headline",
defaultValue: "On its way.",
},
successBody: {
type: ControlType.String,
title: "OK · Body",
defaultValue:
"Keep an eye on your inbox over the next 2-3 business days. We'll send the teardown and the first three things we'd change.",
displayTextArea: true,
},
// ─── Plumbing ───
submitEndpoint: {
type: ControlType.String,
title: "Submit endpoint",
description:
"Optional POST URL (ESP / Zapier / webhook). Blank = GTM-only capture.",
defaultValue: "",
},
qualifiedValue: {
type: ControlType.Number,
title: "Qualified value (USD)",
description: "Fires with budget_qualifier_answered on 'Yes'",
defaultValue: 7500,
min: 0,
step: 100,
},
downsellValue: {
type: ControlType.Number,
title: "Downsell value (USD)",
description: "Fires with downsell_teardown_requested. Keep low/0.",
defaultValue: 0,
min: 0,
step: 50,
},
})