/** * Geolocation-based product restrictions * Compatible with all Shopify plans (no checkout scripts required) * Version 3.0 - Fixes for Sold Out badges and metafield cart text */ // Prevent duplicate initialization if (!window.geoRestrictionsInitialized) { window.geoRestrictionsInitialized = true; class GeolocationRestrictions { constructor() { this.config = { enabled: window.geoRestrictionSettings?.enabled || false, allowedCountries: this.parseCountries(window.geoRestrictionSettings?.allowedCountries || 'US'), restrictedMessage: window.geoRestrictionSettings?.restrictedMessage || 'This product is not available in your region.', showContactLink: window.geoRestrictionSettings?.showContactLink || true, productTemplate: window.geoRestrictionSettings?.productTemplate || 'default', isCollectionPage: window.geoRestrictionSettings?.isCollectionPage || false, cloudflareCountry: window.geoRestrictionSettings?.cloudflareCountry || null }; this.userCountry = null; this.storageKey = 'shopify_user_country'; this.storageExpiry = 'shopify_country_expiry'; } parseCountries(countriesString) { return countriesString .toUpperCase() .split(',') .map(c => c.trim()) .filter(c => c.length === 2); } async init() { console.log('=== GEO RESTRICTIONS INIT ==='); console.log('Enabled:', this.config.enabled); console.log('Product Template:', this.config.productTemplate); console.log('Is Collection Page:', this.config.isCollectionPage); // Skip if restrictions not enabled if (!this.config.enabled) { console.log('Geolocation restrictions disabled'); this.removeLoadingClass(); return; } // Skip if Records template (always available worldwide) if (this.config.productTemplate === 'records') { console.log('Records template - no restrictions, showing all content'); this.removeLoadingClass(); this.showAllowedElements(); return; } // Priority 1: Check for Cloudflare Worker injected country (fastest) if (window.CLOUDFLARE_COUNTRY) { console.log('Using Cloudflare Worker country:', window.CLOUDFLARE_COUNTRY); this.userCountry = window.CLOUDFLARE_COUNTRY; this.setCachedCountry(this.userCountry); this.applyRestrictions(); return; } // Priority 2: Check Shopify's geolocation (via Markets or Geolocation app) if (window.Shopify?.country) { console.log('Using Shopify country:', window.Shopify.country); this.userCountry = window.Shopify.country; this.setCachedCountry(this.userCountry); this.applyRestrictions(); return; } // Priority 3: Check for cached country const cachedCountry = this.getCachedCountry(); if (cachedCountry) { this.userCountry = cachedCountry; this.applyRestrictions(); return; } // Priority 4: API fallback await this.detectCountry(); this.applyRestrictions(); } removeLoadingClass() { document.documentElement.classList.remove('geo-checking'); document.documentElement.classList.add('geo-checked'); } getCachedCountry() { try { const expiry = localStorage.getItem(this.storageExpiry); const country = localStorage.getItem(this.storageKey); if (expiry && country && Date.now() < parseInt(expiry)) { console.log(`Using cached country: ${country}`); return country; } } catch (e) { console.warn('localStorage not available:', e); } return null; } setCachedCountry(country) { try { const expiry = Date.now() + (24 * 60 * 60 * 1000); localStorage.setItem(this.storageKey, country); localStorage.setItem(this.storageExpiry, expiry.toString()); } catch (e) { console.warn('Could not cache country:', e); } } async detectCountry() { try { const response = await fetch('https://ipapi.co/json/', { method: 'GET', headers: { 'Accept': 'application/json' } }); if (!response.ok) throw new Error('Geolocation API failed'); const data = await response.json(); this.userCountry = data.country_code || 'US'; console.log(`Detected country: ${this.userCountry}`); this.setCachedCountry(this.userCountry); } catch (error) { console.warn('Country detection failed, defaulting to US:', error); this.userCountry = 'US'; } } isCountryAllowed() { return this.config.allowedCountries.includes(this.userCountry); } // Helper to collapse parent containers when child is hidden // VERY conservative - only collapse if parent is a simple wrapper collapseParentIfEmpty(el) { // Don't collapse parents - just ensure the hidden element doesn't leave a gap // This is safer than trying to climb the DOM tree el.style.margin = '0'; el.style.padding = '0'; el.style.height = '0'; el.style.minHeight = '0'; // Only check immediate parent, and only if it's a simple wrapper div const parent = el.parentElement; if (!parent) return; const tagName = parent.tagName.toLowerCase(); const className = (parent.className || '').toLowerCase(); // Never collapse these if (tagName !== 'div' && tagName !== 'span') return; if (className.includes('product')) return; if (className.includes('info')) return; if (className.includes('content')) return; if (className.includes('wrapper')) return; if (className.includes('container')) return; if (className.includes('form')) return; if (className.includes('main')) return; if (className.includes('section')) return; if (className.includes('block') && !className.includes('text-block') && !className.includes('group-block')) return; // Check if parent ONLY contains the hidden element const children = Array.from(parent.children); if (children.length === 1 && children[0] === el) { // Parent only contains this one hidden element - safe to collapse parent.style.display = 'none'; parent.style.margin = '0'; parent.style.padding = '0'; parent.setAttribute('data-geo-restricted', 'true'); console.log('Collapsed single-child parent:', parent.className || parent.tagName); } } // Remove "- Unavailable" text from variant selectors for international customers cleanVariantSelectors() { console.log('=== CLEANING VARIANT SELECTORS ==='); // Target Tinker theme's variant-picker specifically const selectors = [ 'variant-picker select option', '.variant-option__select option', 'select[name*="options"] option', 'select option' ]; selectors.forEach(selector => { document.querySelectorAll(selector).forEach(option => { const originalText = option.textContent; if (originalText.includes('Unavailable') || originalText.includes('unavailable') || originalText.includes('Sold out') || originalText.includes('sold out')) { option.setAttribute('data-original-text', originalText); option.textContent = originalText .replace(/ - Unavailable/gi, '') .replace(/ - unavailable/gi, '') .replace(/ - Sold out/gi, '') .replace(/ - sold out/gi, '') .replace(/ -Unavailable/gi, '') .replace(/ -Sold out/gi, '') .replace(/- Unavailable/gi, '') .replace(/- Sold out/gi, ''); console.log('Cleaned option:', originalText, '->', option.textContent); } }); }); // Also update the visible selected text if theme displays it separately document.querySelectorAll('variant-picker, .variant-picker, [class*="variant"]').forEach(picker => { // Some themes show selected value in a separate element picker.querySelectorAll('span, div').forEach(el => { if (el.children.length === 0) { const text = el.textContent; if (text && (text.includes('Unavailable') || text.includes('Sold out')) && text.length < 100) { el.setAttribute('data-original-text', text); el.textContent = text .replace(/ - Unavailable/gi, '') .replace(/ - Sold out/gi, '') .replace(/- Unavailable/gi, '') .replace(/- Sold out/gi, ''); console.log('Cleaned picker text:', text, '->', el.textContent); } } }); }); // Run again after delays to catch any dynamically loaded content setTimeout(() => this.cleanVariantSelectorsDelayed(), 100); setTimeout(() => this.cleanVariantSelectorsDelayed(), 500); setTimeout(() => this.cleanVariantSelectorsDelayed(), 1000); // Also set up a MutationObserver on variant-picker elements document.querySelectorAll('variant-picker').forEach(picker => { if (!picker.hasAttribute('data-geo-observed')) { picker.setAttribute('data-geo-observed', 'true'); const observer = new MutationObserver(() => { this.cleanVariantSelectorsDelayed(); }); observer.observe(picker, { childList: true, subtree: true, characterData: true, attributes: true }); } }); } cleanVariantSelectorsDelayed() { // Target all select options document.querySelectorAll('variant-picker select option, .variant-option__select option, select option').forEach(option => { const text = option.textContent; if (text.includes('Unavailable') || text.includes('Sold out')) { option.textContent = text .replace(/ - Unavailable/gi, '') .replace(/ - Sold out/gi, '') .replace(/- Unavailable/gi, '') .replace(/- Sold out/gi, ''); } }); // Clean any visible text with unavailable in variant areas document.querySelectorAll('variant-picker span, variant-picker div, .variant-option span').forEach(el => { if (el.children.length === 0 && el.textContent) { const text = el.textContent; if ((text.includes('Unavailable') || text.includes('Sold out')) && text.length < 100) { el.textContent = text .replace(/ - Unavailable/gi, '') .replace(/ - Sold out/gi, '') .replace(/- Unavailable/gi, '') .replace(/- Sold out/gi, ''); } } }); } // Helper to clean select options cleanSelectOptions(select) { select.querySelectorAll('option').forEach(option => { const text = option.textContent; if (text.includes(' - Unavailable') || text.includes(' - Sold out')) { option.textContent = text.replace(/ - Unavailable/gi, '').replace(/ - Sold out/gi, ''); } }); } // Watch for changes to variant selectors and clean up "Unavailable" text observeVariantChanges() { // Clean up function const cleanVariantText = () => { document.querySelectorAll('select option, [class*="variant"] span, .disclosure__button span, [data-selected-value]').forEach(el => { const text = el.textContent; if (text.includes(' - Unavailable') || text.includes(' — Unavailable')) { el.textContent = text.replace(/ [-—] Unavailable/gi, ''); } if (text.includes('- Sold out') || text.includes('— Sold out')) { el.textContent = text.replace(/[-—] Sold out/gi, ''); } }); }; // Run cleanup when variant selector changes document.querySelectorAll('select[name*="option"], select[id*="variant"], [class*="variant-select"]').forEach(select => { select.addEventListener('change', () => { setTimeout(cleanVariantText, 50); }); }); // Also observe DOM changes in case variant picker updates dynamically const variantContainer = document.querySelector('.product-form, [class*="variant"], product-info'); if (variantContainer) { const observer = new MutationObserver((mutations) => { mutations.forEach(mutation => { if (mutation.type === 'childList' || mutation.type === 'characterData') { cleanVariantText(); } }); }); observer.observe(variantContainer, { childList: true, subtree: true, characterData: true }); } } applyRestrictions() { this.removeLoadingClass(); // Skip if Records template if (this.config.productTemplate === 'records') { console.log('Records template - skipping restrictions'); document.documentElement.classList.add('geo-allowed'); this.showAllowedElements(); return; } const allowed = this.isCountryAllowed(); console.log(`Country: ${this.userCountry}, Allowed: ${allowed}`); if (!allowed) { document.documentElement.classList.add('geo-restricted'); this.hideRestrictedElements(); if (!this.config.isCollectionPage) { this.showRestrictionMessage(); } } else { document.documentElement.classList.add('geo-allowed'); this.showAllowedElements(); } } hideRestrictedElements() { console.log('=== HIDING RESTRICTED ELEMENTS ==='); console.log('Is Collection Page:', this.config.isCollectionPage); // ============================================= // FIX #1: Hide "Sold Out" badges on collection pages // These appear incorrectly when geo-restrictions are active // ============================================= if (this.config.isCollectionPage) { const soldOutBadgeSelectors = [ '.product-badges__badge', '.product-badges', '[class*="badge"]', '.badge', '.sold-out-badge', '.product-card__badge' ]; soldOutBadgeSelectors.forEach(selector => { document.querySelectorAll(selector).forEach(el => { const text = el.textContent.trim().toLowerCase(); // Only hide if it says "sold out" - don't hide sale badges etc. if (text === 'sold out' || text === 'soldout' || text.includes('sold out')) { el.style.display = 'none'; el.setAttribute('data-geo-hidden-badge', 'true'); console.log('Hidden sold out badge:', el); } }); }); } // ============================================= // FIX #2: Hide metafield add to cart text // (customAttributes.add_to_cart_text) // This appears as a gray text block on product pages // ============================================= const cartTextSelectors = [ '[data-add-to-cart-text]', '.add-to-cart-text', '.product-form__submit span', 'button[name="add"] span', '.btn-add-to-cart span' ]; cartTextSelectors.forEach(selector => { document.querySelectorAll(selector).forEach(el => { // Check if this contains custom metafield text (not default "Add to cart") const text = el.textContent.trim().toLowerCase(); if (text && text !== 'add to cart' && text !== 'add to bag' && text !== 'buy now') { el.setAttribute('data-original-text', el.textContent); el.textContent = 'Add to cart'; el.setAttribute('data-geo-restricted', 'true'); } }); }); // Hide text blocks containing "Add to cart text" metafield content // These appear as gray boxes with pricing info like "Price is per pair" // IMPORTANT: Only match SHORT text blocks to avoid hiding product descriptions document.querySelectorAll('.text-block, .group-block, rte-formatter, [class*="text-block"], [class*="rich-text"], .rte').forEach(el => { const text = el.textContent.trim().toLowerCase(); const textLength = text.length; // Only hide if text is SHORT (under 100 chars) - this is likely the metafield, not product description if (textLength < 100) { if (text.includes('price is per') || text.includes('price per') || text.includes('priced per') || text.includes('sold as pair') || text.includes('sold as a pair') || text === 'per pair' || // exact match only text.includes('each unit') || text.includes('price includes')) { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); this.collapseParentIfEmpty(el); console.log('Hidden add-to-cart text block:', text.substring(0, 50)); } } }); // PRODUCT PAGE: Hide prices const priceSelectors = [ '.product__price', '.price', '.product-price', '[data-price]', '.money' ]; priceSelectors.forEach(selector => { document.querySelectorAll(selector).forEach(el => { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); }); }); // COLLECTION PAGE: Hide prices in product cards const collectionPriceSelectors = [ '.card__information .price', '.card-information .price', '.product-card__price', '.product-item__price', '.grid-product__price', '.product-grid-item .price', '.collection-product-card .price', '.product-card .money', '[class*="card"] .price', '[class*="product-item"] .price', '.card .price', '.card__content .price', '.price__container', '.price-item', 'span.price', 'div.price', '.grid-item .price', '.collection-item .price', '.product-grid .price', '[class*="grid"] .price', '[class*="collection"] .price', '.price:not(.product__price)', // Sale/compare price selectors '.price--on-sale', '.price-item--sale', '.price-item--regular', '.price__sale', '.price__regular', '.compare-at-price', '.was-price', 's', // strikethrough elements often contain compare prices 'del', // deleted/strikethrough price '.price s', '.price del', '.money' ]; let hiddenCount = 0; collectionPriceSelectors.forEach(selector => { document.querySelectorAll(selector).forEach(el => { if (this.config.isCollectionPage) { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); hiddenCount++; } else { const isInProductForm = el.closest('form[action*="/cart/add"], .product-form, .product__info-wrapper'); if (!isInProductForm) { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); hiddenCount++; } } }); }); console.log(`Hidden ${hiddenCount} price elements`); // Aggressive price scan for collection pages if (this.config.isCollectionPage) { document.querySelectorAll('*').forEach(el => { const classList = Array.from(el.classList || []).join(' ').toLowerCase(); const text = el.textContent.trim(); if (classList.includes('price') && text.includes('$') && !el.hasAttribute('data-geo-restricted')) { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); hiddenCount++; } }); console.log(`Total hidden after aggressive scan: ${hiddenCount} elements`); } // COLLECTION PAGE: Hide add to cart buttons in product cards const collectionCartSelectors = [ '.card__content button[name="add"]', '.product-card button[name="add"]', '.product-card .quick-add-button', '.quick-add-modal button', '[class*="card"] button[name="add"]', '[class*="product-item"] button[name="add"]' ]; collectionCartSelectors.forEach(selector => { document.querySelectorAll(selector).forEach(el => { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); }); }); // ============================================= // Remove "- Unavailable" from variant dropdowns // International customers shouldn't see stock status // ============================================= this.cleanVariantSelectors(); document.querySelectorAll(selector).forEach(el => { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); }); }); // PRODUCT PAGE: Hide add to cart buttons and forms const cartSelectors = [ 'button[name="add"]', '.product-form__submit', '.add-to-cart', 'form[action*="/cart/add"]', '.shopify-payment-button', '.product-form__buttons', '.trade-in-buttons-wrapper', '.trade-in-button-group', '.trade-in-btn', '.buy-buttons-block', '.product-form-buttons', '.add-to-cart-button', 'button[data-open-trade-modal]', 'button[data-add-to-cart-normal]', 'button[data-add-with-trade]' ]; cartSelectors.forEach(selector => { document.querySelectorAll(selector).forEach(el => { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); }); }); // Hide quantity selectors document.querySelectorAll('.product-form__quantity, .quantity-selector').forEach(el => { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); }); // Hide trade-in promotional text - expanded to catch more patterns document.querySelectorAll('strong, p, div, span, section, aside').forEach(el => { const text = el.textContent.trim(); // Pattern 1: "Receive up to $X,XXX trade in" if (text.startsWith('Receive up to') && text.includes('trade in') && text.length < 200) { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); // Also hide parent if it's a wrapper this.collapseParentIfEmpty(el); } // Pattern 2: "Now available – Receive up to $X,XXX when you trade in" // Only match SHORT text that contains BOTH phrases if (text.includes('Now available') && text.includes('trade in') && text.length < 200) { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); this.collapseParentIfEmpty(el); } // Pattern 3: Any element containing trade-in offer text if ((text.includes('Receive up to $') || text.includes('trade in your')) && text.length < 300) { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); this.collapseParentIfEmpty(el); } // Clean up Liquid artifacts if (text === '-%}' || text.trim() === '-%}') { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); } }); // Also hide any parent containers that might wrap trade-in promos // Be more specific - only match if class clearly indicates trade-in document.querySelectorAll('[class*="trade-in"], [class*="tradein"], [class*="trade-up"], [class*="tradeup"]').forEach(el => { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); this.collapseParentIfEmpty(el); }); // Hide Tinker theme text-blocks and RTE formatters containing trade-in text // Be more specific to avoid hiding product descriptions document.querySelectorAll('rte-formatter, .text-block, [class*="text-block"], .rte, .rich-text, [class*="rich-text"]').forEach(el => { const text = el.textContent.trim(); const textLength = text.length; // Only hide if it's a SHORT block (under 200 chars) that contains trade-in language // AND contains both "trade in" and dollar amounts or specific promo language if (textLength < 200) { const hasTradeIn = text.toLowerCase().includes('trade in') || text.toLowerCase().includes('trade-in'); const hasPromoLanguage = text.includes('Receive up to') || text.includes('$') || text.toLowerCase().includes('when you trade'); if (hasTradeIn && hasPromoLanguage) { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); this.collapseParentIfEmpty(el); console.log('Hidden trade-in text block:', el.className); } } }); // Target the specific gray box container used in Tinker theme // Only hide if it's SHORT and contains trade-in promo language document.querySelectorAll('.group-block, [class*="group-block"], .product-block, [class*="product-block"]').forEach(el => { const text = el.textContent.trim(); const textLength = text.length; // Only hide short blocks that are clearly trade-in promos if (textLength < 200) { const hasTradeIn = text.toLowerCase().includes('trade in') || text.toLowerCase().includes('trade-in'); const hasPromoLanguage = text.includes('Receive up to') || (text.includes('$') && hasTradeIn); const isNowAvailablePromo = text.includes('Now available') && text.includes('$') && hasTradeIn; if ((hasTradeIn && hasPromoLanguage) || isNowAvailablePromo) { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); this.collapseParentIfEmpty(el); console.log('Hidden promo block:', el.className); } } }); // Hide accordion sections document.querySelectorAll('accordion-custom, details').forEach(el => { const text = el.textContent; if (text.includes('Talk to a Hi-Fi Specialist') || text.includes('Home Audition Made Easy') || (text.includes('Shipping') && !text.includes('Shipping policy'))) { el.style.display = 'none'; el.setAttribute('data-geo-restricted', 'true'); } }); document.querySelectorAll('summary').forEach(el => { const text = el.textContent.trim(); if (text === 'Talk to a Hi-Fi Specialist' || text === 'Home Audition Made Easy' || text === 'Shipping') { const parent = el.closest('details'); if (parent) { parent.style.display = 'none'; parent.setAttribute('data-geo-restricted', 'true'); } } }); // ============================================= // Remove "Unavailable" text from variant selectors // International customers don't need to see stock status // ============================================= // Handle