UNPKG

@salla.sa/twilight-components

Version:
1,035 lines (1,027 loc) 136 kB
/*! * Crafted with ❤ by Salla */ import { proxyCustomElement, HTMLElement, createEvent, h, Host } from '@stencil/core/internal/client'; import { a as KeyBoardArrowRightIcon, K as KeyBoardArrowLeftIcon } from './keyboard_arrow_right.js'; import { L as Location } from './location.js'; import { S as Search } from './search.js'; import { d as defineCustomElement$7 } from './salla-button2.js'; import { d as defineCustomElement$6 } from './salla-loading2.js'; import { d as defineCustomElement$5 } from './salla-modal2.js'; import { d as defineCustomElement$4 } from './salla-placeholder2.js'; import { d as defineCustomElement$3 } from './salla-searchable-dropdown2.js'; import { d as defineCustomElement$2 } from './salla-skeleton2.js'; /** * API Service for salla-bullet-delivery component * Uses Salla's shipping API and scope API for delivery location management */ const API_LOG_PREFIX = 'BulletDeliveryAPI'; const validId = (id) => { const n = id == null ? Number.NaN : Number(id); return !Number.isNaN(n) && n !== 0; }; async function withApiErrorHandling(fn, fallback, logLabel) { try { return await fn(); } catch (error) { console.error(`${API_LOG_PREFIX}: ${logLabel}`, error); return fallback; } } const apiCache = new Map(); const CACHE_TTL = 5 * 60 * 1000; function getCached(key) { const entry = apiCache.get(key); if (!entry || Date.now() - entry.timestamp > CACHE_TTL) { apiCache.delete(key); return null; } return entry.data; } function setCache(key, data) { apiCache.set(key, { data, timestamp: Date.now() }); } /** Uses country code from API (e.g. 'SA'). */ const isSaudiArabia = (countryCode) => String(countryCode).toUpperCase() === 'SA'; /** * Fast Delivery API Service * Uses Salla's shipping API and scopes API. Branch and address data are used as returned by the API (no mapping). */ const bulletDeliveryAPI = { /** * Get available countries from Salla shipping API * @param forBranch - when true, use for_branch=1 (e.g. for pickup/branches tab) */ async getCountries(forBranch = false, query) { const cacheKey = !query?.trim() ? `countries_${forBranch ? 1 : 0}` : null; if (cacheKey) { const cached = getCached(cacheKey); if (cached) return cached; } const result = await withApiErrorHandling(async () => { const params = { for_branch: forBranch ? 1 : 0 }; if (forBranch) { params.for_allocation = 1; } if (query?.trim()) params.query = query.trim(); const data = (await salla.api.request("shipping/countries", { params }))?.data ?? []; return data.map((c) => ({ id: c.id, code: c.code, name: c.name, has_regions: isSaudiArabia(c.code) })); }, [], 'Error getting countries'); if (cacheKey && result.length > 0) setCache(cacheKey, result); return result; }, /** * Get regions for a country from Salla shipping API * Endpoint: GET /shipping/countries/<COUNTRY_ID>/region */ async getRegions(countryId, query) { if (!validId(countryId)) { console.warn(`${API_LOG_PREFIX}: getRegions called without valid country_id`); return []; } const cacheKey = !query?.trim() ? `regions_${countryId}` : null; if (cacheKey) { const cached = getCached(cacheKey); if (cached) return cached; } const result = await withApiErrorHandling(async () => { const params = {}; if (query?.trim()) params.query = query.trim(); const data = (await salla.api.request(`shipping/countries/${countryId}/regions`, { params }))?.data ?? []; return data.map((r) => ({ id: r.id, name: r.name, code: r.code, country_id: Number(countryId) })); }, [], 'Error fetching regions'); if (cacheKey && result.length > 0) setCache(cacheKey, result); return result; }, /** * Get cities from Salla shipping API * @param regionId - Optional; when provided (e.g. for SA), cities are filtered by region */ async getCities(countryId, regionId, query) { if (!validId(countryId)) { console.warn(`${API_LOG_PREFIX}: getCities called without valid country_id`); return []; } const cacheKey = !query?.trim() ? `cities_${countryId}_${regionId || 'all'}` : null; if (cacheKey) { const cached = getCached(cacheKey); if (cached) return cached; } const result = await withApiErrorHandling(async () => { const params = { for_branch: 0, country_id: countryId }; if (regionId) params.region_id = regionId; if (query?.trim()) params.query = query.trim(); const data = (await salla.api.request("shipping/cities", { params }))?.data ?? []; return data.map((c) => ({ id: c.id, name: c.name, country_id: countryId, ...(c.region_id != null && { region_id: c.region_id }) })); }, [], 'Error fetching cities'); if (cacheKey && result.length > 0) setCache(cacheKey, result); return result; }, /** * Get districts from Salla shipping API */ async getDistricts(cityId, query) { if (!validId(cityId)) { console.warn(`${API_LOG_PREFIX}: getDistricts called without valid city_id`); return []; } return withApiErrorHandling(async () => { const params = { for_branch: 0, city_id: cityId }; if (query?.trim()) params.query = query.trim(); const raw = (await salla.api.request("shipping/districts", { params }))?.data; const list = Array.isArray(raw) ? raw : (Array.isArray(raw?.districts) ? raw.districts : []); return list.map((d) => ({ id: d.id, name: d.name, name_en: d.name_en, city_id: cityId })); }, [], 'Error fetching districts'); }, async getSavedAddresses() { return withApiErrorHandling(async () => { const data = (await salla.api.request("address"))?.data; return Array.isArray(data) ? data : []; }, [], 'Error fetching user addresses'); }, async getBranches({ query, lat, lng, country_id, per_page = 20 } = {}) { const hasSearchParams = query?.trim()?.length >= 2 || lat || lng; const cacheKey = !hasSearchParams && country_id ? `branches_${country_id}` : null; if (cacheKey) { const cached = getCached(cacheKey); if (cached) return cached; } const result = await withApiErrorHandling(async () => { const params = { per_page }; if (query?.trim().length >= 2) params.query = query; if (lat) params.lat = lat; if (lng) params.lng = lng; if (country_id) params.country_id = country_id; const res = await salla.api.request("branches", { params }); return (Array.isArray(res?.data) ? res.data : []); }, [], 'Error fetching branches'); if (cacheKey && result.length > 0) setCache(cacheKey, result); return result; }, async saveAddressLocation(payload) { return withApiErrorHandling(async () => { const res = await salla.api.request('address/location', { ...payload, for_allocation: true }, 'post'); const data = res?.data ?? res; const address = typeof data === 'object' && data != null && 'id' in data ? data : undefined; return { success: true, address }; }, { success: false }, 'Error saving address/location'); }, async setDeliveryScope(scopeId) { return withApiErrorHandling(async () => { await salla.api.withoutNotifier(() => salla.scope.change({ id: scopeId })); salla.storage.set("scope", { ...(salla.storage.get("scope") || {}), id: scopeId }); return true; }, false, 'Error setting delivery scope'); }, async allocateScope(payload) { const errMsg = (d) => d?.error?.message ?? d?.message ?? 'Failed to allocate scope'; try { const response = await salla.api.withoutNotifier(() => salla.api.request("scopes/allocation", payload, 'post')); // SDK resolves with { data } and throws on non-2xx; any resolved value is success const data = response?.data ?? response; return { success: true, data: data }; } catch (error) { console.error(`${API_LOG_PREFIX}: Error allocating scope`, error); const err = error; return { success: false, error: err?.response?.data != null ? errMsg(err.response.data) : (err?.message ?? 'Unknown error') }; } }, }; function clearApiCache() { apiCache.clear(); } /** * Helper functions for salla-bullet-delivery component */ const EARTH_RADIUS_KM = 6371; const deg2rad = (deg) => deg * (Math.PI / 180); /** Haversine distance in km */ function calculateDistance(lat1, lon1, lat2, lon2) { const dLat = deg2rad(lat2 - lat1); const dLon = deg2rad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) ** 2; return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } /** First working-hours slot from API branch.working_hours[].times[]. */ const getBranchFirstSlot = (workingHours) => workingHours?.[0]?.times?.[0] ?? null; /** Nearest available branch by distance; single pass O(n). */ function findNearestBranch(branches, userLat, userLon) { let nearest = null; let minD = Number.POSITIVE_INFINITY; for (const b of branches) { const lat = b.location?.lat != null ? Number(b.location.lat) : Number.NaN; const lng = b.location?.lng != null ? Number(b.location.lng) : Number.NaN; if (Number.isNaN(lat) || Number.isNaN(lng) || b.is_open === false) continue; const d = calculateDistance(userLat, userLon, lat, lng); if (d < minD) { minD = d; nearest = b; } } return nearest; } /** 24h time string → locale 12h (e.g. "23:55" → "11:55 PM"). */ function formatTimeTo12h(time24, locale) { const [h, m] = String(time24).trim().split(':').map(Number); return Number.isNaN(h) || Number.isNaN(m) ? time24 : new Date(2000, 0, 1, h, m).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }); } /** Working hours display: "until X" when from is 00:00, else "from - to" in 12h. */ function formatWorkingHoursDisplay(from, to, locale, untilLabel) { const f = String(from).trim(); const t = String(to).trim(); return !f || !t ? '' : f === '00:00' ? `${untilLabel} ${formatTimeTo12h(t, locale)}` : `${formatTimeTo12h(f, locale)} - ${formatTimeTo12h(t, locale)}`; } const filterBranches = (branches, searchQuery) => { if (!searchQuery) return branches; const q = searchQuery.toLowerCase(); return branches.filter((b) => b.name.toLowerCase().includes(q) || b.city?.name?.toLowerCase().includes(q)); }; const getIntentSubtitle = (intent, toAddress, fromBranch) => { if (intent.type === 'branch') { const loc = intent.branch_details?.city ?? intent.branch_details?.name; return loc ? `${fromBranch} ${loc}`.trim() : ''; } if (intent.type === 'address' && intent.address_details) { const addr = [intent.address_details.district?.name, intent.address_details.city?.name, intent.address_details.country?.name].filter(Boolean).join('، ') || intent.address_details.short_address || ''; return addr ? `${toAddress} ${addr}`.trim() : ''; } return ''; }; const idFrom = (val) => (val != null ? Number(val) : undefined); const getIntentCountryId = (i) => idFrom(i?.address_details?.country?.id); const getIntentCountryCode = (i) => i?.country_code ?? i?.address_details?.country?.code ?? ''; const getIntentRegionId = (i) => idFrom(i?.address_details?.region?.id); const getIntentCityId = (i) => idFrom(i?.address_details?.city?.id); const getIntentDistrictId = (i) => idFrom(i?.address_details?.district?.id); const getIntentBranchId = (i) => i?.branch_details?.id; /** Intent coordinates (branch tab only; address flow no longer uses coordinates). */ const getIntentLatitude = (i) => i?.branch_details?.latitude; const getIntentLongitude = (i) => i?.branch_details?.longitude; const hasSessionAddressIntent = (intent) => { if (!intent || intent.type !== 'address' || intent.address_id) return false; const c = getIntentCountryId(intent); const city = getIntentCityId(intent); const d = getIntentDistrictId(intent); const code = getIntentCountryCode(intent); const hasDistrictId = d != null && Number(d) !== 0; const hasDistrictName = Boolean(intent.address_details?.district?.name?.trim()); const districtOk = !isSaudiArabia(code) || hasDistrictId || hasDistrictName; return !!(c && city && districtOk); }; const requireRegionAndDistrictForSA = (countryCode, regionId, districtIdOrName) => !countryCode || !isSaudiArabia(countryCode) ? true : (regionId != null && Number(regionId) !== 0) && (districtIdOrName != null && (typeof districtIdOrName === 'number' ? districtIdOrName !== 0 : String(districtIdOrName).trim().length > 0)); function buildAddressLocationPayloadFromSelection(options) { const { countryId, countryCode, regionId, cityId, districtId, description = '' } = options; const desc = description || ''; return { country_id: Number(countryId) || undefined, region_id: regionId != null ? Number(regionId) : undefined, city_id: cityId != null ? Number(cityId) : undefined, district_id: districtId != null ? Number(districtId) : undefined, description: desc, ...(countryCode && isSaudiArabia(countryCode) && { local: desc }), }; } const GEO_ERROR_MESSAGES = { [GeolocationPositionError.PERMISSION_DENIED]: 'Location permission denied', [GeolocationPositionError.POSITION_UNAVAILABLE]: 'Location unavailable', [GeolocationPositionError.TIMEOUT]: 'Location timeout', }; const getGeolocationErrorMessage = (errorCode) => GEO_ERROR_MESSAGES[errorCode] ?? 'An error occurred while detecting location'; var GetDirections = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M7.94798 3.19336C11.1309 2.1083 12.7223 1.56578 13.5783 2.42174C14.4342 3.27771 13.8917 4.86914 12.8066 8.05201L12.0678 10.2195C11.2344 12.6639 10.8178 13.8861 10.131 13.9872C9.94631 14.0144 9.75517 13.9981 9.57248 13.9394C8.89297 13.7212 8.53377 12.4326 7.81538 9.8553C7.65604 9.28364 7.57637 8.99781 7.39494 8.7795C7.34228 8.71614 7.28386 8.65772 7.2205 8.60506C7.00219 8.42363 6.71636 8.34396 6.1447 8.18462C3.56741 7.46623 2.27876 7.10703 2.0606 6.42752C2.00195 6.24483 1.98558 6.05369 2.01277 5.86903C2.11392 5.18221 3.33613 4.76556 5.78054 3.93225L7.94798 3.19336Z" stroke="#555555" stroke-width="1.2" /> </svg> `; var GPS = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M13.3258 7.65006C13.3258 10.7797 10.7888 13.3167 7.65916 13.3167C4.52955 13.3167 1.99249 10.7797 1.99249 7.65006C1.99249 4.52045 4.52955 1.9834 7.65916 1.9834C10.7888 1.9834 13.3258 4.52045 13.3258 7.65006Z" stroke="#555555" stroke-width="1.3" /> <path d="M14.65 7.65039H13.3167" stroke="#555555" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" /> <path d="M1.98336 7.65039L0.650024 7.65039" stroke="#555555" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" /> <path d="M7.65002 0.650065L7.65002 1.9834" stroke="#555555" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" /> <path d="M7.65002 13.3171L7.65002 14.6504" stroke="#555555" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" /> <path d="M9.64999 7.65039C9.64999 8.75496 8.75456 9.65039 7.64999 9.65039C6.54542 9.65039 5.64999 8.75496 5.64999 7.65039C5.64999 6.54582 6.54542 5.65039 7.64999 5.65039C8.75456 5.65039 9.64999 6.54582 9.64999 7.65039Z" stroke="#555555" stroke-width="1.3" /> </svg> `; var MiniMap = `<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg"> <g filter="url(#filter0_d_24181_220540)"> <mask id="mask0_24181_220540" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="0" width="33" height="32"> <rect x="1.90958" y="0.576577" width="30.8468" height="30.8468" rx="9.22523" fill="#D9D9D9" stroke="white" stroke-width="1.15315"/> </mask> <g mask="url(#mask0_24181_220540)"> <rect x="1.90958" y="0.576577" width="30.8468" height="30.8468" rx="9.22523" fill="#E8E8E8" stroke="white" stroke-width="1.15315"/> <path d="M17.9096 6.77445L28.7204 5.33301H32.4681L32.7564 11.8195V23.6393L10.4141 23.9276V10.8105L17.9096 6.77445Z" fill="#97E7C8"/> <path d="M-2.70303 10.6664H10.2699M41.6934 5.47722C38.378 5.47722 34.7744 5.47722 27.8555 5.47722C20.9366 5.47722 18.4862 6.63037 15.4591 8.64839C12.4321 10.6664 10.2699 10.6664 10.2699 10.6664M10.2699 34.4502V23.6394M10.2699 10.6664V23.6394M-6.16249 23.6394H10.2699M10.2699 23.6394H21.0807M38.8105 23.6394H21.0807M21.0807 23.6394V34.4502" stroke="white" stroke-width="2.30631"/> <rect x="2.34202" y="1.00901" width="29.982" height="29.982" rx="8.79279" stroke="white" stroke-width="2.01802"/> </g> <g clip-path="url(#clip0_24181_220540)"> <path fill-rule="evenodd" clip-rule="evenodd" d="M18.069 19.8621C19.4995 18.6902 22.171 16.0948 22.171 13.0548C22.171 11.7333 21.6461 10.466 20.7117 9.5316C19.7773 8.5972 18.51 8.07227 17.1885 8.07227C15.8671 8.07227 14.5998 8.5972 13.6654 9.5316C12.731 10.466 12.2061 11.7333 12.2061 13.0548C12.2061 16.0948 14.8776 18.6902 16.3081 19.8621C16.5553 20.0679 16.8669 20.1806 17.1885 20.1806C17.5102 20.1806 17.8218 20.0679 18.069 19.8621ZM17.1885 14.1618C17.517 14.1618 17.838 14.0644 18.1111 13.8819C18.3842 13.6995 18.597 13.4401 18.7227 13.1367C18.8484 12.8333 18.8812 12.4994 18.8172 12.1773C18.7531 11.8552 18.595 11.5593 18.3627 11.3271C18.1305 11.0948 17.8346 10.9367 17.5125 10.8726C17.1904 10.8085 16.8565 10.8414 16.5531 10.9671C16.2497 11.0928 15.9903 11.3056 15.8079 11.5787C15.6254 11.8518 15.528 12.1728 15.528 12.5012C15.528 12.9416 15.703 13.364 16.0144 13.6754C16.3258 13.9868 16.7481 14.1618 17.1885 14.1618Z" fill="#55575D"/> </g> </g> <defs> <filter id="filter0_d_24181_220540" x="-0.000325561" y="0" width="34.6667" height="34.6667" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> <feFlood flood-opacity="0" result="BackgroundImageFix"/> <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> <feOffset dy="1.33333"/> <feGaussianBlur stdDeviation="0.666667"/> <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"/> <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_24181_220540"/> <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_24181_220540" result="shape"/> </filter> <clipPath id="clip0_24181_220540"> <rect width="12.1081" height="12.1081" fill="white" transform="translate(11.1338 8.07227)"/> </clipPath> </defs> </svg> `; var Store = `<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M1.14453 5.83032L1.14453 9.16486C1.14453 11.0511 1.14453 11.9943 1.73032 12.5803C2.3161 13.1663 3.25891 13.1663 5.14453 13.1663L9.14453 13.1663C11.0301 13.1663 11.9729 13.1663 12.5587 12.5803C13.1445 11.9943 13.1445 11.0511 13.1445 9.16486V5.83032" stroke="currentcolor" stroke-linecap="round" /> <path d="M9.14453 10.1619C8.68846 10.5667 7.9624 10.8285 7.14453 10.8285C6.32666 10.8285 5.60059 10.5667 5.14453 10.1619" stroke="currentcolor" stroke-linecap="round" /> <path d="M5.90256 4.44533C5.71455 5.12426 5.03086 6.29542 3.73185 6.46516C2.58488 6.61502 1.71497 6.11437 1.49277 5.90504C1.24779 5.7353 0.689441 5.19216 0.552703 4.85269C0.415966 4.51322 0.575492 3.77771 0.689441 3.47785L1.14495 2.15892C1.25615 1.82765 1.51646 1.04411 1.78334 0.779094C2.05022 0.514073 2.59054 0.502543 2.81294 0.502543L7.48329 0.502543C8.68543 0.519525 11.3139 0.491824 11.8335 0.502545C12.3532 0.513266 12.6654 0.948828 12.7565 1.13553C13.5318 3.01341 13.8333 4.08889 13.8333 4.54717C13.7321 5.03605 13.3133 5.95787 11.8335 6.36332C10.2956 6.7847 9.42363 5.96509 9.15007 5.65043M5.27015 5.65044C5.48665 5.91635 6.16578 6.45158 7.15029 6.46516C8.1348 6.47873 8.98485 5.79188 9.28681 5.44676C9.37227 5.34492 9.55687 5.04279 9.7483 4.44533" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" /> </svg> `; const sallaBulletDeliveryCss = "@supports (interpolate-size: allow-keywords) and (block-size: calc-size(auto, size)) {\n .s-bullet-delivery .s-bullet-delivery-modal .s-modal-body, .s-bullet-delivery-inner {\n @apply [interpolate-size:allow-keywords];\n }\n .s-bullet-delivery .s-bullet-delivery-modal .s-modal-body {\n @apply [overflow:clip] [transition:block-size_500ms_ease];\n }\n .s-bullet-delivery-inner {\n @apply [overflow:clip] [transition:block-size_500ms_ease];\n block-size: calc-size(auto, size);\n }\n}\n\n/* Allow searchable dropdown popover to paint outside the modal body */\n.s-bullet-delivery .s-bullet-delivery-modal .s-modal-body:has(.s-searchable-dropdown--open) {\n overflow: visible !important;\n}\n\n@keyframes s-bullet-delivery-fade-in {\n from {\n opacity: 0;\n transform: translateY(6px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}"; const BRANCH_SEARCH_DEBOUNCE_MS = 1000; const GEOLOCATION_TIMEOUT = 10000; const DEBUG_KEY = 'salla-bullet-delivery-debug'; const OVERRIDE_IP_KEY = 'salla-bullet-delivery-override-ip'; function log(message, data) { if (localStorage.getItem(DEBUG_KEY)) { data !== undefined ? console.log(message, data) : console.log(message); } } const SallaBulletDelivery$1 = /*@__PURE__*/ proxyCustomElement(class SallaBulletDelivery extends HTMLElement { constructor() { super(); this.__registerHost(); this.bulletDeliveryConfirmed = createEvent(this, "bulletDeliveryConfirmed", 7); this.bulletDeliveryClosed = createEvent(this, "bulletDeliveryClosed", 7); this.addressCreated = createEvent(this, "addressCreated", 7); this.headerContextUpdate = createEvent(this, "headerContextUpdate", 7); this.confirming = false; this.pendingCartSubmitResolver = null; this.pendingCartSubmitPromise = null; this.cartSubmitConfirmationPending = false; this.branchSearchDebounceTimer = null; this.countrySearchTimer = null; this.regionSearchTimer = null; this.citySearchTimer = null; this.districtSearchTimer = null; this.citySearchCounter = 0; this.componentReady = false; this.pendingOpen = false; this.tabChanging = false; this.intentStorageKey = "bullet_delivery_intent"; this.sessionShownKey = "bullet_delivery_shown"; this.incompleteIntentPromptKey = "bullet_delivery_incomplete_intent_prompted"; this.cartItemAddedEvent = "cart::item.added"; this.cartItemAddedHandler = null; this.cartSubmittingHandler = null; this.useCartEventApi = false; this.authLoggedInHandler = null; this.authLoggedOutHandler = null; this.loginClosedHandler = null; this.hasExplicitPreselectedIdsForOpen = false; this.bulletDeliveryOpenHandler = (eventData) => { this.preselectedAddressId = eventData?.preselected_address_id; this.preselectedBranchId = eventData?.preselected_branch_id; this.hasExplicitPreselectedIdsForOpen = eventData?.preselected_address_id != null || eventData?.preselected_branch_id != null; this.open(); }; this.closeHandler = null; this.bulletDeliveryMobileSelectHandler = null; /** True after saved addresses have been loaded (lazy: only when address tab is shown). */ this.savedAddressesLoaded = false; // Core state this.activeTab = "address"; this.isLoggedIn = false; this.viewMode = "main"; // Location data this.countries = []; this.regions = []; this.cities = []; this.districts = []; this.loadingRegions = false; this.selectedCountry = null; this.selectedRegion = null; this.selectedCity = null; this.selectedDistrict = null; this.districtName = ""; // Saved addresses state this.savedAddresses = []; this.selectedSavedAddress = null; /** True when user selected the session address (guest address from before login, not yet saved) */ this.selectedSessionAddress = false; // Pickup state this.branches = []; this.filteredBranches = []; this.selectedBranch = null; this.branchSearchQuery = ""; // Loading states this.loadingCountries = false; this.loadingCities = false; this.loadingDistricts = false; this.loadingBranches = false; this.loadingNearestBranch = false; this.locationError = ""; this.savingAddress = false; this.loadingSavedAddresses = false; this.showCartWillBeClearedBanner = false; // Searchable dropdown state this.countrySearchQuery = ''; this.regionSearchQuery = ''; this.citySearchQuery = ''; this.districtSearchQuery = ''; this.searchingCountries = false; this.searchingRegions = false; this.searchingCities = false; this.searchingDistricts = false; this.displayedCountries = []; this.displayedRegions = []; this.displayedCities = []; this.displayedDistricts = []; /** Shown when scopes/allocation returns 422 (address outside delivery coverage). Only on delivery tab. */ this.allocationOutOfCoverageMessage = null; this.newAddressForm = { ...SallaBulletDelivery.INITIAL_ADDRESS_FORM, }; this.handleCountrySearch = (query) => { this.countrySearchQuery = query; return; }; this.handleRegionSearch = (query) => { this.regionSearchQuery = query; return; }; this.handleCitySearch = (query) => { this.citySearchQuery = query; if (this.citySearchTimer) clearTimeout(this.citySearchTimer); if (!query.trim()) { this.displayedCities = this.cities; this.searchingCities = false; return; } if (query.trim().length < SallaBulletDelivery.DROPDOWN_SEARCH_MIN_CHARS) return; const requestId = ++this.citySearchCounter; this.citySearchTimer = setTimeout(async () => { this.searchingCities = true; try { const isSA = isSaudiArabia(this.selectedCountry?.code ?? ''); const regionId = isSA ? this.selectedRegion?.id : undefined; const results = await bulletDeliveryAPI.getCities(this.selectedCountry?.id, regionId, query); if (requestId === this.citySearchCounter) { this.displayedCities = results; } } catch { if (requestId === this.citySearchCounter) { this.displayedCities = this.cities; } } finally { if (requestId === this.citySearchCounter) { this.searchingCities = false; } } }, SallaBulletDelivery.DROPDOWN_SEARCH_DEBOUNCE_MS); }; this.handleDistrictSearch = (query) => { this.districtSearchQuery = query; return; }; this.handleCountryDropdownClosed = () => { this.countrySearchQuery = ''; this.displayedCountries = this.countries; this.searchingCountries = false; }; this.handleRegionDropdownClosed = () => { this.regionSearchQuery = ''; this.displayedRegions = this.regions; this.searchingRegions = false; }; this.handleCityDropdownClosed = () => { this.citySearchQuery = ''; if (this.citySearchTimer) { clearTimeout(this.citySearchTimer); this.citySearchTimer = null; } ++this.citySearchCounter; this.displayedCities = this.cities; this.searchingCities = false; }; this.handleDistrictDropdownClosed = () => { this.districtSearchQuery = ''; this.displayedDistricts = this.districts; this.searchingDistricts = false; }; /** * Submit add-address form: create address via API then switch back to list. */ this.handleSubmitAddAddress = async (e) => { e.preventDefault(); if (this.savingAddress) return; if (!this.selectedCountry || !this.selectedCity) return; if (!requireRegionAndDistrictForSA(this.selectedCountry?.code, this.selectedRegion?.id, this.selectedDistrict?.id ?? (this.districtName?.trim() || null))) return; this.savingAddress = true; try { const payload = this.buildAddressLocationPayload(); const { success, address } = await bulletDeliveryAPI.saveAddressLocation(payload); if (success) { await this.loadSavedAddresses(); const latest = address ?? this.savedAddresses[0] ?? null; if (latest?.is_in_coverage) { this.selectedSessionAddress = false; this.selectedSavedAddress = latest; } this.addressCreated.emit({ address: latest }); this.viewMode = "main"; this.districtName = ""; } else { Salla.notify?.error(Salla.lang.get("common.errors.error_occurred")); } } catch (error) { console.error("SallaBulletDelivery: Error saving address", error); Salla.notify?.error(Salla.lang.get("common.errors.error_occurred")); } finally { this.savingAddress = false; } }; this.handleCountrySelected = (item) => { const country = this.countries.find(c => String(c.id) === String(item.id)) || item; // Hide empty cart alert (INV-44) // const isCountrySwitch = // this.selectedCountry && // country && // this.selectedCountry.code !== (country as Country).code; // this.showCartWillBeClearedBanner = !!isCountrySwitch; this.countrySearchQuery = ''; this.displayedCountries = this.countries; this.applyCountryChange(country || null); }; this.handleRegionSelected = (item) => { const region = this.regions.find(r => String(r.id) === String(item.id)) || item; this.selectedRegion = region || null; this.regionSearchQuery = ''; this.displayedRegions = this.regions; this.updateNewAddressForm({ region_id: region?.id, city_id: undefined, district_id: undefined, city: undefined, district: undefined, }); if (region && this.selectedCountry) { this.loadCities(this.selectedCountry.id, region.id); } }; this.handleCitySelected = (item) => { const isSA = isSaudiArabia(this.selectedCountry?.code ?? ''); const city = this.cities.find(c => String(c.id) === String(item.id)) || item; this.selectedCity = city || null; this.selectedDistrict = null; this.districtName = ""; this.citySearchQuery = ''; this.displayedCities = this.cities; if (city) { this.updateNewAddressForm({ city: city, district: undefined }); if (isSA) { this.loadDistricts(city.id); } } }; this.handleDistrictSelected = (item) => { const district = this.districts.find(d => String(d.id) === String(item.id)) || item; this.selectedDistrict = district || null; this.districtName = ""; this.districtSearchQuery = ''; this.displayedDistricts = this.districts; if (district) { this.updateNewAddressForm({ district }); } }; } /** Whether to show delivery + pickup tabs. From store.shipping.support_pickup */ get supportsPickup() { return Boolean(Salla.config.get("store.shipping.support_pickup")) || Boolean(Salla.config.get("store.support_pickup")); } /** The modal opening strategy: 'first_visit' | 'on_cart_click' | 'after_add_to_cart' */ get openingType() { return Salla.config.get("store.settings.bullet_delivery.settings.type"); } /** Whether the modal is required (cannot be closed/skipped). From store.settings.bullet_delivery.settings.is_required */ get isRequired() { return !!Salla.config.get("store.settings.bullet_delivery.settings.is_required"); } get isMobileApp() { return Salla.config.isMobileApp(); } /** * Opens the bullet delivery modal */ async open() { // If mobile app, open the sheet if (this.isMobileApp) { Salla.event.dispatch('salla::bullet-delivery.open-sheet'); return; } // If component is not ready yet, mark as pending and return if (!this.componentReady || !this.modal) { this.pendingOpen = true; return; } // Reset state before opening this.resetState(); // Open modal immediately — fields render right away with inline loading this.modal.open(); Salla.event.dispatch("salla::bullet-delivery.modal.opened"); try { this.isLoggedIn = !this.isGuestUser(); const savedIntent = this.cleanStaleGuestAddressIdIfNeeded(this.getStoredIntent()); this.applyPreselectedIdsFromIntent(savedIntent); const hasStoredAddressIds = !this.isLoggedIn && savedIntent?.type === "address" && getIntentCountryId(savedIntent) != null && getIntentCityId(savedIntent) != null; // Set default tab first so we can lazy-load tab-specific data. if (this.supportsPickup && savedIntent?.type && (savedIntent.type === "address" || savedIntent.type === "branch")) { this.activeTab = savedIntent.type; } else { this.activeTab = "address"; } // Load countries always; load saved addresses only when address tab is active (lazy tab data). if (this.isLoggedIn && this.activeTab === "address") { this.loadingSavedAddresses = true; } await Promise.all([ this.loadCountries(hasStoredAddressIds), this.isLoggedIn && this.activeTab === "address" ? this.loadSavedAddresses().then(() => { this.savedAddressesLoaded = true; }).finally(() => { this.loadingSavedAddresses = false; }) : Promise.resolve(), ]); // Auto-select preselected address if provided if (this.isLoggedIn && this.preselectedAddressId) { const address = this.savedAddresses.find((a) => a.id === this.preselectedAddressId); if (address) { this.selectedSessionAddress = false; this.selectedSavedAddress = address; } } // Load branches only when branch tab is active (lazy tab data) // Use lat/lng from session storage if available for more accurate results if (this.activeTab === "branch") { const intentLat = getIntentLatitude(savedIntent); const intentLng = getIntentLongitude(savedIntent); if (intentLat != null && intentLng != null) { await this.loadBranchesWithLocation(intentLat, intentLng); } else { await this.loadBranches(); } // Auto-select preselected branch if provided if (this.preselectedBranchId) { const branch = this.branches.find((b) => b.id === this.preselectedBranchId); if (branch) { this.selectedBranch = branch; } } } // Prefill form data from session storage await this.prefillFromSessionStorage(); } catch (e) { console.error("SallaBulletDelivery: Error loading data", e); } finally { this.overrideScopeSwitchUI(); } } /** * Closes the bullet delivery modal */ async close() { if (this.shouldForceNonClosable() && !this.confirming) { return; } // If submit flow is waiting and user dismissed the modal, release the pending gate. if (this.cartSubmitConfirmationPending && !this.confirming) { this.resolvePendingCartSubmit(); } this.bulletDeliveryClosed.emit(); return this.modal?.close(); } resetState() { this.viewMode = "main"; this.selectedCountry = null; this.selectedRegion = null; this.selectedCity = null; this.selectedDistrict = null; this.districtName = ""; this.selectedSavedAddress = null; this.selectedSessionAddress = false; this.selectedBranch = null; this.branchSearchQuery = ""; this.regions = []; this.cities = []; this.districts = []; this.branches = []; this.filteredBranches = []; this.newAddressForm = { ...SallaBulletDelivery.INITIAL_ADDRESS_FORM }; this.showCartWillBeClearedBanner = false; this.allocationOutOfCoverageMessage = null; this.locationError = ""; this.savedAddressesLoaded = false; this.loadingSavedAddresses = false; this.loadingCountries = false; this.resetSearchState(); } getIntentStorage() { const rememberLastSession = Boolean(Salla.config.get("store.settings.bullet_delivery.settings.remember_last_session")); return Salla.storage[rememberLastSession ? "store" : "session"]; } getShownStorage() { // Always session-scoped. `remember_last_session` applies to intent persistence only. return Salla.storage.session; } getStoredIntent() { return this.getIntentStorage().get(this.intentStorageKey); } isGuestUser() { return String(Salla.config.get("user.type") ?? "guest") === "guest"; } cleanStaleGuestAddressIdIfNeeded(intent) { if (!this.isGuestUser() || intent?.type !== "address" || !intent.address_id) return intent; const cleaned = { ...intent, address_id: undefined, }; this.setStoredIntent(cleaned); return cleaned; } applyPreselectedIdsFromIntent(intent) { if (this.hasExplicitPreselectedIdsForOpen) { this.hasExplicitPreselectedIdsForOpen = false; return; } this.preselectedAddressId = undefined; this.preselectedBranchId = undefined; if (this.isGuestUser()) { if (intent?.type === "branch" && intent.branch_id) { this.preselectedBranchId = intent.branch_id; } return; } if (intent?.type === "address" && intent.address_id) { this.preselectedAddressId = intent.address_id; } else if (intent?.type === "branch" && intent.branch_id) { this.preselectedBranchId = intent.branch_id; } } hasIncompleteUserAddressIntent() { if (this.isGuestUser()) return false; const intent = this.getStoredIntent(); if (!intent || intent.type !== "address" || intent.address_id || !intent.address_details) return false; // Intent is an address without address_id; consider it "incomplete" // only when it represents a valid session-style address. return hasSessionAddressIntent(intent); } hasCompleteIntentForLoggedInUser() { if (this.isGuestUser()) return false; const intent = this.getStoredIntent(); if (!intent) return false; if (intent.type === "address") return !!intent.address_id; if (intent.type === "branch") return !!intent.branch_id; return false; } /** * Authenticated users on the cart page must select/confirm an address before * they can dismiss the modal. */ shouldForceNonClosable() { if (this.isGuestUser()) return false; if (!Salla.url.is_page("cart")) return false; return !this.hasCompleteIntentForLoggedInUser(); } shouldPromptIncompleteIntentThisSession() { const storage = this.getShownStorage(); return (this.hasIncompleteUserAddressIntent() && storage.get(this.incompleteIntentPromptKey) !== true); } markIncompleteIntentPrompted() { this.getShownStorage().set(this.incompleteIntentPromptKey, true); } setStoredIntent(intent) { this.getIntentStorage().set(this.intentStorageKey, intent); this.overrideScopeSwitchUI(); } clearBulletDeliveryStoredData() { // Clear all bullet-delivery keys from both scopes to avoid stale state across auth transitions. Salla.storage.session.remove(this.intentStorageKey); Salla.storage.store.remove(this.intentStorageKey); Salla.storage.session.remove(this.sessionShownKey); Salla.storage.session.remove(this.incompleteIntentPromptKey); this.overrideScopeSwitchUI(); } /** * Single prefill flow: load geography from stored intent and set selected location + form. * Assumes this.countries is already loaded. No-op if no intent or no city in intent. */ async prefillAddressFromStoredIntent() { const intent = this.getStoredIntent(); if (!intent || intent.type !== "address" || getIntentCityId(intent) == null) return; const countryId = getIntentCountryId(intent); if (!countryId) return; const country = this.countries.find((c) => String(c.id) === String(countryId)); if (!country) return; this.selectedCountry = country; this.updateNewAddressForm({ country_id: Number(country.id) }); const isSA = isSaudiArabia(country.code); const cityId = getIntentCityId(intent); const regionId = getIntentRegionId(intent); const districtId = getIntentDistrictId(intent); const districtName = intent.address_details?.district?.name; const [regions, cities, districts] = await Promise.all([ isSA ? bulletDeliveryAPI.getRegions(country.id) : Promise.resolve([]), bulletDeliveryAPI.getCities(country.id, isSA && regionId != null ? regionId : undefined), cityId != null ? bulletDeliveryAPI.getDistricts(Number(cityId)) : Promise.resolve([]), ]); if (isSA) { this.regions = regions; this.displayedRegions = this.regions; } this.cities = cities; this.displayedCities = this.cities; this.districts = districts; this.displayedDistricts = this.districts; this.loadingCities = false; this.loadingRegions = false; this.loadingDistricts = false; if (isSA && regionId != null) { const region = this.regions.find((r) => String(r.id) === String(regionId)); if (region) { this.selectedRegion = region; this.updateNewAddressForm({ region_id: region.id }); } } if (cityId != null && this.cities.length > 0) { const city = this.cities.find((c) => String(c.id) === String(cityId)); if (city) { this.selectedCity = city; this.updateNewAddressForm({ city_id: city.id, city }); } } if (this.districts.length > 0) { const district = this.findPrefillDistrict(districtId, districtName); if (district) { this.updateNewAddressForm({ district_id: district.id, district }); this.selectedDistrict = district; } } // When district is captured as free text (or couldn't be matched), keep it in the text input state. if (!this.selectedDistrict && districtName) { this.districtName = districtName; } } updateNewAddressForm(partial) { this.newAddressForm = { ...this.newAddressForm, ...partial }; } /** Whether allocation completed successfully this session (modal won't auto-open again). */ hasBeenShownThisSession() { const storage = this.getShownStorage(); return storage.get(this.sessionShownKey) === true; } /** Mark allocation as completed this session (called only after allocateScope succeeds). */ markShownThisSession() { const storage = this.getShownStorage(); storage.set(this.sessionShownKey, true); } getIPLocationConfig() { const configPath = "store.shipping.delivery_location"; const ipAddress = { countryId: Salla.config.get(`${configPath}.country_id`), regionId: Salla.config.get(`${configPath}.region_id`), cityId: Salla.config.get(`${configPath}.city_id`), districtId: Salla.config.get(`${configPath}.district_id`), }; return localStorage.getItem(OVERRIDE_IP_KEY) ? JSON.parse(localStorage.getItem(OVERRIDE_IP_KEY)) : ipAddress; } async loadCountries(skipEagerSubFetch = false) { this.loadingCountries = true; const forBranch = this.activeTab === "branch"; try { this.countries = await bulletDeliveryAPI.getCountries(forBranch); this.displayedCountries = this.countries; const { countryId: configCountryId, regionId: configRegionId, cityId: configCityId, districtId: configDistrictId } = this.getIPLocationConfig(); log("getIPLocationConfig", this.getIPLocationConfig()); const countryToSelect = configCountryId && this.countries.length > 0 ? this.countries.find((c) => String(c.id) === String(configCountryId)) : null; if (countryToSelect) { this.selectedCountry = countryToSelect; this.updateNewAddressForm({ country_id: Number(this.selectedCountry.id), }); // Skip eager sub-fetches when stored intent IDs exist — prefillFromSessionStorage // will fire regions/cities/districts in parallel using the stored IDs directly. if (!skipEagerSubFetch) { const hasIPLocationIds = configRegionId != null || configCityId != null || configDistrictId != null; if (hasIPLocationIds) { await this.prefillFromIPLocation(countryToSelect, { regionId: configRegionId, cityId: configCityId, districtId: configDistrictId, }); } else if (isSaudiArabia(this.selectedCountry.code)) { await this.loadRegions(this.selectedCountry.id); } else { await this.loadCities(this.selectedCountry.id); } } } } finally { this.loadingCountries = false; } } async prefillFromIPLocation(country, ids) { const isSA = isSaudiArabia(country.code); const { regionId, cityId, districtId } = ids; const [regions, cities, districts] = await Promise.all([ isSA ? bulletDeliveryAPI.getRegions(country.id) : Promise.resolve([]), bulletDeliveryAPI.getCities(country.id, isS