1import { Controller } from "@hotwired/stimulus" 2 3// Capture ad-attribution params on landing and forward them so the booking app 4// (app.skinclique.com) can attribute the eventual booking. See CLAUDE.md §12. 5const UTM_KEYS = ["gclid", "fbclid", "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id"] 6const COOKIE_NAME = "sc_utm_attribution" 7const COOKIE_MAX_AGE_DAYS = 30 8// Cap each value so a crafted/garbage URL can't bloat the cookie past the ~4KB 9// browser limit (which would silently drop it or crowd out other cookies). 10const MAX_VALUE_LENGTH = 512 11const BOOKING_CTA_SELECTOR = '[data-cta-id="book-with-provider"]' 12 13export default class extends Controller { 14 connect() { 15 const urlParams = this.readParamsFromUrl() 16 17 // Refresh the cookie only when THIS URL carries attribution â never overwrite 18 // a real cookie with nothing on a UTM-less pageview (last-touch window). 19 if (Object.keys(urlParams).length > 0) { 20 this.writeAttributionCookie(urlParams) 21 } 22 23 // Forward params onto the booking CTA. A fresh URL wins outright; otherwise 24 // fall back to what we stored earlier this session, so a visitor who landed 25 // with UTMs on another page still carries them to the booking app when they 26 // click "Book with <provider>". Matches the platform's URL-over-cookie order. 27 const forwardParams = Object.keys(urlParams).length > 0 ? urlParams : this.readParamsFromCookie() 28 if (Object.keys(forwardParams).length > 0) { 29 this.appendParamsToBookingCtas(forwardParams) 30 } 31 } 32 33 // Allowlisted keys only, each within the length cap. Arbitrary params in the 34 // URL (?evil=â¦) are ignored â we never read a key outside UTM_KEYS. 35 readParamsFromUrl() { 36 const params = new URLSearchParams(window.location.search) 37 const out = {} 38 for (const key of UTM_KEYS) { 39 const value = params.get(key) 40 if (value && value.length <= MAX_VALUE_LENGTH) out[key] = value 41 } 42 return out 43 } 44 45 // The cookie is attacker-settable (any *.skinclique.com origin could write it), 46 // so re-validate on read exactly like a fresh URL: allowlist keys, enforce the 47 // length cap, keep only non-empty string values. Reading only UTM_KEYS also 48 // sidesteps any "__proto__"/polluting keys a crafted JSON payload might carry. 49 readParamsFromCookie() { 50 const entry = document.cookie.split("; ").find((c) => c.startsWith(`${COOKIE_NAME}=`)) 51 if (!entry) return {} 52 53 let parsed 54 try { 55 parsed = JSON.parse(decodeURIComponent(entry.slice(COOKIE_NAME.length + 1))) 56 } catch (_e) { 57 return {} 58 } 59 if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {} 60 61 const out = {} 62 for (const key of UTM_KEYS) { 63 const value = parsed[key] 64 if (typeof value === "string" && value.length > 0 && value.length <= MAX_VALUE_LENGTH) out[key] = value 65 } 66 return out 67 } 68 69 writeAttributionCookie(present) { 70 // Cookie CONTRACT with the booking app (app.skinclique.com): 71 // the value is encodeURIComponent(JSON). encodeURIComponent is required, not 72 // optional â utm_content/term values can contain ";" or "," which would 73 // otherwise corrupt/truncate the cookie. A server-side Ruby reader 74 // (Customers::CapturesUtmParams via Rails `cookies[...]`) gets it URL-unescaped 75 // by Rack before JSON.parse, so it round-trips. Any JS consumer reading 76 // document.cookie directly MUST decodeURIComponent() before JSON.parse(). 77 const value = encodeURIComponent(JSON.stringify(present)) 78 const maxAge = COOKIE_MAX_AGE_DAYS * 24 * 60 * 60 79 const domain = this.cookieDomain() 80 // Secure is HTTPS-only; drop it on http (local dev) so the cookie is testable. 81 const secure = window.location.protocol === "https:" ? "; Secure" : "" 82 document.cookie = `${COOKIE_NAME}=${value}; Path=/; Max-Age=${maxAge}; SameSite=Lax${domain}${secure}` 83 } 84 85 cookieDomain() { 86 // Scope to the shared parent so app.skinclique.com can read it. On localhost / 87 // preview hosts a .skinclique.com Domain attribute makes the browser drop the 88 // cookie, so omit it there. 89 return window.location.hostname.endsWith("skinclique.com") ? "; Domain=.skinclique.com" : "" 90 } 91 92 appendParamsToBookingCtas(present) { 93 // One-shot over CTAs present at connect(). The hero "Book with <name>" CTA is 94 // server-rendered, so it's always in the DOM before Stimulus boots. If a 95 // book-with-provider CTA is ever delivered async (Turbo Frame / AJAX partial), 96 // re-run this on turbo:frame-load or add a MutationObserver.
97 document.querySelectorAll(BOOKING_CTA_SELECTOR).forEach((cta) => { 98 const href = cta.getAttribute("href") 99 if (!href) return 100 let url 101 try { 102 url = new URL(href, window.location.origin) 103 } catch (_e) { 104 return 105 } 106 for (const [key, val] of Object.entries(present)) { 107 url.searchParams.set(key, val) 108 } 109 cta.setAttribute("href", url.toString()) 110 }) 111 } 112}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.