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 = '×'; 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>📷 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">✓ ' + 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 = '×'; 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 = '×'; 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 = '×'; 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 = '×'; 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 = '×'; 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 ×). 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 = '×'; 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.