(() => { const MAIN_SITE = 'https://klyvosystems.com'; const STORE = window.location.origin; function initKotivaLayout() { if (document.getElementById('kotiva-salla-header')) return; /* ===================================================== HEADER ===================================================== */ const header = `
`; /* ===================================================== MOBILE MENU ===================================================== */ const mobileMenu = ` `; /* ===================================================== FOOTER ===================================================== */ const footer = ` `; /* ADD TO PAGE */ document.body.insertAdjacentHTML( 'afterbegin', header + mobileMenu ); document.body.insertAdjacentHTML( 'beforeend', footer ); document.body.classList.add( 'kotiva-salla-ready' ); /* ===================================================== MOBILE MENU LOGIC ===================================================== */ const button = document.getElementById('kotiva-menu-button'); const menu = document.getElementById('kotiva-mobile-menu'); button?.addEventListener('click', () => { const open = menu.classList.toggle('is-open'); button.setAttribute( 'aria-expanded', String(open) ); document.body.classList.toggle( 'kotiva-menu-open', open ); }); menu?.querySelectorAll('a').forEach(link => { link.addEventListener('click', () => { menu.classList.remove('is-open'); document.body.classList.remove( 'kotiva-menu-open' ); button?.setAttribute( 'aria-expanded', 'false' ); }); }); } /* Run safely after DOM loads */ if (document.readyState === 'loading') { document.addEventListener( 'DOMContentLoaded', initKotivaLayout ); } else { initKotivaLayout(); } })(); /* ========================================================= KOTIVA SALLA — LISTING + PDP V6 Direct storefront API stability rewrite - Listing uses ONE Salla products-list forced to source=latest, limit=25 - No native pagination racing / no DOM-page accumulation - Client-side 15 + 10 Load More, filters, and sort - Product URLs come from card.product.url first - PDP product id supports Salla /p123 URL format - Native PDP sliders are removed; one same-category slider only - Empty/failed similar-products section is removed completely ========================================================= */ (() => { const ROOT_PATH = '/'; const EXPECTED_PRODUCTS = 25; const INITIAL_VISIBLE = 15; const LOAD_MORE_STEP = 10; const MAIN_SITE = 'https://klyvosystems.com'; const PRODUCTS = [ { name:'micellar water', filter:'cleanser', category:'Cleanser', relatedCategory:'Face Care', skin:'All Skin Types', concern:'Makeup Removal', featured:true, slug:'micellar-water', image:'assets/products/kot001-micellar-water.webp' }, { name:'acne cleansing gel', filter:'cleanser', category:'Cleanser', relatedCategory:'Face Care', skin:'Oily / Acne-Prone', concern:'Acne & Breakouts', featured:true, slug:'acne-cleansing-gel', image:'assets/products/kot002-acne-cleansing-gel.webp' }, { name:'hyaluronic facial cleanser', filter:'cleanser', category:'Cleanser', relatedCategory:'Face Care', skin:'All Skin Types', concern:'Hydration', featured:false, slug:'hyaluronic-facial-cleanser', image:'assets/products/kot003-hyaluronic-facial-cleanser.webp' }, { name:'facial foam for sensitive skin', filter:'cleanser', category:'Cleanser', relatedCategory:'Face Care', skin:'Sensitive Skin', concern:'Soothing & Calming', featured:false, slug:'facial-foam-sensitive', image:'assets/products/kot004-facial-foam-sensitive.webp' }, { name:'glow up water essence toner', filter:'toner', category:'Toner', relatedCategory:'Face Care', skin:'All Skin Types', concern:'Glow & Radiance', featured:true, slug:'glow-up-water-essence-toner', image:'assets/products/kot005-glow-up-water-essence-toner.webp' }, { name:'oil control foam', filter:'cleanser', category:'Cleanser', relatedCategory:'Face Care', skin:'Oily Skin', concern:'Oil Control', featured:false, slug:'oil-control-foam', image:'assets/products/kot006-oil-control-foam.webp' }, { name:'face & body cleansing foam', filter:'cleanser', category:'Cleanser', relatedCategory:'Face Care', skin:'All Skin Types', concern:'Daily Cleansing', featured:false, slug:'face-body-cleansing-foam', image:'assets/products/kot007-face-body-cleansing-foam.webp' }, { name:'post fillers lip balm', filter:'lip-care', category:'Lip Care', relatedCategory:'Face Care', skin:'All Skin Types', concern:'Lip Care', featured:false, slug:'post-fillers-lip-balm', image:'assets/products/kot008-post-fillers-lip-balm.webp' }, { name:'sun protection spf 50+', filter:'spf', category:'SPF', relatedCategory:'SPF', skin:'All Skin Types', concern:'Sun Protection', featured:true, slug:'sun-protection-spf50', image:'assets/products/kot009-sun-protection-spf50.webp' }, { name:'after sun cream', filter:'body', category:'Body', relatedCategory:'Body Care', skin:'All Skin Types', concern:'After-Sun Soothing', featured:false, slug:'after-sun-cream', image:'assets/products/kot010-after-sun-cream.webp' }, { name:'younger hand cream', filter:'hand-care', category:'Hand Care', relatedCategory:'Body Care', skin:'All Skin Types', concern:'Anti-Aging', featured:false, slug:'younger-hand-cream', image:'assets/products/kot011-younger-hand-cream.webp' }, { name:'whitening hand cream', filter:'hand-care', category:'Hand Care', relatedCategory:'Body Care', skin:'All Skin Types', concern:'Brightening', featured:false, slug:'whitening-hand-cream', image:'assets/products/kot012-whitening-hand-cream.webp' }, { name:'anti-perspirant roll on', filter:'body', category:'Body', relatedCategory:'Body Care', skin:'All Skin Types', concern:'Anti-Perspirant', featured:false, slug:'anti-perspirant-roll-on', image:'assets/products/kot013-anti-perspirant-roll-on.webp' }, { name:'whitening anti-perspirant', filter:'body', category:'Body', relatedCategory:'Body Care', skin:'All Skin Types', concern:'Brightening', featured:false, slug:'whitening-anti-perspirant', image:'assets/products/kot014-whitening-anti-perspirant.webp' }, { name:'hydroshield post-laser cream', filter:'treatment', category:'Treatment', relatedCategory:'Treatments', skin:'Post-Procedure', concern:'Barrier Repair', featured:false, slug:'hydroshield-post-laser-cream', image:'assets/products/kot015-hydroshield-post-laser-cream.webp' }, { name:'triple action whitening day cream', filter:'treatment', category:'Treatment', relatedCategory:'Treatments', skin:'All Skin Types', concern:'Brightening', featured:true, slug:'whitening-day-cream-spf50', image:'assets/products/kot016-whitening-day-cream-spf50.webp' }, { name:'triple action whitening night cream', filter:'treatment', category:'Treatment', relatedCategory:'Treatments', skin:'All Skin Types', concern:'Brightening', featured:false, slug:'whitening-night-cream', image:'assets/products/kot017-whitening-night-cream.webp' }, { name:'triple action bikini area whitening cream', filter:'treatment', category:'Treatment', relatedCategory:'Treatments', skin:'Sensitive Area', concern:'Brightening', featured:false, slug:'bikini-area-whitening-cream', image:'assets/products/kot018-bikini-area-whitening-cream.webp' }, { name:'triple action whitening wash gel for sensitive area', filter:'cleanser', category:'Cleanser', relatedCategory:'Body Care', skin:'Sensitive Area', concern:'Brightening', featured:false, slug:'whitening-wash-gel-sensitive', image:'assets/products/kot019-whitening-wash-gel-sensitive.webp' }, { name:'acne control', filter:'treatment', category:'Treatment', relatedCategory:'Treatments', skin:'Oily / Acne-Prone', concern:'Acne & Breakouts', featured:true, slug:'acne-control', image:'assets/products/kot020-acne-control.webp' }, { name:'pores off serum', filter:'serums', category:'Serums', relatedCategory:'Serums', skin:'Oily / Combination', concern:'Pores & Texture', featured:false, slug:'pores-off-serum', image:'assets/products/kot021-pores-off-serum.webp' }, { name:'uv balance spf 50+', filter:'spf', category:'SPF', relatedCategory:'SPF', skin:'Oily / Acne-Prone', concern:'Sun Protection', featured:true, slug:'uv-balance-spf50', image:'assets/products/kot022-uv-balance-spf50.webp' }, { name:'anti-hair loss ampoules', filter:'hair', category:'Hair', relatedCategory:'Hair', skin:'All Hair Types', concern:'Hair Loss', featured:false, slug:'anti-hair-loss-ampoules', image:'assets/products/kot023-anti-hair-loss-ampoules.webp' }, { name:'anti-hair loss shampoo', filter:'hair', category:'Hair', relatedCategory:'Hair', skin:'All Hair Types', concern:'Hair Loss', featured:false, slug:'anti-hair-loss-shampoo', image:'assets/products/kot024-anti-hair-loss-shampoo.webp' }, { name:'siliscar gel', filter:'treatment', category:'Treatment', relatedCategory:'Treatments', skin:'Scar-Prone', concern:'Scars & Barrier', featured:false, slug:'siliscar-gel', image:'assets/products/kot025-siliscar-gel.webp' } ].map((item, index) => ({ ...item, order:index + 1 })); const FILTERS = [ ['all','All'], ['cleanser','Cleanser'], ['toner','Toner'], ['lip-care','Lip Care'], ['spf','SPF'], ['body','Body'], ['hand-care','Hand Care'], ['treatment','Treatment'], ['serums','Serums'], ['hair','Hair'] ]; const normalize = (value) => String(value || '') .toLowerCase().replace(/^kotiva\s+/i, '').replace(/\s+/g, ' ').trim(); const metaMap = new Map(PRODUCTS.map((item) => [normalize(item.name), item])); const cleanPath = () => location.pathname.replace(/\/+$/, '') || '/'; function isProductPage() { return /(?:-|\/)p\d+(?=\/|$)/i.test(location.pathname) || Boolean(document.querySelector( '[data-testid="store-product-title"],[data-testid="store-product-form"],[data-testid="store-product-image-slider"]' )); } function getProductData(card) { if (!card) return null; let value = card.product; if (!value) value = card.getAttribute?.('product'); if (typeof value === 'string') { try { value = JSON.parse(value); } catch (_) { value = null; } } return value && typeof value === 'object' ? value : null; } function getTitleElement(card) { return card?.querySelector?.('.s-product-card-content-title') || null; } function getCardTitleLink(card) { return getTitleElement(card)?.querySelector('a') || null; } function isSallaProductHref(href) { return /(?:-|\/)p\d+(?=\/|$|[?#])/i.test(String(href || '')); } function getCardHref(card) { const product = getProductData(card); if (product?.url) return String(product.url); const titleLink = getCardTitleLink(card); if (titleLink?.href) return titleLink.href; const anyProductLink = Array.from(card?.querySelectorAll?.('a[href]') || []) .find((a) => isSallaProductHref(a.getAttribute('href') || a.href)); return anyProductLink?.href || ''; } function getCardName(card) { const product = getProductData(card); if (product?.name) return normalize(product.name); const title = getTitleElement(card); return normalize(title?.textContent || getCardTitleLink(card)?.textContent); } function getMeta(cardOrName) { const name = typeof cardOrName === 'string' ? normalize(cardOrName) : getCardName(cardOrName); return metaMap.get(name) || null; } function getPrice(card) { const product = getProductData(card); const raw = product?.sale_price || product?.price || product?.regular_price; if (Number.isFinite(Number(raw))) return Number(raw); const el = card.querySelector('.s-product-card-price,.s-product-card-sale-price h4'); const match = (el?.textContent || '').replace(/,/g, '').match(/[\d.]+/); return match ? Number(match[0]) : 0; } function styleNativeAddButton(host, mode='outline') { if (!host) return; host.setAttribute('width','wide'); if (mode === 'outline') host.setAttribute('fill','outline'); host.classList.add('kv-add-btn', mode === 'solid' ? 'kv-add-btn-solid' : 'kv-add-btn-outline'); const apply = () => { if (!host.shadowRoot || host.shadowRoot.getElementById('kv-native-button-style')) return; const style = document.createElement('style'); style.id = 'kv-native-button-style'; style.textContent = ` button,.s-button-element{width:100%!important;min-height:44px!important;border-radius:999px!important; border:1px solid rgba(35,31,32,.32)!important;background:${mode === 'solid' ? '#231F20' : 'transparent'}!important; color:${mode === 'solid' ? '#fff' : '#231F20'}!important;box-shadow:none!important;font-size:10px!important; line-height:1!important;font-weight:700!important;letter-spacing:.12em!important;text-transform:uppercase!important} button:hover,.s-button-element:hover{background:#231F20!important;border-color:#231F20!important;color:#fff!important} .s-add-product-button-quick-buy,[class*="quick-buy"],salla-quick-buy{display:none!important}`; host.shadowRoot.appendChild(style); }; apply(); setTimeout(apply,250); setTimeout(apply,800); } /* -------------------- V6 CATALOG + LISTING -------------------- */ const CATALOG_CACHE_KEY = 'kotiva:salla:catalog:v6'; let catalogPromise = null; let catalogProducts = []; let activeFilter = 'all'; let activeSort = 'featured'; let visibleLimit = INITIAL_VISIBLE; let listingReady = false; let listingPreparing = false; function createControls(products = []) { const counts = Object.fromEntries(FILTERS.map(([key]) => [key, 0])); products.forEach((product) => { const meta = getMeta(product?.name || ''); if (meta && counts[meta.filter] !== undefined) counts[meta.filter] += 1; }); counts.all = products.length || PRODUCTS.length; const filters = FILTERS.map(([key, label]) => `` ).join(''); return `
${filters}
Sort
`; } function findMasterProductList() { return document.querySelector('salla-products-list') || document.body; } function unwrapProducts(value, output = [], seen = new Set(), depth = 0) { if (depth > 8 || value == null) return output; if (typeof value !== 'object') return output; if (seen.has(value)) return output; seen.add(value); if (Array.isArray(value)) { const productLike = value.filter((item) => item && typeof item === 'object' && item.id && item.name && ( item.price != null || item.sale_price != null || item.regular_price != null || item.thumbnail || item.image || item.urls?.customer || item.url )); if (productLike.length) output.push(...productLike); value.forEach((item) => unwrapProducts(item, output, seen, depth + 1)); return output; } Object.values(value).forEach((child) => unwrapProducts(child, output, seen, depth + 1)); return output; } function uniqueProducts(products) { const map = new Map(); products.forEach((product) => { const id = Number(product?.id || 0); const name = normalize(product?.name || ''); const key = id ? `id:${id}` : `name:${name}`; if (!name || map.has(key)) return; map.set(key, product); }); return Array.from(map.values()); } function productHref(product) { return String(product?.url || product?.urls?.customer || product?.links?.customer || ''); } function productImage(product) { return String(product?.image?.url || product?.image || product?.thumbnail || product?.images?.[0]?.url || product?.images?.[0] || ''); } function moneyValue(value) { if (value == null) return 0; if (typeof value === 'number') return value; if (typeof value === 'string') return Number(value.replace(/[^0-9.]/g, '')) || 0; if (typeof value === 'object') return Number(value.amount ?? value.value ?? value.price ?? 0) || 0; return 0; } function productPrice(product) { return moneyValue(product?.sale_price) || moneyValue(product?.price) || moneyValue(product?.regular_price) || moneyValue(product?.starting_price); } async function waitForSallaProductApi(timeout = 8000) { const started = Date.now(); while (Date.now() - started < timeout) { if (window.salla?.product && typeof window.salla.product.fetch === 'function') return true; await new Promise((resolve) => setTimeout(resolve, 100)); } return false; } async function callProductFetch(payload) { try { const response = await window.salla.product.fetch(payload); return uniqueProducts(unwrapProducts(response)); } catch (error) { console.warn('KOTIVA V6 product.fetch failed', payload, error); return []; } } async function fetchCatalogFresh() { const ready = await waitForSallaProductApi(); if (!ready) return []; // First use the documented storefront API directly. Unlike the web component, // this is not affected by Enaya's homepage section limit or render timing. let products = await callProductFetch({ source: 'latest', limit: EXPECTED_PRODUCTS }); // Defensive fallbacks for stores where the backend caps the first page. if (products.length < EXPECTED_PRODUCTS) { const bigger = await callProductFetch({ source: 'latest', limit: 60 }); products = uniqueProducts([...products, ...bigger]); } if (products.length < EXPECTED_PRODUCTS) { for (let page = 1; page <= 4 && products.length < EXPECTED_PRODUCTS; page += 1) { const batch = await callProductFetch({ source: 'latest', limit: 15, page }); products = uniqueProducts([...products, ...batch]); } } // Keep only our KOTIVA catalog. If Salla still paginates/caps `latest`, // fill any missing products via the documented `search` source. This // removes the last dependency on homepage pagination limits. let matched = uniqueProducts(products.filter((product) => getMeta(product?.name || ''))); if (matched.length < EXPECTED_PRODUCTS) { const have = new Set(matched.map((product) => normalize(product?.name))); const missing = PRODUCTS.filter((meta) => !have.has(normalize(meta.name))); for (let start = 0; start < missing.length; start += 5) { const batch = missing.slice(start, start + 5); const results = await Promise.all(batch.map(async (meta) => { const found = await callProductFetch({ source: 'search', source_value: meta.name, limit: 5 }); return found.find((product) => normalize(product?.name) === normalize(meta.name)) || found.find((product) => normalize(product?.name).includes(normalize(meta.name))) || null; })); matched = uniqueProducts([...matched, ...results.filter(Boolean)]); } } matched.sort((a, b) => (getMeta(a.name)?.order || 999) - (getMeta(b.name)?.order || 999)); if (matched.length) { try { sessionStorage.setItem(CATALOG_CACHE_KEY, JSON.stringify(matched)); } catch (_) {} } return matched; } async function getCatalog(force = false) { if (!force && catalogProducts.length >= EXPECTED_PRODUCTS) return catalogProducts; if (!force) { try { const cached = JSON.parse(sessionStorage.getItem(CATALOG_CACHE_KEY) || '[]'); if (Array.isArray(cached) && cached.length >= EXPECTED_PRODUCTS) { catalogProducts = cached; return catalogProducts; } } catch (_) {} } if (!catalogPromise || force) { catalogPromise = fetchCatalogFresh().then((items) => { catalogProducts = items; return items; }).finally(() => { catalogPromise = null; }); } return catalogPromise; } function catalogCardHtml(product, { compact = false } = {}) { const meta = getMeta(product?.name || ''); if (!meta) return ''; const href = productHref(product); const localImage = `${MAIN_SITE}/${meta.image}`; const remoteImage = productImage(product); const src = localImage || remoteImage; const price = productPrice(product); const status = product?.status || (product?.is_out_of_stock ? 'out' : 'sale'); const type = product?.type || 'product'; const id = Number(product?.id || 0); const title = String(product?.name || meta.name).replace(/^Kotiva\s+/i, ''); return `
Kotiva ${title}
${title}
${meta.category} · ${meta.skin}
SAR ${price.toFixed(2)} EXCL. VAT
${meta.concern}Discover →
${id ? `Add to cart` : ''}
`; } function sortCatalog(items) { const result = [...items]; if (activeSort === 'price-asc') return result.sort((a,b) => productPrice(a) - productPrice(b)); if (activeSort === 'price-desc') return result.sort((a,b) => productPrice(b) - productPrice(a)); if (activeSort === 'name') return result.sort((a,b) => normalize(a.name).localeCompare(normalize(b.name))); if (activeSort === 'newest') return result.sort((a,b) => (getMeta(b.name)?.order || 0) - (getMeta(a.name)?.order || 0)); return result.sort((a,b) => { const ma = getMeta(a.name), mb = getMeta(b.name); const featured = Number(mb?.featured || false) - Number(ma?.featured || false); return featured || (ma?.order || 999) - (mb?.order || 999); }); } function hideNativeListingSections() { document.querySelectorAll('salla-products-list').forEach((list) => { if (list.closest('#kv-similar-products-v6')) return; const section = list.closest('section') || list.parentElement; if (section) section.classList.add('kv-v6-native-list-hidden'); else list.classList.add('kv-v6-native-list-hidden'); }); } function createHero(title = 'The Collection', description = '25 products formulated with clinically proven ingredients') { return `

Doctor-Approved Skincare

${title}

${description}

`; } function ensureListingShell(products, isRoutine = false) { document.querySelectorAll('#kv-shop-hero,#kv-shop-controls,#kv-v6-catalog').forEach((node) => node.remove()); const footer = document.getElementById('kotiva-salla-footer'); const anchor = footer || document.querySelector('footer') || document.body.lastElementChild; const wrapper = document.createElement('div'); wrapper.id = 'kv-v6-catalog-shell'; const hero = isRoutine ? createHero('Your Routine', `${products.length} products chosen for you`) : createHero(); const controls = isRoutine ? '' : createControls(products); const routineLink = isRoutine ? `View full range →` : ''; wrapper.innerHTML = `${hero}${controls}
${routineLink}
`; if (anchor?.parentElement) anchor.parentElement.insertBefore(wrapper, anchor); else document.body.appendChild(wrapper); } function renderListing() { const grid = document.querySelector('#kv-v6-catalog .kv-v6-grid'); const empty = document.querySelector('#kv-v6-catalog .kv-v6-empty'); const loadMore = document.getElementById('kv-v6-load-more'); if (!grid || !empty || !loadMore) return; const filtered = catalogProducts.filter((product) => { const meta = getMeta(product?.name || ''); return meta && (activeFilter === 'all' || meta.filter === activeFilter); }); const ordered = sortCatalog(filtered); const visible = ordered.slice(0, visibleLimit); grid.innerHTML = visible.map((product) => catalogCardHtml(product)).join(''); empty.hidden = visible.length > 0; loadMore.hidden = visibleLimit >= ordered.length; loadMore.textContent = `Load more (${ordered.length - visibleLimit > 0 ? ordered.length - visibleLimit : 0})`; grid.querySelectorAll('salla-add-product-button').forEach((button) => styleNativeAddButton(button)); } function bindListingEventsOnce() { if (document.body.dataset.kvV6ListingBound === '1') return; document.body.dataset.kvV6ListingBound = '1'; document.addEventListener('click', (event) => { const filter = event.target.closest?.('[data-kv-filter]'); if (filter) { event.preventDefault(); activeFilter = filter.dataset.kvFilter || 'all'; visibleLimit = INITIAL_VISIBLE; document.querySelectorAll('[data-kv-filter]').forEach((node) => node.classList.toggle('active', node === filter)); renderListing(); return; } const sort = event.target.closest?.('[data-kv-sort]'); if (sort) { event.preventDefault(); activeSort = sort.dataset.kvSort || 'featured'; document.querySelectorAll('[data-kv-sort]').forEach((node) => node.classList.toggle('active', node === sort)); renderListing(); return; } const more = event.target.closest?.('#kv-v6-load-more'); if (more) { event.preventDefault(); visibleLimit += LOAD_MORE_STEP; renderListing(); return; } const card = event.target.closest?.('.kv-v6-card'); if (card && !event.target.closest?.('a,button,salla-add-product-button,input,select,label')) { const href = card.dataset.href; if (href) window.location.assign(href); } }); } async function initListing() { if (cleanPath() !== ROOT_PATH || isProductPage() || listingPreparing) return; listingPreparing = true; document.body.classList.add('kv-catalog-loading'); bindListingEventsOnce(); const products = await getCatalog(true); if (!products.length) { console.error('KOTIVA V6: storefront catalog fetch returned no products; native listing left visible.'); document.body.classList.remove('kv-catalog-loading'); listingPreparing = false; return; } catalogProducts = products; const routineSkus = (new URLSearchParams(location.search).get('routine') || '') .split(',').map((s) => s.trim().toUpperCase()).filter(Boolean); if (routineSkus.length) { const wanted = new Set(routineSkus.map((s) => Number(s.replace(/^KOT0*/i, '')))); catalogProducts = products.filter((p) => wanted.has(getMeta(p?.name || '')?.order)); activeFilter = 'all'; } hideNativeListingSections(); ensureListingShell(catalogProducts, routineSkus.length > 0); renderListing(); listingReady = true; listingPreparing = false; document.body.classList.remove('kv-catalog-loading'); document.body.classList.add('kv-listing-ready'); console.info(`KOTIVA V6 stable catalog: ${catalogProducts.length} products shown.`); } /* -------------------- PDP -------------------- */ function closestCommonAncestor(a,b) { if (!a || !b) return null; const set = new Set(); for (let n=a; n; n=n.parentElement) set.add(n); for (let n=b; n; n=n.parentElement) if (set.has(n)) return n; return null; } function findPdpParts() { const title = document.querySelector('[data-testid="store-product-title"]'); const form = document.querySelector('[data-testid="store-product-form"]'); const slider = document.querySelector('[data-testid="store-product-image-slider"],.details-slider'); const gallery = slider?.closest('.sidebar') || slider?.parentElement || null; let info = title?.closest('.main-content') || (title && form ? closestCommonAncestor(title,form) : null) || title?.parentElement || null; if (info === document.body) info = title?.parentElement || null; const row = gallery && info ? closestCommonAncestor(gallery,info) : null; return {title,form,slider,gallery,info,row}; } function findDescriptionBlock() { return document.querySelector('[data-testid="store-product-description"],.product__description'); } function findOriginalDescriptionWrapper(desc) { let node = desc?.parentElement, depth=0; while (node && node !== document.body && depth++ < 5) { const text = (node.textContent || '').toLowerCase(); if (text.includes('description') && text.includes('reviews')) return node; node = node.parentElement; } return desc?.parentElement || null; } function moveDescriptionIntoInfo(info,form) { if (!info || info.querySelector('.kv-pdp-editorial')) return; const desc = findDescriptionBlock(); if (!desc) return; const wrapper = findOriginalDescriptionWrapper(desc); const editorial = document.createElement('div'); editorial.className = 'kv-pdp-editorial'; const titleText = document.querySelector('[data-testid="store-product-title"]')?.textContent || ''; const parsed = parseDescription(desc); if (parsed && (parsed.intro || parsed.benefits.length || parsed.howTo)) { editorial.innerHTML = renderEditorial(parsed, getMeta(titleText)); desc.classList.add('kv-pdp-hide'); editorial.appendChild(desc); // keep original in DOM (hidden) for Salla scripts } else { editorial.appendChild(desc); } if (form) info.insertBefore(editorial,form); else info.appendChild(editorial); if (wrapper && wrapper !== desc && wrapper !== info && !wrapper.contains(info)) wrapper.classList.add('kv-pdp-description-origin-hidden'); } /* Parse Salla's flat description into the Laravel PDP structure: intro paragraph, spec grid, Key Benefits, How to use, Key ingredients. */ function parseDescription(desc) { const raw = (desc?.innerText || desc?.textContent || '').replace(/\r/g,'').replace(/[ \t]+/g,' ').trim(); if (!raw) return null; const labels = ['Volume','Skin type','Concern','Benefits','How to use','Key ingredients']; // 'Volume:' style fields use a colon; 'Benefits' / 'How to use' / 'Key ingredients' are headings on their own line. const re = new RegExp(`(?:^|\\n)\\s*(${labels.join('|')})\\s*:?\\s*(?=\\S|$)`, 'ig'); const hits = []; let m; while ((m = re.exec(raw))) hits.push({ label: m[1].toLowerCase(), index: m.index, end: m.index + m[0].length }); const out = { intro: raw.slice(0, hits.length ? hits[0].index : raw.length), volume:'', skin:'', concern:'', benefits:[], howTo:'', ingredients:[] }; const listOf = (t) => t.split(/\n+|•|·|(?<=[a-z\)])\s{2,}/).map((x) => x.replace(/^[-–•\s]+/,'').trim()).filter((x) => x.length > 2); hits.forEach((hit, i) => { const text = raw.slice(hit.end, hits[i+1] ? hits[i+1].index : raw.length).trim(); if (hit.label === 'volume') out.volume = text.split('\n')[0].trim(); else if (hit.label === 'skin type') out.skin = text.split('\n')[0].trim(); else if (hit.label === 'concern') out.concern = text.split('\n')[0].trim(); else if (hit.label === 'benefits') out.benefits = listOf(text); else if (hit.label === 'how to use') out.howTo = text.replace(/\s+/g,' ').trim(); else if (hit.label === 'key ingredients') out.ingredients = listOf(text); }); out.intro = out.intro.replace(/\s+/g,' ').trim(); return out; } function renderEditorial(parsed, meta) { const esc = (v) => String(v || '').replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])); const spec = [ ['Format', meta?.name ? meta.name.replace(/^kotiva\s+/i,'') : ''], ['Use on', meta?.relatedCategory === 'Hair' ? 'Hair' : meta?.relatedCategory === 'Body Care' ? 'Body' : 'Face'], ['Skin type', parsed.skin || meta?.skin || ''], ['Targets', parsed.concern || meta?.concern || ''], ['Size', parsed.volume || ''] ].filter(([,v]) => v); return ` ${parsed.intro ? `

${esc(parsed.intro)}

` : ''}
${spec.map(([k,v]) => `
${esc(k)}${esc(v)}
`).join('')}
${parsed.benefits.length ? `

Key benefits

` : ''} ${parsed.ingredients.length ? `

Key ingredients

${parsed.ingredients.map((i) => `${esc(i)}`).join('')}
` : ''} ${parsed.howTo ? `

How to use

${esc(parsed.howTo)}

` : ''}`; } function extractDescriptionField(label) { const desc = findDescriptionBlock(); const text = (desc?.innerText || desc?.textContent || '').replace(/\s+/g,' '); const match = text.match(new RegExp(`${label}\\s*:\\s*([^|•\\n]+?)(?=\\s+(?:Volume|Skin type|Concern|Benefits|How to use|Key ingredients)\\s*:|$)`,'i')); return match ? match[1].trim() : ''; } const PRICE_ONLY = /^\s*(?:SAR|ر\.س|﷼)?\s*\d[\d.,]*\s*(?:SAR|ر\.س|﷼)?\s*$/i; function hideNativePriceDisplays(info) { if (!info) return; info.querySelectorAll('*').forEach((el) => { if (el.closest('.kv-pdp-price-block,.kv-pdp-buy-row,.kv-pdp-editorial,salla-installment,[class*="tabby"],[id*="tabby"]')) return; if (el.children.length > 3) return; const text = (el.textContent || '').trim(); if (!text) return; if (PRICE_ONLY.test(text) || /^(price|السعر)$/i.test(text)) { // climb to the smallest wrapper that only contains this price/label let target = el; while (target.parentElement && target.parentElement !== info && (target.parentElement.textContent || '').trim() === text) target = target.parentElement; target.classList.add('kv-pdp-hide'); } }); } function hideStrayDescriptions(info) { document.querySelectorAll('[data-testid="store-product-description"],.product__description,.product-description,.s-product-description,[data-testid="store-product-short-description"]').forEach((el) => { if (el.closest('.kv-pdp-editorial')) return; el.classList.add('kv-pdp-hide'); }); if (!info) return; // Enaya sometimes prints the description again as a short excerpt near the price. info.querySelectorAll('p,div,span').forEach((el) => { if (el.closest('.kv-pdp-editorial,.kv-pdp-price-block,.kv-pdp-buy-row')) return; const text = (el.textContent || '').replace(/\s+/g,' ').trim(); if (text.length > 80 && /key ingredients|how to use|skin type/i.test(text) && el.children.length <= 2) el.classList.add('kv-pdp-hide'); }); } function collapseEmptyPdpSections() { const row = document.querySelector('.kv-pdp-main-row'); const end = document.getElementById('kv-similar-products-v6') || document.getElementById('kotiva-salla-footer'); if (!row) return; // Walk every ancestor level between the product row and the related section / footer // and hide siblings that render no visible content (these are the grey bands). let node = row; while (node && node !== document.body) { let sib = node.nextElementSibling; while (sib) { if (sib === end || sib.contains(end) || sib.id === 'kotiva-salla-footer' || sib.tagName === 'SCRIPT' || sib.tagName === 'STYLE') break; const text = (sib.innerText || sib.textContent || '').trim(); const hasMedia = sib.querySelector('img,video,iframe,salla-products-slider:not(.kv-native-pdp-slider-hidden)'); if (!text && !hasMedia) sib.classList.add('kv-pdp-empty-section'); sib = sib.nextElementSibling; } node = node.parentElement; } } function readPdpPrice(priceEl) { // 1) Salla exposes the product on the price component / page config const cfg = window.salla?.config?.get?.('product') || window.salla?.config?.get?.('page.product'); const fromCfg = moneyValue(cfg?.sale_price) || moneyValue(cfg?.price); if (fromCfg) return fromCfg; // 2) attributes const host = priceEl?.closest?.('salla-product-price') || priceEl?.querySelector?.('salla-product-price') || priceEl; const attr = Number(host?.getAttribute?.('sale-price')) || Number(host?.getAttribute?.('price')); if (attr) return attr; // 3) DOM text: take the LARGEST non-zero number (Salla prints "0 150 150" / "old new") const nums = (priceEl?.textContent || '').replace(/,/g,'').match(/\d+(?:\.\d+)?/g) || []; const vals = nums.map(Number).filter((n) => n > 0); return vals.length ? Math.max(...vals) : null; } function injectPdpEditorialMeta(info,title) { if (!info || !title) return; const meta = getMeta(title.textContent || ''); if (!meta) return; title.textContent = (title.textContent || '').trim(); if (!info.querySelector('.kv-pdp-meta-head')) { const head = document.createElement('div'); head.className = 'kv-pdp-meta-head'; head.innerHTML = `
Products→${meta.category}
${meta.skin}
`; title.parentElement?.insertBefore(head,title); } if (!info.querySelector('.kv-pdp-category-line')) { const volume = extractDescriptionField('Volume'); const line = document.createElement('div'); line.className = 'kv-pdp-category-line'; line.textContent = volume ? `${meta.category} · ${volume}` : meta.category; title.insertAdjacentElement('afterend',line); } const price = info.querySelector('[data-testid="store-product-price"],.s-product-price,salla-product-price,.product-price'); if (price && !info.querySelector('.kv-pdp-price-block')) { const base = readPdpPrice(price); const block = document.createElement('div'); block.className = 'kv-pdp-price-block'; block.innerHTML = base ? `
SAR ${base.toFixed(2)} EXCL. VAT
SAR ${(base*1.15).toFixed(2)} including 15% VAT` : `
${(price.textContent||'').trim()} EXCL. VAT
`; price.insertAdjacentElement('beforebegin', block); } hideNativePriceDisplays(info); if (!info.querySelector('.kv-pdp-stock-concern')) { const block = document.createElement('div'); block.className = 'kv-pdp-stock-concern'; block.innerHTML = ` IN STOCK${meta.concern}`; const editorial = info.querySelector('.kv-pdp-editorial'); if (editorial) info.insertBefore(block,editorial); else title.insertAdjacentElement('afterend',block); } } function enforceBuyRow(info) { const row = info?.querySelector('.kv-pdp-buy-row'); if (!row) return; const imp = (el, props) => { if (el) Object.entries(props).forEach(([k, v]) => el.style.setProperty(k, v, 'important')); }; const H = '52px'; const allNodes = (root) => { const out = []; const walk = (node) => { out.push(node); if (node.shadowRoot) Array.from(node.shadowRoot.children).forEach(walk); Array.from(node.children || []).forEach(walk); }; walk(root); return out; }; imp(row, { display: 'flex', 'align-items': 'center', gap: '12px', width: '100%' }); /* ---- stepper: − 1 + , all 52px, centred ---- */ const qty = row.querySelector('salla-quantity-input,[data-testid="store-product-quantity"],.s-quantity-input'); if (qty) { imp(qty, { display: 'inline-flex', 'flex-direction': 'row-reverse', 'align-items': 'center', height: H, 'min-height': H, padding: '0', margin: '0', border: '1px solid rgba(35,31,32,.35)', 'border-radius': '999px', background: '#fff', overflow: 'hidden', direction: 'ltr' }); allNodes(qty).forEach((el) => { if (el === qty) return; const tag = el.tagName; if (tag === 'BUTTON' || el.getAttribute?.('role') === 'button') { imp(el, { display: 'flex', 'align-items': 'center', 'justify-content': 'center', width: '40px', height: H, 'min-height': '0', padding: '0', margin: '0', border: '0', background: 'transparent', color: '#231F20', 'line-height': '1' }); } else if (tag === 'INPUT') { imp(el, { display: 'block', width: '40px', height: H, padding: '0', margin: '0', border: '0', background: 'transparent', 'text-align': 'center', font: "700 13px/" + H + " 'Montserrat', Arial, sans-serif", color: '#231F20', '-moz-appearance': 'textfield' }); } else if (tag === 'DIV' || tag === 'SPAN') { imp(el, { display: 'flex', 'flex-direction': 'row-reverse', 'align-items': 'center', height: H, 'min-height': '0', padding: '0', margin: '0', border: '0', background: 'transparent', 'box-shadow': 'none' }); } }); } /* ---- add-to-cart: one black 52px pill, nothing else visible ---- */ const host = row.querySelector('[data-testid="store-product-add-to-cart"],salla-add-product-button'); if (host) { imp(host, { flex: '1 1 auto', display: 'flex', height: H, 'min-height': H, width: 'auto', margin: '0' }); let mainBtn = null; allNodes(host).forEach((el) => { if (el === host) return; const text = (el.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase(); const isBtn = el.tagName === 'BUTTON' || el.tagName === 'SALLA-BUTTON' || el.tagName === 'A'; if (isBtn && !mainBtn && /add to cart|أضف/.test(text)) { mainBtn = el; const pill = { display: 'flex', 'align-items': 'center', 'justify-content': 'center', width: '100%', height: H, 'min-height': H, padding: '0 28px', margin: '0', border: '0', 'border-radius': '999px', background: '#231F20', color: '#fff', font: "700 11px/1 'Montserrat', Arial, sans-serif", 'letter-spacing': '.12em', 'text-transform': 'uppercase', 'box-shadow': 'none', cursor: 'pointer' }; imp(el, pill); allNodes(el).forEach((inner) => { if (inner === el) return; const t = inner.tagName; if (t === 'BUTTON' || t === 'A' || t === 'SALLA-BUTTON') imp(inner, pill); else if (t === 'DIV' || t === 'SPAN') imp(inner, { display: 'flex', 'align-items': 'center', 'justify-content': 'center', width: '100%', height: H, 'min-height': '0', padding: '0', margin: '0', background: 'transparent', color: '#fff', border: '0' }); else if (t === 'SVG' || t === 'I') imp(inner, { display: 'none' }); }); return; } if (isBtn && mainBtn && el !== mainBtn && !el.contains(mainBtn) && !mainBtn.contains(el)) { imp(el, { display: 'none' }); return; } if (/^(buy now|buy with|اشتر الآن|شراء سريع)/.test(text) && el.children.length <= 3 && !el.contains(mainBtn)) { imp(el, { display: 'none' }); return; } if (!isBtn && (el.tagName === 'DIV' || el.tagName === 'SPAN') && el.contains(mainBtn)) { imp(el, { display: 'flex', width: '100%', height: H, 'min-height': '0', padding: '0', margin: '0', gap: '0' }); } }); } /* anything else that slipped into the row after the host */ Array.from(row.children).forEach((child) => { if (child !== qty && child !== host) imp(child, { display: 'none' }); }); } function simplifyPdpPurchaseArea(info) { if (!info) return; const form = info.querySelector('[data-testid="store-product-form"]'); const add = info.querySelector('[data-testid="store-product-add-to-cart"],salla-add-product-button'); const qty = info.querySelector('salla-quantity-input,[data-testid="store-product-quantity"],.s-quantity-input'); if (form) form.classList.add('kv-pdp-form'); // Hide Salla's native purchase controls; we render our own row below. [add, qty].forEach((el) => el && el.classList.add('kv-pdp-hide')); info.querySelectorAll('salla-quick-order,salla-apple-pay,salla-quick-buy,apple-pay-button,[data-testid="store-product-quick-buy"],.s-quick-order,.s-apple-pay,.s-add-product-button-quick-buy,[class*="quick-buy"],.quick-buy') .forEach((el) => el.classList.add('kv-pdp-hide')); hideNativePriceDisplays(info); if (!info.querySelector('.kv-buy-row')) { const anchor = form || add; if (!anchor) return; const productId = currentProductId(); anchor.insertAdjacentHTML('afterend', `
Build Your Routine
Cash on delivery available.
⚕Doctor Approved · KOTIVA StandardFormulated with clinically proven active ingredients
`); bindBuyRow(info.querySelector('.kv-buy-row')); } } function bindBuyRow(row) { if (!row || row.dataset.kvBound === '1') return; row.dataset.kvBound = '1'; const input = row.querySelector('.kv-qty-input'); const getQty = () => Math.max(1, parseInt(input.value, 10) || 1); const setQty = (n) => { input.value = String(Math.max(1, n)); }; input.addEventListener('change', () => setQty(getQty())); row.querySelectorAll('[data-kv-qty]').forEach((btn) => btn.addEventListener('click', () => setQty(getQty() + Number(btn.dataset.kvQty)))); const id = Number(row.dataset.productId || 0); const busy = (on) => row.querySelectorAll('.kv-btn').forEach((b) => { b.disabled = on; b.classList.toggle('is-busy', on); }); const addToCart = async () => { if (!id || !window.salla?.cart?.addItem) throw new Error('Salla cart API unavailable'); return window.salla.cart.addItem({ id, quantity: getQty() }); }; row.querySelector('[data-kv-action="add"]').addEventListener('click', async () => { busy(true); try { await addToCart(); } catch (e) { console.error('KOTIVA add to cart failed', e); window.salla?.notify?.error?.('Could not add to cart'); } finally { busy(false); } }); row.querySelector('[data-kv-action="buy"]').addEventListener('click', async () => { busy(true); try { await addToCart(); window.location.assign(`${window.location.origin}/cart`); } catch (e) { console.error('KOTIVA buy now failed', e); busy(false); } }); } function hidePdpExtras() { document.querySelectorAll('[data-testid="store-product-social-share"],[data-testid="store-product-wishlist-btn"],.product-sku-wrapper') .forEach((el) => el.classList.add('kv-pdp-hide')); document.querySelectorAll('div,section').forEach((el) => { const text = (el.textContent || '').trim(); if (text.includes('Secure payment') && text.includes('Fast shipping') && text.includes('Quality guarantee')) el.classList.add('kv-pdp-trust-strip'); }); } function currentProductId() { const pathMatch = location.pathname.match(/(?:-|\/)p(\d+)(?=\/|$)/i); const fromPath = Number(pathMatch?.[1] || 0); if (fromPath) return fromPath; const configValue = Number(window.salla?.config?.get?.('page.id') || 0); return configValue || null; } function hideAllNativeProductSliders() { document.querySelectorAll('salla-products-slider').forEach((slider) => { if (slider.closest('#kv-similar-products-v6')) return; const wrapper = slider.closest('section') || slider.closest('.container') || slider.parentElement || slider; wrapper.classList.add('kv-native-pdp-slider-hidden'); }); document.querySelectorAll('[data-testid="store-product-related"]').forEach((node) => { const wrapper = node.closest('section') || node.closest('.container') || node; wrapper.classList.add('kv-native-pdp-slider-hidden'); }); // Hide Enaya recommendation headings/wrappers that can remain after its slider is hidden. document.querySelectorAll('section,div').forEach((node) => { if (node.closest('#kv-similar-products-v6')) return; const text = (node.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase(); if ((text === 'complete your routine' || text === 'similar products' || text === 'you may also like') && node.querySelector('salla-products-slider')) { node.classList.add('kv-native-pdp-slider-hidden'); } }); } function removeExistingCustomSimilar() { document.getElementById('kv-similar-products-v6')?.remove(); document.getElementById('kv-similar-products')?.remove(); } async function buildSameCategorySimilarProducts() { hideAllNativeProductSliders(); removeExistingCustomSimilar(); const title = document.querySelector('[data-testid="store-product-title"]'); const currentMeta = getMeta(title?.textContent || ''); if (!currentMeta) return; const catalog = await getCatalog(false); if (!catalog.length) return; const currentId = currentProductId(); const currentName = normalize(title?.textContent || ''); const siblings = catalog.filter((product) => { const meta = getMeta(product?.name || ''); if (!meta || meta.relatedCategory !== currentMeta.relatedCategory) return false; if (currentId && Number(product?.id) === Number(currentId)) return false; if (normalize(product?.name) === currentName) return false; return true; }); // No siblings means absolutely no section and no reserved whitespace. if (!siblings.length) return; const section = document.createElement('section'); section.id = 'kv-similar-products-v6'; section.innerHTML = `

You may also need

Complete your
routine

View All →
${siblings.slice(0, 4).map((product) => catalogCardHtml(product)).join('')}
`; const footer = document.getElementById('kotiva-salla-footer') || document.querySelector('footer'); if (footer?.parentElement) footer.parentElement.insertBefore(section, footer); else document.body.appendChild(section); section.querySelectorAll('salla-add-product-button').forEach((button) => styleNativeAddButton(button)); } let pdpInitialized=false; let pdpObserver=null; function initPdp() { if (!isProductPage()) return false; const {title,form,gallery,info,row} = findPdpParts(); if (!title || !gallery || !info) return false; document.documentElement.classList.add('kv-pdp'); document.body.classList.add('kv-pdp-body'); document.querySelectorAll('#kv-shop-hero,#kv-shop-controls,#kv-empty-filter,#kv-load-more-v5').forEach((el) => el.remove()); gallery.classList.add('kv-pdp-gallery'); info.classList.add('kv-pdp-info'); row?.classList.add('kv-pdp-main-row'); moveDescriptionIntoInfo(info,form); injectPdpEditorialMeta(info,title); simplifyPdpPurchaseArea(info); hideStrayDescriptions(info); hidePdpExtras(); hideAllNativeProductSliders(); collapseEmptyPdpSections(); if (!pdpObserver) { let pending = null; pdpObserver = new MutationObserver(() => { if (pending) return; pending = setTimeout(() => { pending = null; simplifyPdpPurchaseArea(info); hideStrayDescriptions(info); hideNativePriceDisplays(info); hideAllNativeProductSliders(); collapseEmptyPdpSections(); }, 120); }); pdpObserver.observe(info, { childList:true, subtree:true }); } if (!pdpInitialized) { pdpInitialized=true; buildSameCategorySimilarProducts(); setTimeout(hideAllNativeProductSliders,900); setTimeout(hideAllNativeProductSliders,1800); } return true; } function start() { if (isProductPage()) { let tries=0; const timer=setInterval(() => { tries += 1; if (initPdp() || tries >= 20) clearInterval(timer); },150); return; } if (cleanPath() === ROOT_PATH) { let tries=0; const timer=setInterval(() => { tries += 1; const list=findMasterProductList(); if (list) { clearInterval(timer); initListing(); } else if (tries >= 30) clearInterval(timer); },150); } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded',start,{once:true}); else start(); window.addEventListener('load',() => { if (isProductPage()) initPdp(); else if (cleanPath() === ROOT_PATH && !listingReady) initListing(); },{once:true}); })();