1/** 2 * webflow-local-pricing.js 3 * Hosted here to provide localized subscription pricing for claude.com 4 * pages published via Webflow reverse proxy. 5 * 6 * Contains: 7 * - Country detection via the billing API (server-side GeoIP) 8 * - Currency formatting via Intl.NumberFormat 9 * - Tax-inclusive math for jurisdictions that require it 10 * - Adaptive-pricing callout 11 * 12 * Markup contract (set in Webflow): 13 * <div data-plan="pro_annual" data-plan-field="amount_per_month">$17</div> 14 * 15 * data-plan â required. One of: 16 * pro_monthly, pro_annual, 17 * max_5x_monthly, max_20x_monthly, 18 * team_monthly_per_seat, team_annual_per_seat, 19 * team_nonprofit_monthly_per_seat, 20 * team_premium_monthly_per_seat, team_premium_annual_per_seat, 21 * free (special-cased: reformats $0 to the response currency) 22 * (Unknown values are silently ignored â server-rendered 23 * text stays in place.) 24 * 25 * data-plan-field â optional. One of: 26 * amount (default â raw plan amount, e.g. "$200") 27 * amount_total (alias of amount) 28 * amount_per_month (annual / 12 for year-interval plans) 29 * 30 * Page-level placeholders (one or more, anywhere on the page): 31 * <div data-tax-included-note></div> â populated when API says taxes 32 * are inclusive in this country. 33 * <div data-adaptive-pricing-callout></div> â populated when adaptive pricing 34 * lets the user pay in local currency 35 * (includes an FX-may-vary note). 36 * 37 * <p data-disclaimer-tax-exclusive>â¦</p> â paired footer disclaimers. The 38 * <p data-disclaimer-tax-inclusive>â¦</p> script shows whichever matches 39 * the country's tax model 40 * (tax_display.show_included). 41 * Mark the tax-exclusive line 42 * visible by default and the 43 * tax-inclusive line hidden so the 44 * USD fallback reads correctly if 45 * the fetch fails. 46 * 47 * URL overrides (for QA): 48 * ?country=DE â force a specific country 49 * 50 * Behavior: 51 * - Anonymous: fetched with credentials: 'omit'. No cookies, no auth. 52 * - Server-rendered USD prices remain visible until the fetch resolves. 53 * If the fetch fails, USD stays. (No skeleton, no flash.) 54 * - For each price element, the FIRST $NN(.NN)? in the text is replaced 55 * with the localized price; surrounding text ("From ", "/seat", etc.) 56 * is preserved. 57 * - Tax math: tax-exclusive amounts are multiplied by (1 + tax_rate_pct/100) 58 * when tax_display.show_included is true. 59 * - Dynamic DOM: a MutationObserver re-applies the cached response when new 60 * pricing markers appear after the initial fetch (e.g. a React tab swap 61 * that remounts price cards). The first response is cached in module 62 * scope; no extra network requests. 63 * 64 * Dependencies: none. 65 */ 66 67;(function () { 68 'use strict' 69 70 // Route to staging API when running on the staging marketing domain; 71 // production everywhere else. 72 const API_HOST = 73 window.location.hostname === 'claude-website.ant.dev' 74 ? 'https://claude-ai.staging.ant.dev' 75 : 'https://claude.ai' 76 const API_URL = API_HOST + '/api/billing/subscription_pricing' 77 78 // Match the first $-prefixed price in an element's text. 79 // Handles: "$0", "$17", "$200", "$1,200", "$1,200.50", "$10,000", etc. 80 // Server-rendered USD always uses comma for thousands and period for 81 // decimals, so we don't need the [.,] ambiguity. 82 const PRICE_PATTERN = /\$\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?/ 83 84 // For year-interval plans, amount_minor is the total billed at signup 85 // (verified via staging tests: DE pro_annual = 18000 minor â â¬180/yr â 86 // ÷12 = â¬15/mo headline). If the API ever switches to returning the 87 // per-month equivalent for annual plans instead, flip this to fal
87se. 88 const ANNUAL_AMOUNT_IS_TOTAL = true 89 90 // ---------- helpers ---------- 91 92 function getCountryOverride() { 93 const params = new URLSearchParams(window.location.search) 94 const v = (params.get('country') || '').toUpperCase().trim() 95 return v || null 96 } 97 98 function buildApiUrl(country) { 99 return country ? API_URL + '?country=' + encodeURIComponent(country) : API_URL 100 } 101 102 // Number of fraction digits Intl.NumberFormat uses for a given currency 103 // (EUR=2, JPY=0, etc.). Cached per-currency. 104 const minorUnitsCache = {} 105 function getMinorUnits(currency) { 106 if (currency in minorUnitsCache) return minorUnitsCache[currency] 107 try { 108 const fmt = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency}) 109 minorUnitsCache[currency] = fmt.resolvedOptions().maximumFractionDigits 110 } catch (e) { 111 minorUnitsCache[currency] = 2 112 } 113 return minorUnitsCache[currency] 114 } 115 116 // Format a minor-unit amount as a currency string. 117 // - roundToWhole: round to integer major units (used for amount_per_month, 118 // which is a marketing approximation â $17, not $16.67). 119 // - When the amount is a whole number, drop the fractional part entirely 120 // ($200 not $200.00). When it has cents, show full precision (â¬23.80). 121 function formatCurrency(amountMinor, currency, roundToWhole) { 122 if (amountMinor == null || !currency) return null 123 const minorUnits = getMinorUnits(currency) 124 let major = amountMinor / Math.pow(10, minorUnits) 125 if (roundToWhole) major = Math.round(major) 126 const fractionDigits = roundToWhole ? 0 : minorUnits 127 const isInteger = Math.abs(major - Math.round(major)) < 1e-9 128 const options = { 129 style: 'currency', 130 currency: currency, 131 currencyDisplay: 'narrowSymbol',
132 minimumFractionDigits: isInteger ? 0 : fractionDigits, 133 maximumFractionDigits: fractionDigits, 134 } 135 try { 136 return new Intl.NumberFormat(undefined, options).format(major) 137 } catch (e) { 138 delete options.currencyDisplay 139 return new Intl.NumberFormat(undefined, options).format(major) 140 } 141 } 142 143 // Apply tax inclusively when the API says we should. 144 // Round to the nearest minor unit so floating-point artifacts don't leak 145 // into formatting (e.g. 3091 à 1.10 = 3400.1000000000004 â would render 146 // as "A$34.00" instead of clean "A$34" without the round). 147 function applyTaxIfNeeded(amountMinor, plan, taxDisplay) { 148 if (taxDisplay && taxDisplay.show_included && plan.tax_rate_pct) { 149 return Math.round(amountMinor * (1 + plan.tax_rate_pct / 100)) 150 } 151 return amountMinor 152 } 153 154 // Resolve the minor-unit amount to display for a given plan + field. 155 function resolveAmount(plan, field) { 156 if (!plan || plan.amount_minor == null) return null 157 let amount = plan.amount_minor 158 const isAnnual = plan.interval === 'year' 159 160 if (field === 'amount_per_month') { 161 if (isAnnual && ANNUAL_AMOUNT_IS_TOTAL) amount = Math.round(amount / 12) 162 } else { 163 // 'amount', 'amount_total', or unset 164 if (isAnnual && !ANNUAL_AMOUNT_IS_TOTAL) amount = Math.round(amount * 12) 165 } 166 167 return amount 168 } 169 170 // First non-zero tax rate found across the response's plans, used for the 171 // inclusive-tax note. Tax rates are per-country in practice, so all plans 172 // in a single response share the same rate; if that ever stops being true 173 // the note will reflect only the first rate encountered. 174 function firstTaxRate(response) { 175 if (!response.plans) return null 176 const keys = Object.keys(response.plans) 177 for (let i = 0; i < keys.length; i++) { 178 const p = response.plans[keys[i]] 179 if (p && p.tax_rate_pct) return p.tax_rate_pct 180 } 181 return null 182 } 183 184 // ---------- DOM updates ---------- 185 186 function captureOriginalText(el) { 187 if (!el.hasAttribute('data-original-text')) { 188 el.setAttribute('data-original-text', el.textContent) 189 } 190 } 191 192 function getOriginalText(el) { 193 return el.getAttribute('data-original-text') || el.textContent 194 } 195 196 function updatePriceElement(el, response) { 197 const planKey = el.getAttribute('data-plan') 198 if (!planKey) return 199 200 // The "free" plan isn't in the API response. Reformat the $0 to reflect 201 // the response's currency so DE shows â¬0, JP shows Â¥0, etc. 202 if (planKey === 'free') { 203 if (!response.currency) return 204 const freeFormatted = formatCurrency(0, response.currency, true) 205 if (!freeFormatted) return 206 captureOriginalText(el) 207 const freeOriginal = getOriginalText(el) 208 el.textContent = PRICE_PATTERN.test(freeOriginal) 209 ? freeOriginal.replace(PRICE_PATTERN, freeFormatted) 210 : freeFormatted 211 return 212 } 213 214 const plan = response.plans && response.plans[planKey] 215 if (!plan) return // Unknown / unavailable plan â leave USD in place. 216 217 const field = el.getAttribute('data-plan-field') || 'amount' 218 const rawAmount = resolveAmount(plan, field) 219 if (rawAmount == null) return 220 221 const taxedAmount = applyTaxIfNeeded(rawAmount, plan, response.tax_display) 222 const roundToWhole = field === 'amount_per_month' 223 const formatted = formatCurrency(taxedAmount, plan.currency, roundToWhole) 224 if (!formatted) return 225 226 captureOriginalText(el) 227 const original = getOriginalText(el) 228 229 if (PRICE_PATTERN.test(original)) { 230 el.textContent = original.replace(PRICE_PATTERN, formatted) 231 } else { 232 el.textContent = formatted 233 } 234 } 235 236 function updateTaxIncludedNote(response) { 237 const nodes = document.querySelectorAll('[data-tax-included-note]') 238 if (!nodes.length) return 239 240 const td = response.tax_display 241 const rate = firstTaxRate(response) 242 const show = td && td.show_included && td.tax_label 243 244 nodes.forEach(function (node) { 245 if (show) { 246 node.textContent = rate 247 ? 'Prices include ' + rate + '% ' + td.tax_label 248 : 'Prices include ' + td.tax_label 249 node.style.display = 'block' 250 } else { 251 node.textContent = '' 252 node.style.display = 'none' 253 } 254 }) 255 } 256 257 // Render the adaptive-pricing callout whenever adaptive_pricing.available is 258 // true â a generic FX-may-vary disclosure that doesn't depend on which 259 // currency the API populates. Stays hidden in markets where adaptive 260 // pricing is off (no FX exposure to disclose). 261 function updateAdaptiveCallout(response) { 262 const nodes = document.querySelectorAll('[data-adaptive-pricing-callout]') 263 if (!nodes.length) return 264 265 const ap = response.adaptive_pricing 266 const show = ap && ap.available 267
268 nodes.forEach(function (node) { 269 if (show) { 270 node.textContent = 'Final amount may vary based on exchange rate at time of purchase.' 271 node.style.display = 'block' 272 } else { 273 node.textContent = '' 274 node.style.display = 'none' 275 } 276 }) 277 } 278 279 // Swap the page-footer disclaimer between the tax-exclusive and tax-inclusive 280 // variants based on tax_display.show_included. Mirrors the same condition 281 // that drives updateTaxIncludedNote, so the two stay in sync: if the 282 // "Prices include X% VAT" note is showing, the tax-inclusive disclaimer 283 // (without the "don't include applicable tax" sentence) is the one visible. 284 // 285 // Default state in markup should be: 286 // [data-disclaimer-tax-exclusive] visible 287 // [data-disclaimer-tax-inclusive] hidden (display: none) 288 // so the USD fallback remains accurate if the fetch fails. 289 function updateTaxAwareDisclaimer(response) { 290 const td = response.tax_display 291 const taxInclusive = !!(td && td.show_included) 292 293 document.querySelectorAll('[data-disclaimer-tax-exclusive]').forEach(function (node) { 294 node.style.display = taxInclusive ? 'none' : 'block' 295 }) 296 document.querySelectorAll('[data-disclaimer-tax-inclusive]').forEach(function (node) { 297 node.style.display = taxInclusive ? 'block' : 'none' 298 }) 299 } 300 301 // Cached so a MutationObserver can re-apply when new pricing markers appear 302 // in the DOM after the initial fetch (e.g. React tab swaps that remount 303 // price cards). One fetch per page load, applied as many times as needed. 304 let cachedResponse = null 305 306 function applyResponse(response) { 307 if (!response || typeof response !== 'object') return 308 cachedResponse = response 309 310 const elements = document.querySelectorAll('[data-plan]') 311 for (let i = 0; i < elements.length; i++) { 312 // Wrap each call so one bad plan can't block the rest of the page.
313 try { 314 updatePriceElement(elements[i], response) 315 } catch (e) { 316 console.warn('[local-pricing] update failed for element:', elements[i], e) 317 } 318 } 319 320 updateTaxIncludedNote(response) 321 updateTaxAwareDisclaimer(response) 322 updateAdaptiveCallout(response) 323 } 324 325 // Re-localize the DOM whenever new pricing markers appear after the initial 326 // fetch. Idempotent because updatePriceElement starts from 327 // `data-original-text` (or captures it on first touch), so re-applying to 328 // already-localized nodes is a no-op. 329 // 330 // Scoped to additions of [data-plan] or page-level placeholder attributes 331 // â attribute mutations on existing nodes are ignored to keep the observer 332 // cheap on pages with frequent unrelated DOM churn. 333 const MARKER_SELECTOR = 334 '[data-plan],[data-tax-included-note],[data-adaptive-pricing-callout],[data-disclaimer-tax-exclusive],[data-disclaimer-tax-inclusive]' 335 336 function nodeHasMarker(node) { 337 if (node.nodeType !== 1) return false 338 if (node.matches && node.matches(MARKER_SELECTOR)) return true 339 return !!(node.querySelector && node.querySelector(MARKER_SELECTOR)) 340 } 341 342 function setupMutationObserver() { 343 if (typeof MutationObserver === 'undefined') return 344 const observer = new MutationObserver(function (mutations) { 345 if (!cachedResponse) return 346 for (let i = 0; i < mutations.length; i++) { 347 const added = mutations[i].addedNodes 348 for (let j = 0; j < added.length; j++) { 349 if (nodeHasMarker(added[j])) { 350 applyResponse(cachedResponse) 351 return 352 } 353 } 354 } 355 }) 356 observer.observe(document.body, {childList: true, subtree: true}) 357 } 358 359 // ---------- entry ---------- 360 361 function fetchPricing(country) { 362 return fetch(buildApiUrl(country), { 363 method: 'GET', 364 credentials: 'omit', 365 headers: {Accept: 'application/json'}, 366 }).then(function (r) { 367 if (!r.ok) throw new Error('HTTP ' + r.status) 368 return r.json() 369 }) 370 } 371 372 // Bail out early if the page has nothing the script can act on. Lets the 373 // script be loaded site-wide (e.g. via global Webflow custom code) without 374 // burning a request to the billing API on pages without pricing elements. 375 function pageHasPricingMarkers() { 376 return !!document.querySelector(MARKER_SELECTOR) 377 } 378 379 function init() { 380 if (!pageHasPricingMarkers()) return 381 const country = getCountryOverride() 382 fetchPricing(country) 383 .then(function (response) { 384 applyResponse(response) 385 setupMutationObserver() 386 }) 387 .catch(function (err) { 388 console.warn('[local-pricing] fetch failed:', err) 389 }) 390 } 391 392 if (document.readyState === 'loading') { 393 document.addEventListener('DOMContentLoaded', init) 394 } else { 395 init() 396 } 397})()
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.