PageSourceSearch

https://claude.com/shared/webflow-local-pricing.js

js claude.com collected 2026-09-24 08:24:10 UTC 15,756 bytes, 397 lines download raw bytes

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.