1'use strict'; 2/* Union Trading Co. â faithful static rebuild of the Claude Design prototype. */ 3(function () { 4 5 /* ---------- icon helpers ---------- */ 6 const ic = p => '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">' + p + '</svg>'; 7 const sic = p => '<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor">' + p + '</svg>'; 8 9 /* ---------- crop table (from image-slot state; _applyView reproduction) ---------- */ 10 const CROP = { 11 'div-elec': { s: 1, x: 23.44, y: 0 }, 'div-fmcg': { s: 1, x: 21.83, y: 0 }, 'div-cosmetics': { s: 1, x: 30.91, y: 0 }, 'div-fashion': { s: 1, x: 16.63, y: 0 }, 'div-health': { s: 1, x: 0, y: 0 }, 'div-commercial': { s: 1, x: 0, y: 0 }, 12 'story-storefront': { s: 1, x: 0, y: 4.99 }, 13 'ms-1966': { s: 1, x: 7.69, y: 0 }, 'ms-1970': { s: 1, x: 7.69, y: 0 }, 'ms-1969': { s: 1, x: 7.69, y: 0 }, 14 'pimg-0': { s: 1, x: -41.94, y: 0 }, 'pimg-1': { s: 1, x: -34.51, y: 0 }, 'pimg-2': { s: 1, x: -26.52, y: 0 }, 'pimg-3': { s: 1, x: 0, y: 0 }, 15 'hero-4': { s: 1, x: -23, y: 0 } // Havit (slide 5) â pan right so the subject's face stays in frame 16 }; 17 18 function layoutSlot(el) { 19 const img = el.querySelector('img'); 20 if (!img || !img.naturalWidth) return; 21 const fw = el.clientWidth, fh = el.clientHeight; 22 if (!fw || !fh) return; 23 const iw = img.naturalWidth, ih = img.naturalHeight; 24 const fit = el.dataset.fit || 'cover'; 25 const base = fit === 'contain' ? Math.min(fw / iw, fh / ih) : Math.max(fw / iw, fh / ih); 26 const c = CROP[el.dataset.slot] || { s: 1, x: 0, y: 0 }; 27 const k = base * (c.s || 1); 28 const imgW = iw * k, imgH = ih * k; 29 // desired centre shift (px) from the stored crop, clamped so a cover image 30 // never reveals a gap â pan only as far as the available overflow allows. 31 let cx = (c.x || 0) / 100 * fw, cy = (c.y || 0) / 100 * fh; 32 const maxX = Math.max(0, (imgW - fw) / 2), maxY = Math.max(0, (imgH - fh) / 2); 33 cx = Math.max(-maxX, Math.min(maxX, cx)); 34 cy = Math.max(-maxY, Math.min(maxY, cy)); 35 img.style.width = (imgW / fw * 100) + '%'; 36 img.style.height = (imgH / fh * 100) + '%'; 37 img.style.left = 'calc(50% + ' + cx.toFixed(1) + 'px)'; 38 img.style.top = 'calc(50% + ' + cy.toFixed(1) + 'px)'; 39 } 40 function layoutAll() { document.querySelectorAll('.slot').forEach(layoutSlot); } 41 window.addEventListener('resize', layoutAll); 42 // Re-lay-out each slot whenever its box settles (zoom, 3D transforms, fonts, 43 // responsive) â guarantees cover images fully fill regardless of load timing. 44 const slotRO = ('ResizeObserver' in window) ? new ResizeObserver(es => es.forEach(e => layoutSlot(e.target))) : null; 45 function observeSlots() { if (slotRO) document.querySelectorAll('.slot').forEach(s => slotRO.observe(s)); } 46 47 // build a slot element with an image; relayout on load 48 function slotEl(id, src, fit) { 49 const d = document.createElement('div'); 50 d.className = 'slot'; 51 d.dataset.slot = id; 52 if (fit) d.dataset.fit = fit; 53 const img = document.createElement('img'); 54 img.alt = ''; 55 img.decoding = 'async'; 56 img.onload = () => layoutSlot(d); 57 img.src = src; 58 d.appendChild(img); 59 return d; 60 } 61 function fillSlot(el, id, src, fit) { 62 el.dataset.slot = id; 63 if (fit) el.dataset.fit = fit; 64 const existing = el.querySelector('img'); 65 if (existing && existing.getAttribute('src') === src) { layoutSlot(el); return; } 66 // Preload + decode the new image off-screen while the current one stays visible, 67 // then swap it in already sized. Without this, a fresh <img> paints at its natural 68 // (large) size for a frame before layoutSlot shrinks it â a flicker on manual switches. 69 let done = false; 70 const apply = () => { 71 if (done) return; done = true; 72 let img = el.querySelector('img'); 73 if (!img) { img = document.createElement('img'); img.alt = ''; img.decoding = 'async'; el.appendChild(img); } 74 img.onload = () => layoutSlot(el); 75 img.src = src; 76 layoutSlot(el);
76 // src is cache-warm now â naturalWidth ready, size before paint 77 img.style.visibility = 'visible'; 78 }; 79 const next = new Image(); 80 next.decoding = 'async'; 81 next.onload = apply; 82 next.onerror = apply; 83 next.src = src; 84 if (next.complete && next.naturalWidth) apply(); // already cached & decoded 85 } 86 87 /* ---------- data ---------- */ 88 const NAV = ['About Us', 'Our Divisions', 'Our Stores', 'Our Channels', 'Careers', 'Contact']; 89 const NAV_SOON = ['Careers']; 90 const heroImgs = ['uploads/Carousal4.webp', 'uploads/OGeneral.webp', 'uploads/cecotec.webp', 'uploads/changhong.webp', 'uploads/havit.webp']; 91 const heroPartners = ['GlemGas', 'General', 'Cecotec', 'CHiQ Changhong', 'Havit']; 92 const heroLabelLight = [true, false, false, true, true]; // dark label on the light General & Cecotec images; white on Changhong & Havit (dark scenes) 93 94 function heroPalette() { 95 const light = (bg, head, sub, kick, track, muted) => ({ heroBg: bg, heroHead: head, heroSub: sub, heroKick: kick, heroTrack: track, heroFill: head, heroMuted: muted, heroCtaBg: '#0d0d0d', heroCtaTx: '#fff' }); 96 const dark = (bg, head, sub, kick, track, muted) => ({ heroBg: bg, heroHead: head, heroSub: sub, heroKick: kick, heroTrack: track, heroFill: head, heroMuted: muted, heroCtaBg: head, heroCtaTx: bg }); 97 return [ 98 dark('#182430', '#f2efe8', '#b6c0c8', '#9dafba', '#37485580', '#93a3ac'), // GlemGas 99 light('#e6ddc9', '#2a2417', '#57503d', '#6b6250', '#c9c0a6', '#5c5446'), // O General 100 light('#e8e3d9', '#2a2620', '#57524a', '#675f51', '#cbc4b4', '#585040'), // Cecotec (warm bedroom) 101 dark('#101d33', '#eef2f6', '#aab6c6', '#93a3b6', '#2c3c5680', '#8fa0af'), // CHiQ Changhong (starry navy) 102 light('#dcdedd', '#20262a', '#4e565a', '#5b6367', '#c1c7c6', '#525a5c'), // Havit (cool misty forest) 103 ]; 104 } 105 106 const brandNames = [ 107 { name: 'Braun', src: 'assets/b1.png' }, { name: 'Angel Wear', src: 'assets/b2.png' }, { name: 'India Gate', src: 'assets/b3.png' }, 108 { name: 'Hitachi', src: 'assets/b4.png' }, { name: 'Brandili', src: 'assets/b5.png' }, { name: 'Constance Carroll', src: 'assets/b6.png' }, 109 { name: 'Glysolid', src: 'assets/b7.png' }, { name: 'Eagle Safes', src: 'assets/b8.png' }, { name: 'Prestige', src: 'assets/b9.png' }, { name: 'O General', src: 'assets/b10.png' }, 110 { name: 'Daikin', src: 'img/brands-elec/daikin.webp' }, { name: 'Triumph', src: 'img/brands-aluna/triumph.webp' }, { name: 'Sloggi', src: 'img/brands-aluna/sloggi.webp' }, 111 { name: 'Vileda', src: 'img/brands-elec/vileda.webp' }, { name: 'Cans', src: 'img/brands-elec/cans.webp' }, { name: 'Palladio', src: 'img/brands-elec/palladio.webp' }, { name: 'Vero Moda', src: 'img/brands-elec/vero-moda.webp' } 112 ]; 113 function logoFor(name) { 114 const n = String(name).toLowerCase(); 115 const f = brandNames.find(b => { const bn = b.name.toLowerCase(); return bn === n || bn.includes(n) || n.includes(bn); }); 116 return f ? f.src : null; 117 } 118 119 const divData = [ 120 { name: 'Union Electronics', href: 'union-electronics/', logo: 'img/Div/electronics-logo.webp', cardLogo: 'img/Div/Black.webp', cardLogoH: 'clamp(48px,6vw,74px)', tagline: 'Smarter Homes. Simpler Living.', icon: '<rect x="4" y="3" width="7" height="18" rx="1.5"></rect><circle cx="7.5" cy="8" r="1.4"></circle><rect x="13" y="3" width="7" height="18" rx="1.5"></rect><circle cx="16.5" cy="8" r="1.4"></circle>', desc: 'Recognizing the growing demand for premium electronic appliances, UNION strategically expanded its portfolio to offer a comprehensive range of world-renowned consumer electronics and home appliances. Through partnerships with leading global brands, the company has consistently delivered innovative, reliable, and energy-efficient solutions that enhance everyday living while meeting the evolving needs of customers across Kuwait.', mgr: 'Available on request', contact: 'Chat with Us', brands: ['Hitachi', 'Daikin', '+22 more'], slot: 'div-elec' }, 121 { name: 'Aluna', href: 'aluna/', logo: 'img/Div/aluna-logo.webp', cardLogo: 'img/Div/aluna-logo.webp', cardLogoDark: true, tagline: 'Beauty in
121Every Detail.', icon: '<path d="M9 21h6a2 2 0 002-2v-6a3 3 0 00-3-3h-1V7h-2v3H10a3 3 0 00-3 3v6a2 2 0 002 2z"></path><path d="M10 5h4M11 3h2"></path>', desc: 'Recognizing the growing demand for premium lingerie and shapewear, UNION developed a distinguished portfolio of internationally acclaimed lingerie and shapewear brands. By offering authentic, high-quality products, the company continues to deliver luxury, elegance, and confidence to customers throughout Kuwait.', mgr: 'Available on request', contact: 'Chat with Us', brands: ['Triumph', 'Sloggi', '+12 more'], slot: 'div-cosmetics' }, 122 { name: 'FMCG', href: 'fmcg/', logo: 'img/Div/fmcg-logo.webp', cardLogo: 'img/Div/fmcg-logo.webp', cardLogoDark: true, tagline: 'Everyday Essentials, Delivered.', icon: '<circle cx="9" cy="20" r="1.2"></circle><circle cx="17" cy="20" r="1.2"></circle><path d="M2 4h2.5l2.5 12h10l2-8H6.2"></path>', desc: 'As consumer expectations for quality everyday essentials continued to rise, UNION expanded its portfolio to include a wide range of fast-moving consumer goods from leading international brands. By delivering trusted food, beverage, and household products, the company has become a reliable partner in meeting the daily needs of consumers.', mgr: 'Available on request', contact: 'Chat with Us', brands: ['Vileda', 'Cans', '+14 more'], slot: 'div-fmcg' }, 123 { name: 'Labels', soon: true, logo: 'img/Div/labels-logo.webp', cardLogo: 'img/Div/labels-logo.webp', cardLogoDark: true, cardLogoH: 'clamp(48px,6vw,74px)', tagline: 'Timeless Everyday Style.', icon: '<circle cx="12" cy="5" r="2"></circle><path d="M12 7l7 5-2 2-2-1.5V21H9v-8.5L7 14l-2-2 7-5z"></path>', desc: 'With changing lifestyles and increasing demand for premium fashion and cosmetics, UNION established a strong presence by introducing internationally renowned fashion, cosmetics, and lifestyle brands. Through a carefully curated portfolio, the company continues to deliver quality, style, and exceptional retail experiences to customers across the region.', mgr: 'Available on request', contact: 'Chat with Us', brands: ['Palladio', 'Vero Moda', '+7 more'], slot: 'div-fashion' }, 124 { name: 'Union Services', soon: true, logo: 'img/Div/services-logo.webp', cardLogo: 'img/Div/services-logo.webp', cardLogoDark: true, cardLogoH: 'clamp(44px,5.4vw,66px)', tagline: 'Caring for Every Family.', icon: '<path d="M12 20s-6.5-4.2-9-8.2C1.4 9 2.6 5.5 6 5.5c2 0 3.2 1.2 4 2.4.8-1.2 2-2.4 4-2.4 3.4 0 4.6 3.5 3 6.3-2.5 4-9 8.2-9 8.2z"></path>', desc: 'Union Services is dedicated to providing reliable and professional after-sales and technical services to ensure our customers get the best performance and value from their products. From pre-installation (site survey), installation, startup, and preventive maintenance to troubleshooting and repairs, our experienced service teams are committed to delivering quality service and a smooth customer experience.', mgr: 'Available on request', contact: 'Chat with Us', brands: [], services: ['Air Conditioning, HVAC & VRF Systems', 'Home Appliances & Built-in Systems', 'Audio/Video & Electronics Systems', 'Site Survey & Technical Assessment', 'Installation, Start-Up & Commissioning', 'Preventive Maintenance & AMC', 'Corrective Maintenance, Troubleshooting & Repair', 'Product Replacement & Upgrading', 'Genuine Spare Parts & Technical Support', 'Water Solutions Services'], slot: 'div-health' }, 125 { name: 'Commercial AC Projects', soon: true, logo: 'img/Div/commercial-logo.webp', cardLogo: 'img/Div/commercial-logo.webp', cardLogoDark: true, cardLogoH: 'clamp(44px,5.2vw,64px)', tabW: '138px', tagline: 'Powerful, Efficient Cooling.', icon: '<rect x="3" y="5" width="18" height="8" rx="2"></rect><path d="M6.5 9h1M10 9h7.5M7 16c.2 1.4 1 1.8 1 3M12 16c.2 1.4 1 1.8 1 3M17 16c-.2 1.4-1 1.8-1 3"></path>', desc: "At the time of the company's inception there was a great demand in the market for climate control technology. UTC's answer to this demand was the creation of its very own line of climate control solution: General Air Conditioners.", descHtml: "At the time of the company's inception there was a great demand in the market for climate control technology. UTC's answer to this demand was the creation of its very own line of climate control <strong style=\"color:#161513;font-weight:700\">solution: General Air Conditioners.</strong>", mgr: 'Mr. Walid Khalid', contact: 'Chat with Us', brands: ['Daikin', 'Hitachi', 'General'], slot: 'div-commercial' } 126 ]; 127 128 const storyStats = [ 129 { n: '75+', l: 'YEARS IN BUSINESS', sub: 'Built on trust. Driven by excellence.', icon: ic('<rect x="3" y="7" width="18" height="13" rx="1"></rect><path d="M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2"></path>') }, 130 { n: '80+', l: 'GLOBAL BRANDS', sub: 'Partnerships with world leaders.', icon: ic('<circle cx="12" cy="12" r="9"></circle><path d="M3 12h18M12 3c3 3.5 3 14.5 0 18M12 3c-3 3.5-3 14.5 0 18"></path>') }, 131 { n: '500+', l: 'EMPLOYEES', sub: 'A team powering our promise.', icon: ic('<circle cx="9" cy="8" r="3"></circle><path d="M3 20c0-3 3-5 6-5s6 2 6 5"></path><path d="M16 6a3 3 0 010 6M21 20c0-2.5-2-4-4-4.5"></path>') }, 132 { n: '10+', l: 'RETAIL OUTLETS', sub: 'Across Kuwait and beyond.', icon: ic('<path d="M3 9l1.5-5h15L21 9"></path><path d="M4 9v10a1 1 0 001 1h14a1 1 0 001-1V9"></path><path d="M3 9h18"></path><path d="M9 20v-6h6v6"></path>') } 133 ]; 134 const storyExtra = ['uploads/city-showroom.webp', 'uploads/old-general-photo.webp']; 135 136 const plist = [ 137 { name: 'Milton Lloyd', since: '1978', years: '44+', src: 'uploads/Mil-trimmed.webp', person: 'Peter Jackson', role: 'CEO', quote: 'Our mission with UTC branded perfumes, throughout the past 44 years, has (under Union Trading Companyâs continuing unique and continuing vision / foresight and leadership) always been to offer to consumers with limited financial resources outstanding perfumery of the finest quality and at the lowest possible price. Since 1978, this unique and continuous perfumery cooperation between Union Trading Company and Milton-Lloyd has manufactured (in the UK) and distributed (initially in Kuwait and then throughout the world) more than 300 million bottles of UTC glass perfumery. A huge number. An unprecedented success. We believe that Milton-Lloydâs cooperation and friendship with Union Trading Company is as strong or stronger today than it has ever been. And the future of UTC is bright.' }, 138 { name: 'Glemgas', since: '1970', years: '50+', src: 'uploads/Glem-trimmed.webp', person: 'Marco Guerzoni', role: 'Marketing Director', quote: 'It has been a long journey of over 50 years under the guidance of UTC that helped us to be market leader in Kuwait. All of our achievements werenât possible without the great support and effort of the entire UTC team. From the ones in the past who have made such a great story possible, to the current and future one that god willing they will certainly raise the bar to achieve what now seems impossible to our eyes. I personally want to thank all of UTC for their precious dedication and support, and am looking forward to celebrate our new impossible achievements.' }, 139 { name: 'Jockey', since: '2003', years: '20+', src: 'uploads/Jock-trimmed.webp', person: 'Brand Team', role: 'Brand Partner', quote: 'Jockey International, Inc. has enjoyed a valuable working relationship with Union Trading Co. for the past 20 years. We value UTCâs loyal patronage over those years and the business relationship that has ensued because of our cooperation. We have watched Union Trading Co. grow into a thriving business and a business well-known throughout the Gulf region. We know Union Trading Co. has many options and we are pleased with the close relationship between our companies and look forward to many more years of serving Union Trading Co. We are committed to providing Union Trading Co. with quality service and the best products at a fair price. The cooperation between Jockey International, Inc. and Union Trading Co. has led to a respectful business relationship between our two companies. Jockey International, Inc. looks forward to many more years of our association with Union Trading Co. and wish you the success that you so richly deserve as you relaunch of your Corporate Website. Thank y
139ou, Union Trading Co. for giving Jockey International, Inc. the opportunity to serve your great company.' }, 140 ]; 141 142 const milestones = [ 143 { year: '1949', title: 'First Showroom, Ahmadi', desc: 'Our journey began with a single showroom in Ahmadi, marking the start of Union Trading Co.', slot: 'ms-1949' }, 144 { year: '1958', title: 'Kuwait City Showroom Launch', desc: 'We expanded our presence with the launch of our showroom in Kuwait City, bringing us closer to our customers.', slot: 'ms-1958' }, 145 { year: '1966', title: 'Partnership with Gillette', desc: 'We partnered with Gillette to bring world-class grooming products to our customers.', slot: 'ms-1966' }, 146 { year: '1969', title: 'Partnership with Glemgas', desc: 'We joined hands with Glemgas to bring premium Italian home appliances to our customers.', slot: 'ms-1969' }, 147 { year: '1970', title: 'Partnership with General', desc: 'Joined hands with General to offer advanced and reliable air conditioning solutions.', slot: 'ms-1970' }, 148 { year: '1977', title: 'Foundation of UTC Perfumes', desc: 'We ventured into beauty with the foundation of UTC Perfumes â quality fragrances for every lifestyle.', slot: 'ms-1977' }, 149 { year: '1980', title: 'Salhiya Showroom', desc: 'We opened our Salhiya showroom, extending Union Trading Co.âs retail presence into the heart of Kuwait City.', slot: 'ms-1980' }, 150 { year: '1982', title: 'Farwaniya Showroom', desc: 'Our Farwaniya showroom opened its doors, bringing our brands closer to families across the governorate.', slot: 'ms-1982' },
151 { year: '1999', title: 'New Headquarters in Dawliya Complex', desc: 'We relocated to a new headquarters in the Dawliya Complex, anchoring our operations for the decades ahead.', slot: 'ms-1999' }, 152 { year: '2009', title: 'Appliance Showroom â Jahra, Farwaniya & Hawally', desc: 'We launched dedicated appliance showrooms in Jahra, Farwaniya and Hawally, widening access to home and electronic appliances.', slot: 'ms-2009' }, 153 { year: '2017', title: 'Yaal Showroom', desc: 'The Yaal showroom opened, adding a modern retail destination to our growing network.', slot: 'ms-2017' }, 154 { year: '2020', title: 'New Hawally Department Store', desc: 'We unveiled our new Hawally department store â a flagship retail experience for a new generation of customers.', slot: 'ms-2020' }, 155 { year: '2024', title: 'Launch of Aluna', desc: 'We introduced Aluna, our dedicated lingerie and shapewear destination, bringing internationally acclaimed lingerie and shapewear to customers across Kuwait.', slot: 'ms-2024' }, 156 { year: '2026', title: 'Union Electronics', desc: 'We brought Union Electronics to life across Physical and Digital Stores, delivering a seamless omni-channel experience for the latest technology and home appliances.', slot: 'ms-2026' } 157 ]; 158 159 const socials = [ 160 { name: 'Instagram', href: 'https://www.instagram.com/unionelectronics.kw/', icon: sic('<path d="M12 8.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7zm0 2a1.5 1.5 0 110 3 1.5 1.5 0 010-3z"></path><path d="M7 3h10a4 4 0 014 4v10a4 4 0 01-4 4H7a4 4 0 01-4-4V7a4 4 0 014-4zm0 2a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2H7zm10.5 1.5a1 1 0 110 2 1 1 0 010-2z"></path>') }, 161 { name: 'Facebook', href: 'https://www.facebook.com/unionelectronicskw', icon: sic('<path d="M14 9h3V5h-3c-2.2 0-4 1.8-4 4v2H7v4h3v6h4v-6h3l1-4h-4V9c0-.6.4-1 1-1z"></path>') }, 162 { name: 'LinkedIn', href: 'https://www.linkedin.com/company/union-trading-company-kuwait/', icon: sic('<path d="M6.5 8v10H3.5V8h3zM5 3.5A1.75 1.75 0 105 7 1.75 1.75 0 005 3.5zM20.5 18h-3v-5c0-1.3-.5-2-1.5-2s-1.5.7-1.5 2v5h-3V8h3v1.3c.5-.8 1.4-1.5 2.7-1.5 2 0 3.3 1.3 3.3 4z"></path>') }, 163 { name: 'TikTok', href: 'https://www.tiktok.com/@unionelectronics.kw?_r=1&_t=ZS-998zkW4Kypg', icon: sic('<path d="M16 3c.3 2 1.5 3.5 3.5 4v3c-1.4 0-2.6-.4-3.5-1v5.5A5.5 5.5 0 118 9v3.2A2.5 2.5 0 1011 14V3h3z"></path>') }, 164 { name: 'YouTube', href: 'https://youtube.com/@unionelectronicskw?si=DtCur73sViKpsxy3', icon: sic('<path d="M23 12s0-3.2-.4-4.7a2.5 2.5 0 00-1.7-1.7C19.3 5.2 12 5.2 12 5.2s-7.3 0-8.9.4a2.5 2.5 0 00-1.7 1.7C1 8.8 1 12 1 12s0 3.2.4 4.7a2.5 2.5 0 001.7 1.7c1.6.4 8.9.4 8.9.4s7.3 0 8.9-.4a2.5 2.5 0 001.7-1.7C23 15.2 23 12 23 12zM9.8 15.3V8.7l5.7 3.3-5.7 3.3z"></path>') } 165 ]; 166 const phoneIcon = '<path d="M4 4h4l2 5-2 2a11 11 0 005 5l2-2 5 2v4a2 2 0 01-2 2A16 16 0 014 6a2 2 0 012-2z"></path>'; 167 const contacts = [ 168 { icon: ic(phoneIcon), main: '+965 2242 3355', sub: 'Head Office Number' }, 169 { icon: ic(phoneIcon), main: '+965 184 0011', sub: 'Service Center Hotline' }, 170 { icon: ic(phoneIcon), main: '+965 9407 3737', sub: 'WhatsApp' }, 171 { icon: ic('<rect x="3" y="5" width="18" height="14" rx="1"></rect><path d="M3 7l9 6 9-6"></path>'), main: '[email protected]', sub: 'Email Us' }, 172 { icon: ic('<path d="M12 21s7-6 7-11a7 7 0 10-14 0c0 5 7 11 7 11z"></path><circle cx="12" cy="10" r="2.5"></circle>'), main: 'Union Trading Co.', sub: 'Rehab Al-Salem Street, Al Dawliya Complex, 3rd Floor, Kuwait City.' } 173 ]; 174 const footAbout = ['About UTC', 'FAQ', 'Careers', 'Sitemap', 'Privacy Statement']; 175 176 // Offices for the contact panel â each opens the Our Stores explorer; where a 177 // matching store record exists, it deep-links to it via ?store=<index>. 178 const offices = [ 179 { name: 'Head Office', addr: ['Dawliah Commercial Center, Block 15', 'Al Qibla, Kuwait City'], phone: '+965 2242 3355', email: '[email protected]', store: 12, img: 'img/Showroom%20Images/HOPrem.webp' }, 180 { name: 'Flagship Store', addr: ['Fahad Al-Salem Street, Block 1', 'Kuwait City, Kuwait'], phone: '+965 2242 3355', email: '[email protected]', store: 0, img: 'img/Showroom%20Images/Flagship.webp' }, 181 { name: 'Salmiya Store', addr: ['Salem Al Mubarak Street', 'Salmiya, Kuwait'], phone: '+965 9550 1717', email: '[email protected]', store: 1, img: 'img/stores/salmiya-1.webp' } 182 ]; 183 184 // Bottom feature strip 185 const features = [ 186 { icon: '<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path>', title: 'Quick Response', sub: 'We aim to respond within 24 business hours.' }, 187 { icon: '<path d="M4 14v-2a8 8 0 0116 0v2"></path><rect x="3" y="14" width="4" height="6" rx="1.5"></rect><rect x="17" y="14" width="4" height="6" rx="1.5"></rect>', title: 'Dedicated Support', sub: 'Our team is here to assist you at every step.' }, 188 { icon: '<path d="M9 12l2 2 4-4"></path><path d="M12 3l7 3v5c0 4.5-3 8-7 10-4-2-7-5.5-7-10V6z"></path>', title: 'Partnerships', sub: "Interested in becoming a partner? Let's connect." }, 189 { icon: '<circle cx="12" cy="12" r="9"></circle><path d="M3 12h18M12 3a15 15 0 010 18M12 3a15 15 0 000 18"></path>', title: 'Global Standards', sub: 'Delivering international qual
189ity with local commitment.' }, 190 { icon: '<path d="M4 19l5-5 3 3 7-8"></path><path d="M16 9h4v4"></path>', title: 'Mutual Growth', sub: 'We grow together to create long-term value.' } 191 ]; 192 193 /* ---------- state ---------- */ 194 const S = { hero: 0, div: 0, storyImg: 0, partner: 0 }; 195 196 /* ---------- static renders ---------- */ 197 const el = id => document.getElementById(id); 198 199 // nav (About Us -> About page, Our Divisions -> divisions, Contact -> contact) 200 const NAV_HREF = { 'About Us': 'about/', 'Our Divisions': 'divisions/', 'Our Stores': 'our-stores/', 'Our Channels': 'our-channels/', 'Careers': 'careers/', 'Contact': '#contact' }; 201 el('nav').innerHTML = NAV.map(l => NAV_SOON.indexOf(l) !== -1 202 ? '<span title="Coming soon" style="color:rgba(255,255,255,0.32);font-size:15px;font-weight:500;letter-spacing:0.01em;padding-bottom:4px;border-bottom:2px solid transparent;cursor:not-allowed">' + l + '</span>' 203 : '<a href="' + (NAV_HREF[l] || '#') + '" class="hoverline" style="color:#fff;font-size:15px;font-weight:500;letter-spacing:0.01em;padding-bottom:4px;border-bottom:2px solid transparent">' + l + '</a>').join(''); 204 205 // brand marquee â the full Electronics brand set, rendered as white silhouettes 206 // via the invert filter. Kept separate from brandNames so the division brand-chip 207 // lookup (logoFor) is unaffected. 208 const marqBrands = [ 209 { name: 'B&D', src: 'img/brands-elec/b-d.webp' }, 210 { name: 'Triumph', src: 'img/brands-aluna/triumph.webp' }, 211 { name: 'Belkin', src: 'img/brands-elec/belkin.webp' }, 212 { name: 'Canton', src: 'img/brands-elec/canton.webp' }, 213 { name: 'Sloggi', src: 'img/brands-aluna/sloggi.webp' }, 214 { name: 'Cecotec', src: 'img/brands-elec/cecotec.webp' }, 215 { name: 'Changhong', src: 'img/brands-elec/changhong.webp' }, 216 { name: 'Maidenform', src: 'img/brands-aluna/maidenform.webp' }, 217 { name: 'Chigo', src: 'img/brands-elec/chigo.webp' }, 218 { name: 'Daikin', src: 'img/brands-elec/daikin.webp' }, 219 { name: 'Hanes', src: 'img/brands-aluna/hanes-mark.webp', raw: true }, 220 { name: 'Fakir', src: 'img/brands-elec/fakir.webp' }, 221 { name: 'Galanz', src: 'img/brands-elec/galanz.webp' }, 222 { name: 'Bali', src: 'img/brands-aluna/bali.webp' }, 223 { name: 'General', src: 'img/brands-elec/general.webp' }, 224 { name: 'Glemgas', src: 'img/brands-elec/glemgas.webp' }, 225 { name: 'Naomi & Nicole', src: 'img/brands-aluna/naomi-nicole.webp' }, 226 { name: 'Havic', src: 'img/brands-elec/havic.webp' }, 227 { name: 'Hifuture', src: 'img/brands-elec/hifuture.webp' }, 228 { name: 'Carole Hochman', src: 'img/brands-aluna/carole-hochman.webp', pad: '6px 14px' }, 229 { name: 'Hitachi', src: 'img/brands-elec/hitachi.webp' }, 230 { name: 'Konka', src: 'img/brands-elec/konka.webp' }, 231 { name: 'Leg Avenue', src: 'img/brands-aluna/leg-avenue.webp' }, 232 { name: 'Morphy', src: 'img/brands-elec/morphy.webp' }, 233 { name: 'Naim & Focal', src: 'img/brands-elec/naim-focal.webp', big: true }, 234 { name: 'Eileen West', src: 'img/brands-aluna/eileen-west.webp', pad: '6px 10px' }, 235 { name: 'Proove', src: 'img/brands-elec/proove.webp' }, 236 { name: 'ROWA', src: 'img/brands-elec/rowa.webp' }, 237 { name: 'Cupid', src: 'img/brands-aluna/cupid.webp' }, 238 { name: 'Startec', src: 'img/brands-elec/startec.webp' }, 239 { name: 'TCL', src: 'img/brands-elec/tcl.webp' }, 240 { name: 'Mapale', src: 'img/brands-aluna/mapale.webp' }, 241 { name: 'Vestel', src: 'img/brands-elec/vestel.webp' }, 242 { name: 'Ivory', src: 'img/brands-aluna/ivory.webp', pad: '8px 12px' }, 243 { name: 'Westpoint', src: 'img/brands-elec/westpoint.webp' }, 244 { name: 'C.Lengerie', src: 'img/brands-aluna/c-lengerie.webp' }, 245 { name: 'Yuwell', src: 'img/brands-elec/yuwell.webp' }, 246 { name: 'G World', src: 'img/brands-aluna/g-world.webp' } 247 ]; 248 const marqueeBrands = marqBrands; 249 const marqHtml = marqueeBrands.map(b => { 250 const filt = b.raw ? 'none' : 'brightness(0) invert(1)'; 251 return '<div class="bcell" style="width:200px;height:56px;border-right:1px solid rgba(255,255,255,0.16);padding:' + (b.pad || (b.big ? '5px 24px' : '13px 24px')) + ';opacity:0.82;transition:opacity .35s ease">' 252 + '<div class="bimg" role="img" aria-label="' + b.name + '" style="width:100%;height:100%;filter:' + filt + ';background-image:url("' + b.src + '");background-size:contain;background-repeat:no-repeat;background-position:center;transition:transform .35s ease"></div></div>'; 253 }).join(''); 254 const marqsets = document.querySelectorAll('.marqset');
255 marqsets.forEach(m => m.innerHTML = marqHtml); 256 // JS-driven marquee so the first brand starts centred, then scrolls 257 (function () { 258 const row = document.querySelector('.marqrow'); 259 if (!row || !marqsets.length) return; 260 row.style.animation = 'none'; 261 let W = 0, pos0 = 0, pos = 0, paused = false; 262 function measure() { W = marqsets[0].getBoundingClientRect().width; pos0 = window.innerWidth / 2 - 100 - W; pos = pos0; } 263 measure(); 264 row.addEventListener('mouseenter', () => paused = true); 265 row.addEventListener('mouseleave', () => paused = false); 266 window.addEventListener('resize', measure); 267 let last = 0; 268 const PXPS = 30; // px/sec â matched to the Aluna inner-page brand marquee 269 function frame(t) { 270 if (!last) last = t; const dt = t - last; last = t; 271 if (!paused && W) { pos -= dt * PXPS / 1000; if (pos <= pos0 - W) pos += W; row.style.transform = 'translateX(' + pos.toFixed(1) + 'px)'; } 272 requestAnimationFrame(frame); 273 } 274 requestAnimationFrame(frame); 275 })(); 276 277 // socials (footer), footAbout 278 el('socials2').innerHTML = socials.map(s => '<a href="' + s.href + '" target="_blank" rel="noopener" aria-label="' + s.name + '" style="width:34px;height:34px;border:1px solid #33312d;display:flex;align-items:center;justify-content:center;color:#cfcbc0">' + s.icon + '</a>').join(''); 279 280 // office rows â open the Our Stores explorer (deep-linked to the store where one exists) 281 const officeIcon = (p) => '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="flex-shrink:0">' + p + '</svg>'; 282 const officesEl = el('offices'); 283 if (officesEl) { 284 officesEl.innerHTML = offices.map((o, i) => { 285 const href = o.store != null ? 'our-stores/?store=' + o.store : 'our-stores/'; 286 const divider = i < offices.length - 1 ? 'border-bottom:1px solid #ece8de;' : ''; 287 return '<a class="office-card" href="' + href + '" style="display:flex;gap:14px;align-items:center;padding:8px 10px;text-decoration:none;color:inherit;border-radius:12px;' + divider + 'transition:background .25s">' 288 + '<span style="width:56px;height:52px;flex-shrink:0;border-radius:10px;background:#eceae2 url("' + o.img + '") center/cover no-repeat"></span>' 289 + '<div style="min-width:0;flex:1">' 290 + '<div style="font-family:\'Archivo\';font-weight:700;font-size:15px;color:#161513;margin-bottom:3px">' + o.name + '</div>' 291 + '<div style="font-size:12px;color:#645f57;line-height:1.4;margin-bottom:5px">' + o.addr.join('<br>') + '</div>' 292 + '<div style="display:flex;flex-wrap:wrap;gap:6px 18px">' 293 + '<span style="display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:#5c5951">' + officeIcon('<path d="M4 4h4l2 5-2 2a11 11 0 005 5l2-2 5 2v4a2 2 0 01-2 2A16 16 0 014 6a2 2 0 012-2z"></path>') + o.phone + '</span>' 294 + '</div>' 295 + '</div>' 296 + '<span class="office-arrow" style="width:38px;height:38px;flex-shrink:0;border-radius:50%;border:1px solid #ddd8cc;display:flex;align-items:center;justify-content:center;color:#161513;align-self:center;transition:background .25s,color .25s,border-color .25s"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"></path></svg></span>' 297 + '</a>'; 298 }).join(''); 299 300 // mobile only: pagination dots for the offices swipe-carousel (hidden on desktop via inline display:none) 301 if (!document.querySelector('.office-dots')) { 302 const dw = document.createElement('div'); 303 dw.className = 'office-dots'; 304 dw.style.cssText = 'display:none;justify-content:center;gap:7px;margin-top:12px'; 305 dw.innerHTML = offices.map((o, i) => '<button class="tapx" data-od="' + i + '" aria-label="' + o.name + '" style="width:8px;height:8px;border-radius:50%;border:none;padding:0;cursor:pointer;background:' + (i === 0 ? '#a97f43' : 'rgba(60,48,30,0.22)') + ';transition:width .25s ease,border-radius .25s ease,background .25s ease"></button>').join('');
306 officesEl.parentNode.insertBefore(dw, officesEl.nextSibling); 307 const dots = Array.prototype.slice.call(dw.children); 308 const items = Array.prototype.slice.call(officesEl.children); 309 const activeIdx = () => { const mid = officesEl.scrollLeft + officesEl.clientWidth / 2; let best = 0, bd = Infinity; items.forEach((c, i) => { const cen = c.offsetLeft + c.offsetWidth / 2, d = Math.abs(cen - mid); if (d < bd) { bd = d; best = i; } }); return best; }; 310 const sync = () => { const a = activeIdx(); dots.forEach((d, i) => { const on = i === a; d.style.background = on ? '#a97f43' : 'rgba(60,48,30,0.22)'; d.style.width = on ? '20px' : '8px'; d.style.borderRadius = on ? '4px' : '50%'; }); }; 311 let oraf = 0; 312 officesEl.addEventListener('scroll', () => { cancelAnimationFrame(oraf); oraf = requestAnimationFrame(sync); }, { passive: true }); 313 dots.forEach((d, i) => d.addEventListener('click', () => { const c = items[i]; officesEl.scrollTo({ left: c.offsetLeft - (officesEl.clientWidth - c.offsetWidth) / 2, behavior: 'smooth' }); })); 314 window.addEventListener('resize', sync); 315 setTimeout(sync, 200); 316 } 317 } 318 319 // feature strip â minimal gold-accent icon + title & description, with slim centered dividers 320 const featEl = document.querySelector('.feat-grid'); 321 if (featEl) { 322 featEl.innerHTML = features.map((f, i) => { 323 const div = i > 0 ? 'background:linear-gradient(#e6e0d3,#e6e0d3) left center / 1px 54% no-repeat;' : ''; 324 return '<div style="display:flex;gap:13px;align-items:center;padding:2px clamp(14px,1.5vw,22px);' + div + '">' 325 + '<div style="width:40px;height:40px;flex-shrink:0;border-radius:50%;background:#f4eee2;display:flex;align-items:center;justify-content:center;color:#a97f43"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + f.icon + '</svg></div>' 326 + '<div style="min-width:0"><div style="font-family:\'Archivo\';font-weight:700;font-size:14px;letter-spacing:-0.01em;color:#161513;margin-bottom:4px">' + f.title + '</div>' 327 + '<div style="font-size:12px;color:#635e55;line-height:1.45">' + f.sub + '</div></div>' 328 + '</div>'; 329 }).join(''); 330 331 // mobile only: pagination dots for the feature swipe-carousel (hidden on desktop via inline display:none) 332 if (!document.querySelector('.feat-dots')) { 333 const dw = document.createElement('div'); 334 dw.className = 'feat-dots'; 335 dw.style.cssText = 'display:none;justify-content:center;gap:7px;margin-top:14px'; 336 dw.innerHTML = features.map((f, i) => '<button class="tapx" data-fd="' + i + '" aria-label="' + f.title + '" style="width:8px;height:8px;border-radius:50%;border:none;padding:0;cursor:pointer;background:' + (i === 0 ? '#a97f43' : 'rgba(60,48,30,0.22)') + ';transition:width .25s ease,border-radius .25s ease,background .25s ease"></button>').join(''); 337 featEl.parentNode.insertBefore(dw, featEl.nextSibling); 338 const dots = Array.prototype.slice.call(dw.children); 339 const items = Array.prototype.slice.call(featEl.children); 340 const activeIdx = () => { const mid = featEl.scrollLeft + featEl.clientWidth / 2; let best = 0, bd = Infinity; items.forEach((c, i) => { const cen = c.offsetLeft + c.offsetWidth / 2, d = Math.abs(cen - mid); if (d < bd) { bd = d; best = i; } }); return best; }; 341 const sync = () => { const a = activeIdx(); dots.forEach((d, i) => { const on = i === a; d.style.background = on ? '#a97f43' : 'rgba(60,48,30,0.22)'; d.style.width = on ? '20px' : '8px'; d.style.borderRadius = on ? '4px' : '50%'; }); }; 342 let fraf = 0; 343 featEl.addEventListener('scroll', () => { cancelAnimationFrame(fraf); fraf = requestAnimationFrame(sync); }, { passive: true }); 344 dots.forEach((d, i) => d.addEventListener('click', () => { const c = items[i]; featEl.scrollTo({ left: c.offsetLeft - (featEl.clientWidth - c.offsetWidth) / 2, behavior: 'smooth' }); })); 345 window.addEventListener('resize', sync); 346 setTimeout(sync, 200); 347 } 348 } 349 350 const footHref = { 'Careers': 'careers/', 'About UTC': 'about/', 'Sitemap': 'sitemap/', 'Privacy Statement': 'privacy-policy/', 'FAQ': 'faq/' }; 351 const footSoon = ['Careers']; // careers is an unfinished placeholder â keep it un-linked for now 352 el('footAbout').innerHTML = footAbout.map(l => footSoon.indexOf(l) !== -1 353 ? '<span title="Coming soon" style="color:#5c5850;font-size:14px;cursor:default;pointer-events:none">
353' + l + ' <span style="font-size:10px;letter-spacing:.1em;opacity:.8">· SOON</span></span>' 354 : '<a href="' + (footHref[l] || '#') + '" style="color:#8f8c85;font-size:14px">' + l + '</a>').join(''); 355 356 // hero slides + dots 357 el('heroSlides').innerHTML = ''; 358 heroImgs.forEach((img, i) => { 359 const layer = document.createElement('div'); 360 layer.style.cssText = 'position:absolute;inset:0;transition:opacity 800ms ease;opacity:' + (i === 0 ? 1 : 0) + ';pointer-events:none'; 361 layer.appendChild(slotEl('hero-' + i, img, 'cover')); 362 layer.dataset.h = i; 363 el('heroSlides').appendChild(layer); 364 }); 365 el('heroDots').innerHTML = heroImgs.map((_, i) => '<button class="tapx" data-hd="' + i + '" aria-label="slide" style="width:9px;height:9px;border-radius:50%;border:none;cursor:pointer;padding:0;background:' + (i === 0 ? '#141311' : 'rgba(255,255,255,0.55)') + '"></button>').join(''); 366 el('heroDots').querySelectorAll('button').forEach(b => b.onclick = () => setHero(+b.dataset.hd)); 367 el('heroMuted').textContent = ('0' + heroImgs.length).slice(-2); // total-slides counter, auto-matches heroImgs 368 369 // division image layers + tabs 370 el('divLayers').innerHTML = ''; 371 divData.forEach((d, i) => { 372 const layer = document.createElement('div'); 373 layer.style.cssText = 'position:absolute;inset:0;transition:opacity 500ms ease;opacity:' + (i === 0 ? 1 : 0) + ';z-index:' + (i === 0 ? 2 : 1) + ';pointer-events:none'; 374 layer.appendChild(slotEl(d.slot, 'img/' + d.slot + '.webp', 'cover')); 375 el('divLayers').appendChild(layer); 376 }); 377 function tabHtml() { 378 return divData.map((d, i) => { 379 const active = i === S.div; 380 const divider = (i === divData.length - 1 || i === S.div || i === S.div - 1) ? '1px solid transparent' : '1px solid #2c2a26'; 381 const inner = d.logo 382 ? '<img loading="lazy" decoding="async" src="' + d.logo + '" alt="' + d.name + '" style="width:' + (d.tabW || '100px') + ';height:36px;object-fit:contain;filter:brightness(0) invert(1);display:block">' 383 : '<span style="width:26px;height:26px;display:flex;align-items:center;justify-content:center">' + ic(d.icon) + '</span><span style="font-size:12px;font-weight:700;text-align:center;line-height:1.2">' + d.name + '</span>'; 384 return '<button data-dt="' + i + '" class="divtab' + (active ? ' active' : '') + '" style="cursor:pointer;background:' + (active ? '#262a2e' : 'transparent') + ';border:none;border-right:' + divider + ';color:' + (active ? '#ffffff' : '#b4b8bc') + ';padding:11px 14px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;flex:1 1 0;min-width:118px;min-height:56px;border-radius:14px;position:relative;transition:background 250ms ease">' + inner + '</button>'; 385 }).join(''); 386 } 387 el('divTabs').innerHTML = tabHtml(); 388 el('divTabs').querySelectorAll('button').forEach(b => b.onclick = () => setDiv(+b.dataset.dt)); 389 el('divPrev').onclick = () => setDiv((S.div - 1 + divData.length) % divData.length); 390 el('divNext').onclick = () => setDiv((S.div + 1) % divData.length); 391 // Swipe left/right on the divisions card to navigate (touch devices) 392 (function () { 393 const card = document.querySelector('#divPanel .divcard') || el('divPanel'); 394 if (!card) return; 395 let x0 = 0, y0 = 0, t0 = 0, lx = 0, ly = 0; 396 card.addEventListener('touchstart', e => { const t = e.changedTouches[0]; if (!t) return; x0 = lx = t.clientX; y0 = ly = t.clientY; t0 = Date.now(); }, { passive: true }); 397 card.addEventListener('touchmove', e => { const t = e.changedTouches[0]; if (t) { lx = t.clientX; ly = t.clientY; } }, { passive: true }); 398 card.addEventListener('touchend', e => { 399 const t = e.changedTouches[0]; 400 const ex = t ? t.clientX : lx, ey = t ? t.clientY : ly; 401 const dx = ex - x0, dy = ey - y0; 402 if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy) * 1.4 && Date.now() - t0 < 700) { 403 setDiv((S.div + (dx < 0 ? 1 : -1) + divData.length) % divData.length); 404 } 405 }, { passive: true }); 406 })(); 407 // Re-fit the description when crossing the mobile/desktop breakpoint 408 window.addEventListener('resize', () => { const d = divData[S.div]; el('divDesc').innerHTML = d.descHtml || d.desc; clampDesc(); }); 409 410 // story photo layers (storefront + 2 extras) â built into the desktop frame and, 411 // when present, the mobile frame so both show the same angled slideshow. 412 const STORY_SRC = [{ id: 'story-storefront', src: 'img/story-storefront.webp' }, { id: 'story-x0', src: storyExtra[0] }, { id: 'story-x1', src: storyExtra[1] }]; 413 function buildStoryLayers(container) { 414 const layers = []; 415 STORY_SRC.forEach((o, i) => { 416 const layer = document.createElement('div'); 417 layer.style.cssText = 'position:absolute;inset:0;opacity:' + (i === 0 ? 1 : 0) + ';transition:opacity 1.1s ease'; 418 layer.appendChild(slotEl(o.id, o.src, 'cover')); 419 container.appendChild(layer); 420 layers.push(layer); 421 }); 422 return layers; 423 } 424 const storyLayers = buildStoryLayers(el('storyPhotos')); 425 const storyPhotosMobileEl = document.getElementById('storyPhotosMobile'); 426 const storyLayersMobile = storyPhotosMobileEl ? buildStoryLayers(storyPhotosMobileEl) : []; 427 428 // story stats (desktop cqw + mobile) 429 el('storyStats').innerHTML = storyStats.map(s => '<div style="display:flex;gap:1.2cqw;align-items:center"><div style="width:4cqw;height:4cqw;min-width:42px;min-height:42px;border-radius:50%;border:1px solid rgba(255,255,255,0.22);display:flex;align-items:center;justify-content:center;color:#cfcbc2;flex-shrink:0">' + s.icon + '</div><div><div data-kpi="' + s.n + '" style="font-family:\'Archivo\';font-weight:800;font-size:3.6cqw;line-height:1;color:#fff">' + s.n + '</div><div style="font-size:0.95cqw;letter-spacing:0.08em;color:#cbc7be;font-weight:700;margin:0.55cqw 0 0.3cqw">' + s.l + '</div><div style="font-size:0.8cqw;line-height:1.4;color:#8a867e">' + s.sub + '</div></div></div>').join(''); 430 el('storyStatsMobile').innerHTML = storyStats.map(s => '<div><div style="font-family:\'Archivo\';font-weight:800;font-size:34px;color:#fff">' + s.n + '</div><div style="font-size:12px;letter-spacing:0.06em;color:#cbc7be;font-weight:700;margin:6px 0 3px">' + s.l + '</div><div style="font-size:12px;color:#8a867e">' + s.sub + '</div></div>').join(''); 431 432 // partner rail dots, image layers, cells 433 function renderPartnerStatic() {
434 el('partnerRail').innerHTML = plist.map((p, i) => { 435 const a = i === activePartner(); 436 return '<div data-pr="' + i + '" style="display:flex;align-items:center;gap:12px;cursor:pointer"><span style="font-size:11px;font-weight:700;letter-spacing:0.1em;width:18px;color:' + (a ? '#161513' : '#b3afa6') + '">0' + (i + 1) + '</span><span style="width:9px;height:9px;border-radius:50%;background:' + (a ? '#161513' : 'transparent') + ';border:1px solid ' + (a ? '#161513' : '#c3bfb5') + ';box-sizing:border-box"></span></div>'; 437 }).join(''); 438 el('partnerRail').querySelectorAll('[data-pr]').forEach(d => d.onclick = () => setPartner(+d.dataset.pr)); 439 } 440 el('partnerImages').innerHTML = ''; 441 plist.forEach((p, i) => { 442 const layer = document.createElement('div'); 443 layer.style.cssText = 'position:absolute;inset:0;opacity:' + (i === 0 ? 1 : 0) + ';transition:opacity .8s ease;pointer-events:none;z-index:' + (i === 0 ? 2 : 1); 444 if (!p.noAssets) layer.appendChild(slotEl('pimg-' + i, 'img/pimg-' + i + '.webp', 'cover')); // no brand image yet -> clean dark panel 445 el('partnerImages').appendChild(layer); 446 }); 447 function renderPartnerCells() { 448 const a = activePartner(); 449 let h = plist.map((p, i) => { 450 const on = i === a; 451 const bar = p.src 452 ? '<div class="slot pbar" data-slot="pbar-' + i + '" data-fit="contain" data-src="' + p.src + '" style="height:clamp(26px,2.4vw,34px);width:100%;position:relative;filter:' + (on ? 'invert(1) brightness(2)' : 'none') + '"></div>' 453 : '<div style="height:clamp(26px,2.4vw,34px);width:100%;display:flex;align-items:center;justify-content:center;filter:' + (on ? 'invert(1) brightness(2)' : 'none') + '"><span style="font-family:\'Archivo\',sans-serif;font-weight:800;font-size:clamp(13px,1.1vw,16px);color:#161513;white-space:nowrap">' + p.name + '</span></div>'; 454 return '<div data-pc="' + i + '" style="flex:1;min-width:0;padding:clamp(16px,1.8vw,26px) clamp(10px,1.5vw,22px);background:' + (on ? '#141414' : '#ffffff') + ';border-right:1px solid #ededed;cursor:pointer;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px">' 455 + '<span style="position:absolute;top:10px;left:14px;font-size:10px;font-weight:700;letter-spacing:0.1em;color:' + (on ? '#7a7a7a' : '#b3afa6') + '">0' + (i + 1) + '</span>' 456 + bar 457 + '<span style="font-size:10px;letter-spacing:0.12em;font-weight:600;color:' + (on ? '#8a8a8a' : '#67645d') + '">SINCE ' + p.since + '</span></div>'; 458 }).join(''); 459 h += '<div class="pc-more-cell" style="flex:0.8;min-width:0;padding:clamp(16px,1.8vw,26px) clamp(12px,1.6vw,24px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px"><span class="pc-more" style="font-size:11px;letter-spacing:0.14em;font-weight:700;color:#6d6a62;text-align:center;line-height:1.5">AND MORE<br>PARTNERS</span><span style="width:34px;height:1px;background:#c8c4ba"></span></div>'; 460 el('partnerCells').innerHTML = h; 461 el('partnerCells').querySelectorAll('.pbar').forEach(sl => fillSlot(sl, sl.dataset.slot, sl.dataset.src, 'contain')); 462 el('partnerCells').querySelectorAll('[data-pc]').forEach(d => d.onclick = () => setPartner(+d.dataset.pc)); 463 } 464 el('partnerLogo').style.width = '100%'; 465 466 // milestones panels + mobile 467 el('msPanels').innerHTML = milestones.map(m => 468 '<div data-ms-panel style="position:absolute;inset:0;overflow:hidden;border-radius:14px;background:#151515;backface-visibility:hidden;transform-origin:center center;will-change:transform,opacity;box-shadow:0 28px 70px -26px rgba(0,0,0,.85)">' 469 + '<div class="slot" data-slot="' + m.slot + '"><img alt="" src="img/' + m.slot + '.webp"></div>' 470 + '<div style="position:absolute;inset:0;background:linear-gradient(90deg,rgba(10,10,10,.92) 0%,rgba(10,10,10,.72) 30%,rgba(10,10,10,.25) 62%,rgba(10,10,10,0) 85%)"></div>' 471 + '<div style="position:absolute;left:0;top:0;bottom:0;width:min(52%,420px);display:flex;flex-direction:column;justify-content:center;padding:clamp(28px,4vw,54px)">' 472 + '<div style="font-family:\'Archivo\';font-weight:800;font-size:clamp(40px,4.6vw,68px);line-height:0.9;color:#fff">' + m.year + '</div>' 473 + '<h3 style="font-family:\'Archivo\';font-weight:700;font-size:clamp(18px,1.9vw,26px);line-height:1.15;margin:clamp(12px,1.6vh,18px) 0 14px;color:#fff">' + m.title + '</h3>' 474 + '<div style="width:36px;height:2px;background:#7a7a7a;margin-bottom:14px"></div>' 475 + '<p style="color:#b5b5b5;
475font-size:clamp(13px,1.05vw,15px);line-height:1.6;margin:0;max-width:300px">' + m.desc + '</p></div>' 476 + '<div data-ms-dim style="position:absolute;inset:0;background:#0a0a0a;opacity:0.5;transition:opacity .45s ease;pointer-events:none"></div>' 477 + '<div data-ms-ring style="position:absolute;inset:0;border:1px solid rgba(255,255,255,.09);border-radius:14px;opacity:0;transition:opacity .45s ease;pointer-events:none"></div></div>' 478 ).join(''); 479 el('msMobile').innerHTML = milestones.map(m => 480 '<div style="position:relative;border-radius:14px;overflow:hidden;margin-bottom:16px;min-height:200px;background:#151515">' 481 + '<div class="slot" data-slot="' + m.slot + '-m"><img alt="" src="img/' + m.slot + '.webp"></div>' 482 + '<div style="position:absolute;inset:0;background:linear-gradient(90deg,rgba(10,10,10,.92),rgba(10,10,10,.4))"></div>' 483 + '<div style="position:relative;padding:24px"><div style="font-family:\'Archivo\';font-weight:800;font-size:42px;color:#fff">' + m.year + '</div>' 484 + '<h3 style="font-family:\'Archivo\';font-weight:700;font-size:20px;margin:10px 0 10px;color:#fff">' + m.title + '</h3>' 485 + '<div style="width:36px;height:2px;background:#7a7a7a;margin-bottom:12px"></div>' 486 + '<p style="color:#b5b5b5;font-size:14px;line-height:1.6;margin:0;max-width:340px">' + m.desc + '</p></div></div>' 487 ).join(''); 488 489 // Mobile milestones: horizontal swipe carousel with dots (desktop uses the 3D drum) 490 (function () { 491 const mob = el('msMobile'), dw = el('msDots'); 492 if (!mob || !dw) return; 493 const N = mob.children.length; 494 dw.innerHTML = milestones.map((m, i) => 495 '<button class="tapx" data-msdot="' + i + '" aria-label="' + m.year + '" title="' + m.year + '" style="width:8px;height:8px;border-radius:50%;border:none;padding:0;cursor:pointer;background:rgba(255,255,255,0.3);transition:width .25s ease,border-radius .25s ease,background .25s ease"></button>' 496 ).join(''); 497 const dots = Array.prototype.slice.call(dw.children); 498 const activeIdx = () => { 499 const cs = mob.children, mid = mob.scrollLeft + mob.clientWidth / 2; 500 let best = 0, bd = Infinity; 501 for (let i = 0; i < cs.length; i++) { const c = cs[i], center = c.offsetLeft + c.offsetWidth / 2, d = Math.abs(center - mid); if (d < bd) { bd = d; best = i; } } 502 return best; 503 }; 504 const sync = () => { const a = activeIdx(); dots.forEach((d, i) => { const on = i === a; d.style.background = on ? '#fff' : 'rgba(255,255,255,0.3)'; d.style.width = on ? '22px' : '8px'; d.style.borderRadius = on ? '5px' : '50%'; }); }; 505 let raf = 0; 506 mob.addEventListener('scroll', () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(sync); }, { passive: true }); 507 dots.forEach((d, i) => d.addEventListener('click', () => { const c = mob.children[i]; mob.scrollTo({ left: c.offsetLeft - (mob.clientWidth - c.offsetWidth) / 2, behavior: 'smooth' }); })); 508 window.addEventListener('resize', sync); 509 setTimeout(sync, 200); 510 511 /* Auto-advance the mobile carousel through all milestones. The timer is CLEARED 512 entirely whenever the carousel is off-screen or the tab is hidden (no work in 513 the background), and briefly held after any manual swipe/tap so it never fights 514 the user. On desktop the carousel is display:none (offsetParent === null), so 515 this stays dormant and the 3D drum takes over. */ 516 const AUTO_MS = 3600, RESUME_MS = 5000; 517 let autoTimer = 0, pausedUntil = 0, onScreen = false; 518 const shown = () => mob.offsetParent !== null; // true only in the mobile layout 519 const goTo = i => { const c = mob.children[i]; if (c) mob.scrollTo({ left: c.offsetLeft - (mob.clientWidth - c.offsetWidth) / 2, behavior: 'smooth' }); }; 520 const beat = () => { if (!onScreen || !shown() || Date.now() < pausedUntil) return; goTo((activeIdx() + 1) % N); }; 521 const startAuto = () => { if (!autoTimer && shown()) autoTimer = setInterval(beat, AUTO_MS); }; 522 const stopAuto = () => { if (autoTimer) { clearInterval(autoTimer); autoTimer = 0; } }; 523 const hold = () => { pausedUntil = Date.now() + RESUME_MS; }; 524 ['touchstart', 'pointerdown', 'wheel'].forEach(ev => mob.addEventListener(ev, hold, { passive: true }));
525 dots.forEach(d => d.addEventListener('click', hold)); 526 const io = new IntersectionObserver(es => es.forEach(e => { 527 onScreen = e.isIntersecting; 528 if (onScreen) startAuto(); else stopAuto(); 529 }), { threshold: 0.2 }); 530 io.observe(mob); 531 document.addEventListener('visibilitychange', () => { if (document.hidden) stopAuto(); else if (onScreen) startAuto(); }); 532 })(); 533 534 /* ---------- hero controller ---------- */ 535 function setHero(i) { 536 S.hero = ((i % heroImgs.length) + heroImgs.length) % heroImgs.length; 537 const pal = heroPalette()[S.hero]; 538 el('heroText').style.background = pal.heroBg; 539 el('heroKick').style.color = pal.heroKick; 540 el('heroHead').style.color = pal.heroHead; 541 el('heroSub').style.color = pal.heroSub; 542 el('heroCta').style.background = pal.heroCtaBg; 543 el('heroCta').style.color = pal.heroCtaTx; 544 el('heroLabel').textContent = '0' + (S.hero + 1); 545 el('heroLabel').style.color = pal.heroHead; 546 el('heroTrack').style.background = pal.heroTrack; 547 el('heroFill').style.background = pal.heroFill; 548 el('heroFill').style.width = Math.round((S.hero + 1) / heroImgs.length * 100) + '%'; 549 el('heroMuted').style.color = pal.heroMuted; 550 el('heroPartner').textContent = heroPartners[S.hero]; 551 const lite = heroLabelLight[S.hero] !== false; 552 el('heroPartner').style.color = lite ? '#ffffff' : '#141311'; 553 el('heroPartnerKick').style.color = lite ? 'rgba(255,255,255,0.7)' : '#5a5750'; 554 el('heroSlides').querySelectorAll('[data-h]').forEach(l => l.style.opacity = (+l.dataset.h === S.hero) ? 1 : 0); 555 el('heroDots').querySelectorAll('button').forEach(b => b.style.background = (+b.dataset.hd === S.hero) ? '#141311' : 'rgba(255,255,255,0.55)'); 556 } 557 let heroTimer = setInterval(() => setHero(S.hero + 1), 5000); 558 559 /* ---------- divisions controller ---------- */ 560 // Mobile-only: clamp the division description to 3 lines, trimming at a word 561 // boundary and stripping trailing punctuation so the ellipsis has no comma. 562 function clampDesc() { 563 const el = document.getElementById('divDesc'); 564 if (!el || window.innerWidth > 900) return; 565 const full = (divData[S.div] && divData[S.div].desc) || el.textContent || ''; 566 el.textContent = full; 567 const lh = parseFloat(getComputedStyle(el).lineHeight) || 22; 568 const maxH = lh * 3 + 2; 569 if (el.scrollHeight <= maxH) return; 570 const words = full.split(/\s+/); 571 const strip = s => s.replace(/[\s.,;:!?ââ-]+$/, ''); 572 let lo = 1, hi = words.length, best = 1; 573 while (lo <= hi) { 574 const mid = (lo + hi) >> 1; 575 el.textContent = strip(words.slice(0, mid).join(' ')) + 'â¦'; 576 if (el.scrollHeight <= maxH) { best = mid; lo = mid + 1; } else { hi = mid - 1; } 577 } 578 el.textContent = strip(words.slice(0, best).join(' ')) + 'â¦'; 579 } 580 function setDiv(i) { 581 S.div = ((i % divData.length) + divData.length) % divData.length; 582 const d = divData[S.div]; 583 el('divTagline').textContent = d.tagline; 584 el('divCounter').textContent = '0' + (S.div + 1); 585 el('divIcon').innerHTML = ic(d.icon); 586 if (d.cardLogo) { 587 el('divName').innerHTML = '<img loading="lazy" decoding="async" src="' + d.cardLogo + '" alt="' + d.name + '" style="height:' + (d.cardLogoH || 'clamp(32px,4vw,50px)') + ';width:auto;max-width:100%;object-fit:contain;object-position:left top;display:block' + (d.cardLogoDark ? ';filter:brightness(0)' : '') + '">'; 588 } else { 589 el('divName').textContent = d.name; 590 } 591 el('divDesc').innerHTML = d.descHtml || d.desc; 592 clampDesc(); 593 el('divMgr').textContent = d.mgr; 594 el('divContact').textContent = d.contact; 595 var vm = el('divMore'); 596 if (vm) { 597 if (d.soon) { 598 vm.textContent = 'Coming soon'; 599 vm.removeAttribute('href'); 600 vm.style.color = '#a7a39a'; vm.style.borderColor = '#d8d3c7'; vm.style.background = 'transparent'; vm.style.cursor = 'default'; vm.style.pointerEvents = 'none'; 601 } else { 602 vm.textContent = 'View More'; 603 vm.setAttribute('href', d.href || '#contact'); 604 vm.style.color = '#161513'; vm.style.borderColor = '#161513'; vm.style.background = ''; vm.style.cursor = ''; vm.style.pointerEvents = ''; 605 } 606 } 607 // featured brands 608 const featLabel = el('divBrandsLabel'); 609 if (featLabel) featLabel.textContent = d.services ? 'FEATURED
609SERVICES' : 'FEATURED BRANDS'; 610 // services division carries a longer description + a list â let its description size to content so the list/pill fit the fixed card 611 var _dd = el('divDesc'); if (_dd) _dd.style.height = d.services ? 'auto' : '200px'; 612 if (d.services) { 613 if (featLabel) featLabel.style.display = ''; 614 var sShown = d.services.slice(0, 2), sMore = d.services.length - sShown.length; 615 el('divBrands').innerHTML = '<div class="div-services" style="display:flex;flex-direction:column;align-items:flex-start;gap:12px;width:100%">' + sShown.map(function (s) { return '<div style="display:flex;gap:11px;align-items:center;font-size:14px;color:#5c594f;line-height:1.4"><span style="width:5px;height:5px;border-radius:50%;background:#161513;flex:none"></span><span>' + s + '</span></div>'; }).join('') + (sMore > 0 ? '<div style="align-self:flex-start;display:inline-flex;align-items:center;border:1px solid #d8d3c7;background:transparent;padding:9px 18px;border-radius:999px;margin-top:4px"><span style="font-family:\'Archivo\';font-weight:700;font-size:12px;letter-spacing:.03em;color:#57544c">+' + sMore + ' more services</span></div>' : '') + '</div>'; 616 } else if (d.brandsSoon) { 617 if (featLabel) featLabel.style.display = ''; 618 el('divBrands').innerHTML = '<div style="border:1px solid #d8d3c7;background:#fff;height:48px;min-width:120px;display:flex;align-items:center;justify-content:center;padding:0 22px;border-radius:12px"><span style="font-family:\'Archivo\';font-weight:600;font-size:13px;color:#a7a39a">Coming soon</span></div>'; 619 } else { 620 const raw = d.brands || []; 621 const named = raw.filter(n => !/^\+/.test(n)); 622 const moreEntry = raw.find(n => /^\+/.test(n)); 623 const moreNum = moreEntry ? (parseInt(moreEntry.replace(/\D/g, ''), 10) || 0) : 0; 624 const shown = named.slice(0, 3); 625 const moreTotal = (named.length - shown.length) + moreNum; 626 if (featLabel) featLabel.style.display = named.length ? '' : 'none'; 627 let bh = shown.map(name => { 628 const src = logoFor(name); 629 if (src) return '<div style="background:#0d0d0d;height:48px;width:126px;display:flex;align-items:center;justify-content:center;padding:0 20px;border-radius:12px"><div role="img" aria-label="' + name + '" style="width:100%;height:26px;filter:brightness(0) invert(1);background-image:url("' + src + '");background-size:contain;background-repeat:no-repeat;background-position:center"></div></div>'; 630 return '<div style="background:#0d0d0d;height:48px;min-width:78px;display:flex;align-items:center;justify-content:center;padding:0 22px;border-radius:12px"><span style="font-family:\'Archivo\';font-weight:700;font-size:13px;color:#fff">' + name + '</span></div>'; 631 }).join(''); 632 if (moreTotal > 0) bh += '<div style="border:1px solid #cfd1d4;background:#fff;height:48px;min-width:72px;display:flex;align-items:center;justify-content:center;padding:0 18px;border-radius:12px"><span style="font-family:\'Archivo\';font-weight:700;font-size:13px;color:#57544c">+' + moreTotal + ' more</span></div>'; 633 el('divBrands').innerHTML = bh; 634 } 635 // image layers 636 Array.from(el('divLayers').children).forEach((l, i) => { l.style.opacity = i === S.div ? 1 : 0; l.style.zIndex = i === S.div ? 2 : 1; }); 637 // tabs 638 el('divTabs').innerHTML = tabHtml(); 639 el('divTabs').querySelectorAll('button').forEach(b => b.onclick = () => setDiv(+b.dataset.dt)); 640 } 641 let divTimer = setInterval(() => setDiv(S.div + 1), 7000); 642 643 /* ---------- story image cycle ---------- */ 644 setInterval(() => { 645 S.storyImg++; 646 const active = S.storyImg % storyLayers.length; 647 storyLayers.forEach((l, i) => l.style.opacity = i === active ? 1 : 0); 648 storyLayersMobile.forEach((l, i) => l.style.opacity = i === active ? 1 : 0); 649 }, 4000); 650 651 /* ---------- partners controller ---------- */ 652 function activePartner() { const n = plist.length; return ((S.partner % n) + n) % n; } 653 function setPartner(i) { 654 S.partner = i; 655 const a = activePartner(); 656 const p = plist[a]; 657 if (p.src) { fillSlot(el('partnerLogo'), 'plogo-' + a, p.src, 'contain'); } 658 else { el('partnerLogo').innerHTML = '<span style="font-family:\'Archivo\',sans-serif;font-weight:800;font-size:clamp(24px,2.4vw,34px);letter-spacing:.01em;color:#161513;white-space:nowrap">' + p.name + '</span>'; } 659 el('partnerLogo').style.width = '100%'; 660 el('partnerQuote').textContent = p.quote; 661 // "View more" appears only when the quote is actually clamped/overflowing 662 var _more = el('partnerMore'); 663 if (_more) requestAnimationFrame(function () { var q = el('partnerQuote'); _more.style.display = (q.scrollHeight - q.clientHeight > 2) ? 'inline-flex' : 'none'; }); 664 var _avWrap = el('partnerAvatar').parentElement; 665 if (p.noAssets) { if (_avWrap) _avWrap.style.display = 'none'; } 666 else { if (_avWrap) _avWrap.style.display = ''; fillSlot(el('partnerAvatar'), 'pavatar-' + a, 'img/pavatar-' + a + '.webp', 'cover'); } 667 el('partnerPerson').textContent = p.person; 668 el('partnerRole').textContent = p.role; 669 el('partnerYears').textContent = p.years;
670 el('partnerSince').textContent = p.since; 671 Array.from(el('partnerImages').children).forEach((l, i) => { l.style.opacity = i === a ? 1 : 0; l.style.zIndex = i === a ? 2 : 1; }); 672 renderPartnerStatic(); 673 renderPartnerCells(); 674 } 675 let partnerTimer = setInterval(() => setPartner(S.partner + 1), 6000); 676 677 // full-testimonial popup (opened by the card's "View more") 678 function fillPartnerModal() { 679 const p = plist[activePartner()]; 680 const lg = el('pmLogo'), nm = el('pmName'); 681 if (lg && nm) { 682 if (p.src) { nm.style.display = 'none'; lg.style.display = ''; lg.innerHTML = ''; fillSlot(lg, 'pmlogo', p.src, 'contain'); } 683 else { lg.style.display = 'none'; nm.style.display = ''; nm.textContent = p.name; } 684 } 685 if (el('pmQuote')) el('pmQuote').textContent = p.quote; 686 if (el('pmPerson')) el('pmPerson').textContent = p.person; 687 if (el('pmRole')) el('pmRole').textContent = p.role; 688 if (el('pmYears')) el('pmYears').textContent = p.years; 689 } 690 function openPartnerModal() { const m = el('partnerModal'); if (!m) return; fillPartnerModal(); m.classList.add('open'); clearInterval(partnerTimer); } 691 function closePartnerModal() { const m = el('partnerModal'); if (!m) return; m.classList.remove('open'); clearInterval(partnerTimer); partnerTimer = setInterval(() => setPartner(S.partner + 1), 6000); } 692 if (el('partnerMore')) el('partnerMore').addEventListener('click', openPartnerModal); 693 if (el('partnerModal')) el('partnerModal').addEventListener('click', (e) => { if (e.target.closest('[data-pmclose]')) closePartnerModal(); }); 694 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePartnerModal(); }); 695 696 /* ---------- contact form ---------- */ 697 // Static site: route the message via mailto. The "I want toâ¦" dropdown decides 698 // the recipient â Partnerships and General Inquiry go to two different inboxes 699 // (each <option> carries its target address in data-mail). 700 // Partnership selection reveals extra fields (type, organisation, other info) 701 // and auto-fills the Subject with "Partnership Inquiry".
702 const cfIntent = el('cfIntent'), cfPart = el('cfPartnership'); 703 function syncPartnership() { 704 const on = cfIntent && cfIntent.value === 'partnership'; 705 if (cfPart) cfPart.style.display = on ? 'block' : 'none'; 706 const subj = el('cfSubject'); 707 if (subj) { 708 if (on) { if (!subj.value.trim()) subj.value = 'Partnership Inquiry'; } 709 else if (subj.value.trim() === 'Partnership Inquiry') { subj.value = ''; } 710 } 711 } 712 if (cfIntent) { cfIntent.addEventListener('change', syncPartnership); syncPartnership(); } 713 714 // Preselect "Partnership Opportunity" (and optionally a division) â used by the 715 // inner-page "Partner with us" CTAs and the homepage partner button. 716 function preselectPartnership(division) { 717 if (cfIntent) { cfIntent.value = 'partnership'; syncPartnership(); } 718 const dv = el('cfDivision'); 719 if (dv && division) dv.value = division; 720 } 721 // Deep-link from other pages: index.html?intent=partnership&division=<slug>#contact 722 (function () { 723 const p = new URLSearchParams(location.search || ''); 724 const intent = p.get('intent'), division = p.get('division'); 725 if (intent === 'partnership' || division) { 726 preselectPartnership(division || ''); 727 // scroll to the form itself (not the section top) so the preselected dropdowns are visible. 728 // Recompute the absolute position each time â images above load late and shift the layout. 729 const target = el('contactForm') || el('contact'); 730 if (target) { 731 const doScroll = function () { 732 const y = target.getBoundingClientRect().top + window.pageYOffset - 90; // clear sticky header 733 window.scrollTo({ top: y, behavior: 'smooth' }); 734 }; 735 setTimeout(doScroll, 200); 736 window.addEventListener('load', function () { setTimeout(doScroll, 200); }); 737 } 738 } 739 })(); 740 // Homepage's own "Want to partner with us?" button â preselect without a reload 741 const homePartnerBtn = document.querySelector('a.h-shop[href="#contact"]'); 742 if (homePartnerBtn) homePartnerBtn.addEventListener('click', function () { preselectPartnership(''); }); 743 744 // Paste your free Web3Forms access key here to have the WEBSITE send submissions 745 // straight to your inbox (get one in 30s at https://web3forms.com â enter your email, 746 // they mail you a key). While this is empty, the form falls back to opening the 747 // visitor's email app (mailto) so nothing breaks. 748 const WEB3FORMS_KEY = ''; 749 750 // ââ Contact-form routing âââââââââââââââââââââââââââââââââââââââââââââââââ 751 // All enquiries are SENT FROM one central mailbox (the EmailJS-connected account), 752 // and delivered TO the inbox chosen by the enquiry type â and, for partnerships, 753 // by the selected division. Multiple recipients = comma-separated (EmailJS To + mailto both accept it). 754 // PRODUCTION addresses (set 2026-08-30). 755 const MAIL_ROUTES = { 756 general: '[email protected]', // General Inquiry 757 support: '[email protected],[email protected],[email protected],[email protected]', // Product Support (all four) 758 // Partnership Opportunity â the head of the selected division: 759 divisions: { 760 'union-electronics': '[email protected],[email protected]', // Electronics (both) 761 'aluna': '[email protected]', 762 'fmcg': '[email protected]', 763 'labels': '[email protected]', 764 'union-services': '[email protected],[email protected],[email protected],[email protected]', 765 'commercial': '[email protected]' // Commercial AC & Appliance Projects 766 }, 767 fallback: '[email protected]' // partnership with no division picked, etc. 768 }; 769 function routeTo(intent, divisionKey) { 770 if (intent === 'partnership') return MAIL_ROUTES.divisions[divisionKey] || MAIL_ROUTES.fallback; 771 if (intent === 'support') return MAIL_ROUTES.support; 772 return MAIL_ROUTES[intent] || MAIL_ROUTES.fallback; 773 } 774 775 // EmailJS â client-side email that works on ANY hosting (GitHub Pages, a cloud 776 // server, anywhere): the visitor's browser sends the enquiry through YOUR connected 777 // mailbox. Fill the 3 IDs from the EmailJS dashboard (emailjs.com â Account) to turn 778 // it on; while any is blank the form uses the Web3Forms/mailto path below, so nothing 779 // breaks. 780 // SENT FROM: connect [email protected] as the EmailJS Email Service â that becomes 781 // the From address on every enquiry. 782 // SENT TO: the template's "To" field = {{to_email}} (dynamic) â the recipient is 783 // supplied by the code (routeTo â MAIL_ROUTES; all routes = [email protected] 784 // during testing), so we can retarget it without editing the template. NOTE: a public, 785 // dynamic {{to_email}} can in theory be abused as an open relay â fine for 786 // this testing phase; before the corporate launch, lock it down (fixed To,
787 // or move sending to a backend/Azure Function). 788 // reply_to is set to the visitor's email, so replies go straight back to the customer. 789 // Remember to add this site's domain under EmailJS â Account â Security â Allowed origins. 790 const EMAILJS = { publicKey: 'LN88tTw5xPwrUuH67', serviceId: 'service_87ki86s', templateId: 'template_zun7cpc' }; 791 let _emailjsReady = null; 792 function ensureEmailJS() { 793 if (_emailjsReady) return _emailjsReady; 794 _emailjsReady = new Promise((res, rej) => { 795 if (window.emailjs) { try { emailjs.init({ publicKey: EMAILJS.publicKey }); } catch (e) {} return res(); } 796 const s = document.createElement('script'); 797 s.src = 'emailjs.min.js'; // self-hosted (bundled) â no external CDN dependency 798 s.onload = () => { try { emailjs.init({ publicKey: EMAILJS.publicKey }); } catch (e) {} res(); }; 799 s.onerror = rej; 800 document.head.appendChild(s); 801 }); 802 return _emailjsReady; 803 } 804 805 el('contactForm').addEventListener('submit', e => { 806 e.preventDefault(); 807 const sel = el('cfIntent'); 808 const opt = sel.options[sel.selectedIndex]; 809 const divKey = el('cfDivision') ? el('cfDivision').value : ''; 810 const to = routeTo(sel.value, divKey); // central send â routed by enquiry type / division 811 const name = (el('cfName').value || '').trim(); 812 const email = (el('cfEmail').value || '').trim(); 813 const phone = (el('cfPhone').value || '').trim(); 814 const subjIn = (el('cfSubject').value || '').trim(); 815 const msg = (el('cfMessage').value || '').trim(); 816 const isPartner = sel.value === 'partnership'; 817 const subject = (subjIn || (opt ? opt.text : 'Website enquiry')) + (name ? ' â ' + name : ''); 818 let division = '', partType = '', org = '', other = ''; 819 const lines = [ 820 'Enquiry type: ' + (opt ? opt.text : ''), 821 'Name: ' + name, 822 'Email: ' + email, 823 'Phone: ' + phone 824 ]; 825 if (isPartner) { 826 const dvSel = el('cfDivision'); 827 division = dvSel && dvSel.value ? ((dvSel.options[dvSel.selectedIndex] || {}).text || '') : ''; 828 partType = el('cfPartType') ? el('cfPartType').value : ''; 829 org = el('cfOrg') ? (el('cfOrg').value || '').trim() : ''; 830 other = el('cfOther') ? (el('cfOther').value || '').trim() : ''; 831 if (division) lines.push('Division: ' + division); 832 lines.push('Partnership type: ' + partType); 833 lines.push('Organisation: ' + org); 834 lines.push('Other information: ' + other); 835 } 836 lines.push('', msg); 837 838 const btn = el('submitBtn'); 839 const setBtn = t => { btn.childNodes[0].nodeValue = t; }; 840 // Nothing goes out half-filled: a name, a working email, a phone number and a 841 // message are all needed, and a partnership enquiry has to say which division it 842 // is about â without that it lands in the fallback inbox and is forwarded by hand. 843 var EMAIL_OK = new RegExp('^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'); 844 var DIGITS = function (v) { return String(v).replace(new RegExp('[^0-9]', 'g'), ''); }; 845 var flag = function (field, label) { 846 if (field) { 847 field.style.borderColor = '#c0392b'; 848 field.focus(); 849 var clear = function () { field.style.borderColor = ''; }; 850 field.addEventListener('input', clear, { once: true }); 851 field.addEventListener('change', clear, { once: true }); 852 } 853 setBtn(label); 854 setTimeout(function () { setBtn('SEND MESSAGE'); }, 3000); 855 }; 856 // A reply needs somewhere to go and someone to reply to, so these are asked for 857 // as well. The email pattern is built from a string rather than written as a 858 // regular expression, so nothing can be lost when this file is generated. 859 var EMAIL_OK = new RegExp('^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'); 860 var DIGITS = function (v) { return String(v).replace(new RegExp('[^0-9]', 'g'), ''); }; 861 if (!name) { flag(el('cfName'), 'ADD YOUR NAME '); return; } 862 if (!email) { flag(el('cfEmail'), 'ADD YOUR EMAIL '); return; } 863 if (!EMAIL_OK.test(email)) { flag(el('cfEmail'), 'CHECK YOUR EMAIL '); return; } 864 if (!phone || DIGITS(phone).length < 6) { flag(el('cfPhone'), 'ADD YOUR PHONE NUMBER '); return; } 865 if (!msg) { flag(el('cfMessage'), 'WRITE A MESSAGE '); return; } 866 if (isPartner && !divKey) { flag(el('cfDivision'), 'CHOOSE A DIVISION '); return; } 867 const openMail = label => { 868 window.location.href = 'mailto:' + to + '?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(lines.join('\n')); 869 setBtn(label || 'OPENING EMAIL⦠'); 870 setTimeout(() => setBtn('SEND MESSAGE'), 4000); 871 }; 872 873 // Preferred: EmailJS (client-side; works on static or cloud hosting). Sends the 874 // enquiry through your connected mailbox, routed to the selected option's inbox. 875 if (EMAILJS.publicKey && EMAILJS.serviceId && EMAILJS.templateId) { 876 if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setBtn('CHECK YOUR EMAIL '); setTimeout(() => setBtn('SEND MESSAGE'), 3000); return; } 877 btn.disabled = true; setBtn('SENDING⦠'); 878 // Two message parts for one template: partner_details renders inside a BOLD element in the 879 // template (blank for general enquiries â nothing shows), message_text is the visitor's message 880 // in normal weight. EmailJS escapes HTML inside variables, so the bold styling lives in the template. 881 var partnerDetails = isPartner 882 ? ('Division: ' + division + '\nPartnership type: ' + partType + '\nOrganisation: ' + org + '\nOther information: ' + other + '\n\n') 883 : ''; 884 ensureEmailJS() 885 .then(() => emailjs.send(EMAILJS.serviceId, EMAILJS.templateId, { 886 to_email: to, subject: subject, from_name: name || 'Website visitor', reply_to: email, 887 name: name, email: email, phone: phone, 888 enquiry_type: opt ? opt.text : '', 889 division: division, partnership_type: partType, organisation: org, other_information: other, 890 partner_details: partnerDetails, // bold block in the template (blank for general enquiries) 891 message_text: msg, // the visitor's message (normal weight) 892 message: lines.join('\n') // full plain-text block (fallback) 893 })) 894 .then(() => { 895 setBtn('MESSAGE SENT â '); 896 el('contactForm').reset(); syncPartnership(); 897 showFormModal(); // "your request has been submitted" popup 898 setTimeout(() => { setBtn('SEND MESSAGE'); btn.disabled = false; }, 5000); 899 }) 900 .catch(() => { btn.disabled = false; openMail('OPENING EMAIL⦠'); }); // fallback so the enquiry isn't lost 901 return; 902 } 903 904 // No form service configured yet â open the visitor's email app. 905 if (!WEB3FORMS_KEY) { openMail(); return; } 906 907 // Basic validation before a real send. 908 if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setBtn('CHECK YOUR EMAIL '); setTimeout(() => setBtn('SEND MESSAGE'), 3000); return; } 909 910 // Send through the website (no page reload; lands in the inbox tied to the key). 911 btn.disabled = true; setBtn('SENDING⦠'); 912 fetch('https://api.web3forms.com/submit', { 913 method: 'POST', 914 headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, 915 body: JSON.stringify({ 916 access_key: WEB3FORMS_KEY, 917 subject: subject, 918 from_name: name || 'Website visitor', 919 name: name, email: email, phone: phone, 920 enquiry_type: opt ? opt.text : '', 921 division: division, partnership_type: partType, organisation: org, other_information: other, 922 message: lines.join('\n'), 923 botcheck: '' 924 }) 925 }) 926 .then(r => r.json()) 927 .then(d => { 928 if (d && d.success) { 929 setBtn('MESSAGE SENT â '); 930 el('contactForm').reset(); syncPartnership(); 931 showFormModal(); // "your request has been submitted" popup 932 setTimeout(() => { setBtn('SEND MESSAGE'); btn.disabled = false; }, 5000); 933 } else { throw new Error('service'); } 934 }) 935 .catch(() => { btn.disabled = false; openMail('OPENING EMAIL⦠'); }); // fallback so the enquiry isn't lost 936 }); 937 938 // Contact "request submitted" popup â shown after the website sends the enquiry. 939 function showFormModal() { var m = el('formModal'); if (m) m.classList.add('open'); } 940 (function () { 941 var m = document.getElementById('formModal'); if (!m) return; 942 var close = function () { m.classList.remove('open'); }; 943 m.addEventListener('click', function (e) { if (e.target.closest('[data-fm]')) close(); }); 944 document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); }); 945 })(); 946 947 /* ---------- KPI count-up ---------- */ 948 function initKpis() { 949 const els = Array.from(document.querySelectorAll('[data-kpi]')); 950 if (!els.length) return; 951 let done = false;
952 els.forEach(e => { const raw = e.getAttribute('data-kpi'); e.__t = parseInt(raw.replace(/[^0-9]/g, ''), 10) || 0; e.__s = raw.replace(/[0-9]/g, ''); e.textContent = '0' + e.__s; }); 953 const animate = () => { 954 const dur = 1600, start = performance.now(); 955 const tick = now => { const t = Math.min(1, (now - start) / dur); const e2 = 1 - Math.pow(1 - t, 3); els.forEach(e => e.textContent = Math.round(e.__t * e2) + e.__s); if (t < 1) requestAnimationFrame(tick); }; 956 requestAnimationFrame(tick); 957 }; 958 const obs = new IntersectionObserver(ents => { ents.forEach(en => { if (en.isIntersecting && !done) { done = true; animate(); obs.disconnect(); } }); }, { threshold: 0.4 }); 959 els.forEach(e => obs.observe(e)); 960 } 961 962 /* ---------- milestones 3D drum ---------- */ 963 function initMilestones() { 964 const sec = document.querySelector('[data-ms-section]'); 965 if (!sec) return; 966 const panels = Array.from(document.querySelectorAll('[data-ms-panel]')); 967 const counter = document.querySelector('[data-ms-counter]'); 968 const rail = document.querySelector('[data-ms-rail]'); 969 const progdot = document.querySelector('[data-ms-progdot]'); 970 const prevBtn = document.querySelector('[data-ms-prev]'); 971 const nextBtn = document.querySelector('[data-ms-next]'); 972 const N = panels.length; 973 if (!N) return; 974 const pad = n => (n < 10 ? '0' + n : '' + n); 975 const STEP = 60, FACTOR = 0.5 / Math.tan((STEP / 2) * Math.PI / 180); 976 const measure = () => panels.forEach(p => { const vp = p.closest('[data-ms-viewport]'); p.__r = (vp ? vp.offsetHeight : 400) * FACTOR; }); 977 measure(); 978 let target = 0, cur = 0, lastActive = -1, running = false, prog = 0; 979 panels.forEach(p => { p.__dim = p.querySelector('[data-ms-dim]'); p.__ring = p.querySelector('[data-ms-ring]'); }); 980 const setActive = active => { 981 panels.forEach((p, i) => { const on = i === active; if (p.__dim) p.__dim.style.opacity = on ? '0' : '0.5'; if (p.__ring) p.__ring.style.opacity = on ? '1' : '0'; }); 982 if (counter) counter.innerHTML = pad(active + 1) + ' <span style="color:#5a5a5a">/ ' + pad(N) + '</span>'; 983 }; 984 const render = raw => { 985 panels.forEach((p, i) => { 986 const ang = (i - raw) * STEP; 987 const vis = Math.abs(ang) < 91; 988 const op = vis ? Math.max(0, Math.pow(Math.cos(ang * Math.PI / 180), 0.9)) : 0; 989 const r = p.__r || 300; 990 p.style.transform = 'translateZ(' + (-r).toFixed(0) + 'px) rotateX(' + (-ang).toFixed(2) + 'deg) translateZ(' + r.toFixed(0) + 'px)'; 991 p.style.opacity = op.toFixed(3); 992 p.style.zIndex = vis ? String(100 - Math.round(Math.abs(ang))) : '0'; 993 }); 994 if (rail && progdot) progdot.style.top = (Math.min(1, Math.max(0, raw / (N - 1))) * (rail.offsetHeight - 7)).toFixed(1) + 'px'; 995 const active = Math.min(N - 1, Math.round(raw)); 996 if (active !== lastActive) { lastActive = active; setActive(active); } 997 }; 998 const tick = () => { cur += (target - cur) * 0.12; if (Math.abs(target - cur) < 0.0006) { cur = target; render(cur); running = false; return; } render(cur); requestAnimationFrame(tick); }; 999 const kick = () => { if (!running) { running = true; requestAnimationFrame(tick); } }; 1000 const go = n => { prog = Math.max(0, Math.min(N - 1, n)); target = prog; lastWheel = Date.now(); kick(); }; 1001 const vp = document.querySelector('[data-ms-viewport]'); 1002 const headerEl = document.querySelector('header'); 1003 let engaged = false, lastWheel = 0; 1004 const SENS = 520; 1005 const onWheel = e => { 1006 const rect = sec.getBoundingClientRect(), vh = window.innerHeight; 1007 const hdr = headerEl ? headerEl.offsetHeight : 74; 1008 // take over the wheel only once the section is snapped fully into place (its top resting at the 1009 // sticky header). scroll-snap-stop:always lands it there without overshoot, so the barrel never 1010 // fights the snap. Any partial/half-scrolled state -> the page scrolls (and snaps) normally. 1011 const cap = Math.max(10, vh * 0.03); 1012 const near = rect.top >= -cap && rect.top <= hdr + cap; 1013 const dir = e.deltaY > 0 ? 1 : -1; 1014 const atEnd = (dir > 0 && prog >= N - 1 - 0.001) || (dir < 0 && prog <= 0.001); 1015 if (!near || atEnd) { engaged = false; return; } 1016 e.preventDefault(); 1017 engaged = true; 1018 prog = Math.max(0, Math.min(N - 1, prog + e.deltaY / SENS)); 1019 target = prog; lastWheel = Date.now(); kick(); 1020 }; 1021 if (vp) vp.addEventListener('wheel', onWheel, { passive: false }); 1022 if (prevBtn) prevBtn.onclick = () => go(Math.round(prog) - 1); 1023 if (nextBtn) nextBtn.onclick = () => go(Math.round(prog) + 1); 1024 window.addEventListener('resize', () => { measure(); render(cur); }); 1025 /* Auto-advance the drum, but only in the desktop layout AND while the section is 1026 actually on-screen. The timer is cleared entirely off-screen / when the tab is 1027 hidden / on mobile (where the swipe carousel takes over), so nothing animates in 1028 the background. */ 1029 let drumTimer = 0, drumOn = false;
1030 const drumBeat = () => { 1031 if (window.innerWidth <= 900) return; 1032 const rect = sec.getBoundingClientRect(), vh = window.innerHeight; 1033 const covers = rect.top <= vh * 0.5 && rect.bottom >= vh * 0.5; 1034 if (covers && Date.now() - lastWheel > 3200) go((Math.round(prog) + 1) % N); 1035 }; 1036 const startDrum = () => { if (!drumTimer && window.innerWidth > 900) drumTimer = setInterval(drumBeat, 3400); }; 1037 const stopDrum = () => { if (drumTimer) { clearInterval(drumTimer); drumTimer = 0; } }; 1038 const drumIO = new IntersectionObserver(es => es.forEach(e => { 1039 drumOn = e.isIntersecting && e.intersectionRatio >= 0.45; 1040 if (drumOn) startDrum(); else stopDrum(); 1041 }), { threshold: [0, 0.45, 1] }); 1042 drumIO.observe(sec); 1043 document.addEventListener('visibilitychange', () => { if (document.hidden) stopDrum(); else if (drumOn) startDrum(); }); 1044 cur = target; render(cur); 1045 } 1046 1047 /* ---------- fit hero to viewport minus header ---------- */ 1048 function fitHero() { const h = document.querySelector('header'); if (h) document.documentElement.style.setProperty('--hdr', h.offsetHeight + 'px'); } 1049 window.addEventListener('resize', fitHero); 1050 1051 /* ---------- init ---------- */ 1052 setHero(0); 1053 setDiv(0); 1054 setPartner(0); 1055 fitHero(); 1056 setTimeout(fitHero, 300); 1057 setTimeout(layoutAll, 60); 1058 setTimeout(layoutAll, 400); 1059 setTimeout(observeSlots, 80); 1060 setTimeout(() => { initMilestones(); initKpis(); }, 150); 1061 window.addEventListener('load', () => { layoutAll(); fitHero(); }); 1062 1063})();
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.