PageSourceSearch

https://www.skinclique.com/assets/controllers/utm_capture_controller-994111a4.js

js skinclique.com collected 2026-09-28 06:01:16 UTC 5,013 bytes, 112 lines download raw bytes

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.