/* ========================================================================== Mobile Phone Insurance (mobile-only landing) — functional part Staging page: https://tbc-phone-insurance.webflow.io/ Live URL shape: https:///?gid= (the GUID = GroupId, unique per client) Written following the same pattern as the Individual Medical Product page (Webflow.push wrapper, config block, auto test/prod detection, is--active toggling for the full-screen states, class-based states instead of inline styles where the design needs them). All markup/classes/ids below were verified directly against the real staging page via the live DOM. API (MobileInsurance, test host myprofile-api-test.remoteapi.ge): 1) GetPolicyInfo {GroupId} -> on page load 2) GetPersonalInfo {personalNumber, firstName, lastName, birthDate} -> ONLY when the birth date is entered/changed and the other three fields are filled. Not on later edits of name / surname / personal number and not on submit. 3) CreatePolicy {...policy data + birthDate + IMEI list} -> "გაგზავნა" when both checkboxes are ticked. The server validates name / surname / personal number / birth date here and answers with an error text -> matching fields get is-error + banner. GetPolicyInfo.status: 1 -> show the form 2 -> show .s-policy-issued ("პოლისი გაფორმებულია") anything else / not found / error -> .s-no-offer-found Map of DOM hooks actually present on the page (verified in the browser): ---------------------------------------------------------------------- #loader.s-loading (class is--active) -> full-screen preloader .s-policy-issued (class is--active) -> status = 2 screen .s-data-received (class is--active) -> after a successful CreatePolicy .s-no-offer-found (class is--active) -> offer not found / invalid GUID .popup-load-info (class is--active) -> "მიმდინარეობს მონაცემების გადამოწმება" overlay while GetPersonalInfo / CreatePolicy are in flight #inp-name / #inp-last-name / #inp-personal-id-number / #inp-mobile-number / #inp-date-birth -> personal fields (name, surname and personal number are prefilled but editable; the mobile is read-only (class is-readonly); the birth date is picked with the calendar — tap the field, or an element with [data-date-trigger] / .icon-calendar) #error-name / #error-last-name / #error-personal-id-number / #error-mobile-number / #error-date-birth -> .feedback-error under each .list-mobile-phone > .mobile-phone-item -> TEMPLATE for one phone; it is cloned once per phoneDetails entry (ids made unique per clone) .text-count -> "მობილური N" .text-name-phone -> phoneModel .text-name-phone-price -> price + ₾ .text-name-phone-insurance -> premium + ₾ input (IMEI) + .feedback-error -> IMEI code + its error a.button (დასკანერება) -> opens the camera scanner .text-total-price -> sum of all premiums .policy-popup__chb-wrap-2 (x2) -> the two consent checkboxes div.c-policy-popup__chb__label-2 -> label text (gets .is-error) a.c-policy-popup__chb__label-link-2 -> 1st: personInfoUrl, 2nd: policyWordingUrl (href/target only — the visible text stays static) a.c-policy__cta -> "გაგზავნა" submit button .wrap-error-personal-number-date -> red top banner (text div + a.close-btn-red-error) "მითითებული პირადი ნომერი და დაბადების თარიღი არ ემთხვევა". Hidden on start, shown on GetPersonalInfo mismatch (and, with a generic text, on other request errors). × closes it. (A .c-error-banner is created only if this element is gone.) Classes this script ADDS (style them in Webflow if you want to override the small fallback CSS injected by this script): .is-error on .form-input (red border) and on the checkbox label text .is-disable-btn on the submit button while a request is in flight ========================================================================== */ var Webflow = Webflow || []; Webflow.push(function () { // ------------------------------------------------------------------ // Config // ------------------------------------------------------------------ // Auto-detected: *.webflow.io staging, localhost and any host that has // "test" in its name (the spec example uses zoomproperty-test.tbci.ge) // => test API. Any other domain => production API. const host = window.location.hostname; const isTest = host.endsWith('.webflow.io') || host === 'localhost' || host === '127.0.0.1' || /(^|[.-])test([.-]|$)/i.test(host); const isProduction = !isTest; const API_BASE_TEST = 'https://myprofile-api-test.remoteapi.ge/myprofileportal/api/MobileInsurance'; // TODO: test URL above is from the spec; prod URL below is inferred by the // same test -> prod domain swap used on the other projects — verify before go-live. const API_BASE_PROD = 'https://myprofile-api-prod.remoteapi.ge/myprofileportal/api/MobileInsurance'; const API_BASE = isProduction ? API_BASE_PROD : API_BASE_TEST; const URL_POLICY_INFO = API_BASE + '/GetPolicyInfo'; const URL_PERSONAL_INFO = API_BASE + '/GetPersonalInfo'; const URL_CREATE_POLICY = API_BASE + '/CreatePolicy'; // Lazy-loaded only when the user taps "დასკანერება" and the browser has no // native BarcodeDetector (iOS Safari, Firefox). ~330 KB, loaded once. const ZXING_URL = 'https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/umd/index.min.js'; // Preferred decoder on those browsers: zxing-wasm (the C++ ZXing compiled to WebAssembly, // ~1 MB). It reads dense / tiny barcodes (e.g. the IMEI on an iPhone box label) that the // JS port above cannot. If it fails to load, the JS port is used instead. const ZXING_WASM_JS = 'https://cdn.jsdelivr.net/npm/zxing-wasm@3.1.3/dist/iife/reader/index.js'; const ZXING_WASM_DIR = 'https://cdn.jsdelivr.net/npm/zxing-wasm@3.1.3/dist/reader/'; // Which fields the client may NOT edit (confirmed by the client): the phone // number and the phone/price data. The name, surname and personal number come // prefilled from GetPolicyInfo (the shop operator may have made a typo) but // the customer can correct them; the birth date and IMEI are filled in by the // customer. A locked field that came back empty stays editable. const LOCKED_FIELDS = ['#inp-mobile-number']; const CURRENCY = '₾'; // Texts that are NOT in the design (taken from the spec screenshots where // possible, the rest are plain Georgian placeholders — confirm with the client). const TXT = { mismatch: 'მითითებული პირადი ნომერი და დაბადების თარიღი არ ემთხვევა', genericError: 'დაფიქსირდა შეცდომა, გთხოვთ სცადოთ თავიდან', scanHint: 'მიმართეთ კამერა IMEI შტრიხკოდზე 15–20 სმ მანძილიდან', scanFar: 'ტელეფონი ოდნავ მოაშორეთ, რომ კამერამ ფოკუსი აიღოს', noCamera: 'კამერაზე წვდომა ვერ მოხერხდა. შეიყვანეთ IMEI ხელით', close: 'დახურვა', photo: 'ფოტოთი წაკითხვა', // (wording from the client's reference app) photoBusy: 'სურათი მუშავდება...', // (wording from the client's reference app) photoFail: 'IMEI შტრიხკოდი ვერ ამოვიცანით. გადაიღეთ უფრო ახლოდან ან შეიყვანეთ ხელით', }; // Console logging: set to false to silence everything except warnings. const DEBUG = true; const log = (...args) => { if (DEBUG) console.log('[phone-page]', ...args); }; const GUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; // ------------------------------------------------------------------ // State // ------------------------------------------------------------------ const state = { groupId: '', policy: null, // GetPolicyInfo payload (unwrapped) phones: [], // [{ data, el, input, error }] verifiedKey: null, // values for which GetPersonalInfo already succeeded failedKey: null, // values for which GetPersonalInfo already failed failedInfo: null, // what exactly was wrong ({message, fields}) verifying: false, verifyPromise: null, submitting: false, }; const $ = (sel, root) => (root || document).querySelector(sel); const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel)); // The phone block in the Designer is only a template — take it out of the // DOM right away so "Phone 0 / 0 ₾" never flashes, keep a pristine clone. const phoneList = $('.list-mobile-phone'); const phoneTemplate = phoneList && $('.mobile-phone-item', phoneList) ? $('.mobile-phone-item', phoneList).cloneNode(true) : null; if (phoneList) phoneList.innerHTML = ''; // ------------------------------------------------------------------ // Full-screen states // ------------------------------------------------------------------ // Browser chrome colour (iPhone status bar / bottom toolbar). Two things are needed so the // current background reaches under them instead of the bars showing a different colour: // 1) viewport-fit=cover — lets fixed full-screen layers extend under the bars; // 2) background + = background of the screen that is // on top right now (loader / policy issued / data received / no offer / the form). const SCREEN_SELECTORS = ['#loader', '.s-loading', '.s-policy-issued', '.s-data-received', '.s-no-offer-found']; function extendIntoSafeArea() { let m = $('meta[name="viewport"]'); if (!m) { m = document.createElement('meta'); m.name = 'viewport'; m.content = 'width=device-width, initial-scale=1'; document.head.appendChild(m); } if (!/viewport-fit\s*=/.test(m.content)) m.content = m.content.replace(/[,\s]+$/, '') + ', viewport-fit=cover'; } function opaqueBackground(el) { // (stops before /: those carry the colour this script writes itself) for (let e = el; e && e.nodeType === 1 && e !== document.body && e !== document.documentElement; e = e.parentElement) { const c = getComputedStyle(e).backgroundColor; if (c && c !== 'transparent' && !/rgba\([^)]*,\s*0\)$/.test(c)) return c; } return '#fff'; } // The colour comes from the screen that is on top right now (the block with .is--active), // and is written to AND (iOS samples one or the other depending on the // version) plus the theme-color meta. !important beats the classes set in Webflow. function syncChromeColor() { const active = SCREEN_SELECTORS.map(sel => $(sel)).find(el => el && el.classList.contains('is--active')); const color = opaqueBackground(active || $('.c-page-wrapper') || document.body); [document.documentElement, document.body].forEach(el => el.style.setProperty('background-color', color, 'important')); // While a full-screen state is on top, everything else on the page is hidden (see the CSS). // On some iPhones the strip under the bottom toolbar is drawn from the PAGE, not from the // fixed full-screen layer — without this the form (and its button) shows through there. document.documentElement.classList.toggle('phone-screen-active', !!active); let t = $('meta[name="theme-color"]'); if (!t) { t = document.createElement('meta'); t.name = 'theme-color'; document.head.appendChild(t); } t.content = color; if (color !== syncChromeColor.last) { syncChromeColor.last = color; log('chrome colour (status bar / toolbar):', color, active ? '(' + (active.id ? '#' + active.id : '.' + active.classList[0]) + ')' : '(page)'); } } // Re-sync whenever a screen is switched by ANY code (this script, Webflow interactions, ...) // and once more after the CSS transitions / the first paint, so it never stays stale. let chromeTimer = null; function syncChromeColorSoon() { syncChromeColor(); requestAnimationFrame(syncChromeColor); clearTimeout(chromeTimer); chromeTimer = setTimeout(syncChromeColor, 400); } function watchChromeColor() { if (typeof MutationObserver === 'function') { const mo = new MutationObserver(syncChromeColorSoon); SCREEN_SELECTORS.map(sel => $(sel)).filter(Boolean).forEach(el => mo.observe(el, { attributes: true, attributeFilter: ['class', 'style'] })); } window.addEventListener('pageshow', syncChromeColorSoon); // back/forward cache document.addEventListener('visibilitychange', () => { if (!document.hidden) syncChromeColorSoon(); }); window.addEventListener('load', syncChromeColorSoon); } function setLoading(show) { log('preloader (#loader):', show ? 'ON' : 'OFF'); const el = $('#loader') || $('.s-loading'); if (el) el.classList.toggle('is--active', show); syncChromeColorSoon(); } function setPopupLoading(show) { log('overlay (.popup-load-info):', show ? 'ON' : 'OFF'); const el = $('.popup-load-info'); if (el) el.classList.toggle('is--active', show); } function showScreen(selector) { log('SHOW SCREEN', selector, $(selector) ? '' : '(element not found!)'); const el = $(selector); if (el) { el.classList.remove('none'); el.classList.add('is--active'); } document.body.style.overflow = 'hidden'; // block page scroll behind the screen syncChromeColorSoon(); } // The page wrapper (.c-page-wrapper) is hidden by default in the Designer (display:none) so // nothing flashes while the data loads. It is revealed only when the form is ready // (policy status 1). The full-screen states (loader / issued / received / no offer) are // separate blocks and work without it. The class is--active is tried first (in case the // Designer styles .c-page-wrapper.is--active); if the wrapper is still display:none after // that, an inline display:block is set. function showPage() { const w = $('.c-page-wrapper'); if (!w) return; w.classList.add('is--active'); if (getComputedStyle(w).display === 'none') w.style.display = 'block'; log('page wrapper (.c-page-wrapper): SHOWN, display =', getComputedStyle(w).display); syncChromeColorSoon(); } const showNoOffer = () => showScreen('.s-no-offer-found'); const showPolicyIssued = () => showScreen('.s-policy-issued'); const showDataReceived = () => showScreen('.s-data-received'); // ------------------------------------------------------------------ // HTTP helper // ------------------------------------------------------------------ // Reads the body as text first: some responses are empty or plain text // (seen on the other TBC endpoints), so never call resp.json() blindly. async function postJson(url, body) { const name = url.split('/').pop(); log('→ POST', name, body); const resp = await fetch(url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const text = await resp.text(); let data = null; if (text) { try { data = JSON.parse(text); } catch (e) { data = null; } } log('← ' + name, 'HTTP ' + resp.status, resp.ok ? 'ok' : 'NOT ok', data !== null ? '(JSON)' : text ? '(not JSON)' : '(empty body)'); return { ok: resp.ok, status: resp.status, data: data, text: text }; } // ------------------------------------------------------------------ // 1) GetPolicyInfo on page load // ------------------------------------------------------------------ function getGroupId() { // Link shape: https:///?gid= const v = new URLSearchParams(window.location.search).get('gid'); return v && GUID_RE.test(v) ? v.match(GUID_RE)[0] : ''; // DISABLED for now — GUID in the path, https:/// (as in the spec). It needs a // rewrite on the client's side (Webflow answers 404 on such paths). To enable it, uncomment // and put these two lines BEFORE the query lookup above (or after it, as a fallback): // const m = window.location.pathname.match(GUID_RE); // return m ? m[0] : ''; } // The doc shows a flat object; the other TBC project turned out to wrap the // real payload in {policyInfo:{...}}. Accept both so a wrapper can't break it. function unwrapPolicy(data) { if (!data || typeof data !== 'object') return null; if (data.isSuccess === false) return null; if (data.policyInfo && typeof data.policyInfo === 'object') return data.policyInfo; return data; } async function loadPolicy() { state.groupId = getGroupId(); log('init:', { host: host, env: isProduction ? 'PRODUCTION' : 'TEST', apiBase: API_BASE, groupId: state.groupId || '(none)', lockedFields: LOCKED_FIELDS.join(',') }); if (!state.groupId) { console.warn('[phone-page] no GUID in the URL'); setLoading(false); showNoOffer(); return; } let policy = null; try { const res = await postJson(URL_POLICY_INFO, { GroupId: state.groupId }); log('GetPolicyInfo:', res.status, res.data || res.text); policy = res.ok ? unwrapPolicy(res.data) : null; } catch (e) { console.warn('[phone-page] GetPolicyInfo error:', e); } setLoading(false); if (!policy || !Array.isArray(policy.phoneDetails) || !policy.phoneDetails.length) { log('policy missing or without phoneDetails -> no offer'); showNoOffer(); return; } const status = Number(policy.status); log('policy status:', policy.status, '| phones:', policy.phoneDetails.length); if (status === 2) { log('status 2 -> policy already issued'); showPolicyIssued(); return; } if (status !== 1) { console.warn('[phone-page] unexpected status:', policy.status); showNoOffer(); return; } fixSwappedPersonFields(policy); state.policy = policy; fillForm(policy); renderPhones(policy.phoneDetails); applyConsentLinks(policy); showPage(); } // ------------------------------------------------------------------ // Filling the page with data // ------------------------------------------------------------------ // TEMPORARY WORKAROUND. The test API returns the person fields shifted: // firstName = surname, lastName = personal number, personalNumber = first name. // When personalNumber is not 11 digits but lastName is, the values are put // back in the right places. Once the backend is fixed this does nothing. function fixSwappedPersonFields(p) { const isPn = v => /^\d{11}$/.test(String(v || '').trim()); if (!isPn(p.personalNumber) && isPn(p.lastName)) { const pn = p.lastName, first = p.personalNumber, last = p.firstName; p.personalNumber = pn; p.firstName = first; p.lastName = last; console.warn('[phone-page] GetPolicyInfo person fields looked swapped (personalNumber was not 11 digits) -> remapped. Backend should fix this.'); } } function fillForm(p) { if (p.personalNumber && !/^\d{11}$/.test(String(p.personalNumber).trim())) { console.warn('[phone-page] personalNumber is not 11 digits — check the field mapping in GetPolicyInfo:', p.personalNumber); } const map = [ ['#inp-name', p.firstName], ['#inp-last-name', p.lastName], ['#inp-personal-id-number', p.personalNumber], ['#inp-mobile-number', p.mobile], ['#inp-date-birth', isoToDisplay(p.birthDate)], ]; log('prefill:', { firstName: p.firstName, lastName: p.lastName, personalNumber: p.personalNumber, mobile: p.mobile, birthDate: p.birthDate }); map.forEach(([sel, value]) => { const input = $(sel); if (!input) { console.warn('[phone-page] field not found:', sel); return; } input.value = value ? String(value) : ''; syncFloat(input); if (LOCKED_FIELDS.indexOf(sel) !== -1 && input.value) { input.readOnly = true; input.classList.add('is-readonly'); log('locked (read-only):', sel); } }); } function formatMoney(n) { const v = Math.round((Number(n) || 0) * 100) / 100; return new Intl.NumberFormat('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(v); } function renderPhones(phones) { if (!phoneList || !phoneTemplate) return; phoneList.innerHTML = ''; state.phones = []; phones.forEach((p, i) => { const el = phoneTemplate.cloneNode(true); const n = i + 1; // unique ids per clone (the template reuses one id for every phone) const input = $('input', el); const label = $('label', el); const error = $('.feedback-error', el); if (input) { input.id = 'inp-imei-number-' + n; input.value = p.imeiCode ? String(p.imeiCode) : ''; } if (label) label.setAttribute('for', 'inp-imei-number-' + n); if (error) { error.id = 'error-imei-number-' + n; error.style.display = 'none'; } const count = $('.text-count', el); if (count) count.textContent = (count.textContent || '').replace(/\d+\s*$/, '') .trim() + ' ' + n; setText($('.text-name-phone', el), p.phoneModel); setText($('.text-name-phone-price', el), formatMoney(p.price) + ' ' + CURRENCY); setText($('.text-name-phone-insurance', el), formatMoney(p.premium) + ' ' + CURRENCY); phoneList.appendChild(el); const item = { data: p, el: el, input: input, error: error }; state.phones.push(item); if (input) { bindFloatingLabel(input); input.addEventListener('input', () => { input.value = input.value.replace(/\s+/g, ''); clearFieldError(input, error); }); } const scanBtn = $('a.button', el); if (scanBtn) { scanBtn.addEventListener('click', e => { e.preventDefault(); openScanner(item); }); } }); const total = phones.reduce((sum, p) => sum + (Number(p.premium) || 0), 0); log('phones rendered:', state.phones.length, '| total premium:', total); $$('.text-total-price').forEach(el => { el.textContent = formatMoney(total) + ' ' + CURRENCY; }); } function setText(el, value) { if (el) el.textContent = value == null ? '' : String(value); } // Per the spec: the 1st checkbox link opens personInfoUrl, the 2nd opens // policyWordingUrl. Only href/target are touched: the visible text // ("N° TR/001/25") stays static. function applyConsentLinks(p) { const urls = [p.personInfoUrl, p.policyWordingUrl]; $$('.policy-popup__chb-wrap-2').forEach((wrap, i) => { const link = $('a.c-policy-popup__chb__label-link-2', wrap); if (!link) return; const url = urls[i]; log('consent link #' + (i + 1), i === 0 ? '(personInfoUrl)' : '(policyWordingUrl)', url ? 'set' : 'EMPTY — not set'); if (url) { link.setAttribute('href', url); link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener'); } }); } // ------------------------------------------------------------------ // Floating labels (the page's own embed only binds the fields that exist // at DOMContentLoaded — the cloned IMEI inputs need it too, and programmatic // value changes (prefill / scanner) need a manual sync) // ------------------------------------------------------------------ function syncFloat(input) { const label = input.closest('.form-input') && $('label', input.closest('.form-input')); if (!label) return; label.classList.toggle('float', input.value.trim() !== ''); } function bindFloatingLabel(input) { const label = input.closest('.form-input') && $('label', input.closest('.form-input')); if (!label) return; input.addEventListener('focus', () => label.classList.add('float')); input.addEventListener('blur', () => syncFloat(input)); syncFloat(input); } // ------------------------------------------------------------------ // Field errors // ------------------------------------------------------------------ function hideAllFieldErrors() { $$('.feedback-error').forEach(el => { el.style.display = 'none'; }); } function setFieldError(input, errorEl) { const wrap = input && input.closest('.form-input'); if (wrap) wrap.classList.add('is-error'); if (errorEl) errorEl.style.display = 'block'; } function clearFieldError(input, errorEl) { const wrap = input && input.closest('.form-input'); if (wrap) wrap.classList.remove('is-error'); if (errorEl) errorEl.style.display = 'none'; } // ------------------------------------------------------------------ // Top-level fields: error clearing, birth-date mask, auto GetPersonalInfo // ------------------------------------------------------------------ const PERSON_FIELDS = [ { key: 'firstName', input: '#inp-name', error: '#error-name' }, { key: 'lastName', input: '#inp-last-name', error: '#error-last-name' }, { key: 'personalNumber', input: '#inp-personal-id-number', error: '#error-personal-id-number' }, { key: 'mobile', input: '#inp-mobile-number', error: '#error-mobile-number' }, { key: 'birthDate', input: '#inp-date-birth', error: '#error-date-birth' }, ]; function initTopFields() { PERSON_FIELDS.forEach(f => { const input = $(f.input); if (!input) return; input.addEventListener('input', () => { clearFieldError(input, $(f.error)); clearMismatchMarks(); // red borders from the registry / server answer go away together with the banner hideBanner(); }); // GetPersonalInfo is NOT called when name / surname / personal number are edited // (client: only after the birth date is entered; the final check is done by CreatePolicy). }); const birth = $('#inp-date-birth'); if (birth) { initDatePicker(birth); birth.setAttribute('inputmode', 'numeric'); birth.setAttribute('maxlength', '10'); birth.setAttribute('autocomplete', 'bday'); birth.addEventListener('input', () => { const d = birth.value.replace(/\D/g, '').slice(0, 8); let out = d; if (d.length > 4) out = d.slice(0, 2) + '.' + d.slice(2, 4) + '.' + d.slice(4); else if (d.length > 2) out = d.slice(0, 2) + '.' + d.slice(2); birth.value = out; scheduleVerify(); // the ONLY trigger of GetPersonalInfo: birth date entered/changed }); } const pn = $('#inp-personal-id-number'); if (pn) { pn.setAttribute('inputmode', 'numeric'); pn.addEventListener('input', () => { pn.value = pn.value.replace(/\D/g, '').slice(0, 11); }); } const mob = $('#inp-mobile-number'); if (mob) { mob.setAttribute('inputmode', 'tel'); mob.addEventListener('input', () => { mob.value = mob.value.replace(/[^\d+]/g, ''); }); } } const val = sel => (($(sel) || {}).value || '').trim(); // Birth date: the spec says the client picks it with the calendar. // A native is laid over the (read-only) text field as a // transparent, fully clickable layer, so the user's own tap opens the system // date picker. This is what makes it work on iPhone: iOS Safari ignores // programmatic showPicker()/focus() on hidden inputs, but always opens the // wheel when the real input is tapped. The chosen date is written back to the // text field as DD.MM.YYYY. If type="date" is not supported at all, the // masked manual input stays as a fallback. function initDatePicker(textInput) { const wrap = textInput.closest('.form-input') || textInput.parentElement; const picker = document.createElement('input'); picker.type = 'date'; if (picker.type !== 'date') { log('birth date: type=date not supported -> manual input with mask'); return; } // fallback picker.tabIndex = -1; picker.setAttribute('aria-label', textInput.getAttribute('placeholder') || 'Birth date'); picker.className = 'date-picker-native'; const pad = n => String(n).padStart(2, '0'); const now = new Date(); picker.min = '1900-01-01'; picker.max = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate()); if (window.getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative'; wrap.appendChild(picker); textInput.readOnly = true; // no keyboard, the calendar is the only way textInput.removeAttribute('inputmode'); log('birth date: native calendar overlay enabled'); const syncPickerValue = () => { const iso = displayToIso(textInput.value) || ''; if (picker.value !== iso) picker.value = iso; }; syncPickerValue(); // Desktop browsers only open the popup from the calendar icon, so help them // out. Touch devices rely on the native tap on the overlay itself. const fine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches; picker.addEventListener('click', () => { log('birth date: calendar tapped'); if (fine && typeof picker.showPicker === 'function') { try { picker.showPicker(); } catch (err) { /* ignore */ } } }); // Optional separate trigger (icon) outside the overlay const open = e => { if (e) e.preventDefault(); syncPickerValue(); if (typeof picker.showPicker === 'function') { try { picker.showPicker(); return; } catch (err) { /* fall through */ } } picker.focus(); try { picker.click(); } catch (err) { /* ignore */ } }; $$('[data-date-trigger], .icon-calendar', wrap).forEach(el => el.addEventListener('click', open)); const outsideTrigger = $('[data-date-trigger]'); if (outsideTrigger && !wrap.contains(outsideTrigger)) outsideTrigger.addEventListener('click', open); // How the chosen date is committed (written to the field + verified): // - Android / desktop: the "change" event fires only when the user confirms // the date, so it is committed right away. // - iOS: the wheel fires "input"/"change" as soon as it opens and on every // scroll step (an empty field even jumps to today's date). Committing on // each of those showed a wrong date and the "does not match" banner while // the user was still choosing. So on iOS the field is left alone until the // wheel is closed (blur); only then the chosen date is written + verified. const isIOS = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); const todayIso = picker.max; // The field may be (re)filled later from GetPolicyInfo, so the wheel is // synced with the field's current value every time the user touches it. ['pointerdown', 'touchstart', 'focus'].forEach(ev => picker.addEventListener(ev, syncPickerValue, { passive: true })); const commit = () => { const iso = picker.value; // nothing chosen / "today" (iOS default when the wheel is opened untouched): // the field is left exactly as it is (prefilled value stays) if (!iso || iso === todayIso) { log('birth date: nothing chosen, field kept as is', textInput.value || '(empty)'); return; } const shown = isoToDisplay(iso); if (shown === textInput.value) return; // same date as already in the field textInput.value = shown; syncFloat(textInput); log('birth date: picked', iso); textInput.dispatchEvent(new Event('input', { bubbles: true })); // clears the error + triggers GetPersonalInfo }; // The calendar's "Clear" / "Reset" button sets the native value to '' and fires // "input"/"change" -> empty the visible field too (also on iOS: there an empty value // always comes from Reset, because opening the wheel on an empty field fills TODAY, // and on a filled field the wheel starts on the field's date). const clearField = () => { if (!textInput.value) return; textInput.value = ''; syncFloat(textInput); log('birth date: cleared via the calendar'); textInput.dispatchEvent(new Event('input', { bubbles: true })); // drops errors / banner, no verify (incomplete) }; // iOS: nothing is written while the wheel is open/scrolling; a picked date is // committed only once the wheel is closed (Done / tap outside) -> blur. // Reset is applied right away (it is an explicit action, no preview involved). const onPick = () => { if (!picker.value) { clearField(); return; } if (!isIOS) commit(); }; picker.addEventListener('change', onPick); if (isIOS) { picker.addEventListener('input', () => { if (!picker.value) clearField(); }); picker.addEventListener('blur', commit); picker.addEventListener('focusout', commit); } } function isoToDisplay(s) { const m = String(s || '').match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? m[3] + '.' + m[2] + '.' + m[1] : ''; } // "DD.MM.YYYY" -> "YYYY-MM-DD", or null if it is not a real past date function displayToIso(s) { const m = String(s || '').trim().match(/^(\d{2})[./-](\d{2})[./-](\d{4})$/); if (!m) return null; const d = +m[1], mo = +m[2], y = +m[3]; const dt = new Date(Date.UTC(y, mo - 1, d)); if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null; if (y < 1900 || dt.getTime() > Date.now()) return null; const p2 = n => String(n).padStart(2, '0'); return y + '-' + p2(mo) + '-' + p2(d); } function normalizeMobile(s) { let d = String(s || '').replace(/\D/g, ''); if (d.length === 12 && d.indexOf('995') === 0) d = d.slice(3); return d; } function personValues() { return { personalNumber: val('#inp-personal-id-number'), firstName: val('#inp-name'), lastName: val('#inp-last-name'), birthDate: displayToIso(val('#inp-date-birth')) || '', }; } const personKey = v => JSON.stringify([v.personalNumber, v.firstName, v.lastName, v.birthDate]); // auto-verify waits until the personal number is a full 11 digits (no API calls for half-typed numbers) const personComplete = v => !!(/^\d{11}$/.test(v.personalNumber) && v.firstName && v.lastName && v.birthDate); let verifyTimer = null; function scheduleVerify() { clearTimeout(verifyTimer); verifyTimer = setTimeout(() => { if (!state.policy || state.verifying || state.submitting) return; const v = personValues(); if (!personComplete(v)) { log('auto-verify skipped: personal block not complete yet', v); return; } const key = personKey(v); if (key === state.verifiedKey) { log('auto-verify skipped: these values are already verified'); return; } if (key === state.failedKey) { log('auto-verify skipped: these values already failed'); return; } log('auto-verify: birth date + personal data complete -> GetPersonalInfo'); verifyPerson(); }, 350); } // GetPersonalInfo answers 400 with a plain Georgian text that says WHAT is wrong, e.g. // "არასწორი სახელი" (wrong first name) // "არასწორია გვარი" (wrong surname) // "არასწორია დაბადების თარიღი" (wrong birth date) // "არასწორია პირადი ნომერი ან დაბადების თარიღი." (wrong personal number or birth date) // "ოპერაცია წარუმატებლად დასრულდა" (generic failure) // Several can be glued together ("არასწორი სახელი არასწორია გვარი"). The banner then // shows the matching text and the matching field(s) get the red border. function describeMismatch(serverText) { const raw = String(serverText || '').trim(); const f = { firstName: /სახელ/.test(raw), lastName: /გვარ/.test(raw), personalNumber: /პირად/.test(raw), birthDate: /დაბადების/.test(raw), }; const fields = Object.keys(f).filter(k => f[k]); const generic = !fields.length || (f.personalNumber && f.birthDate && fields.length === 2); return { message: generic ? TXT.mismatch : raw.replace(/[.\s]+$/, ''), fields: fields, }; } let markedWraps = []; function clearMismatchMarks() { markedWraps.forEach(w => w.classList.remove('is-error')); markedWraps = []; } function showMismatch(info) { clearMismatchMarks(); const byKey = {}; PERSON_FIELDS.forEach(pf => { byKey[pf.key] = pf.input; }); info.fields.forEach(k => { const input = $(byKey[k]); const wrap = input && input.closest('.form-input'); if (wrap) { wrap.classList.add('is-error'); markedWraps.push(wrap); } }); showBanner(info.message); } // GetPersonalInfo — resolves true/false. Never rejects. function verifyPerson() { const v = personValues(); const key = personKey(v); state.verifying = true; clearMismatchMarks(); setPopupLoading(true); state.verifyPromise = (async () => { try { const res = await postJson(URL_PERSONAL_INFO, v); log('GetPersonalInfo:', res.status, res.data || res.text); const d = res.data; const ok = res.ok && d && typeof d === 'object' && d.isSuccess !== false && !!(d.personalNumber || d.firstName || d.lastName); log('GetPersonalInfo result:', ok ? 'MATCH' : 'MISMATCH'); if (ok) { state.verifiedKey = key; state.failedKey = null; hideBanner(); return true; } state.failedKey = key; const serverText = (typeof d === 'string' ? d : (d && (d.message || d.description || d.error))) || res.text || ''; state.failedInfo = describeMismatch(serverText); log('mismatch:', serverText, '->', state.failedInfo); showMismatch(state.failedInfo); return false; } catch (e) { console.warn('[phone-page] GetPersonalInfo error:', e); showBanner(TXT.genericError); // network error: not remembered, can be retried return false; } finally { state.verifying = false; setPopupLoading(false); } })(); return state.verifyPromise; } // ------------------------------------------------------------------ // Consent checkboxes // ------------------------------------------------------------------ function consentBlocks() { return $$('.policy-popup__chb-wrap-2').map((wrap, i) => ({ input: $('input[type="checkbox"]', wrap), text: $('div.c-policy-popup__chb__label-2', wrap), span: $('span.c-policy-popup__chb__label-2', wrap), index: i, })); } function initConsentCheckboxes() { consentBlocks().forEach(b => { if (!b.input) return; // both checkboxes ship with id="checkbox" — make them unique const id = 'checkbox-consent-' + (b.index + 1); b.input.id = id; b.input.name = id; if (b.span) b.span.setAttribute('for', id); b.input.addEventListener('change', () => { log('consent checkbox #' + (b.index + 1), b.input.checked ? 'checked' : 'unchecked'); if (b.input.checked && b.text) b.text.classList.remove('is-error'); }); }); } // ------------------------------------------------------------------ // Submit ("გაგზავნა") // ------------------------------------------------------------------ function setSubmitBusy(busy) { const btn = $('a.c-policy__cta'); if (!btn) return; btn.classList.toggle('is-disable-btn', busy); btn.style.pointerEvents = busy ? 'none' : ''; btn.setAttribute('aria-disabled', busy ? 'true' : 'false'); } // Shows every error at once (like the design) and returns true when valid. function validateForm() { let firstBad = null; const mark = (input, err) => { setFieldError(input, err); if (!firstBad) firstBad = input; }; PERSON_FIELDS.forEach(f => { const input = $(f.input); const err = $(f.error); if (!input) return; const value = input.value.trim(); let bad = !value; if (!bad && f.key === 'birthDate' && !displayToIso(value)) bad = true; if (bad) mark(input, err); else clearFieldError(input, err); }); state.phones.forEach(p => { if (!p.input) return; if (!p.input.value.trim()) mark(p.input, p.error); else clearFieldError(p.input, p.error); }); let consentOk = true; consentBlocks().forEach(b => { if (!b.input) return; if (!b.input.checked) { consentOk = false; if (b.text) b.text.classList.add('is-error'); if (!firstBad) firstBad = b.input.closest('.policy-popup__chb-wrap-2'); } else if (b.text) { b.text.classList.remove('is-error'); } }); const badIds = []; PERSON_FIELDS.forEach(f => { const i = $(f.input); if (i && i.closest('.form-input') && i.closest('.form-input').classList.contains('is-error')) badIds.push(f.input); }); state.phones.forEach((ph, i) => { if (ph.input && ph.input.closest('.form-input') && ph.input.closest('.form-input').classList.contains('is-error')) badIds.push('IMEI #' + (i + 1)); }); log('validation:', (firstBad || !consentOk) ? 'FAILED' : 'ok', { invalidFields: badIds, consentsOk: consentOk }); if (firstBad && firstBad.scrollIntoView) { firstBad.scrollIntoView({ behavior: 'smooth', block: 'center' }); } return !firstBad && consentOk; } function initSubmit() { const btn = $('a.c-policy__cta'); if (!btn) return; btn.addEventListener('click', async e => { e.preventDefault(); log('submit clicked'); if (state.submitting || !state.policy) { log('submit ignored:', state.submitting ? 'already in progress' : 'policy not loaded'); return; } if (!validateForm()) return; state.submitting = true; setSubmitBusy(true); try { // No GetPersonalInfo here: CreatePolicy validates name / surname / personal // number / birth date on the server and answers with an error if they are wrong. if (state.verifying) await state.verifyPromise; // don't overlap with a running check clearMismatchMarks(); hideBanner(); await createPolicy(); } finally { state.submitting = false; setSubmitBusy(false); } }); } // CreatePolicy failed: if the server text names the wrong field(s) (name, surname, // personal number, birth date) mark them red + show the matching banner, // otherwise show the generic error. function showCreateError(res) { const d = res && res.data; let txt = ''; if (typeof d === 'string') txt = d; else if (d && typeof d === 'object') { txt = d.message || d.description || d.error || d.detail || d.title || ''; if (typeof txt !== 'string') txt = ''; if (!txt && d.errors) { try { txt = JSON.stringify(d.errors); } catch (e) { txt = ''; } } } if (!txt) txt = (res && res.text) || ''; const info = describeMismatch(txt); log('CreatePolicy error text:', txt, '->', info); if (info.fields.length) showMismatch(info); else showBanner(TXT.genericError); } async function createPolicy() { const p = state.policy; const v = personValues(); // name / surname / personal number: what is in the form (the client may have // corrected them; CreatePolicy validates them on the server). // mobile: locked, so it is the GetPolicyInfo value (form value only if the policy had none). const mobileRaw = (LOCKED_FIELDS.indexOf('#inp-mobile-number') !== -1 && p.mobile && String(p.mobile).trim()) ? String(p.mobile).trim() : val('#inp-mobile-number'); const body = { personalNumber: v.personalNumber, firstName: v.firstName, lastName: v.lastName, mobile: normalizeMobile(mobileRaw), zoomerOrderId: p.zoomerOrderId, proposalId: p.proposalId, groupId: p.groupId || state.groupId, birthDate: v.birthDate, phoneDetails: state.phones.map(ph => ({ id: ph.data.id, price: ph.data.price, phoneModel: ph.data.phoneModel, imeiCode: ph.input ? ph.input.value.trim() : '', premium: ph.data.premium, })), }; setPopupLoading(true); try { const res = await postJson(URL_CREATE_POLICY, body); log('CreatePolicy:', res.status, res.data || res.text); // success = a non-empty array with one created policy per phone const created = res.ok && Array.isArray(res.data) && res.data.length > 0 && res.data.every(x => x && (x.policyNumber || x.policyId)); log('CreatePolicy result:', created ? 'CREATED ' + res.data.length + ' policies' : 'FAILED (unexpected response)'); if (created) { showDataReceived(); } else { showCreateError(res); } } catch (e) { console.warn('[phone-page] CreatePolicy error:', e); showBanner(TXT.genericError); } finally { setPopupLoading(false); } } // ------------------------------------------------------------------ // Red banner on top (design: "მითითებული პირადი ნომერი ... არ ემთხვევა" + ×) // ------------------------------------------------------------------ // The banner is built in the Designer: .wrap-error-personal-number-date // (text div + a.close-btn-red-error). It is visible by default there, so the // script hides it on start and only shows it on an error. If it is ever // removed from the page, a fallback .c-error-banner is created instead. const BANNER_SEL = '.wrap-error-personal-number-date, .c-error-banner'; function ensureBanner() { let b = $(BANNER_SEL); if (!b) { b = document.createElement('div'); b.className = 'c-error-banner'; b.setAttribute('role', 'alert'); b.innerHTML = '
×'; const hostEl = $('.c-info-container') || document.body; hostEl.insertBefore(b, hostEl.firstChild); } const t = $('.c-error-banner__text', b) || b.firstElementChild || b; if (b.__defaultText === undefined) b.__defaultText = (t.textContent || '').trim(); return b; } function showBanner(text) { log('banner SHOW:', text); const b = ensureBanner(); const t = $('.c-error-banner__text', b) || b.firstElementChild || b; // the mismatch message is the static text from the Designer — keep it // (and restore it after a generic error overwrote it) t.textContent = (text === TXT.mismatch && b.__defaultText) ? b.__defaultText : text; b.style.display = 'flex'; if (b.scrollIntoView) b.scrollIntoView({ behavior: 'smooth', block: 'start' }); const close = $('.close-btn-red-error, .c-error-banner__close', b); if (close && !close.__bound) { close.__bound = true; close.addEventListener('click', e => { e.preventDefault(); hideBanner(); }); } } function hideBanner() { const b = $(BANNER_SEL); if (b && b.style.display !== 'none') log('banner HIDE'); if (b) b.style.display = 'none'; } // ------------------------------------------------------------------ // IMEI camera scanner (the design has no scanner screen — minimal // full-screen overlay built here). Native BarcodeDetector when available // (Chrome / Android), otherwise ZXing loaded on demand (iOS Safari etc.). // ------------------------------------------------------------------ const scanner = { ui: null, open: false, stream: null, raf: 0, reader: null, item: null }; function luhn(s) { let sum = 0; for (let i = 0; i < s.length; i++) { let d = +s[s.length - 1 - i]; if (i % 2 === 1) { d *= 2; if (d > 9) d -= 9; } sum += d; } return sum % 10 === 0; } // A barcode on a phone box may carry more than the IMEI — accept a bare 15-digit value, or // a STANDALONE 15-digit number inside a longer text ("IMEI: 490154203237518 / SN123"), but // never a 15-digit piece of a longer digit string: the 32-digit EID on an iPhone box // (89049032004008882600019283078798) starts with 890490320040088, which passes the Luhn // check by chance and used to be taken for an IMEI. function extractImei(raw) { const t = String(raw || '').trim(); if (/^\d{15}$/.test(t)) return t; const re = /(^|\D)(\d{15})(?=\D|$)/g; // no look-behind: old iOS Safari cannot parse it let m; while ((m = re.exec(t))) { if (luhn(m[2])) return m[2]; re.lastIndex = m.index + m[1].length + 15; } return ''; } function ensureScannerUi() { if (scanner.ui) return scanner.ui; const root = document.createElement('div'); root.className = 'imei-scanner'; root.innerHTML = '' + '
' + '

' + '
' + '' + '
' + '' + '' + '
'; document.body.appendChild(root); const ui = { root: root, video: $('.imei-scanner__video', root), hint: $('.imei-scanner__hint', root), close: $('.imei-scanner__close', root), photo: $('.imei-scanner__photo', root), frame: $('.imei-scanner__frame', root), file: $('.imei-scanner__file', root), busyText: $('.imei-scanner__busy-text', root), }; ui.close.textContent = TXT.close; ui.photo.textContent = TXT.photo; ui.busyText.textContent = TXT.photoBusy; // "Read by photo": the phone's own camera app shoots at full resolution with its own autofocus, // far sharper than a video frame — the way to read dense / small / blurry-in-the-viewfinder barcodes. ui.photo.addEventListener('click', e => { e.preventDefault(); startPhoto(); }); ui.file.addEventListener('change', () => { const f = ui.file.files && ui.file.files[0]; if (f) { scanner.photoPending = false; scanPhoto(f); } }); ui.file.addEventListener('cancel', () => { if (scanner.photoPending) resumeLive(); }); // (browsers that fire it) ui.close.addEventListener('click', e => { e.preventDefault(); closeScanner(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && scanner.open) closeScanner(); }); scanner.ui = ui; return ui; } function applyImei(item, imei) { log('scanner: IMEI applied', imei, '->', item && item.input ? item.input.id : '(no input)'); if (!item || !item.input) return; item.input.value = imei; syncFloat(item.input); clearFieldError(item.input, item.error); item.input.dispatchEvent(new Event('input', { bubbles: true })); } function stopScanning() { scanner.sid = (scanner.sid || 0) + 1; // every loop of the finished session sees this and stops clearTimeout(scanner.fallbackTimer); if (scanner.raf) { cancelAnimationFrame(scanner.raf); scanner.raf = 0; } clearTimeout(scanner.timer); clearInterval(scanner.focusTimer); clearTimeout(scanner.hintTimer); clearTimeout(scanner.lensTimer); clearInterval(scanner.sharpTimer); if (scanner.reader) { try { scanner.reader.reset(); } catch (e) { /* ignore */ } scanner.reader = null; } if (scanner.stream) { scanner.stream.getTracks().forEach(t => t.stop()); scanner.stream = null; } if (scanner.ui) scanner.ui.video.srcObject = null; } function closeScanner() { log('scanner: closed'); scanner.open = false; scanner.pool = null; clearTimeout(scanner.fallbackTimer); clearTimeout(scanner.closeTimer); scanner.photoPending = false; scanner.busy = false; scanner.photoTok = (scanner.photoTok || 0) + 1; // a photo still being decoded is dropped if (scanner.ui) { scanner.ui.root.classList.remove('is-busy'); scanner.ui.video.style.visibility = ''; } if (scanner.ui && scanner.ui.frame) scanner.ui.frame.classList.remove('is-ok', 'is-seen'); stopScanning(); if (scanner.ui) scanner.ui.root.style.display = 'none'; document.body.style.overflow = ''; } const scriptPromises = {}; function loadScript(src) { if (scriptPromises[src]) return scriptPromises[src]; scriptPromises[src] = new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = src; s.onload = () => resolve(); s.onerror = () => { delete scriptPromises[src]; reject(new Error('script load failed')); }; document.head.appendChild(s); }); return scriptPromises[src]; } const BARCODE_FORMATS = ['code_128', 'code_39', 'ean_13', 'itf', 'data_matrix', 'qr_code']; // amber flash: something was seen but it is not what we need (serial number, EAN ... or an IMEI // that is outside the aiming frame) function flashSeen() { const ui = scanner.ui; if (!ui || !ui.frame) return; ui.frame.classList.add('is-seen'); clearTimeout(scanner.seenTimer); scanner.seenTimer = setTimeout(() => ui.frame.classList.remove('is-seen'), 350); } // success: write the IMEI, green frame + the number for a moment, then the scanner closes function acceptImei(imei) { applyImei(scanner.item, imei); scanner.open = false; // stops the scan loops scanner.pool = null; clearTimeout(scanner.timer); clearTimeout(scanner.fallbackTimer); clearInterval(scanner.focusTimer); clearTimeout(scanner.hintTimer); clearTimeout(scanner.lensTimer); clearInterval(scanner.sharpTimer); const ui = scanner.ui; if (ui) { if (ui.frame) { ui.frame.classList.remove('is-seen'); ui.frame.classList.add('is-ok'); } ui.hint.textContent = '✓ ' + imei; try { if (navigator.vibrate) navigator.vibrate(60); } catch (e) { /* not supported */ } } clearTimeout(scanner.closeTimer); scanner.closeTimer = setTimeout(closeScanner, 700); return true; } // A barcode text without a position (kept for a single-shot source such as a photo): // accept it if it contains an IMEI. function scanAccept(text) { if (!scanner.open) return false; // scanner was closed meanwhile const imei = extractImei(text); log('scanner: barcode read', JSON.stringify(text), imei ? '-> IMEI ' + imei : '-> not an IMEI, keep scanning'); if (!imei) { flashSeen(); return false; } return acceptImei(imei); } // ---- ONE IMEI per scan ------------------------------------------------------------- // A box often carries two IMEI barcodes (IMEI1 / IMEI2, both valid). Which of them is read // must not depend on which one the decoder happens to return first, so: // 1) only a barcode that lies INSIDE the white aiming frame counts; // 2) if several are inside, the one closest to the centre of the frame wins; // 3) the choice is made after a short settle time (SETTLE_MS) — all hits of that time are // compared, so a decoder that sees the far one first and the near one a moment later // still ends up with the near one. // Candidates are {text, x, y} with the barcode centre in VIDEO pixels (x/y null = unknown). const SETTLE_MS = 300; // The aiming frame in video pixels ({x,y,w,h}); the