PageSourceSearch

https://www.wrap-up.ai/widget.js

js wrap-up.ai collected 2026-10-05 13:18:46 UTC 434,703 bytes, 7,014 lines download raw bytes

1/**
2 * Wrap Up AI \u2014 Widget Embed Script
3 * Drop-in JS widget for partners. Usage:
4 *
5 * Popup mode (default \u2014 no changes to existing behaviour):
6 *   <script src="https://www.wrap-up.ai/widget.js" data-token="TOKEN"></script>
7 *
8 * Inline mode (renders full tool directly inside a container div):
9 *   <div id="wrapup-widget"></div>
10 *   <script src="https://www.wrap-up.ai/widget.js" data-token="TOKEN" data-mode="inline"></script>
11 */
12(function () {
13  'use strict';
14
15  // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 1. Bootstrap \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
1500c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
16  var script = document.currentScript || (function () {
17    var scripts = document.querySelectorAll('script[data-token]');
18    return scripts[scripts.length - 1];
19  })();
20
21  if (!script) {
22    console.error('[WrapUpAI] widget.js: no script tag with data-token found');
23    return;
24  }
25
26  var EMBED_TOKEN = script.getAttribute('data-token');
27  var MODE = script.getAttribute('data-mode') || 'popup';
28  var SERVICE = (script.getAttribute('data-service') || 'car');
29  // Where a render is POSTed. Read once, and ABSENT means exactly the path
30  // this file has always used - the defaults below are the literals the
31  // three call sites carried before 2026-09-19, so a host page that sets
32  // nothing builds a byte-identical URL. A surface that renders through a
33  // different endpoint (the partner demo) points them at its own.
34  var GENERATE_PATH = script.getAttribute('data-generate-path') || '/api/widget/generate';
35  var KITCHEN_GENERATE_PATH = script.getAttribute('data-kitchen-generate-path') || '/api/widget/kitchen/generate';
36  // The partner demo (2026-09-23) points the email card's two calls at its
37  // own routes, prefills the address a prospect link already knows, and
38  // asks for a DOM event after a verify and after each render so the page
39  // around the widget can paint its own bar. Every attribute absent = the
40  // widget as it was, byte for byte in behaviour.
41  var VERIFY_EMAIL_PATH = script.getAttribute('data-verify-path') || '/api/widget/verify-email';
42  var CONFIRM_EMAIL_PATH = script.getAttribute('data-confirm-path') || '/api/widget/confirm-email';
43  var PROSPECT_TOKEN = script.getAttribute('data-prospect-token') || '';
44  var PREFILL_EMAIL = script.getAttribute('data-prefill-email') || '';
45  var DEMO_EVENTS = script.getAttribute('data-demo') === '1';
46  // data-sample="0": no sample photo behind the upload box and no "try it
47  // with this sample photo" link - the visitor uploads their own car.
48  // data-sample="backdrop" (the partner demo, 2026-09-23): the sample car
49  // blurred and dimmed behind the box as decoration only - no link, and the
50  // photo is never loaded into the widget. Absent, partner widgets keep the
51  // sample as before.
52  var SAMPLE_ENABLED = script.getAttribute('data-sample') !== '0' && script.getAttribute('data-sample') !== 'backdrop';
53  var SAMPLE_BACKDROP_ONLY = script.getAttribute('data-sample') === 'backdrop';
54  // Deep link: ?wrapup_color=<id> on the host page URL (the swatch wall's
55  // "See this colour on your car" button lands here) preselects that colour
56  // once the grid is built, provided the id is in the partner's colour set.
57  // Read once; applied once per page load, by the first grid that renders.
58  var PRESELECT_COLOR_ID = (function () {
59    try {
60      var v = new URLSearchParams(window.location.search).get('wrapup_color');
61      var n = parseInt(String(v || ''), 10);
62      return isFinite(n) && n > 0 ? String(n) : '';
63    } catch (e) { return ''; }
64  })();
65  var _preselectApplied = false;
66  var API_BASE = (function () {
67    try { return new URL(script.src).origin; } catch (e) { return ''; }
68  })();
69
70  if (!EMBED_TOKEN) {
71    console.error('[WrapUpAI] widget.js: data-token is empty');
72    return;
73  }
74
75  // Shared state
76  var _sessionToken = null;
77  var _colors = [];
78  var _selectedColorId = null;
79  var _selectedFile = null;
80  var _isGenerating = false;
81  var _freeRenderLimit = 3; // overridden from /api/widget/init response
82  // The shop's logo for the strip at the top of the widget, from init:
83  // the uploaded logo (a data URL), or the YOUR LOGO placeholder for a
84  // trial without one, or nothing for a paying partner without one - the
85  // rule of server/lib/partnerWatermark.ts, applied to the page.
86  var _logoUrl = '';
87  var _isTrial = false;
88  // partners.sample_photo from init (2026-09-23): 'none' keeps the empty
89  // upload box plain, 'default' shows the sample car. Absent = default.
90  var _samplePhoto = 'default';
91  var _quoteFormUrl = ''; // optional partner-configured URL for paywall "Get a Quote" button
92  // Wheels rollout C2 (Wheels BETA): entitlement + browse links from init.
93  // wheelsEnabled false or absent = the widget renders and behaves exactly
94  // as before; the Wheels section is never built.
95  var _wheelsEnabled = false;
96  var _wheelSuppliers = [];
97  var _carRenderUrl = ''; // optional partner-configured URL of the car render page
98  var _kitchenRenderUrl = ''; // optional partner-configured URL of the kitchen render page
99  var _businessName = ''; // partner business name for upsell card "Contact {business}" button
100  var _contactEmail = ''; // optional partner contact email for upsell card mailto: button
101  var _openCarQuoteForm = null; // set by injectInline; opens the in-widget quote overlay (inline mode only)
102  // Social rewards (paywall earn-renders entry point). _socialRewardsPossible
103  // starts true and flips false PERMANENTLY (per page load) the first time
104  // the server answers 403 social_rewards_disabled - after that the paywall
105  // renders exactly as it did before this feature existed. _socialClaimCtx
106  // is set by injectInline (car inline) and injectInlineKitchen (kitchen
107  // inline) - each registers its own auth headers and post-claim UI restore,
108  // and the module-scope builder below stays agnostic about which service
109  // set it (the _openCarQuoteForm hook pattern above). No hook = no button:
110  // popup mode never builds the element.
111  var _socialRewardsPossible = true;
112  var _socialClaimCtx = null;
113  // Entitlement from the init payload (strict true, the _wheelsEnabled
114  // pattern below): an absent field reads false, so a partner on an older
115  // cached init payload never builds the button at all.
116  var _socialRewardsEnabled = false;
117
118  // Share-to-Instagram (Tier 1 Growth Loops) — module-level so refs survive
119  // across showInlineResult invocations and the Share button can read them
120  // synchronously inside the click handler. Reset at the START of every
121  // showInlineResult call so a second render does not share against stale
122  // image refs from the previous render.
123  var _shareReady = false;
124  var _shareOriginalImg = null;
125  var _shareRenderImg = null;
126  // Logo cache survives between renders. The asset URL is static (a single
127  // public PNG path) so caching the decoded Image is safe — we never need to
128  // invalidate during a session. The CORS-anonymous fetch and the new asset
129  // URL together also mean we do not inherit any stale no-CORS cached entry
130  // from before the canvas-taint fix landed: /wrapup-ai-watermark.png is a
131  // new path with no prior browser-cache entry to conflict with.
132  var _shareCachedLogo = null;
133  var _shareLogoLoadPromise = null;
134
135  // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 Visitor render-count helpers (localStorage, keyed by embed token) \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
136  function _getVisitorRenders() {
137    try { return parseInt(localStorage.getItem('wuprc_' + EMBED_TOKEN) || '0', 10) || 0; } catch (e) { return 0; }
138  }
139  function _incVisitorRenders() {
140    try { localStorage.setItem('wuprc_' + EMBED_TOKEN, String(_getVisitorRenders() + 1)); } catch (e) {}
141  }
142  function _isAtRenderLimit() {
143    return _freeRenderLimit > 0 && _getVisitorRenders() >= _freeRenderLimit;
144  }
145  // Hover affordance for the calm outline paywall buttons: lighten the grey
146  // border while hovered. Same idiom as the floating trigger button's hover
147  // (inline-style mouseenter/mouseleave) - the file has no injected CSS.
148  function _pwHoverLighten(el) {
149    el.addEventListener('mouseenter', function () { el.style.borderColor = '#666666'; });
150    el.addEventListener('mouseleave', function () { el.style.borderColor = '#444444'; });
151  }
152  // Paywall button rows stack into a full-width column on narrow containers
153  // instead of wrapping into ragged pairs. Same responsive idiom as
154  // applyKtLayout (container-width measurement + ResizeObserver; the file
155  // has no injected CSS, so no media queries), observing the paywall card.
156  function _stackPaywallRow(pw, btnRow) {
157    function apply() {
158      var w = pw.getBoundingClientRect().width || 0;
159      btnRow.style.flexDirection = (w > 0 && w < 480) ? 'column' : 'row';
160    }
161    apply();
162    if (typeof ResizeObserver !== 'undefined') {
163      new ResizeObserver(apply).observe(pw);
164    } else {
165      window.addEventListener('resize', apply);
166    }
167  }
168  // Success pulse on the "X / Y" free-renders counter (both surfaces tag it
169  // with data-wrapup-renders-count). One injected stylesheet, CSS only: a
170  // short scale pulse plus a lime flash, about 600ms, then back to normal.
171  // Skipped entirely under prefers-reduced-motion - checked here AND in the
172  // stylesheet, so a late media-query change is honoured too.
173  function _pulseRendersCounter() {
174    try {
175      if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
176    } catch (e) { return; }
177    if (!document.getElementById('wrapup-renders-pulse-style')) {
178      var st = document.createElement('style');
179      st.id = 'wrapup-renders-pulse-style';
180      st.textContent = '@keyframes wrapup-renders-pulse{0%{transform:scale(1)}35%{transform:scale(1.25);color:#D2D915;text-shadow:0 0 14px #D2D915}100%{transform:scale(1)}}' +
181        '.wrapup-renders-pulse{display:inline-block;animation:wrapup-renders-pulse 600ms ease-out}' +
182        '@media (prefers-reduced-motion:reduce){.wrapup-renders-pulse{animation:none}}';
183      (document.head || document.documentElement).appendChild(st);
184    }
185    var els = document.querySelectorAll('[data-wrapup-renders-count]');
186    for (var i = 0; i < els.length; i++) {
187      (function (el) {
188        el.classList.remove('wrapup-renders-pulse');
189        void el.offsetWidth;
190        el.classList.add('wrapup-renders-pulse');
191        setTimeout(function () { el.classList.remove('wrapup-renders-pulse'); }, 700);
192      })(els[i]);
193    }
194  }
195  // Consent checkbox drawn in CSS (both surfaces). The native control was
196  // styled only through accent-color; once locked - checked AND disabled -
197  // browsers dim disabled controls over the accent, so the lime went muddy,
198  // and the dark card had no colour-scheme hint. One injected stylesheet:
199  // appearance:none, a fixed 18px box, a clearly visible border (#444444 on
200  // the dark card, KT.muted on white - KT.border is too soft to notice), a solid
201  // lime fill with a black check drawn from a rotated border when checked,
202  // and a :disabled rule that keeps opacity and colours identical to the
203  // checked state. Behaviour is untouched: the control still gets disabled.
204  function _ensureConsentStyle() {
205    if (document.getElementById('wrapup-consent-style')) return;
206    var st = document.createElement('style');
207    st.id = 'wrapup-consent-style';
208    st.textContent =
209      '.wrapup-cb{-webkit-appearance:none;appearance:none;width:18px;height:18px;flex:none;box-sizing:border-box;border-radius:4px;position:relative;display:inline-block;cursor:pointer;margin:0;padding:0}' +
210      '.wrapup-cb-dark{color-scheme:dark;border:1px solid #444444;background:#1a1a1a}' +
211      '.wrapup-cb-light{color-scheme:light;border:1px solid ' + KT.muted + ';background:' + KT.bg + '}' +
212      '.wrapup-cb:checked{background:#D2D915;border-color:#D2D915}' +
213      '.wrapup-cb:checked::after{content:"";position:absolute;left:5px;top:1px;width:5px;height:10px;border:solid #000000;border-width:0 2px 2px 0;transform:rotate(45deg)}' +
214      '.wrapup-cb:disabled{opacity:1;cursor:default}' +
215      '.wrapup-cb:disabled:checked{background:#D2D915;border-color:#D2D915;opacity:1}' +
216      '.wrapup-cb:focus-visible{outline:2px solid #D2D915;outline-offset:2px}';
217    (document.head || document.documentElement).appendChild(st);
218  }
219  // opts.quoteOnly (2026-09-22): a TRIAL whose renders are spent shows the
220  // same card with "Get a Quote" alone - the Buy Credits button is OUR
221  // pricing page, not the shop's, and a shop visitor has nothing to buy.
222  function _buildPaywallEl(opts) {
223    var quoteOnly = !!(opts && opts.quoteOnly);
224    var pw = document.createElement('div');
225    pw.setAttribute('style', 'margin-top:20px;padding:28px 24px;background:#1a1a1a;border:1px solid #333333;border-radius:12px;text-align:center');
226    var title = document.createElement('p');
227    title.textContent = 'You\u2019ve used all your free renders';
228    title.setAttribute('style', 'color:#ffffff;font-size:18px;font-weight:700;margin:0 0 8px;font-family:inherit');
229    var sub = document.createElement('p');
230    sub.textContent = quoteOnly ? 'Ask for a quote and the shop will get back to you.' : 'Unlock unlimited renders with a WRAP-UP.AI plan.';
231    sub.setAttribute('style', 'color:#888888;font-size:14px;margin:0 0 20px;font-family:inherit');
232    var btnRow = document.createElement('div');
233    btnRow.setAttribute('style', 'display:flex;gap:10px;justify-content:center;flex-wrap:wrap');
234    if (!quoteOnly) {
235      var buyBtn = document.createElement('a');
236      buyBtn.href = 'https://www.wrap-up.ai?pricing=true';
237      buyBtn.target = '_blank';
238      buyBtn.rel = 'noopener';
239      buyBtn.textContent = 'Buy Credits for More Renders';
240      buyBtn.setAttribute('style', 'display:inline-block;padding:13px 24px;background:transparent;color:#ffffff;border:1px solid #444444;border-radius:8px;font-size:15px;font-weight:600;text-decoration:none;font-family:inherit;cursor:pointer;text-align:center');
241      _pwHoverLighten(buyBtn);
242      btnRow.appendChild(buyBtn);
243    }
244    // "Get a Quote" opens the in-widget overlay (no external nav). Shown
245    // whenever the overlay is available (inline mode), regardless of _quoteFormUrl.
246    if (_openCarQuoteForm) {
247      var quoteBtn = document.createElement('button');
248      quoteBtn.type = 'button';
249      quoteBtn.textContent = 'Get a Quote';
250      quoteBtn.setAttribute('style', 'display:inline-block;padding:13px 24px;background:transparent;color:#ffffff;border:1px solid #444444;border-radius:8px;font-size:15px;font-weight:600;font-family:inherit;cursor:pointer');
251      _pwHoverLighten(quoteBtn);
252      quoteBtn.addEventListener('click', function () { _openCarQuoteForm(); });
253      btnRow.appendChild(quoteBtn);
254    }
255    pw.appendChild(title);
256    pw.appendChild(sub);
257    pw.appendChild(btnRow);
258    _stackPaywallRow(pw, btnRow);
259    return pw;
260  }
261
262  // Social rewards claim element (mounted by injectInline and
263  // injectInlineKitchen below the free-render counter, behind the same
264  // three-part gate). The claim-code call still happens LAZILY on click,
265  // and the 403 social_rewards_disabled handling stays as a safety net for
266  // a stale init payload: it removes the element and flips
267  // _socialRewardsPossible so later builds skip it.
268  function _buildSocialClaimEl(variant) {
269    // Palette split (the _buildHeicErrorEl theme-param precedent): no
270    // argument keeps the car's dark-surface colours exactly as before;
271    // 'light' maps each colour role onto the KT palette so the kitchen
272    // paywall reuses this builder unchanged.
273    var light = variant === 'light';
274    var mutedC   = light ? KT.muted  : '#888888';
275    var textC    = light ? KT.text   : '#ffffff';
276    var borderC  = light ? KT.border : '#444444';
277    var hover    = light ? _ktPwHover : _pwHoverLighten;
278    var box = document.createElement('div');
279    // The id backs the presence check that keeps the verify-time mount
280    // idempotent (the openModal getElementById idiom).
281    box.id = 'wrapup-social-claim';
282    // Bottom margin matches each surface's own block rhythm: 16px on the
283    // car (uploadSection/colorSection) and 20px on the kitchen (uploadCard).
284    // No top margin on the car: the counter above already carries the 16px,
285    // so the seam is one margin, not two.
286    box.setAttribute('style', 'margin-top:' + (light ? '20px' : '0') + ';margin-bottom:' + (light ? '20px' : '16px') + ';padding:20px;background:' + (light ? KT.bg : '#1a1a1a') + ';border:1px solid ' + (light ? KT.border : '#333333') + ';border-radius:12px;text-align:center');
287    var actionWrap = document.createElement('div');
288    var status = document.createElement('p');
289    status.setAttribute('style', 'color:#e74c3c;font-size:12px;margin:8px 0 0;display:none;font-family:inherit');
290    // Designed Instagram claim button - deliberately the SAME yellow accent
291    // on both the dark (car) and light (kitchen) surface, so the variant
292    // parameter does not touch it.
293    var claimBtn = document.createElement('button');
294    claimBtn.type = 'button';
295    claimBtn.setAttribute('style', 'display:flex;width:100%;box-sizing:border-box;flex-direction:row;align-items:center;justify-content:center;gap:14px;padding:14px 20px;background:#D2D915;color:#000000;border:none;border-radius:12px;font-family:inherit;cursor:pointer');
296    var claimIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
297    claimIcon.setAttribute('width', '26');
298    claimIcon.setAttribute('height', '26');
299    claimIcon.setAttribute('viewBox', '227.3 140.1 631.4 631.5');
300    claimIcon.setAttribute('fill', '#000000');
301    claimIcon.setAttribute('style', 'flex-shrink:0');
302    var claimIconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
303    claimIconPath.setAttribute('d', 'M799.49 692.64c0,10.88 -8.79,19.72 -19.63,19.72l-473.63 0c-10.9,0 -19.74,-8.84 -19.74,-19.72l0 -296 78.93 0c-10.28,14.8 -13.76,42.41 -13.76,59.92 0,105.69 86.15,191.66 192.07,191.66 105.92,0 192.04,-85.97 192.04,-191.66 0,-17.51 -2.41,-44.71 -15.16,-59.92l78.88 0 0 296zm-255.76 -357.21c67,0 121.42,54.24 121.42,121.13 0,66.92 -54.42,121.16 -121.42,121.16 -67,0 -121.42,-54.25 -121.42,-121.16 0,-66.89 54.42,-121.13 121.42,-121.13zm137.44 -96.65c0,-10.9 8.79,-19.74 19.63,-19.74l59.25 0c10.85,0 19.81,8.84 19.81,19.74l0 59.2c0,10.9 -8.96,19.73 -19.81,19.73l-59.25 0c-10.85,0 -19.63,-8.83 -19.63,-19.73l0 -59.2zm102.47 -98.67l-481.3 0c-41.45,0 -75.05,33.54 -75.05,74.89l0 481.67c0,41.37 33.6,74.9 75.05,74.9l481.3 0c41.51,0 75.09,-33.53 75.09,-74.9l0 -481.67c0,-41.35 -33.58,-74.89 -75.09,-74.89z');
304    claimIcon.appendChild(claimIconPath);
305    var claimText = document.createElement('span');
306    claimText.setAttribute('style', 'display:block;text-align:left;line-height:1.2');
307    var claimLine1 = document.createElement('span');
308    claimLine1.textContent = 'FOLLOW US...';
309    claimLine1.setAttribute('style', 'display:block;font-weight:700;font-size:15px');
310    var claimLine2 = document.createElement('span');
311    claimLine2.setAttribute('style', 'display:block;font-size:14px;font-weight:400');
312    claimLine2.appendChild(document.createTextNode('and get '));
313    var claimBold = document.createElement('span');
314    claimBold.textContent = 'FREE RENDERS';
315    claimBold.setAttribute('style', 'font-weight:700');
316    claimLine2.appendChild(claimBold);
317    claimText.appendChild(claimLine1);
318    claimText.appendChild(claimLine2);
319    claimBtn.appendChild(claimIcon);
320    claimBtn.appendChild(claimText);
321    actionWrap.appendChild(claimBtn);
322    box.appendChild(actionWrap);
323    box.appendChild(status);
324
325    function showStatus(msg) { status.textContent = msg; status.style.display = 'block'; }
326
327    claimBtn.addEventListener('click', function () {
328      if (!_socialClaimCtx) return;
329      claimBtn.disabled = true;
330      status.style.display = 'none';
331      fetch(API_BASE + '/api/widget/claim-code', {
332        method: 'POST',
333        headers: _socialClaimCtx.headers()
334      }).then(function (r) {
335        return r.json().catch(function () { return {}; }).then(function (d) {
336          return { status: r.status, ok: r.ok, d: d };
337        });
338      }).then(function (res) {
339        if (res.status === 403 && res.d && res.d.error === 'social_rewards_disabled') {
340          _socialRewardsPossible = false;
341          if (box.parentNode) box.parentNode.removeChild(box);
342          return;
343        }
344        if (res.status === 409 && res.d && res.d.error === 'already_claimed') {
345          actionWrap.innerHTML = '';
346          var usedLine = document.createElement('p');
347          usedLine.textContent = 'This reward was already claimed for your account. You can claim it again in about a month.';
348          usedLine.setAttribute('style', 'color:' + mutedC + ';font-size:13px;margin:0;font-family:inherit');
349          actionWrap.appendChild(usedLine);
350          return;
351        }
352        if (!res.ok || !res.d || !res.d.deepLink) {
353          showStatus('Something went wrong. Please try again.');
354          claimBtn.disabled = false;
355          return;
356        }
357        var d = res.d;
358        actionWrap.innerHTML = '';
359        var rewardN = Number(d.rewardRenders || 0);
360        // Heading plus three numbered steps, left-aligned: a lime numeral
361        // circle on the left and, to its right, a column with the step text
362        // above its control. Always a column, so there is no breakpoint and
363        // no observer - the card itself is the width constraint.
364        var stepsWrap = document.createElement('div');
365        stepsWrap.setAttribute('style', 'text-align:left');
366        var heading = document.createElement('p');
367        heading.textContent = 'Follow us and get ' + rewardN + ' free renders every 30 days';
368        heading.setAttribute('style', 'color:' + textC + ';font-size:15px;font-weight:700;margin:0 0 16px;font-family:inherit;line-height:1.3');
369        stepsWrap.appendChild(heading);
370        function addStep(num, text, control) {
371          var srow = document.createElement('div');
372          srow.setAttribute('style', 'display:flex;flex-direction:row;align-items:flex-start;gap:12px;margin:0 0 14px');
373          var circle = document.createElement('span');
374          circle.textContent = String(num);
375          circle.setAttribute('style', 'flex:none;width:26px;height:26px;border-radius:50%;background:#D2D915;color:#000000;font-size:14px;font-weight:700;font-family:inherit;display:flex;align-items:center;justify-content:center');
376          var col = document.createElement('div');
377          col.setAttribute('style', 'display:flex;flex-direction:column;align-items:flex-start;gap:8px;min-width:0;flex:1 1 auto');
378          var label = document.createElement('span');
379          label.textContent = text;
380          label.setAttribute('style', 'color:' + textC + ';font-size:14px;font-family:inherit;line-height:1.4;padding-top:3px');
381          col.appendChild(label);
382          if (control) col.appendChild(control);
383          srow.appendChild(circle);
384          srow.appendChild(col);
385          stepsWrap.appendChild(srow);
386        }
387        var hasDisplayCode = typeof d.displayCode === 'string' && d.displayCode !== '';
388        // The pasteable code: a calm dashed "coupon" block in the surface
389        // colours (monospace, large) so the yellow accent stays unique to
390        // the action buttons. Copy follows the share flow's guarded
391        // navigator.clipboard.writeText idiom; the button confirms on itself
392        // for two seconds. Without clipboard support the code is still
393        // selectable text and no dead button is offered.
394        var codeWrap = null;
395        if (hasDisplayCode) {
396          codeWrap = document.createElement('div');
397          codeWrap.setAttribute('style', 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:0');
398          var codeBox = document.createElement('span');
399          codeBox.textContent = d.displayCode;
400          codeBox.setAttribute('style', 'display:inline-block;padding:12px 16px;color:' + textC + ';border:1px dashed ' + borderC + ';border-radius:8px;font-family:monospace;font-size:16px;font-weight:700;letter-spacing:1px');
401          codeWrap.appendChild(codeBox);
402          if (navigator.clipboard && navigator.clipboard.writeText) {
403            var copyBtn = document.createElement('button');
404            copyBtn.type = 'button';
405            copyBtn.textContent = 'Copy';
406            copyBtn.setAttribute('style', 'display:inline-block;padding:12px 16px;background:transparent;color:' + textC + ';border:1px solid ' + borderC + ';border-radius:8px;font-size:13px;font-weight:600;font-family:inherit;cursor:pointer');
407            hover(copyBtn);
408            copyBtn.addEventListener('click', function () {
409              navigator.clipboard.writeText(d.displayCode).then(function () {
410                copyBtn.textContent = 'Copied';
411                setTimeout(function () { copyBtn.textContent = 'Copy'; }, 2000);
412              }).catch(function () {});
413            });
414            codeWrap.appendChild(copyBtn);
415          }
416        }
417        var igBtn = document.createElement('a');
418        igBtn.href = d.deepLink;
419        igBtn.target = '_blank';
420        igBtn.rel = 'noopener';
421        igBtn.textContent = 'Open Instagram';
422        // The forward step of the flow the yellow claim button started -
423        // carry the accent. "I am following you" below stays the calm
424        // outline on both surfaces.
425        igBtn.setAttribute('style', 'display:inline-block;padding:13px 24px;background:#D2D915;color:#000000;border:none;border-radius:8px;font-size:15px;font-weight:600;text-decoration:none;font-family:inherit;cursor:pointer;text-align:center');
426        var checkBtn = document.createElement('button');
427        checkBtn.type = 'button';
428        checkBtn.textContent = 'I am following you';
429        checkBtn.setAttribute('style', 'display:inline-block;padding:13px 24px;background:transparent;color:' + textC + ';border:1px solid ' + borderC + ';border-radius:8px;font-size:15px;font-weight:600;font-family:inherit;cursor:pointer');
430        hover(checkBtn);
431        // Consecutive taps with no change. A confirmation genuinely can take
432        // a few seconds, so the first two keep the wait-and-retry wording;
433        // from the third on, the visitor is pointed at the DM, which is
434        // where the verifier explains a refusal (account already claimed
435        // inside the window, monthly cap reached, and so on). Local to this
436        // element - no storage, no extra request.
437        var unconfirmedTaps = 0;
438        checkBtn.addEventListener('click', function () {
439          checkBtn.disabled = true;
440          status.style.display = 'none';
441          _socialClaimCtx.checkState(function (restored) {
442            if (!restored) {
443              unconfirmedTaps += 1;
444              showStatus(unconfirmedTaps >= 3
445                ? 'We could not confirm the follow. Please check your Instagram messages for the reason.'
446                : 'Not confirmed yet - please wait a moment and try again.');
447              checkBtn.disabled = false;
448            } else {
449              _pulseRendersCounter();
450            }
451          });
452        });
453        addStep(1, 'Copy this code', codeWrap);
454        addStep(2, 'Open Instagram and paste it in the chat', igBtn);
455        addStep(3, 'Come back here and push:', checkBtn);
456        actionWrap.appendChild(stepsWrap);
457      }).catch(function () {
458        showStatus('Something went wrong. Please try again.');
459        claimBtn.disabled = false;
460      });
461    });
462    return box;
463  }
464
465  // Upsell card shown below every free render. Always offers
466  // "Purchase plan for more renders"; additionally offers a
467  // mailto: "Contact {businessName}" button when the partner has configured
468  // a contact email, and a "Get a quote for this render" button when the
469  // partner has configured a quote form URL.
470  function _buildUpsellCardEl() {
471    var card = document.createElement('div');
472    card.setAttribute('style', 'margin-top:20px;padding:20px 24px;background:#1a1a1a;border:1px solid #333333;border-radius:12px;text-align:center');
473    var title = document.createElement('p');
474    title.textContent = 'Want more renders?';
475    title.setAttribute('style', 'color:#ffffff;font-size:16px;font-weight:700;margin:0 0 6px;font-family:inherit');
476    var btnRow = document.createElement('div');
477    btnRow.setAttribute('style', 'display:flex;gap:10px;justify-content:center;flex-wrap:wrap');
478
479    // Button 1 (always): Purchase plan
480    var planBtn = document.createElement('a');
481    planBtn.href = 'https://www.wrap-up.ai?pricing=true';
482    planBtn.target = '_blank';
483    planBtn.rel = 'noopener';
484    planBtn.textContent = 'Purchase plan for more renders';
485    planBtn.setAttribute('style', 'display:inline-block;padding:11px 20px;background:#D2D915;color:#000000;border-radius:8px;font-size:14px;font-weight:700;text-decoration:none;font-family:inherit;cursor:pointer');
486    btnRow.appendChild(planBtn);
487
488    // Button 2 (optional): Contact the shop — mailto
489    if (_contactEmail) {
490      var contactBtn = document.createElement('a');
491      contactBtn.href = 'mailto:' + _contactEmail;
492      contactBtn.rel = 'noopener';
493      contactBtn.textContent = _businessName ? ('Contact ' + _businessName) : 'Contact us';
494      contactBtn.setAttribute('style', 'display:inline-block;padding:11px 20px;background:transparent;color:#ffffff;border:1px solid #444444;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;font-family:inherit;cursor:pointer');
495      btnRow.appendChild(contactBtn);
496    }
497
498    // Button 3 (optional): in-widget quote overlay (inline mode). No external
499    // nav; shown whenever the overlay is available, regardless of _quoteFormUrl.
500    if (_openCarQuoteForm) {
501      var quoteBtn = document.createElement('button');
502      quoteBtn.type = 'button';
503      quoteBtn.textContent = 'Get a quote for this render';
504      quoteBtn.setAttribute('style', 'display:inline-block;padding:11px 20px;background:transparent;color:#ffffff;border:1px solid #444444;border-radius:8px;font-size:14px;font-weight:600;font-family:inherit;cursor:pointer');
505      quoteBtn.addEventListener('click', function () { _openCarQuoteForm(); });
506      btnRow.appendChild(quoteBtn);
507    }
508
509    card.appendChild(title);
510    card.appendChild(btnRow);
511    return card;
512  }
513
514  // Detect iPhone HEIC/HEIF files by extension or MIME. Used by both popup
515  // and inline upload paths. Server-side server/heicToJpeg.ts remains as a
516  // safety net for files that slip past (e.g. .heic renamed to .jpg with
517  // stripped MIME).
518  function _isHeicFile(file) {
519    if (!file) return false;
520    var name = (file.name || '').toLowerCase();
521    var len = name.length;
522    if (len >= 5 && name.lastIndexOf('.heic') === len - 5) return true;
523    if (len >= 5 && name.lastIndexOf('.heif') === len - 5) return true;
524    var t = (file.type || '').toLowerCase();
525    return t === 'image/heic' || t === 'image/heif';
526  }
527
528  // Inline notice rendered when a HEIC/HEIF file is detected client-side.
529  // theme: 'light' (popup modal, white background) or 'dark' (inline widget,
530  // #1a1a1a background). onReset is invoked when the visitor clicks
531  // Try another file. The wrap stops click propagation so accidental clicks
532  // inside the notice do not re-open the picker.
533  function _buildHeicErrorEl(theme, onReset) {
534    var dark = theme === 'dark';
535    var bg          = dark ? '#1a1a1a' : '#fff5f5';
536    var border      = dark ? '#3a2a2a' : '#f3c2c2';
537    var headColor   = dark ? '#ff8a8a' : '#b00020';
538    var bodyColor   = dark ? '#dddddd' : '#333333';
539    var subtleColor = dark ? '#888888' : '#666666';
540    var btnBorder   = dark ? '#555555' : '#cccccc';
541    var btnColor    = dark ? '#ffffff' : '#222222';
542
543    var wrap = document.createElement('div');
544    wrap.setAttribute('style', [
545      'background:' + bg, 'border:1px solid ' + border, 'border-radius:10px',
546      'padding:16px', 'text-align:left', 'font-family:inherit', 'font-size:14px',
547      'color:' + bodyColor
548    ].join(';'));
549    wrap.addEventListener('click', function (e) { e.stopPropagation(); });
550
551    var msg = document.createElement('p');
552    msg.textContent = 'This looks like a HEIC photo (Apple\u2019s iPhone format). Please convert it to JPG or PNG first.';
553    msg.setAttribute('style', 'margin:0 0 10px;color:' + headColor + ';font-weight:600');
554    wrap.appendChild(msg);
555
556    var toggle = document.createElement('button');
557    toggle.type = 'button';
558    toggle.textContent = 'Show how to convert';
559    toggle.setAttribute('style', [
560      'background:none', 'border:0', 'padding:0', 'margin:0 0 10px',
561      'color:' + headColor, 'cursor:pointer', 'font-family:inherit',
562      'font-size:13px', 'font-weight:600', 'text-decoration:underline'
563    ].join(';'));
564
565    var details = document.createElement('div');
566    details.setAttribute('style', 'display:none;margin:0 0 12px');
567
568    function _platformBlock(label, body) {
569      var box = document.createElement('div');
570      box.setAttribute('style', 'margin-bottom:8px');
571      var lbl = document.createElement('p');
572      lbl.textContent = label;
573      lbl.setAttribute('style', 'margin:0;font-weight:600;color:' + bodyColor);
574      var p = document.createElement('p');
575      p.textContent = body;
576      p.setAttribute('style', 'margin:2px 0 0;color:' + subtleColor);
577      box.appendChild(lbl);
578      box.appendChild(p);
579      return box;
580    }
581
582    details.appendChild(_platformBlock('Mac',
583      'Right-click the photo > Open With > Preview > File > Export > set Format to JPEG > Save.'));
584    details.appendChild(_platformBlock('Windows',
585      'Right-click the photo > Open With > Photos > click the "..." menu > Save as > choose JPG.'));
586    details.appendChild(_platformBlock('Android',
587      'Open the photo in Google Photos > tap Share > choose "Save as JPG" or send via WhatsApp.'));
588    var tip = document.createElement('p');
589    tip.textContent = 'Tip: Sending the photo via WhatsApp also converts it automatically.';
590    tip.setAttribute('style', 'margin:8px 0 0;font-size:12px;color:' + subtleColor);
591    details.appendChild(tip);
592
593    toggle.addEventListener('click', function () {
594      var open = details.style.display !== 'none';
595      details.style.display = open ? 'none' : 'block';
596      toggle.textContent = open ? 'Show how to convert' : 'Hide how to convert';
597    });
598
599    wrap.appendChild(toggle);
600    wrap.appendChild(details);
601
602    var resetBtn = document.createElement('button');
603    resetBtn.type = 'button';
604    resetBtn.textContent = 'Try another file';
605    resetBtn.setAttribute('style', [
606      'background:transparent', 'border:1px solid ' + btnBorder, 'border-radius:8px',
607      'padding:8px 14px', 'color:' + btnColor, 'cursor:pointer', 'font-family:inherit',
608      'font-size:13px', 'font-weight:600'
609    ].join(';'));
610    resetBtn.addEventListener('click', function () {
611      if (typeof onReset === 'function') onReset();
612    });
613    wrap.appendChild(resetBtn);
614
615    return wrap;
616  }
617
618  // \u2500\u2500 Share-to-Instagram helpers (Tier 1 Growth Loops) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
619  // Mirrors client/src/lib/share-composite.ts; the React surface uses that
620  // module and the widget surface uses these inline equivalents because
621  // widget.js is a self-contained vanilla bundle that cannot import from
622  // src/. KEEP THE TWO IN SYNC: caption template, composite geometry,
623  // footer-height formula, and PNG logo path are shared design contracts.
624  // If you change either, change the other and update the cross-references
625  // in both files.
626  //
627  // CORS / canvas tainting: every Image loaded here gets crossOrigin set
628  // BEFORE src so the browser issues the request as a CORS request. The
629  // widget runs cross-origin from API_BASE (host page on partner domain,
630  // assets on wrap-up.ai) and without this attribute drawing the image to
631  // canvas would taint the canvas and toBlob() would throw. The server
632  // sends Access-Control-Allow-Origin on /api/widget/* (middleware at
633  // server/partnerRoutes.ts) and on /wrapup-ai-watermark.png (narrow GET
634  // route at server/static.ts). Both responses must keep that header for
635  // the share button to work.
636
637  function _shareLoadImage(src) {
638    return new Promise(function (resolve, reject) {
639      var img = new Image();
640      // crossOrigin must be set BEFORE src so the request is issued in CORS
641      // mode. Setting it after src is too late — the request has already
642      // been queued without the CORS flag.
643      img.crossOrigin = 'anonymous';
644      img.onload = function () { resolve(img); };
645      img.onerror = function () { reject(new Error('Image load failed: ' + src)); };
646      img.src = src;
647    });
648  }
649
650  function _shareLoadLogo() {
651    if (_shareCachedLogo) return Promise.resolve(_shareCachedLogo);
652    if (!_shareLogoLoadPromise) {
653      _shareLogoLoadPromise = _shareLoadImage(API_BASE + '/wrapup-ai-watermark.png')
654        .then(function (img) { _shareCachedLogo = img; return img; })
655        .catch(function (err) { _shareLogoLoadPromise = null; throw err; });
656    }
657    return _shareLogoLoadPromise;
658  }
659
660  // MIRROR: this footer-height formula is the canonical version at
661  // server/imageProcessing.ts:60-62 (function createBannerSvg). The React
662  // equivalent is in client/src/lib/share-composite.ts. KEEP THE THREE IN
663  // SYNC: any change to the multiplier or the clamp bounds on the server
664  // must also be applied at the two client locations.
665  function _shareFooterHeight(surface, W) {
666    return surface === 'widget'
667      ? Math.max(84, Math.min(120, Math.round(W * 0.105)))
668      : Math.max(56, Math.min(80, Math.round(W * 0.07)));
669  }
670
671  // Strips spaces and non-alphanumeric while preserving original casing and
672  // accented characters. RegExp constructor (not literal) for parity with the
673  // React helper.
674  var _SHARE_HASHTAG_RE = new RegExp('[^\\p{L}\\p{N}]', 'gu');
675  function _shareToHashtag(s) { return s.replace(_SHARE_HASHTAG_RE, ''); }
676
677  function _shareBuildCaption(manufacturer, colorName) {
678    var m = (manufacturer == null ? '' : String(manufacturer)).trim();
679    var c = (colorName == null ? '' : String(colorName)).trim();
680    var lines = ['Check out my custom wrap visualization made with @go_wrapup'];
681    if (m && c) {
682      lines.push('');
683      lines.push('Wrap-color: ' + m + ' ' + c);
684    }
685    var tags = ['#wrapupAI', '#wrapup', '#instantcolorchanger'];
686    if (m) {
687      var sm = _shareToHashtag(m);
688      if (sm) tags.push('#' + sm);
689    }
690    if (c) {
691      var sc = _shareToHashtag(c);
692      if (sc) tags.push('#' + sc);
693    }
694    lines.push('');
695    lines.push(tags.join(' '));
696    return lines.join('\n');
697  }
698
699  function _shareBuildComposite(original, render, surface) {
700    return new Promise(function (resolve, reject) {
701      var W = render.naturalWidth, H = render.naturalHeight;
702      var oW = original.naturalWidth, oH = original.naturalHeight;
703      if (!W || !H) return reject(new Error('Render image has zero dimensions'));
704      if (!oW || !oH) return reject(new Error('Original image has zero dimensions'));
705
706      // Crop the server-baked footer-bar off the bottom. H_eff is the
707      // visible content height after the crop; both halves draw to H_eff.
708      var footerH = _shareFooterHeight(surface, W);
709      var H_eff = Math.max(1, H - footerH);
710
711      var canvas = document.createElement('canvas');
712      canvas.width = W; canvas.height = H_eff;
713      var ctx = canvas.getContext('2d');
714      if (!ctx) return reject(new Error('Canvas 2d context unavailable'));
715
716      // Symmetric top-anchored crop. The render loses its bottom footerH
717      // pixels \u2014 a (1 - H_eff/H) proportion of vertical content. We apply
718      // the SAME proportion crop to the original so both halves show the
719      // same vertical slice of their respective sources. Source rect on
720      // the original is anchored to (0, 0) and extends through the LEFT
721      // HALF of the original (oW/2) and the TOP H_eff/H proportion
722      // (oH * cropProp). No cover-crop, no centering \u2014 straight top-left
723      // top-down crop.
724      //
725      // Known limitation: this preserves vertical-content symmetry between
726      // the two halves but does NOT preserve aspect ratio when the
727      // original's aspect (oW/oH) differs from the render's aspect (W/H).
728      // drawImage will then non-uniformly scale the original to fit
729      // (W/2, H_eff), producing visible horizontal or vertical distortion
730      // on the LEFT (BEFORE) side. The previous cover-crop implementation
731      // hid this distortion at the cost of asymmetric vertical content
732      // (left half showed centered crop; right half showed top-anchored
733      // crop). Vertical symmetry was the explicit user requirement;
734      // aspect-mismatch distortion on arbitrary user uploads is the
735      // accepted tradeoff.
736      var cropProportion = H_eff / H;
737      var origCropH = oH * cropProportion;
738      ctx.drawImage(original, 0, 0, oW / 2, origCropH, 0, 0, W / 2, H_eff);
739
740      // Right half: top portion of render's right half, pixel-perfect.
741      // Source rect excludes the bottom footerH rows; destination matches.
742      ctx.drawImage(render, W / 2, 0, W / 2, H_eff, W / 2, 0, W / 2, H_eff);
743
744      // 2px white divider with dark glow at the seam.
745      ctx.save();
746      ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
747      ctx.shadowBlur = 8;
748      ctx.fillStyle = '#ffffff';
749      ctx.fillRect(W / 2 - 1, 0, 2, H_eff);
750      ctx.restore();
751
752      // WrapUp AI watermark logo: bottom-left of the AFTER side, flush
753      // against the divider, anchored to the post-crop bottom edge. 4% of
754      // effective canvas height (subtle attribution, not a foreground
755      // element). Drawn directly on the photo \u2014 no badge background. The
756      // PNG asset (658\u00d7152, RGBA) carries its own alpha so transparent
757      // pixels reveal the photo underneath cleanly.
758      //
759      // Legibility tradeoff: without the previous semi-transparent black
760      // background, logo readability depends on the underlying photo
761      // content. On dark wraps and dim scenes the white watermark reads
762      // cleanly; on light scenes (white wraps, sky, bright environments)
763      // the logo may be hard to see. Accepted tradeoff for a cleaner
764      // share image.
765      _shareLoadLogo().then(function (logo) {
766        var logoH = H_eff * 0.04;
767        var logoAspect = logo.naturalWidth / logo.naturalHeight || (658 / 152);
768        var logoW = logoH * logoAspect;
769        var marginFromDivider = 8, marginFromBottom = 8;
770        var logoX = W / 2 + marginFromDivider;
771        var logoY = H_eff - marginFromBottom - logoH;
772        ctx.drawImage(logo, logoX, logoY, logoW, logoH);
773      }).catch(function () {
774        // skip silently
775      }).then(function () {
776        canvas.toBlob(function (blob) {
777          if (blob) resolve(blob);
778          else reject(new Error('Canvas toBlob returned null'));
779        }, 'image/jpeg', 0.85);
780      });
781    });
782  }
783
784  // Minimal toast: fixed top-center, dark background, fades after ~3s. No
785  // existing widget toast/notice helper to reuse \u2014 setS1Status is local to
786  // the inline form area; showHeicError replaces dropzone content. So this
787  // is built fresh. No backdrop-blur (GR 5) and no emojis (GR 3).
788  function _showShareToast(msg) {
789    var el = document.createElement('div');
790    el.textContent = msg;
791    el.setAttribute('style', [
792      'position:fixed', 'top:16px', 'left:50%',
793      'transform:translateX(-50%)',
794      'background:#1a1a1a', 'color:#ffffff',
795      'border:1px solid #333333', 'border-radius:8px',
796      'padding:10px 16px', 'font-size:13px',
797      'font-family:inherit', 'font-weight:500',
798      'box-shadow:0 4px 16px rgba(0,0,0,0.4)',
799      'z-index:9999998',
800      'max-width:90vw', 'text-align:center',
801      'transition:opacity 0.3s',
802      'opacity:1'
803    ].join(';'));
804    document.body.appendChild(el);
805    setTimeout(function () { el.style.opacity = '0'; }, 2700);
806    setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3100);
807  }
808
809  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
810
811  // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 2. Init: fetch session + colors \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
81100c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
812  // ---- Showcase mode (SW-2): "Create Your Own Filtered Widget" ------------
813  // A partner-configured, upload-less colour showcase gallery. Additive on
814  // the injectCombined precedent: ONE dispatch line in init(), everything
815  // else lives in these functions. Reads data-widget-id, calls the SW-1
816  // public showcase init and renders a display gallery - no consent, no
817  // upload, no session token, no render calls, no credits; the generate
818  // paths are never touched. On ANY failure (missing container, missing
819  // preset id, HTTP error, bad JSON, network) it renders NOTHING - a
820  // broken showcase must never deface a partner page. Self-contained
821  // styling in the widget idiom (#111111 dark, GR 3), ASCII source only.
822  function injectShowcase() {
823    var rootEl = document.getElementById('wrapup-widget');
824    if (!rootEl) return;
825    var widgetIdAttr = script.getAttribute('data-widget-id') || '';
826    if (!widgetIdAttr) return;
827    fetch(API_BASE + '/api/widget/showcase-init', {
828      method: 'POST',
829      headers: { 'Content-Type': 'application/json' },
830      body: JSON.stringify({ token: EMBED_TOKEN, widgetId: widgetIdAttr })
831    }).then(function (r) {
832      if (!r.ok) return null;
833      return r.json().catch(function () { return null; });
834    }).then(function (data) {
835      if (!data || !Array.isArray(data.colors)) return;
836      renderShowcase(rootEl, data);
837    }).catch(function () { /* silent - render nothing */ });
838  }
839
840  function renderShowcase(rootEl, data) {
841    // Fix round 1: the showcase displays wrap_colors - CAR colours - so
842    // the kitchen link is gone entirely, regardless of entitlement. The
843    // popup exists only to offer the car render link; without a
844    // carRenderUrl a tile click is a NO-OP (never a popup with just a
845    // name).
846    var links = data.links || {};
847    var carUrl = (links.carRenderUrl && typeof links.carRenderUrl === 'string') ? links.carRenderUrl : '';
848
849    var container = document.createElement('div');
850    container.setAttribute('style', [
851      'background:#111111', 'border-radius:16px', 'padding:24px',
852      'font-family:inherit', 'color:#ffffff', 'width:100%',
853      'box-sizing:border-box', 'position:relative'
854    ].join(';'));
855
856    if (data.name) {
857      var heading = document.createElement('p');
858      heading.textContent = String(data.name);
859      heading.setAttribute('style', 'margin:0 0 16px;font-size:20px;font-weight:700;color:#ffffff;font-family:inherit');
860      container.appendChild(heading);
861    }
862
863    // Fix round 1: a horizontally scrolling band - columns flow to the
864    // RIGHT, swipe/scroll to browse; 3 rows from 480px up, 2 on
865    // narrower/mobile screens, picked ONCE at render (inline styles
866    // cannot carry media queries; the host page's CSS is never relied
867    // on). Momentum scrolling on touch; platform-default scrollbars.
868    // Fix round 2 (presentation only): smaller tiles (roughly two-thirds
869    // of round 1 - a ~100px target instead of 150px), and the column
870    // width is COMPUTED from the band's own width so that a partial
871    // (~40%) column always peeks past the right edge at any container
872    // width - the scroll affordance never depends on the tile width
873    // happening to not divide the container. Arrow buttons + edge fades
874    // are wired below, driven by one scroll-state updater.
875    var bandRows = (rootEl.clientWidth || window.innerWidth || 0) >= 480 ? 3 : 2;
876    var bandW = rootEl.clientWidth || window.innerWidth || 600;
877    var fullCols = Math.max(2, Math.floor((bandW - 24) / 112));
878    var colW = Math.max(88, Math.min(120, Math.round((bandW - 12 * fullCols) / (fullCols + 0.4))));
879    var bandWrap = document.createElement('div');
880    bandWrap.setAttribute('style', 'position:relative');
881    var scroller = document.createElement('div');
882    scroller.setAttribute('style', [
883      'overflow-x:auto', '-webkit-overflow-scrolling:touch'
884    ].join(';'));
885    var grid = document.createElement('div');
886    grid.setAttribute('style', [
887      'display:grid', 'grid-auto-flow:column',
888      'grid-template-rows:repeat(' + bandRows + ', auto)',
889      'grid-auto-columns:' + colW + 'px',
890      'gap:12px'
891    ].join(';'));
892
893    data.colors.forEach(function (c) {
894      if (!c || typeof c !== 'object') return;
895      var tile = document.createElement('button');
896      tile.type = 'button';
897      tile.setAttribute('style', [
898        'display:block', 'width:100%', 'padding:0', 'text-align:left',
899        'background:#1a1a1a', 'border:1px solid #333333', 'border-radius:10px',
900        // Fix round 2: pointer + popup only when the car link exists; a
901        // no-op tile keeps the default cursor and no hover affordance.
902        'overflow:hidden', (carUrl ? 'cursor:pointer' : 'cursor:default'), 'font-family:inherit'
903      ].join(';'));
904      if (c.thumbnailUrl) {
905        var img = document.createElement('img');
906        img.src = c.thumbnailUrl;
907        img.alt = String(c.name || '');
908        img.loading = 'lazy';
909        img.setAttribute('style', 'width:100%;aspect-ratio:1;object-fit:cover;display:block;background:#222222');
910        tile.appendChild(img);
911      } else {
912        var ph = document.createElement('div');
913        ph.setAttribute('style', 'width:100%;aspect-ratio:1;background:#222222');
914        tile.appendChild(ph);
915      }
916      var meta = document.createElement('div');
917      meta.setAttribute('style', 'padding:5px 7px');
918      var nameEl = document.createElement('span');
919      nameEl.textContent = String(c.name || '');
920      nameEl.setAttribute('style', 'display:block;font-size:11px;font-weight:600;color:#ffffff;font-family:inherit;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
921      var manEl = document.createElement('span');
922      manEl.textContent = String(c.manufacturer || '');
923      manEl.setAttribute('style', 'display:block;font-size:10px;color:#888888;font-family:inherit;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
924      meta.appendChild(nameEl);
925      meta.appendChild(manEl);
926      tile.appendChild(meta);
927      tile.addEventListener('click', function () {
928        if (!carUrl) return;
929        openShowcasePopup(container, c, carUrl);
930      });
931      grid.appendChild(tile);
932    });
933    scroller.appendChild(grid);
934    bandWrap.appendChild(scroller);
935
936    // Fix round 2: scroll affordance. Edge fades (pointer-events:none
937    // gradient overlays into the #111111 container) and arrow buttons at
938    // the band's edges, each visible ONLY on a side with more content -
939    // one updater drives all four off the scroller's own scroll state.
940    // Arrows scroll one viewport-width per click; glyphs are angle-quote
941    // escapes so the file stays ASCII.
942    function edgeFade(side) {
943      var f = document.createElement('div');
944      f.setAttribute('style', [
945        'position:absolute', 'top:0', 'bottom:0', side + ':0', 'width:32px',
946        'pointer-events:none', 'display:none',
947        'background:linear-gradient(to ' + (side === 'left' ? 'right' : 'left') + ', #111111, rgba(17,17,17,0))'
948      ].join(';'));
949      bandWrap.appendChild(f);
950      return f;
951    }
952    function edgeArrow(side, glyph, label) {
953      var b = document.createElement('button');
954      b.type = 'button';
955      b.setAttribute('aria-label', label);
956      b.textContent = glyph;
957      b.setAttribute('style', [
958        'position:absolute', 'top:50%', 'transform:translateY(-50%)', side + ':4px',
959        'width:32px', 'height:32px', 'border-radius:50%', 'display:none',
960        'background:rgba(0,0,0,0.65)', 'border:1px solid #333333', 'color:#ffffff',
961        'font-size:18px', 'line-height:1', 'cursor:pointer', 'font-family:inherit',
962        'padding:0', 'z-index:2'
963      ].join(';'));
964      b.addEventListener('click', function () {
965        var delta = (side === 'left' ? -1 : 1) * scroller.clientWidth;
966        if (scroller.scrollBy) scroller.scrollBy({ left: delta, behavior: 'smooth' });
967        else scroller.scrollLeft += delta;
968      });
969      bandWrap.appendChild(b);
970      return b;
971    }
972    var leftFade = edgeFade('left');
973    var rightFade = edgeFade('right');
974    var leftBtn = edgeArrow('left', '\u2039', 'Scroll left');
975    var rightBtn = edgeArrow('right', '\u203a', 'Scroll right');
976    function updateBandAffordance() {
977      var maxScroll = scroller.scrollWidth - scroller.clientWidth;
978      var scrollable = maxScroll > 1;
979      var atStart = scroller.scrollLeft <= 1;
980      var atEnd = scroller.scrollLeft >= maxScroll - 1;
981      leftFade.style.display = scrollable && !atStart ? 'block' : 'none';
982      leftBtn.style.display = scrollable && !atStart ? 'block' : 'none';
983      rightFade.style.display = scrollable && !atEnd ? 'block' : 'none';
984      rightBtn.style.display = scrollable && !atEnd ? 'block' : 'none';
985    }
986    scroller.addEventListener('scroll', updateBandAffordance);
987    container.appendChild(bandWrap);
988
989    // Footer - the existing widget attribution idiom, verbatim.
990    var footer = document.createElement('p');
991    footer.innerHTML = 'Powered by <a href="https://www.wrap-up.ai" target="_blank" rel="noopener" style="color:#D2D915;text-decoration:none">WRAP-UP.AI</a>';
992    footer.setAttribute('style','margin:16px 0 0;font-size:11px;color:#444444;text-align:center;font-family:inherit');
993    container.appendChild(footer);
994
995    rootEl.innerHTML = '';
996    rootEl.appendChild(container);
997    // Scroll metrics only exist once the band is in the document.
998    updateBandAffordance();
999  }
1000
1001  function openShowcasePopup(container, color, carUrl) {
1002    var existing = container.querySelector('[data-showcase-popup]');
1003    if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
1004    var overlay = document.createElement('div');
1005    overlay.setAttribute('data-showcase-popup', '');
1006    overlay.setAttribute('style', [
1007      'position:absolute', 'top:0', 'left:0', 'right:0', 'bottom:0',
1008      'background:rgba(0,0,0,0.75)', 'display:flex', 'align-items:center',
1009      'justify-content:center', 'padding:24px', 'box-sizing:border-box',
1010      'border-radius:16px', 'z-index:10'
1011    ].join(';'));
1012    var card = document.createElement('div');
1013    card.setAttribute('style', [
1014      'background:#1a1a1a', 'border:1px solid #333333', 'border-radius:12px',
1015      'padding:24px', 'max-width:320px', 'width:100%', 'text-align:center',
1016      'position:relative', 'box-sizing:border-box'
1017    ].join(';'));
1018    var close = document.createElement('button');
1019    close.type = 'button';
1020    close.setAttribute('aria-label', 'Close');
1021    // Multiplication-sign close glyph via escape - the file stays ASCII.
1022    close.textContent = '\u00d7';
1023    close.setAttribute('style', [
1024      'position:absolute', 'top:8px', 'right:8px', 'width:28px', 'height:28px',
1025      'background:transparent', 'border:none', 'color:#888888', 'font-size:20px',
1026      'cursor:pointer', 'line-height:1', 'font-family:inherit'
1027    ].join(';'));
1028    close.addEventListener('click', function () {
1029      if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
1030    });
1031    var title = document.createElement('p');
1032    title.textContent = String(color.name || '');
1033    title.setAttribute('style', 'margin:0 0 4px;font-size:16px;font-weight:700;color:#ffffff;font-family:inherit');
1034    var man = document.createElement('p');
1035    man.textContent = String(color.manufacturer || '');
1036    man.setAttribute('style', 'margin:0 0 16px;font-size:12px;color:#888888;font-family:inherit');
1037    card.appendChild(close);
1038    card.appendChild(title);
1039    card.appendChild(man);
1040    // Same-tab link (it is the partner's own site). The popup only opens
1041    // when carRenderUrl exists (the tile click no-ops otherwise), so the
1042    // car link is always present here. Fix round 1: the kitchen link is
1043    // gone - this gallery shows CAR colours.
1044    var carA = document.createElement('a');
1045    carA.href = carUrl;
1046    carA.textContent = 'See this colour on your car';
1047    carA.setAttribute('style', [
1048      'display:block', 'box-sizing:border-box', 'width:100%', 'padding:12px',
1049      'margin:8px 0 0', 'background:#D2D915', 'color:#000000', 'border-radius:8px',
1050      'font-size:14px', 'font-weight:700', 'text-decoration:none', 'font-family:inherit'
1051    ].join(';'));
1052    card.appendChild(carA);
1053    overlay.appendChild(card);
1054    container.appendChild(overlay);
1055  }
1056  // ---- end Showcase mode (SW-2) --------------------------------------------
1057
1058  function init() {
1059    // Showcase mode (SW-2): its own public init endpoint, no render
1060    // session, no credits - dispatched BEFORE the render-init fetch so a
1061    // showcase embed never touches /api/widget/init (and a render-init
1062    // failure can never deface a showcase page).
1063    if (MODE === 'showcase') { injectShowcase(); return; }
1064    fetch(API_BASE + '/api/widget/init', {
1065      method: 'POST',
1066      headers: { 'Content-Type': 'application/json' },
1067      body: JSON.stringify({ widgetId: EMBED_TOKEN, service: SERVICE })
1068    })
1069      .then(function (r) {
1070        // Preserve HTTP status even when the body is non-JSON (proxy error pages etc.)
1071        return r.json().catch(function () { return {}; }).then(function (d) { d._httpStatus = r.status; return d; });
1072      })
1073      .then(function (data) {
1074        if (!data.sessionToken) {
1075          console.error('[WrapUpAI] init failed:', data.message || data);
1076          renderInitError(data.message || ('HTTP ' + (data._httpStatus || '???')));
1077          return;
1078        }
1079        _sessionToken = data.sessionToken;
1080        _colors = data.colors || [];
1081        _freeRenderLimit = typeof data.freeRenderLimit === 'number' ? data.freeRenderLimit : 3;
1082        _quoteFormUrl = (data.quoteFormUrl && typeof data.quoteFormUrl === 'string') ? data.quoteFormUrl : '';
1083        _carRenderUrl = (data.carRenderUrl && typeof data.carRenderUrl === 'string') ? data.carRenderUrl : '';
1084        _kitchenRenderUrl = (data.kitchenRenderUrl && typeof data.kitchenRenderUrl === 'string') ? data.kitchenRenderUrl : '';
1085        _businessName = (data.businessName && typeof data.businessName === 'string') ? data.businessName : '';
1086        _contactEmail = (data.contactEmail && typeof data.contactEmail === 'string') ? data.contactEmail : '';
1087        _logoUrl = (data.logoUrl && typeof data.logoUrl === 'string') ? data.logoUrl : '';
1088        _isTrial = data.isTrial === true;
1089        _samplePhoto = data.samplePhoto === 'none' ? 'none' : 'default';
1090        // Wheels rollout C2: strict true, so an older cached init payload
1091        // (field absent) keeps the widget byte-identical to today.
1092        _wheelsEnabled = data.wheelsEnabled === true;
1093        _wheelSuppliers = Array.isArray(data.wheelSuppliers) ? data.wheelSuppliers : [];
1094        // Social rewards entitlement (strict true, same rationale as
1095        // _wheelsEnabled above: absent field = today's behaviour).
1096        _socialRewardsEnabled = data.socialRewardsEnabled === true;
1097        // The logo strip goes in first, so in combined mode it sits ABOVE
1098        // the service toggle injectCombined inserts next, and is first in
1099        // every tab. Car inline and combined only; kitchen-only embeds are
1100        // untouched.
1101        if (MODE === 'combined' || (MODE === 'inline' && SERVICE !== 'kitchen')) _ensureLogoStrip();
1102        // Combined car+kitchen mode (data-mode="combined"): a service toggle
1103        // above the widget. injectCombined drives its own per-service init, so
1104        // this initial fetch result is intentionally discarded for combined.
1105        if (MODE === 'combined') { injectCombined(); return; }
1106        // Standalone quote mode (data-mode="quote"): render NO widget. A partner
1107        // button - window.wrapUpOpenQuote() or any [data-wrapup-quote] element -
1108        // opens the existing kitchen quote overlay, which posts to
1109        // /api/widget/quote with this fresh (no-render) session token. v1 is
1110        // kitchen-only; car standalone is a separate future PR.
1111        if (MODE === 'quote') {
1112          if (SERVICE !== 'kitchen') {
1113            console.warn('WrapUp: standalone quote is currently kitchen-only');
1114            return;
1115          }
1116          // Seed the verified-visitor state the kitchen overlay reads (same
1117          // localStorage keys injectInlineKitchen relies on at module load
1117): a
1118          // returning verified visitor gets a prefilled read-only email; an
1119          // unverified one falls into the overlay's editable-email path.
1120          // _ktHistory stays empty / _ktLastRenderId null -> no-render mode.
1121          try { _ktCustomerToken = localStorage.getItem('wup_ct_' + EMBED_TOKEN) || null; } catch (e) { _ktCustomerToken = null; }
1122          try { _ktEmail = localStorage.getItem('wup_email_' + EMBED_TOKEN) || ''; } catch (e) { _ktEmail = ''; }
1123          window.wrapUpOpenQuote = function () {
1124            if (MODE !== 'quote' || !_sessionToken) {
1125              console.warn('WrapUp: quote trigger not ready');
1126              return;
1127            }
1128            _ktOpenQuoteForm(function () {});
1129          };
1130          // Declarative trigger: any [data-wrapup-quote] element opens the overlay.
1131          var attachWrapUpQuoteDelegate = function () {
1132            document.addEventListener('click', function (ev) {
1133              var t = (ev.target && ev.target.closest) ? ev.target.closest('[data-wrapup-quote]') : null;
1134              if (!t) return;
1135              ev.preventDefault();
1136              window.wrapUpOpenQuote();
1137            });
1138          };
1139          if (document.readyState === 'loading') {
1140            document.addEventListener('DOMContentLoaded', attachWrapUpQuoteDelegate);
1141          } else {
1142            attachWrapUpQuoteDelegate();
1143          }
1144          return;
1145        }
1146        if (SERVICE === 'kitchen') { injectInlineKitchen(); return; }
1147        if (MODE === 'inline') {
1148          injectInline();
1149        } else {
1150          injectButton();
1151        }
1152      })
1153      .catch(function (err) {
1154        console.error('[WrapUpAI] init error:', err);
1155        renderInitError('Network error');
1156      });
1157  }
1158
1159  // One logo strip at the very top of the widget, as a sibling BEFORE
1160  // #wrapup-widget (so the inject functions keep exclusive ownership of the
1161  // mount, like the combined toggle). Idempotent by id. The strip has a
1162  // fixed 48px row, so nothing below it moves when the image arrives. No
1163  // strip at all when there is nothing to show (a paying partner without a
1164  // logo), which is the page as it was.
1165  function _ensureLogoStrip() {
1166    var rootEl = document.getElementById('wrapup-widget');
1167    if (!rootEl || !rootEl.parentNode) return;
1168    if (document.getElementById('wrapup-logo-strip')) return;
1169    var src = _logoUrl || (_isTrial ? API_BASE + '/your-logo.svg' : '');
1170    if (!src) return;
1171    var strip = document.createElement('div');
1172    strip.id = 'wrapup-logo-strip';
1173    strip.setAttribute('style', 'display:flex;justify-content:center;align-items:center;height:48px;margin:0 0 12px;box-sizing:border-box;overflow:hidden');
1174    var img = document.createElement('img');
1175    img.alt = _logoUrl ? (_businessName || 'Logo') : 'YOUR LOGO';
1176    img.setAttribute('style', 'display:block;max-height:48px;max-width:240px;width:auto;height:auto');
1177    img.onerror = function () { strip.style.display = 'none'; };
1178    img.src = src;
1179    strip.appendChild(img);
1180    rootEl.parentNode.insertBefore(strip, rootEl);
1181  }
1182
1183  // Inline mode only: if init fails we still want something visible in the
1184  // partner page instead of a silent black area. Popup mode has no mounted
1185  // container yet at page load, so we skip it there (console.error suffices).
1186  function renderInitError(reason) {
1187    if (MODE !== 'inline') return;
1188    var root = document.getElementById('wrapup-widget');
1189    if (!root) return;
1190    var card = document.createElement('div');
1191    card.setAttribute('style', 'background:#1a1a1a;border:1px solid #333333;border-radius:12px;padding:24px;color:#ffffff;font-family:inherit;text-align:center');
1192    var title = document.createElement('p');
1193    title.textContent = 'Widget unavailable';
1194    title.setAttribute('style', 'margin:0 0 6px;font-weight:700;font-size:16px');
1195    var sub = document.createElement('p');
1196    sub.textContent = 'We could not load the wrap visualiser right now. Please try again in a moment.';
1197    sub.setAttribute('style', 'margin:0;font-size:13px;color:#888888');
1198    var detail = document.createElement('p');
1199    detail.textContent = String(reason || '').slice(0, 120);
1200    detail.setAttribute('style', 'margin:10px 0 0;font-size:11px;color:#555555');
1201    card.appendChild(title);
1202    card.appendChild(sub);
1203    if (reason) card.appendChild(detail);
1204    root.appendChild(card);
1205  }
1206
1207  // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 3. Floating trigger button (popup mode only \u2014 unchanged) \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
1208  // ---------------------------------------------------------------------------
1209  // Colour picker - one implementation, used by BOTH partner pickers.
1210  //
1211  // The supplier widget's picker brought over whole: a large selected colour
1212  // with its label, a search box, family and finish filters on swatchbook's
1213  // model, and a grid of square tiles. Written once because the two partner
1214  // pickers had already drifted - the popup had no filters or search at all,
1215  // the inline one had brand+finish with counts taken over the whole list -
1216  // and a second copy would have drifted again.
1217  //
1218  // Colours arrive in opts.theme rather than through a theme system. This file
1219  // has always carried its values inline, the popup is a light modal and the
1220  // inline widget is dark, and inventing a T object here would be a bigger
1221  // change than the picker itself.
1222  //
1223  // opts:
1224  //   theme        {text, text2, surface, border, inputBg, accent}
1225  //   forceNarrow  true keeps the one-column arrangement at any width (the
1226  //                popup: a 560px modal capped at 90vh has no room for a
1227  //                second column beside a dropzone and a Generate button)
1228  //   rows         grid rows on the narrow layout, default 4
1229  //   brandSelect  optional node for the slot the supplier widget gives its
1230  //                product-line chips - top of the right column on wide,
1231  //                above the block on narrow
1232  //   onSelect(c)  the caller keeps its own selection state
1233  //   onRender(shown, total)  after every grid pass
1234  // returns { root, setColors, clear }
1235  // ---------------------------------------------------------------------------
1236  function buildColorPicker(opts) {
1237    var T = opts.theme;
1238    var FORCE_NARROW = opts.forceNarrow === true;
1239    var NARROW_MAX = 480;
1240    var NARROW_COLS = 5;
1241    // Grid rows on the narrow layout. Four by default, the supplier widget's
1242    // number; the popup asks for three, because its grid shares a 90vh modal
1243    // with a dropzone and a Generate button and the fourth row was what put
1244    // that button below the fold on a 768px-tall screen. Wide is always six.
1245    var NARROW_ROWS = (typeof opts.rows === 'number' && opts.rows > 0) ? opts.rows : 4;
1246    var GAP = 8;
1247    var TILE_MIN = 64;
1248    var _all = [];
1249    var _sel = null;
1250    var _tilePx = TILE_MIN;
1251    var _filters = { family: '', finish: '' };
1252    var _query = '';
1253
1254    function pel(tag, styles, text) {
1255      var n = document.createElement(tag);
1256      if (styles && styles.length) n.setAttribute('style', styles.join(';'));
1257      if (text != null) n.textContent = text;
1258      return n;
1259    }
1260    // The NEW badge (2026-10-03): the word NEW on a colour the operator
1261    // marked as recently released (isNew on the colour payload - a boolean,
1262    // the server never sends the date). On a tile it sits in the top-left
1263    // corner; next to the selected colour's name it sits inline. #111111 with
1264    // white text and a 1px white border, so it reads on a light and on a
1265    // dark swatch. Sizes carry !important like the other styles a host
1266    // stylesheet can reach; the text goes in through textContent.
1267    function newBadge(inline) {
1268      var b = pel('span', (inline
1269        ? ['display:inline-block !important', 'vertical-align:middle', 'margin:0 0 0 6px !important']
1270        : ['position:absolute !important', 'top:4px !important', 'left:4px !important', 'pointer-events:none']
1271      ).concat([
1272        'padding:1px 4px !important', 'font-size:9px !important', 'line-height:11px !important',
1273        'font-weight:700 !important', 'letter-spacing:0.5px !important', 'text-transform:none',
1274        'color:#ffffff !important', 'background:#111111 !important',
1275        'border:1px solid #ffffff !important', 'border-radius:3px !important',
1276        'font-family:inherit', 'box-sizing:border-box'
1277      ]), 'NEW');
1278      b.setAttribute('data-new-badge', '1');
1279      return b;
1280    }
1281
1282    var root = pel('div', ['width:100%', 'box-sizing:border-box']);
1283
1284    // ----- the selected colour ------------------------------------------------
1285    var selImage = pel('button', [
1286      'flex:0 0 auto', 'border-radius:12px', 'box-sizing:border-box',
1287      'padding:0', 'cursor:pointer', 'display:none',
1288      'align-items:center', 'justify-content:center',
1289      'background-size:cover', 'background-position:center',
1290      'background-color:' + T.surface, 'border:1px solid ' + T.border
1291    ]);
1292    selImage.type = 'button';
1293    var selPlaceholder = pel('span', [
1294      'font-size:13px', 'font-weight:600', 'color:' + T.text2,
1295      'font-family:inherit', 'padding:0 12px', 'text-align:center'
1296    ], 'Pick a colour below');
1297    selImage.appendChild(selPlaceholder);
1298
1299    var selName = pel('p', [
1300      'margin:0', 'font-size:17px', 'font-weight:700', 'line-height:1.25',
1301      'color:' + T.text, 'font-family:inherit', 'text-align:left',
1302      'display:-webkit-box', '-webkit-line-clamp:3', '-webkit-box-orient:vertical',
1303      'overflow:hidden'
1304    ], '');
1305    var selMeta = pel('p', [
1306      'margin:4px 0 0', 'font-size:13px', 'font-weight:600',
1307      'color:' + T.text2, 'font-family:inherit', 'text-align:left',
1308      'display:-webkit-box', '-webkit-line-clamp:2', '-webkit-box-orient:vertical',
1309      'overflow:hidden'
1310    ], '');
1311    var selFinish = pel('p', [
1312      'margin:2px 0 0', 'font-size:12px', 'font-weight:500',
1313      'color:' + T.text2, 'font-family:inherit', 'text-align:left',
1314      'overflow:hidden', 'text-overflow:ellipsis', 'white-space:nowrap'
1315    ], '');
1316    var selLabel = pel('button', [
1317      'display:none', 'width:100%', 'box-sizing:border-box',
1318      'margin:0 0 10px', 'padding:0', 'min-width:0',
1319      'background:transparent', 'border:none', 'cursor:pointer',
1320      'font-family:inherit', 'text-align:left'
1321    ]);
1322    selLabel.type = 'button';
1323    selLabel.appendChild(selName);
1324    selLabel.appendChild(selMeta);
1325    selLabel.appendChild(selFinish);
1326
1327    function scrollToSwatch() {
1328      if (!_sel) return;
1329      var sw = grid.querySelector('[data-swatch="' + _sel.id + '"]');
1330      if (sw) sw.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1331    }
1332    selImage.addEventListener('click', scrollToSwatch);
1333    selLabel.addEventListener('click', scrollToSwatch);
1334
1335    var selCol = pel('div', ['min-width:0', 'flex:1 1 auto']);
1336    selCol.appendChild(selLabel);
1337    var selRow = pel('div', ['display:none', 'width:100%', 'box-sizing:border-box', 'gap:12px']);
1338    selRow.appendChild(selImage);
1339    selRow.appendChild(selCol);
1340
1341    // ----- search and filters -------------------------------------------------
1342    // Inline !important on every property a host stylesheet can reach.
1343    // 2wrap.com's theme carries `select { width: 100% }`, which stretched each
1344    // select to the full column and pushed the second one clean out of the row
1345    // - rendered, 875px to the right of the visible edge, reachable only by
1346    // dragging a row with no scroll affordance. This file is embedded on sites
1347    // nobody here controls, so the controls state their own geometry and refuse
1348    // to be told otherwise.
1349    //
1350    // flex:1 1 0 shares the row equally: two visible selects take half each,
1351    // one takes the whole row. The row no longer scrolls sideways, so nothing
1352    // can sit outside the part of it a visitor can see. An option longer than
1353    // its select is truncated by the browser, which is the right trade against
1354    // a control nobody can find.
1355    var controlStyle = [
1356      'box-sizing:border-box', 'height:36px !important', 'padding:0 10px',
1357      'border:1px solid ' + T.border, 'border-radius:8px',
1358      'background:' + T.inputBg, 'color:' + T.text,
1359      'font-size:16px !important', 'font-family:inherit', 'outline:none',
1360      'cursor:pointer',
1361      'width:auto !important', 'min-width:0 !important', 'max-width:none !important',
1362      'flex:1 1 0 !important', 'display:block !important'
1363    ].join(';');
1364
1365    var searchInp = document.createElement('input');
1366    searchInp.type = 'text';
1367    searchInp.placeholder = 'Search colours...';
1368    // 16px like the selects: iOS Safari zooms the page when a focused control
1369    // renders under that, and never zooms back out.
1370    searchInp.setAttribute('style', [
1371      'width:100%', 'box-sizing:border-box', 'height:36px', 'padding:0 10px',
1372      'border:1px solid ' + T.border, 'border-radius:8px',
1373      'background:' + T.inputBg, 'color:' + T.text,
1374      'font-size:16px', 'font-family:inherit', 'outline:none', 'margin-bottom:8px'
1375    ].join(';'));
1376    searchInp.addEventListener('input', function () {
1377      _query = searchInp.value.trim().toLowerCase();
1378      render();
1379    });
1380
1381    // How many colours the grid holds: the set the caller handed over, so
1382    // the inline picker's brand select narrows it and "154" becomes the
1383    // brand's figure; with a search or a filter on it reads "23 of 154".
1384    // Both parts are textContent, painted at the end of every grid pass.
1385    // The node rides with the search box: above it in the wide column,
1386    // between it and the filter row on narrow. Sizes carry !important like
1387    // the controls beside it, so a host heading rule cannot restyle the
1388    // number.
1389    var countNum = pel('span', [
1390      'font-size:30px !important', 'font-weight:700 !important',
1391      'line-height:1 !important', 'color:' + T.text, 'font-family:inherit'
1392    ], '');
1393    var countLabel = pel('span', [
1394      'font-size:13px !important', 'font-weight:600 !important',
1395      'line-height:1 !important', 'color:' + T.text2, 'font-family:inherit'
1396    ], '');
1397    var countEl = pel('div', [
1398      'display:flex !important', 'align-items:baseline', 'gap:6px',
1399      'margin:0 0 8px', 'text-align:left', 'width:100%', 'box-sizing:border-box'
1400    ]);
1401    countEl.appendChild(countNum);
1402    countEl.appendChild(countLabel);
1403    function paintCount(shown, total) {
1404      var narrowed = !!(_query || _filters.family || _filters.finish);
1405      countNum.textContent = String(narrowed ? shown : total);
1406      // The leading space is for screen readers, which read the two spans as
1407      // one string; the flex gap draws the visible one, and the line-leading
1408      // space itself never renders.
1409      countLabel.textContent = narrowed ? ' of ' + total + ' colours' : ' colours';
1410    }
1411
1412    var filterRow = pel('div', [
1413      'display:flex', 'gap:8px', 'margin-bottom:10px',
1414      'flex-wrap:nowrap', 'overflow:hidden'
1415    ]);
1416    var controls = {};
1417    ['family', 'finish'].forEach(function (key) {
1418      var sel = document.createElement('select');
1419      sel.setAttribute('aria-label', key === 'family' ? 'Family' : 'Finish');
1420      sel.setAttribute('style', controlStyle);
1421      sel.addEventListener('change', function () {
1422        _filters[key] = sel.value;
1423        // The selection is deliberately kept. A colour filtered out of the
1424        // grid stays chosen and stays in the block above.
1425        render();
1426      });
1427      controls[key] = sel;
1428      filterRow.appendChild(sel);
1429    });
1430
1431    // swatchbook.js's model (client/public/swatchbook.js): one predicate whose
1432    // `skip` argument ignores a control's OWN selection, so each option counts
1433    // "given the others", the finish list read off the data, and the "Other"
1434    // bucket for colours with no family. Scope is whatever the caller has
1435    // already narrowed to - the inline picker's brand select, in practice.
1436    function passes(c, skip) {
1437      if (skip !== 'family' && _filters.family) {
1438        if (_filters.family === 'Other'
1439          ? (c.families && c.families.length)
1440          : (c.families || []).indexOf(_filters.family) === -1) return false;
1441      }
1442      if (skip !== 'finish' && _filters.finish && (c.category || '') !== _filters.finish) return false;
1443      return true;
1444    }
1445    function familyValues() {
1446      var out = [], other = false;
1447      _all.forEach(function (c) {
1448        var fs = c.families || [];
1449        if (!fs.length) { other = true; return; }
1450        fs.forEach(function (f) { if (f && out.indexOf(f) === -1) out.push(f); });
1451      });
1452      out.sort();
1453      if (other) out.push('Other');
1454      return out;
1455    }
1456    function finishValues() {
1457      var out = [];
1458      _all.forEach(function (c) {
1459        var f = c.category || '';
1460        if (f && out.indexOf(f) === -1) out.push(f);
1461      });
1462      return out.sort();
1463    }
1464    function countFor(key, v) {
1465      var n = 0;
1466      _all.forEach(function (c) {
1467        if (!passes(c, key)) return;
1468        if (key === 'family') {
1469          if (v === 'Other' ? !(c.families && c.families.length) : (c.families || []).indexOf(v) !== -1) n++;
1470        } else if ((c.category || '') === v) n++;
1471      });
1472      return n;
1473    }
1474    function familyLabel(f) {
1475      var t = String(f || '');
1476      return t.charAt(0).toUpperCase() + t.slice(1);
1477    }
1478    // Rebuilt every pass so the counts follow the other control. Cleared child
1479    // by child: nothing here writes innerHTML with content in it.
1480    function paintFilters() {
1481      var lists = { family: familyValues(), finish: finishValues() };
1482      var allLabel = { family: 'All families', finish: 'All finishes' };
1483      var shown = 0;
1484      ['family', 'finish'].forEach(function (key) {
1485        var sel = controls[key];
1486        var values = lists[key];
1487        // One value is not a choice.
1488        if (values.length < 2) {
1489          // setProperty, not style.display: assigning drops the !important
1490          // flag the style attribute carries, and a host sheet could then put
1491          // the control back.
1492          sel.style.setProperty('display', 'none', 'important');
1493          _filters[key] = '';
1494          return;
1495        }
1496        sel.style.setProperty('display', 'block', 'important');
1497        shown++;
1498        if (_filters[key] && values.indexOf(_filters[key]) === -1) _filters[key] = '';
1499        while (sel.firstChild) sel.removeChild(sel.firstChild);
1500        var all = document.createElement('option');
1501        all.value = '';
1502        all.textContent = allLabel[key];
1503        sel.appendChild(all);
1504        values.forEach(function (v) {
1505          var o = document.createElement('option');
1506          o.value = v;
1507          o.textContent = (key === 'family' ? familyLabel(v) : v) + ' (' + countFor(key, v) + ')';
1508          sel.appendChild(o);
1509        });
1510        sel.value = _filters[key];
1511      });
1512      filterRow.style.display = shown ? 'flex' : 'none';
1513    }
1514
1515    // ----- the grid -----------------------------------------------------------
1516    var grid = pel('div', [
1517      'display:grid', 'grid-template-columns:repeat(auto-fill, minmax(' + TILE_MIN + 'px, 1fr))',
1518      'gap:' + GAP + 'px', 'overflow-y:auto', 'padding-right:4px'
1519    ]);
1520    var noMatch = pel('p', [
1521      'margin:6px 0 0', 'font-size:13px', 'color:' + T.text2,
1522      'font-family:inherit', 'display:none'
1523    ], 'No colours match');
1524
1525    root.appendChild(selRow);
1526    root.appendChild(searchInp);
1527    root.appendChild(countEl);
1528    root.appendChild(filterRow);
1529    root.appendChild(grid);
1530    root.appendChild(noMatch);
1531
1532    function render() {
1533      paintFilters();
1534      grid.innerHTML = '';
1535      var shown = 0;
1536      _all.forEach(function (c) {
1537        if (_query) {
1538          var hay = (c.name || '') + ' ' + (c.colorNumber || '');
1539          if (hay.toLowerCase().indexOf(_query) === -1) return;
1540        }
1541        if (!passes(c)) return;
1542        shown++;
1543        // A plain square. The caption every tile used to carry is gone: it
1544        // made the cell taller than it is wide, and the name is in the block
1545        // above at a size worth reading.
1546        var sw = pel('div', [
1547          'height:' + _tilePx + 'px', 'border-radius:10px', 'cursor:pointer',
1548          'border:2px solid transparent', 'box-sizing:border-box',
1549          'background-size:cover', 'background-position:center',
1550          'background-color:' + (c.hexColor || T.surface)
1551        ]);
1552        sw.setAttribute('data-swatch', String(c.id));
1553        sw.title = c.colorNumber ? c.colorNumber + ' ' + c.name : c.name;
1554        if (c.thumbnailUrl) sw.style.backgroundImage = 'url("' + c.thumbnailUrl + '")';
1555        if (_sel && _sel.id === c.id) sw.style.borderColor = T.accent;
1556        if (c.isNew === true) { sw.style.position = 'relative'; sw.appendChild(newBadge(false)); }
1557        // opts.isLocked (2026-09-23, the partner demo): a colour the host
1558        // will refuse carries a small lock and its click goes to
1559        // opts.onLocked instead of selecting. Absent, nothing changes.
1560        var lockedTile = !!(opts.isLocked && opts.isLocked(c));
1561        if (lockedTile) {
1562          sw.style.position = 'relative';
1563          sw.style.opacity = '0.55';
1564          var lock = pel('span', ['position:absolute', 'right:4px', 'bottom:4px', 'width:16px', 'height:16px', 'border-radius:4px', 'background:rgba(0,0,0,0.7)', 'display:flex', 'align-items:center', 'justify-content:center', 'pointer-events:none']);
1565          lock.innerHTML = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
1566          lock.setAttribute('data-locked', '1');
1567          sw.appendChild(lock);
1568        }
1569        sw.addEventListener('click', function () {
1570          if (lockedTile) { if (opts.onLocked) opts.onLocked(c); return; }
1571          select(c);
1572          grid.querySelectorAll('[data-swatch]').forEach(function (s) { s.style.borderColor = 'transparent'; });
1573          sw.style.borderColor = T.accent;
1574        });
1575        grid.appendChild(sw);
1576      });
1577      noMatch.style.display = shown === 0 ? 'block' : 'none';
1578      paintCount(shown, _all.length);
1579      if (opts.onRender) opts.onRender(shown, _all.length);
1580      applyLayout();
1581    }
1582
1583    function paintSelected() {
1584      if (_sel) {
1585        // hasAiSample rides both partner colour payloads now. Strict true, so
1586        // an older cached one without the field falls back to the swatch
1587        // rather than asking for an image that may not exist.
1588        var path = _sel.hasAiSample === true ? '/ai-sample' : '/swatch';
1589        selImage.style.backgroundImage =
1590          'url("' + API_BASE + '/api/colors/' + _sel.id + path + '?w=480")';
1591        selImage.style.backgroundColor = _sel.hexColor || T.surface;
1592        selName.textContent = _sel.name;
1593        if (_sel.isNew === true) selName.appendChild(newBadge(true));
1594        selMeta.textContent = (_sel.manufacturer || '')
1595          + (_sel.colorNumber ? ' \u00B7 ' + _sel.colorNumber : '');
1596        selFinish.textContent = _sel.category || '';
1597      } else {
1598        selImage.style.backgroundImage = 'none';
1599        selImage.style.backgroundColor = T.surface;
1600        selName.textContent = '';
1601        selMeta.textContent = '';
1602        selFinish.textContent = '';
1603      }
1604      applyLayout();
1605    }
1606
1607    function colWidth(width, narrow) {
1608      var cols = narrow ? NARROW_COLS : Math.max(1, Math.floor((width + GAP) / (TILE_MIN + GAP)));
1609      return (width - (cols - 1) * GAP) / cols;
1610    }
1611    function tileSpan(width, narrow, tiles) {
1612      return colWidth(width, narrow) * tiles + GAP * (tiles - 1);
1613    }
1614    function applyLayout() {
1615      var pad = parseFloat(getComputedStyle(grid).paddingRight) || 0;
1616      var width = grid.clientWidth - pad;
1617      if (!(width > 0)) return;
1618      var narrow = FORCE_NARROW || width < NARROW_MAX;
1619      var picked = !!_sel;
1620
1621      var col = colWidth(width, narrow);
1622      var size = tileSpan(width, narrow, 3);
1623      selImage.style.width = size + 'px';
1624      selImage.style.height = size + 'px';
1625      // Wide keeps the square as a dashed placeholder so the column beside it
1626      // does not jump on the first click; narrow has no second column to
1627      // protect and the square is most of the screen, so it goes.
1628      selImage.style.display = (picked || !narrow) ? 'flex' : 'none';
1629      selImage.style.borderStyle = picked ? 'solid' : 'dashed';
1630      selPlaceholder.style.display = picked ? 'none' : 'block';
1631      selImage.style.cursor = picked ? 'pointer' : 'default';
1632      selLabel.style.display = picked ? 'block' : 'none';
1633      selRow.style.alignItems = narrow ? 'center' : 'flex-start';
1634
1635      // The SAME nodes, moved - never a second copy.
1636      if (narrow) {
1637        if (opts.brandSelect && opts.brandSelect.parentNode !== root) root.insertBefore(opts.brandSelect, selRow);
1638        if (searchInp.parentNode !== root) root.insertBefore(searchInp, grid);
1639        if (countEl.parentNode !== root) root.insertBefore(countEl, grid);
1640        if (filterRow.parentNode !== root) root.insertBefore(filterRow, grid);
1641      } else {
1642        if (opts.brandSelect && opts.brandSelect.parentNode !== selCol) selCol.insertBefore(opts.brandSelect, selLabel);
1643        if (searchInp.parentNode !== selCol) selCol.appendChild(searchInp);
1644        if (countEl.parentNode !== selCol) selCol.insertBefore(countEl, searchInp);
1645        if (filterRow.parentNode !== selCol) selCol.appendChild(filterRow);
1646      }
1647      var show = picked || !narrow;
1648      selRow.style.display = show ? 'flex' : 'none';
1649      selRow.style.marginBottom = show ? '10px' : '0';
1650
1651      _tilePx = col;
1652      grid.style.gridTemplateColumns = narrow
1653        ? 'repeat(' + NARROW_COLS + ', 1fr)'
1654        : 'repeat(auto-fill, minmax(' + TILE_MIN + 'px, 1fr))';
1655      var painted = grid.querySelectorAll('[data-swatch]');
1656      for (var i = 0; i < painted.length; i++) painted[i].style.height = col + 'px';
1657      var rows = narrow ? NARROW_ROWS : 6;
1658      grid.style.maxHeight = Math.round(rows * col + (rows - 1) * GAP) + 'px';
1659    }
1660
1661    function select(c) {
1662      _sel = c;
1663      paintSelected();
1664      if (opts.onSelect) opts.onSelect(c);
1665    }
1666
1667    if (typeof ResizeObserver === 'function') {
1668      new ResizeObserver(function () { applyLayout(); }).observe(grid);
1669    } else if (window.addEventListener) {
1670      window.addEventListener('resize', applyLayout);
1671    }
1672
1673    return {
1674      root: root,
1675      setColors: function (colors) {
1676        _all = Array.isArray(colors) ? colors : [];
1677        _sel = null;
1678        _filters = { family: '', finish: '' };
1679        paintSelected();
1680        render();
1681      },
1682      // Re-filter without touching the selection: the inline brand select.
1683      refilter: function (colors) {
1684        _all = Array.isArray(colors) ? colors : [];
1685        render();
1686      },
1687      // Repaint the tiles (the locks) without touching the selection.
1688      relock: function () { render(); },
1689      clear: function () {
1690        _sel = null;
1691        paintSelected();
1692        grid.querySelectorAll('[data-swatch]').forEach(function (s) { s.style.borderColor = 'transparent'; });
1693      },
1694      selectById: function (id) {
1695        var sw = grid.querySelector('[data-swatch="' + id + '"]');
1696        if (sw) { sw.click(); return true; }
1697        return false;
1698      },
1699      layout: applyLayout
1700    };
1701  }
1702
1703  function injectButton() {
1704    var btn = document.createElement('button');
1705    btn.id = 'wrapup-trigger';
1706    btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/><path d="M8 12l3 3 5-5"/></svg> Get My Free Renders';
1707    btn.setAttribute('style', [
1708      'position:fixed', 'bottom:24px', 'right:24px', 'z-index:999999',
1709      'display:flex', 'align-items:center', 'gap:8px', 'padding:12px 20px',
1710      'background:#D2D915', 'color:#000', 'border:none', 'border-radius:50px',
1711      'font-size:15px', 'font-weight:600', 'cursor:pointer',
1712      'box-shadow:0 4px 20px rgba(0,0,0,0.25)',
1713      'transition:transform 0.15s,box-shadow 0.15s',
1714      'font-family:system-ui,sans-serif'
1715    ].join(';'));
1716    btn.addEventListener('mouseenter', function () {
1717      btn.style.transform = 'scale(1.05)';
1718      btn.style.boxShadow = '0 6px 28px rgba(0,0,0,0.3)';
1719    });
1720    btn.addEventListener('mouseleave', function () {
1721      btn.style.transform = 'scale(1)';
1722      btn.style.boxShadow = '0 4px 20px rgba(0,0,0,0.25)';
1723    });
1724    btn.addEventListener('click', openModal);
1725    document.body.appendChild(btn);
1726  }
1727
1728  // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 4. Modal (popup mode only \u2014 unchanged) \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
1729  function openModal() {
1730    if (document.getElementById('wrapup-modal-overlay')) return;
1731
1732    var overlay = document.createElement('div');
1733    overlay.id = 'wrapup-modal-overlay';
1734    overlay.setAttribute('style', [
1735      'position:fixed', 'inset:0', 'z-index:9999998',
1736      'background:rgba(0,0,0,0.65)',
1737      'display:flex', 'align-items:center', 'justify-content:center',
1738      'padding:16px', 'box-sizing:border-box'
1739    ].join(';'));
1740    overlay.addEventListener('click', function (e) {
1741      if (e.target === overlay) closeModal();
1742    });
1743
1744    var modal = document.createElement('div');
1745    modal.setAttribute('style', [
1746      'background:#fff', 'border-radius:16px', 'width:100%', 'max-width:560px',
1747      'max-height:90vh', 'overflow-y:auto !important', 'padding:28px', 'box-sizing:border-box',
1748      'font-family:system-ui,sans-serif', 'position:relative'
1749    ].join(';'));
1750
1751    var closeBtn = document.createElement('button');
1752    closeBtn.innerHTML = '&times;';
1753    closeBtn.setAttribute('style', [
1754      'position:absolute', 'top:16px', 'right:16px',
1755      'background:none', 'border:none', 'font-size:24px',
1756      'cursor:pointer', 'color:#666', 'line-height:1'
1757    ].join(';'));
1758    closeBtn.addEventListener('click', closeModal);
1759
1760    var title = document.createElement('h2');
1761    title.textContent = 'Visualise a Car Wrap';
1762    title.setAttribute('style', 'margin:0 0 20px;font-size:20px;color:#111;font-weight:700');
1763
1764    var uploadLabel = document.createElement('p');
1765    uploadLabel.textContent = '1. Upload a photo of your car';
1766    uploadLabel.setAttribute('style', 'margin:0 0 8px;font-weight:600;color:#333;font-size:14px');
1767
1768    var dropzone = document.createElement('div');
1769    dropzone.id = 'wrapup-dropzone';
1770    dropzone.setAttribute('style', [
1771      'border:2px dashed #ddd', 'border-radius:10px', 'padding:24px',
1772      'text-align:center', 'cursor:pointer', 'color:#888', 'font-size:14px',
1773      'margin-bottom:20px', 'transition:border-color 0.2s'
1774    ].join(';'));
1775    dropzone.innerHTML = '<div>&#128247; Click or drag a photo here</div><div style="font-size:12px;margin-top:4px">JPG or PNG</div>';
1776    var DROPZONE_IDLE_HTML = dropzone.innerHTML;
1777
1778    var fileInput = document.createElement('input');
1779    fileInput.type = 'file';
1780    fileInput.accept = 'image/*';
1781    fileInput.style.display = 'none';
1782
1783    dropzone.addEventListener('click', function () { fileInput.click(); });
1784    dropzone.addEventListener('dragover', function (e) {
1785      e.preventDefault();
1786      dropzone.style.borderColor = '#D2D915';
1787    });
1788    dropzone.addEventListener('dragleave', function () {
1789      dropzone.style.borderColor = '#ddd';
1790    });
1791    dropzone.addEventListener('drop', function (e) {
1792      e.preventDefault();
1793      dropzone.style.borderColor = '#ddd';
1794      var f = e.dataTransfer.files[0];
1795      if (!f) return;
1796      if (_isHeicFile(f)) { showHeicError(); return; }
1797      handleFile(f);
1798    });
1799    fileInput.addEventListener('change', function () {
1800      var f = fileInput.files[0];
1801      fileInput.value = '';
1802      if (!f) return;
1803      if (_isHeicFile(f)) { showHeicError(); return; }
1804      handleFile(f);
1805    });
1806
1807    function showHeicError() {
1808      _selectedFile = null;
1809      dropzone.innerHTML = '';
1810      dropzone.appendChild(_buildHeicErrorEl('light', function () {
1811        dropzone.innerHTML = DROPZONE_IDLE_HTML;
1812        fileInput.click();
1813      }));
1814    }
1815
1816    function handleFile(file) {
1817      _selectedFile = file;
1818      dropzone.innerHTML = '<div style="color:#D2D915;font-weight:600">&#10003; ' + file.name + '</div>';
1819    }
1820
1821    var colorLabel = document.createElement('p');
1822    colorLabel.textContent = '2. Choose a wrap colour';
1823    colorLabel.setAttribute('style', 'margin:0 0 8px;font-weight:600;color:#333;font-size:14px');
1824
1825    // The shared picker, in the light values this modal already uses. Always
1826    // the narrow arrangement: the modal is 560px wide and capped at 90vh, and
1827    // it also has to hold a dropzone, the Generate button and the result, so
1828    // a second column beside the image would push the button past the fold.
1829    // The flat "name by manufacturer" line it used to carry is gone - the
1830    // block prints the name, the brand, the colour number and the finish.
1831    var picker = buildColorPicker({
1832      theme: {
1833        text: '#333333', text2: '#888888', surface: '#f5f5f5',
1834        border: '#dddddd', inputBg: '#ffffff', accent: '#D2D915'
1835      },
1836      forceNarrow: true,
1837      // Three rows, not four: the grid shares the modal with a dropzone and
1838      // the Generate button, and the fourth row is exactly what pushed that
1839      // button under the fold on a 768px-tall screen.
1840      rows: 3,
1841      onSelect: function (c) { _selectedColorId = c.id; }
1842    });
1843    var colorGrid = picker.root;
1844    colorGrid.style.marginBottom = '20px';
1845    picker.setColors(_colors);
1846    // Deep link (popup): the grid is built on every open; the first build
1847    // after page load clicks the preselected swatch, which runs the same
1848    // handler a tap does. The modal is fixed-position, so nothing scrolls.
1849    if (PRESELECT_COLOR_ID && !_preselectApplied) {
1850      if (picker.selectById(PRESELECT_COLOR_ID)) _preselectApplied = true;
1851    }
1852
1853    var genBtn = document.createElement('button');
1854    genBtn.id = 'wrapup-gen-btn';
1855    genBtn.textContent = 'Visualise Now';
1856    genBtn.setAttribute('style', [
1857      'width:100%', 'padding:14px', 'background:#D2D915', 'color:#000',
1858      'border:none', 'border-radius:10px', 'font-size:16px', 'font-weight:700',
1859      'cursor:pointer', 'display:block'
1860    ].join(';'));
1861    genBtn.addEventListener('click', function() {
1862      if (_customerToken) {
1863        doGenerate();
1864      } else {
1865        step1El.style.display = 'block';
1866        emailInput.focus();
1867      }
1868    });
1869
1870    var resultArea = document.createElement('div');
1871    resultArea.id = 'wrapup-result';
1872    resultArea.style.display = 'none';
1873
1874    // The Generate button follows the modal down instead of sitting at a
1875    // fixed place in it. Three grid rows got it within 40px of the fold on a
1876    // 768px-tall screen and no row count was going to close that for every
1877    // screen, so it sticks to the bottom of the modal's own scroll and is
1878    // reachable at any height.
1879    //
1880    // A wrapper rather than position:sticky on the button itself: the band
1881    // needs the modal's white behind it, or swatches scroll through the
1882    // button's rounded corners, and it needs room above so the grid never
1883    // touches it.
1884    //
1885    // Nothing is needed to keep the result area clear of it. A bottom-sticky
1886    // element releases once its own place in the flow rises into view, and the
1887    // band sits ABOVE the result area, so scrolling into a render carries the
1888    // button up and away rather than parking it over the picture.
1889    var genSticky = document.createElement('div');
1890    genSticky.setAttribute('style', [
1891      'position:sticky', 'bottom:0', 'z-index:1',
1892      'background:#ffffff', 'padding:10px 0 16px', 'margin:0'
1893    ].join(';'));
1894    genSticky.appendChild(genBtn);
1895
1896    modal.appendChild(closeBtn);
1897    modal.appendChild(title);
1898    modal.appendChild(uploadLabel);
1899    modal.appendChild(dropzone);
1900    modal.appendChild(fileInput);
1901    modal.appendChild(colorLabel);
1902    modal.appendChild(colorGrid);
1903    modal.appendChild(genSticky);
1904    modal.appendChild(resultArea);
1905
1906    overlay.appendChild(modal);
1907    document.body.appendChild(overlay);
1908    // The grid has no width until the overlay is in the document, and the
1909    // picker sizes its tiles and its image from that width. The observer
1910    // inside it reports this too, but only where ResizeObserver exists; this
1911    // call is what makes the first paint right everywhere.
1912    picker.layout();
1913  }
1914
1915  function closeModal() {
1916    var overlay = document.getElementById('wrapup-modal-overlay');
1917    if (overlay) overlay.remove();
1918    _selectedFile = null;
1919    _selectedColorId = null;
1920    _isGenerating = false;
1921  }
1922
1923  // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 5. Generation (popup mode only \u2014 unchanged) \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
1924  function runGenerate() {
1925    if (_isGenerating) return;
1926    if (!_selectedFile) { alert('Please upload a photo of your car first.'); return; }
1927    if (!_selectedColorId) { alert('Please select a wrap colour.'); return; }
1928
1929    // Free render limit check (client-side, keyed by embed token)
1930    if (_isAtRenderLimit()) {
1931      var resultEl = document.getElementById('wrapup-result');
1932      if (resultEl) {
1933        resultEl.style.display = 'block';
1934        resultEl.innerHTML = '';
1935        resultEl.appendChild(_buildPaywallEl());
1936      }
1937      return;
1938    }
1939
1940    _isGenerating = true;
1941    var btn = document.getElementById('wrapup-gen-btn');
1942    if (btn) { btn.textContent = 'Generating\u2026'; btn.disabled = true; }
1943
1944    var formData = new FormData();
1945    formData.append('image', _selectedFile);
1946    formData.append('colorId', String(_selectedColorId));
1947
1948    fetch(API_BASE + GENERATE_PATH, {
1949      method: 'POST',
1950      headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _customerToken || '' },
1951      body: formData
1952    })
1953      .then(function (r) {
1954        if (r.status === 402) {
1955          return r.json().catch(function () { return {}; }).then(function (d) {
1956            _isGenerating = false;
1957            if (btn) { btn.textContent = 'Visualise Now'; btn.disabled = false; }
1958            showWidgetError(_renderRefusalMessage(d));
1959            return null;
1960          });
1961        }
1962        if (r.status === 401) {
1963          _isGenerating = false;
1964          if (btn) { btn.textContent = 'Visualise Now'; btn.disabled = false; }
1965          init();
1966          return null;
1967        }
1968        if (!r.ok) {
1969          _isGenerating = false;
1970          if (btn) { btn.textContent = 'Visualise Now'; btn.disabled = false; }
1971          showWidgetError('Something went wrong. Please try again.');
1972          return null;
1973        }
1974        return r.json();
1975      })
1976      .then(function (data) {
1977        if (!data) return;
1978        _isGenerating = false;
1979        if (btn) { btn.textContent = 'Visualise Now'; btn.disabled = false; }
1980        _incVisitorRenders();
1981        showResult(data);
1982      })
1983      .catch(function (err) {
1984        _isGenerating = false;
1985        if (btn) { btn.textContent = 'Visualise Now'; btn.disabled = false; }
1986        console.error('[WrapUpAI] generate error:', err);
1987        showWidgetError('Something went wrong. Please try again.');
1988      });
1989  }
1990
1991  function showWidgetError(msg) {
1992    var el = document.getElementById('wrapup-result');
1993    if (!el) return;
1994    el.style.display = 'block';
1995    // BUILT, not interpolated. Since the line above in
1996    // _renderRefusalMessage, msg can be a string the SERVER wrote, and
1997    // writing a server-controlled string into innerHTML on a partner's own
1998    // site is an injection surface rather than a styling choice. The
1999    // element, the style string and the colour are exactly what the markup
2000    // produced; only the way the text arrives changed.
2001    el.innerHTML = '';
2002    var p = document.createElement('p');
2003    p.setAttribute('style', 'color:#c0392b;font-weight:600;text-align:center;padding:16px 0');
2004    p.textContent = msg;
2005    el.appendChild(p);
2006  }
2007
2008  // Car render image URL for a history/result entry. Prefers the unguessable
2009  // image token (GET /api/widget/renders/t/:token/image); falls back to the
2010  // sequential id route only when an entry carries no token; and finally to
2011  // a stored imageUrl (data-URL renders that were never persisted). Every
2012  // car-surface site that needs an image URL calls this - none builds inline.
2013  function _carImageUrl(entry) {
2014    if (!entry) return '';
2015    if (typeof entry.token === 'string' && entry.token) return API_BASE + '/api/widget/renders/t/' + entry.token + '/image';
2016    if (entry.id != null) return API_BASE + '/api/widget/renders/' + entry.id + '/image';
2017    return typeof entry.imageUrl === 'string' ? entry.imageUrl : '';
2018  }
2019
2020  function showResult(data) {
2021    var resultArea = document.getElementById('wrapup-result');
2022    if (!resultArea) return;
2023    resultArea.style.display = 'block';
2024    resultArea.innerHTML = [
2025      '<hr style="margin:0 0 16px;border:none;border-top:1px solid #eee">',
2026      '<p style="margin:0 0 8px;font-weight:600;color:#333;font-size:14px">Your wrap visualisation</p>',
2027      '<p style="margin:0 0 12px;font-size:12px;color:#888">' + (data.colorName || '') + (data.manufacturer ? ' \u2014 ' + data.manufacturer : '') + '</p>',
2028      '<img src="' + _carImageUrl({ token: data.imageToken, imageUrl: data.imageUrl }) + '" alt="Wrap visualisation" style="width:100%;border-radius:10px;display:block">',
2029      '<p style="margin:12px 0 0;font-size:11px;color:#bbb;text-align:center">Powered by <a href="https://www.wrap-up.ai" target="_blank" rel="noopener" style="color:#D2D915;text-decoration:none">WRAP-UP.AI</a></p>'
2030    ].join('');
2031    resultArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2032  }
2033
2034  // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 6. Inline mode \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
203400c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
2035  function injectInline() {
2036    var rootEl = document.getElementById('wrapup-widget');
2037    if (!rootEl) {
2038      console.error('[WrapUpAI] inline mode: no element with id="wrapup-widget" found');
2039      return;
2040    }
2041
2042    // Expose the in-widget quote overlay to the module-scope paywall / upsell
2043    // builders (mirrors the _ktAddHistory hook pattern). openQuoteFormOverlay is
2044    // a hoisted function declaration below, so this assignment is valid here.
2045    _openCarQuoteForm = openQuoteFormOverlay;
2046
2047    // Social rewards hook (same pattern as _openCarQuoteForm above): gives
2048    // the module-scope paywall claim element this closure's auth headers,
2049    // and a checkState that re-fetches customer-state with the exact idiom
2050    // of the on-load fetch below and, when renders remain again, clears the
2051    // paywall out of resultArea and repaints the counter and generate button
2052    // (showPaywall no longer hides the input sections; the display resets
2053    // below are kept as harmless belt-and-braces for that pre-existing DOM).
2054    // Every name referenced here is a var/function of this closure, only
2055    // dereferenced at click time - long after the build below assigns them.
2056    _socialClaimCtx = {
2057      headers: function () {
2058        return { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _customerToken };
2059      },
2060      checkState: function (cb) {
2061        fetch(API_BASE + '/api/widget/customer-state', {
2062          headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _customerToken }
2063        }).then(function (r) {
2064          if (!r.ok) return null;
2065          return r.json();
2066        }).then(function (data) {
2067          if (!data) { cb(false); return; }
2068          _verifiedEmail = data.email || _verifiedEmail;
2069          _rendersUsed  = Number(data.rendersUsed || 0);
2070          _rendersTotal = Number(data.freeRenderLimit || 0);
2071          if (_rendersTotal > 0 && _rendersUsed < _rendersTotal) {
2072            resultArea.innerHTML = '';
2073            resultArea.style.display = 'none';
2074            uploadArea.style.display = '';
2075            uploadLabel.style.display = '';
2076            ctaArea.innerHTML = '';
2077            ctaArea.style.display = 'none';
2078            uploadSection.style.display = 'block';
2079            colorSection.style.display = 'block';
2080            genBtn.style.display = 'block';
2081            updateCountdown();
2082            updateGenBtn(true);
2083            cb(true);
2084          } else {
2085            cb(false);
2086          }
2087        }).catch(function () { cb(false); });
2088      }
2089    };
2090
2091    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 State \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
209100c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
2092    var _verifiedEmail    = (function(){ try { return localStorage.getItem('wup_email_' + EMBED_TOKEN) || ''; } catch(e) { return ''; } })();
2093    var _customerToken    = (function(){ try { return localStorage.getItem('wup_ct_' + EMBED_TOKEN) || null; } catch(e) { return null; } })();
2094    // The partner demo's brand lock (2026-09-23): the brand the visitor's
2095    // first render locked them to and the sentence to say for any other.
2096    // Both come from the server - the render and confirm responses, or the
2097    // page via data-locked-brand / data-locked-message on the mount - and
2098    // are never made up here. Empty means no lock.
2099    var _demoLockedBrand = '';
2100    var _demoLockMessage = '';
2101    function _lockedBrand() { return _demoLockedBrand || (DEMO_EVENTS && rootEl.getAttribute('data-locked-brand')) || ''; }
2102    function _lockMessage() { return _demoLockMessage || (DEMO_EVENTS && rootEl.getAttribute('data-locked-message')) || ''; }
2103    function _takeLock(data) {
2104      if (!DEMO_EVENTS || !data) return;
2105      if (typeof data.lockedBrand === 'string' && data.lockedBrand) _demoLockedBrand = data.lockedBrand;
2106      if (typeof data.lockMessage === 'string' && data.lockMessage) _demoLockMessage = data.lockMessage;
2107      if (_inlinePicker && _inlinePicker.relock) _inlinePicker.relock();
2108    }
2109    var _consented        = (function(){ try { return !!localStorage.getItem('wup_privacy_consent_at'); } catch(e) { return false; } })();
2110    var _rendersUsed      = 0;
2111    var _rendersTotal     = 0;
2112    var _isFirstRender    = !_customerToken;
2113    var _inlineName       = '';
2114    var _inlinePhone      = '';
2115    var _inlineCarBrand   = '';
2116    var _inlineCarModel   = '';
2117    var _inlineFile       = null;
2118    var _inlineOriginalUrl = '';
2119    var _inlineColorId    = null;
2120    var _inlineColorName  = '';
2121    var _inlineLastRenderId = null; // renderId from the response; cleared on new generate
2122    var _inlineLastRenderToken = null; // imageToken from the response; cleared on new generate
2123    var _inlineLastOriginalFile = null; // the original File behind the CURRENT render (quote originalPhoto)
2124    var _inlineGenerating = false;
2125    var _inlineAspectRatio = 56.25;
2126    // Render history — array of { id, token, colorName, manufacturer }. Persisted per embed token.
2127    var _renderHistory = (function(){
2128      try {
2129        var raw = localStorage.getItem('wup_history2_' + EMBED_TOKEN);
2130        var parsed = raw ? JSON.parse(raw) : [];
2131        return Array.isArray(parsed) ? parsed : [];
2132      } catch(e) { return []; }
2133    })();
2134    // Total render count from the server. Drives History-button visibility on
2135    // the strip (button shown when total > 5). Stays 0 for first-time visitors
2136    // and on server-fetch failure — button hidden in both cases.
2137    var _renderHistoryTotal = 0;
2138
2139    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 Container \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
213900c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
2140    var container = document.createElement('div');
2141    container.setAttribute('style', [
2142      'background:#111111', 'border-radius:16px', 'padding:24px',
2143      'font-family:inherit', 'color:#ffffff', 'width:100%',
2144      'box-sizing:border-box', 'position:relative',
2145    ].join(';'));
2146    rootEl.appendChild(container);
2147
2148    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 Result area (shared) \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
214800c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
2149    var resultArea = document.createElement('div');
2150    resultArea.style.display = 'none';
2151    resultArea.style.marginTop = '0';
2152    // Bottom slot for the upsell card and the paywall, where resultArea used
2153    // to be, so moving the render up does not move the calls to action.
2154    var ctaArea = document.createElement('div');
2155    ctaArea.style.display = 'none';
2156    ctaArea.style.marginTop = '0';
2157
2158    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
215800c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
2159    // STEP 1 \u2014 Email Verification
2160    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
216000c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
2161    var step1El = document.createElement('div');
2162    step1El.setAttribute('style','background:#1a1a1a;border-radius:12px;padding:20px;box-sizing:border-box;margin:0 0 16px;display:none');
2163    step1El.style.display = 'none';
2164
2165    var s1Title = document.createElement('p');
2166    s1Title.textContent = 'Visualise Your Wrap';
2167    s1Title.setAttribute('style','margin:0 0 6px;font-weight:700;font-size:20px;color:#ffffff;font-family:inherit');
2168
2169    var s1Sub = document.createElement('p');
2170    s1Sub.textContent = DEMO_EVENTS ? 'We\'ll send a 6-digit code to your email - no spam, ever.' : 'We only need your email to send you the result \u2014 no spam, ever.';
2171    s1Sub.setAttribute('style','margin:0 0 18px;font-size:14px;color:#888888;font-family:inherit');
2172
2173    var emailInput = document.createElement('input');
2174    emailInput.type = 'email';
2175    emailInput.placeholder = '[email protected]';
2176    emailInput.setAttribute('style',[
2177      'width:100%','padding:12px 14px','background:#1a1a1a','border:1px solid #333333',
2178      'border-radius:8px','color:#ffffff','font-size:15px','font-family:inherit',
2179      'box-sizing:border-box','margin-bottom:8px','outline:none',
2180    ].join(';'));
2181
2182    var s1Tagline = document.createElement('p');
2183    s1Tagline.textContent = 'We only need your email to send you the result \u2014 no spam, ever.';
2184    s1Tagline.setAttribute('style','margin:0 0 14px;font-size:12px;color:#666666;font-family:inherit');
2185
2186    var sendCodeBtn = document.createElement('button');
2187    sendCodeBtn.textContent = 'Send Verification Code';
2188    sendCodeBtn.setAttribute('style',[
2189      'width:100%','padding:13px','background:#D2D915','color:#000000','border:none',
2190      'border-radius:8px','font-size:15px','font-weight:700','cursor:pointer',
2191      'font-family:inherit','box-sizing:border-box',
2192    ].join(';'));
2193
2194    var codeSection = document.createElement('div');
2195    codeSection.style.display = 'none';
2196    codeSection.style.marginTop = '18px';
2197
2198    var codeSentMsg = document.createElement('p');
2199    codeSentMsg.setAttribute('style','margin:0 0 12px;font-size:14px;color:#aaaaaa;font-family:inherit');
2200
2201    var codeInput = document.createElement('input');
2202    codeInput.type = 'text';
2203    codeInput.inputMode = 'numeric';
2204    codeInput.maxLength = 6;
2205    codeInput.placeholder = '000000';
2206    codeInput.setAttribute('style',[
2207      'width:100%','padding:12px','background:#1a1a1a','border:1px solid #333333',
2208      'border-radius:8px','color:#ffffff','font-size:24px','letter-spacing:8px',
2209      'font-family:inherit','box-sizing:border-box','margin-bottom:10px',
2210      'text-align:center','outline:none',
2211    ].join(';'));
2212
2213    var verifyCodeBtn = document.createElement('button');
2214    verifyCodeBtn.textContent = 'Verify Code';
2215    verifyCodeBtn.setAttribute('style',[
2216      'width:100%','padding:13px','background:#D2D915','color:#000000','border:none',
2217      'border-radius:8px','font-size:15px','font-weight:700','cursor:pointer','font-family:inherit',
2218    ].join(';'));
2219
2220    var resendLink = document.createElement('a');
2221    resendLink.textContent = 'Resend code';
2222    resendLink.setAttribute('style','display:block;text-align:center;margin-top:10px;font-size:12px;color:#888888;cursor:pointer;text-decoration:underline;font-family:inherit');
2223
2224    var s1Status = document.createElement('p');
2225    s1Status.setAttribute('style','margin:10px 0 0;font-size:13px;
2225color:#e74c3c;font-family:inherit;min-height:18px');
2226
2227    codeSection.appendChild(codeSentMsg);
2228    codeSection.appendChild(codeInput);
2229    codeSection.appendChild(verifyCodeBtn);
2230    codeSection.appendChild(resendLink);
2231
2232    step1El.appendChild(s1Title);
2233    step1El.appendChild(s1Sub);
2234    step1El.appendChild(emailInput);
2235    if (PREFILL_EMAIL && !emailInput.value) emailInput.value = PREFILL_EMAIL;
2236    step1El.appendChild(s1Tagline);
2237    step1El.appendChild(sendCodeBtn);
2238    step1El.appendChild(codeSection);
2239    step1El.appendChild(s1Status);
2240
2241    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
224100c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
2242    // STEP 2 + 3 \u2014 Main widget (shown after verification)
2243    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
224300c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
2244    var mainEl = document.createElement('div');
2245
2246    // Render history bar — always at the top, above the upload area. One thumb per
2247    // past render with color name below and a download button; click thumb to open
2248    // full-size. Starts empty; thumbs are appended as the visitor generates renders.
2249    var historyBar = document.createElement('div');
2250    historyBar.setAttribute('style',[
2251      'display:none','flex-wrap:wrap','justify-content:center',
2252      'gap:10px','margin-bottom:16px',
2253    ].join(';'));
2254
2255    // Countdown block — shown after verification. Two lines, no card background:
2256    //   1) "free renders remaining : " (grey) + big bold brand-coloured "X / Y"
2257    //   2) small "Rendering for: <email>" line with the email in green
2258    var countdownEl = document.createElement('div');
2259    countdownEl.setAttribute('style','margin-bottom:16px;text-align:center;font-family:inherit;display:none');
2260    var countdownLine1 = document.createElement('p');
2261    countdownLine1.setAttribute('style','margin:0;font-size:14px;color:#888888;font-family:inherit;display:none');
2262    var countdownLabel = document.createTextNode('free renders remaining : ');
2263    var countdownXY = document.createElement('span');
2264    countdownXY.setAttribute('data-wrapup-renders-count', '');
2265    countdownXY.setAttribute('style','color:#D2D915;font-size:22px;font-weight:800;font-family:inherit;margin-left:4px;vertical-align:middle');
2266    countdownLine1.appendChild(countdownLabel);
2267    countdownLine1.appendChild(countdownXY);
2268    var countdownLine2 = document.createElement('p');
2269    countdownLine2.setAttribute('style','margin:4px 0 0;font-size:12px;color:#888888;font-family:inherit;word-break:break-all');
2270    countdownEl.appendChild(countdownLine1);
2271    countdownEl.appendChild(countdownLine2);
2272    var countdownLine3 = document.createElement('p');
2273    countdownLine3.setAttribute('style','margin:8px 0 0;font-size:12px;color:#888888;font-family:inherit;line-height:1.4;display:none');
2274    countdownEl.appendChild(countdownLine3);
2275
2276    // Upload section
2277    var uploadSection = document.createElement('div');
2278    uploadSection.setAttribute('style','margin-bottom:16px');
2279    var uploadLabel = document.createElement('p');
2280    uploadLabel.textContent = 'Upload a photo of your vehicle';
2281    uploadLabel.setAttribute('style','margin:0 0 8px;font-size:14px;font-weight:600;color:#ffffff;font-family:inherit');
2282    var uploadArea = document.createElement('div');
2283    uploadArea.setAttribute('style',[
2284      'border:2px dashed #333333','border-radius:12px','min-height:250px','padding:28px 20px',
2285      'text-align:center','cursor:pointer','background:#1a1a1a','position:relative',
2286    ].join(';'));
2287    // The sample car (Peter's Huracan) dimmed behind the empty upload box.
2288    // Set here, when the empty state is built, so the photo is fetched only
2289    // then and not on every page load; a chosen file replaces it, exactly
2290    // as it replaces the plain background today.
2291    // /api/widget/sample-photo, not the static file: the static file has no
2292    // CORS header, so on a partner's own domain the fetch below was refused
2293    // and the link did nothing. The route serves the same bytes with the
2294    // header (2026-09-23).
2295    var SAMPLE_PHOTO_URL = API_BASE + '/api/widget/sample-photo';
2296    var sampleOn = SAMPLE_ENABLED && _samplePhoto !== 'none';
2297    if (sampleOn) {
2298      uploadArea.style.backgroundImage = 'linear-gradient(rgba(26,26,26,0.72),rgba(26,26,26,0.72)),url(' + SAMPLE_PHOTO_URL + ')';
2299      uploadArea.style.backgroundSize = 'cover';
2300      uploadArea.style.backgroundPosition = 'center';
2301    }
2302    var uploadIcon = document.createElement('div');
2303    uploadIcon.innerHTML = '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#555555" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21,15 16,10 5,21"/></svg>';
2304    uploadIcon.style.marginBottom = '8px';
2305    var uploadText = document.createElement('p');
2306    uploadText.textContent = 'Click to upload or drag & drop';
2307    uploadText.setAttribute('style','margin:0 0 4px;font-size:14px;color:#888888;font-family:inherit');
2308    var uploadHint = document.createElement('p');
2309    uploadHint.textContent = 'JPG, PNG or WebP · Max 10 MB';
2310    uploadHint.setAttribute('style','margin:0;font-size:12px;color:#555555;font-family:inherit');
2311    var fileInput = document.createElement('input');
2312    fileInput.type = 'file';
2313    fileInput.accept = 'image/*';
2314    fileInput.setAttribute('style','position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%');
2315    // Backdrop-only mode: the photo on its own layer under the icon and the
2316    // text (appended first, so they paint over it), blurred - a CSS filter
2317    // on that layer, not a backdrop-filter - and darkened so "Click to
2318    // upload" stays readable. Scaled a little so the blur has no soft edge
2319    // inside the box; the box clips it.
2320    if (SAMPLE_BACKDROP_ONLY) {
2321      uploadArea.style.overflow = 'hidden';
2322      var sampleBackdrop = document.createElement('div');
2323      sampleBackdrop.setAttribute('data-testid', 'sample-backdrop');
2324      sampleBackdrop.setAttribute('style', [
2325        'position:absolute', 'inset:0', 'pointer-events:none',
2326        'background-image:linear-gradient(rgba(26,26,26,0.72),rgba(26,26,26,0.72)),url(' + SAMPLE_PHOTO_URL + ')',
2327        'background-size:cover', 'background-position:center',
2328        'filter:blur(6px)', 'transform:scale(1.08)',
2329      ].join(';'));
2330      uploadArea.appendChild(sampleBackdrop);
2331      uploadIcon.style.position = 'relative';
2332      uploadText.style.position = 'relative';
2333      uploadHint.style.position = 'relative';
2334    }
2335    uploadArea.appendChild(uploadIcon);
2336    uploadArea.appendChild(uploadText);
2337    uploadArea.appendChild(uploadHint);
2338    uploadArea.appendChild(fileInput);
2339    uploadSection.appendChild(uploadLabel);
2340    uploadSection.appendChild(uploadArea);
2341    // "Or try it with this sample photo": the sample goes in through the
2342    // same path a drag-drop takes - a File in fileInput.files and a change
2343    // event - so from here on it is a normal upload and a normal render,
2344    // paid for like any other. Below the box, because the invisible file
2345    // input covers the box itself.
2346    var sampleLink = document.createElement('p');
2347    sampleLink.setAttribute('style', 'margin:8px 0 0;font-size:13px;color:#888888;font-family:inherit;text-align:center');
2348    if (!sampleOn) sampleLink.style.display = 'none';
2349    var sampleAnchor = document.createElement('span');
2350    sampleAnchor.textContent = 'Or try it with this sample photo';
2351    sampleAnchor.setAttribute('style', 'cursor:pointer;text-decoration:underline;color:#D2D915');
2352    sampleAnchor.setAttribute('data-testid', 'link-sample-photo');
2353    sampleAnchor.addEventListener('click', function () {
2354      sampleAnchor.textContent = 'Loading the sample photo...';
2355      fetch(SAMPLE_PHOTO_URL).then(function (r) {
2356        if (!r.ok) throw new Error('HTTP ' + r.status);
2357        return r.blob();
2358      }).then(function (blob) {
2359        var file = new File([blob], 'sample-car-huracan-side.jpg', { type: 'image/jpeg' });
2360        var dt = new DataTransfer(); dt.items.add(file); fileInput.files = dt.files; fileInput.dispatchEvent(new Event('change'));
2361        sampleAnchor.textContent = 'Or try it with this sample photo';
2362      }).catch(function () {
2363        sampleAnchor.textContent = 'The sample photo could not be loaded - upload your own.';
2364      });
2365    });
2366    sampleLink.appendChild(sampleAnchor);
2367    uploadSection.appendChild(sampleLink);
2368    // The render appears where the photo was (supplier-widget precedent):
2369    // resultArea lives INSIDE the upload section and showInlineResult hides
2370    // uploadArea so the before/after slider takes its place. The upsell card
2371    // and the paywall keep their old bottom position in ctaArea below.
2372    uploadSection.appendChild(resultArea);
2373
2374    // Color section
2375    var colorSection = document.createElement('div');
2376    colorSection.setAttribute('style','margin-bottom:16px');
2377    var colorLabel = document.createElement('p');
2378    colorLabel.textContent = 'Choose a wrap colour';
2379    colorLabel.setAttribute('style','margin:0 0 8px;font-size:14px;font-weight:600;color:#ffffff;font-family:inherit');
2380    var colorGrid = document.createElement('div');
2381    colorGrid.setAttribute('style','padding-right:2px');
2382    // The "Selected: Name - Manufacturer" line is gone: the picker's block
2383    // prints the name, the brand, the colour number and the finish, beside a
2384    // picture of the colour.
2385    colorSection.appendChild(colorLabel);
2386    colorSection.appendChild(colorGrid);
2387    // Set by loadColors once the picker exists, so the reset below can clear
2388    // the selection through it.
2389    var _inlinePicker = null;
2390
2391    // ---- Wheels rollout C2 (Wheels BETA) ----------------------------------
2392    // Built ONLY when init said wheelsEnabled; the state vars always exist
2393    // so doGenerate can read them, but with the section absent they never
2394    // leave their defaults and the request body is identical to today.
2395    // Hardcoded lists mirror WHEEL_COLORS / WHEEL_FINISHES / WHEEL_SIZES in
2396    // shared/schema.ts (ids on the wire, labels shown); ASCII only.
2397    var _whEnabled = false;
2398    var _whTab = 'recolor';
2399    var _whColor = null;
2400    var _whColorHex = null;
2401    var _whFinish = 'satin';
2402    var _whSize = 'original';
2403    var _whFile = null;
2404    var _whPreviewUrl = null;
2405    var WH_COLORS = [
2406      { id: 'black', name: 'Black' },
2407      { id: 'aluminium', name: 'Brushed Aluminium' },
2408      { id: 'bronze', name: 'Bronze' },
2409      { id: 'anthracite', name: 'Anthracite Grey' }
2410    ];
2411    var WH_FINISHES = [
2412      { id: 'gloss', name: 'Gloss' },
2413      { id: 'satin', name: 'Satin' },
2414      { id: 'matte', name: 'Matte' }
2415    ];
2416    var WH_SIZES = [
2417      { id: 'original', name: 'Original' },
2418      { id: 'plus1', name: '+1"' },
2419      { id: 'plus2', name: '+2"' },
2420      { id: 'plus3', name: '+3"' }
2421    ];
2422    var wheelsSection = null;
2423    if (_wheelsEnabled) (function buildWheelsSection() {
2424      // Card restyle (consumer 9927ab6 + fe07675 translated to widget CSS):
2425      // ONE container carries the border and the selected accent; the
2426      // header is a real button that toggles the section, and the options
2427      // area expands INSIDE the same container behind a border-top divider.
2428      // Self-contained inline styles only - no host-page CSS involved.
2429      wheelsSection = document.createElement('div');
2430      wheelsSection.setAttribute('style', 'margin-bottom:16px;border:1px solid #333333;border-radius:10px;overflow:hidden;background:#1a1a1a');
2431
2432      function whIconSvg(stroke) {
2433        return '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="' + stroke + '" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><line x1="12" y1="3" x2="12" y2="9"/><line x1="12" y1="15" x2="12" y2="21"/><line x1="3" y1="12" x2="9" y2="12"/><line x1="15" y1="12" x2="21" y2="12"/></svg>';
2434      }
2435      var whHeaderBtn = document.createElement('button');
2436      whHeaderBtn.type = 'button';
2437      whHeaderBtn.setAttribute('style', [
2438        'display:flex', 'align-items:flex-start', 'gap:12px', 'width:100%',
2439        'padding:12px', 'background:transparent', 'border:none', 'cursor:pointer',
2440        'text-align:left', 'font-family:inherit'
2441      ].join(';'));
2442      var whIconTile = document.createElement('div');
2443      whIconTile.setAttribute('style', 'flex-shrink:0;width:36px;height:36px;border-radius:8px;background:#252525;display:flex;align-items:center;justify-content:center');
2444      whIconTile.innerHTML = whIconSvg('#aaaaaa');
2445      var whTextWrap = document.createElement('div');
2446      var whTitleRow = document.createElement('div');
2447      whTitleRow.setAttribute('style', 'display:flex;align-items:center;gap:8px');
2448      var whTitle = document.createElement('span');
2449      whTitle.textContent = 'Wheels';
2450      whTitle.setAttribute('style', 'font-size:14px;font-weight:600;color:#ffffff;font-family:inherit');
2451      var whBeta = document.createElement('span');
2452      whBeta.textContent = 'BETA';
2453      whBeta.setAttribute('style', 'font-size:10px;font-weight:700;letter-spacing:1px;color:#D2D915;border:1px solid #D2D915;border-radius:4px;padding:1px 5px;font-family:inherit');
2454      var whCheck = document.createElement('span');
2455      whCheck.textContent = '\u2713';
2456      whCheck.setAttribute('style', 'display:none;color:#D2D915;font-size:14px;font-weight:700');
2457      whTitleRow.appendChild(whTitle);
2458      whTitleRow.appendChild(whBeta);
2459      whTitleRow.appendChild(whCheck);
2460      var whDesc = document.createElement('p');
2461      whDesc.textContent = 'Recolor your wheels or try a new wheel design';
2462      whDesc.setAttribute('style', 'margin:2px 0 0;font-size:12px;color:#888888;font-family:inherit');
2463      whTextWrap.appendChild(whTitleRow);
2464      whTextWrap.appendChild(whDesc);
2465      whHeaderBtn.appendChild(whIconTile);
2466      whHeaderBtn.appendChild(whTextWrap);
2467      // The checkbox from commit 1 stays as the state carrier so the
2468      // existing change handler is untouched; it is visually hidden and
2469      // driven by the header button (presentation-only rewiring).
2470      var whToggle = document.createElement('input');
2471      whToggle.type = 'checkbox';
2472      whToggle.style.display = 'none';
2473      whHeaderBtn.appendChild(whToggle);
2474      whHeaderBtn.addEventListener('click', function () {
2475        whToggle.checked = !whToggle.checked;
2476        whToggle.dispatchEvent(new Event('change'));
2477      });
2478      wheelsSection.appendChild(whHeaderBtn);
2479
2480      var whBody = document.createElement('div');
2481      whBody.setAttribute('style', 'display:none;border-top:1px solid #333333;padding:12px');
2482      wheelsSection.appendChild(whBody);
2483
2484      // Tab row
2485      function whTabBtnStyle(active) {
2486        return [
2487          'flex:1', 'padding:8px 10px', 'font-size:13px', 'font-weight:600', 'font-family:inherit',
2488          'border-radius:8px', 'cursor:pointer',
2489          active ? 'background:#D2D915' : 'background:#252525',
2490          active ? 'color:#000000' : 'color:#aaaaaa',
2491          'border:1px solid ' + (active ? '#D2D915' : '#333333')
2492        ].join(';');
2493      }
2494      var whTabRow = document.createElement('div');
2495      whTabRow.setAttribute('style', 'display:flex;gap:8px;margin-bottom:12px');
2496      var whTabRecolor = document.createElement('button');
2497      whTabRecolor.type = 'button';
2498      whTabRecolor.textContent = 'Recolor my wheels';
2499      var whTabNew = document.createElement('button');
2500      whTabNew.type = 'button';
2501      whTabNew.textContent = 'New wheels';
2502      whTabRow.appendChild(whTabRecolor);
2503      whTabRow.appendChild(whTabNew);
2504      whBody.appendChild(whTabRow);
2505
2506      // Recolor panel
2507      var whRecolorPanel = document.createElement('div');
2508      whBody.appendChild(whRecolorPanel);
2509      function whChipStyle(active) {
2510        return [
2511          'display:inline-flex', 'align-items:center', 'gap:6px', 'padding:7px 12px', 'margin:0 6px 6px 0',
2512          'font-size:13px', 'font-family:inherit', 'border-radius:8px', 'cursor:pointer',
2513          'background:' + (active ? '#2a2a14' : '#252525'),
2514          'color:' + (active ? '#D2D915' : '#cccccc'),
2515          'border:1px solid ' + (active ? '#D2D915' : '#333333')
2516        ].join(';');
2517      }
2518      var whColorChipEls = [];
2519      var whChipWrap = document.createElement('div');
2520      WH_COLORS.forEach(function (wc) {
2521        var chip = document.createElement('button');
2522        chip.type = 'button';
2523        chip.textContent = wc.name;
2524        chip.addEventListener('click', function () {
2525          _whColor = _whColor === wc.id ? null : wc.id;
2526          _whColorHex = null;
2527          _whFile = null;
2528          whUpdateRecolor();
2529        });
2530        whColorChipEls.push({ id: wc.id, el: chip });
2531        whChipWrap.appendChild(chip);
2532      });
2533      // Custom chip: a small round rainbow swatch that becomes a live
2534      // preview of the picked hex (conic-gradient, pure CSS).
2535      var whCustomChip = document.createElement('button');
2536      whCustomChip.type = 'button';
2537      var whCustomDot = document.createElement('span');
2538      whCustomDot.setAttribute('style', 'width:14px;height:14px;border-radius:50%;border:1px solid #555555;background:conic-gradient(red, yellow, lime, cyan, blue, magenta, red)');
2539      var whCustomText = document.createElement('span');
2540      whCustomText.textContent = 'Custom';
2541      whCustomChip.appendChild(whCustomDot);
2542      whCustomChip.appendChild(whCustomText);
2543      whCustomChip.addEventListener('click', function () {
2544        if (_whColorHex !== null) { _whColorHex = null; }
2545        else { _whColorHex = '#4a4a4a'; _whColor = null; _whFile = null; }
2546        whUpdateRecolor();
2547      });
2548      whChipWrap.appendChild(whCustomChip);
2549      whRecolorPanel.appendChild(whChipWrap);
2550
2551      var whHexRow = document.createElement('div');
2552      whHexRow.setAttribute('style', 'display:none;align-items:center;gap:10px;margin:6px 0 2px');
2553      var whHexInput = document.createElement('input');
2554      whHexInput.type = 'color';
2555      whHexInput.value = '#4a4a4a';
2556      whHexInput.setAttribute('style', 'width:52px;height:32px;border:1px solid #333333;border-radius:6px;background:#1a1a1a;cursor:pointer;padding:0');
2557      var whHexText = document.createElement('span');
2558      whHexText.setAttribute('style', 'font-size:12px;color:#888888;font-family:inherit;text-transform:uppercase');
2559      whHexInput.addEventListener('input', function () {
2560        _whColorHex = whHexInput.value;
2561        _whColor = null;
2562        _whFile = null;
2563        whUpdateRecolor();
2564      });
2565      whHexRow.appendChild(whHexInput);
2566      whHexRow.appendChild(whHexText);
2567      whRecolorPanel.appendChild(whHexRow);
2568
2569      var whFinishWrap = document.createElement('div');
2570      whFinishWrap.setAttribute('style', 'display:none;margin-top:8px');
2571      var whFinishLabel = document.createElement('p');
2572      whFinishLabel.textContent = 'Finish';
2573      whFinishLabel.setAttribute('style', 'margin:0 0 6px;font-size:13px;font-weight:600;color:#ffffff;font-family:inherit');
2574      whFinishWrap.appendChild(whFinishLabel);
2575      var whFinishChipEls = [];
2576      WH_FINISHES.forEach(function (wf) {
2577        var chip = document.createElement('button');
2578        chip.type = 'button';
2579        chip.textContent = wf.name;
2580        chip.addEventListener('click', function () {
2581          _whFinish = wf.id;
2582          whUpdateRecolor();
2583        });
2584        whFinishChipEls.push({ id: wf.id, el: chip });
2585        whFinishWrap.appendChild(chip);
2586      });
2587      whRecolorPanel.appendChild(whFinishWrap);
2588
2589      // New wheels panel
2590      var whNewPanel = document.createElement('div');
2591      whNewPanel.style.display = 'none';
2592      whBody.appendChild(whNewPanel);
2593      var whGuide = document.createElement('p');
2594      whGuide.textContent = 'Pick a wheel on a brand site, copy or save the image, and upload or paste it here.';
2595      whGuide.setAttribute('style', 'margin:0 0 8px;font-size:12px;color:#888888;font-family:inherit');
2596      whNewPanel.appendChild(whGuide);
2597      if (_wheelSuppliers.length > 0) {
2598        var whBrowse = document.createElement('p');
2599        whBrowse.setAttribute('style', 'margin:0 0 8px;font-size:12px;color:#888888;font-family:inherit');
2600        whBrowse.appendChild(document.createTextNode('Browse wheels: '));
2601        _wheelSuppliers.forEach(function (ws, i) {
2602          if (!ws || typeof ws.catalogUrl !== 'string' || typeof ws.name !== 'string') return;
2603          var a = document.createElement('a');
2604          a.href = ws.catalogUrl;
2605          a.target = '_blank';
2606          a.rel = 'noopener noreferrer';
2607          a.textContent = ws.name;
2608          a.setAttribute('style', 'color:#D2D915;text-decoration:underline');
2609          whBrowse.appendChild(a);
2610          if (i < _wheelSuppliers.length - 1) whBrowse.appendChild(document.createTextNode(' '));
2611        });
2612        whNewPanel.appendChild(whBrowse);
2613      }
2614      var whUploadRow = document.createElement('div');
2615      whUploadRow.setAttribute('style', 'display:flex;gap:8px');
2616      var whUploadBtn = document.createElement('button');
2617      whUploadBtn.type = 'button';
2618      whUploadBtn.textContent = 'Upload a wheel photo';
2619      whUploadBtn.setAttribute('style', 'flex:1;padding:12px;border:2px dashed #333333;border-radius:10px;background:#1a1a1a;color:#888888;font-size:13px;font-family:inherit;cursor:pointer');
2620      var whFileInput = document.createElement('input');
2621      whFileInput.type = 'file';
2622      whFileInput.accept = 'image/*';
2623      whFileInput.style.display = 'none';
2624      whUploadBtn.addEventListener('click', function () { whFileInput.click(); });
2625      whFileInput.addEventListener('change', function () {
2626        var f = whFileInput.files[0];
2627        whFileInput.value = '';
2628        if (f) whHandleFile(f);
2629      });
2630      whUploadRow.appendChild(whUploadBtn);
2631      // Feature-detected paste button; silent fallback on ANY failure.
2632      var whCanClipRead = typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.read === 'function';
2633      if (whCanClipRead) {
2634        var whPasteBtn = document.createElement('button');
2635        whPasteBtn.type = 'button';
2636        whPasteBtn.textContent = 'Paste image';
2637        whPasteBtn.setAttribute('style', 'padding:12px;border:2px dashed #333333;border-radius:10px;background:#1a1a1a;color:#888888;font-size:13px;font-family:inherit;cursor:pointer');
2638        whPasteBtn.addEventListener('click', function () {
2639          navigator.clipboard.read().then(function (items) {
2640            for (var i = 0; i < items.length; i++) {
2641              var t = null;
2642              for (var j = 0; j < items[i].types.length; j++) {
2643                if (items[i].types[j].indexOf('image/') === 0) { t = items[i].types[j]; break; }
2644              }
2645              if (t) {
2646                return items[i].getType(t).then(function (blob) {
2647                  whHandleFile(new File([blob], 'pasted-wheel.' + (blob.type.split('/')[1] || 'png'), { type: blob.type }));
2648                });
2649              }
2650            }
2651          }).catch(function () { /* silent - upload stays the primary path */ });
2652        });
2653        whUploadRow.appendChild(whPasteBtn);
2654      }
2655      whNewPanel.appendChild(whUploadRow);
2656      var whPreviewRow = document.createElement('div');
2657      whPreviewRow.setAttribute('style', 'display:none;align-items:center;gap:10px;margin-top:8px');
2658      var whPreviewImg = document.createElement('img');
2659      whPreviewImg.setAttribute('style', 'width:56px;height:56px;object-fit:cover;border-radius:8px;border:1px solid #333333');
2660      var whPreviewName = document.createElement('span');
2661      whPreviewName.setAttribute('style', 'flex:1;font-size:12px;color:#888888;font-family:inherit;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
2662      var whPreviewRemove = document.createElement('button');
2663      whPreviewRemove.type = 'button';
2664      whPreviewRemove.textContent = 'Remove';
2665      whPreviewRemove.setAttribute('style', 'padding:6px 10px;border:1px solid #333333;border-radius:8px;background:#252525;color:#cccccc;font-size:12px;font-family:inherit;cursor:pointer');
2666      whPreviewRemove.addEventListener('click', function () {
2667        _whFile = null;
2668        whUpdateNew();
2669      });
2670      whPreviewRow.appendChild(whPreviewImg);
2671      whPreviewRow.appendChild(whPreviewName);
2672      whPreviewRow.appendChild(whPreviewRemove);
2673      whNewPanel.appendChild(whPreviewRow);
2674
2675      var whSizeWrap = document.createElement('div');
2676      whSizeWrap.setAttribute('style', 'margin-top:10px');
2677      var whSizeLabel = document.createElement('p');
2678      whSizeLabel.textContent = 'Wheel size';
2679      whSizeLabel.setAttribute('style', 'margin:0 0 6px;font-size:13px;font-weight:600;color:#ffffff;font-family:inherit');
2680      whSizeWrap.appendChild(whSizeLabel);
2681      var whSizeChipEls = [];
2682      WH_SIZES.forEach(function (wsz) {
2683        var chip = document.createElement('button');
2684        chip.type = 'button';
2685        chip.textContent = wsz.name;
2686        chip.addEventListener('click', function () {
2687          _whSize = wsz.id;
2688          whUpdateNew();
2689        });
2690        whSizeChipEls.push({ id: wsz.id, el: chip });
2691        whSizeWrap.appendChild(chip);
2692      });
2693      whNewPanel.appendChild(whSizeWrap);
2694
2695      var whDisclaimer = document.createElement('p');
2696      whDisclaimer.textContent = 'Beta: wheel results vary more than color renders. Size changes are indicative only - this is a visualization, not a fitment guarantee. Wheel images you upload are used only to generate your render.';
2697      whDisclaimer.setAttribute('style', 'margin:10px 0 0;font-size:11px;color:#555555;font-family:inherit;line-height:1.5');
2698      whBody.appendChild(whDisclaimer);
2699
2700      function whHandleFile(file) {
2701        _whFile = file;
2702        _whColor = null;
2703        _whColorHex = null;
2704        whUpdateNew();
2705      }
2706
2707      function whUpdateRecolor() {
2708        whColorChipEls.forEach(function (c) { c.el.setAttribute('style', whChipStyle(_whColor === c.id)); });
2709        whCustomChip.setAttribute('style', whChipStyle(_whColorHex !== null));
2710        whCustomDot.style.background = _whColorHex !== null ? _whColorHex : 'conic-gradient(red, yellow, lime, cyan, blue, magenta, red)';
2711        whHexRow.style.display = _whColorHex !== null ? 'flex' : 'none';
2712        if (_whColorHex !== null) { whHexInput.value = _whColorHex; whHexText.textContent = _whColorHex; }
2713        var colorActive = _whColor !== null || _whColorHex !== null;
2714        whFinishWrap.style.display = colorActive ? 'block' : 'none';
2715        whFinishChipEls.forEach(function (c) { c.el.setAttribute('style', whChipStyle(_whFinish === c.id)); });
2716      }
2717
2718      function whUpdateNew() {
2719        if (_whPreviewUrl) { try { URL.revokeObjectURL(_whPreviewUrl); } catch (e) {} _whPreviewUrl = null; }
2720        if (_whFile) {
2721          try { _whPreviewUrl = URL.createObjectURL(_whFile); whPreviewImg.src = _whPreviewUrl; } catch (e) {}
2722          whPreviewName.textContent = _whFile.name || 'pasted image';
2723          whPreviewRow.style.display = 'flex';
2724          whUploadRow.style.display = 'none';
2725        } else {
2726          whPreviewRow.style.display = 'none';
2727          whUploadRow.style.display = 'flex';
2728        }
2729        whSizeChipEls.forEach(function (c) { c.el.setAttribute('style', whChipStyle(_whSize === c.id)); });
2730      }
2731
2732      // Paste EVENT support: active only while the section is enabled AND
2733      // the New wheels tab is showing. Non-image pastes are ignored and
2734      // never intercepted, so the host page is unaffected.
2735      var whPasteHandler = function (e) {
2736        var items = e.clipboardData && e.clipboardData.items;
2737        if (!items) return;
2738        for (var i = 0; i < items.length; i++) {
2739          if (items[i].kind === 'file' && items[i].type.indexOf('image/') === 0) {
2740            var f = items[i].getAsFile();
2741            if (f) whHandleFile(new File([f], 'pasted-wheel.' + (items[i].type.split('/')[1] || 'png'), { type: items[i].type }));
2742            return;
2743          }
2744        }
2745      };
2746      var whPasteAttached = false;
2747      function whSyncPasteListener() {
2748        var want = _whEnabled && _whTab === 'new-wheels';
2749        if (want && !whPasteAttached) { document.addEventListener('paste', whPasteHandler); whPasteAttached = true; }
2750        if (!want && whPasteAttached) { document.removeEventListener('paste', whPasteHandler); whPasteAttached = false; }
2751      }
2752
2753      function whSetTab(tab) {
2754        _whTab = tab;
2755        if (tab === 'recolor') {
2756          // A recolor submit can never carry a size or an image.
2757          _whFile = null;
2758          _whSize = 'original';
2759        } else {
2760          _whColor = null;
2761          _whColorHex = null;
2762          _whFinish = 'satin';
2763        }
2764        whTabRecolor.setAttribute('style', whTabBtnStyle(tab === 'recolor'));
2765        whTabNew.setAttribute('style', whTabBtnStyle(tab === 'new-wheels'));
2766        whRecolorPanel.style.display = tab === 'recolor' ? 'block' : 'none';
2767        whNewPanel.style.display = tab === 'new-wheels' ? 'block' : 'none';
2768        whUpdateRecolor();
2769        whUpdateNew();
2770        whSyncPasteListener();
2771      }
2772      whTabRecolor.addEventListener('click', function () { whSetTab('recolor'); });
2773      whTabNew.addEventListener('click', function () { whSetTab('new-wheels'); });
2774
2775      whToggle.addEventListener('change', function () {
2776        _whEnabled = whToggle.checked;
2777        if (!_whEnabled) {
2778          _whColor = null;
2779          _whColorHex = null;
2780          _whFinish = 'satin';
2781          _whSize = 'original';
2782          _whFile = null;
2783        }
2784        whBody.style.display = _whEnabled ? 'block' : 'none';
2785        // Card restyle: selected accent on the whole container while
2786        // enabled (presentation only - state and resets above unchanged).
2787        wheelsSection.style.borderColor = _whEnabled ? '#D2D915' : '#333333';
2788        whHeaderBtn.style.background = _whEnabled ? '#22220f' : 'transparent';
2789        whIconTile.style.background = _whEnabled ? '#D2D915' : '#252525';
2790        whIconTile.innerHTML = whIconSvg(_whEnabled ? '#000000' : '#aaaaaa');
2791        whCheck.style.display = _whEnabled ? 'inline' : 'none';
2792        whUpdateRecolor();
2793        whUpdateNew();
2794        whSyncPasteListener();
2795      });
2796
2797      whSetTab('recolor');
2798    })();
2799    // ---- end Wheels rollout C2 --------------------------------------------
2800
2801    // ---- Wheels-adjacent C3-2: chrome delete / window tint cards ----------
2802    // The consumer's two render options join the car inline flow as
2803    // SELECTION cards in the same self-contained card idiom the C2 wheels
2804    // card established (icon tile, title, description, whole card toggles,
2805    // selected accent). Rendered UNCONDITIONALLY - chrome/tint has no
2806    // entitlement toggle anywhere and gets none here; no BETA badge, these
2807    // are not beta. Ids mirror the merged C3-1 server allow-list
2808    // (["chrome-delete", "window-tint"], PARTNER_KNOWN_OPTIONS). With
2809    // neither card active doGenerate appends NOTHING and the request body
2810    // is byte-identical to before. Toggling a card hides a displayed
2811    // result - the render on screen no longer matches the selection.
2812    var _optChromeDelete = false;
2813    var _optWindowTint = false;
2814    var optionCards = [];
2815    [
2816      { id: 'chrome-delete', title: 'Chrome Delete', desc: 'Replace chrome trim with color-matched finish',
2817        icon: '<rect x="3" y="10" width="18" height="4" rx="2"/><path d="M7 10V7m10 3V7"/>' },
2818      { id: 'window-tint', title: 'Window Tint', desc: 'Add professional window tinting',
2819        icon: '<rect x="4" y="5" width="16" height="14" rx="2"/><path d="M4 13l16-7"/>' }
2820    ].forEach(function (def) {
2821      function optIconSvg(stroke) {
2822        return '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="' + stroke + '" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' + def.icon + '</svg>';
2823      }
2824      var card = document.createElement('div');
2825      card.setAttribute('style', 'margin-bottom:16px;border:1px solid #333333;border-radius:10px;overflow:hidden;background:#1a1a1a');
2826      var btn = document.createElement('button');
2827      btn.type = 'button';
2828      btn.setAttribute('style', [
2829        'display:flex', 'align-items:flex-start', 'gap:12px', 'width:100%',
2830        'padding:12px', 'background:transparent', 'border:none', 'cursor:pointer',
2831        'text-align:left', 'font-family:inherit'
2832      ].join(';'));
2833      var tile = document.createElement('div');
2834      tile.setAttribute('style', 'flex-shrink:0;width:36px;height:36px;border-radius:8px;background:#252525;display:flex;align-items:center;justify-content:center');
2835      tile.innerHTML = optIconSvg('#aaaaaa');
2836      var textWrap = document.createElement('div');
2837      var titleRow = document.createElement('div');
2838      titleRow.setAttribute('style', 'display:flex;align-items:center;gap:8px');
2839      var titleEl = document.createElement('span');
2840      titleEl.textContent = def.title;
2841      titleEl.setAttribute('style', 'font-size:14px;font-weight:600;color:#ffffff;font-family:inherit');
2842      var checkEl = document.createElement('span');
2843      checkEl.textContent = '\u2713';
2844      checkEl.setAttribute('style', 'display:none;color:#D2D915;font-size:14px;font-weight:700');
2845      titleRow.appendChild(titleEl);
2846      titleRow.appendChild(checkEl);
2847      var descEl = document.createElement('p');
2848      descEl.textContent = def.desc;
2849      descEl.setAttribute('style', 'margin:2px 0 0;font-size:12px;color:#888888;font-family:inherit');
2850      textWrap.appendChild(titleRow);
2851      textWrap.appendChild(descEl);
2852      btn.appendChild(tile);
2853      btn.appendChild(textWrap);
2854      card.appendChild(btn);
2855      btn.addEventListener('click', function () {
2856        var on;
2857        if (def.id === 'chrome-delete') { _optChromeDelete = !_optChromeDelete; on = _optChromeDelete; }
2858        else { _optWindowTint = !_optWindowTint; on = _optWindowTint; }
2859        card.style.borderColor = on ? '#D2D915' : '#333333';
2860        btn.style.background = on ? '#22220f' : 'transparent';
2861        tile.style.background = on ? '#D2D915' : '#252525';
2862        tile.innerHTML = optIconSvg(on ? '#000000' : '#aaaaaa');
2863        checkEl.style.display = on ? 'inline' : 'none';
2864        if (resultArea.style.display !== 'none') { resultArea.style.display = 'none'; uploadArea.style.display = ''; uploadLabel.style.display = ''; ctaArea.style.display = 'none'; }
2865      });
2866      optionCards.push(card);
2867    });
2868    // ---- end Wheels-adjacent C3-2 -----------------------------------------
2869
2870    // Form section (Step 2 only)
2871    var formSection = document.createElement('div');
2872    formSection.setAttribute('style','margin-bottom:16px;display:none');
2873    var formTitle = document.createElement('p');
2874    formTitle.textContent = 'Your details';
2875    formTitle.setAttribute('style','margin:0 0 12px;font-size:14px;font-weight:600;color:#ffffff;font-family:inherit');
2876
2877    function mkField(lbl, ph, tp) {
2878      var w = document.createElement('div'); w.style.marginBottom = '10px';
2879      var l = document.createElement('label');
2880      l.textContent = lbl;
2881      l.setAttribute('style','display:block;margin-bottom:4px;font-size:12px;color:#888888;font-family:inherit');
2882      var i = document.createElement('input'); i.type = tp||'text'; i.placeholder = ph;
2883      i.setAttribute('style',[
2884        'width:100%','padding:10px 14px','background:#1a1a1a','border:1px solid #333333',
2885        'border-radius:8px','color:#ffffff','font-size:14px','font-family:inherit',
2886        'box-sizing:border-box','outline:none',
2887      ].join(';'));
2888      w.appendChild(l); w.appendChild(i);
2889      return { wrap:w, inp:i };
2890    }
2891
2892    var emailDisplayWrap = document.createElement('div'); emailDisplayWrap.style.marginBottom = '10px';
2893    emailDisplayWrap.style.display = 'none';
2894    var emailDisplayLbl = document.createElement('label');
2895    emailDisplayLbl.textContent = 'Email';
2896    emailDisplayLbl.setAttribute('style','display:block;margin-bottom:4px;font-size:12px;color:#888888;font-family:inherit');
2897    var emailDisplayInp = document.createElement('input');
2898    emailDisplayInp.type = 'email'; emailDisplayInp.readOnly = true;
2899    emailDisplayInp.setAttribute('style',[
2900      'width:100%','padding:10px 14px','background:#151515','border:1px solid #1e1e1e',
2901      'border-radius:8px','color:#555555','font-size:14px','font-family:inherit',
2902      'box-sizing:border-box','cursor:not-allowed',
2903    ].join(';'));
2904    emailDisplayWrap.appendChild(emailDisplayLbl);
2905    emailDisplayWrap.appendChild(emailDisplayInp);
2906
2907    var nameF  = mkField('Full name',     'John Smith');
2908    var phoneF = mkField('Phone number',  '+31 6 12345678', 'tel');
2909    var brandF = mkField('Car brand',     'e.g. Mercedes');
2910    var modelF = mkField('Car model',     'e.g. Sprinter');
2911
2912    formSection.appendChild(formTitle);
2913    formSection.appendChild(emailDisplayWrap);
2914    formSection.appendChild(nameF.wrap);
2915    formSection.appendChild(phoneF.wrap);
2916    formSection.appendChild(brandF.wrap);
2917    formSection.appendChild(modelF.wrap);
2918
2919    // Generate button
2920    var genBtn = document.createElement('button');
2921    genBtn.textContent = 'Generate My Render';
2922    genBtn.disabled = true;
2923    genBtn.setAttribute('style',[
2924      'width:100%','padding:14px','background:#333333','color:#888888','border:none',
2925      'border-radius:8px','font-size:16px','font-weight:700','cursor:not-allowed',
2926      'font-family:inherit','margin-top:4px','transition:background 0.2s,color 0.2s',
2927    ].join(';'));
2928    // Generate error line: sits directly under the button, where the visitor
2929    // is looking, instead of in resultArea at the very bottom of the widget.
2930    // Text only (textContent) so a server message can never inject markup.
2931    var genErrorEl = document.createElement('p');
2932    genErrorEl.setAttribute('style','display:none;margin:10px 0 0;font-size:13px;
2932color:#e74c3c;font-family:inherit;text-align:center');
2933
2934    // Privacy consent (Item 0aa.3) — shown above genBtn until ticked once.
2935    // Scoped per partner-origin by the browser; no embed-token suffix needed.
2936    // Always rendered: a returning visitor with persisted consent sees the
2937    // checkbox CHECKED and DISABLED (locked - consent already given).
2938    var consentEl = document.createElement('div');
2939    consentEl.setAttribute('style', [
2940      'margin:0 0 12px','display:flex','align-items:flex-start','gap:10px',
2941      'font-family:inherit',
2942    ].join(';'));
2943    var consentCb = document.createElement('input');
2944    consentCb.type = 'checkbox';
2945    consentCb.id   = 'wrapup-consent';
2946    // Drawn control (see _ensureConsentStyle): the native box dimmed the lime
2947    // once locked (checked + disabled) and drew itself light-scheme on the
2948    // dark card. Inline keeps only the offset and the cursor.
2949    consentCb.setAttribute('style','margin-top:3px;cursor:pointer');
2950    consentCb.className = 'wrapup-cb wrapup-cb-dark';
2951    _ensureConsentStyle();
2952    var consentLb = document.createElement('label');
2953    consentLb.setAttribute('for','wrapup-consent');
2954    consentLb.setAttribute('style','color:#ffffff;font-size:13px;line-height:1.4;cursor:pointer');
2955    consentLb.appendChild(document.createTextNode('I have read and agree to the '));
2956    var consentLink = document.createElement('a');
2957    consentLink.href   = 'https://www.wrap-up.ai/privacy';
2958    consentLink.target = '_blank';
2959    consentLink.rel    = 'noopener';
2960    consentLink.setAttribute('style','color:#D2D915;text-decoration:underline');
2961    consentLink.textContent = 'Privacy Policy';
2962    // Stop the click bubbling so it does not also toggle the checkbox.
2963    consentLink.addEventListener('click', function(e) { e.stopPropagation(); });
2964    consentLb.appendChild(consentLink);
2965    consentLb.appendChild(document.createTextNode('.'));
2966    consentEl.appendChild(consentCb);
2967    consentEl.appendChild(consentLb);
2968    function lockConsentCb() {
2969      consentCb.checked = true;
2970      consentCb.disabled = true;
2971      consentCb.style.cursor = 'default';
2972      consentLb.style.cursor = 'default';
2973    }
2974    if (_consented) lockConsentCb();
2975    consentCb.addEventListener('change', function() {
2976      if (!consentCb.checked) return;
2977      try { localStorage.setItem('wup_privacy_consent_at', new Date().toISOString()); } catch(e) {}
2978      _consented = true;
2979      lockConsentCb();
2980      updateSendCodeGate();
2981      updateGenBtn(formSection.style.display === 'none' || !!_customerToken);
2982    });
2983
2984    // Consent gates the email-verification step: the send-code button stays
2985    // disabled until the privacy checkbox is checked (persisted consent
2986    // satisfies it immediately).
2987    function updateSendCodeGate() {
2988      sendCodeBtn.disabled = !_consented;
2989      sendCodeBtn.style.opacity = _consented ? '1' : '0.45';
2990      sendCodeBtn.style.cursor = _consented ? 'pointer' : 'not-allowed';
2991    }
2992    updateSendCodeGate();
2993
2994    // Progress section
2995    var progressSection = document.createElement('div');
2996    progressSection.style.display = 'none';
2997    var progressBar = document.createElement('div');
2998    progressBar.setAttribute('style',[
2999      'height:4px','background:linear-gradient(90deg,#D2D915,#a0a010)',
3000      'border-radius:2px','margin-bottom:10px',
3001    ].join(';'));
3002    var progressText = document.createElement('p');
3003    progressText.textContent = 'Generating your wrap\u2026';
3004    progressText.setAttribute('style','margin:0;font-size:13px;color:#888888;font-family:inherit;text-align:center');
3005    progressSection.appendChild(progressBar);
3006    progressSection.appendChild(progressText);
3007
3008    mainEl.appendChild(historyBar);
3009
3010    // Persistent "Get a Quote" button anchored at the history bar - visible
3011    // when the visitor is verified and at least one quotable render exists
3012    // (current, or a history entry with a parseable id). The result-area
3013    // button stays as the contextual shortcut; both open the same form.
3014    var carQuoteBarWrap = document.createElement('div');
3015    carQuoteBarWrap.setAttribute('style', 'display:none;text-align:center;margin-bottom:16px');
3016    var carQuoteBarBtn = document.createElement('button');
3017    carQuoteBarBtn.type = 'button';
3018    carQuoteBarBtn.textContent = 'Get a Quote';
3019    carQuoteBarBtn.setAttribute('style', 'display:inline-block;padding:10px 16px;background:#D2D915;color:#000000;border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit');
3020    var carQuoteBarStatus = document.createElement('p');
3021    carQuoteBarStatus.setAttribute('style', 'display:none;margin:8px 0 0;font-size:12px;color:#ffffff;font-family:inherit');
3022    carQuoteBarBtn.addEventListener('click', function () {
3023      openQuoteFormOverlay(function () {
3024        carQuoteBarStatus.textContent = 'Quote request sent - we emailed you a copy.';
3025        carQuoteBarStatus.style.display = 'block';
3026      });
3027    });
3028    carQuoteBarWrap.appendChild(carQuoteBarBtn);
3029    carQuoteBarWrap.appendChild(carQuoteBarStatus);
3030    mainEl.appendChild(carQuoteBarWrap);
3031    function updateCarQuoteBar() {
3032      carQuoteBarWrap.style.display = (_customerToken && _carHasQuotableRender()) ? 'block' : 'none';
3033    }
3034
3035    mainEl.appendChild(countdownEl);
3036    // Social rewards entry point - always visible below the counter when the
3037    // gate passes (was paywall-only). Same three-part gate as before; gate
3038    // false = zero social DOM and zero listeners, exactly as today.
3039    // A verified visitor is also required: the claim-code endpoint refuses
3040    // an unverified visitor (no x-customer-token), so offering the button
3041    // without one is a dead end. The confirm-email handler mounts it the
3042    // moment verification succeeds.
3043    if (_socialRewardsEnabled && _socialRewardsPossible !== false && _socialClaimCtx && _customerToken) {
3044      mainEl.appendChild(_buildSocialClaimEl());
3045    }
3046    mainEl.appendChild(uploadSection);
3047    mainEl.appendChild(colorSection);
3048    // Wheels-adjacent C3-2: the two option cards precede the wheels card
3049    // (consumer page order: Chrome Delete, Window Tint, Wheels).
3050    optionCards.forEach(function (c) { mainEl.appendChild(c); });
3051    // Wheels rollout C2: present only when init said wheelsEnabled.
3052    if (wheelsSection) mainEl.appendChild(wheelsSection);
3053    mainEl.appendChild(step1El);
3054    mainEl.appendChild(formSection);
3055    if (consentEl) mainEl.appendChild(consentEl);
3056    mainEl.appendChild(genBtn);
3057    mainEl.appendChild(genErrorEl);
3058    mainEl.appendChild(progressSection);
3059    mainEl.appendChild(ctaArea);
3060
3061    // Footer
3062    var footer = document.createElement('p');
3063    footer.innerHTML = 'Powered by <a href="https://www.wrap-up.ai" target="_blank" rel="noopener" style="color:#D2D915;text-decoration:none">WRAP-UP.AI</a>';
3064    footer.setAttribute('style','margin:16px 0 0;font-size:11px;color:#444444;text-align:center;font-family:inherit');
3065
3066    // Brand-independence disclaimer
3067    var disclaimer = document.createElement('p');
3068    disclaimer.textContent = 'WrapUp is an independent service and is not affiliated with, endorsed by, or sponsored by any of the brands we mention. All brand and product names are trademarks of their respective owners.';
3069    disclaimer.setAttribute('style','margin:4px 0 0;font-size:11px;color:#444444;text-align:center;font-family:inherit;line-height:1.5');
3070
3071    container.appendChild(mainEl);
3072    container.appendChild(footer);
3073    container.appendChild(disclaimer);
3074
3075    if (!_isFirstRender) {
3076      formSection.style.display = 'none';
3077      updateGenBtn(true);
3078      // Returning visitor: email is already in localStorage so we can render the
3079      // "Rendering for: …" line immediately. Then confirm state with the server —
3080      // if the token turns out to be stale, drop it and force re-verification.
3081      // Paint the email line immediately from localStorage; X/Y line stays hidden
3082      // until the customer-state fetch returns real counts.
3083      if (_verifiedEmail) updateCountdown();
3084      fetch(API_BASE + '/api/widget/customer-state', {
3085        headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _customerToken }
3086      }).then(function(r) {
3087        if (r.status === 401 || r.status === 404) {
3088          try {
3089            localStorage.removeItem('wup_ct_' + EMBED_TOKEN);
3090            localStorage.removeItem('wup_email_' + EMBED_TOKEN);
3091          } catch(e) {}
3092          _customerToken = null;
3093          _verifiedEmail = '';
3094          _isFirstRender = true;
3095          countdownEl.style.display = 'none';
3096          step1El.style.display = 'block';
3097          formSection.style.display = 'none';
3098          return null;
3099        }
3100        if (!r.ok) return null;
3101        return r.json();
3102      }).then(function(data) {
3103        if (!data) return;
3104        _verifiedEmail = data.email || _verifiedEmail;
3105        _rendersUsed  = Number(data.rendersUsed || 0);
3106        _rendersTotal = Number(data.freeRenderLimit || 0);
3107        try { if (_verifiedEmail) localStorage.setItem('wup_email_' + EMBED_TOKEN, _verifiedEmail); } catch(e) {}
3108        updateCountdown();
3109        _fetchServerRenderHistory();
3110        if (_rendersTotal > 0 && _rendersUsed >= _rendersTotal) {
3111          showPaywall();
3112        }
3113      }).catch(function() { /* network hiccup — leave UI as-is; generate call will re-check */ });
3114    } else {
3115      updateGenBtn(true);
3116    }
3117    renderHistoryBar();
3118    loadColors();
3119
3120    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 Helpers \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
312000c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
3121
3122    function updateCountdown() {
3123      if (!_customerToken) { countdownEl.style.display = 'none'; return; }
3124      countdownEl.style.display = 'block';
3125      if (_rendersTotal > 0) {
3126        var rem = Math.max(0, _rendersTotal - _rendersUsed);
3127        countdownLine1.style.display = 'block';
3128        countdownXY.textContent = rem + ' / ' + _rendersTotal;
3129        countdownLine3.style.display = 'block';
3130        countdownLine3.textContent = 'AI renders aren\'t free to run \u2014 but your first ' + _rendersTotal + ' are on us. Need more? Just let us know.';
3131      } else {
3132        countdownLine1.style.display = 'none';
3133        countdownLine3.style.display = 'none';
3134      }
3135      countdownLine2.innerHTML = '';
3136      if (_verifiedEmail) {
3137        countdownLine2.appendChild(document.createTextNode('Rendering for: '));
3138        var emailSpan = document.createElement('span');
3139        emailSpan.textContent = _verifiedEmail;
3140        emailSpan.style.color = '#D2D915';
3141        countdownLine2.appendChild(emailSpan);
3142      }
3143    }
3144
3145    // Paint the thumbnails bar from _renderHistory. Each thumb: image, color name
3146    // label, download button; clicking the image opens it full-size. Strip shows
3147    // up to 5 thumbs; if the server reports total > 5, a History button cell is
3148    // appended that opens the full grid modal.
3149    function renderHistoryBar() {
3150      updateCarQuoteBar();
3151      historyBar.innerHTML = '';
3152      var visible = _renderHistory.slice(0, 5);
3153      var showHistoryBtn = _renderHistoryTotal > 5;
3154      if (!visible.length && !showHistoryBtn) { historyBar.style.display = 'none'; return; }
3155      historyBar.style.display = 'flex';
3156      visible.forEach(function(item) {
3157        var url = _carImageUrl(item);
3158        var cell = document.createElement('div');
3159        cell.setAttribute('style','position:relative;width:90px;text-align:center;font-family:inherit');
3160        var thumbWrap = document.createElement('div');
3161        thumbWrap.setAttribute('style','position:relative;width:100%;padding-top:75%;border-radius:8px;overflow:hidden;background:#1a1a1a;cursor:pointer');
3162        var img = document.createElement('img');
3163        img.src = url;
3164        img.alt = item.colorName || 'render';
3165        img.setAttribute('style','position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:block');
3166        thumbWrap.appendChild(img);
3167        thumbWrap.addEventListener('click', function(){ openImageFullscreen(url); });
3168
3169        var dl = document.createElement('a');
3170        dl.href = url;
3171        dl.download = 'wrap-' + (item.colorName ? item.colorName.replace(/[^a-z0-9]+/gi,'-') : 'render') + '.jpg';
3172        dl.target = '_blank';
3173        dl.rel = 'noopener';
3174        dl.title = 'Download';
3175        dl.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
3176        dl.setAttribute('style','position:absolute;top:6px;right:6px;width:26px;height:26px;border-radius:50%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;text-decoration:none');
3177        dl.addEventListener('click', function(e){ e.stopPropagation(); });
3178        thumbWrap.appendChild(dl);
3179
3180        var label = document.createElement('p');
3181        label.textContent = item.colorName || '';
3182        label.setAttribute('style','margin:6px 0 0;font-size:11px;color:#ffffff;font-family:inherit;white-space:nowrap;overflow:hidden;text-overflow:ellipsis');
3183        if (item.manufacturer) label.title = item.colorName + ' — ' + item.manufacturer;
3184
3185        cell.appendChild(thumbWrap);
3186        cell.appendChild(label);
3187        historyBar.appendChild(cell);
3188      });
3189      if (showHistoryBtn) {
3190        var hCell = document.createElement('div');
3191        hCell.setAttribute('style','position:relative;width:90px;text-align:center;font-family:inherit');
3192        var hWrap = document.createElement('div');
3193        hWrap.setAttribute('style','position:relative;width:100%;padding-top:75%;border-radius:8px;background:#1a1a1a;border:1px solid #333;cursor:pointer');
3194        var hInner = document.createElement('div');
3195        hInner.textContent = 'History';
3196        hInner.setAttribute('style','position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#ffffff;font-size:12px;font-weight:600;font-family:inherit');
3197        hWrap.appendChild(hInner);
3198        hWrap.addEventListener('click', _openHistoryModal);
3199        hCell.appendChild(hWrap);
3200        historyBar.appendChild(hCell);
3201      }
3202    }
3203
3204    // Fetch the visitor's render history from the server. Replaces in-memory
3205    // _renderHistory with server-authoritative items (newest first), updates
3206    // _renderHistoryTotal so the History button appears when total > 5,
3207    // refreshes the localStorage cache, and repaints the strip. Best-effort;
3208    // on failure the existing localStorage-backed strip stays in place.
3209    function _fetchServerRenderHistory() {
3210      if (!_sessionToken || !_customerToken) return;
3211      fetch(API_BASE + '/api/widget/renders?limit=5', {
3212        headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _customerToken }
3213      }).then(function(r){ return r.ok ? r.json() : null; })
3214        .then(function(data) {
3215          if (!data) return;
3216          _renderHistory = (data.items || []).map(function(item) {
3217            return {
3218              id: item.id,
3219              token: (typeof item.imageToken === 'string' && item.imageToken) ? item.imageToken : null,
3220              colorName: item.colorName || '',
3221              manufacturer: item.manufacturer || '',
3222            };
3223          });
3224          _renderHistoryTotal = Number(data.total || 0);
3225          try { localStorage.setItem('wup_history2_' + EMBED_TOKEN, JSON.stringify(_renderHistory)); } catch(e) {}
3226          renderHistoryBar();
3227        }).catch(function(){});
3228    }
3229
3230    function addToHistory(item) {
3231      _renderHistory.push(item);
3232      var cap = Math.max(3, _rendersTotal || 3);
3233      if (_renderHistory.length > cap) _renderHistory = _renderHistory.slice(-cap);
3234      try { localStorage.setItem('wup_history2_' + EMBED_TOKEN, JSON.stringify(_renderHistory)); } catch(e) {}
3235      renderHistoryBar();
3236    }
3237
3238    // Simple full-screen image viewer — backdrop click to close, ESC to close, ×
3239    // button top-right, download link top-left. Used by history thumbs.
3240    function openImageFullscreen(url) {
3241      var ov = document.createElement('div');
3242      ov.setAttribute('style','position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,0.92);display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box');
3243      var img = document.createElement('img');
3244      img.src = url;
3245      img.setAttribute('style','max-width:100%;max-height:100%;object-fit:contain;border-radius:8px');
3246      var close = document.createElement('button');
3247      close.innerHTML = '&times;';
3248      close.setAttribute('style','position:absolute;top:16px;right:16px;background:rgba(255,255,255,0.12);color:#fff;border:none;border-radius:50%;width:40px;height:40px;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center');
3249      var dl = document.createElement('a');
3250      dl.href = url; dl.download = 'wrap.jpg'; dl.target = '_blank'; dl.rel = 'noopener';
3251      dl.textContent = 'Download';
3252      dl.setAttribute('style','position:absolute;top:16px;left:16px;padding:8px 14px;background:#D2D915;color:#000;border-radius:6px;font-size:13px;font-weight:700;text-decoration:none;font-family:inherit');
3253      function dismiss(){ ov.remove(); document.removeEventListener('keydown', onKey); }
3254      function onKey(e){ if (e.key === 'Escape') dismiss(); }
3255      ov.addEventListener('click', function(e){ if (e.target === ov) dismiss(); });
3256      close.addEventListener('click', dismiss);
3257      document.addEventListener('keydown', onKey);
3258      ov.appendChild(img); ov.appendChild(close); ov.appendChild(dl);
3259      document.body.appendChild(ov);
3260    }
3261
3262    // Full-screen history grid modal. Lazy-loads thumbs via IntersectionObserver
3263    // (falls back to eager src on browsers without it), paginates server-side at
3264    // 100 per page with a Load-more button when total exceeds the loaded count.
3265    // Click a thumb → close this modal, open openImageFullscreen with the full
3266    // image URL.
3267    function _openHistoryModal() {
3268      var ov = document.createElement('div');
3269      ov.setAttribute('style','position:fixed;inset:0;z-index:999998;background:rgba(0,0,0,0.92);display:flex;flex-direction:column;padding:24px;box-sizing:border-box');
3270      var header = document.createElement('div');
3271      header.setAttribute('style','display:flex;justify-content:flex-end;margin-bottom:16px;flex-shrink:0');
3272      var close = document.createElement('button');
3273      close.innerHTML = '&times;';
3274      close.setAttribute('style','background:rgba(255,255,255,0.12);color:#fff;border:none;border-radius:50%;width:40px;height:40px;font-size:24px;
3274cursor:pointer;display:flex;align-items:center;justify-content:center');
3275      header.appendChild(close);
3276      var body = document.createElement('div');
3277      body.setAttribute('style','flex:1;overflow-y:auto;padding-right:8px');
3278      var grid = document.createElement('div');
3279      grid.setAttribute('style','display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:12px');
3280      body.appendChild(grid);
3281      var loadMoreContainer = document.createElement('div');
3282      loadMoreContainer.setAttribute('style','margin-top:16px;text-align:center');
3283      body.appendChild(loadMoreContainer);
3284      ov.appendChild(header);
3285      ov.appendChild(body);
3286
3287      var observer = ('IntersectionObserver' in window) ? new IntersectionObserver(function(entries){
3288        entries.forEach(function(entry){
3289          if (entry.isIntersecting) {
3290            var img = entry.target;
3291            if (img.dataset.src && !img.src) {
3292              img.src = img.dataset.src;
3293              observer.unobserve(img);
3294            }
3295          }
3296        });
3297      }, { rootMargin: '100px' }) : null;
3298
3299      var offset = 0;
3300      var pageSize = 100;
3301      var loading = false;
3302
3303      function appendItems(items) {
3304        items.forEach(function(item) {
3305          var imageUrl = _carImageUrl({ id: item.id, token: item.imageToken });
3306          var cell = document.createElement('div');
3307          cell.setAttribute('style','position:relative;cursor:pointer;border-radius:8px;overflow:hidden;background:#1a1a1a');
3308          var thumbWrap = document.createElement('div');
3309          thumbWrap.setAttribute('style','position:relative;width:100%;padding-top:75%');
3310          var img = document.createElement('img');
3311          if (observer) {
3312            img.dataset.src = imageUrl;
3313            observer.observe(img);
3314          } else {
3315            img.src = imageUrl;
3316          }
3317          img.alt = item.colorName || 'render';
3318          img.setAttribute('style','position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:block');
3319          thumbWrap.appendChild(img);
3320          if (item.colorName) {
3321            var label = document.createElement('div');
3322            label.textContent = item.colorName;
3323            label.setAttribute('style','position:absolute;left:0;right:0;bottom:0;background:linear-gradient(transparent,rgba(0,0,0,0.85));color:#ffffff;font-size:11px;padding:18px 8px 6px;text-align:center;font-family:inherit;white-space:nowrap;overflow:hidden;text-overflow:ellipsis');
3324            thumbWrap.appendChild(label);
3325          }
3326          cell.appendChild(thumbWrap);
3327          cell.addEventListener('click', function(){
3328            dismiss();
3329            openImageFullscreen(imageUrl);
3330          });
3331          grid.appendChild(cell);
3332        });
3333      }
3334
3335      function loadPage() {
3336        if (loading) return;
3337        loading = true;
3338        loadMoreContainer.innerHTML = '';
3339        fetch(API_BASE + '/api/widget/renders?limit=' + pageSize + '&offset=' + offset, {
3340          headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _customerToken || '' }
3341        }).then(function(r){ return r.ok ? r.json() : null; })
3342          .then(function(data) {
3343            loading = false;
3344            if (!data) return;
3345            var items = data.items || [];
3346            appendItems(items);
3347            offset += items.length;
3348            if (offset < Number(data.total || 0)) {
3349              var btn = document.createElement('button');
3350              btn.textContent = 'Load more';
3351              btn.setAttribute('style','padding:10px 20px;background:#222;color:#fff;border:1px solid #333;border-radius:8px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:600');
3352              btn.addEventListener('click', loadPage);
3353              loadMoreContainer.appendChild(btn);
3354            }
3355          }).catch(function(){ loading = false; });
3356      }
3357
3358      function dismiss(){ ov.remove(); document.removeEventListener('keydown', onKey); if (observer) observer.disconnect(); }
3359      function onKey(e){ if (e.key === 'Escape') dismiss(); }
3360      ov.addEventListener('click', function(e){ if (e.target === ov) dismiss(); });
3361      close.addEventListener('click', dismiss);
3362      document.addEventListener('keydown', onKey);
3363      document.body.appendChild(ov);
3364      loadPage();
3365    }
3366
3367    // Renders the Buy-Credits paywall in resultArea. step1El/formSection are
3368    // still hidden (the verify-time call site fires while they are on
3369    // screen), but upload, colour and the generate button STAY visible so a
3370    // visitor who earns renders via the claim flow keeps their photo and
3371    // colour choice - the counts-disabled generate button guards the dead
3372    // click. Countdown stays visible (showing 0 remaining) so the user
3373    // understands why.
3374    function showPaywall(opts) {
3375      step1El.style.display = 'none';
3376      formSection.style.display = 'none';
3377      ctaArea.style.display = 'block';
3378      ctaArea.innerHTML = '';
3379      ctaArea.appendChild(_buildPaywallEl(opts));
3380      updateGenBtn(true);
3381    }
3382
3383    function setS1Status(msg, isErr) {
3384      s1Status.textContent = msg;
3385      s1Status.style.color = isErr ? '#e74c3c' : '#4CAF50';
3386    }
3387
3388    function isFormReady() {
3389      return !!(nameF.inp.value.trim() && phoneF.inp.value.trim() &&
3390                brandF.inp.value.trim() && modelF.inp.value.trim() &&
3391                _inlineFile && _inlineColorId);
3392    }
3393
3394    function updateGenBtn(step3) {
3395      // Over the limit the button used to be hidden by showPaywall; it is
3396      // now disabled from the same counts the countdown is painted from.
3397      var atLimit = _rendersTotal > 0 && _rendersUsed >= _rendersTotal;
3398      var ready = step3 ? !!(  _inlineFile && _inlineColorId) : isFormReady();
3399      var actionable = !atLimit && _consented && (!_customerToken || ready);
3400      genBtn.disabled = atLimit || !_consented || (_customerToken ? !ready : false);
3401      genBtn.style.background = actionable ? '#D2D915' : '#333333';
3402      genBtn.style.color      = actionable ? '#000000' : '#888888';
3403      genBtn.style.cursor     = actionable ? 'pointer'  : 'not-allowed';
3404    }
3405
3406    function showMain(first) {
3407      step1El.style.display = 'none';
3408      emailDisplayInp.value = _verifiedEmail;
3409      emailDisplayWrap.style.display = 'block';
3410      try {
3411        localStorage.setItem('wup_ct_' + EMBED_TOKEN, _customerToken);
3412        if (_verifiedEmail) localStorage.setItem('wup_email_' + EMBED_TOKEN, _verifiedEmail);
3413      } catch(e) {}
3414      updateCountdown();
3415      formSection.style.display = first ? 'block' : 'none';
3416      uploadSection.style.display = 'block';
3417      colorSection.style.display = 'block';
3418      genBtn.style.display = 'block';
3419    }
3420
3421    // File handling
3422    var heicErrorEl = null;
3423    function showInlineHeicError() {
3424      _inlineFile = null;
3425      uploadIcon.style.display = 'none';
3426      uploadText.style.display = 'none';
3427      uploadHint.style.display = 'none';
3428      fileInput.style.display = 'none';
3429      uploadArea.style.backgroundImage = '';
3430      uploadArea.style.borderStyle = 'dashed';
3431      uploadArea.style.borderColor = '#333333';
3432      if (heicErrorEl) { heicErrorEl.remove(); heicErrorEl = null; }
3433      heicErrorEl = _buildHeicErrorEl('dark', function () {
3434        if (heicErrorEl) { heicErrorEl.remove(); heicErrorEl = null; }
3435        uploadIcon.style.display = '';
3436        uploadText.style.display = '';
3437        uploadHint.style.display = '';
3438        fileInput.style.display = '';
3439        fileInput.value = '';
3440        fileInput.click();
3441      });
3442      uploadArea.appendChild(heicErrorEl);
3443    }
3444    fileInput.addEventListener('change', function(e) {
3445      var file = e.target.files && e.target.files[0];
3446      if (!file) return;
3447      if (file.size > 10 * 1024 * 1024) { alert('File too large. Max 10 MB.'); return; }
3448      if (_isHeicFile(file)) {
3449        // Mirrors the kitchen widget: keep the file, the server converts it
3450        // (ensureNonHeif). Browsers cannot render HEIC, so show the file
3451        // name instead of a background preview.
3452        _inlineFile = file;
3453        try { _inlineOriginalUrl = URL.createObjectURL(file); } catch (err) { _inlineOriginalUrl = ''; }
3454        uploadArea.style.backgroundImage = 'none';
3455        uploadArea.style.borderStyle = 'solid';
3456        uploadIcon.style.display = 'none';
3457        uploadText.style.display = 'block';
3458        uploadHint.style.display = 'block';
3459        uploadText.textContent = file.name;
3460        uploadText.style.color = '#D2D915';
3461        uploadHint.textContent = 'iPhone (HEIC) photo - preview not available, converted automatically';
3462        uploadArea.style.borderColor = '#D2D915';
3463        updateGenBtn(formSection.style.display === 'none' || !!_customerToken);
3464        return;
3465      }
3466      _inlineFile = file;
3467      var reader = new FileReader();
3468      reader.onload = function(ev) {
3469        _inlineOriginalUrl = ev.target.result;
3470        var img = new Image();
3471        img.onload = function() { _inlineAspectRatio = (img.naturalHeight / img.naturalWidth) * 100; };
3472        img.src = ev.target.result;
3473        uploadArea.style.backgroundImage = 'url(' + ev.target.result + ')';
3474        uploadArea.style.backgroundSize = 'cover';
3475        uploadArea.style.backgroundPosition = 'center';
3476        uploadArea.style.borderStyle = 'solid';
3477        uploadIcon.style.display = 'none';
3478        uploadText.style.display = 'none';
3479        uploadHint.style.display = 'none';
3480      };
3481      reader.readAsDataURL(file);
3482      uploadText.textContent = file.name;
3483      uploadText.style.color = '#D2D915';
3484      uploadHint.textContent = (file.size / 1024).toFixed(0) + ' KB';
3485      uploadArea.style.borderColor = '#D2D915';
3486      updateGenBtn(formSection.style.display === 'none' || !!_customerToken);
3487    });
3488    uploadArea.addEventListener('dragover', function(e) { e.preventDefault(); uploadArea.style.borderColor = '#D2D915'; });
3489    uploadArea.addEventListener('dragleave', function() { uploadArea.style.borderColor = _inlineFile ? '#D2D915' : '#333333'; });
3490    uploadArea.addEventListener('drop', function(e) {
3491      e.preventDefault();
3492      var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
3493      if (!f) return;
3494      // HEIC drops are accepted too - the change handler above routes them
3495      // through the same keep-the-file path.
3496      var dt = new DataTransfer(); dt.items.add(f); fileInput.files = dt.files; fileInput.dispatchEvent(new Event('change'));
3497    });
3498
3499    // Color grid
3500    function loadColors() {
3501      colorGrid.innerHTML = '<p style="font-size:13px;color:#555555;font-family:inherit">Loading colours\u2026</p>';
3502      fetch(API_BASE + '/api/widget/colors', {
3503        headers: { 'Authorization': 'Bearer ' + _sessionToken }
3504      }).then(function(r) { return r.json(); }).then(function(colors) {
3505        colorGrid.innerHTML = '';
3506        if (!colors || !colors.length) {
3507          colorGrid.innerHTML = '<p style="font-size:13px;color:#555555;font-family:inherit">No colours available.</p>';
3508          return;
3509        }
3510        // Brand stays a select of its own - it is the partner's shelf, not a
3511        // property of a colour - and it rides in the slot the supplier widget
3512        // gives its product-line chips: top of the right column on wide,
3513        // above the block on narrow. Its counts ARE global, correctly: brand
3514        // is the outermost scope, so nothing else narrows it.
3515        var brands = [];
3516        var brandCounts = {};
3517        colors.forEach(function(c) {
3518          if (!c.manufacturer) return;
3519          if (brands.indexOf(c.manufacturer) === -1) brands.push(c.manufacturer);
3520          brandCounts[c.manufacturer] = (brandCounts[c.manufacturer] || 0) + 1;
3521        });
3522        var activeBrand = 'All';
3523        var brandSel = document.createElement('select');
3524        brandSel.setAttribute('aria-label', 'Brand');
3525        brandSel.setAttribute('style', [
3526          'box-sizing:border-box', 'width:100%', 'height:36px', 'padding:0 10px',
3527          'border:1px solid #333333', 'border-radius:8px',
3528          'background:#1a1a1a', 'color:#ffffff',
3529          // 16px: iOS Safari zooms the page when a focused control renders
3530          // under that, and never zooms back out.
3531          'font-size:16px', 'font-family:inherit', 'outline:none',
3532          'margin-bottom:10px', 'cursor:pointer'
3533        ].join(';'));
3534        var brandAll = document.createElement('option');
3535        brandAll.value = 'All';
3536        brandAll.textContent = 'All Brands (' + colors.length + ')';
3537        brandSel.appendChild(brandAll);
3538        brands.forEach(function(brand) {
3539          var opt = document.createElement('option');
3540          opt.value = brand;
3541          opt.textContent = brand + ' (' + (brandCounts[brand] || 0) + ')';
3542          brandSel.appendChild(opt);
3543        });
3544        // One value is not a choice - the rule the picker already applies to
3545        // its own two selects. A partner who carries a single brand gets no
3546        // brand control, and "All Brands (240)" beside one option was never
3547        // telling them anything.
3548        if (brands.length < 2) brandSel.style.display = 'none';
3549        function inBrand() {
3550          return activeBrand === 'All'
3551            ? colors
3552            : colors.filter(function(c) { return c.manufacturer === activeBrand; });
3553        }
3554
3555        // Everything else - the large selected colour, the label, the search
3556        // box, the family and finish filters and the grid - is the shared
3557        // picker, in the dark values this widget already uses. The brand+finish
3558        // pair it used to carry is gone: finish moves into the picker with
3559        // counts that follow the other control, and family is new here.
3560        var picker = buildColorPicker({
3561          theme: {
3562            text: '#ffffff', text2: '#aaaaaa', surface: '#1a1a1a',
3563            border: '#333333', inputBg: '#1a1a1a', accent: '#D2D915'
3564          },
3565          brandSelect: brandSel,
3566          isLocked: function(c) { var lb = _lockedBrand(); return !!(lb && (c.manufacturer || '') !== lb); },
3567          onLocked: function() { showGenError(_lockMessage() || 'Your demo is locked to another brand.'); },
3568          onSelect: function(c) {
3569            _inlineColorId = c.id;
3570            _inlineColorName = c.name;
3571            updateGenBtn(formSection.style.display === 'none' || !!_customerToken);
3572          }
3573        });
3574        _inlinePicker = picker;
3575        brandSel.addEventListener('change', function() {
3576          activeBrand = brandSel.value;
3577          // The brand narrows the set the picker filters and counts within;
3578          // the chosen colour is deliberately left alone, as with the other
3579          // two controls.
3580          picker.refilter(inBrand());
3581        });
3582        colorGrid.appendChild(picker.root);
3583        picker.setColors(colors);
3584        // The grid only has a width once it is in the document.
3585        picker.layout();
3586        // Deep link (inline and combined): the first, unfiltered render has
3587        // every colour, so the preselected one is present when it is in the
3588        // partner's set. Selecting it runs the same handler a tap does, then
3589        // the widget scrolls into view so the visitor lands on it.
3590        if (PRESELECT_COLOR_ID && !_preselectApplied) {
3591          if (picker.selectById(PRESELECT_COLOR_ID)) {
3592            _preselectApplied = true;
3593            try { rootEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) {}
3594          }
3595        }
3596      }).catch(function() {
3597        colorGrid.innerHTML = '<p style="font-size:13px;
3597color:#e74c3c;font-family:inherit">Failed to load colours.</p>';
3598      });
3599    }
3600
3601    [nameF.inp, phoneF.inp, brandF.inp, modelF.inp].forEach(function(inp) {
3602      inp.addEventListener('input', function() { if (_isFirstRender) updateGenBtn(false); });
3603    });
3604
3605    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 Send code \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
360500c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
3606    function doSendCode() {
3607      var em = emailInput.value.trim();
3608      if (!em || !/\S+@\S+\.\S+/.test(em)) { setS1Status('Please enter a valid email address.', true); return; }
3609      sendCodeBtn.textContent = 'Sending\u2026';
3610      sendCodeBtn.disabled = true;
3611      setS1Status('', false);
3612      fetch(API_BASE + VERIFY_EMAIL_PATH, {
3613        method: 'POST',
3614        headers: { 'Content-Type': 'application/json' },
3615        body: JSON.stringify({ email: em, sessionToken: _sessionToken, prospectToken: PROSPECT_TOKEN })
3616      }).then(function(r) { return r.json(); }).then(function(data) {
3617        sendCodeBtn.disabled = false;
3618        if (data.success) {
3619          _verifiedEmail = em;
3620          codeSentMsg.textContent = 'We sent a 6-digit code to ' + em;
3621          codeSection.style.display = 'block';
3622          codeInput.focus();
3623          sendCodeBtn.textContent = 'Resend Code';
3624          setS1Status('', false);
3625        } else {
3626          sendCodeBtn.textContent = 'Send Verification Code';
3627          setS1Status(data.error || 'Failed to send code. Please try again.', true);
3628        }
3629      }).catch(function() {
3630        sendCodeBtn.disabled = false;
3631        sendCodeBtn.textContent = 'Send Verification Code';
3632        setS1Status('Network error. Please try again.', true);
3633      });
3634    }
3635
3636    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 Verify code \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
363600c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
3637    function doVerifyCode() {
3638      var code = codeInput.value.replace(/[^0-9]/g, '').slice(0, 6);
3639      if (code.length !== 6) { setS1Status('Please enter the 6-digit code.', true); return; }
3640      verifyCodeBtn.textContent = 'Verifying\u2026';
3641      verifyCodeBtn.disabled = true;
3642      setS1Status('', false);
3643      fetch(API_BASE + CONFIRM_EMAIL_PATH, {
3644        method: 'POST',
3645        headers: { 'Content-Type': 'application/json' },
3646        body: JSON.stringify({ email: _verifiedEmail, code: code, sessionToken: _sessionToken })
3647      }).then(function(r) { return r.json(); }).then(function(data) {
3648        verifyCodeBtn.textContent = 'Verify Code';
3649        verifyCodeBtn.disabled = false;
3650        if (data.customerToken) {
3651          _customerToken  = data.customerToken;
3652          _rendersUsed    = data.rendersUsed   || 0;
3653          _rendersTotal   = data.freeRenderLimit || 0;
3654          // On the partner demo the visitor IS the shop, so the first-render
3655          // details form (name, phone, car) is not asked; the render they
3656          // set up before the card runs as soon as the code is right.
3657          _isFirstRender  = DEMO_EVENTS ? false : (_rendersUsed === 0);
3658          _fetchServerRenderHistory();
3659          try {
3660            if (_verifiedEmail) localStorage.setItem('wup_email_' + EMBED_TOKEN, _verifiedEmail);
3661            localStorage.setItem('wup_ct_' + EMBED_TOKEN, _customerToken);
3662          } catch(e) {}
3663          _takeLock(data);
3664          if (DEMO_EVENTS) { try { rootEl.dispatchEvent(new CustomEvent('wrapup:verified', { bubbles: true, detail: { email: _verifiedEmail } })); } catch (e) {} }
3665          updateCountdown();
3666          // Verified just now: mount the social claim element the build-time
3667          // gate skipped (no customer token then). The id presence check
3668          // keeps this idempotent.
3669          if (_socialRewardsEnabled && _socialRewardsPossible !== false && _socialClaimCtx &&
3670              _customerToken && !document.getElementById('wrapup-social-claim')) {
3671            mainEl.insertBefore(_buildSocialClaimEl(), uploadSection);
3672          }
3673          if (_rendersTotal > 0 && _rendersUsed >= _rendersTotal) {
3674            mainEl.style.display  = 'block';
3675            showPaywall();
3676          } else {
3677            showMain(_isFirstRender);
3678            loadColors();
3679            if (DEMO_EVENTS && _inlineFile && _inlineColorId) setTimeout(doGenerate, 0);
3680          }
3681        } else {
3682          var msg = data.error === 'invalid_code' ? 'Incorrect code. Please try again.' :
3683                    data.error === 'code_expired'  ? 'Code expired \u2014 click Resend to get a new one.' :
3684                    data.error === 'too_many_attempts' ? 'Too many attempts. Request a new code and try again.' :
3685                    data.error === 'rate_limited' ? 'Too many attempts. Please wait a few minutes and try again.' :
3686                    (data.error || 'Verification failed. Please try again.');
3687          setS1Status(msg, true);
3688        }
3689      }).catch(function() {
3690        verifyCodeBtn.textContent = 'Verify Code';
3691        verifyCodeBtn.disabled = false;
3692        setS1Status('Network error. Please try again.', true);
3693      });
3694    }
3695
3696    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 Generate render \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
369600c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
3697    function doGenerate() {
3698      if (_inlineGenerating || genBtn.disabled) return;
3699      if (!_inlineFile)    { setS1Status('Please upload a photo first.', true); return; }
3700      if (!_inlineColorId) { setS1Status('Please select a wrap colour.', true); return; }
3701      if (!_customerToken) { return; }
3702
3703      _inlineGenerating = true;
3704      _inlineLastRenderId = null;
3705      _inlineLastRenderToken = null;
3706      _inlineLastOriginalFile = null;
3707      genBtn.disabled   = true;
3708      genBtn.textContent = 'Generating\u2026';
3709      genBtn.style.background = '#333333';
3710      genBtn.style.color      = '#888888';
3711      genBtn.style.cursor     = 'not-allowed';
3712      progressSection.style.display = 'block';
3713      resultArea.style.display      = 'none';
3714      genErrorEl.style.display = 'none';
3715      // A previous result hid the drop zone; bring the photo back so a
3716      // failure never leaves the upload area empty (supplier precedent).
3717      uploadArea.style.display = '';
3718      uploadLabel.style.display = '';
3719      ctaArea.style.display = 'none';
3720      ctaArea.innerHTML = '';
3721
3722      var formData = new FormData();
3723      formData.append('image',   _inlineFile);
3724      formData.append('colorId', String(_inlineColorId));
3725      // Wheels rollout C2: fields ride ONLY when the section is on and a
3726      // value is actively selected - never both colors, never empty
3727      // strings, finish only alongside a color, size only when not
3728      // original. With the section off (or not built) nothing is appended
3729      // and the body is identical to before.
3730      if (_whEnabled) {
3731        if (_whColor) formData.append('wheelColor', _whColor);
3732        if (_whColorHex) formData.append('wheelColorHex', _whColorHex);
3733        if (_whColor || _whColorHex) formData.append('wheelFinish', _whFinish);
3734        if (_whSize && _whSize !== 'original') formData.append('wheelSize', _whSize);
3735        if (_whFile) formData.append('wheelImage', _whFile);
3736      }
3737      // Wheels-adjacent C3-2: the options field rides ONLY when at least
3738      // one option card is active. The C3-1 server parses a
3739      // JSON-stringified array of known ids and treats an absent field as
3740      // [] - with neither card active nothing is appended and the body is
3741      // byte-identical to before.
3742      var optList = [];
3743      if (_optChromeDelete) optList.push('chrome-delete');
3744      if (_optWindowTint) optList.push('window-tint');
3745      if (optList.length > 0) formData.append('options', JSON.stringify(optList));
3746      if (_isFirstRender) {
3747        _inlineName     = nameF.inp.value.trim();
3748        _inlinePhone    = phoneF.inp.value.trim();
3749        _inlineCarBrand = brandF.inp.value.trim();
3750        _inlineCarModel = modelF.inp.value.trim();
3751        formData.append('customerName',  _inlineName);
3752        formData.append('customerPhone', _inlinePhone);
3753        formData.append('carBrand',      _inlineCarBrand);
3754        formData.append('carModel',      _inlineCarModel);
3755      }
3756
3757      var progressMsgs = [
3758        'Generating your wrap\u2026',
3759        'Applying the colour\u2026',
3760        'Almost there\u2026',
3761        'Finishing touches\u2026',
3762      ];
3763      var msgIdx = 0;
3764      progressText.textContent = progressMsgs[0];
3765      var msgInterval = setInterval(function() {
3766        msgIdx = (msgIdx + 1) % progressMsgs.length;
3767        progressText.textContent = progressMsgs[msgIdx];
3768      }, 3000);
3769
3770      function stopProgress() { clearInterval(msgInterval); progressSection.style.display = 'none'; }
3771
3772      fetch(API_BASE + GENERATE_PATH, {
3773        method: 'POST',
3774        headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _customerToken },
3775        body: formData
3776      }).then(function(r) {
3777        if (r.status === 403) {
3778          return r.json().then(function(d) {
3779            stopProgress(); resetGenBtn();
3780            if (d.error === 'render_limit_reached' || d.error === 'invali
3780d_customer_token') {
3781              showPaywall();
3782            } else {
3783              showGenError('Access denied. Please refresh and try again.');
3784            }
3785            return null;
3786          });
3787        }
3788        if (r.status === 402) {
3789          return r.json().catch(function() { return {}; }).then(function(d) {
3790            stopProgress(); resetGenBtn();
3791            // The shop's TRIAL renders are spent: the paywall card with the
3792            // quote form, not an error line and not our pricing.
3793            if (d && d.reason === 'trial_exhausted') { showPaywall({ quoteOnly: true }); return null; }
3794            showGenError(_renderRefusalMessage(d));
3795            return null;
3796          });
3797        }
3798        if (r.status === 500) {
3799          // The server sends err.message on this path (sharp decode, Gemini,
3800          // and so on); show it when present, else the generic line.
3801          return r.json().catch(function() { return {}; }).then(function(d) {
3802            stopProgress(); resetGenBtn();
3803            showGenError(d && typeof d.message === 'string' && d.message ? d.message : 'Something went wrong. Please try again.');
3804            return null;
3805          });
3806        }
3807        if (!r.ok) { stopProgress(); resetGenBtn(); showGenError('Something went wrong. Please try again.'); return null; }
3808        return r.json();
3809      }).then(function(data) {
3810        if (!data) return;
3811        stopProgress();
3812        _inlineGenerating = false;
3813        _rendersUsed++;
3814        _isFirstRender = false;
3815        updateCountdown();
3816        // Hide form permanently after first render
3817        formSection.style.display = 'none';
3818        // Reset upload + color for next render
3819        _inlineColorId = null;
3820        _inlineColorName = '';
3821        // One call now: the picker drops its selection, empties the block and
3822        // clears every swatch border.
3823        if (_inlinePicker) _inlinePicker.clear();
3824        genBtn.textContent = 'Generate My Render';
3825        updateGenBtn(true);
3826        // Render id + image token for the quote button and the history entry,
3827        // straight from the response. A data-URL fallback (render not
3828        // persisted) leaves both null and hides the quote button.
3829        _inlineLastRenderId = (data.renderId != null) ? data.renderId : null;
3830        _inlineLastRenderToken = (typeof data.imageToken === 'string' && data.imageToken) ? data.imageToken : null;
3831        _inlineLastOriginalFile = _inlineLastRenderId != null ? _inlineFile : null;
3832        // Display the persisted render through its token URL; the slider,
3833        // fullscreen overlay and share preload all read data.imageUrl.
3834        if (_inlineLastRenderId != null) {
3835          data.imageUrl = _carImageUrl({ id: _inlineLastRenderId, token: _inlineLastRenderToken });
3836        }
3837        // Push the render to the history bar at the top...
3838        addToHistory({
3839          id: _inlineLastRenderId,
3840          token: _inlineLastRenderToken,
3841          imageUrl: _inlineLastRenderId != null ? undefined : data.imageUrl,
3842          colorName: data.colorName || '',
3843          manufacturer: data.manufacturer || '',
3844        });
3845        // ...and render the before/after slider inline in resultArea.
3846        // showInlineResult manages resultArea.style.display and innerHTML itself.
3847        showInlineResult(data);
3848        _takeLock(data);
3849        if (DEMO_EVENTS) { try { rootEl.dispatchEvent(new CustomEvent('wrapup:render', { bubbles: true, detail: { rendersUsed: data.rendersUsed, rendersRemaining: data.rendersRemaining } })); } catch (e) {} }
3850        // The drop zone is hidden while the result stands in its place, so the
3851        // photo-change affordance moves here (supplier-widget precedent).
3852        var changeBtn = document.createElement('button');
3853        changeBtn.type = 'button';
3854        changeBtn.textContent = 'Upload a different photo';
3855        changeBtn.setAttribute('style', 'display:block;margin:10px auto 0;background:none;border:none;padding:4px;cursor:pointer;font-family:inherit;font-size:13px;color:#888888;text-decoration:underline');
3856        changeBtn.addEventListener('click', function () {
3857          resultArea.style.display = 'none';
3858          resultArea.innerHTML = '';
3859          uploadArea.style.display = '';
3860          uploadLabel.style.display = '';
3861          fileInput.click();
3862        });
3863        resultArea.appendChild(changeBtn);
3864        // Upsell card below every watermarked free render, and the paywall
3865        // when that was the last one - both in the bottom slot, where they
3866        // were before the render moved up. The input controls stay visible;
3867        // the counts-disabled generate button guards the dead click.
3868        ctaArea.style.display = 'block';
3869        ctaArea.innerHTML = '';
3870        ctaArea.appendChild(_buildUpsellCardEl());
3871        if (_rendersTotal > 0 && _rendersUsed >= _rendersTotal) {
3872          ctaArea.appendChild(_buildPaywallEl());
3873        }
3874      }).catch(function(err) {
3875        stopProgress(); resetGenBtn();
3876        console.error('[WrapUpAI] generate error:', err);
3877        showGenError('Something went wrong. Please try again.');
3878      });
3879    }
3880
3881    function resetGenBtn() {
3882      _inlineGenerating  = false;
3883      genBtn.textContent = 'Generate My Render';
3884      updateGenBtn(!_isFirstRender);
3885    }
3886
3887    function showGenError(msg) {
3888      resultArea.style.display = 'none';
3889      uploadArea.style.display = '';
3890      uploadLabel.style.display = '';
3891      ctaArea.style.display = 'none';
3892      genErrorEl.textContent = msg;
3893      genErrorEl.style.display = 'block';
3894    }
3895
3896    // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
389600c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 Event wiring \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
389600c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
3897    sendCodeBtn.addEventListener('click', doSendCode);
3898    emailInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') doSendCode(); });
3899    verifyCodeBtn.addEventListener('click', doVerifyCode);
3900    codeInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') doVerifyCode(); });
3901    resendLink.addEventListener('click', function() {
3902      codeSection.style.display = 'none';
3903      codeInput.value = '';
3904      sendCodeBtn.textContent = 'Send Verification Code';
3905      sendCodeBtn.disabled = false;
3906      setS1Status('', false);
3907    });
3908    genBtn.addEventListener('click', function() {
3909      if (_customerToken) {
3910        doGenerate();
3911      } else {
3912        step1El.style.display = 'block';
3913        emailInput.focus();
3914      }
3915    });
3916
3917function showInlineResult(data) {
3918      uploadArea.style.display = 'none';
3919      uploadLabel.style.display = 'none';
3920      resultArea.style.display = 'block';
3921      resultArea.innerHTML = '';
3922
3923      // Reset Share state at the start of every render so a second render
3924      // does not share against stale image refs from the previous one. The
3925      // pre-load below repopulates these once both source images and the
3926      // logo decode.
3927      _shareReady = false;
3928      _shareOriginalImg = null;
3929      _shareRenderImg = null;
3930
3931      // Divider
3932      var divider = document.createElement('div');
3933      divider.setAttribute('style', 'border-top:1px solid #222222;margin-bottom:20px');
3934
3935      // Result heading
3936      var resultTitle = document.createElement('p');
3937      resultTitle.textContent = 'Your wrap visualisation';
3938      resultTitle.setAttribute('style', 'margin:0 0 4px;font-weight:700;color:#ffffff;font-size:16px;font-family:inherit');
3939
3940      var colorMeta = document.createElement('p');
3941      colorMeta.textContent = (data.colorName || '') + (data.manufacturer ? ' \u2014 ' + data.manufacturer : '');
3942      colorMeta.setAttribute('style', 'margin:0 0 16px;font-size:13px;color:#888888;font-family:inherit');
3943
3944      // Fix 5: Slider container with fixed aspect ratio via padding-top trick.
3945      // Appended to DOM BEFORE building children so we can read offsetWidth synchronously \u2014
3946      // this guarantees beforeImg gets the exact px width needed for object-fit:cover to
3947      // render identically to afterImg from the very first paint.
3948      var sliderContainer = document.createElement('div');
3949      sliderContainer.setAttribute('style', [
3950        'position:relative',
3951        'width:100%',
3952        'padding-top:' + _inlineAspectRatio.toFixed(2) + '%',
3953        'overflow:hidden',
3954        'border-radius:12px',
3955        'cursor:ew-resize',
3956        'user-select:none',
3957        '-webkit-user-select:none',
3958        'touch-action:none'
3959      ].join(';'));
3960
3961      // Append heading + container to DOM now so we can measure
3962      resultArea.appendChild(divider);
3963      resultArea.appendChild(resultTitle);
3964      resultArea.appendChild(colorMeta);
3965      resultArea.appendChild(sliderContainer);
3966
3967      // Read container width synchronously (element is now in the layout)
3968      var containerW = sliderContainer.getBoundingClientRect().width || sliderContainer.offsetWidth || 300;
3969
3970      // After image \u2014 absolute, object-fit:cover fills container exactly
3971      var afterImg = document.createElement('img');
3972      afterImg.src = data.imageUrl;
3973      afterImg.alt = 'Wrapped';
3974      afterImg.setAttribute('style', [
3975        'position:absolute',
3976        'top:0', 'left:0',
3977        'width:100%', 'height:100%',
3978        'object-fit:cover',
3979        'display:block',
3980        'pointer-events:none'
3981      ].join(';'));
3982
3983      // Before image \u2014 overflow-hidden wrapper clips it.
3984      // Wrapper uses top+right+bottom+left:0 so its height tracks the container reliably
3985      // regardless of the padding-top trick. The before image is given the same px width
3986      // as the full container so object-fit:cover produces an identical crop to afterImg.
3987      var beforeWrapper = document.createElement('div');
3988      beforeWrapper.setAttribute('style', [
3989        'position:absolute',
3990        'top:0', 'right:0', 'bottom:0', 'left:0',
3991        'width:50%',
3992        'overflow:hidden'
3993      ].join(';'));
3994      var beforeImg = document.createElement('img');
3995      beforeImg.src = _inlineOriginalUrl;
3996      beforeImg.alt = 'Original';
3997      beforeImg.setAttribute('style', [
3998        'position:absolute',
3999        'top:0', 'left:0',
4000        'width:' + containerW + 'px',
4001        'min-width:' + containerW + 'px',
4002        'height:100%',
4003        'object-fit:cover',
4004        'display:block',
4005        'pointer-events:none'
4006      ].join(';'));
4007      beforeWrapper.appendChild(beforeImg);
4008
4009      // Handle: vertical line + centred circle
4010      var handleEl = document.createElement('div');
4011      handleEl.setAttribute('style', [
4012        'position:absolute',
4013        'top:0', 'left:50%',
4014        'height:100%', 'width:0',
4015        'pointer-events:none'
4016      ].join(';'));
4017
4018      var handleLine = document.createElement('div');
4019      handleLine.setAttribute('style', [
4020        'position:absolute',
4021        'top:0', 'left:50%',
4022        'transform:translateX(-50%)',
4023        'width:2px', 'height:100%',
4024        'background:#ffffff'
4025      ].join(';'));
4026
4027      var handleCircle = document.createElement('div');
4028      handleCircle.setAttribute('style', [
4029        'position:absolute',
4030        'top:50%', 'left:50%',
4031        'transform:translate(-50%,-50%)',
4032        'width:38px', 'height:38px',
4033        'border-radius:50%',
4034        'background:#D2D915',
4035        'display:flex', 'align-items:center', 'justify-content:center',
4036        'box-shadow:0 2px 10px rgba(0,0,0,0.6)',
4037        'z-index:2'
4038      ].join(';'));
4039      handleCircle.innerHTML = '<svg width="18" height="14" viewBox="0 0 18 14" fill="none" stroke="#000000" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 1 1 7 5 13"/><polyline points="13 1 17 7 13 13"/></svg>';
4040
4041      handleEl.appendChild(handleLine);
4042      handleEl.appendChild(handleCircle);
4043
4044      // BEFORE / AFTER corner labels
4045      var labelBefore = document.createElement('div');
4046      labelBefore.textContent = 'BEFORE';
4047      labelBefore.setAttribute('style', [
4048        'position:absolute', 'top:10px', 'left:10px',
4049        'background:rgba(0,0,0,0.55)', 'color:#ffffff',
4050        'font-size:11px', 'font-weight:700', 'padding:3px 8px',
4051        'border-radius:4px', 'letter-spacing:0.08em',
4052        'pointer-events:none', 'font-family:inherit'
4053      ].join(';'));
4054
4055      var labelAfter = document.createElement('div');
4056      labelAfter.textContent = 'AFTER';
4057      labelAfter.setAttribute('style', [
4058        'position:absolute', 'top:10px', 'right:10px',
4059        'background:rgba(210,217,21,0.9)', 'color:#000000',
4060        'font-size:11px', 'font-weight:700', 'padding:3px 8px',
4061        'border-radius:4px', 'letter-spacing:0.08em',
4062        'pointer-events:none', 'font-family:inherit'
4063      ].join(';'));
4064
4065      // Fullscreen expand button \u2014 sits inside sliderContainer, below the AFTER label
4066      // Matches real app: absolute top-44px right-8px, z-index:10
4067      var fullscreenBtn = document.createElement('button');
4068      fullscreenBtn.title = 'View fullscreen';
4069      fullscreenBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
4070      fullscreenBtn.setAttribute('style', [
4071        'position:absolute', 'top:44px', 'right:8px',
4072        'background:rgba(0,0,0,0.65)', 'color:#D2D915',
4073        'border:none', 'border-radius:6px',
4074        'width:32px', 'height:32px',
4075        'display:flex', 'align-items:center', 'justify-content:center',
4076        'cursor:pointer', 'z-index:10', 'padding:0', 'box-sizing:border-box'
4077      ].join(';'));
4078      fullscreenBtn.addEventListener('click', function (e) {
4079        e.stopPropagation();
4080        openFullscreenOverlay(data.imageUrl, _inlineOriginalUrl);
4081      });
4082      // iOS Safari fix: stop the touchstart/mousedown from bubbling to
4083      // sliderContainer's gesture handler. The container's touchstart calls
4084      // preventDefault(), which suppresses WebKit's synthetic click on the
4085      // button — so without these listeners, tapping the button on iOS only
4086      // moves the slider (to the right edge where the button sits) and the
4087      // click handler above never fires. stopPropagation (NOT preventDefault)
4088      // is the right tool: it keeps the button's own click intact while
4089      // preventing the container from intercepting. passive:true on the
4090      // touchstart signals we will not call preventDefault, allowing iOS to
4091      // optimize the listener. Slider drag elsewhere in the container is
4092      // unaffected. See May 1 diagnose for full analysis.
4093      fullscreenBtn.addEventListener('touchstart', function (e) {
4094        e.stopPropagation();
4095      }, { passive: true });
4096      fullscreenBtn.addEventListener('mousedown', function (e) {
4097        e.stopPropagation();
4098      });
4099
4100      sliderContainer.appendChild(afterImg);
4101      sliderContainer.appendChild(beforeWrapper);
4102      sliderContainer.appendChild(handleEl);
4103      sliderContainer.appendChild(labelBefore);
4104      sliderContainer.appendChild(labelAfter);
4105      sliderContainer.appendChild(fullscreenBtn);
4106
4107      // No extra wrapper needed \u2014 button is inside the container at a safe position
4108      var sliderWrapper = sliderContainer;
4109
4110      // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 Drag logic (mouse + touch) \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
4111      var isDragging = false;
4112
4113      function setSliderPosition(clientX) {
4114        var rect = sliderContainer.getBoundingClientRect();
4115        var x = Math.max(0, Math.min(clientX - rect.left, rect.width));
4116        var pct = (x / rect.width * 100).toFixed(2);
4117        beforeWrapper.style.width = pct + '%';
4118        // Keep beforeImg the same absolute width as the container so object-fit:cover
4119        // renders identically to the after image regardless of wrapper width
4120        if (rect.width > 0) {
4121          beforeImg.style.width = rect.width + 'px';
4122          beforeImg.style.minWidth = rect.width + 'px';
4123        }
4124        handleEl.style.left = pct + '%';
4125      }
4126
4127      // Safety rAF: re-sync beforeImg width after first paint (handles any deferred layout)
4128      requestAnimationFrame(function () {
4129        var w = sliderContainer.offsetWidth;
4130        if (w > 0 && w !== containerW) {
4131          beforeImg.style.width = w + 'px';
4132          beforeImg.style.minWidth = w + 'px';
4133        }
4134      });
4135
4136      sliderContainer.addEventListener('mousedown', function (e) {
4137        isDragging = true;
4138        setSliderPosition(e.clientX);
4139        e.preventDefault();
4140      });
4141      document.addEventListener('mousemove', function (e) {
4142        if (isDragging) setSliderPosition(e.clientX);
4143      });
4144      document.addEventListener('mouseup', function () { isDragging = false; });
4145
4146      sliderContainer.addEventListener('touchstart', function (e) {
4147        isDragging = true;
4148        setSliderPosition(e.touches[0].clientX);
4149        e.preventDefault();
4150      }, { passive: false });
4151      sliderContainer.addEventListener('touchmove', function (e) {
4152        if (isDragging) setSliderPosition(e.touches[0].clientX);
4153        e.preventDefault();
4154      }, { passive: false });
4155      sliderContainer.addEventListener('touchend', function () { isDragging = false; });
4156
4157      // Share-to-Instagram button (Tier 1 Growth Loops). Style matches the
4158      // upsell card secondary outline button at _buildUpsellCardEl. Inline
4159      // SVG (lucide Share2 path) — no emojis per GR 3. Starts disabled; the
4160      // non-blocking pre-load below enables it once both source images and
4161      // the WrapUp logo asset decode. Pre-load is async on purpose: the
4162      // slider must render immediately, not block on image decode.
4163      var shareBtn = document.createElement('button');
4164      shareBtn.type = 'button';
4165      shareBtn.setAttribute('aria-label', 'Share');
4166      shareBtn.disabled = true;
4167      shareBtn.innerHTML =
4168        '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
4169        ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round"' +
4170        ' style="margin-right:8px;vertical-align:-3px">' +
4171        '<circle cx="18" cy="5" r="3"/>' +
4172        '<circle cx="6" cy="12" r="3"/>' +
4173        '<circle cx="18" cy="19" r="3"/>' +
4174        '<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>' +
4175        '<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>' +
4176        '</svg>Share';
4177      shareBtn.setAttribute('style', [
4178        'display:inline-flex', 'align-items:center', 'justify-content:center',
4179        'margin-top:14px',
4180        'padding:11px 20px', 'background:transparent', 'color:#ffffff',
4181        'border:1px solid #444444', 'border-radius:8px',
4182        'font-size:14px', 'font-weight:600',
4183        'font-family:inherit',
4184        'cursor:not-allowed', 'opacity:0.5',
4185        'transition:opacity 0.2s'
4186      ].join(';'));
4187
4188      shareBtn.addEventListener('click', function () {
4189        if (shareBtn.disabled || shareBtn.dataset.busy === 'true') return;
4190        if (!_shareReady || !_shareOriginalImg || !_shareRenderImg) return;
4191        shareBtn.dataset.busy = 'true';
4192
4193        var caption = _shareBuildCaption(data.manufacturer, data.colorName);
4194
4195        var clipPromise = (navigator.clipboard && navigator.clipboard.writeText)
4196          ? navigator.clipboard.writeText(caption).catch(function (e) {
4197              console.warn('[share] clipboard write failed:', e);
4198            })
4199          : Promise.resolve();
4200
4201        clipPromise.then(function () {
4202          return _shareBuildComposite(_shareOriginalImg, _shareRenderImg, 'widget');
4203        }).then(function (blob) {
4204          var file = new File([blob], 'wrap-up-ai-share.jpg', { type: 'image/jpeg' });
4205
4206          // Fire-and-forget analytics. Auth header lets the server resolve
4207          // partner_id from the widget session HMAC. No designId — widget
4208          // renders live in partner_renders, not generated_images.
4209          fetch(API_BASE + '/api/share-intent', {
4210            method: 'POST',
4211            headers: {
4212              'Content-Type': 'application/json',
4213              'Authorization': 'Bearer ' + _sessionToken
4214            },
4215            body: JSON.stringify({ surface: 'widget' })
4216          }).catch(function () {});
4217
4218          // Restrict native share to mobile. Desktop Chrome advertises
4219          // navigator.share for files but rejects with AbortError immediately
4220          // when called from a stale user-activation context — composite
4221          // build (~250KB JPG) outlasts the gesture window, no share sheet
4222          // ever appears, and the silent-dismiss path looks like a broken
4223          // button. Desktop always uses the download fallback.
4224          var isMobile = (navigator.userAgentData && navigator.userAgentData.mobile === true)
4225            || /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
4226          var canShareFiles = isMobile
4227            && typeof navigator.share === 'function'
4228            && typeof navigator.canShare === 'function'
4229            && navigator.canShare({ files: [file] });
4230
4231          if (canShareFiles) {
4232            // Files-only payload — passing text alongside files on iOS
4233            // Safari can drop the file at certain share targets including
4234            // Instagram. The caption is on the clipboard for the user to
4235            // paste into the Instagram caption field.
4236            return navigator.share({ files: [file] }).then(function () {
4237              _showShareToast('Caption copied — paste it in Instagram after the image opens.');
4238            }).catch(function (err) {
4239              if (err && err.name !== 'AbortError') throw err;
4240              // user dismissed the share sheet — silent
4241            });
4242          } else {
4243            var url = URL.createObjectURL(blob);
4244            var a = document.createElement('a');
4245            a.href = url;
4246            a.download = 'wrap-up-ai-share.jpg';
4247            document.body.appendChild(a);
4248            a.click();
4249            document.body.removeChild(a);
4250            setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
4251            _showShareToast('Image downloaded. Caption copied to clipboard. Paste it when you upload to Instagram.');
4252          }
4253        }).catch(function (err) {
4254          console.error('[share] failed:', err);
4255          _showShareToast('Share failed. Please try again.');
4256        }).then(function () {
4257          shareBtn.dataset.busy = 'false';
4258        });
4259      });
4260
4261      resultArea.appendChild(shareBtn);
4262
4263      // "Get a Quote" - only when the render id is known (renderId from the
4264      // response; data-URL fallback responses hide the button).
4265      var quoteStatus = document.createElement('p');
4266      quoteStatus.setAttribute('style', 'display:none;margin:10px 0 0;font-size:12px;color:#ffffff;font-family:inherit');
4267      if (_inlineLastRenderId != null) {
4268        var quoteBtn = document.createElement('button');
4269        quoteBtn.type = 'button';
4270        quoteBtn.textContent = 'Get a Quote';
4271        quoteBtn.setAttribute('style', [
4272          'display:inline-block', 'margin-top:12px', 'margin-left:8px',
4273          'padding:10px 16px', 'background:transparent', 'color:#ffffff',
4274          'border:1px solid #444444', 'border-radius:8px',
4275          'font-size:13px', 'font-weight:600', 'cursor:pointer', 'font-family:inherit',
4276        ].join(';'));
4277        quoteBtn.addEventListener('click', function () {
4278          openQuoteFormOverlay(function () {
4279            quoteStatus.textContent = 'Quote request sent - we emailed you a copy.';
4280            quoteStatus.style.display = 'block';
4281          });
4282        });
4283        resultArea.appendChild(quoteBtn);
4284      }
4285      resultArea.appendChild(quoteStatus);
4286
4287      // Pre-load source images + logo in parallel. Does NOT block slider
4288      // render — slider has already painted by the time we reach this
4289      // point. When all three decode, flip _shareReady and enable the
4290      // button. Failure leaves the button disabled (Download still works
4291      // through the existing pathways elsewhere in the widget).
4292      Promise.all([
4293        _shareLoadImage(data.imageUrl),
4294        _shareLoadImage(_inlineOriginalUrl),
4295        _shareLoadLogo()
4296      ]).then(function (results) {
4297        _shareRenderImg = results[0];
4298        _shareOriginalImg = results[1];
4299        _shareReady = true;
4300        shareBtn.disabled = false;
4301        shareBtn.style.opacity = '1';
4302        shareBtn.style.cursor = 'pointer';
4303      }).catch(function (err) {
4304        console.warn('[share] preload failed:', err);
4305      });
4306
4307      // Note: divider, resultTitle, colorMeta, sliderContainer were already appended
4308      // to resultArea above (before measuring containerW). Nothing more to append here.
4309
4310      resultArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
4311    }
4312
4313    // Car render id of a history entry; data-URL entries (render not
4314    // persisted) have none and are excluded from the quote picker.
4315    function _carRenderIdOf(entry) {
4316      return (entry && entry.id != null) ? entry.id : null;
4317    }
4318    // True when at least one quotable render exists (current, or a history
4319    // entry with a parseable id) - gates the persistent quote button.
4320    function _carHasQuotableRender() {
4321      if (_inlineLastRenderId != null) return true;
4322      for (var i = 0; i < _renderHistory.length; i++) {
4323        if (_carRenderIdOf(_renderHistory[i]) != null) return true;
4324      }
4325      return false;
4326    }
4327
4328    // "Get a Quote" overlay form v2 (dark widget theme). Split contact
4329    // fields, a render history picker, optional extra photos, and the
4330    // in-session original photo when the CURRENT render is selected. Posts
4331    // multipart FormData to /api/widget/quote with the car session token AND
4332    // x-customer-token (the v2 server requires it); onSent runs after a
4333    // successful send.
4334    function openQuoteFormOverlay(onSent) {
4335      // Candidate renders: current first, then history entries with
4336      // parseable ids (newest first as painted). Dedupe by id.
4337      var candidates = [];
4338      var seenIds = {};
4339      if (_inlineLastRenderId != null) {
4340        candidates.push({ id: _inlineLastRenderId, imageUrl: _carImageUrl({ id: _inlineLastRenderId, token: _inlineLastRenderToken }) });
4341        seenIds[String(_inlineLastRenderId)] = 1;
4342      }
4343      _renderHistory.forEach(function (it) {
4344        var rid = _carRenderIdOf(it);
4345        if (rid == null || seenIds[String(rid)]) return;
4346        seenIds[String(rid)] = 1;
4347        candidates.push({ id: rid, imageUrl: _carImageUrl(it) });
4348      });
4349      // No-render mode: the overlay opens even with zero candidates (paywall and
4350      // upsell entry points). With candidates, behavior is unchanged.
4351      var hasRender = candidates.length > 0;
4352      var selectedRenderId = hasRender ? candidates[0].id : null;
4353
4354      var overlay = document.createElement('div');
4355      overlay.setAttribute('style', [
4356        'position:fixed', 'inset:0', 'z-index:9999999',
4357        'background:rgba(0,0,0,0.75)',
4358        'display:flex', 'align-items:center', 'justify-content:center',
4359        'padding:20px', 'box-sizing:border-box',
4360      ].join(';'));
4361
4362      var card = document.createElement('div');
4363      card.setAttribute('style', [
4364        'background:#111111', 'color:#ffffff', 'border:1px solid #333333',
4365        'border-radius:14px', 'padding:24px', 'width:100%', 'max-width:420px',
4366        'max-height:90vh', 'overflow-y:auto', 'box-sizing:border-box', 'font-family:inherit',
4367      ].join(';'));
4368
4369      var title = document.createElement('p');
4370      title.textContent = 'Get a quote';
4371      title.setAttribute('style', 'margin:0 0 16px;font-size:16px;font-weight:700;color:#ffffff;font-family:inherit');
4372      card.appendChild(title);
4373
4374      // History picker - which render is this quote about? Shown only when the
4375      // visitor has >= 1 render; otherwise a short no-render note.
4376      if (hasRender) {
4377        var pickerLabel = document.createElement('p');
4378        pickerLabel.textContent = 'Which render is this quote for?';
4379        pickerLabel.setAttribute('style', 'margin:0 0 6px;font-size:12px;font-weight:600;color:#ffffff;font-family:inherit');
4380        card.appendChild(pickerLabel);
4381        var pickerRow = document.createElement('div');
4382        pickerRow.setAttribute('style', 'display:flex;gap:8px;overflow-x:auto;padding:2px;margin-bottom:14px');
4383        candidates.forEach(function (cand) {
4384          var thumb = document.createElement('img');
4385          thumb.crossOrigin = 'anonymous'; // GR 15 - set before src
4386          thumb.src = cand.imageUrl;
4387          thumb.alt = 'Render ' + cand.id;
4388          thumb.setAttribute('data-quote-render', String(cand.id));
4389          thumb.setAttribute('style', 'width:72px;height:54px;object-fit:cover;border-radius:8px;cursor:pointer;flex:0 0 auto;border:2px solid ' + (cand.id === selectedRenderId ? '#D2D915' : '#333333'));
4390          thumb.addEventListener('click', function () {
4391            selectedRenderId = cand.id;
4392            pickerRow.querySelectorAll('[data-quote-render]').forEach(function (el) {
4393              el.style.borderColor = (el.getAttribute('data-quote-render') === String(cand.id)) ? '#D2D915' : '#333333';
4394            });
4395          });
4396          pickerRow.appendChild(thumb);
4397        });
4398        card.appendChild(pickerRow);
4399      } else {
4400        var noRenderNote = document.createElement('p');
4401        noRenderNote.textContent = 'No render attached \u2014 upload a photo of your project.';
4402        noRenderNote.setAttribute('style', 'margin:0 0 14px;font-size:12px;color:#888888;font-family:inherit');
4403        card.appendChild(noRenderNote);
4404      }
4405
4406      var inputStyle = 'display:block;width:100%;padding:9px 10px;border:1px solid #333333;border-radius:8px;background:#1a1a1a;color:#ffffff;font-size:13px;font-family:inherit;box-sizing:border-box';
4407      function field(labelText, type) {
4408        var wrap = document.createElement('div');
4409        wrap.setAttribute('style', 'margin-bottom:12px');
4410        var lbl = document.createElement('label');
4411        lbl.textContent = labelText;
4412        lbl.setAttribute('style', 'display:block;margin-bottom:4px;font-size:12px;font-weight:600;color:#ffffff;font-family:inherit');
4413        var inp;
4414        if (type === 'textarea') {
4415          inp = document.createElement('textarea');
4416          inp.rows = 3;
4417          inp.maxLength = 1000;
4418          inp.setAttribute('style', inputStyle + ';resize:vertical');
4419        } else {
4420          inp = document.createElement('input');
4421          inp.type = type;
4422          inp.setAttribute('style', inputStyle);
4423        }
4424        wrap.appendChild(lbl);
4425        wrap.appendChild(inp);
4426        card.appendChild(wrap);
4427        return inp;
4428      }
4429
4430      var firstNameInp = field('First name *', 'text');
4431      var lastNameInp = field('Last name *', 'text');
4432      var phoneInp = field('Phone *', 'tel');
4433      // Verified visitor (has a customer token): email is prefilled + read-only.
4434      // Unverified (no-render entry without prior verification): editable 
4434+ required.
4435      var verified = !!_customerToken;
4436      var emailInp = field(verified ? 'Email' : 'Email *', 'email');
4437      var brandInp = field('Car brand', 'text');
4438      var modelInp = field('Car model', 'text');
4439      var streetInp = field('Street address *', 'text');
4440      var cityInp = field('City *', 'text');
4441      var zipInp = field('Zip code *', 'text');
4442      var countryInp = field('Country *', 'text');
4443      var stateInp = field('State (optional)', 'text');
4444      var messageInp = field('Message (optional)', 'textarea');
4445      // Prefill from the first-render form values when this session captured
4446      // them (name split on the first space); email is read-only from the
4447      // verified-email state.
4448      if (_inlineName) {
4449        var spaceAt = _inlineName.indexOf(' ');
4450        if (spaceAt > 0) {
4451          firstNameInp.value = _inlineName.slice(0, spaceAt).trim();
4452          lastNameInp.value = _inlineName.slice(spaceAt + 1).trim();
4453        } else {
4454          firstNameInp.value = _inlineName;
4455        }
4456      }
4457      if (_inlinePhone) phoneInp.value = _inlinePhone;
4458      if (_inlineCarBrand) brandInp.value = _inlineCarBrand;
4459      if (_inlineCarModel) modelInp.value = _inlineCarModel;
4460      if (verified) {
4461        emailInp.value = _verifiedEmail || '';
4462        emailInp.readOnly = true;
4463        emailInp.setAttribute('style', inputStyle + ';color:#888888;background:#222222');
4464      }
4465
4466      // Extra photos: compressed client-side, max 3, removable list.
4467      var photosWrap = document.createElement('div');
4468      photosWrap.setAttribute('style', 'margin-bottom:12px');
4469      var photosLbl = document.createElement('label');
4470      photosLbl.textContent = 'Add photos (optional, max 3)';
4471      photosLbl.setAttribute('style', 'display:block;margin-bottom:4px;font-size:12px;font-weight:600;color:#ffffff;font-family:inherit');
4472      var photosInp = document.createElement('input');
4473      photosInp.type = 'file';
4474      photosInp.accept = 'image/*';
4475      photosInp.multiple = true;
4476      photosInp.setAttribute('style', 'display:block;width:100%;font-size:12px;color:#888888');
4477      var photosList = document.createElement('div');
4478      photosList.setAttribute('style', 'margin-top:6px');
4479      var photosNotice = document.createElement('p');
4480      photosNotice.setAttribute('style', 'display:none;margin:4px 0 0;font-size:11px;color:#888888;font-family:inherit');
4481      photosWrap.appendChild(photosLbl);
4482      photosWrap.appendChild(photosInp);
4483      photosWrap.appendChild(photosList);
4484      photosWrap.appendChild(photosNotice);
4485      card.appendChild(photosWrap);
4486      var extraBlobs = []; // { name, blob }
4487      function showPhotosNotice(msg) { photosNotice.textContent = msg; photosNotice.style.display = 'block'; }
4488      function renderPhotosList() {
4489        photosList.innerHTML = '';
4490        extraBlobs.forEach(function (entry, idx) {
4491          var row = document.createElement('div');
4492          row.setAttribute('style', 'display:flex;align-items:center;gap:8px;font-size:12px;color:#ffffff;margin-top:4px;font-family:inherit');
4493          var nm = document.createElement('span');
4494          nm.textContent = entry.name;
4495          nm.setAttribute('style', 'flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
4496          var rm = document.createElement('span');
4497          rm.textContent = 'remove';
4498          rm.setAttribute('style', 'flex:0 0 auto;font-size:11px;color:#888888;text-decoration:underline;cursor:pointer');
4499          rm.addEventListener('click', function () {
4500            extraBlobs.splice(idx, 1);
4501            renderPhotosList();
4502          });
4503          row.appendChild(nm);
4504          row.appendChild(rm);
4505          photosList.appendChild(row);
4506        });
4507      }
4508      photosInp.addEventListener('change', function () {
4509        photosNotice.style.display = 'none';
4510        var picked = Array.prototype.slice.call(photosInp.files || []);
4511        photosInp.value = '';
4512        picked.forEach(function (file) {
4513          if (extraBlobs.length >= 3) {
4514            showPhotosNotice('Up to 3 photos - extra files were ignored.');
4515            return;
4516          }
4517          var slot = { name: file.name || 'photo.jpg', blob: null };
4518          extraBlobs.push(slot);
4519          renderPhotosList();
4520          _compressImageFile(file, function (blob) {
4521            if (!blob) {
4522              var at = extraBlobs.indexOf(slot);
4523              if (at !== -1) extraBlobs.splice(at, 1);
4524              renderPhotosList();
4525              showPhotosNotice('Could not process ' + (file.name || 'a file') + ' - skipped.');
4526              return;
4527            }
4528            slot.blob = blob;
4529          });
4530        });
4531      });
4532
4533      var errLine = document.createElement('p');
4534      errLine.setAttribute('style', 'display:none;margin:0 0 12px;font-size:12px;color:#e74c3c;font-family:inherit');
4535      card.appendChild(errLine);
4536
4537      var btnRow = document.createElement('div');
4538      btnRow.setAttribute('style', 'display:flex;gap:10px;justify-content:flex-end');
4539      var cancelBtn = document.createElement('button');
4540      cancelBtn.type = 'button';
4541      cancelBtn.textContent = 'Cancel';
4542      cancelBtn.setAttribute('style', 'padding:10px 16px;background:transparent;color:#ffffff;border:1px solid #444444;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit');
4543      var sendBtn = document.createElement('button');
4544      sendBtn.type = 'button';
4545      sendBtn.textContent = 'Send request';
4546      sendBtn.setAttribute('style', 'padding:10px 16px;background:#D2D915;color:#000000;border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit');
4547      btnRow.appendChild(cancelBtn);
4548      btnRow.appendChild(sendBtn);
4549      card.appendChild(btnRow);
4550
4551      function dismiss() { overlay.remove(); }
4552      overlay.addEventListener('click', function (e) { if (e.target === overlay) dismiss(); });
4553      cancelBtn.addEventListener('click', dismiss);
4554
4555      function showErr(msg) { errLine.textContent = msg; errLine.style.display = 'block'; }
4556
4557      sendBtn.addEventListener('click', function () {
4558        if (sendBtn.disabled) return;
4559        errLine.style.display = 'none';
4560        var firstV = firstNameInp.value.trim();
4561        var lastV = lastNameInp.value.trim();
4562        var phoneV = phoneInp.value.trim();
4563        var streetV = streetInp.value.trim();
4564        var cityV = cityInp.value.trim();
4565        var zipV = zipInp.value.trim();
4566        var countryV = countryInp.value.trim();
4567        if (!firstV || !lastV || !phoneV || !streetV || !cityV || !zipV || !countryV) {
4568          showErr('Please fill in all required fields.');
4569          return;
4570        }
4571        // Unverified visitor: email is editable and required.
4572        var emailV = emailInp.value.trim();
4573        if (!verified && !/^\S+@\S+\.\S+/.test(emailV)) {
4574          showErr('Please enter a valid email address.');
4575          return;
4576        }
4577        // No-render mode: at least one photo is required (mirrors the server's
4578        // photo_required rule). No in-session original here, so it must come
4579        // from extraPhotos.
4580        var readyPhotos = extraBlobs.filter(function (entry) { return !!entry.blob; });
4581        if (!hasRender && readyPhotos.length === 0) {
4582          showErr('Please add at least one photo of your project.');
4583          return;
4584        }
4585        sendBtn.disabled = true;
4586        sendBtn.textContent = 'Sending...';
4587        sendBtn.style.opacity = '0.6';
4588        function restore() { sendBtn.disabled = false; sendBtn.textContent = 'Send request'; sendBtn.style.opacity = '1'; }
4589
4590        function doSend(originalBlob) {
4591          // Multipart FormData (do NOT set Content-Type manually - the
4592          // browser adds the multipart boundary).
4593          var fd = new FormData();
4594          fd.append('service', 'car');
4595          if (hasRender) fd.append('renderId', String(selectedRenderId));
4596          if (!verified) fd.append('customerEmail', emailV);
4597          fd.append('firstName', firstV);
4598          fd.append('lastName', lastV);
4599          fd.append('phone', phoneV);
4600          fd.append('street', streetV);
4601          fd.append('city', cityV);
4602          fd.append('zip', zipV);
4603          fd.append('country', countryV);
4604          fd.append('state', stateInp.value.trim());
4605          fd.append('carBrand', brandInp.value.trim());
4606          fd.append('carModel', modelInp.value.trim());
4607          fd.append('message', messageInp.value.trim());
4608          if (originalBlob) fd.append('originalPhoto', originalBlob, 'original.jpg');
4609          extraBlobs.forEach(function (entry) {
4610            if (entry.blob) fd.append('extraPhotos', entry.blob, entry.name);
4611          });
4612          fetch(API_BASE + '/api/widget/quote', {
4613            method: 'POST',
4614            headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _customerToken || '' },
4615            body: fd
4616          }).then(function (r) {
4617            return r.json().catch(function () { return {}; });
4618          }).then(function (d) {
4619            if (d && d.ok === true) {
4620              dismiss();
4621              if (onSent) onSent();
4622              return;
4623            }
4624            restore();
4625            showErr(_quoteErrorMessage(d));
4626          }).catch(function () {
4627            restore();
4628            showErr(_quoteErrorMessage(null));
4629          });
4630        }
4631
4632        // The original photo exists client-side only for the CURRENT render
4633        // of this session; history-picked renders send no original.
4634        if (hasRender && selectedRenderId === _inlineLastRenderId && _inlineLastOriginalFile) {
4635          _compressImageFile(_inlineLastOriginalFile, function (blob) { doSend(blob || null); });
4636        } else {
4637          doSend(null);
4638        }
4639      });
4640
4641      overlay.appendChild(card);
4642      document.body.appendChild(overlay);
4643    }
4644
4645    // Fix 6: Fullscreen overlay with Before / After toggle
4646    function openFullscreenOverlay(afterUrl, beforeUrl) {
4647      var fsOverlay = document.createElement('div');
4648      fsOverlay.setAttribute('style', [
4649        'position:fixed', 'inset:0', 'z-index:9999999',
4650        'background:rgba(0,0,0,0.95)',
4651        'display:flex', 'flex-direction:column',
4652        'align-items:center', 'justify-content:center',
4653        'padding:20px', 'box-sizing:border-box'
4654      ].join(';'));
4655
4656      var fsImg = document.createElement('img');
4657      fsImg.src = afterUrl;
4658      fsImg.setAttribute('style', [
4659        'max-width:100%',
4660        'max-height:calc(100vh - 100px)',
4661        'object-fit:contain',
4662        'border-radius:10px',
4663        'display:block'
4664      ].join(';'));
4665
4666      // Before / After toggle row
4667      var btnRow = document.createElement('div');
4668      btnRow.setAttribute('style', 'display:flex;gap:12px;margin-top:18px');
4669
4670      function makeToggleBtn(label, isActive) {
4671        var b = document.createElement('button');
4672        b.textContent = label;
4673        b.setAttribute('style', [
4674          'padding:9px 28px',
4675          'border-radius:8px',
4676          'border:2px solid ' + (isActive ? '#D2D915' : '#444444'),
4677          'background:' + (isActive ? '#D2D915' : 'transparent'),
4678          'color:' + (isActive ? '#000000' : '#ffffff'),
4679          'font-size:13px', 'font-weight:700',
4680          'cursor:pointer', 'font-family:inherit',
4681          'letter-spacing:0.08em',
4682          'transition:background 0.15s,border-color 0.15s,color 0.15s'
4683        ].join(';'));
4684        return b;
4685      }
4686
4687      var afterToggle  = makeToggleBtn('AFTER', true);
4688      var beforeToggle = makeToggleBtn('BEFORE', false);
4689
4690      function setActive(showAfter) {
4691        fsImg.src = showAfter ? afterUrl : beforeUrl;
4692        afterToggle.style.background    = showAfter ? '#D2D915'     : 'transparent';
4693        afterToggle.style.borderColor   = showAfter ? '#D2D915'     : '#444444';
4694        afterToggle.style.color         = showAfter ? '#000000'     : '#ffffff';
4695        beforeToggle.style.background   = showAfter ? 'transparent' : '#D2D915';
4696        beforeToggle.style.borderColor  = showAfter ? '#444444'     : '#D2D915';
4697        beforeToggle.style.color        = showAfter ? '#ffffff'     : '#000000';
4698      }
4699
4700      afterToggle.addEventListener('click',  function (e) { e.stopPropagation(); setActive(true);  });
4701      beforeToggle.addEventListener('click', function (e) { e.stopPropagation(); setActive(false); });
4702
4703      btnRow.appendChild(beforeToggle);
4704      btnRow.appendChild(afterToggle);
4705
4706      // Close button (top-right \u00c3\u0083\u00c2\u0083\u00c3\u0082\u00c2\u0097)
4707      var fsClose = document.createElement('button');
4708      fsClose.innerHTML = '&times;';
4709      fsClose.setAttribute('style', [
4710        'position:absolute', 'top:16px', 'right:16px',
4711        'background:rgba(255,255,255,0.12)', 'color:#ffffff',
4712        'border:none', 'border-radius:50%',
4713        'width:38px', 'height:38px',
4714        'font-size:22px', 'line-height:1',
4715        'cursor:pointer', 'display:flex',
4716        'align-items:center', 'justify-content:center',
4717        'z-index:10'
4718      ].join(';'));
4719      fsClose.addEventListener('click', function (e) {
4720        e.stopPropagation();
4721        fsOverlay.remove();
4722      });
4723
4724      // Click backdrop to dismiss
4725      fsOverlay.addEventListener('click', function () { fsOverlay.remove(); });
4726      fsImg.addEventListener('click',    function (e) { e.stopPropagation(); });
4727      btnRow.addEventListener('click',   function (e) { e.stopPropagation(); });
4728
4729      fsOverlay.appendChild(fsImg);
4730      fsOverlay.appendChild(btnRow);
4731      fsOverlay.appendChild(fsClose);
4732      document.body.appendChild(fsOverlay);
4733    }
4734  }
4735
4736  // \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080 7. Start \u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u
473600c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080\u00c3\u0083\u00c2\u00a2\u00c3\u0082\u00c2\u0094\u00c3\u0082\u00c2\u0080
4737  // ===== TICKET MODAL (v36 Items 0b+0c) =====
4738  // Vanilla-JS parallel of client/src/components/ticket-dialog.tsx. Same
4739  // data contract (POST /api/tickets, surface='widget') and same validation
4740  // rules. Background #111111, no emojis, no backdrop-blur per GR 3 + GR 5.
4741  function openTicketModal() {
4742    if (document.getElementById('wrapup-ticket-overlay')) return;
4743
4744    var overlay = document.createElement('div');
4745    overlay.id = 'wrapup-ticket-overlay';
4746    overlay.setAttribute('style', [
4747      'position:fixed', 'inset:0', 'z-index:9999999',
4748      'background:rgba(0,0,0,0.6)',
4749      'display:flex', 'align-items:center', 'justify-content:center',
4750      'padding:16px', 'box-sizing:border-box',
4751      'font-family:system-ui,-apple-system,sans-serif'
4752    ].join(';'));
4753
4754    var panel = document.createElement('div');
4755    panel.setAttribute('style', [
4756      'background:#111111', 'border:1px solid #333333', 'border-radius:12px',
4757      'width:100%', 'max-width:440px', 'max-height:90vh', 'overflow-y:auto',
4758      'padding:24px', 'box-sizing:border-box', 'color:#ffffff', 'position:relative'
4759    ].join(';'));
4760
4761    var title = document.createElement('h2');
4762    title.textContent = 'Submit a ticket';
4763    title.setAttribute('style', 'margin:0 0 6px;font-size:18px;font-weight:700;color:#ffffff;font-family:inherit');
4764
4765    var sub = document.createElement('p');
4766    sub.textContent = "Tell us what's wrong or what's missing.";
4767    sub.setAttribute('style', 'margin:0 0 16px;font-size:13px;color:#888888;font-family:inherit');
4768
4769    var descLabel = document.createElement('label');
4770    descLabel.textContent = 'What happened?';
4771    descLabel.setAttribute('style', 'display:block;margin:0 0 6px;font-size:13px;color:#cccccc;font-family:inherit');
4772
4773    var descEl = document.createElement('textarea');
4774    descEl.rows = 6;
4775    descEl.maxLength = 2000;
4776    descEl.placeholder = 'Describe the issue or your suggestion...';
4777    descEl.setAttribute('style', [
4778      'display:block', 'width:100%', 'box-sizing:border-box',
4779      'padding:10px 12px', 'background:#1a1a1a', 'color:#ffffff',
4780      'border:1px solid #333333', 'border-radius:8px',
4781      'font-size:14px', 'font-family:inherit', 'resize:vertical', 'outline:none'
4782    ].join(';'));
4783
4784    var counter = document.createElement('p');
4785    counter.textContent = '0 / 2000';
4786    counter.setAttribute('style', 'margin:4px 0 16px;font-size:11px;color:#666666;text-align:right;font-family:inherit');
4787
4788    var emailLabel = document.createElement('label');
4789    emailLabel.textContent = 'Email (optional)';
4790    emailLabel.setAttribute('style', 'display:block;margin:0 0 6px;font-size:13px;color:#cccccc;font-family:inherit');
4791
4792    var emailEl = document.createElement('input');
4793    emailEl.type = 'email';
4794    emailEl.placeholder = '[email protected]';
4795    emailEl.value = (function () {
4796      try { return localStorage.getItem('wup_email_' + EMBED_TOKEN) || ''; }
4797      catch (e) { return ''; }
4798    })();
4799    emailEl.setAttribute('style', [
4800      'display:block', 'width:100%', 'box-sizing:border-box',
4801      'padding:10px 12px', 'background:#1a1a1a', 'color:#ffffff',
4802      'border:1px solid #333333', 'border-radius:8px',
4803      'font-size:14px', 'font-family:inherit', 'outline:none'
4804    ].join(';'));
4805
4806    var emailHint = document.createElement('p');
4807    emailHint.textContent = "Add an email if you'd like a reply. Otherwise leave blank.";
4808    emailHint.setAttribute('style', 'margin:4px 0 16px;font-size:11px;color:#666666;font-family:inherit');
4809
4810    var errorEl = document.createElement('p');
4811    errorEl.setAttribute('style', 'margin:0 0 12px;font-size:13px;color:#ff6b6b;font-family:inherit;display:none');
4812
4813    var btnRow = document.createElement('div');
4814    btnRow.setAttribute('style', 'display:flex;gap:8px;justify-content:flex-end;margin-top:8px');
4815
4816    var cancelBtn = document.createElement('button');
4817    cancelBtn.type = 'button';
4818    cancelBtn.textContent = 'Cancel';
4819    cancelBtn.setAttribute('style', [
4820      'padding:10px 16px', 'background:transparent', 'color:#cccccc',
4821      'border:1px solid #444444', 'border-radius:8px',
4822      'font-size:14px', 'font-weight:600', 'font-family:inherit', 'cursor:pointer'
4823    ].join(';'));
4824
4825    var submitBtn = document.createElement('button');
4826    submitBtn.type = 'button';
4827    submitBtn.textContent = 'Submit ticket';
4828    submitBtn.setAttribute('style', [
4829      'padding:10px 16px', 'background:#D2D915', 'color:#000000',
4830      'border:0', 'border-radius:8px',
4831      'font-size:14px', 'font-weight:700', 'font-family:inherit', 'cursor:pointer'
4832    ].join(';'));
4833
4834    function emailValid() {
4835      var e = emailEl.value.trim();
4836      if (e.length === 0) return true;
4837      return /^\S+@\S+\.\S+$/.test(e);
4838    }
4839    function descValid() {
4840      return descEl.value.trim().length >= 10;
4841    }
4842    function updateSubmitState() {
4843      var ok = descValid() && emailValid();
4844      submitBtn.disabled = !ok;
4845      submitBtn.style.opacity = ok ? '1' : '0.5';
4846      submitBtn.style.cursor = ok ? 'pointer' : 'not-allowed';
4847    }
4848    descEl.addEventListener('input', function () {
4849      counter.textContent = descEl.value.length + ' / 2000';
4850      updateSubmitState();
4851    });
4852    emailEl.addEventListener('input', updateSubmitState);
4853    updateSubmitState();
4854
4855    var isSubmitting = false;
4856    function showError(msg) {
4857      errorEl.textContent = msg;
4858      errorEl.style.display = 'block';
4859    }
4860    function clearError() {
4861      errorEl.textContent = '';
4862      errorEl.style.display = 'none';
4863    }
4864
4865    function showSuccessState() {
4866      panel.innerHTML = '';
4867      var ok = document.createElement('p');
4868      ok.textContent = 'Ticket submitted. Thanks!';
4869      ok.setAttribute('style', 'margin:0 0 16px;font-size:16px;font-weight:700;color:#ffffff;font-family:inherit;text-align:center');
4870      var closeBtn = document.createElement('button');
4871      closeBtn.type = 'button';
4872      closeBtn.textContent = 'Close';
4873      closeBtn.setAttribute('style', [
4874        'display:block', 'margin:0 auto', 'padding:10px 20px',
4875        'background:#D2D915', 'color:#000000', 'border:0', 'border-radius:8px',
4876        'font-size:14px', 'font-weight:700', 'font-family:inherit', 'cursor:pointer'
4877      ].join(';'));
4878      closeBtn.addEventListener('click', dismiss);
4879      panel.appendChild(ok);
4880      panel.appendChild(closeBtn);
4881      setTimeout(dismiss, 2000);
4882    }
4883
4884    function doSubmit() {
4885      if (isSubmitting) return;
4886      if (!descValid() || !emailValid()) return;
4887      isSubmitting = true;
4888      clearError();
4889      submitBtn.disabled = true;
4890      submitBtn.textContent = 'Submitting...';
4891      var headers = { 'Content-Type': 'application/json' };
4892      if (_sessionToken) headers['Authorization'] = 'Bearer ' + _sessionToken;
4893      var bodyVal = {
4894        description: descEl.value.trim(),
4895        email: emailEl.value.trim() || undefined,
4896        surface: 'widget',
4897        viewport: window.innerWidth + 'x' + window.innerHeight,
4898        url: window.location.href
4899      };
4900      fetch('https://wrap-up.ai/api/tickets', {
4901        method: 'POST',
4902        headers: headers,
4903        body: JSON.stringify(bodyVal)
4904      }).then(function (res) {
4905        if (res.status === 201) {
4906          showSuccessState();
4907          return null;
4908        }
4909        return res.text().then(function (raw) {
4910          var detail = raw || ('HTTP ' + res.status);
4911          if (raw) {
4912            try {
4913              var parsed = JSON.parse(raw);
4914              if (parsed && typeof parsed.message === 'string') detail = parsed.message;
4915              else if (parsed && typeof parsed.error === 'string') detail = parsed.error;
4916            } catch (e) { /* not JSON */ }
4917          }
4918          throw new Error(detail);
4919        });
4920      }).catch(function (err) {
4921        isSubmitting = false;
4922        submitBtn.disabled = false;
4923        submitBtn.textContent = 'Submit ticket';
4924        updateSubmitState();
4925        showError(err && err.message ? err.message : 'Network error. Please try again.');
4926      });
4927    }
4928
4929    cancelBtn.addEventListener('click', dismiss);
4930    submitBtn.addEventListener('click', doSubmit);
4931
4932    function dismiss() {
4933      overlay.remove();
4934      document.removeEventListener('keydown', onKey);
4935    }
4936    function onKey(e) { if (e.key === 'Escape') dismiss(); }
4937
4938    overlay.addEventListener('click', function (e) {
4939      if (e.target === overlay) dismiss();
4940    });
4941    document.addEventListener('keydown', onKey);
4942
4943    panel.appendChild(title);
4944    panel.appendChild(sub);
4945    panel.appendChild(descLabel);
4946    panel.appendChild(descEl);
4947    panel.appendChild(counter);
4948    panel.appendChild(emailLabel);
4949    panel.appendChild(emailEl);
4950    panel.appendChild(emailHint);
4951    panel.appendChild(errorEl);
4952    btnRow.appendChild(cancelBtn);
4953    btnRow.appendChild(submitBtn);
4954    panel.appendChild(btnRow);
4955    overlay.appendChild(panel);
4956    document.body.appendChild(overlay);
4957
4958    setTimeout(function () { try { descEl.focus(); } catch (e) { /* focus best-effort */ } }, 50);
4959  }
4960  // ===== END TICKET MODAL =====
4961
4962  // ===== WHITE INLINE KITCHEN WIDGET (Phase 5b) =====
4963  // Additive. Wired only when data-service="kitchen" (see bootstrap + init).
4964  // Reuses the SAME customer-token + email-verification mechanism as the car
4965  // inline widget: the same endpoints (/api/widget/verify-email,
4966  // /api/widget/confirm-email), the same localStorage keys
4967  // (wup_ct_<token>, wup_email_<token>, wup_privacy_consent_at), and the same
4968  // session token. Renders entirely in the light KT palette below; it never
4969  // references or mutates the car DARK style literals.
4970  var KT = { bg:'#ffffff', text:'#1a1a1a', muted:'#6b6b6b', border:'#e5e5e5', accent:'#D2D915', accentText:'#1a1a1a', swatchFallback:'#eeeeee' };
4971
4972  var _ktFile = null;
4973  var _ktOriginalUrl = null; // object-URL of the uploaded photo (slider BEFORE)
4974  // Multi-surface selection: one optional finish per surface (swatch objects
4975  // from _colors; null = surface not selected). The grid assigns to whichever
4976  // surface tile is active.
4977  var _ktSurfaceSel = { cabinets: null, countertop: null, backsplash: null };
4978  var _ktActiveSurface = 'cabinets';
4979  var _ktGenerating = false;
4980  var _ktCustomerToken = (function(){ try { return localStorage.getItem('wup_ct_' + EMBED_TOKEN) || null; } catch(e) { return null; } })();
4981  var _ktEmail = (function(){ try { return localStorage.getItem('wup_email_' + EMBED_TOKEN) || ''; } catch(e) { return ''; } })();
4982  var _ktConsented = (function(){ try { return !!localStorage.getItem('wup_privacy_consent_at'); } catch(e) { return false; } })();
4983  var _ktRendersUsed = 0;
4984  var _ktRendersTotal = 0;
4985  var _ktUpdateCounter = function () {}; // set by injectInlineKitchen; lets runKitchenGenerate live-decrement
4986  var _ktHistory = (function () { try { return JSON.parse(localStorage.getItem('wup_kt_history2_' + EMBED_TOKEN) || '[]') || []; } catch (e) { return []; } })();
4987  var _ktHistoryTotal = 0;
4988  var _ktAddHistory = function () {}; // set by injectInlineKitchen; adds a new render to the history bar
4989  var _ktHideResultPlaceholder = function () {}; // set by injectInlineKitchen; hides the output-column empty state
4990  var _ktLastRenderId = null; // renderId of the last successful kitchen render; cleared on new generate
4991  var _ktLastRenderToken = null; // imageToken of the last successful kitchen render; cleared on new generate
4992
4993  function _ktStyle(el, s) { el.setAttribute('style', s); return el; }
4994
4995  // Kitchen counterpart of _pwHoverLighten, adapted to the light KT surface:
4996  // the border shifts KT.border -> KT.muted while hovered (on white the
4997  // affordance runs darker, not lighter). Same inline-style mouseenter/
4998  // mouseleave idiom.
4999  function _ktPwHover(el) {
5000    el.addEventListener('mouseenter', function () { el.style.borderColor = KT.muted; });
5001    el.addEventListener('mouseleave', function () { el.style.borderColor = KT.border; });
5002  }
5003
5004  // White exhausted-state paywall card, mirroring the car widget's
5005  // _buildPaywallEl in the KT palette. The "Buy Credits" button is ONLY a
5006  // redirect to the existing pricing page (same URL as the car) - no Stripe/
5007  // credit code is touched. "Get a Quote" is shown only when the partner has a
5008  // quote form URL, mirroring the car.
5009  function _ktBuildPaywallEl(opts) {
5010    var quoteOnly = !!(opts && opts.quoteOnly);
5011    var pw = document.createElement('div');
5012    _ktStyle(pw, 'margin-top:20px;padding:28px 24px;background:' + KT.bg + ';border:1px solid ' + KT.border + ';border-radius:12px;text-align:center');
5013    var title = document.createElement('p');
5014    title.textContent = 'You\u2019ve used all your free renders';
5015    _ktStyle(title, 'color:' + KT.text + ';font-size:18px;font-weight:700;margin:0 0 8px;font-family:inherit');
5016    var sub = document.createElement('p');
5017    sub.textContent = quoteOnly ? 'Ask for a quote and the shop will get back to you.' : 'Unlock unlimited renders with a WRAP-UP.AI plan.';
5018    _ktStyle(sub, 'color:' + KT.muted + ';font-size:14px;margin:0 0 20px;font-family:inherit');
5019    var btnRow = document.createElement('div');
5020    _ktStyle(btnRow, 'display:flex;gap:10px;justify-content:center;flex-wrap:wrap');
5021    if (!quoteOnly) {
5022      var buyBtn = document.createElement('a');
5023      buyBtn.href = 'https://www.wrap-up.ai?pricing=true';
5024      buyBtn.target = '_blank';
5025      buyBtn.rel = 'noopener';
5026      buyBtn.textContent = 'Buy Credits for More Renders';
5027      _ktStyle(buyBtn, 'display:inline-block;padding:13px 24px;background:transparent;color:' + KT.text + ';border:1px solid ' + KT.border + ';border-radius:8px;font-size:15px;font-weight:600;text-decoration:none;font-family:inherit;cursor:pointer;text-align:center');
5028      _ktPwHover(buyBtn);
5029      btnRow.appendChild(buyBtn);
5030    }
5031    // "Get a Quote" opens the in-widget overlay (no external nav). Shown
5032    // regardless of _quoteFormUrl; the overlay works with or without a render.
5033    var quoteBtn = document.createElement('button');
5034    quoteBtn.type = 'button';
5035    quoteBtn.textContent = 'Get a Quote';
5036    _ktStyle(quoteBtn, 'display:inline-block;padding:13px 24px;background:transparent;color:' + KT.text + ';border:1px solid ' + KT.border + ';border-radius:8px;font-size:15px;font-weight:600;font-family:inherit;cursor:pointer');
5037    _ktPwHover(quoteBtn);
5038    quoteBtn.addEventListener('click', function () { _ktOpenQuoteForm(); });
5039    btnRow.appendChild(quoteBtn);
5040    // Request credits - emails the partner (no credits granted; GR 2). Only
5041    // shown when a verified customer token exists (the endpoint requires it)
5042    // AND social rewards are off: with the Instagram route available,
5043    // following is the free path and the manual credit request is redundant.
5044    if (_ktCustomerToken && !(_socialRewardsEnabled && _socialRewardsPossible !== false && _socialClaimCtx)) {
5045      var reqBtn = document.createElement('button');
5046      reqBtn.type = 'button';
5047      reqBtn.textContent = 'Request credits';
5048      _ktStyle(reqBtn, 'display:inline-block;padding:13px 24px;background:transparent;color:' + KT.text + ';border:1px solid ' + KT.border + ';border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit');
5049      _ktPwHover(reqBtn);
5050      reqBtn.addEventListener('click', function () {
5051        if (reqBtn.disabled) return;
5052        reqBtn.disabled = true;
5053        reqBtn.textContent = 'Sending\u2026';
5054        fetch(API_BASE + '/api/widget/kitchen/request-credits', {
5055          method: 'POST',
5056          headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _ktCustomerToken || '' },
5057          body: JSON.stringify({})
5058        }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
5059          if (d && d.ok) {
5060            reqBtn.textContent = 'Request sent';
5061            reqBtn.style.opacity = '0.6';
5062          } else {
5063            reqBtn.textContent = 'Request credits';
5064            reqBtn.disabled = false;
5065          }
5066        }).catch(function () {
5067          reqBtn.textContent = 'Request credits';
5068          reqBtn.disabled = false;
5069        });
5070      });
5071      btnRow.appendChild(reqBtn);
5072    }
5073    pw.appendChild(title);
5074    pw.appendChild(sub);
5075    pw.appendChild(btnRow);
5076    _stackPaywallRow(pw, btnRow);
5077    return pw;
5078  }
5079
5080  // Share button mirroring the car widget's shareBtn, in the KT palette.
5081  // Reuses the existing module-level _shareLoadImage/_shareLoadLogo/
5082  // _shareBuildCaption/_shareBuildComposite/_showShareToast helpers (no
5083  // duplication). Posts share-intent with surface 'kitchen'. Starts disabled
5084  // until both source images + the logo decode (crossOrigin handled by
5085  // _shareLoadImage, GR 15). No emojis (inline lucide Share2 SVG).
5086  function _ktBuildShareButton(afterUrl, beforeUrl, colorName, manufacturer) {
5087    var ready = false, origImg = null, renderImg = null, busy = false;
5088    var btn = document.createElement('button');
5089    btn.type = 'button';
5090    btn.setAttribute('aria-label', 'Share');
5091    btn.disabled = true;
5092    btn.innerHTML =
5093      '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
5094      ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round"' +
5095      ' style="margin-right:8px;vertical-align:-3px">' +
5096      '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>' +
5097      '<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>' +
5098      '</svg>Share';
5099    _ktStyle(btn, 'display:inline-flex;align-items:center;justify-content:center;margin-top:12px;margin-right:8px;padding:10px 16px;background:transparent;
5099color:' + KT.text + ';border:1px solid ' + KT.border + ';border-radius:8px;font-size:13px;font-weight:600;font-family:inherit;cursor:not-allowed;opacity:0.5;transition:opacity 0.2s');
5100
5101    Promise.all([_shareLoadImage(afterUrl), _shareLoadImage(beforeUrl), _shareLoadLogo()]).then(function (res) {
5102      renderImg = res[0]; origImg = res[1]; ready = true;
5103      btn.disabled = false; btn.style.opacity = '1'; btn.style.cursor = 'pointer';
5104    }).catch(function (err) { console.warn('[kitchen share] preload failed:', err); });
5105
5106    btn.addEventListener('click', function () {
5107      if (btn.disabled || busy || !ready || !origImg || !renderImg) return;
5108      busy = true;
5109      var caption = _shareBuildCaption(manufacturer, colorName);
5110      var clipPromise = (navigator.clipboard && navigator.clipboard.writeText)
5111        ? navigator.clipboard.writeText(caption).catch(function () {})
5112        : Promise.resolve();
5113      clipPromise.then(function () {
5114        // 'widget' composite surface: the kitchen render is watermarked with
5115        // widgetMode, so the footer-crop matches the widget formula.
5116        return _shareBuildComposite(origImg, renderImg, 'widget');
5117      }).then(function (blob) {
5118        var file = new File([blob], 'wrap-up-ai-share.jpg', { type: 'image/jpeg' });
5119        fetch(API_BASE + '/api/share-intent', {
5120          method: 'POST',
5121          headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _sessionToken },
5122          body: JSON.stringify({ surface: 'kitchen' })
5123        }).catch(function () {});
5124        var isMobile = (navigator.userAgentData && navigator.userAgentData.mobile === true)
5125          || /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
5126        var canShareFiles = isMobile && typeof navigator.share === 'function'
5127          && typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] });
5128        if (canShareFiles) {
5129          return navigator.share({ files: [file] }).then(function () {
5130            _showShareToast('Caption copied - paste it in Instagram after the image opens.');
5131          }).catch(function (err) { if (err && err.name !== 'AbortError') throw err; });
5132        } else {
5133          var url = URL.createObjectURL(blob);
5134          var a = document.createElement('a');
5135          a.href = url; a.download = 'wrap-up-ai-share.jpg';
5136          document.body.appendChild(a); a.click(); document.body.removeChild(a);
5137          setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
5138          _showShareToast('Image downloaded. Caption copied to clipboard. Paste it when you upload to Instagram.');
5139        }
5140      }).catch(function (err) {
5141        console.error('[kitchen share] failed:', err);
5142        _showShareToast('Share failed. Please try again.');
5143      }).then(function () { busy = false; });
5144    });
5145
5146    return btn;
5147  }
5148
5149  // Image URL for a unified-history entry. Car rows go through the car
5150  // builder (_carImageUrl, shared with the car surface, so the car path is
5151  // spelled once); kitchen rows prefer the unguessable image token
5152  // (GET /api/widget/kitchen/renders/t/:token/image) and fall back to the
5153  // sequential id route only when the entry carries no token. Missing
5154  // service defaults to kitchen. Every kitchen-surface site calls this.
5155  function _ktRenderImageUrl(entry) {
5156    if (!entry) return '';
5157    if (entry.service === 'car') return _carImageUrl(entry);
5158    if (typeof entry.token === 'string' && entry.token) return API_BASE + '/api/widget/kitchen/renders/t/' + entry.token + '/image';
5159    if (entry.id != null) return API_BASE + '/api/widget/kitchen/renders/' + entry.id + '/image';
5160    return '';
5161  }
5162
5163  // Simple fullscreen image viewer for kitchen history thumbs (mirrors the car
5164  // openImageFullscreen): dark backdrop, KT-accent download, ESC/backdrop close.
5165  function _ktOpenImage(url) {
5166    var ov = document.createElement('div');
5167    _ktStyle(ov, 'position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,0.92);display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box');
5168    var img = document.createElement('img');
5169    img.crossOrigin = 'anonymous'; // GR 15 - set before src
5170    img.src = url;
5171    _ktStyle(img, 'max-width:100%;max-height:100%;object-fit:contain;border-radius:8px');
5172    var close = document.createElement('button');
5173    close.type = 'button';
5174    close.innerHTML = '&times;';
5175    _ktStyle(close, 'position:absolute;top:16px;right:16px;background:rgba(255,255,255,0.12);color:#fff;border:none;border-radius:50%;width:40px;height:40px;font-size:24px;
5175cursor:pointer;display:flex;align-items:center;justify-content:center');
5176    var dl = document.createElement('a');
5177    dl.href = url; dl.download = 'kitchen.jpg'; dl.target = '_blank'; dl.rel = 'noopener';
5178    dl.textContent = 'Download';
5179    _ktStyle(dl, 'position:absolute;top:16px;left:16px;padding:8px 14px;background:' + KT.accent + ';color:' + KT.accentText + ';border-radius:6px;font-size:13px;font-weight:700;text-decoration:none;font-family:inherit');
5180    function dismiss() { ov.remove(); document.removeEventListener('keydown', onKey); }
5181    function onKey(e) { if (e.key === 'Escape') dismiss(); }
5182    ov.addEventListener('click', function (e) { if (e.target === ov) dismiss(); });
5183    close.addEventListener('click', dismiss);
5184    document.addEventListener('keydown', onKey);
5185    ov.appendChild(img); ov.appendChild(close); ov.appendChild(dl);
5186    document.body.appendChild(ov);
5187  }
5188
5189  // Paginated kitchen-history grid modal (mirrors the car _openHistoryModal),
5190  // fetching from the customer-scoped /api/widget/kitchen/renders. Dark lightbox.
5191  function _ktOpenHistoryModal() {
5192    if (!_sessionToken || !_ktCustomerToken) return;
5193    var ov = document.createElement('div');
5194    _ktStyle(ov, 'position:fixed;inset:0;z-index:999998;background:rgba(0,0,0,0.92);display:flex;flex-direction:column;padding:24px;box-sizing:border-box');
5195    var header = document.createElement('div');
5196    _ktStyle(header, 'display:flex;justify-content:flex-end;margin-bottom:16px;flex-shrink:0');
5197    var close = document.createElement('button');
5198    close.type = 'button'; close.innerHTML = '&times;';
5199    _ktStyle(close, 'background:rgba(255,255,255,0.12);color:#fff;border:none;border-radius:50%;width:40px;height:40px;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center');
5200    header.appendChild(close);
5201    var body = document.createElement('div');
5202    _ktStyle(body, 'flex:1;overflow-y:auto;padding-right:8px');
5203    var grid = document.createElement('div');
5204    _ktStyle(grid, 'display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:12px');
5205    body.appendChild(grid);
5206    var loadMoreContainer = document.createElement('div');
5207    _ktStyle(loadMoreContainer, 'margin-top:16px;text-align:center');
5208    body.appendChild(loadMoreContainer);
5209    ov.appendChild(header); ov.appendChild(body);
5210
5211    var offset = 0, pageSize = 100, loading = false;
5212    function appendItems(items) {
5213      items.forEach(function (item) {
5214        var imageUrl = _ktRenderImageUrl({ id: item.id, service: item.service, token: item.imageToken });
5215        var cell = document.createElement('div');
5216        _ktStyle(cell, 'position:relative;cursor:pointer;border-radius:8px;overflow:hidden;background:#1a1a1a');
5217        var wrap = document.createElement('div');
5218        _ktStyle(wrap, 'position:relative;width:100%;padding-top:75%');
5219        var img = document.createElement('img');
5220        img.crossOrigin = 'anonymous'; // GR 15 - set before src
5221        img.src = imageUrl;
5222        img.alt = item.colorName || 'render';
5223        _ktStyle(img, 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:block');
5224        wrap.appendChild(img);
5225        // Service tag (Car / Kitchen) - subtle KT pill, top-left.
5226        var svcTag = document.createElement('span');
5227        svcTag.textContent = (item.service === 'car') ? 'Car' : 'Kitchen';
5228        _ktStyle(svcTag, 'position:absolute;top:6px;left:6px;background:rgba(255,255,255,0.9);color:' + KT.muted + ';font-size:10px;font-weight:600;padding:1px 6px;border-radius:6px;line-height:1.4');
5229        wrap.appendChild(svcTag);
5230        if (item.colorName) {
5231          var label = document.createElement('div');
5232          label.textContent = item.colorName;
5233          _ktStyle(label, 'position:absolute;left:0;right:0;bottom:0;background:linear-gradient(transparent,rgba(0,0,0,0.85));color:#fff;font-size:11px;padding:18px 8px 6px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis');
5234          wrap.appendChild(label);
5235        }
5236        cell.appendChild(wrap);
5237        cell.addEventListener('click', function () { dismiss(); _ktOpenImage(imageUrl); });
5238        grid.appendChild(cell);
5239      });
5240    }
5241    function loadPage() {
5242      if (loading) return;
5243      loading = true;
5244      loadMoreContainer.innerHTML = '';
5245      fetch(API_BASE + '/api/widget/kitchen/renders?limit=' + pageSize + '&offset=' + offset, {
5246        headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _ktCustomerToken || '' }
5247      }).then(function (r) { return r.ok ? r.json() : null; }).then(function (data) {
5248        loading = false;
5249        if (!data) return;
5250        var items = data.items || [];
5251        appendItems(items);
5252        offset += items.length;
5253        if (offset < Number(data.total || 0)) {
5254          var btn = document.createElement('button');
5255          btn.type = 'button';
5256          btn.textContent = 'Load more';
5257          _ktStyle(btn, 'padding:10px 20px;background:#222;color:#fff;border:1px solid #333;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600');
5258          btn.addEventListener('click', loadPage);
5259          loadMoreContainer.appendChild(btn);
5260        }
5261      }).catch(function () { loading = false; });
5262    }
5263    function dismiss() { ov.remove(); document.removeEventListener('keydown', onKey); }
5264    function onKey(e) { if (e.key === 'Escape') dismiss(); }
5265    ov.addEventListener('click', function (e) { if (e.target === ov) dismiss(); });
5266    close.addEventListener('click', dismiss);
5267    document.addEventListener('keydown', onKey);
5268    document.body.appendChild(ov);
5269    loadPage();
5270  }
5271
5272  // White before/after slider (mouse + touch), mirroring the car widget's
5273  // showInlineResult slider in the KT palette. BEFORE = the uploaded photo,
5274  // AFTER = the kitchen render. Returns the container element.
5275  function _ktBuildSlider(afterUrl, beforeUrl) {
5276    var container = document.createElement('div');
5277    _ktStyle(container, [
5278      'position:relative', 'width:100%', 'padding-top:75%', 'overflow:hidden',
5279      'border-radius:12px', 'cursor:ew-resize', 'user-select:none',
5280      '-webkit-user-select:none', 'touch-action:none', 'background:' + KT.swatchFallback
5281    ].join(';'));
5282
5283    var afterImg = document.createElement('img');
5284    afterImg.crossOrigin = 'anonymous'; // GR 15 - set before src
5285    afterImg.src = afterUrl;
5286    afterImg.alt = 'After';
5287    _ktStyle(afterImg, 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:block;pointer-events:none');
5288
5289    var beforeWrapper = document.createElement('div');
5290    _ktStyle(beforeWrapper, 'position:absolute;top:0;right:0;bottom:0;left:0;width:50%;overflow:hidden');
5291    var beforeImg = document.createElement('img');
5292    beforeImg.crossOrigin = 'anonymous'; // GR 15 - set before src
5293    beforeImg.src = beforeUrl;
5294    beforeImg.alt = 'Before';
5295    _ktStyle(beforeImg, 'position:absolute;top:0;left:0;height:100%;object-fit:cover;display:block;pointer-events:none');
5296    beforeWrapper.appendChild(beforeImg);
5297
5298    var handleEl = document.createElement('div');
5299    _ktStyle(handleEl, 'position:absolute;top:0;left:50%;height:100%;width:0;pointer-events:none');
5300    var handleLine = document.createElement('div');
5301    _ktStyle(handleLine, 'position:absolute;top:0;left:50%;transform:translateX(-50%);width:2px;height:100%;background:' + KT.text);
5302    var handleCircle = document.createElement('div');
5303    _ktStyle(handleCircle, 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:36px;height:36px;border-radius:50%;background:' + KT.accent + ';box-shadow:0 2px 10px rgba(0,0,0,0.25);z-index:2');
5304    handleEl.appendChild(handleLine);
5305    handleEl.appendChild(handleCircle);
5306
5307    var labelBefore = document.createElement('div');
5308    labelBefore.textContent = 'BEFORE';
5309    _ktStyle(labelBefore, 'position:absolute;top:10px;left:10px;background:rgba(0,0,0,0.55);color:#ffffff;font-size:11px;font-weight:700;padding:3px 8px;border-radius:4px;letter-spacing:0.08em;pointer-events:none');
5310    var labelAfter = document.createElement('div');
5311    labelAfter.textContent = 'AFTER';
5312    _ktStyle(labelAfter, 'position:absolute;top:10px;right:10px;background:rgba(210,217,21,0.9);color:#000000;font-size:11px;font-weight:700;padding:3px 8px;border-radius:4px;letter-spacing:0.08em;pointer-events:none');
5313
5314    // Fullscreen expand button - opens the AFTER/BEFORE fullscreen viewer.
5315    var fullscreenBtn = document.createElement('button');
5316    fullscreenBtn.type = 'button';
5317    fullscreenBtn.title = 'View fullscreen';
5318    fullscreenBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/>
5318<line x1="3" y1="21" x2="10" y2="14"/></svg>';
5319    _ktStyle(fullscreenBtn, 'position:absolute;top:44px;right:8px;background:rgba(0,0,0,0.55);color:#ffffff;border:none;border-radius:6px;width:32px;height:32px;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:10;padding:0;box-sizing:border-box');
5320    fullscreenBtn.addEventListener('click', function (e) { e.stopPropagation(); _ktOpenFullscreen(afterUrl, beforeUrl); });
5321    // iOS: keep the button's own click intact while the container's touchstart
5322    // calls preventDefault (same reasoning as the car widget's fullscreen btn).
5323    fullscreenBtn.addEventListener('touchstart', function (e) { e.stopPropagation(); }, { passive: true });
5324    fullscreenBtn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
5325
5326    container.appendChild(afterImg);
5327    container.appendChild(beforeWrapper);
5328    container.appendChild(handleEl);
5329    container.appendChild(labelBefore);
5330    container.appendChild(labelAfter);
5331    container.appendChild(fullscreenBtn);
5332
5333    function setSliderPosition(clientX) {
5334      var rect = container.getBoundingClientRect();
5335      var x = Math.max(0, Math.min(clientX - rect.left, rect.width));
5336      var pct = (rect.width > 0 ? (x / rect.width * 100) : 50).toFixed(2);
5337      beforeWrapper.style.width = pct + '%';
5338      if (rect.width > 0) {
5339        beforeImg.style.width = rect.width + 'px';
5340        beforeImg.style.minWidth = rect.width + 'px';
5341      }
5342      handleEl.style.left = pct + '%';
5343    }
5344    function syncWidth() {
5345      var w = container.getBoundingClientRect().width || container.offsetWidth;
5346      if (w > 0) { beforeImg.style.width = w + 'px'; beforeImg.style.minWidth = w + 'px'; }
5347    }
5348    // Match the container's aspect to the real render once it decodes (fidelity).
5349    afterImg.addEventListener('load', function () {
5350      if (afterImg.naturalWidth > 0) {
5351        container.style.paddingTop = (afterImg.naturalHeight / afterImg.naturalWidth * 100).toFixed(2) + '%';
5352      }
5353      requestAnimationFrame(syncWidth);
5354    });
5355    requestAnimationFrame(syncWidth);
5356    // Re-sync the pixel-frozen before-image width when the column resizes
5357    // (responsive stacking). The observer is GC-ed with the element; legacy
5358    // browsers without ResizeObserver re-sync on the first drag.
5359    if (typeof ResizeObserver !== 'undefined') {
5360      new ResizeObserver(function () { syncWidth(); }).observe(container);
5361    }
5362
5363    var isDragging = false;
5364    container.addEventListener('mousedown', function (e) { isDragging = true; setSliderPosition(e.clientX); e.preventDefault(); });
5365    document.addEventListener('mousemove', function (e) { if (isDragging) setSliderPosition(e.clientX); });
5366    document.addEventListener('mouseup', function () { isDragging = false; });
5367    container.addEventListener('touchstart', function (e) { isDragging = true; setSliderPosition(e.touches[0].clientX); e.preventDefault(); }, { passive: false });
5368    container.addEventListener('touchmove', function (e) { if (isDragging) setSliderPosition(e.touches[0].clientX); e.preventDefault(); }, { passive: false });
5369    container.addEventListener('touchend', function () { isDragging = false; });
5370
5371    return container;
5372  }
5373
5374  // Fullscreen viewer with an AFTER / BEFORE toggle, mirroring the car widget's
5375  // openFullscreenOverlay. Dark backdrop (standard lightbox) with KT-accent
5376  // toggles. No emojis (the close glyph is the HTML entity &times;).
5377  function _ktOpenFullscreen(afterUrl, beforeUrl) {
5378    var overlay = document.createElement('div');
5379    _ktStyle(overlay, 'position:fixed;inset:0;z-index:9999999;background:rgba(0,0,0,0.92);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;box-sizing:border-box');
5380
5381    var fsImg = document.createElement('img');
5382    fsImg.crossOrigin = 'anonymous'; // GR 15 - set before src
5383    fsImg.src = afterUrl;
5384    fsImg.alt = 'Kitchen preview';
5385    _ktStyle(fsImg, 'max-width:100%;max-height:calc(100vh - 100px);object-fit:contain;border-radius:10px;display:block');
5386
5387    var btnRow = document.createElement('div');
5388    _ktStyle(btnRow, 'display:flex;gap:12px;margin-top:18px');
5389    function makeToggleBtn(label, isActive) {
5390      var b = document.createElement('button');
5391      b.type = 'button';
5392      b.textContent = label;
5393      _ktStyle(b, 'padding:9px 28px;border-radius:8px;border:2px solid ' + (isActive ? KT.accent : '#777777') + ';background:' + (isActive ? KT.accent : 'transparent') + ';color:' + (isActive ? KT.accentText : '#ffffff') + ';font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;letter-spacing:0.08em');
5394      return b;
5395    }
5396    var afterToggle = makeToggleBtn('AFTER', true);
5397    var beforeToggle = makeToggleBtn('BEFORE', false);
5398    function setActive(showAfter) {
5399      fsImg.src = showAfter ? afterUrl : beforeUrl;
5400      afterToggle.style.background  = showAfter ? KT.accent : 'transparent';
5401      afterToggle.style.borderColor = showAfter ? KT.accent : '#777777';
5402      afterToggle.style.color       = showAfter ? KT.accentText : '#ffffff';
5403      beforeToggle.style.background  = showAfter ? 'transparent' : KT.accent;
5404      beforeToggle.style.borderColor = showAfter ? '#777777' : KT.accent;
5405      beforeToggle.style.color       = showAfter ? '#ffffff' : KT.accentText;
5406    }
5407    afterToggle.addEventListener('click',  function (e) { e.stopPropagation(); setActive(true);  });
5408    beforeToggle.addEventListener('click', function (e) { e.stopPropagation(); setActive(false); });
5409    btnRow.appendChild(beforeToggle);
5410    btnRow.appendChild(afterToggle);
5411
5412    var fsClose = document.createElement('button');
5413    fsClose.type = 'button';
5414    fsClose.innerHTML = '&times;';
5415    _ktStyle(fsClose, 'position:absolute;top:16px;right:16px;background:rgba(255,255,255,0.12);color:#ffffff;border:none;border-radius:50%;width:38px;height:38px;font-size:22px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:10');
5416    fsClose.addEventListener('click', function (e) { e.stopPropagation(); overlay.remove(); });
5417
5418    overlay.addEventListener('click', function () { overlay.remove(); });
5419    fsImg.addEventListener('click',   function (e) { e.stopPropagation(); });
5420    btnRow.addEventListener('click',  function (e) { e.stopPropagation(); });
5421
5422    overlay.appendChild(fsImg);
5423    overlay.appendChild(btnRow);
5424    overlay.appendChild(fsClose);
5425    document.body.appendChild(overlay);
5426  }
5427
5428  // Shared by the car and kitchen quote forms: canvas downscale of a chosen
5429  // photo to JPEG q0.8, longest side <= 1600px (never enlarged). cb(blob) on
5430  // success, cb(null) on any error - the caller skips the file.
5431  function _compressImageFile(file, cb) {
5432    try {
5433      var url = URL.createObjectURL(file);
5434      var img = new Image();
5435      img.onload = function () {
5436        try {
5437          var w = img.naturalWidth, h = img.naturalHeight;
5438          if (!w || !h) { URL.revokeObjectURL(url); cb(null); return; }
5439          var scale = Math.min(1, 1600 / Math.max(w, h));
5440          var cw = Math.max(1, Math.round(w * scale));
5441          var ch = Math.max(1, Math.round(h * scale));
5442          var canvas = document.createElement('canvas');
5443          canvas.width = cw;
5444          canvas.height = ch;
5445          canvas.getContext('2d').drawImage(img, 0, 0, cw, ch);
5446          URL.revokeObjectURL(url);
5447          canvas.toBlob(function (blob) { cb(blob || null); }, 'image/jpeg', 0.8);
5448        } catch (e) { try { URL.revokeObjectURL(url); } catch (e2) {} cb(null); }
5449      };
5450      img.onerror = function () { try { URL.revokeObjectURL(url); } catch (e) {} cb(null); };
5451      img.src = url;
5452    } catch (e) { cb(null); }
5453  }
5454
5455  // Shared quote-form server-error copy (same mapping for car + kitchen).
5456  function _quoteErrorMessage(d) {
5457    if (d && d.error === 'no_contact_email') return 'This shop has not set up quote requests yet - please contact them directly.';
5458    if (d && d.error === 'image_too_large') return 'One of the photos is too large - please pick smaller ones.';
5459    if (d && (d.error === 'too_many_files' || d.error === 'invalid_file_type')) return 'Please attach up to 3 photos (images only).';
5460    return 'Could not send your request - please try again.';
5461  }
5462
5463  // Shared render-refusal copy (same mapping for car + kitchen). Every 402
5464  // from the render gate carries { error: 'widget_unavailable', reason,
5465  // message }. THE MESSAGE IS NOW RENDERED (2026-09-19): it is the one
5466  // source of this wording, and reason only picks a fallback for a refusal
5467  // that carries no message. The objection this comment used to record -
5468  // that a display helper wrote via innerHTML - was fixed in the same
5469  // commit; all three now write via textContent.
5470  function _renderRefusalMessage(d) {
5471    // The server's sentence wins (2026-09-19). The three below stay as a
5472    // safety net for a refusal that forgets to carry one - they are no
5473    // longer a second source of the wording, and the server's copies are
5474    // byte-identical to them, so nothing a visitor reads changed the day
5475    // this landed.
5476    if (d && typeof d.message === 'string' && d.message) return d.message;
5477    if (d && d.reason === 'daily_cap_reached') return 'This visualizer has reached today\'s limit. Please come back tomorrow.';
5478    if (d && d.reason === 'monthly_cap_reached') return 'This visualizer has reached its limit for this month. Please contact the shop.';
5479    if (d && d.reason === 'subscription_inactive') return 'This visualizer is temporarily unavailable.';
5480    return 'Something went wrong. Please try again.';
5481  }
5482
5483  // "Get a Quote" overlay form v2 (KT palette). Split contact fields, a
5484  // kitchen-history render picker, and optional extra photos. Posts
5485  // multipart FormData to /api/widget/quote with the kitchen session token
5486  // AND x-customer-token (the v2 server requires it); onSent runs after a
5487  // successful send so the caller can show the confirmation line.
5488  function _ktOpenQuoteForm(onSent) {
5489    // Candidate renders: the current one (when present) plus the visitor's
5490    // KITCHEN history (car rows never appear here). Dedupe by id.
5491    var candidates = [];
5492    var seenIds = {};
5493    if (_ktLastRenderId != null) {
5494      candidates.push({ id: _ktLastRenderId, imageUrl: _ktRenderImageUrl({ id: _ktLastRenderId, service: 'kitchen', token: _ktLastRenderToken }) });
5495      seenIds[String(_ktLastRenderId)] = 1;
5496    }
5497    _ktHistory.forEach(function (it) {
5498      if (!it || it.id == null || it.service !== 'kitchen') return;
5499      if (seenIds[String(it.id)]) return;
5500      seenIds[String(it.id)] = 1;
5501      candidates.push({ id: it.id, imageUrl: _ktRenderImageUrl(it) });
5502    });
5503    // No-render mode: the overlay opens even with zero candidates (paywall and
5504    // persistent-bar entry points). With candidates, behavior is unchanged.
5505    var hasRender = candidates.length > 0;
5506    var selectedRenderId = hasRender ? candidates[0].id : null;
5507
5508    var overlay = document.createElement('div');
5509    _ktStyle(overlay, 'position:fixed;inset:0;z-index:9999999;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;padding:20px;box-sizing:border-box');
5510
5511    var card = document.createElement('div');
5512    _ktStyle(card, 'background:' + KT.bg + ';color:' + KT.text + ';border-radius:14px;padding:24px;width:100%;max-width:420px;max-height:90vh;overflow-y:auto;box-sizing:border-box;font-family:inherit');
5513
5514    var title = document.createElement('p');
5515    title.textContent = 'Get a quote';
5516    _ktStyle(title, 'margin:0 0 16px;font-size:16px;font-weight:700;color:' + KT.text);
5517    card.appendChild(title);
5518
5519    // History picker - which render is this quote about? Shown only when the
5520    // visitor has >= 1 kitchen render; otherwise a short no-render note.
5521    if (hasRender) {
5522      var pickerLabel = document.createElement('p');
5523      pickerLabel.textContent = 'Which render is this quote for?';
5524      _ktStyle(pickerLabel, 'margin:0 0 6px;font-size:12px;font-weight:600;color:' + KT.text);
5525      card.appendChild(pickerLabel);
5526      var pickerRow = document.createElement('div');
5527      _ktStyle(pickerRow, 'display:flex;gap:8px;overflow-x:auto;padding:2px;margin-bottom:14px');
5528      candidates.forEach(function (cand) {
5529        var thumb = document.createElement('img');
5530        thumb.crossOrigin = 'anonymous'; // GR 15 - set before src
5531        thumb.src = cand.imageUrl;
5532        thumb.alt = 'Render ' + cand.id;
5533        thumb.setAttribute('data-quote-render', String(cand.id));
5534        _ktStyle(thumb, 'width:72px;height:54px;object-fit:cover;border-radius:8px;cursor:pointer;flex:0 0 auto;border:2px solid ' + (cand.id === selectedRenderId ? KT.accent : KT.border));
5535        thumb.addEventListener('click', function () {
5536          selectedRenderId = cand.id;
5537          pickerRow.querySelectorAll('[data-quote-render]').forEach(function (el) {
5538            el.style.borderColor = (el.getAttribute('data-quote-render') === String(cand.id)) ? KT.accent : KT.border;
5539          });
5540        });
5541        pickerRow.appendChild(thumb);
5542      });
5543      card.appendChild(pickerRow);
5544    } else {
5545      // Create-a-render prompt: only in no-render mode, and only when the
5546      // partner configured a kitchen render-page URL. Sends the visitor (same
5547      // tab) to the page where the render widget lives. No URL -> nothing.
5548      if (_kitchenRenderUrl) {
5549        var makeRenderWrap = document.createElement('div');
5550        _ktStyle(makeRenderWrap, 'margin:0 0 14px;padding:12px;border:1px solid ' + KT.border + ';border-radius:10px;text-align:center');
5551        var makeRenderQ = document.createElement('p');
5552        makeRenderQ.textContent = 'Haven\'t made a render yet?';
5553        _ktStyle(makeRenderQ, 'margin:0 0 8px;font-size:12px;color:' + KT.text);
5554        var makeRenderLink = document.createElement('a');
5555        makeRenderLink.href = _kitchenRenderUrl;
5556        makeRenderLink.textContent = 'Click here to create a render';
5557        _ktStyle(makeRenderLink, 'display:inline-block;padding:9px 16px;background:' + KT.accent + ';color:' + KT.accentText + ';border-radius:8px;font-size:13px;font-weight:700;text-decoration:none;font-family:inherit');
5558        // Close the overlay first, then navigate (same tab) - otherwise the
5559        // destination loads behind the still-open overlay. The real href stays
5560        // so hover/right-click/middle-click still expose the URL.
5561        makeRenderLink.addEventListener('click', function (e) {
5562          e.preventDefault();
5563          dismiss();
5564          window.location.href = _kitchenRenderUrl;
5565        });
5566        makeRenderWrap.appendChild(makeRenderQ);
5567        makeRenderWrap.appendChild(makeRenderLink);
5568        card.appendChild(makeRenderWrap);
5569      }
5570      var noRenderNote = document.createElement('p');
5571      noRenderNote.textContent = 'No render attached \u2014 upload a photo of your project.';
5572      _ktStyle(noRenderNote, 'margin:0 0 14px;font-size:12px;color:' + KT.muted);
5573      card.appendChild(noRenderNote);
5574    }
5575
5576    var inputStyle = 'display:block;width:100%;padding:9px 10px;border:1px solid ' + KT.border + ';border-radius:8px;background:' + KT.bg + ';
5576color:' + KT.text + ';font-size:13px;font-family:inherit;box-sizing:border-box';
5577    function field(labelText, type) {
5578      var wrap = document.createElement('div');
5579      _ktStyle(wrap, 'margin-bottom:12px');
5580      var lbl = document.createElement('label');
5581      lbl.textContent = labelText;
5582      _ktStyle(lbl, 'display:block;margin-bottom:4px;font-size:12px;font-weight:600;color:' + KT.text);
5583      var inp;
5584      if (type === 'textarea') {
5585        inp = document.createElement('textarea');
5586        inp.rows = 3;
5587        inp.maxLength = 1000;
5588        _ktStyle(inp, inputStyle + ';resize:vertical');
5589      } else {
5590        inp = document.createElement('input');
5591        inp.type = type;
5592        _ktStyle(inp, inputStyle);
5593      }
5594      wrap.appendChild(lbl);
5595      wrap.appendChild(inp);
5596      card.appendChild(wrap);
5597      return inp;
5598    }
5599
5600    var firstNameInp = field('First name *', 'text');
5601    var lastNameInp = field('Last name *', 'text');
5602    var phoneInp = field('Phone *', 'tel');
5603    // Verified visitor (has a customer token): email is prefilled + read-only.
5604    // Unverified (no-render entry without prior verification): editable + required.
5605    var verified = !!_ktCustomerToken;
5606    var emailInp = field(verified ? 'Email' : 'Email *', 'email');
5607    if (verified) {
5608      emailInp.value = _ktEmail || '';
5609      emailInp.readOnly = true;
5610      _ktStyle(emailInp, inputStyle + ';color:' + KT.muted + ';background:' + KT.swatchFallback);
5611    }
5612    var streetInp = field('Street address *', 'text');
5613    var cityInp = field('City *', 'text');
5614    var zipInp = field('Zip code *', 'text');
5615    var countryInp = field('Country *', 'text');
5616    var stateInp = field('State (optional)', 'text');
5617    var messageInp = field('Message (optional)', 'textarea');
5618
5619    // Extra photos: compressed client-side, max 3, removable list.
5620    var photosWrap = document.createElement('div');
5621    _ktStyle(photosWrap, 'margin-bottom:12px');
5622    var photosLbl = document.createElement('label');
5623    photosLbl.textContent = 'Add photos (optional, max 3)';
5624    _ktStyle(photosLbl, 'display:block;margin-bottom:4px;font-size:12px;font-weight:600;color:' + KT.text);
5625    var photosInp = document.createElement('input');
5626    photosInp.type = 'file';
5627    photosInp.accept = 'image/*';
5628    photosInp.multiple = true;
5629    _ktStyle(photosInp, 'display:block;width:100%;font-size:12px;color:' + KT.muted);
5630    var photosList = document.createElement('div');
5631    _ktStyle(photosList, 'margin-top:6px');
5632    var photosNotice = document.createElement('p');
5633    _ktStyle(photosNotice, 'display:none;margin:4px 0 0;font-size:11px;color:' + KT.muted);
5634    photosWrap.appendChild(photosLbl);
5635    photosWrap.appendChild(photosInp);
5636    photosWrap.appendChild(photosList);
5637    photosWrap.appendChild(photosNotice);
5638    card.appendChild(photosWrap);
5639    var extraBlobs = []; // { name, blob }
5640    function showPhotosNotice(msg) { photosNotice.textContent = msg; photosNotice.style.display = 'block'; }
5641    function renderPhotosList() {
5642      photosList.innerHTML = '';
5643      extraBlobs.forEach(function (entry, idx) {
5644        var row = document.createElement('div');
5645        _ktStyle(row, 'display:flex;align-items:center;gap:8px;font-size:12px;color:' + KT.text + ';margin-top:4px');
5646        var nm = document.createElement('span');
5647        nm.textContent = entry.name;
5648        _ktStyle(nm, 'flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
5649        var rm = document.createElement('span');
5650        rm.textContent = 'remove';
5651        _ktStyle(rm, 'flex:0 0 auto;font-size:11px;color:' + KT.muted + ';text-decoration:underline;cursor:pointer');
5652        rm.addEventListener('click', function () {
5653          extraBlobs.splice(idx, 1);
5654          renderPhotosList();
5655        });
5656        row.appendChild(nm);
5657        row.appendChild(rm);
5658        photosList.appendChild(row);
5659      });
5660    }
5661    photosInp.addEventListener('change', function () {
5662      photosNotice.style.display = 'none';
5663      var picked = Array.prototype.slice.call(photosInp.files || []);
5664      photosInp.value = '';
5665      picked.forEach(function (file) {
5666        if (extraBlobs.length >= 3) {
5667          showPhotosNotice('Up to 3 photos - extra files were ignored.');
5668          return;
5669        }
5670        var slot = { name: file.name || 'photo.jpg', blob: null };
5671        extraBlobs.push(slot);
5672        renderPhotosList();
5673        _compressImageFile(file, function (blob) {
5674          if (!blob) {
5675            var at = extraBlobs.indexOf(slot);
5676            if (at !== -1) extraBlobs.splice(at, 1);
5677            renderPhotosList();
5678            showPhotosNotice('Could not process ' + (file.name || 'a file') + ' - skipped.');
5679            return;
5680          }
5681          slot.blob = blob;
5682        });
5683      });
5684    });
5685
5686    var errLine = document.createElement('p');
5687    _ktStyle(errLine, 'display:none;margin:0 0 12px;font-size:12px;color:#c0392b');
5688    card.appendChild(errLine);
5689
5690    var btnRow = document.createElement('div');
5691    _ktStyle(btnRow, 'display:flex;gap:10px;justify-content:flex-end');
5692    var cancelBtn = document.createElement('button');
5693    cancelBtn.type = 'button';
5694    cancelBtn.textContent = 'Cancel';
5695    _ktStyle(cancelBtn, 'padding:10px 16px;background:transparent;color:' + KT.text + ';border:1px solid ' + KT.border + ';border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit');
5696    var sendBtn = document.createElement('button');
5697    sendBtn.type = 'button';
5698    sendBtn.textContent = 'Send request';
5699    _ktStyle(sendBtn, 'padding:10px 16px;background:' + KT.accent + ';color:' + KT.accentText + ';border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit');
5700    btnRow.appendChild(cancelBtn);
5701    btnRow.appendChild(sendBtn);
5702    card.appendChild(btnRow);
5703
5704    function dismiss() { overlay.remove(); }
5705    overlay.addEventListener('click', function (e) { if (e.target === overlay) dismiss(); });
5706    cancelBtn.addEventListener('click', dismiss);
5707
5708    function showErr(msg) { errLine.textContent = msg; errLine.style.display = 'block'; }
5709
5710    sendBtn.addEventListener('click', function () {
5711      if (sendBtn.disabled) return;
5712      errLine.style.display = 'none';
5713      var firstV = firstNameInp.value.trim();
5714      var lastV = lastNameInp.value.trim();
5715      var phoneV = phoneInp.value.trim();
5716      var streetV = streetInp.value.trim();
5717      var cityV = cityInp.value.trim();
5718      var zipV = zipInp.value.trim();
5719      var countryV = countryInp.value.trim();
5720      if (!firstV || !lastV || !phoneV || !streetV || !cityV || !zipV || !countryV) {
5721        showErr('Please fill in all required fields.');
5722        return;
5723      }
5724      // Unverified visitor: email is editable and required.
5725      var emailV = emailInp.value.trim();
5726      if (!verified && !/^\S+@\S+\.\S+/.test(emailV)) {
5727        showErr('Please enter a valid email address.');
5728        return;
5729      }
5730      // No-render mode: at least one photo is required (mirrors the server's
5731      // photo_required rule). Kitchen sends only extraPhotos.
5732      var readyPhotos = extraBlobs.filter(function (entry) { return !!entry.blob; });
5733      if (!hasRender && readyPhotos.length === 0) {
5734        showErr('Please add at least one photo of your project.');
5735        return;
5736      }
5737      sendBtn.disabled = true;
5738      sendBtn.textContent = 'Sending...';
5739      sendBtn.style.opacity = '0.6';
5740      function restore() { sendBtn.disabled = false; sendBtn.textContent = 'Send request'; sendBtn.style.opacity = '1'; }
5741      // Multipart FormData (do NOT set Content-Type manually - the browser
5742      // adds the multipart boundary). Kitchen sends no originalPhoto: the
5743      // server uses the DB-stored original. renderId is sent only when a render
5744      // is selected; customerEmail only when the visitor is unverified.
5745      var fd = new FormData();
5746      fd.append('service', 'kitchen');
5747      if (hasRender) fd.append('renderId', String(selectedRenderId));
5748      if (!verified) fd.append('customerEmail', emailV);
5749      fd.append('firstName', firstV);
5750      fd.append('lastName', lastV);
5751      fd.append('phone', phoneV);
5752      fd.append('street', streetV);
5753      fd.append('city', cityV);
5754      fd.append('zip', zipV);
5755      fd.append('country', countryV);
5756      fd.append('state', stateInp.value.trim());
5757      fd.append('message', messageInp.value.trim());
5758      extraBlobs.forEach(function (entry) {
5759        if (entry.blob) fd.append('extraPhotos', entry.blob, entry.name);
5760      });
5761      fetch(API_BASE + '/api/widget/quote', {
5762        method: 'POST',
5763        headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _ktCustomerToken || '' },
5764        body: fd
5765      }).then(function (r) {
5766        return r.json().catch(function () { return {}; });
5767      }).then(function (d) {
5768        if (d && d.ok === true) {
5769          dismiss();
5770          if (onSent) onSent();
5771          return;
5772        }
5773        restore();
5774        showErr(_quoteErrorMessage(d));
5775      }).catch(function () {
5776        restore();
5777        showErr(_quoteErrorMessage(null));
5778      });
5779    });
5780
5781    overlay.appendChild(card);
5782    document.body.appendChild(overlay);
5783  }
5784
5785  function injectInlineKitchen() {
5786    var rootEl = document.getElementById('wrapup-widget');
5787    if (!rootEl) {
5788      console.error('[WrapUpAI] kitchen inline mode: no element with id="wrapup-widget" found');
5789      return;
5790    }
5791    rootEl.innerHTML = '';
5792
5793    // Social rewards hook (mirrors the injectInline registration): gives the
5794    // module-scope claim element THIS closure's auth headers and a checkState
5795    // that re-fetches customer-state and, when renders remain again, clears
5796    // the paywall out of resultArea. The kitchen paywall hides nothing else,
5797    // so there is nothing more to restore. resultArea / updateKtCountdown /
5798    // updateKtGenBtn are hoisted names of this closure, only dereferenced at
5799    // click time - long after the build below assigns them.
5800    _socialClaimCtx = {
5801      headers: function () {
5802        return { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _ktCustomerToken };
5803      },
5804      checkState: function (cb) {
5805        fetch(API_BASE + '/api/widget/customer-state', {
5806          headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _ktCustomerToken }
5807        }).then(function (r) {
5808          if (!r.ok) return null;
5809          return r.json();
5810        }).then(function (data) {
5811          if (!data) { cb(false); return; }
5812          _ktEmail = data.email || _ktEmail;
5813          _ktRendersUsed = Number(data.rendersUsed || 0);
5814          _ktRendersTotal = Number(data.freeRenderLimit || 0);
5815          if (_ktRendersTotal > 0 && _ktRendersUsed < _ktRendersTotal) {
5816            resultArea.innerHTML = '';
5817            resultArea.style.display = 'none';
5818            updateKtCountdown();
5819            updateKtGenBtn();
5820            cb(true);
5821          } else {
5822            cb(false);
5823          }
5824        }).catch(function () { cb(false); });
5825      }
5826    };
5827
5828    var card = document.createElement('div');
5829    _ktStyle(card, 'background:' + KT.bg + ';color:' + KT.text + ';border:1px solid ' + KT.border + ';border-radius:14px;padding:24px;font-family:inherit;width:100%;box-sizing:border-box');
5830
5831    // No partner name or intro text - the shop controls the surrounding
5832    // layout. The widget starts directly at step 1 (upload).
5833
5834    // Two-column layout: left = the image side (upload + photo preview, which
5835    // the render result replaces after generation), right = the controls
5836    // (finish picker, consent, verification, generate). The counter is a
5837    // full-width status strip above both columns. Stacks vertically on narrow
5838    // containers via applyKtLayout (after mount).
5839    var columnsWrap = document.createElement('div');
5840    _ktStyle(columnsWrap, 'display:flex;flex-direction:column;gap:24px;align-items:stretch');
5841    var leftCol = document.createElement('div');
5842    _ktStyle(leftCol, 'flex:1 1 0;min-width:0');
5843    var rightCol = document.createElement('div');
5844    _ktStyle(rightCol, 'flex:1 1 0;min-width:0');
5845    columnsWrap.appendChild(leftCol);
5846    columnsWrap.appendChild(rightCol);
5847
5848    // (a) free-renders counter (mirrors the car widget's countdownEl). Hidden
5849    // until a customer token + counts are known.
5850    var counterEl = document.createElement('div');
5851    _ktStyle(counterEl, 'margin-bottom:14px;text-align:center;display:none');
5852    var counterLine1 = document.createElement('p');
5853    _ktStyle(counterLine1, 'margin:0;font-size:14px;color:' + KT.muted + ';display:none');
5854    counterLine1.appendChild(document.createTextNode('free renders remaining : '));
5855    var counterXY = document.createElement('span');
5856    counterXY.setAttribute('data-wrapup-renders-count', '');
5857    _ktStyle(counterXY, 'color:' + KT.text + ';font-size:20px;font-weight:800;margin-left:4px;vertical-align:middle');
5858    counterLine1.appendChild(counterXY);
5859    var counterLine2 = document.createElement('p');
5860    _ktStyle(counterLine2, 'margin:4px 0 0;font-size:12px;color:' + KT.muted + ';word-break:break-all');
5861    counterEl.appendChild(counterLine1);
5862    counterEl.appendChild(counterLine2);
5863    var counterLine3 = document.createElement('p');
5864    _ktStyle(counterLine3, 'margin:8px 0 0;font-size:12px;color:' + KT.muted + ';line-height:1.4;display:none');
5865    counterEl.appendChild(counterLine3);
5866    // Status strip above both columns; card children: counterEl, columnsWrap, footer.
5867    card.appendChild(counterEl);
5868    // Social rewards entry point - always visible below the counter when the
5869    // gate passes (was paywall-only). Same three-part gate as the car mount.
5870    // A verified visitor is also required: the claim-code endpoint refuses
5871    // an unverified visitor (no x-customer-token), so offering the button
5872    // without one is a dead end. The confirm-email handler mounts it the
5873    // moment verification succeeds.
5874    if (_socialRewardsEnabled && _socialRewardsPossible !== false && _socialClaimCtx && _ktCustomerToken) {
5875      card.appendChild(_buildSocialClaimEl('light'));
5876    }
5877    card.appendChild(columnsWrap);
5878
5879    // (a2) render-history bar (mirrors the car renderHistoryBar) - thumbs of
5880    // the visitor's own past kitchen renders, hidden until there are any.
5881    // Appended at the bottom of leftCol, after resultArea (see section (g)).
5882    var historyBar = document.createElement('div');
5883    _ktStyle(historyBar, 'display:none;gap:10px;flex-wrap:wrap;justify-content:center;margin-top:18px;margin-bottom:18px');
5884
5885    // Empty state for the image side - replaced by the real render once the
5886    // first preview arrives (hidden in the generate success handler).
5887    // Appended to leftCol after uploadCard (see section (b)).
5888    var resultPlaceholder = document.createElement('div');
5889    _ktStyle(resultPlaceholder, 'border:1px dashed ' + KT.border + ';border-radius:10px;padding:40px 16px;text-align:center;color:' + KT.muted + ';font-size:13px');
5890    resultPlaceholder.textContent = 'Your preview will appear here';
5891    // Expose the hider so runKitchenGenerate can swap placeholder + photo
5892    // preview -> result (the render takes the photo's place on the left).
5893    _ktHideResultPlaceholder = function () {
5894      resultPlaceholder.style.display = 'none';
5895      previewImg.style.display = 'none';
5896    };
5897
5898    // (b) upload area
5899    var uploadLabel = document.createElement('p');
5900    uploadLabel.textContent = '1. Upload a photo of your kitchen';
5901    _ktStyle(uploadLabel, 'margin:0 0 8px;font-size:13px;font-weight:500;color:' + KT.text);
5902    leftCol.appendChild(uploadLabel);
5903
5904    var uploadCard = document.createElement('div');
5905    _ktStyle(uploadCard, 'border:1px solid ' + KT.border + ';border-radius:10px;padding:16px;margin-bottom:20px;background:' + KT.bg);
5906
5907    var fileInput = document.createElement('input');
5908    fileInput.type = 'file';
5909    fileInput.accept = 'image/*,.heic,.heif';
5910    _ktStyle(fileInput, 'display:block;width:100%;font-size:13px;color:' + KT.muted);
5911
5912    var previewImg = document.createElement('img');
5913    _ktStyle(previewImg, 'display:none;margin-top:12px;max-width:100%;border-radius:8px');
5914
5915    var uploadHelp = document.createElement('p');
5916    uploadHelp.textContent = 'JPG, PNG or HEIC. Your photo is used only to generate this preview.';
5917    _ktStyle(uploadHelp, 'margin:8px 0 0;font-size:11px;color:' + KT.muted);
5918
5919    fileInput.addEventListener('change', function (e) {
5920      var file = e.target.files && e.target.files[0];
5921      if (!file) return;
5922      // HEIC handling - reuse the shared notice in its LIGHT variant.
5923      if (_isHeicFile(file)) {
5924        // Browsers cannot preview HEIC; the server converts it, so we accept
5925        // it but cannot show a local preview. Keep the file, hide preview.
5926        _ktFile = file;
5927        try { _ktOriginalUrl = URL.createObjectURL(file); } catch (err) { _ktOriginalUrl = null; }
5928        previewImg.style.display = 'none';
5929        updateKtGenBtn();
5930        return;
5931      }
5932      if (!/^image\//.test(file.type)) {
5933        uploadCard.innerHTML = '';
5934        uploadCard.appendChild(_buildHeicErrorEl('light', function () {
5935          injectInlineKitchen();
5936        }));
5937        return;
5938      }
5939      _ktFile = file;
5940      try {
5941        var url = URL.createObjectURL(file);
5942        _ktOriginalUrl = url;
5943        previewImg.src = url;
5944        previewImg.style.display = 'block';
5945        // Fresh photo resets the image side: hide the previous result AND the
5946        // placeholder - the photo itself now occupies the image side until the
5947        // render replaces it (the previous render stays in the history bar).
5948        // Over the limit resultArea holds the PAYWALL - keep it visible or
5949        // the visitor is stranded with no way forward. (The HEIC branch
5950        // above never hid it.)
5951        if (!(_ktRendersTotal > 0 && _ktRendersUsed >= _ktRendersTotal)) {
5952          resultArea.style.display = 'none';
5953        }
5954        resultPlaceholder.style.display = 'none';
5955      } catch (err) { /* preview best-effort */ }
5956      updateKtGenBtn();
5957    });
5958
5959    uploadCard.appendChild(fileInput);
5960    uploadCard.appendChild(previewImg);
5961    uploadCard.appendChild(uploadHelp);
5962    leftCol.appendChild(uploadCard);
5963    leftCol.appendChild(resultPlaceholder);
5964
5965    // (c) swatch grid
5966    var swatchLabel = document.createElement('p');
5967    swatchLabel.textContent = '2. Pick a surface, then choose a finish';
5968    _ktStyle(swatchLabel, 'margin:0 0 8px;font-size:13px;font-weight:500;color:' + KT.text);
5969    rightCol.appendChild(swatchLabel);
5970
5971    // Surface tiles - cabinets / countertop / backsplash. Clicking a tile
5972    // makes it the ACTIVE surface; clicking a swatch in the grid below assigns
5973    // that finish to the active surface. Each tile shows its current finish
5974    // as a chip, with a 'clear' control to deselect that surface.
5975    function _ktSurfaceIconSvg(surface) {
5976      function r(x, y, w, h, on) {
5977        return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" rx="2" fill="' + (on ? '#333333' : 'none') + '" stroke="' + (on ? '#333333' : '#cfcfcf') + '" stroke-width="1.5"/>';
5978      }
5979      var cab = surface === 'cabinets', bs = surface === 'backsplash', ct = surface === 'countertop';
5980      return '<svg viewBox="0 0 120 96" width="100%" height="52" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">'
5981        + r(6, 4, 34, 20, cab) + r(43, 4, 34, 20, cab) + r(80, 4, 34, 20, cab)
5982        + r(6, 27, 108, 20, bs)
5983        + r(3, 49, 114, 5, ct)
5984        + r(6, 58, 34, 28, cab) + r(43, 58, 34, 28, cab) + r(80, 58, 34, 28, cab)
5985        + '<line x1="0" y1="90" x2="120" y2="90" stroke="#cfcfcf" stroke-width="1.5"/></svg>';
5986    }
5987
5988    var KT_SURFACES = ['cabinets', 'countertop', 'backsplash'];
5989    var KT_SURFACE_NAMES = { cabinets: 'Cabinets', countertop: 'Countertop', backsplash: 'Backsplash' };
5990    var tilesRow = document.createElement('div');
5991    _ktStyle(tilesRow, 'display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:12px');
5992    var _ktSurfaceTileEls = {}; // surface -> { tile, chip, clearEl }
5993
5994    function _ktRefreshSurfaceTiles() {
5995      KT_SURFACES.forEach(function (surface) {
5996        var els = _ktSurfaceTileEls[surface];
5997        if (!els) return;
5998        var active = _ktActiveSurface === surface;
5999        _ktStyle(els.tile, 'border:' + (active ? '2px solid ' + KT.accent : '1px solid ' + KT.border) + ';border-radius:10px;padding:8px;text-align:center;cursor:pointer;background:' + KT.bg);
6000        var sel = _ktSurfaceSel[surface];
6001        els.chip.innerHTML = '';
6002        if (sel) {
6003          _ktStyle(els.chip, 'display:inline-flex;align-items:center;gap:4px;border:1px solid ' + KT.border + ';border-radius:999px;padding:2px 8px;font-size:11px;color:' + KT.text + ';margin-top:6px;max-width:100%');
6004          if (sel.thumbnailUrl) {
6005            var chipImg = document.createElement('img');
6006            chipImg.crossOrigin = 'anonymous'; // GR 15 - set before src
6007            chipImg.src = sel.thumbnailUrl;
6008            chipImg.alt = '';
6009            _ktStyle(chipImg, 'width:16px;height:16px;border-radius:3px;object-fit:cover;display:block');
6010            els.chip.appendChild(chipImg);
6011          }
6012          var chipName = document.createElement('span');
6013          chipName.textContent = sel.name || '';
6014          _ktStyle(chipName, 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis');
6015          els.chip.appendChild(chipName);
6016          els.clearEl.style.display = 'block';
6017        } else {
6018          _ktStyle(els.chip, 'display:inline-block;border:1px dashed ' + KT.border + ';border-radius:999px;padding:2px 8px;font-size:11px;color:' + KT.muted + ';margin-top:6px');
6019          els.chip.textContent = 'None';
6020          els.clearEl.style.display = 'none';
6021        }
6022      });
6023    }
6024
6025    // Highlight the ACTIVE surface's current selection in the grid.
6026    function _ktApplyGridHighlight() {
6027      var sel = _ktSurfaceSel[_ktActiveSurface];
6028      var selId = sel ? String(sel.id) : null;
6029      grid.querySelectorAll('[data-kt-swatch]').forEach(function (el) {
6030        var on = selId !== null && el.getAttribute('data-kt-swatch') === selId;
6031        el.style.boxShadow = on ? '0 0 0 2px ' + KT.accent : 'none';
6032        el.style.borderColor = on ? KT.accent : KT.border;
6033      });
6034    }
6035
6036    KT_SURFACES.forEach(function (surface) {
6037      var tile = document.createElement('div');
6038      var icon = document.createElement('div');
6039      icon.innerHTML = _ktSurfaceIconSvg(surface); // fixed ASCII SVG string (same precedent as the download-icon SVG)
6040      var nameP = document.createElement('p');
6041      nameP.textContent = KT_SURFACE_NAMES[surface];
6042      _ktStyle(nameP, 'font-size:12px;font-weight:600;
6042color:' + KT.text + ';margin:6px 0 0');
6043      var chip = document.createElement('div');
6044      var clearEl = document.createElement('div');
6045      clearEl.textContent = 'clear';
6046      _ktStyle(clearEl, 'font-size:11px;color:' + KT.muted + ';text-decoration:underline;cursor:pointer;margin-top:4px;display:none');
6047      clearEl.addEventListener('click', function (e) {
6048        e.stopPropagation();
6049        _ktSurfaceSel[surface] = null;
6050        _ktRefreshSurfaceTiles();
6051        _ktApplyGridHighlight();
6052        updateKtGenBtn();
6053      });
6054      tile.addEventListener('click', function () {
6055        _ktActiveSurface = surface;
6056        _ktRefreshSurfaceTiles();
6057        _ktApplyGridHighlight();
6058      });
6059      tile.appendChild(icon);
6060      tile.appendChild(nameP);
6061      tile.appendChild(chip);
6062      tile.appendChild(clearEl);
6063      tilesRow.appendChild(tile);
6064      _ktSurfaceTileEls[surface] = { tile: tile, chip: chip, clearEl: clearEl };
6065    });
6066    _ktRefreshSurfaceTiles();
6067
6068    // Distinct, non-empty categories from the loaded swatches (sorted).
6069    var _ktCats = [];
6070    (function () {
6071      var seen = {};
6072      _colors.forEach(function (c) {
6073        var cat = (c.category || '').trim();
6074        if (cat && !seen[cat]) { seen[cat] = 1; _ktCats.push(cat); }
6075      });
6076      _ktCats.sort();
6077    })();
6078
6079    // KT-styled filter row: category select + search input. Mirrors the car
6080    // widget's filter-row + scroll-cap pattern, kitchen-region only.
6081    var filterRow = document.createElement('div');
6082    _ktStyle(filterRow, 'display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px');
6083    var catSelect = document.createElement('select');
6084    _ktStyle(catSelect, 'width:100%;padding:8px;border:1px solid ' + KT.border + ';border-radius:8px;background:' + KT.bg + ';color:' + KT.text + ';font-size:13px;font-family:inherit;cursor:pointer');
6085    var optAll = document.createElement('option');
6086    optAll.value = '__all__';
6087    optAll.textContent = 'All';
6088    catSelect.appendChild(optAll);
6089    _ktCats.forEach(function (cat) {
6090      var o = document.createElement('option');
6091      o.value = cat;
6092      o.textContent = cat;
6093      catSelect.appendChild(o);
6094    });
6095    var searchInput = document.createElement('input');
6096    searchInput.type = 'text';
6097    searchInput.placeholder = 'Search';
6098    _ktStyle(searchInput, 'width:100%;padding:8px;border:1px solid ' + KT.border + ';border-radius:8px;background:' + KT.bg + ';color:' + KT.text + ';font-size:13px;font-family:inherit;box-sizing:border-box');
6099    filterRow.appendChild(catSelect);
6100    filterRow.appendChild(searchInput);
6101
6102    var grid = document.createElement('div');
6103    _ktStyle(grid, 'display:grid;grid-template-columns:repeat(5,1fr);gap:10px;max-height:300px;overflow-y:auto;padding:2px;margin-bottom:20px');
6104
6105    // Build a single swatch tile (markup unchanged).
6106    function _ktBuildSwatchTile(c) {
6107      var tile = document.createElement('button');
6108      tile.type = 'button';
6109      tile.setAttribute('data-kt-swatch', String(c.id));
6110      _ktStyle(tile, 'border:1px solid ' + KT.border + ';border-radius:8px;overflow:hidden;background:' + KT.bg + ';padding:0;cursor:pointer;text-align:left');
6111
6112      var thumbWrap = document.createElement('div');
6113      _ktStyle(thumbWrap, 'width:100%;aspect-ratio:1/1;background:' + KT.swatchFallback);
6114      if (c.thumbnailUrl) {
6115        var img = document.createElement('img');
6116        img.crossOrigin = 'anonymous'; // GR 15 - set before src
6117        img.src = c.thumbnailUrl;
6118        img.alt = c.name || '';
6119        _ktStyle(img, 'width:100%;height:100%;object-fit:cover;display:block');
6120        thumbWrap.appendChild(img);
6121      }
6122      var nameEl = document.createElement('div');
6123      nameEl.textContent = c.name || '';
6124      _ktStyle(nameEl, 'padding:6px 6px 8px;font-size:11px;color:' + KT.muted + ';white-space:nowrap;overflow:hidden;text-overflow:ellipsis');
6125
6126      tile.appendChild(thumbWrap);
6127      tile.appendChild(nameEl);
6128      tile.addEventListener('click', function () {
6129        _ktSurfaceSel[_ktActiveSurface] = c;
6130        _ktRefreshSurfaceTiles();
6131        _ktApplyGridHighlight();
6132        updateKtGenBtn();
6133      });
6134      return tile;
6135    }
6136
6137    // Filter + (re)render tiles into the grid on category-change / search.
6138    // Keeps the current selection highlighted if it survives the filter.
6139    function _ktRenderSwatchTiles() {
6140      var cat = catSelect.value;
6141      var q = (searchInput.value || '').trim().toLowerCase();
6142      grid.innerHTML = '';
6143      _colors.forEach(function (c) {
6144        if (cat !== '__all__' && c.category !== cat) return;
6145        if (q) {
6146          var hay = ((c.name || '') + ' ' + (c.manufacturer || '') + ' ' + (c.category || '')).toLowerCase();
6147          if (hay.indexOf(q) === -1) return;
6148        }
6149        var tile = _ktBuildSwatchTile(c);
6150        var activeSel = _ktSurfaceSel[_ktActiveSurface];
6151        if (activeSel && activeSel.id === c.id) {
6152          tile.style.boxShadow = '0 0 0 2px ' + KT.accent;
6153          tile.style.borderColor = KT.accent;
6154        }
6155        grid.appendChild(tile);
6156      });
6157    }
6158
6159    if (!_colors.length) {
6160      var empty = document.createElement('p');
6161      empty.textContent = 'No finishes available yet.';
6162      _ktStyle(empty, 'margin:0;font-size:12px;color:' + KT.muted);
6163      rightCol.appendChild(empty);
6164    } else {
6165      rightCol.appendChild(tilesRow);
6166      rightCol.appendChild(filterRow);
6167      _ktRenderSwatchTiles();
6168      rightCol.appendChild(grid);
6169      catSelect.addEventListener('change', _ktRenderSwatchTiles);
6170      searchInput.addEventListener('input', _ktRenderSwatchTiles);
6171    }
6172
6173    // (d) consent row - same localStorage key as the car widget. Always
6174    // rendered: a returning visitor with persisted consent sees the checkbox
6175    // CHECKED and DISABLED (locked - consent already given).
6176    var consentRow = document.createElement('div');
6177    _ktStyle(consentRow, 'display:flex;align-items:flex-start;gap:8px;margin-bottom:18px');
6178    var consentCb = document.createElement('input');
6179    consentCb.type = 'checkbox';
6180    consentCb.id = 'wrapup-kt-consent';
6181    // Drawn control, light variant (see _ensureConsentStyle).
6182    _ktStyle(consentCb, 'margin-top:2px;cursor:pointer');
6183    consentCb.className = 'wrapup-cb wrapup-cb-light';
6184    _ensureConsentStyle();
6185    var consentLabel = document.createElement('label');
6186    consentLabel.setAttribute('for', 'wrapup-kt-consent');
6187    _ktStyle(consentLabel, 'font-size:12px;color:' + KT.muted + ';line-height:1.5;cursor:pointer');
6188    consentLabel.appendChild(document.createTextNode('I have read and agree to the '));
6189    var ppLink = document.createElement('a');
6190    ppLink.href = 'https://www.wrap-up.ai/privacy';
6191    ppLink.target = '_blank';
6192    ppLink.rel = 'noopener';
6193    ppLink.textContent = 'Privacy Policy';
6194    _ktStyle(ppLink, 'color:' + KT.text + ';text-decoration:underline');
6195    ppLink.addEventListener('click', function (ev) { ev.stopPropagation(); });
6196    consentLabel.appendChild(ppLink);
6197    consentLabel.appendChild(document.createTextNode('.'));
6198    function lockKtConsentCb() {
6199      consentCb.checked = true;
6200      consentCb.disabled = true;
6201      consentCb.style.cursor = 'default';
6202      consentLabel.style.cursor = 'default';
6203    }
6204    if (_ktConsented) lockKtConsentCb();
6205    consentCb.addEventListener('change', function () {
6206      if (!consentCb.checked) return;
6207      _ktConsented = true;
6208      try { localStorage.setItem('wup_privacy_consent_at', new Date().toISOString()); } catch (e) {}
6209      lockKtConsentCb();
6210      updateKtSendCodeGate();
6211      updateKtGenBtn();
6212    });
6213    consentRow.appendChild(consentCb);
6214    consentRow.appendChild(consentLabel);
6215    rightCol.appendChild(consentRow);
6216
6217    // (e) email-verification step - only shown when there is no customer token
6218    // yet (mirrors the car flow's first-render gate). Reuses verify-email +
6219    // confirm-email, stored under the same localStorage keys.
6220    var verifyWrap = document.createElement('div');
6221    _ktStyle(verifyWrap, 'border:1px solid ' + KT.border + ';border-radius:10px;padding:16px;margin-bottom:18px');
6222    var verifyStatus = document.createElement('p');
6223    _ktStyle(verifyStatus, 'margin:8px 0 0;font-size:12px;color:#c0392b;display:none');
6224
6225    var emailRow = document.createElement('div');
6226    _ktStyle(emailRow, 'display:flex;flex-direction:column;gap:8px');
6227    var emailLabel = document.createElement('p');
6228    emailLabel.textContent = 'Verify your email to start';
6229    _ktStyle(emailLabel, 'margin:0;font-size:13px;font-weight:500;color:' + KT.text);
6230    var emailInput = document.createElement('input');
6231    emailInput.type = 'email';
6232    emailInput.placeholder = '[email protected]';
6233    if (_ktEmail) emailInput.value = _ktEmail;
6234    _ktStyle(emailInput, 'width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid ' + KT.border + ';border-radius:8px;font-size:14px;color:' + KT.text);
6235    var sendCodeBtn = document.createElement('button');
6236    sendCodeBtn.type = 'button';
6237    sendCodeBtn.textContent = 'Send Verification Code';
6238    _ktStyle(sendCodeBtn, 'padding:10px 16px;background:' + KT.accent + ';color:' + KT.accentText + ';border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer');
6239
6240    var codeRow = document.createElement('div');
6241    _ktStyle(codeRow, 'display:none;flex-direction:column;gap:8px;margin-top:12px');
6242    var codeMsg = document.createElement('p');
6243    _ktStyle(codeMsg, 'margin:0;font-size:12px;color:' + KT.muted);
6244    var codeInput = document.createElement('input');
6245    codeInput.type = 'text';
6246    codeInput.inputMode = 'numeric';
6247    codeInput.placeholder = '6-digit code';
6248    _ktStyle(codeInput, 'width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid ' + KT.border + ';border-radius:8px;font-size:14px;letter-spacing:2px;color:' + KT.text);
6249    var verifyCodeBtn = document.createElement('button');
6250    verifyCodeBtn.type = 'button';
6251    verifyCodeBtn.textContent = 'Verify Code';
6252    _ktStyle(verifyCodeBtn, 'padding:10px 16px;background:' + KT.accent + ';color:' + KT.accentText + ';border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer');
6253
6254    // Consent gates the email-verification step: the send-code button stays
6255    // disabled until the privacy checkbox is checked (persisted consent
6256    // satisfies it immediately).
6257    function updateKtSendCodeGate() {
6258      sendCodeBtn.disabled = !_ktConsented;
6259      sendCodeBtn.style.opacity = _ktConsented ? '1' : '0.45';
6260      sendCodeBtn.style.cursor = _ktConsented ? 'pointer' : 'not-allowed';
6261    }
6262    updateKtSendCodeGate();
6263
6264    function setVerifyStatus(msg) {
6265      if (!msg) { verifyStatus.style.display = 'none'; return; }
6266      verifyStatus.textContent = msg;
6267      verifyStatus.style.display = 'block';
6268    }
6269
6270    sendCodeBtn.addEventListener('click', function () {
6271      var em = emailInput.value.trim();
6272      if (!em || !/\S+@\S+\.\S+/.test(em)) { setVerifyStatus('Please enter a valid email address.'); return; }
6273      sendCodeBtn.textContent = 'Sending\u2026';
6274      sendCodeBtn.disabled = true;
6275      setVerifyStatus('');
6276      fetch(API_BASE + '/api/widget/verify-email', {
6277        method: 'POST',
6278        headers: { 'Content-Type': 'application/json' },
6279        body: JSON.stringify({ email: em, sessionToken: _sessionToken })
6280      }).then(function (r) { return r.json(); }).then(function (data) {
6281        sendCodeBtn.disabled = false;
6282        if (data.success) {
6283          _ktEmail = em;
6284          codeMsg.textContent = 'We sent a 6-digit code to ' + em;
6285          codeRow.style.display = 'flex';
6286          sendCodeBtn.textContent = 'Resend Code';
6287          setVerifyStatus('');
6288        } else {
6289          sendCodeBtn.textContent = 'Send Verification Code';
6290          setVerifyStatus(data.error || 'Failed to send code. Please try again.');
6291        }
6292      }).catch(function () {
6293        sendCodeBtn.disabled = false;
6294        sendCodeBtn.textContent = 'Send Verification Code';
6295        setVerifyStatus('Network error. Please try again.');
6296      });
6297    });
6298
6299    verifyCodeBtn.addEventListener('click', function () {
6300      var code = codeInput.value.replace(/[^0-9]/g, '').slice(0, 6);
6301      if (code.length !== 6) { setVerifyStatus('Please enter the 6-digit code.'); return; }
6302      verifyCodeBtn.textContent = 'Verifying\u2026';
6303      verifyCodeBtn.disabled = true;
6304      setVerifyStatus('');
6305      fetch(API_BASE + '/api/widget/confirm-email', {
6306        method: 'POST',
6307        headers: { 'Content-Type': 'application/json' },
6308        body: JSON.stringify({ email: _ktEmail, code: code, sessionToken: _sessionToken })
6309      }).then(function (r) { return r.json(); }).then(function (data) {
6310        verifyCodeBtn.textContent = 'Verify Code';
6311        verifyCodeBtn.disabled = false;
6312        if (data.customerToken) {
6313          _ktCustomerToken = data.customerToken;
6314          // Hydrate the counts the endpoint already returns (the car handler
6315          // consumes them; this one previously discarded them, leaving the
6316          // counter blank until the next rebuild).
6317          _ktRendersUsed = Number(data.rendersUsed || 0);
6318          _ktRendersTotal = Number(data.freeRenderLimit || 0);
6319          try {
6320            if (_ktEmail) localStorage.setItem('wup_email_' + EMBED_TOKEN, _ktEmail);
6321            localStorage.setItem('wup_ct_' + EMBED_TOKEN, _ktCustomerToken);
6322          } catch (e) {}
6323          verifyWrap.style.display = 'none';
6324          updateKtGenBtn();
6325          updateKtCountdown();
6326          // Verified just now: mount the social claim element the build-time
6327          // gate skipped (no customer token then). The id presence check
6328          // keeps this idempotent.
6329          if (_socialRewardsEnabled && _socialRewardsPossible !== false && _socialClaimCtx &&
6330              _ktCustomerToken && !document.getElementById('wrapup-social-claim')) {
6331            card.insertBefore(_buildSocialClaimEl('light'), columnsWrap);
6332          }
6333          // Already out of free renders at verification time: show the
6334          // paywall now (mirrors the car confirm-email handler; same idiom
6335          // as the on-load customer-state block below).
6336          if (_ktRendersTotal > 0 && _ktRendersUsed >= _ktRendersTotal && resultArea) {
6337            resultArea.style.display = 'block';
6338            resultArea.innerHTML = '';
6339            resultArea.appendChild(_ktBuildPaywallEl());
6340          }
6341        } else {
6342          var msg = data.error === 'invalid_code' ? 'Incorrect code. Please try again.' :
6343                    data.error === 'code_expired'  ? 'Code expired \u2014 click Resend to get a new one.' :
6344                    data.error === 'too_many_attempts' ? 'Too many attempts. Request a new code and try again.' :
6345                    data.error === 'rate_limited' ? 'Too many attempts. Please wait a few minutes and try again.' :
6346                    (data.error || 'Verification failed. Please try again.');
6347          setVerifyStatus(msg);
6348        }
6349      }).catch(function () {
6350        verifyCodeBtn.textContent = 'Verify Code';
6351        verifyCodeBtn.disabled = false;
6352        setVerifyStatus('Network error. Please try again.');
6353      });
6354    });
6355
6356    emailRow.appendChild(emailLabel);
6357    emailRow.appendChild(emailInput);
6358    emailRow.appendChild(sendCodeBtn);
6359    codeRow.appendChild(codeMsg);
6360    codeRow.appendChild(codeInput);
6361    codeRow.appendChild(verifyCodeBtn);
6362    verifyWrap.appendChild(emailRow);
6363    verifyWrap.appendChild(codeRow);
6364    verifyWrap.appendChild(verifyStatus);
6365    if (_ktCustomerToken) verifyWrap.style.display = 'none';
6366    rightCol.appendChild(verifyWrap);
6367
6368    // (f) Generate button
6369    var genBtn = document.createElement('button');
6370    genBtn.type = 'button';
6371    genBtn.id = 'wrapup-kt-gen-btn';
6372    genBtn.textContent = 'Generate Preview';
6373    _ktStyle(genBtn, 'width:100%;padding:14px;background:' + KT.accent + ';color:' + KT.accentText + ';border:none;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer');
6374    genBtn.addEventListener('click', function () { runKitchenGenerate(genBtn, resultArea); });
6375    rightCol.appendChild(genBtn);
6376
6377    // (g) result area
6378    var resultArea = document.createElement('div');
6379    resultArea.id = 'wrapup-kt-result';
6380    _ktStyle(resultArea, 'display:none;margin-top:20px');
6381    leftCol.appendChild(resultArea);
6382    leftCol.appendChild(historyBar);
6383
6384    // Persistent "Get a Quote" button anchored at the history bar - visible
6385    // when the visitor is verified and at least one kitchen render exists
6386    // (current or history). The result-area button stays as the contextual
6387    // shortcut; both open the same form.
6388    var quoteBarWrap = document.createElement('div');
6389    _ktStyle(quoteBarWrap, 'display:none;text-align:center;margin-bottom:18px');
6390    var quoteBarBtn = document.createElement('button');
6391    quoteBarBtn.type = 'button';
6392    quoteBarBtn.textContent = 'Get a Quote';
6393    _ktStyle(quoteBarBtn, 'display:inline-block;padding:10px 16px;background:' + KT.accent + ';color:' + KT.accentText + ';border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit');
6394    var quoteBarStatus = document.createElement('p');
6395    _ktStyle(quoteBarStatus, 'display:none;margin:8px 0 0;font-size:12px;color:' + KT.text);
6396    quoteBarBtn.addEventListener('click', function () {
6397      _ktOpenQuoteForm(function () {
6398        quoteBarStatus.textContent = 'Quote request sent - we emailed you a copy.';
6399        quoteBarStatus.style.display = 'block';
6400      });
6401    });
6402    quoteBarWrap.appendChild(quoteBarBtn);
6403    quoteBarWrap.appendChild(quoteBarStatus);
6404    leftCol.appendChild(quoteBarWrap);
6405    function _ktUpdateQuoteBar() {
6406      var hasKitchenRender = _ktLastRenderId != null || _ktHistory.some(function (it) {
6407        return it && it.id != null && it.service === 'kitchen';
6408      });
6409      quoteBarWrap.style.display = (_ktCustomerToken && hasKitchenRender) ? 'block' : 'none';
6410    }
6411
6412    // (h) footer
6413    var footer = document.createElement('div');
6414    _ktStyle(footer, 'margin-top:24px');
6415    var powered = document.createElement('p');
6416    _ktStyle(powered, 'margin:0;font-size:11px;color:' + KT.muted + ';text-align:center');
6417    powered.appendChild(document.createTextNode('Powered by '));
6418    var poweredLink = document.createElement('a');
6419    poweredLink.href = 'https://www.wrap-up.ai';
6420    poweredLink.target = '_blank';
6421    poweredLink.rel = 'noopener';
6422    poweredLink.textContent = 'WRAP-UP.AI';
6423    _ktStyle(poweredLink, 'color:' + KT.text + ';text-decoration:none;font-weight:600');
6424    powered.appendChild(poweredLink);
6425    var disclaimer = document.createElement('p');
6426    disclaimer.textContent = 'WrapUp is an independent service and is not affiliated with, endorsed by, or sponsored by any of the brands we mention. All brand and product names are trademarks of their respective owners.';
6427    _ktStyle(disclaimer, 'margin:8px 0 0;font-size:11px;color:' + KT.muted + ';text-align:center;line-height:1.5');
6428    footer.appendChild(powered);
6429    footer.appendChild(disclaimer);
6430    card.appendChild(footer);
6431
6432    rootEl.appendChild(card);
6433
6434    // Container-width responsive stacking: side-by-side columns when the
6435    // partner container is wide enough, single column otherwise.
6436    function applyKtLayout() {
6437      var w = rootEl.getBoundingClientRect().width || 0;
6438      columnsWrap.style.flexDirection = (w >= 720) ? 'row' : 'column';
6439    }
6440    applyKtLayout();
6441    if (typeof ResizeObserver !== 'undefined') {
6442      new ResizeObserver(applyKtLayout).observe(rootEl);
6443    } else {
6444      window.addEventListener('resize', applyKtLayout);
6445    }
6446
6447    function updateKtGenBtn() {
6448      // Over the limit the click used to be merely server-refused (403); it
6449      // is now disabled from the same counts the counter is painted from.
6450      var atLimit = _ktRendersTotal > 0 && _ktRendersUsed >= _ktRendersTotal;
6451      var anySurface = !!(_ktSurfaceSel.cabinets || _ktSurfaceSel.countertop || _ktSurfaceSel.backsplash);
6452      var ready = !!_ktFile && anySurface && _ktConsented && !!_ktCustomerToken && !atLimit;
6453      genBtn.disabled = !ready || _ktGenerating;
6454      genBtn.style.opacity = (ready && !_ktGenerating) ? '1' : '0.45';
6455      genBtn.style.cursor = (ready && !_ktGenerating) ? 'pointer' : 'not-allowed';
6456    }
6457    // Expose the closure updater to the swatch/consent/upload handlers above.
6458    // (They are defined before this declaration but JS function hoisting makes
6459    // updateKtGenBtn available; the initial call sets the disabled state.)
6460    updateKtGenBtn();
6461
6462    // Free-renders counter line: "free renders remaining : X / Y", plus a
6463    // "Rendering for: <email>" line (mirrors the car widget's updateCountdown).
6464    function updateKtCountdown() {
6465      if (!_ktCustomerToken) { counterEl.style.display = 'none'; return; }
6466      counterEl.style.display = 'block';
6467      if (_ktRendersTotal > 0) {
6468        var rem = Math.max(0, _ktRendersTotal - _ktRendersUsed);
6469        counterLine1.style.display = 'block';
6470        counterXY.textContent = rem + ' / ' + _ktRendersTotal;
6471        counterLine3.style.display = 'block';
6472        counterLine3.textContent = 'AI renders aren\'t free to run \u2014 but your first ' + _ktRendersTotal + ' are on us. Need more? Just let us know.';
6473      } else {
6474        counterLine1.style.display = 'none';
6475        counterLine3.style.display = 'none';
6476      }
6477      counterLine2.innerHTML = '';
6478      if (_ktEmail) {
6479        counterLine2.appendChild(document.createTextNode('Rendering for: '));
6480        var emailSpan = document.createElement('span');
6481        emailSpan.textContent = _ktEmail;
6482        emailSpan.style.color = KT.accent;
6483        counterLine2.appendChild(emailSpan);
6484      }
6485    }
6486    // Expose the counter updater so runKitchenGenerate can live-decrement the
6487    // free-renders count immediately after a successful render (mirrors car).
6488    _ktUpdateCounter = updateKtCountdown;
6489
6490    // Render history bar (mirrors car renderHistoryBar / _fetchServerRenderHistory)
6491    function renderKtHistoryBar() {
6492      _ktUpdateQuoteBar();
6493      historyBar.innerHTML = '';
6494      var visible = _ktHistory.slice(0, 5);
6495      var showHistoryBtn = _ktHistoryTotal > 5;
6496      if (!visible.length && !showHistoryBtn) { historyBar.style.display = 'none'; return; }
6497      historyBar.style.display = 'flex';
6498      visible.forEach(function (item) {
6499        var imageUrl = _ktRenderImageUrl(item);
6500        var cell = document.createElement('div');
6501        _ktStyle(cell, 'position:relative;width:90px;text-align:center');
6502        var wrap = document.createElement('div');
6503        _ktStyle(wrap, 'position:relative;width:100%;padding-top:75%;border-radius:8px;overflow:hidden;background:' + KT.swatchFallback + ';border:1px solid ' + KT.border + ';cursor:pointer');
6504        var img = document.createElement('img');
6505        img.crossOrigin = 'anonymous'; // GR 15 - set before src
6506        img.src = imageUrl;
6507        img.alt = item.colorName || 'render';
6508        _ktStyle(img, 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:block');
6509        wrap.appendChild(img);
6510        wrap.addEventListener('click', function () { _ktOpenImage(imageUrl); });
6511        var dl = document.createElement('a');
6512        dl.href = imageUrl;
6513        dl.download = 'kitchen-' + (item.colorName ? item.colorName.replace(/[^a-z0-9]+/gi, '-') : 'render') + '.jpg';
6514        dl.target = '_blank'; dl.rel = 'noopener'; dl.title = 'Download';
6515        dl.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
6516        _ktStyle(dl, 'position:absolute;top:6px;right:6px;width:26px;height:26px;border-radius:50%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;text-decoration:none');
6517        dl.addEventListener('click', function (e) { e.stopPropagation(); });
6518        wrap.appendChild(dl);
6519        // Service tag (Car / Kitchen) - subtle KT pill, top-left.
6520        var svcTag = document.createElement('span');
6521        svcTag.textContent = (item.service === 'car') ? 'Car' : 'Kitchen';
6522        _ktStyle(svcTag, 'position:absolute;top:6px;left:6px;background:rgba(255,255,255,0.9);color:' + KT.muted + ';font-size:10px;font-weight:600;padding:1px 6px;border-radius:6px;line-height:1.4');
6523        wrap.appendChild(svcTag);
6524        var label = document.createElement('p');
6525        label.textContent = item.colorName || '';
6526        _ktStyle(label, 'margin:6px 0 0;font-size:11px;color:' + KT.muted + ';white-space:nowrap;overflow:hidden;text-overflow:ellipsis');
6527        cell.appendChild(wrap);
6528        cell.appendChild(label);
6529        historyBar.appendChild(cell);
6530      });
6531      if (showHistoryBtn) {
6532        var hCell = document.createElement('div');
6533        _ktStyle(hCell, 'position:relative;width:90px;text-align:center');
6534        var hWrap = document.createElement('div');
6535        _ktStyle(hWrap, 'position:relative;width:100%;padding-top:75%;border-radius:8px;background:' + KT.bg + ';border:1px solid ' + KT.border + ';cursor:pointer');
6536        var hInner = document.createElement('div');
6537        hInner.textContent = 'History';
6538        _ktStyle(hInner, 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:' + KT.text + ';font-size:12px;font-weight:600');
6539        hWrap.appendChild(hInner);
6540        hWrap.addEventListener('click', _ktOpenHistoryModal);
6541        hCell.appendChild(hWrap);
6542        historyBar.appendChild(hCell);
6543      }
6544    }
6545    // Fetch the visitor's own kitchen renders (customer-scoped) and repaint.
6546    function _ktFetchHistory() {
6547      if (!_sessionToken || !_ktCustomerToken) return;
6548      fetch(API_BASE + '/api/widget/kitchen/renders?limit=5', {
6549        headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _ktCustomerToken }
6550      }).then(function (r) { return r.ok ? r.json() : null; }).then(function (data) {
6551        if (!data) return;
6552        _ktHistory = (data.items || []).map(function (item) {
6553          return {
6554            id: item.id,
6555            service: item.service || 'kitchen',
6556            token: (typeof item.imageToken === 'string' && item.imageToken) ? item.imageToken : null,
6557            colorName: item.colorName || '',
6558            manufacturer: item.manufacturer || '',
6559          };
6560        });
6561        _ktHistoryTotal = Number(data.total || 0);
6562        try { localStorage.setItem('wup_kt_history2_' + EMBED_TOKEN, JSON.stringify(_ktHistory)); } catch (e) {}
6563        renderKtHistoryBar();
6564      }).catch(function () {});
6565    }
6566    // Expose an adder so runKitchenGenerate can prepend the new render.
6567    _ktAddHistory = function (item) {
6568      if (!item || item.id == null) return;
6569      _ktHistory.unshift({
6570        id: item.id,
6571        service: 'kitchen',
6572        token: (typeof item.token === 'string' && item.token) ? item.token : null,
6573        colorName: item.colorName || '',
6574        manufacturer: item.manufacturer || '',
6575      });
6576      if (_ktHistory.length > 12) _ktHistory = _ktHistory.slice(0, 12);
6577      _ktHistoryTotal = _ktHistoryTotal + 1;
6578      try { localStorage.setItem('wup_kt_history2_' + EMBED_TOKEN, JSON.stringify(_ktHistory)); } catch (e) {}
6579      renderKtHistoryBar();
6580    };
6581    // Paint from the localStorage cache immediately; refresh from server below.
6582    renderKtHistoryBar();
6583    _ktFetchHistory();
6584
6585    // On mount, when a customer token exists, paint the email line immediately
6586    // from localStorage, then read the render counts from the (kitchen-agnostic)
6587    // customer-state endpoint and repaint the counter.
6588    if (_ktCustomerToken) {
6589      updateKtCountdown();
6590      // The counter above paints from the PERSISTENT module counts before the
6591      // fetch below resolves, so the paywall must be decided from the same
6592      // data - otherwise a returning over-limit visitor sees zero remaining
6593      // with no paywall while the round-trip is slow or fails silently. The
6594      // identical check inside the .then stays as the fresh-counts refresher
6595      // (its innerHTML reset replaces this build, never duplicates it).
6596      if (_ktRendersTotal > 0 && _ktRendersUsed >= _ktRendersTotal && resultArea) {
6597        resultArea.style.display = 'block';
6598        resultArea.innerHTML = '';
6599        resultArea.appendChild(_ktBuildPaywallEl());
6600      }
6601      fetch(API_BASE + '/api/widget/customer-state', {
6602        headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _ktCustomerToken }
6603      }).then(function (r) {
6604        // Stale/invalid token: drop it and force re-verification (mirrors the
6605        // car widget's on-load customer-state handling, minus history).
6606        if (r.status === 401 || r.status === 404) {
6607          try {
6608            localStorage.removeItem('wup_ct_' + EMBED_TOKEN);
6609            localStorage.removeItem('wup_email_' + EMBED_TOKEN);
6610          } catch (e) {}
6611          _ktCustomerToken = null;
6612          _ktEmail = '';
6613          counterEl.style.display = 'none';
6614          verifyWrap.style.display = 'block';
6615          updateKtGenBtn();
6616          return null;
6617        }
6618        return r.ok ? r.json() : null;
6619      }).then(function (d) {
6620        if (!d) return;
6621        // Hydrate email + counts from the server and persist the email.
6622        _ktEmail = d.email || _ktEmail;
6623        _ktRendersUsed = Number(d.rendersUsed || 0);
6624        _ktRendersTotal = Number(d.freeRenderLimit || 0);
6625        try { if (_ktEmail) localStorage.setItem('wup_email_' + EMBED_TOKEN, _
6625ktEmail); } catch (e) {}
6626        updateKtCountdown();
6627        // Already out of free renders on load: show the paywall (mirrors the
6628        // car widget calling showPaywall() from its customer-state handler).
6629        if (_ktRendersTotal > 0 && _ktRendersUsed >= _ktRendersTotal && resultArea) {
6630          resultArea.style.display = 'block';
6631          resultArea.innerHTML = '';
6632          resultArea.appendChild(_ktBuildPaywallEl());
6633        }
6634      }).catch(function () { /* network hiccup - leave UI as-is */ });
6635    }
6636  }
6637
6638  function runKitchenGenerate(btn, resultArea) {
6639    if (_ktGenerating) return;
6640    if (!_ktFile || !(_ktSurfaceSel.cabinets || _ktSurfaceSel.countertop || _ktSurfaceSel.backsplash)) return;
6641    if (!_ktCustomerToken) return;
6642
6643    _ktGenerating = true;
6644    var _prevKtErr = document.getElementById('wrapup-kt-gen-error');
6645    if (_prevKtErr) _prevKtErr.style.display = 'none';
6646    _ktLastRenderId = null;
6647    _ktLastRenderToken = null;
6648    // Rotating progress messages while the render runs (mirrors the car
6649    // widget's doGenerate progress loop), shown in the result area AND
6650    // mirrored into the Generate button so the feedback lives where the
6651    // user clicked.
6652    var _progressMsgs = ['Generating your preview\u2026', 'Applying the finish\u2026', 'Almost there\u2026', 'Finishing touches\u2026'];
6653    var _progressInterval = null;
6654    if (btn) { btn.textContent = _progressMsgs[0]; btn.disabled = true; btn.style.opacity = '0.45'; btn.style.cursor = 'not-allowed'; }
6655
6656    if (resultArea) {
6657      resultArea.style.display = 'block';
6658      resultArea.innerHTML = '';
6659      var _progressEl = document.createElement('p');
6660      _progressEl.textContent = _progressMsgs[0];
6661      _ktStyle(_progressEl, 'margin:0;font-size:13px;color:' + KT.muted + ';text-align:center;padding:20px 0');
6662      resultArea.appendChild(_progressEl);
6663      var _mIdx = 0;
6664      _progressInterval = setInterval(function () {
6665        _mIdx = (_mIdx + 1) % _progressMsgs.length;
6666        _progressEl.textContent = _progressMsgs[_mIdx];
6667        if (btn) btn.textContent = _progressMsgs[_mIdx];
6668      }, 3000);
6669    }
6670
6671    function reset() {
6672      _ktGenerating = false;
6673      if (_progressInterval) { clearInterval(_progressInterval); _progressInterval = null; }
6674      if (btn) { btn.textContent = 'Generate Preview'; btn.disabled = false; btn.style.opacity = '1'; btn.style.cursor = 'pointer'; }
6675    }
6676    // Generate error line directly under the Generate button (the button is
6677    // this call's own argument), instead of the result area in the other
6678    // column. Created once, keyed by id; textContent only.
6679    function showKtGenError(msg) {
6680      if (!btn || !btn.parentNode) return;
6681      var errEl = document.getElementById('wrapup-kt-gen-error');
6682      if (!errEl) {
6683        errEl = document.createElement('p');
6684        errEl.id = 'wrapup-kt-gen-error';
6685        _ktStyle(errEl, 'display:none;margin:10px 0 0;font-size:13px;color:#c0392b;text-align:center');
6686        btn.parentNode.insertBefore(errEl, btn.nextSibling);
6687      }
6688      errEl.textContent = msg;
6689      errEl.style.display = 'block';
6690      if (resultArea) { resultArea.innerHTML = ''; resultArea.style.display = 'none'; }
6691    }
6692    // Exhausted-state paywall (Buy Credits + Get a Quote), mirroring the car.
6693    function showKtPaywall() {
6694      if (!resultArea) return;
6695      resultArea.style.display = 'block';
6696      resultArea.innerHTML = '';
6697      resultArea.appendChild(_ktBuildPaywallEl());
6698      resultArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
6699    }
6700
6701    var formData = new FormData();
6702    formData.append('image', _ktFile);
6703    // Multi-surface payload: {"cabinets"?: id, "countertop"?: id, "backsplash"?: id}.
6704    // Values must be numbers (the server rejects non-integers).
6705    var surfacesPayload = {};
6706    ['cabinets', 'countertop', 'backsplash'].forEach(function (surface) {
6707      var sel = _ktSurfaceSel[surface];
6708      if (sel && sel.id != null) surfacesPayload[surface] = Number(sel.id);
6709    });
6710    formData.append('surfaces', JSON.stringify(surfacesPayload));
6711
6712    fetch(API_BASE + KITCHEN_GENERATE_PATH, {
6713      method: 'POST',
6714      headers: { 'Authorization': 'Bearer ' + _sessionToken, 'x-customer-token': _ktCustomerToken || '' },
6715      body: formData
6716    }).then(function (r) {
6717      if (r.status === 403) {
6718        return r.json().catch(function () { return {}; }).then(function (d) {
6719          reset();
6720          if (d.error === 'render_limit_reached') {
6721            showKtPaywall();
6722          }
6722 else if (d.error === 'invalid_customer_token') {
6723            showKtGenError('Your session expired. Please refresh the page.');
6724          } else if (d.error === 'kitchen_not_enabled') {
6725            showKtGenError('Kitchen previews are not enabled for this site.');
6726          } else {
6727            showKtGenError('Access denied. Please refresh and try again.');
6728          }
6729          return null;
6730        });
6731      }
6732      if (r.status === 401) {
6733        reset();
6734        showKtGenError('Your session expired. Please refresh the page.');
6735        return null;
6736      }
6737      if (r.status === 402) {
6738        return r.json().catch(function () { return {}; }).then(function (d) {
6739          reset();
6740          // The shop's TRIAL renders are spent: the paywall card with the
6741          // quote form, not an error line and not our pricing.
6742          if (d && d.reason === 'trial_exhausted') { resultArea.innerHTML = ''; resultArea.appendChild(_ktBuildPaywallEl({ quoteOnly: true })); return null; }
6743          showKtGenError(_renderRefusalMessage(d));
6744          return null;
6745        });
6746      }
6747      if (r.status === 500) {
6748        return r.json().catch(function () { return {}; }).then(function (d) {
6749          reset();
6750          showKtGenError(d && typeof d.message === 'string' && d.message ? d.message : 'Something went wrong. Please try again.');
6751          return null;
6752        });
6753      }
6754      if (!r.ok) { reset(); showKtGenError('Something went wrong. Please try again.'); return null; }
6755      return r.json();
6756    }).then(function (data) {
6757      if (!data) return;
6758      reset();
6759      if (resultArea) {
6760        _ktHideResultPlaceholder();
6761        resultArea.style.display = 'block';
6762        resultArea.innerHTML = '';
6763        var hr = document.createElement('div');
6764        _ktStyle(hr, 'border-top:1px solid ' + KT.border + ';margin:0 0 16px');
6765        var heading = document.createElement('p');
6766        heading.textContent = 'Your kitchen preview';
6767        _ktStyle(heading, 'margin:0 0 4px;font-size:14px;font-weight:600;color:' + KT.text);
6768        var sub = document.createElement('p');
6769        // Sub-line: list ALL selected finishes, composed client-side from
6770        // _ktSurfaceSel. A single selection keeps the legacy format.
6771        var _subSurfaces = ['cabinets', 'countertop', 'backsplash'];
6772        var _subLabels = { cabinets: 'Cabinets', countertop: 'Countertop', backsplash: 'Backsplash' };
6773        var _subEntries = [];
6774        _subSurfaces.forEach(function (surface) {
6775          if (_ktSurfaceSel[surface]) _subEntries.push({ surface: surface, sel: _ktSurfaceSel[surface] });
6776        });
6777        if (_subEntries.length === 1) {
6778          var _oneSel = _subEntries[0].sel;
6779          sub.textContent = (_oneSel.name || '') + (_oneSel.manufacturer ? ' \u2014 ' + _oneSel.manufacturer : '');
6780        } else if (_subEntries.length > 1) {
6781          var _subManufs = [];
6782          _subEntries.forEach(function (en) {
6783            var m = en.sel.manufacturer || '';
6784            if (m && _subManufs.indexOf(m) === -1) _subManufs.push(m);
6785          });
6786          if (_subManufs.length === 1) {
6787            sub.textContent = _subEntries.map(function (en) {
6788              return _subLabels[en.surface] + ': ' + (en.sel.name || '');
6789            }).join(' | ') + ' \u2014 ' + _subManufs[0];
6790          } else {
6791            sub.textContent = _subEntries.map(function (en) {
6792              return _subLabels[en.surface] + ': ' + (en.sel.name || '') + (en.sel.manufacturer ? ' (' + en.sel.manufacturer + ')' : '');
6793            }).join(' | ');
6794          }
6795        } else {
6796          sub.textContent = (data.colorName || '') + (data.manufacturer ? ' \u2014 ' + data.manufacturer : '');
6797        }
6798        _ktStyle(sub, 'margin:0 0 12px;font-size:12px;color:' + KT.muted);
6799        // Slider BEFORE: prefer the server-normalized original (works even for
6800        // HEIC uploads); fall back to the local uploaded-File object-URL.
6801        var beforeUrl = (typeof data.originalImageData === 'string' && data.originalImageData)
6802          ? data.originalImageData
6803          : _ktOriginalUrl;
6804        // Before/after slider when we have a BEFORE image; plain image otherwise.
6805        var media;
6806        if (beforeUrl) {
6807          media = _ktBuildSlider(data.imageUrl, beforeUrl);
6808        } else {
6809          media = document.createElement('img');
6810          media.crossOrigin = 'anonymous'; // GR 15 - set before src
6811          media.src = data.imageUrl;
6812          media.alt = 'Kitchen preview';
6813          _ktStyle(media, 'width:100%;border-radius:10px;display:block');
6814        }
6815        var again = document.createElement('button');
6816        again.type = 'button';
6817        again.textContent = 'Try another finish';
6818        _ktStyle(again, 'margin-top:12px;padding:10px 16px;background:transparent;
6818color:' + KT.text + ';border:1px solid ' + KT.border + ';border-radius:8px;font-size:13px;font-weight:600;cursor:pointer');
6819        again.addEventListener('click', function () { resultArea.style.display = 'none'; resultArea.innerHTML = ''; });
6820        // Download the rendered image (works directly off the returned data URI).
6821        var dl = document.createElement('a');
6822        dl.href = data.imageUrl;
6823        dl.download = 'kitchen-preview.jpg';
6824        dl.textContent = 'Download';
6825        dl.setAttribute('role', 'button');
6826        _ktStyle(dl, 'display:inline-block;margin-top:12px;margin-right:8px;padding:10px 16px;background:' + KT.accent + ';color:' + KT.accentText + ';border-radius:8px;font-size:13px;font-weight:700;text-decoration:none;cursor:pointer');
6827        resultArea.appendChild(hr);
6828        resultArea.appendChild(heading);
6829        resultArea.appendChild(sub);
6830        resultArea.appendChild(media);
6831        resultArea.appendChild(dl);
6832        // Share button (needs both BEFORE + AFTER to build the composite).
6833        if (beforeUrl) {
6834          resultArea.appendChild(_ktBuildShareButton(data.imageUrl, beforeUrl, data.colorName, data.manufacturer));
6835        }
6836        // "Get a Quote" - only when the render was persisted (renderId known).
6837        _ktLastRenderId = (data.renderId != null) ? data.renderId : null;
6838        _ktLastRenderToken = (typeof data.imageToken === 'string' && data.imageToken) ? data.imageToken : null;
6839        var quoteStatus = document.createElement('p');
6840        _ktStyle(quoteStatus, 'display:none;margin:10px 0 0;font-size:12px;color:' + KT.text);
6841        if (_ktLastRenderId != null) {
6842          var quoteBtn = document.createElement('button');
6843          quoteBtn.type = 'button';
6844          quoteBtn.textContent = 'Get a Quote';
6845          _ktStyle(quoteBtn, 'display:inline-block;margin-top:12px;margin-right:8px;padding:10px 16px;background:transparent;color:' + KT.text + ';border:1px solid ' + KT.border + ';border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit');
6846          quoteBtn.addEventListener('click', function () {
6847            _ktOpenQuoteForm(function () {
6848              quoteStatus.textContent = 'Quote request sent - we emailed you a copy.';
6849              quoteStatus.style.display = 'block';
6850            });
6851          });
6852          resultArea.appendChild(quoteBtn);
6853        }
6854        resultArea.appendChild(again);
6855        resultArea.appendChild(quoteStatus);
6856        // Live-decrement the free-renders counter (mirrors the car's _rendersUsed++).
6857        _ktRendersUsed = _ktRendersUsed + 1;
6858        _ktUpdateCounter();
6859        // If that was the last free render, append the paywall below the
6860        // result (the car success handler's last-render check; appendChild so
6861        // the render stays visible, not showKtPaywall which clears it).
6862        if (_ktRendersTotal > 0 && _ktRendersUsed >= _ktRendersTotal) {
6863          resultArea.appendChild(_ktBuildPaywallEl());
6864        }
6865        // Add the new render to the history bar (mirrors the car addToHistory).
6866        if (data.renderId != null) {
6867          _ktAddHistory({ id: data.renderId, token: _ktLastRenderToken, colorName: data.colorName, manufacturer: data.manufacturer });
6868        }
6869        resultArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
6870      }
6871    }).catch(function (err) {
6872      reset();
6873      console.error('[WrapUpAI] kitchen generate error:', err);
6874      showKtGenError('Something went wrong. Please try again.');
6875    });
6876  }
6877  // ===== END WHITE INLINE KITCHEN WIDGET =====
6878
6879  // ===== COMBINED CAR + KITCHEN WIDGET (data-mode="combined") =====
6880  // Additive shell: renders a two-tile service toggle as a sibling ABOVE
6881  // #wrapup-widget and re-initialises the chosen service on switch, reusing
6882  // injectInline / injectInlineKitchen unchanged. The toggle lives OUTSIDE
6883  // #wrapup-widget; the shell clears #wrapup-widget itself before each
6884  // (re-)inject (car does not self-clear). Per-service init is client-side
6885  // only - the /api/widget/init fetch already carries the service field.
6886  var _combinedService = (SERVICE === 'kitchen' ? 'kitchen' : 'car');
6887
6888  // Reset ONLY render-scoped state so a switch starts fresh, without wiping
6889  // identity tokens, consent, or the visitor's shared render history.
6890  function _resetRenderScopedState() {
6891    _selectedFile = null;
6892    _selectedColorId = null;
6893    _isGenerating = false;
6894    _ktFile = null;
6895    _ktOriginalUrl = null;
6896    _ktSurfaceSel = { cabinets: null, countertop: null, backsplash: null };
6897    _ktActiveSurface = 'cabinets';
6898    _ktGenerating = false;
6899    _ktLastRenderId = null;
6900    _ktLastRenderToken = null;
6901  }
6902
6903  // Per-service init, kept separate from init() so init() stays byte-identical.
6904  // Mirrors init()'s fetch shape and its state stores exactly; reuses the same
6905  // renderInitError path on failure.
6906  function _initForService(service, done) {
6907    fetch(API_BASE + '/api/widget/init', {
6908      method: 'POST',
6909      headers: { 'Content-Type': 'application/json' },
6910      body: JSON.stringify({ widgetId: EMBED_TOKEN, service: service })
6911    })
6912      .then(function (r) {
6913        return r.json().catch(function () { return {}; }).then(function (d) { d._httpStatus = r.status; return d; });
6914      })
6915      .then(function (data) {
6916        if (!data.sessionToken) {
6917          console.error('[WrapUpAI] init failed:', data.message || data);
6918          renderInitError(data.message || ('HTTP ' + (data._httpStatus || '???')));
6919          return;
6920        }
6921        _sessionToken = data.sessionToken;
6922        _colors = data.colors || [];
6923        _freeRenderLimit = typeof data.freeRenderLimit === 'number' ? data.freeRenderLimit : 3;
6924        _quoteFormUrl = (data.quoteFormUrl && typeof data.quoteFormUrl === 'string') ? data.quoteFormUrl : '';
6925        _carRenderUrl = (data.carRenderUrl && typeof data.carRenderUrl === 'string') ? data.carRenderUrl : '';
6926        _kitchenRenderUrl = (data.kitchenRenderUrl && typeof data.kitchenRenderUrl === 'string') ? data.kitchenRenderUrl : '';
6927        _businessName = (data.businessName && typeof data.businessName === 'string') ? data.businessName : '';
6928        _contactEmail = (data.contactEmail && typeof data.contactEmail === 'string') ? data.contactEmail : '';
6929        // Wheels rollout C2: strict true, so an older cached init payload
6930        // (field absent) keeps the widget byte-identical to today.
6931        _wheelsEnabled = data.wheelsEnabled === true;
6932        _wheelSuppliers = Array.isArray(data.wheelSuppliers) ? data.wheelSuppliers : [];
6933        done();
6934      })
6935      .catch(function (err) {
6936        console.error('[WrapUpAI] init error:', err);
6937        renderInitError('Network error');
6938      });
6939  }
6940
6941  function injectCombined() {
6942    var rootEl = document.getElementById('wrapup-widget');
6943    if (!rootEl) {
6944      console.error('[WrapUpAI] combined mode: no element with id="wrapup-widget" found');
6945      return;
6946    }
6947
6948    // Toggle bar inserted as a sibling directly BEFORE #wrapup-widget, so the
6949    // inject functions keep exclusive ownership of #wrapup-widget.
6950    var toggleBar = document.createElement('div');
6951    var carTile = document.createElement('button');
6952    var kitchenTile = document.createElement('button');
6953    carTile.type = 'button';
6954    kitchenTile.type = 'button';
6955    carTile.textContent = 'Car wrap';
6956    kitchenTile.textContent = 'Kitchen wrap';
6957
6958    function _tileStyle(active, onDark) {
6959      return [
6960        'flex:1 1 0', 'padding:12px 16px', 'border-radius:10px',
6961        'font-family:inherit', 'font-size:14px', 'font-weight:600',
6962        'cursor:pointer', 'box-sizing:border-box', 'background:transparent',
6963        'color:' + (onDark ? '#ffffff' : '#1a1a1a'),
6964        'border:' + (active ? '2px solid #D2D915' : '1px solid ' + (onDark ? '#333333' : '#e5e5e5'))
6965      ].join(';');
6966    }
6967
6968    function _paintToggle() {
6969      var onDark = _combinedService === 'car';
6970      toggleBar.setAttribute('style', [
6971        'display:flex', 'gap:10px', 'margin-bottom:12px', 'padding:12px',
6972        'border-radius:14px', 'box-sizing:border-box', 'width:100%',
6973        'background:' + (onDark ? '#111111' : '#ffffff')
6974      ].join(';'));
6975      carTile.setAttribute('style', _tileStyle(_combinedService === 'car', onDark));
6976      kitchenTile.setAttribute('style', _tileStyle(_combinedService === 'kitchen', onDark));
6977    }
6978
6979    toggleBar.appendChild(carTile);
6980    toggleBar.appendChild(kitchenTile);
6981    rootEl.parentNode.insertBefore(toggleBar, rootEl);
6982
6983    // Clear #wrapup-widget ourselves (car does not self-clear), reset the
6984    // render-scoped state, repaint the toggle, then re-init the active service.
6985    function _renderActive() {
6986      rootEl.innerHTML = '';
6987      _resetRenderScopedState();
6988      _paintToggle();
6989      _initForService(_combinedService, function () {
6990        if (_combinedService === 'kitchen') { injectInlineKitchen(); } else { injectInline(); }
6991      });
6992    }
6993
6994    carTile.addEventListener('click', function () {
6995      if (_combinedService === 'car') return;
6996      _combinedService = 'car';
6997      _renderActive();
6998    });
6999    kitchenTile.addEventListener('click', function () {
7000      if (_combinedService === 'kitchen') return;
7001      _combinedService = 'kitchen';
7002      _renderActive();
7003    });
7004
7005    _renderActive();
7006  }
7007  // ===== END COMBINED WIDGET =====
7008
7009  if (document.readyState === 'loading') {
7010    document.addEventListener('DOMContentLoaded', init);
7011  } else {
7012    init();
7013  }
7014})();

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.