1// ============================================================================ 2// Giftella web app · Ecosystem / Shop tab (Sep 2026 overhaul) 3// 4// The curated marketplace: independent partner brands you can shop for gifts, 5// split by region (US · Nigeria · UK), narrowed by category, search and 6// "shop gifts for" presets. Mirrors the iOS "Shops" screen and the previous 7// web tab: the same built-in seed (renders instantly, never a blank page), 8// Firestore `ecosystem/data/stores` merged on top (Firestore wins on id), 9// curated picks from `ecosystem/data/products` where storeID == id, and 10// every outbound click through the giftella.app/go affiliate tracker. 11// Layout (founder, Sep 10): slim header strip with the region switcher, one 12// search + filter row, ONE grid of compact cards (featured first), a slim 13// partner band, affiliate note last. Warm ink + gold tokens only, no purple. 14// Self-contained: mount(root) once, show(params) per visit, hide(). 15// ============================================================================ 16import { state, bus, $, qs, qsa, esc, injectCSS, toast, ls, lsSet, openSheet, logEvent, isHttp, fs, db, DEMO } from "./core.js?v=2026.09.11a"; 17 18// --------------------------------------------------------------------------- 19// Constants (ported from the previous web tab + iOS EcosystemModels) 20// --------------------------------------------------------------------------- 21const LS_REGION = "giftella.eco.region.v1"; 22const APPLY_URL = "/ecosystem/apply"; 23const GO_BASE = "https://giftella.app/go"; 24const REFRESH_MS = 5 * 60 * 1000; 25 26const REGIONS = [ 27 { code: "US", label: "US", long: "United States", inName: "the United States", flag: "ðºð¸" }, 28 { code: "NG", label: "Nigeria", long: "Nigeria", inName: "Nigeria", flag: "ð³ð¬" }, 29 { code: "GB", label: "UK", long: "United Kingdom", inName: "the United Kingdom", flag: "ð¬ð§" } 30]; 31const CATS = [ 32 { id: "all", label: "All", emoji: "â¨" }, 33 { id: "Fashion", label: "Fashion", emoji: "ð" }, 34 { id: "Beauty", label: "Beauty", emoji: "ð" }, 35 { id: "Home & Decor", label: "Home", emoji: "ð¡" }, 36 { id: "Food & Drink", label: "Food", emoji: "ð«" }, 37 { id: "Tech & Gadgets", label: "Tech", emoji: "ð±" }, 38 { id: "Wellness", label: "Wellness", emoji: "ð§" }, 39 { id: "Jewelry", label: "Jewelry", emoji: "ð" }, 40 { id: "Lifestyle", label: "Lifestyle", emoji: "ð" }, 41 { id: "Handmade", label: "Handmade", emoji: "ð¨" }, 42 { id: "Kids & Baby", label: "Kids", emoji: "ð§¸" }, 43 { id: "Books & Stationery", label: "Books", emoji: "ð" }, 44 { id: "Experiences", label: "Experiences", emoji: "ð«" } 45]; 46const CAT_EMOJI = CATS.reduce((m, c) => { m[c.id] = c.emoji; return m; }, {}); 47 48// "Shop gifts for" presets. Each is a filter (and, for New, a sort) over the 49// same region-scoped list. her/him are gentle heuristics over category + tags; 50// under50 uses partner-declared price hints plus a lazy product-price lookup. 51const QUICK = [ 52 { id: "her", label: "For her", emoji: "ð·" }, 53 { id: "him", label: "For him", emoji: "âï¸" }, 54 { id: "under50", label: "Under $50", emoji: "ð·ï¸" }, 55 { id: "new", label: "New partners", emoji: "ð" }, 56 { id: "featured", label: "Featured", emoji: "â " } 57]; 58const HER = { cats: ["Beauty", "Jewelry", "Fashion", "Wellness", "Handmade", "Lifestyle", "Home & Decor", "Books & Stationery", "Food & Drink"], re: /for her|women|woman|ladies|\bher\b|feminine|skincare|beauty|jewel|candle|flower|hamper|gift box|unisex/i }; 59const HIM = { cats: ["Fashion", "Tech & Gadgets", "Food & Drink", "Lifestyle", "Wellness", "Books & Stationery", "Home & Decor"], re: /for him|\bmen\b|mens|\bman\b|masculine|gadget|grooming|beard|whisk|streetwear|unisex|hamper|gift box/i }; 60const CHEAP_RE = /under\s*\$?\s*50|affordable|budget|stocking|small gifts|\$\s*\d{1,2}\b/i; 61 62// Built-in store seed. Mirrors the iOS/Android EcosystemSeed so the Ecosystem 63// is never empty and brand-new partners appear instantly, even before their 64// Firestore doc has propagated. Firestore wins on id conflict. 65const SEED = [ 66 { id: "sonde", name: "Sondé", tagline: "Duality of Man", 67 description: "Sondé is the story of survival and ambition, a raw and refined reflection of the duality of man. We are light and dark, strength and softness. We wear our struggle like armor and turn pain into power.", 68 websiteURL: "https://sondeworld.myshopify.com/", emoji: "ð¤", 69 category: "Fashion", countryCode: "US",
70 tags: ["streetwear", "high fashion", "luxury", "vintage", "unisex"], 71 isActive: true, isFeatured: true }, 72 { id: "tchama", name: "Tchama", tagline: "Life In Abundance", 73 description: "Tchama is the seeking of abundance in all aspects of your life, not letting a dull moment inhibit your true calling.", 74 websiteURL: "https://tchamalab.com", emoji: "ð", 75 category: "Fashion", countryCode: "US", 76 tags: ["streetwear", "high fashion", "luxury", "vintage"], 77 isActive: true, isFeatured: true }, 78 { id: "viavogue-signature-limited", name: "Viavogue Signature Limited", tagline: "Celebrate. Curate. Elevate.", 79 description: "Viavogue Signature Limited is a Nigerian luxury gifting and event-styling house. Bespoke gift boxes, premium hampers and celebration styling for every occasion, delivered with signature elegance.", 80 websiteURL: "https://www.viavogue.com.ng/", emoji: "â¨", 81 category: "Lifestyle", countryCode: "NG", 82 tags: ["luxury gifts", "hampers", "gift boxes", "event styling", "celebration", "new partner"], 83 isActive: true, isFeatured: true } 84]; 85// Local partner artwork bundled in /public/ecosystem/, used when a store doc 86// has no logoURL/bannerURL yet. Missing files fall back to emoji + gradient. 87const LOCAL_LOGOS = { "sonde": "/ecosystem/sonde.png", "tchama": "/ecosystem/tchama.png", "viavogue-signature-limited": "/ecosystem/viavogue.png" }; 88const LOCAL_BANNERS = { "sonde": "/ecosystem/sonde_banner.png", "tchama": "/ecosystem/tchama_banner.png" }; 89// Deterministic banner gradient per store so cards feel varied but consistent. 90const BANNERS = [ 91 "linear-gradient(135deg,#1E1913,#3A3226)", 92 "linear-gradient(135deg,#C9A24A,#efd79a)", 93 "linear-gradient(135deg,#2A2219,#1E1913)", 94 "linear-gradient(135deg,#A8842F,#C9A24A)", 95 "linear-gradient(135deg,#3D3226,#221C15)", 96 "linear-gradient(135deg,#EFD79A,#C9A24A)" 97]; 98 99// --------------------------------------------------------------------------- 100// Module state 101// --------------------------------------------------------------------------- 102let root = null; 103const ECO = { stores: [], loaded: false, loading: false, loadedAt: 0, region: "", category: "all", search: "", quick: "", cheapIds: null, cheapLoading: false }; 104const productCache = new Map(); // storeId -> products[] 105let openSeq = 0; // guards async work of a closed sheet 106 107// --------------------------------------------------------------------------- 108// CSS (module-scoped, tokens only) 109// --------------------------------------------------------------------------- 110const CSS = ` 111#page-shop [hidden] { display: none !important; } 112/* header strip (slim, one row: title left, region switcher right) */ 113.ga-hero.ec-head { padding: 22px 24px; border-radius: 24px; display:flex; align-items:center; justify-content:space-between; gap: 14px 24px; flex-wrap:wrap; } 114.ec-head-main { min-width: 0; flex: 1 1 320px; } 115.ec-head .ga-eyebrow { margin-bottom: 6px; } 116.ec-head .ga-h1 { font-size: 28px; line-height: 1.1; letter-spacing: -.4px; } 117.ec-head .ga-lead { font-size: 14px; line-height: 1.45; margin: 4px 0 0; max-width: none; } 118.ec-regions { display:flex; align-items:center; gap: 6px; flex-wrap:wrap; flex: none; } 119.ec-regions .ga-chip { padding: 6px 12px; font-size: 13px; background: var(--card); border-color: var(--border); color: var(--muted); } 120.ec-regions .ga-chip .e { font-size: 14px; } 121.ec-regions .ga-chip:hover { border-color: var(--gold); color: var(--text); } 122.ec-regions .ga-chip.on { background: var(--ga-gold-grad); color: var(--ga-ink); border-color: transparent; } 123.ec-regions .ga-chip .n { font-size: 11px; font-weight: 800; padding: 1px 6px; border-radius: 999px; background: var(--surface-2); } 124.ec-regions .ga-chip.on .n { background: rgba(30,25,19,.14); } 125@media (max-width: 640px) { .ga-hero.ec-head { padding: 18px; border-radius: 20px; gap: 12px; } .ec-head .ga-h1 { font-size: 26px; } } 126/* tools: search pill + one scrolling strip (categories · divider · presets) */ 127.ec-tools { margin-top: 14px; display:flex; align-items:center; gap: 12px; } 128.ec-search { position:relative; flex: none; width: 100%; max-width: 320px; }
129.ec-search .ga-input { padding: 8px 38px 8px 36px; font-size: 14px; border-radius: 999px; } 130.ec-search .lead { position:absolute; left: 13px; top: 50%; transform: translateY(-50%); font-size: 14px; color: var(--muted); pointer-events:none; } 131.ec-search .clr { position:absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 26px; height: 26px; border-radius: 50%; background: var(--surface-2); color: var(--muted); display:grid; place-items:center; font-size: 11px; } 132.ec-search .clr:hover { color: var(--text); } 133.ec-strip { flex: 1; min-width: 0; align-items:center; padding: 2px 28px 2px 0; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); } 134.ec-cats, .ec-quick { display:flex; align-items:center; gap: 8px; flex:none; } 135.ec-strip .ga-chip { padding: 6px 12px; font-size: 13px; } 136.ec-strip .ga-chip .e { font-size: 14px; } 137.ec-div { flex:none; width: 16px; height: 1px; background: var(--border); align-self:center; margin: 0 2px; } 138.ec-quick .ga-chip { background: var(--surface-2); border-color: transparent; } 139.ec-quick .ga-chip:hover { border-color: var(--gold); } 140.ec-quick .ga-chip.on { background: var(--text); color: var(--bg); border-color: transparent; box-shadow: none; } 141@media (max-width: 899px) { 142 .ec-tools { flex-direction: column; align-items: stretch; gap: 10px; margin-top: 12px; } 143 .ec-search { max-width: none; } 144 .ec-strip { margin-left:-16px; margin-right:-16px; padding-left:16px; padding-right:16px; -webkit-mask-image: none; mask-image: none; } 145} 146@media (max-width: 430px) { .ec-strip { margin-left:-14px; margin-right:-14px; padding-left:14px; padding-right:14px; } } 147/* directory section */ 148.ec-sec { margin-top: 20px; } 149.ec-sec .ga-sec-head { align-items: baseline; flex-wrap: wrap; margin-bottom: 10px; } 150.ec-sec .ga-sec-head .ga-h2 { font-size: 20px; } 151.ec-sec .ga-sec-head .sub { font-size: 13px; margin-top: 2px; } 152.ec-sec .ga-sec-head .cnt { color: var(--muted); font-size: 13px; font-weight: 600; white-space: nowrap; } 153.ec-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; } 154@media (max-width: 640px) { .ec-grid { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; } } 155@media (max-width: 379px) { .ec-grid { grid-template-columns: 1fr; } } 156/* compact brand card (about 190px tall) */ 157.ec-card { position:relative; display:flex; flex-direction:column; overflow:hidden; text-align:left; padding:0; color: var(--text); outline: none; border-radius: 18px; } 158.ec-card:focus-visible { box-shadow: 0 0 0 3px rgba(201,162,74,.35); border-color: var(--gold); } 159.ec-card.feat { border-color: rgba(201,162,74,.5); } 160.ec-banner { position:relative; height: 64px; flex:none; display:grid; place-items:center; overflow:hidden; background: var(--ec-grad, linear-gradient(135deg,#1E1913,#3A3226)); border-bottom: 1px solid var(--border2); } 161.ec-banner .fb { font-size: 24px; line-height:1; filter: drop-shadow(0 4px 10px rgba(0,0,0,.25)); transition: opacity .3s ease; } 162.ec-banner img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; } 163.ec-banner::after { content:""; position:absolute; inset:0; background: linear-gradient(180deg, rgba(0,0,0,0) 55%, rgba(0,0,0,.18)); pointer-events:none; } 164.ec-banner.has-img .fb { opacity: 0; } 165.ec-banner .wm, .es-banner .wm { position:absolute; right: 8px; bottom: -8px; font-family: var(--ga-serif); font-style: italic; font-size: 34px; line-height: 1; letter-spacing: -1px; color: rgba(255,255,255,.14); white-space:nowrap; pointer-events:none; max-width: 100%; overflow:hidden; text-overflow: clip; } 166.es-banner .wm { font-size: 76px; bottom: -18px; right: 6px; color: rgba(255,255,255,.12); } 167.ec-banner.has-img .wm, .es-banner.has-img .wm { display:none; } 168.ec-flag { position:absolute; top: 7px; right: 8px; z-index:2; font-size: 11px; line-height: 1; padding: 4px 7px; border-radius: 999px; background: rgba(30,25,19,.5); border: 1px solid rgba(255,255,255,.18); color:#fff; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); font-weight: 700; } 169.ec-body { padding: 0 12px 12px; display:flex; flex-direction:column; flex:1; min-width:0; } 170.ec-top { display:flex; align-items:flex-end; justify-content:space-between; gap: 8px; margin-top: -18px; position:relative; z-index:2; } 171.ec-logo { width: 44px; height: 44px; border-radius: 13px; background:#fff; border: 2px solid var(--card); position:relative; display:grid; place-items:center; font-size: 20px; overflow:hidden; box-shadow: var(--ga-shadow-sm); flex:none; } 172.ec-logo img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; } 173.ec-logo .fb { line-height:1; } 174.ec-logo.has-img .fb { visibility: hidden; } 175.ec-badges { display:flex; gap: 4px; flex-wrap:wrap; justify-content:flex-end; align-content:flex-start; max-height: 20px; overflow:hidden; min-width:0; } 176.ec-badges .ga-tag { font-size: 10px; padding: 3px 7px; letter-spacing: .2px; white-space:nowrap; } 177.ec-name { font-family: var(--ga-serif); font-weight: 400; font-size: 17px; line-height: 1.15; letter-spacing: -.2px; margin: 6px 0 0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } 178.ec-tagline { color: var(--muted); font-size: 12.5px; line-height: 1.4; margin: 2px 0 0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } 179.ec-meta { display:flex; align-items:center; gap: 8px; margin-top:auto; padding-top: 10px; } 180.ec-tags { display:flex; gap: 4px; flex-wrap:wrap; flex:1; min-width:0; max-height: 20px; overflow:hidden; align-content:flex-start; } 181.ec-tags .ga-tag { font-size: 10px; padding: 3px 7px; letter-spacing: 0; text-transform: none; font-weight: 700; white-space:nowrap; } 182.ec-tags .ga-tag.soft { background: var(--surface-2); color: var(--muted); } 183.ec-meta .go { flex:none; display:inline-flex; align-items:center; gap: 4px; font-size: 12px; font-weight: 800; color: var(--ga-gold-ink); padding: 5px 10px; border-radius: 999px; background: rgba(201,162,74,.12); border: 1px solid rgba(201,162,74,.3); white-space:nowrap; } 184:root[data-theme="dark"] .ec-meta .go { color: var(--gold2); } 185.ec-card:hover .ec-meta .go { background: var(--ga-gold-grad); color: var(--ga-ink); border-color: transparent; } 186/* empty states (compact) */ 187.ga-empty.ec-empty { padding: 26px 18px; } 188.ec-empty .regions { display:flex; gap: 8px; justify-content:center; flex-wrap:wrap; margin-top: 12px; } 189.ec-empty .ga-btn { margin-top: 12px; } 190/* partner band (one slim row) */ 191.ga-panel.ec-brand { margin-top: 24px; padding: 18px 22px; display:flex; align-items:center; gap: 14px; position:relative; overflow:hidden; border-radius: 20px; } 192.ec-brand::before { content:""; position:absolute; right:-60px; top:-70px; width: 200px; height: 200px; border-radius: 50%; background: radial-gradient(circle, rgba(201,162,74,.2), transparent 68%); pointer-events:none; } 193.ec-brand .ic { flex:none; width: 40px; height: 40px; border-radius: 12px; display:grid; place-items:center; font-size: 19px; background: rgba(201,162,74,.14); } 194.ec-brand .txt { flex:1; min-width:0; margin:0; font-size: 14px; line-height: 1.4; color: var(--muted); } 195.ec-brand .txt b { color: var(--text); font-family: var(--ga-serif); font-weight: 400; font-size: 17px; margin-right: 6px; } 196.ec-brand .ga-btn { flex:none; position:relative; } 197@media (max-width: 640px) { .ga-panel.ec-brand { flex-direction: column; align-items: stretch; padding: 16px; gap: 10px; } .ec-brand .ic { display:none; } .ec-brand .txt b { display:block; margin: 0 0 2px; } } 198/* affiliate note (page footer) */ 199.ec-disclose { margin: 18px 0 0; font-size: 12px; color:var(--muted); line-height: 1.5; text-align:center; }
200/* store sheet */ 201.es-banner { position:relative; height: 150px; border-radius: 20px; overflow:hidden; display:grid; place-items:center; background: var(--ec-grad, linear-gradient(135deg,#1E1913,#3A3226)); } 202.es-banner .fb { font-size: 50px; line-height:1; filter: drop-shadow(0 8px 18px rgba(0,0,0,.3)); transition: opacity .3s ease; } 203.es-banner img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; } 204.es-banner::after { content:""; position:absolute; inset:0; background: linear-gradient(180deg, rgba(0,0,0,0) 50%, rgba(0,0,0,.25)); pointer-events:none; } 205.es-banner.has-img .fb { opacity:0; } 206.es-banner .ec-flag { top: 12px; right: 12px; font-size: 12.5px; padding: 5px 9px; } 207.es-banner .ec-badge { position:absolute; top: 12px; left: 12px; z-index:2; display:inline-flex; align-items:center; gap: 4px; padding: 5px 10px; border-radius: 999px; font-size: 10.5px; font-weight: 800; letter-spacing: .6px; text-transform: uppercase; background: var(--ga-gold-grad); color: var(--ga-ink); box-shadow: 0 6px 14px -6px rgba(201,162,74,.9); } 208.es-banner .ec-badge.promo { background: rgba(255,255,255,.92); } 209.es-head { display:flex; gap: 16px; align-items:flex-end; margin: -36px 8px 0; position:relative; z-index:2; flex-wrap:wrap; } 210.es-logo { width: 80px; height: 80px; border-radius: 20px; border: 4px solid var(--bg); background:#fff; display:grid; place-items:center; font-size: 38px; overflow:hidden; box-shadow: var(--ga-shadow); position:relative; flex:none; } 211.es-logo img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; } 212.es-logo.has-img .fb { visibility:hidden; } 213.es-titles { flex:1; min-width: 200px; padding-bottom: 4px; } 214.es-title { font-family: var(--ga-serif); font-size: clamp(24px, 3vw, 30px); line-height: 1.08; letter-spacing: -.3px; margin: 0; } 215.es-tagline { color: var(--muted); font-size: 14.5px; margin: 4px 0 0; } 216.es-tags { display:flex; gap: 6px; flex-wrap:wrap; margin: 14px 8px 0; } 217.es-tags .ga-tag { letter-spacing: 0; text-transform: none; font-weight: 700; } 218.es-tags .ga-tag.soft { background: var(--surface-2); color: var(--muted); } 219.es-desc { margin: 16px 8px 0; font-size: 15px; line-height: 1.62; max-width: 68ch; } 220.es-cta { display:flex; align-items:center; gap: 12px 16px; flex-wrap:wrap; margin: 20px 8px 0; } 221.es-cta .ga-btn.lg { flex: 1; min-width: 240px; } 222.es-cta .host { font-size: 12.5px; color: var(--muted); display:flex; flex-direction:column; gap: 2px; } 223.es-cta .host b { color: var(--text); font-weight: 700; } 224.es-picks { margin: 28px 8px 0; } 225.es-picks .ga-sec-head { margin-bottom: 12px; } 226.es-picks .ga-sec-head .ga-h2 { font-size: 21px; } 227.es-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; } 228@media (max-width: 480px) { .es-grid { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; } } 229.es-prod { display:flex; flex-direction:column; overflow:hidden; text-decoration:none; color: var(--text); padding:0; } 230.es-prod .pm { position:relative; aspect-ratio: 1 / 1; display:grid; place-items:center; overflow:hidden; background: linear-gradient(145deg, rgba(201,162,74,.14), rgba(201,162,74,.06)); } 231.es-prod .pm .fb { font-size: 36px; } 232.es-prod .pm img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; background:#fff; } 233.es-prod .pm.has-img .fb { visibility:hidden; } 234.es-prod .pb { padding: 10px 11px 12px; display:flex; flex-direction:column; gap: 4px; flex:1; } 235.es-prod .pt { font-size: 13px; font-weight: 700; line-height: 1.3; display:-webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow:hidden; } 236.es-prod .pp { font-size: 12.5px; font-weight: 800; color: var(--ga-gold-ink); margin-top:auto; display:flex; align-items:center; justify-content:space-between; gap: 6px; } 237:root[data-theme="dark"] .es-prod .pp { color: var(--gold2); } 238.es-prod .pp .arr { color: var(--muted); font-weight: 700; font-size: 12px; } 239.es-sk { border-radius: 18px; border: 1px solid var(--border); background: var(--card); overflow:hidden; } 240.es-sk .sk-media { aspect-ratio: 1/1; border-radius: 0; } 241.es-sk .sk-line { height: 11px; margin: 10px 11px 0; } 242.es-sk .sk-line:last-child { margin-bottom: 13px; } 243.es-quiet { color: var(--muted); font-size: 13.5px; padding: 14px 16px; border: 1px dashed var(--border); border-radius: 16px; background: var(--surface-2); } 244.es-quiet a { color: var(--ga-gold-ink); font-weight: 700; text-decoration:none; } 245:root[data-theme="dark"] .es-quiet a { color: var(--gold2); } 246.es-quiet a:hover { text-decoration: underline; } 247.es-disclose { margin: 22px 8px 0; font-size: 11.5px; color: var(--muted); line-height: 1.5; } 248@media (max-width: 640px) { .es-banner { border-radius: 18px; } .es-head { margin: -30px 4px 0; gap: 12px; } .es-logo { width: 68px; height: 68px; border-radius: 18px; font-size: 32px; } .es-tags, .es-desc, .es-cta, .es-picks, .es-disclose { margin-left: 4px; margin-right: 4px; } .es-cta .ga-btn.lg { width:100%; } } 249`; 250
251// --------------------------------------------------------------------------- 252// Helpers 253// --------------------------------------------------------------------------- 254function regionOf(cc) { const c = String(cc || "").toUpperCase(); if (c === "NG") return "NG"; if (c === "GB" || c === "UK") return "GB"; return "US"; } 255function detectRegion() { try { return regionOf((navigator.language || "").split("-")[1]); } catch (_) { return "US"; } } 256function regionMeta(code) { return REGIONS.find((r) => r.code === code) || REGIONS[0]; } 257function goLink(id) { return GO_BASE + "?store=" + encodeURIComponent(id); } 258function productLink(storeId, p) { 259 // Mirrors iOS GiftellaGo.productLink: carry the product URL, title and a 260 // price snapshot so /go can attribute the click; falls back to the store link. 261 if (!p || !isHttp(p.productURL)) return goLink(storeId); 262 let u = goLink(storeId) + "&u=" + encodeURIComponent(p.productURL); 263 if (p.title) u += "&t=" + encodeURIComponent(String(p.title).slice(0, 120)); 264 const cents = Math.round((Number(p.priceValue) || 0) * 100); if (cents > 0) u += "&p=" + cents; 265 return u; 266} 267function hostOf(url) { try { return new URL(url).hostname.replace(/^www\./, ""); } catch (_) { return ""; } } 268function fold(s) { return String(s || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim(); } 269function pinRank(name) { const n = fold(name); if (n.startsWith("sonde")) return 0; if (n.startsWith("tchama")) return 1; return 99; } 270function bannerGrad(s) { let x = 0; const k = String(s.id || s.name || ""); for (let i = 0; i < k.length; i++) x = (x * 31 + k.charCodeAt(i)) >>> 0; return BANNERS[x % BANNERS.length]; } 271function tagsOf(s) { return Array.isArray(s.tags) ? s.tags.filter(Boolean).map(String) : (typeof s.tags === "string" ? s.tags.split(",").map((t) => t.trim()).filter(Boolean) : []); } 272function isNew(s) { return s.isNew === true || tagsOf(s).some((t) => t.toLowerCase() === "new partner"); } 273function isPromoted(s) { return s.isPromoted === true; } 274function takesPoints(s) { return s.acceptsCredits === true; } 275function logoOf(s) { return isHttp(s.logoURL) || (s.logoURL && s.logoURL.startsWith("/")) ? s.logoURL : (LOCAL_LOGOS[s.id] || ""); } 276function bannerOf(s) { return isHttp(s.bannerURL) || (s.bannerURL && s.bannerURL.startsWith("/")) ? s.bannerURL : (LOCAL_BANNERS[s.id] || ""); } 277function emojiOf(s) { return s.emoji || "ðª"; } 278/** Banner watermark: the first word of a long name keeps the strip tidy. */ 279function wmOf(s) { const n = String(s.name || ""); return n.length > 14 ? n.split(/\s+/)[0] : n; } 280function toMillis(v) { if (!v) return 0; if (v instanceof Date) return v.getTime(); if (typeof v.toDate === "function") return v.toDate().getTime(); if (typeof v.seconds === "number") return v.seconds * 1000; const d = new Date(v); return isNaN(d) ? 0 : d.getTime(); } 281function catEmoji(c) { return CAT_EMOJI[c] || "ðï¸"; } 282function normalizeStore(raw) { 283 const s = Object.assign({}, raw); 284 s.id = String(s.id || "").trim(); 285 s.name = String(s.name || s.id || "").trim(); 286 s.tagline = String(s.tagline || "").trim(); 287 s.description = String(s.description || "").trim(); 288 s.category = String(s.category || "Lifestyle").trim() || "Lifestyle"; 289 s.tags = tagsOf(s); 290 s.countryCode = regionOf(s.countryCode); 291 return s; 292} 293// Merge seed + Firestore; Firestore doc wins on matching id. 294function mergeStores(remote) { 295 const byId = {}; 296 SEED.forEach((s) => { byId[s.id] = Object.assign({}, s); }); 297 (remote || []).forEach((s) => { if (!s || !s.id) return; byId[s.id] = Object.assign({}, byId[s.id] || {}, s); }); 298 return Object.values(byId).map(normalizeStore).filter((s) => s.id && s.isActive !== false); 299} 300function sortStores(list, quick) { 301 return list.slice().sort((a, b) => { 302 const pa = pinRank(a.name), pb = pinRank(b.name); if (pa !== pb) return pa - pb; 303 if (quick === "new") { const d = toMillis(b.joinedAt) - toMillis(a.joinedAt); if (d) return d; } 304 if (isPromoted(a) !== isPromoted(b)) return isPromoted(a) ? -1 : 1; 305 if (!!a.isFeatured !== !!b.isFeatured) return a.isFeatured ? -1 : 1; 306 return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); 307 }); 308} 309function quickMatch(s) { 310 const q = ECO.quick; if (!q) return true; 311 const hay = tagsOf(s).join(" ") + " " + (s.tagline || ""); 312 if (q === "her") return HER.cats.includes(s.category) || HER.re.test(hay); 313 if (q === "him") return HIM.cats.includes(s.category) || HIM.re.test(hay); 314 if (q === "new") return isNew(s) || (toMillis(s.joinedAt) > Date.now() - 45 * 86400000); 315 if (q === "featured") return !!s.isFeatured || isPromoted(s); 316 if (q === "under50") { 317 if (ECO.cheapIds && ECO.cheapIds.has(s.id)) return true; 318 if (typeof s.priceFrom === "number" && s.priceFrom > 0 && s.priceFrom <= 50) return true; 319 if (s.priceTier === "$" || s.priceTier === "$$") return true; 320 return CHEAP_RE.test(hay); 321 } 322 return true; 323} 324function matches(s) { 325 if (regionOf(s.countryCode) !== ECO.region) return false;
326 if (ECO.category !== "all" && s.category !== ECO.category) return false; 327 if (!quickMatch(s)) return false; 328 if (ECO.search) { 329 const hay = fold([s.name, s.tagline, s.category, s.description, tagsOf(s).join(" ")].join(" ")); 330 if (!hay.includes(ECO.search)) return false; 331 } 332 return true; 333} 334function browsing() { return !ECO.search && ECO.category === "all" && !ECO.quick; } 335function regionCounts() { const m = { US: 0, NG: 0, GB: 0 }; ECO.stores.forEach((s) => { m[regionOf(s.countryCode)] = (m[regionOf(s.countryCode)] || 0) + 1; }); return m; } 336 337/** Wire <img data-fb> fallbacks: on load mark the host, on error remove the img. */ 338function wireImages(scope) { 339 qsa("img[data-fb]", scope).forEach((img) => { 340 if (img._wired) return; img._wired = true; 341 const host = img.parentElement; 342 const ok = () => { host && host.classList.add("has-img"); }; 343 const bad = () => { if (host) host.classList.remove("has-img"); img.remove(); }; 344 if (img.complete) { if (img.naturalWidth > 0) ok(); else if (img.src) bad(); return; } 345 img.addEventListener("load", ok, { once: true }); 346 img.addEventListener("error", bad, { once: true }); 347 }); 348} 349function imgTag(src, alt) { return src ? '<img src="' + esc(src) + '" alt="' + esc(alt || "") + '" loading="lazy" decoding="async" referrerpolicy="no-referrer" data-fb>' : ""; } 350 351// --------------------------------------------------------------------------- 352// Data 353// --------------------------------------------------------------------------- 354async function load(force = false) { 355 if (!ECO.region) ECO.region = ls(LS_REGION, "") || detectRegion(); 356 if (!ECO.stores.length) ECO.stores = mergeStores([]); // seed paints instantly 357 if (DEMO) { ECO.loaded = true; ECO.loadedAt = Date.now(); return; } 358 if (ECO.loading) return; 359 if (ECO.loaded && !force && Date.now() - ECO.loadedAt < REFRESH_MS) return; 360 ECO.loading = true; 361 try { 362 const snap = await fs.getDocs(fs.collection(db, "ecosystem", "data", "stores")); 363 const remote = snap.docs.map((d) => ({ id: d.id, ...d.data() })); 364 ECO.stores = mergeStores(remote); 365 ECO.loaded = true; ECO.loadedAt = Date.now(); 366 render(); 367 } catch (_) { 368 // Keep the seed on screen if Firestore is unreachable; retry on the next visit. 369 ECO.loaded = true; ECO.loadedAt = Date.now() - REFRESH_MS + 30000; 370 } finally { ECO.loading = false; } 371} 372/** Stores with at least one curated pick under $50 (lazy, cached per session). */ 373async function loadCheapIds() { 374 if (ECO.cheapIds || ECO.cheapLoading || DEMO) return; 375 ECO.cheapLoading = true; 376 try { 377 const q = fs.query(fs.collection(db, "ecosystem", "data", "products"), fs.where("priceValue", ">", 0), fs.where("priceValue", "<=", 50), fs.limit(300)); 378 const snap = await fs.getDocs(q); 379 const ids = new Set(); snap.forEach((d) => { const p = d.data(); if (p && p.storeID && p.isActive !== false) ids.add(String(p.storeID)); }); 380 ECO.cheapIds = ids; 381 } catch (_) { ECO.cheapIds = new Set(); } 382 ECO.cheapLoading = false; 383 if (ECO.quick === "under50") renderLists(); 384} 385async function loadProducts(id) { 386 if (productCache.has(id)) return productCache.get(id); 387 let list = []; 388 if (DEMO) { list = (window.__ecoDemoProducts && window.__ecoDemoProducts[id]) || []; } 389 else { 390 const snap = await fs.getDocs(fs.query(fs.collection(db, "ecosystem", "data", "products"), fs.where("storeID", "==", id))); 391 list = snap.docs.map((d) => ({ id: d.id, ...d.data() })); 392 } 393 list = list.filter((p) => p && p.isActive !== false && (p.title || p.imageURL)) 394 .sort((a, b) => (b.isFeatured ? 1 : 0) - (a.isFeatured ? 1 : 0) || toMillis(b.addedAt) - toMillis(a.addedAt)) 395 .slice(0, 8); 396 productCache.set(id, list); 397 return list; 398} 399 400// --------------------------------------------------------------------------- 401// Mount / show / hide 402// --------------------------------------------------------------------------- 403export function mount(r) { 404 root = r; 405 injectCSS("ecosystem-css", CSS); 406 ECO.region = ls(LS_REGION, "") || detectRegion(); 407 ECO.stores = mergeStores([]); 408 root.innerHTML = '<div class="ga-page ec-page">' + 409 // ---- header strip: title left, region switcher right ---- 410 '<section class="ga-hero ink ec-head" id="ec-hero">' + 411 '<div class="ec-head-main"><p class="ga-eyebrow">Curated marketplace</p><h1 class="ga-h1">Independent brands we love</h1>' + 412 '<p class="ga-lead">Handpicked partner stores, chosen by people, for real gifts.</p></div>' + 413 '<div class="ec-regions" id="ec-regions" role="tablist" aria-label="Shop by country"></div>' + 414 "</section>" + 415 // ---- tools: search pill + one scrolling strip (categories · presets) ---- 416 '<div class="ec-tools">' + 417 '<div class="ec-search"><span class="lead">ð</span><input class="ga-input" id="ec-search" type="text" inputmode="search" placeholder="Search stores, vibes, categoriesâ¦" autocomplete="off" enterkeyhint="search" aria-label="Search stores">' + 418 '<button type="button" class="clr" id="ec-clear" aria-label="Clear search" hidden>â</button></div>' + 419 '<div class="ga-chips scroll ec-strip" id="ec-strip">' + 420 '<div class="ec-cats" id="ec-cats" role="tablist" aria-label="Categories"></div>' + 421 '<span class="ec-div" aria-hidden="true"></span>' + 422 '<div class="ec-quick" id="ec-quick" role="group" aria-label="Shop gifts for"></div>' + 423 "</div>" + 424 "</div>" + 425 // ---- directory: one grid, featured first ---- 426 '<section class="ec-sec" id="ec-sec" hidden>' + 427 '<div class="ga-sec-head"><div><h2 class="ga-h2" id="ec-h">All brands</h2><div class="sub" id="ec-sub" hidden></div></div><span class="cnt" id="ec-cnt"></span></div>' + 428 '<div class="ec-grid" id="ec-grid"></div>' + 429 "</section>" + 430 '<div id="ec-empty" class="ec-sec" hidden></div>' + 431 // ---- partner band ---- 432 '<section class="ga-panel ec-brand" id="ec-brand">' + 433 '<span class="ic" aria-hidden="true">ð·ï¸</span>' + 434 '<p class="txt"><b>Are you a brand?</b>Join the Ecosystem and reach people who are actively choosing a gift.</p>' + 435 '<a class="ga-btn primary" href="' + APPLY_URL + '" target="_blank" rel="noopener">Apply as a partner â</a>' + 436 "</section>" + 437 '<p class="ec-disclose">Some links are affiliate links. Giftella may earn a commission when you shop through the Ecosystem, at no extra cost to you.</p>' + 438 "</div>"; 439 wireTools(); 440 render(); 441 bus.on("refocus", () => { if (state.page === "shop") load(); }); 442} 443export function show(params = {}) { 444 render(); 445 load(); 446 const sub = params && params.sub ? String(params.sub).split("/")[0] : "";
447 if (sub) setTimeout(() => { if (ECO.stores.some((s) => s.id === sub)) openStore(sub); else toast("That store isn't in the Ecosystem yet"); }, 60); 448} 449export function hide() {} 450/** A few featured brands for other tabs (the You page "Independent brands" card). Seed first, Firestore once loaded. */ 451/** Brand matches for the topbar search. Seed first, Firestore once loaded. Name hits rank above tag/description hits. */ 452export function searchStores(q, limit = 4) { 453 const n = fold(q); if (!n) return []; 454 const list = ECO.stores.length ? ECO.stores : mergeStores([]); 455 const hits = list.filter((s) => fold([s.name, s.tagline, s.category, s.description, tagsOf(s).join(" ")].join(" ")).includes(n)); 456 const rank = (s) => { const f = fold(s.name); return f.startsWith(n) ? 0 : (f.includes(n) ? 1 : 2); }; 457 return sortStores(hits, "").sort((a, b) => rank(a) - rank(b)).slice(0, Math.max(1, limit)) 458 .map((s) => ({ id: s.id, name: s.name, tagline: s.tagline || s.category || "", emoji: emojiOf(s), logo: logoOf(s) })); 459} 460 461export function featured(n = 3) { 462 const list = ECO.stores.length ? ECO.stores : mergeStores([]); 463 return sortStores(list.filter((s) => s.isFeatured || isPromoted(s)), "").slice(0, n) 464 .map((s) => ({ id: s.id, name: s.name, tagline: s.tagline || s.category || "", emoji: emojiOf(s), logo: logoOf(s), region: regionMeta(regionOf(s.countryCode)).flag })); 465} 466 467// --------------------------------------------------------------------------- 468// Tools (search, categories, quick presets, region) 469// --------------------------------------------------------------------------- 470/** addEventListener that tolerates a missing element, so mount() never throws. */ 471function on(id, evt, fn) { const el = $(id); if (el) el.addEventListener(evt, fn); return el; } 472function wireTools() { 473 const input = $("ec-search"), clr = $("ec-clear"); 474 const clear = () => { if (input) input.value = ""; ECO.search = ""; if (clr) clr.hidden = true; renderLists(); }; 475 on("ec-search", "input", () => { ECO.search = fold(input.value); if (clr) clr.hidden = !input.value; renderLists(); }); 476 on("ec-search", "keydown", (e) => { if (e.key === "Escape") clear(); }); 477 on("ec-clear", "click", () => { clear(); if (input) input.focus(); }); 478 on("ec-cats", "click", (e) => { const b = e.target.closest("[data-c]"); if (!b) return; ECO.category = b.dataset.c; renderChips(); renderLists(); }); 479 on("ec-quick", "click", (e) => { 480 const b = e.target.closest("[data-q]"); if (!b) return; 481 ECO.quick = ECO.quick === b.dataset.q ? "" : b.dataset.q; 482 if (ECO.quick === "under50") loadCheapIds(); 483 renderQuick(); renderLists(); 484 logEvent("ecosystem_quick", { preset: ECO.quick || "none" }); 485 }); 486 on("ec-regions", "click", (e) => { const b = e.target.closest("[data-r]"); if (b) setRegion(b.dataset.r); }); 487 on("ec-grid", "click", (e) => { const c = e.target.closest(".ec-card[data-id]"); if (c) openStore(c.dataset.id); }); 488 on("ec-grid", "keydown", (e) => { if (e.key !== "Enter" && e.key !== " ") return; const c = e.target.closest(".ec-card[data-id]"); if (c) { e.preventDefault(); openStore(c.dataset.id); } }); 489 on("ec-empty", "click", (e) => { 490 const r = e.target.closest("[data-r]"); if (r) { setRegion(r.dataset.r); return; } 491 if (e.target.closest("[data-reset]")) resetFilters(); 492 }); 493} 494function setRegion(code) { 495 if (!REGIONS.some((r) => r.code === code) || code === ECO.region) return; 496 ECO.region = code; lsSet(LS_REGION, code); 497 renderRegions(); renderLists(); 498 logEvent("ecosystem_region", { region: code }); 499} 500function resetFilters() { 501 ECO.search = ""; ECO.category = "all"; ECO.quick = ""; 502 const input = $("ec-search"); if (input) input.value = ""; const clr = $("ec-clear"); if (clr) clr.hidden = true; 503 renderChips(); renderQuick(); renderLists(); 504} 505 506// --------------------------------------------------------------------------- 507// Render 508// --------------------------------------------------------------------------- 509function render() { renderRegions(); renderChips(); renderQuick(); renderLists(); } 510function renderRegions() { 511 const el = $("ec-regions"); if (!el) return; 512 const counts = regionCounts(); 513 el.innerHTML = REGIONS.map((r) => { 514 const on = r.code === ECO.region; 515 return '<button type="button" class="ga-chip' + (on ? " on" : "") + '" role="tab" aria-selected="' + on + '" data-r="' + r.code + '" title="' + esc(r.long) + '"><span class="e">' + r.flag + "</span>" + esc(r.label) + '<span class="n">' + (counts[r.code] || 0) + "</span></button>"; 516 }).join(""); 517} 518/** Scroll the active chip of the filter strip into view (single scrolling row). */ 519function revealChip(scope) { 520 const strip = $("ec-strip"); const on = scope && qs(".ga-chip.on", scope); 521 if (!strip || !on || !on.scrollIntoView || strip.scrollWidth <= strip.clientWidth + 4) return;
522 try { on.scrollIntoView({ block: "nearest", inline: "nearest" }); } catch (_) {} 523} 524function renderChips() { 525 const el = $("ec-cats"); if (!el) return; 526 const present = new Set(ECO.stores.map((s) => s.category)); 527 el.innerHTML = CATS.map((c) => { 528 const on = c.id === ECO.category; 529 return '<button type="button" class="ga-chip' + (on ? " on" : "") + '" role="tab" aria-selected="' + on + '" data-c="' + esc(c.id) + '"' + (c.id !== "all" && !present.has(c.id) ? ' style="opacity:.72"' : "") + '><span class="e">' + c.emoji + "</span>" + esc(c.label) + "</button>"; 530 }).join(""); 531 revealChip(el); 532} 533function renderQuick() { 534 const el = $("ec-quick"); if (!el) return; 535 el.innerHTML = QUICK.map((q) => { const on = q.id === ECO.quick; return '<button type="button" class="ga-chip' + (on ? " on" : "") + '" aria-pressed="' + on + '" data-q="' + q.id + '"><span class="e">' + q.emoji + "</span>" + esc(q.label) + "</button>"; }).join(""); 536 if (ECO.quick) revealChip(el); 537} 538function quickTitle() { 539 const r = regionMeta(ECO.region).label; 540 const map = { her: "Gifts for her", him: "Gifts for him", under50: "Gifts under $50", new: "New partners", featured: "Featured partners" }; 541 if (ECO.quick) return map[ECO.quick] + " in " + r; 542 if (ECO.category !== "all") return (CATS.find((c) => c.id === ECO.category) || {}).label + " brands in " + r; 543 if (ECO.search) return "Results in " + r; 544 return "All brands in " + r; 545} 546function renderLists() { 547 const sec = $("ec-sec"), grid = $("ec-grid"), empty = $("ec-empty"); 548 if (!sec || !grid || !empty) return; 549 const matched = sortStores(ECO.stores.filter(matches), ECO.quick); // featured and promoted already sort first 550 const region = regionMeta(ECO.region); 551 const searchText = ($("ec-search") || {}).value || ""; 552 553 // One grid for every brand in the region 554 sec.hidden = !matched.length; 555 if (matched.length) { 556 const anyFeat = matched.some((s) => s.isFeatured || isPromoted(s)); 557 const h = $("ec-h"), sub = $("ec-sub"), cnt = $("ec-cnt"); 558 if (h) h.textContent = browsing() && anyFeat ? "Featured in " + region.label : quickTitle(); 559 const subText = ECO.search ? "Matching â" + searchText.trim() + "â" : (ECO.quick === "under50" && ECO.cheapLoading ? "Checking pricesâ¦" : ""); 560 if (sub) { sub.textContent = subText; sub.hidden = !subText; } 561 if (cnt) cnt.textContent = matched.length + (matched.length === 1 ? " brand" : " brands"); 562 grid.innerHTML = matched.map((s, i) => storeCard(s, i)).join(""); 563 wireImages(grid); 564 } 565 // Empty states 566 empty.hidden = !!matched.length; 567 if (!matched.length) { 568 const others = REGIONS.filter((r) => r.code !== ECO.region).map((r) => '<button type="button" class="ga-chip sm" data-r="' + r.code + '">' + r.flag + " " + esc(r.label) + "</button>").join(""); 569 if (browsing()) { 570 empty.innerHTML = '<div class="ga-empty ec-empty"><div class="e">ðï¸</div><b>No stores in ' + esc(region.inName) + " yet</b>" + 571 "We're signing up the best gift brands here. Know a brand we should feature?" + 572 '<div><a class="ga-btn primary sm" href="' + APPLY_URL + '" target="_blank" rel="noopener">Apply as a partner â</a></div>' + 573 '<div class="regions">' + others + "</div></div>"; 574 } else if (ECO.quick === "under50" && ECO.cheapLoading) { 575 empty.innerHTML = '<div class="ga-empty ec-empty"><div class="e">ð·ï¸</div><b>Checking pricesâ¦</b>Looking for curated picks under $50 in ' + esc(region.inName) + ".</div>"; 576 } else { 577 const what = quickTitle().toLowerCase().replace(/ in [^ ]+$/, ""); 578 const head = ECO.search ? "No brands match â" + esc(searchText.trim()) + "â in " + esc(region.inName) : "No " + esc(what) + " in " + esc(region.inName) + " yet"; 579 empty.innerHTML = '<div class="ga-empty ec-empty"><div class="e">ð</div><b>' + head + "</b>" + 580 (ECO.search ? "Try another word, a category or a preset." : "Try a different category or preset, or browse everything.") + 581 '<div><button type="button" class="ga-btn sm" data-reset>Reset filters</button></div>' + 582 '<div class="regions">' + others + "</div></div>"; 583 } 584 } 585} 586/** Compact brand card: 64px banner, overlapping logo, name, tagline, tags, Shop pill. */ 587function storeCard(s, i) { 588 const region = regionMeta(regionOf(s.countryCode)); 589 const tags = tagsOf(s).filter((t) => t.toLowerCase() !== "new partner").slice(0, 2); 590 const badges = []; 591 if (isPromoted(s)) badges.push('<span class="ga-tag gold">Promoted</span>'); 592 if (isNew(s)) badges.push('<span class="ga-tag gold">â New</span>'); 593 if (s.isFeatured) badges.push('<span class="ga-tag gold">â Featured</span>'); 594 const feat = s.isFeatured || isPromoted(s); 595 return '<article class="ga-card hover ec-card ga-in' + (feat ? " feat" : "") + '" style="--i:' + (i % 12) + '" role="button" tabindex="0" data-id="' + esc(s.id) + '" aria-label="Open ' + esc(s.name) + '">' + 596 '<div class="ec-banner" style="--ec-grad:' + bannerGrad(s) + '">' + 597 '<span class="wm" aria-hidden="true">' + esc(wmOf(s)) + '</span><span class="fb">
597' + esc(emojiOf(s)) + "</span>" + imgTag(bannerOf(s), "") + 598 '<span class="ec-flag" title="' + esc(region.long) + '">' + region.flag + "</span></div>" + 599 '<div class="ec-body">' + 600 '<div class="ec-top"><div class="ec-logo"><span class="fb">' + esc(emojiOf(s)) + "</span>" + imgTag(logoOf(s), s.name + " logo") + "</div>" + 601 (badges.length ? '<div class="ec-badges">' + badges.join("") + "</div>" : "") + "</div>" + 602 '<h3 class="ec-name">' + esc(s.name) + "</h3>" + 603 '<p class="ec-tagline">' + esc(s.tagline || s.description || s.category) + "</p>" + 604 '<div class="ec-meta"><div class="ec-tags"><span class="ga-tag">' + catEmoji(s.category) + " " + esc(s.category) + "</span>" + 605 tags.map((t) => '<span class="ga-tag soft">' + esc(t) + "</span>").join("") + 606 (takesPoints(s) ? '<span class="ga-tag sage">⦠Takes points</span>' : "") + "</div>" + 607 '<span class="go">Shop â</span></div>' + 608 "</div></article>"; 609} 610 611// --------------------------------------------------------------------------- 612// Store sheet 613// --------------------------------------------------------------------------- 614export function openStore(id) { 615 const s = ECO.stores.find((x) => x.id === id); if (!s) return; 616 const seq = ++openSeq; 617 const region = regionMeta(regionOf(s.countryCode)); 618 const go = goLink(s.id); 619 const host = hostOf(s.websiteURL); 620 const tags = tagsOf(s).filter((t) => t.toLowerCase() !== "new partner").slice(0, 6); 621 logEvent("ecosystem_store_opened", { id: s.id }); 622 const html = 623 '<div class="es-banner" style="--ec-grad:' + bannerGrad(s) + '">' + 624 (isNew(s) ? '<span class="ec-badge">â New partner</span>' : isPromoted(s) ? '<span class="ec-badge promo">Promoted</span>' : "") + 625 '<span class="ec-flag">' + region.flag + " " + esc(region.long) + "</span>" + 626 '<span class="wm" aria-hidden="true">' + esc(s.name) + '</span><span class="fb">' + esc(emojiOf(s)) + "</span>" + imgTag(bannerOf(s), "") + "</div>" + 627 '<div class="es-head"><div class="es-logo"><span class="fb">' + esc(emojiOf(s)) + "</span>" + imgTag(logoOf(s), s.name + " logo") + "</div>" + 628 '<div class="es-titles"><h2 class="es-title">' + esc(s.name) + "</h2>" + (s.tagline ? '<p class="es-tagline">' + esc(s.tagline) + "</p>" : "") + "</div></div>" + 629 '<div class="es-tags"><span class="ga-tag">' + catEmoji(s.category) + " " + esc(s.category) + "</span>" + 630 (s.isFeatured ? '<span class="ga-tag gold">â Featured</span>' : "") + (takesPoints(s) ? '<span class="ga-tag sage">⦠Takes points</span>' : "") + 631 tags.map((t) => '<span class="ga-tag soft">' + esc(t) + "</span>").join("") + "</div>" + 632 (s.description ? '<p class="es-desc">' + esc(s.description) + "</p>" : "") + 633 '<div class="es-cta"><a class="ga-btn primary lg" data-cta href="' + esc(go) + '" target="_blank" rel="noopener sponsored">Shop ' + esc(s.name) + " â</a>" + 634 (host ? '<span class="host"><span>Opens in a new tab</span><b>' + esc(host) + "</b></span>" : "") + "</div>" + 635 '<section class="es-picks" id="es-picks"><div class="ga-sec-head"><div><h3 class="ga-h2">Featured picks</h3><div class="sub">Curated by ' + esc(s.name) + " for gifting.</div></div></div>" + 636 '<div class="es-grid" id="es-grid">' + pickSkeletons(4) + "</div></section>" + 637 '<p class="es-disclose">Affiliate link. Giftella may earn a commission on purchases, at no extra cost to you. Prices and availability are set by ' + esc(s.name) + ".</p>"; 638 openSheet({ 639 title: "Partner store", sub: s.category + " · " + region.flag + " " + region.long, size: "wide", html, 640 onMount(body) { 641 wireImages(body); 642 body.querySelector("[data-cta]").addEventListener("click", () => { logEvent("ecosystem_click", { id: s.id }); }); 643 loadProducts(s.id).then((prods) => { if (seq !== openSeq || !body.isConnected) return; renderPicks(body, s, prods); }) 644 .catch(() => { if (seq === openSeq && body.isConnected) renderPicks(body, s, []); }); 645 }, 646 onClose() { if (seq === openSeq) openSeq++; } 647 }); 648} 649function pickSkeletons(n) { let out = ""; for (let i = 0; i < n; i++) out += '<div class="es-sk"><div class="sk sk-media"></div><div class="sk sk-line" style="width:78%"></div><div class="sk sk-line" style="width:45%"></div></div>'; return out; } 650function renderPicks(body, s, prods) { 651 const grid = qs("#es-grid", body); const sec = qs("#es-picks", body); if (!grid || !sec) return; 652 if (!prods.length) { 653 sec.innerHTML = '<div class="es-quiet">Curated picks from ' + esc(s.name) + ' are on their way. In the meantime, <a href="' + esc(goLink(s.id)) + '" target="_blank" rel="noopener sponsored" data-cta2>browse the full collection â</a>.</div>'; 654 const a = qs("[data-cta2]", sec); if (a) a.addEventListener("click", () => logEvent("ecosystem_click", { id: s.id, via: "picks-empty" })); 655 return; 656 } 657 grid.innerHTML = prods.map((p, i) => { 658 const price = p.price || (Number(p.priceValue) > 0 ? "$" + (Math.round(Number(p.priceValue) * 100) / 100).toLocaleStr
658ing(undefined, { maximumFractionDigits: 2 }) : ""); 659 return '<a class="ga-card hover es-prod ga-in" style="--i:' + (i % 12) + '" href="' + esc(productLink(s.id, p)) + '" target="_blank" rel="noopener sponsored" data-p="' + esc(p.title || "") + '">' + 660 '<div class="pm"><span class="fb">' + esc(p.emoji || "ð") + "</span>" + imgTag(isHttp(p.imageURL) ? p.imageURL : "", p.title || "") + "</div>" + 661 '<div class="pb"><div class="pt">' + esc(p.title || "Gift pick") + '</div><div class="pp"><span>' + esc(price) + '</span><span class="arr">Shop â</span></div></div></a>'; 662 }).join(""); 663 wireImages(grid); 664 qsa("a[data-p]", grid).forEach((a) => a.addEventListener("click", () => logEvent("ecosystem_click", { id: s.id, product: a.dataset.p }))); 665}
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.