1<!DOCTYPE html> 2<html lang="sv"> 3 4<head> 5<link rel="alternate" href="https://vecanis.se/customer/login" hreflang="sv" /> 6<link rel="alternate" href="https://vecanis.se/en/customer/login" hreflang="en" /> 7<meta property="ix:host" content="cdn.quickbutik.com/images"> 8 <title>Vecanis</title> 9 <meta charset="utf-8"> 10 11 <meta name="author" content="Quickbutik"> 12 <meta name="description" content="Vegan stuff for vegan dogs!"> 13 14 <meta property="og:description" content="Vegan stuff for vegan dogs!"> 15 <meta property="og:title" content="Vecanis"> 16 <meta property="og:url" content="https://vecanis.se/customer/login"> 17 <meta property="og:site_name" content="Vecanis"> 18 <meta property="og:type" content="website"> 19 <meta property="og:image" content=""> 20 21 <meta property="twitter:card" content="summary"> 22 <meta property="twitter:title" content="Vecanis"> 23 <meta property="twitter:description" content="Vegan stuff for vegan dogs!"> 24 <meta property="twitter:image" content=""> 25 26 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 27 28 <link rel="canonical" href="https://vecanis.se/customer/login"> 29 30 <!-- Google Fonts --> 31 32 33 34 35 36 37 38 <!-- Preload images --> 39 <link rel="preload" as="image" href="https://cdn.quickbutik.com/images/2666L/templates/lua/assets/logo.png?s=96971&auto=format&fit=clip&h=160&w=600"> 40 41 <!-- Favicon --> 42 <link rel="icon" type="image/png" href="https://cdn.quickbutik.com/images/2666L/templates/lua/assets/favicon.png?s=96971&auto=format"> 43 44 <!-- Components --> 45
45<script defer src="https://storage.quickbutik.com/templates/lua/js/settings.js?96971"></script>
45 46
46<script defer src="https://storage.quickbutik.com/templates/lua/js/tailwind-styling.js?96971"></script>
46 47 48 <!-- Quickbutik --> 49
50<script type="text/javascript">var qs_store_url = "https://vecanis.se";</script>
vendor: 1 bytes, line 50
50
51<script type="text/javascript">var qs_store_apps_data = {"location":"\/customer\/login","products":[],"product_id":null,"category_id":null,"category_name":null,"fetch":"fetch","order":null,"search":null,"recentPurchaseId":null}; var qs_store_apps = [];</script>
vendor: 1 bytes, line 51
51
52<script defer type="text/javascript" src="/assets/qb_essentials_alpine.js?v=20260819a"></script>
52 53 54
54<script defer src="https://storage.quickbutik.com/templates/lua/js/appsOverrides.js?96971"></script>
54 55 56 <!-- Libraries --> 57
57<script defer type="text/javascript" src="/assets/imgix.min.js?v=290323"></script>
57 58
58<script defer src="https://storage.quickbutik.com/templates/lua/js/swiper-bundle.min.js?96971"></script>
58 59
59<script defer src="https://storage.quickbutik.com/templates/lua/js/sliders.js?96971"></script>
59 60
60<script defer src="https://storage.quickbutik.com/templates/lua/js/photoswipe-lightbox.umd.min.js?96971"></script>
60 61
61<script defer src="https://storage.quickbutik.com/templates/lua/js/photoswipe.umd.min.js?96971"></script>
61 62 63 <!-- Default parameters for Imgix --> 64
64<script>document.addEventListener("DOMContentLoaded", () => { imgix.config.defaultParams = { auto: 'format' } })</script>
64 65 66 <!-- Scripts in head --> 67 <link rel="icon" type="image/png" href="https://s3-eu-west-1.amazonaws.com/storage.quickbutik.com/stores/2666L/files/favicon.ico"> 68 69 70 71 <!-- Stylesheet --> 72 <style>[x-cloak]{display: none !important;}img[ix-sizes="auto"]{display:block;width:100%;}img[src=""],img[src="#"],img:not([src]){visibility: hidden;}</style> 73 <link rel="preload" href="https://storage.quickbutik.com/templates/lua/css/swiper-bundle.min.css?96971" as="style" onload="this.onload=null;this.rel='stylesheet'"> 74 <noscript><link rel="stylesheet" href="https://storage.quickbutik.com/templates/lua/css/swiper-bundle.min.css?96971"></noscript> 75 <link rel="preload" href="https://storage.quickbutik.com/templates/lua/css/photoswipe.css?96971" as="style" onload="this.onload=null;this.rel='stylesheet'"> 76 <noscript><link rel="stylesheet" href="https://storage.quickbutik.com/templates/lua/css/photoswipe.css?96971"></noscript> 77 <link type="text/css" rel="stylesheet" href="https://storage.quickbutik.com/stores/2666L/templates/lua/css/theme.css?96971">
78<script type="text/javascript" defer>document.addEventListener('DOMContentLoaded', () => { 79 document.body.setAttribute('data-qb-page', 'list'); 80});</script>
80<script type="text/javascript" defer>(function () { 81 const MAX_GET_QUERY_LENGTH = 2000; 82 83 window.apps = window.apps ?? {}; 84 85 window.registerApp = function (app) { 86 console.debug('registerApp', app); 87 window.apps[app.identifier] = app; 88 }; 89 90 document.addEventListener('DOMContentLoaded', () => { 91 const page = document.body.dataset?.qbPage; 92 const bodyFormData = new FormData(); 93 94 const productIds = [ 95 ...new Set( 96 [...document.querySelectorAll('[data-qb-selector="product-item"]')] 97 .map((product) => product.dataset.productId?.trim()) 98 .filter(Boolean) 99 ), 100 ]; 101 102 bodyFormData.set('location', window.location.pathname + window.location.search); 103 bodyFormData.set('products', productIds.join(',')); 104 bodyFormData.set( 105 'category_id', 106 document.querySelector('[data-qb-category-id]')?.dataset?.qbCategoryId || '' 107 ); 108 bodyFormData.set( 109 'product_id', 110 ['product'].includes(page) && document.querySelector('[data-product-id]')?.dataset?.productId || '' 111 ); 112 113 bodyFormData.set('fetch', 'fetch'); 114 bodyFormData.set('order', ''); 115 bodyFormData.set('search', ''); 116 bodyFormData.set('recentPurchaseId', ''); 117 118 const visitedProductId = bodyFormData.get('product_id'); 119 if (visitedProductId) { 120 const visit = new URLSearchParams({ product_id: visitedProductId }); 121 if (!(navigator.sendBeacon && navigator.sendBeacon(qs_store_url + '/apps/visit', visit))) { 122 fetch(qs_store_url + '/apps/visit', { method: 'post', body: visit, keepalive: true }).catch(() => {}); 123 } 124 } 125 126 const query = [...bodyFormData.entries()] 127 .map(([key, value]) => 128 encodeURIComponent(key) + '=' + 129 (key === 'products' ? productIds.map(encodeURIComponent).join(',') : encodeURIComponent(value)) 130 ) 131 .join('&'); 132 133 const postApps = () => { 134 const postData = new FormData(); 135 bodyFormData.forEach((value, key) => postData.append(key, value)); 136 postData.set('variants', JSON.stringify(typeof qs_options !== 'undefined' ? qs_options : [])); 137 138 return fetch(qs_store_url + '/apps/fetch', { method: 'post', body: postData }).then((res) => res.json()); 139 }; 140 141 const appsResponse = 142 query.length <= MAX_GET_QUERY_LENGTH 143 ? fetch(qs_store_url + '/apps/fetch?' + query, { method: 'get' }) 144 .then((res) => { 145 if (!res.ok || !res.headers.get('X-QB-Apps-Fetch')) { 146 throw new Error('GET /apps/fetch not accepted (' + res.status + ')'); 147 } 148 149 return res.json(); 150 }) 151 .catch(postApps) 152 : postApps(); 153 154 appsResponse 155 .then((data) => { 156 console.debug('Fetch apps success', data); 157 158 if (typeof Alpine === 'undefined') { 159 document.addEventListener('alpine:init', () => { 160 initApps(data); 161 }) 162 } else { 163 initApps(data); 164 } 165 }) 166 .catch((error) => { 167 console.debug('Fetch apps error', error); 168 }); 169 }); 170 171 function initApps(appData) { 172 const page = document.querySelector('[data-qb-page]')?.dataset?.qbPage; 173 174 console.debug('initApps', appData, page, window.apps); 175 176 if (page) { 177 Object.keys(window.apps).forEach((appIdentifier) => { 178 if (window.apps[appIdentifier].pages.includes(page)) { 179 let appOverrides = window?.appsOverrides?.[appIdentifier]; 180 181 if (appOverrides) { 182 window.apps[appIdentifier] = merge( 183 window.apps[appIdentifier], 184 appOverrides 185 ); 186 } 187 188 window.apps[appIdentifier].onMount({ appData, page }); 189 } 190 }); 191 } 192 } 193 194 function isObject(obj) { 195 return obj !== null && typeof obj === 'object'; 196 } 197 198 function merge(target, source) { 199 if (!isObject(target) || !isObject(source)) { 200 throw new Error('Both target and source must be objects.'); 201 } 202 203 for (const key in source) { 204 if (source.hasOwnProperty(key)) { 205 const targetValue = target[key]; 206 const sourceValue = source[key]; 207 208 if (isObject(sourceValue) && isObject(targetValue)) { 209 target[key] = merge(targetValue, sourceValue); 210 } else { 211 target[key] = sourceValue; 212 } 213 } 214 } 215 216 return target; 217 } 218})(); 219</script>
219<script src="/assets/shopassets/cookieconsent/cookieconsent.js?v=230"></script>
219 220<style> 221 :root { 222 --cc-btn-primary-bg: #222222 !important; 223 --cc-btn-primary-text: #fff !important; 224 --cc-btn-primary-hover-bg: #222222 !important; 225 --cc-btn-primary-hover-text: #fff !important; 226 } 227</style> 228<style> 229 html.force--consent, 230 html.force--consent body{ 231 height: auto!important; 232 width: 100vw!important; 233 } 234</style> 235 236
236<script type="text/javascript"> 237 var qb_cookieconsent = initCookieConsent(); 238 var qb_cookieconsent_blocks = [{"title":"Vi anv\u00e4nder cookies","description":"Vecanis anv\u00e4nder cookies - inte fr\u00e5n ugnen, utan de digitala. Genom att forts\u00e4tta p\u00e5 sidan godk\u00e4nner du anv\u00e4ndandet av cookies."},{"title":"N\u00f6dv\u00e4ndiga cookies *","description":"Dessa cookies \u00e4r n\u00f6dv\u00e4ndiga f\u00f6r att butiken ska fungera korrekt och g\u00e5r inte att st\u00e4nga av utan att beh\u00f6va l\u00e4mna denna webbplats. De anv\u00e4nds f\u00f6r att funktionalitet som t.ex. varukorg, skapa ett konto och annat ska fungera korrekt.","toggle":{"value":"basic","enabled":true,"readonly":true}},{"title":"Statistik och prestanda","description":"Dessa cookies g\u00f6r att vi kan r\u00e4kna bes\u00f6k och trafik och d\u00e4rmed ta reda p\u00e5 saker som vilka sidor som \u00e4r mest popul\u00e4ra och hur bes\u00f6karna r\u00f6r sig p\u00e5 v\u00e5r webbplats. All information som samlas in genom dessa cookies \u00e4r sammanst\u00e4lld, vilket inneb\u00e4r att den \u00e4r anonym. Om du inte till\u00e5ter dessa cookies kan vi inte ge dig en lika skr\u00e4ddarsydd upplevelse.","toggle":{"value":"analytics","enabled":false,"readonly":false},"cookie_table":[{"col1":"_ga","col2":"google.com","col3":"2 years","col4":"Google Analytics"},{"col1":"_gat","col2":"google.com","col3":"1 minute","col4":"Google Analytics"},{"col1":"_gid","col2":"google.com","col3":"1 day","col4":"Google Analytics"}]},{"title":"Marknadsf\u00f6ring","description":"Dessa kakor s\u00e4tts via v\u00e5r webbplats av v\u00e5ra annonspartners f\u00f6r att skapa en profil utifr\u00e5n dina intressen och visa relevanta annonser p\u00e5 andra webbplatser. De kan samla information om ditt surfbeteende och anv\u00e4ndas f\u00f6r att identifiera din webbl\u00e4sare och internetenhet. Vissa annonspartners kan \u00e4ven ta emot hashad kontaktinformation (till exempel e-postadress) f\u00f6r att f\u00f6rb\u00e4ttra annonsm\u00e5lgrupperingen. Om du inte till\u00e5ter dessa kakor kommer du att uppleva mindre riktad annonsering.","toggle":{"value":"marketing","enabled":false,"readonly":false}}]; 239</script>
239 240<link rel="stylesheet" href="https://storage.quickbutik.com/stores/2666L/templates/lua/css/tailwind.css?96971" /> <!-- Google Tag Manager --> 241
241<script> 242 window.dataLayer = window.dataLayer || []; 243 function gtag(){dataLayer.push(arguments);} 244 245 gtag('consent', 'default', { 246 'analytics_storage': 'denied', 247 'ad_storage': 'denied', 248 'ad_user_data': 'denied', 249 'ad_personalization': 'denied', 250 'wait_for_update': 500, 251 }); 252 253 // Update consent if already granted (returning visitor with cc_cookie) 254 (function(){ 255 try { 256 var m = document.cookie.match(/(?:^|;\s*)cc_cookie=([^;]+)/); 257 if (m) { 258 var c = JSON.parse(decodeURIComponent(m[1])); 259 if (c && c.level) { 260 var hasAnalytics = c.level.indexOf('analytics') !== -1; 261 var hasMarketing = c.level.indexOf('marketing') !== -1; 262 if (hasAnalytics || hasMarketing) { 263 gtag('consent', 'update', { 264 'analytics_storage': hasAnalytics ? 'granted' : 'denied', 265 'ad_storage': hasMarketing ? 'granted' : 'denied', 266 'ad_user_data': hasMarketing ? 'granted' : 'denied', 267 'ad_personalization': hasMarketing ? 'granted' : 'denied', 268 }); 269 } 270 } 271 } 272 } catch(e) {} 273 })(); 274 275 // Initialize GTM
vendor: 348 bytes, lines 275-280
275 276 (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': 277 new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], 278 j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 279 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); 280 })(window,document,'script','dataLayer','
280GTM-WTRG25FS
vendor: 9 bytes, lines 280-282
280'); 281 282
282// Handle consent updates (user clicks Accept) 283 document.addEventListener('QB_CookieConsentAccepted', function(event) { 284 var cookie = qbGetEvent(event).cookie; 285 var getConsentLevel = function(level) { return (cookie && cookie.level && cookie.level.includes(level)) ? 'granted' : 'denied'; }; 286 287 gtag('consent', 'update', { 288 'analytics_storage': getConsentLevel('analytics'), 289 'ad_storage': getConsentLevel('marketing'), 290 'ad_user_data': getConsentLevel('marketing'), 291 'ad_personalization': getConsentLevel('marketing'), 292 }); 293 }); 294 </script>
294 295 <!-- End Google Tag Manager --> 296 297 <!-- Google Tag Manager (noscript) -->
vendor: 72 bytes, lines 297-298
297 298 <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=
298GTM-WTRG25FS
vendor: 91 bytes, lines 298-300
298" 299 height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript> 300
300<!-- End Google Tag Manager (noscript) --></head> 301 302<body class="antialiased bg-body text-body font-body"> 303 <section 304 data-qb-area="header" data-qb-element="" 305 x-data="{navMenuOpen: false}" 306 @keydown.escape="navMenuOpen = false; $store.cart.hideCart(); $store.settings.hideSettings();" 307 class="bg-header md:pb-4"> 308 309 <section x-data class="w-full" data-qb-element="notifications"> 310 <template x-for="notification in $store.notifications.notifications" :key="notification.id"> 311 <div x-show="notification.visible" x-on:click="$store.notifications.removeNotification(notification.id)" 312 x-transition:enter='transition ease-out duration-default transform' 313 x-transition:enter-start='opacity-0 -translate-y-4' 314 x-transition:enter-end='opacity-100 translate-y-0' 315 x-transition:leave='transition ease-in duration-default transform' 316 x-transition:leave-start='opacity-100 translate-y-0' 317 x-transition:leave-end='opacity-0 -translate-y-4' 318 > 319 <div class="relative w-full px-4 sm:px-8 py-4 bg-black" :style="{'background-color': notification.backgroundColor || '#000'}"> 320 <div class="relative z-10 flex items-center justify-between -m-2"> 321 <div class="flex-1 p-2" :style="{'color': notification.textColor || '#fff'}"> 322 <template x-if="notification.message"> 323 <h3 class="font-heading font-semibold text-lg text-center" x-html="notification.message"></h3> 324 </template> 325 <template x-if="notification.htmlContent"> 326 <div class="text-center" x-html="notification.htmlContent"></div> 327 </template> 328 </div> 329 <div class="p-2"> 330 <button 331 class="w-8 h-8 flex items-center justify-center rounded-full hover:opacity-75 transition-opacity duration-200 cursor-pointer" 332 :style="{'color': notification.textColor || '#fff'}" 333 x-on:click.stop="$store.notifications.removeNotification(notification.id)" 334 aria-label="Close notification" 335 > 336 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 337 <path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 338 </svg> 339 </button> 340 </div> 341 </div> 342 </div> 343 </div> 344 </template> 345 </section> 346 347 348 <div class="flex items-center justify-between relative py-2" style=""> 349 <div class="container px-4 mx-auto"> 350 <div class="flex items-center justify-between relative"> 351 <div class="flex text-sm"> 352 353 <!-- Tax --> 354 355 </div> 356 <div class="flex text-sm"> 357 358 <!-- Settings --> 359 360 <button @click="$store.settings.showSettings()" aria-label="Inställningar" class="flex items-center opacity-70 hover:opacity-100 mr-4 focus:outline-none" style=""> 361 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 opacity-50"> 362 <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /> 363 <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> 364 </svg>
365 <span class="hidden sm:inline-block ml-1">Inställningar</span> 366 </button> 367 368 <!-- Login --> 369 <a href="/customer/login" class="flex items-center opacity-70 hover:opacity-100"> 370 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 opacity-50"> 371 <path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z"></path> 372 </svg> 373 <span class="hidden sm:inline-block ml-1">Logga in</span> 374 </a> 375 376 </div> 377 </div> 378 </div> 379 </div> 380 381 <hr class="border-foreground opacity-5"> 382 383 <nav> 384 <div class="container px-4 py-2 sm:py-6 mx-auto"> 385 <div class="flex items-center justify-between relative"> 386 387 <!-- Menu button --> 388 <div class="flex justify-items-start w-2/5"> 389 390 <div class="lg:hidden flex justify-items-start w-1/5"> 391 <button @click="navMenuOpen = !navMenuOpen" aria-label="Menu" 392 class="navbar-burger py-3 px-2 sm:px-4 hover:text-primary hover:bg-black/5 transition duration-default focus:outline-none"> 393 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 sm:w-7 sm:h-7"> 394 <path fill-rule="evenodd" clip-rule="evenodd" 395 d="M3.5 7C3.22386 7 3 6.77614 3 6.5C3 6.22386 3.22386 6 3.5 6H20.5C20.7761 6 21 6.22386 21 6.5C21 6.77614 20.7761 7 20.5 7H3.5ZM3.5 12C3.22386 12 3 11.7761 3 11.5C3 11.2239 3.22386 11 3.5 11H20.5C20.7761 11 21 11.2239 21 11.5C21 11.7761 20.7761 12 20.5 12H3.5ZM3 16.5C3 16.7761 3.22386 17 3.5 17H20.5C20.7761 17 21 16.7761 21 16.5C21 16.2239 20.7761 16 20.5 16H3.5C3.22386 16 3 16.2239 3 16.5Z" 396 fill="currentColor"></path> 397 </svg> 398 </button> 399 </div> 400 401 </div> 402 403 <!-- Logo --> 404 <a class="inline-block h-16" href="https://vecanis.se"> 405 <img ix-sizes="auto" ix-src="https://cdn.quickbutik.com/images/2666L/templates/lua/assets/logo.png?s=96971&auto=format&fit=clip&h=160&w=400&dpr=2" alt="Vecanis" class="object-center object-contain sm:px-4 h-16"> 406 </a> 407 408 <div class="flex justify-end w-2/5"> 409 410 <!-- Search --> 411 <button @click="$store.search.showSearch(); $nextTick(() => document.getElementById('searchInput').focus());" aria-label="Sök" class="py-2 px-2 rounded-full hover:bg-black/5 hover:text-primary transition duration-default focus:outline-none"> 412 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 sm:w-6 sm:h-6"> 413 <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /> 414 </svg> 415 </button> 416 417 <!-- Wishlist --> 418 419 <!-- Cart --> 420 <button @click="$store.cart.toggleCart()" aria-label="Varukorg" class="relative py-2 px-2 rounded-full hover:bg-black/5 hover:text-primary transition duration-default focus:outline-none"> 421 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 sm:w-6 sm:h-6"> 422 <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" /> 423 </svg> 424 425 <!-- Cart bubble --> 426 <template x-if="$store.cart.totalQuantity > 0">
427 <span class="cart-bubble absolute flex items-center justify-center -top-0.5 -right-0.5 h-6 w-6 border-2 border-header bg-primary text-white font-bold text-xs rounded-full scale-75 md:scale-100" x-text="$store.cart.totalQuantity"></span> 428 </template> 429 430 </button> 431 </div> 432 433 </div> 434 </div> 435 <div class="hidden lg:block container px-4 mx-auto"> 436 <div> 437 438 <ul x-data="{ navbarLevelsDepth: 3 }" class="flex justify-center"> 439 440 441 442 <li x-data="dropdown" @mouseover="open = navbarLevelsDepth >= 1 && true" @mouseleave="open = false" class=" mx-2 lg:mx-4 group"> 443 <div class="relative flex items-center justify-center"> 444 <a href="/hundmat" id="menu-item-8" 445 class="flex py-2 px-1 transition ease-in-out duration-default hover:opacity-70 "> 446 <span>Hundmat</span> 447 </a> 448 <svg x-show="navbarLevelsDepth >= 1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" 449 stroke-width="1.5" stroke="currentColor" 450 class="w-4 h-4 cursor-pointer transform duration-default rotate-90 " :class="{'rotate-0' : open}"> 451 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"></path> 452 </svg> 453 </div> 454 455 <!-- First drop down --> 456 <ul x-cloak 457 x-show="open" 458 " 459 x-transition:enter='transition ease-out transform' 460 x-transition:enter-start='opacity-0 -translate-y-4' 461 x-transition:enter-end='opacity-100 translate-y-0' 462 x-transition:leave='transition ease-in transform' 463 x-transition:leave-start='opacity-100 translate-y-0' 464 x-transition:leave-end='opacity-0 -translate-y-4' 465 class="absolute z-50 w-full max-w-xs px-6 py-2 bg-header shadow-md roundednone duration-default" aria-labelledby="menu-item-8"> 466 467 468 <li><a href="/hundmat/topping" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Topping</a></li> 469 470 471 472 <li><a href="/hundmat/blotmat" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Blötmat</a></li> 473 474 475 476 <li><a href="/hundmat/torrfoder" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Torrfoder</a></li> 477 478 479 480 <li><a href="/hundmat/allergivanlig-hundmat" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Allergi</a></li> 481 482 483 484 <li><a href="/hundmat/glutenfritt" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Glutenfritt</a></li> 485 486 487 488 <li><a href="/hundmat/fettsnalt" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Fettsnålt</a></li> 489 490 491 492 <li><a href="/hundmat/spannmalsfritt" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Spannmålsfritt</a></li> 493 494 495 496 <li><a href="/hundmat/senior" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Senior</a></li> 497 498 499 500 <li><a href="/hundmat/valp" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Valp</a></li> 501 502 503 504 <li><a href="/hundmat/mini" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Mini</a></li> 505 506 </ul> 507 </li> 508 509 510 511 <li x-data="dropdown" @mouseover="open = navbarLevelsDepth >= 1 && true" @mouseleave="open = false" class=" mx-2 lg:mx-4 group"> 512 <div class="relative flex items-center justify-center"> 513 <a href="/hundgodis" id="menu-item-146" 514 class="flex py-2 px-1 transition ease-in-out duration-default hover:opacity-70 ">
515 <span>Hundgodis</span> 516 </a> 517 <svg x-show="navbarLevelsDepth >= 1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" 518 stroke-width="1.5" stroke="currentColor" 519 class="w-4 h-4 cursor-pointer transform duration-default rotate-90 " :class="{'rotate-0' : open}"> 520 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"></path> 521 </svg> 522 </div> 523 524 <!-- First drop down --> 525 <ul x-cloak 526 x-show="open" 527 " 528 x-transition:enter='transition ease-out transform' 529 x-transition:enter-start='opacity-0 -translate-y-4' 530 x-transition:enter-end='opacity-100 translate-y-0' 531 x-transition:leave='transition ease-in transform' 532 x-transition:leave-start='opacity-100 translate-y-0' 533 x-transition:leave-end='opacity-0 -translate-y-4' 534 class="absolute z-50 w-full max-w-xs px-6 py-2 bg-header shadow-md roundednone duration-default" aria-labelledby="menu-item-146"> 535 536 537 <li><a href="/hundgodis/tuggben" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Tuggben</a></li> 538 539 540 541 <li><a href="/hundgodis/beloningsgodis" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Belöningsgodis</a></li> 542 543 544 545 <li><a href="/hundgodis/allergi-hundgodis" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Allergi</a></li> 546 547 548 549 <li><a href="/hundgodis/glutenfritt-hundgodis" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Glutenfritt</a></li> 550 551 552 553 <li><a href="/hundgodis/fettsnalt-hundgodis" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">FettsnÃ¥lt</a></li> 554 555 556 557 <li><a href="/hundgodis/spannmalsfritt-hundgodis" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">SpannmÃ¥lsfritt</a></li> 558 559 560 561 <li><a href="/hundgodis/dentalgodis" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Dentalgodis</a></li> 562 563 564 565 <li><a href="/hundgodis/seniorhundgodis" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Senior</a></li> 566 567 568 569 <li><a href="/hundgodis/valp-hundgodis" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Valp</a></li> 570 571 572 573 <li><a href="/hundgodis/mini" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Mini</a></li> 574 575 576 577 <li><a href="/hundgodis/jumbo" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Jumbo</a></li> 578 579 580 581 <li><a href="/ovrigt" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Ãvrigt</a></li> 582 583 </ul> 584 </li> 585 586 587 588 <li x-data="dropdown" @mouseover="open = navbarLevelsDepth >= 1 && true" @mouseleave="open = false" class=" mx-2 lg:mx-4 group"> 589 <div class="relative flex items-center justify-center"> 590 <a href="/tillskott" id="menu-item-105" 591 class="flex py-2 px-1 transition ease-in-out duration-default hover:opacity-70 ">
592 <span>Tillskott</span> 593 </a> 594 <svg x-show="navbarLevelsDepth >= 1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" 595 stroke-width="1.5" stroke="currentColor" 596 class="w-4 h-4 cursor-pointer transform duration-default rotate-90 " :class="{'rotate-0' : open}"> 597 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"></path> 598 </svg> 599 </div> 600 601 <!-- First drop down --> 602 <ul x-cloak 603 x-show="open" 604 " 605 x-transition:enter='transition ease-out transform' 606 x-transition:enter-start='opacity-0 -translate-y-4' 607 x-transition:enter-end='opacity-100 translate-y-0' 608 x-transition:leave='transition ease-in transform' 609 x-transition:leave-start='opacity-100 translate-y-0' 610 x-transition:leave-end='opacity-0 -translate-y-4' 611 class="absolute z-50 w-full max-w-xs px-6 py-2 bg-header shadow-md roundednone duration-default" aria-labelledby="menu-item-105"> 612 613 614 <li><a href="/tillskott/26-munhygien" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Munhygien</a></li> 615 616 617 618 <li><a href="/tillskott/pals-hud" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Päls & Hud</a></li> 619 620 621 622 <li><a href="/tillskott/leder-rorlighet" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Leder & Rörlighet</a></li> 623 624 625 626 <li><a href="/tillskott/mage" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Mage & Tarm</a></li> 627 628 629 630 <li><a href="/tillskott/multivitamin" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Multivitamin</a></li> 631 632 633 634 <li><a href="/tillskott/omega" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Omega</a></li> 635 636 637 638 <li><a href="/tillskott/allmanhalsa" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Allmänhälsa</a></li> 639 640 641 642 <li><a href="/tillskott/valp-tillskott" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Valp</a></li> 643 644 645 646 <li><a href="/tillskott/lugnande" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Lugnande</a></li> 647 648 </ul> 649 </li> 650 651 652 653 <li x-data="dropdown" @mouseover="open = navbarLevelsDepth >= 1 && true" @mouseleave="open = false" class=" mx-2 lg:mx-4 group"> 654 <div class="relative flex items-center justify-center"> 655 <a href="/tillbehor" id="menu-item-107" 656 class="flex py-2 px-1 transition ease-in-out duration-default hover:opacity-70 "> 657 <span>Tillbehör</span> 658 </a> 659 <svg x-show="navbarLevelsDepth >= 1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" 660 stroke-width="1.5" stroke="currentColor" 661 class="w-4 h-4 cursor-pointer transform duration-default rotate-90 " :class="{'rotate-0' : open}"> 662 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"></path> 663 </svg> 664 </div> 665 666 <!-- First drop down --> 667 <ul x-cloak 668 x-show="open" 669 " 670 x-transition:enter='transition ease-out transform' 671 x-transition:enter-start='opacity-0 -translate-y-4' 672 x-transition:enter-end='opacity-100 translate-y-0' 673 x-transition:leave='transition ease-in transform' 674 x-transition:leave-start='opacity-100 translate-y-0' 675 x-transition:leave-end='opacity-0 -translate-y-4' 676 class="absolute z-50 w-full max-w-xs px-6 py-2 bg-header shadow-md roundednone duration-default" aria-labelledby="menu-item-107"> 677 678 679 <li><a href="/tillbehor/hud-palsvard" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Hud & Pälsvård</a></li> 680 681 682 683 <li><a href="/tillbehor/bajspasar" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Bajspåsar</a></li> 684 685 686 687 <li><a href="/tillbehor/munhygien" class="block py-3 transition ease-in-out duration-default hover:opacity-70 ">Munhygien</a></li> 688 689 </ul> 690 </li> 691 692 693 <li class=" mx-2 lg:mx-4"> 694 <a class="block py-2 px-1 transition ease-in-out duration-default hover:opacity-70 " href="/erbjudanden" 695 title="Erbjudanden">
696 <span>Erbjudanden</span> 697 </a> 698 </li> 699 700 701 702 <li class=" mx-2 lg:mx-4"> 703 <a class="block py-2 px-1 transition ease-in-out duration-default hover:opacity-70 " href="/sidor/information" 704 title="Information"> 705 <span>Information</span> 706 </a> 707 </li> 708 709 710 711 <li class=" mx-2 lg:mx-4"> 712 <a class="block py-2 px-1 transition ease-in-out duration-default hover:opacity-70 " href="/blogg" 713 title="Blogg"> 714 <span>Blogg</span> 715 </a> 716 </li> 717 718 719 720 <li class=" mx-2 lg:mx-4"> 721 <a class="block py-2 px-1 transition ease-in-out duration-default hover:opacity-70 " href="/presentkort" 722 title="Presentkort"> 723 <span>Presentkort</span> 724 </a> 725 </li> 726 727 728 729 </ul> 730 </div> 731 </div> 732 </nav> 733 734 <!-- Menu --> 735 <div 736 x-cloak 737 @keydown.escape="navMenuOpen = false" 738 x-show="navMenuOpen" 739 class="navbar-menu fixed top-0 left-0 bottom-0 w-full sm:max-w-sm z-50"> 740 741 <!-- Overlay --> 742 <div 743 x-show='navMenuOpen' 744 @click='navMenuOpen = false' 745 x-transition:enter='transition ease-out duration-default transform' 746 x-transition:enter-start='opacity-0' 747 x-transition:enter-end='opacity-100' 748 x-transition:leave='transition ease-in duration-default transform' 749 x-transition:leave-start='opacity-100' 750 x-transition:leave-end='opacity-0' 751 class='fixed inset-0 transition-opacity bg-overlay bg-opacity-40' 752 aria-hidden='true' 753 ></div> 754 755 <!-- Menu --> 756 <nav 757 x-show='navMenuOpen' 758 @keydown.escape="navMenuOpen = false" 759 class="relative flex flex-col h-full bg-body" 760 x-transition:enter='transition ease-out duration-default transform' 761 x-transition:enter-start='opacity-0 -translate-x-10 sm:-translate-x-10' 762 x-transition:enter-end='opacity-100 translate-x-0' 763 x-transition:leave='transition ease-in duration-default transform' 764 x-transition:leave-start='opacity-100 translate-x-0' 765 x-transition:leave-end='opacity-0 -translate-x-10 sm:-translate-x-10' 766 > 767 <div class="flex items-center justify-between mb-12 p-6"> 768 769 <!-- Logo --> 770 <a class="items-center" href="https://vecanis.se"> 771 772 <img alt="Vecanis" class="img-fluid img-logo w-32" 773 ix-src="https://cdn.quickbutik.com/images/2666L/templates/lua/assets/logo.png?s=96971&auto=format"> 774 </a> 775 776 <!-- Close button --> 777 <button @click="navMenuOpen = false" class="inline-block navbar-close focus:outline-none" type="button" 778 aria-label="Close"> 779 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" 780 stroke="currentColor" class="w-6 h-6"> 781 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path> 782 </svg></button> 783 </div> 784 785 <!-- Menu --> 786 <div class="px-6 overflow-y-auto"> 787 788 <ul> 789 790 <li x-data="{expanded:false}" class=""> 791 <div class="flex"> 792 <a href="/hundmat" 793 class="flex flex-1 py-3 font-bold hover:text-gray-900 "> 794 <span id="navMenuItem8">Hundmat</span> 795 </a> 796 <div class="flex h-full flex-col py-3"> 797 <svg @click="expanded = !expanded" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" 798 stroke="currentColor" class="w-6 h-6 cursor-pointer transform duration-default" :class="{'rotate-90' : expanded}"> 799 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"></path> 800 </svg> 801 </div> 802 </div> 803 <ul x-show="expanded" 804 x-transition:enter='transition ease-out duration-default transform' 805 x-transition:enter-start='opacity-0 -translate-y-4' 806 x-transition:enter-end='opacity-100 translate-y-0' 807 x-transition:leave='transition ease-in duration-default transform' 808 x-transition:leave-start='opacity-100 translate-y-0' 809 x-transition:leave-end='opacity-0 -translate-y-4' 810 class="pl-4" aria-labelledby="navMenuItem8"> 811 812 813 <li><a href="/hundmat/topping" class="block py-3 ">Topping</a></li> 814 815 816 817 <li><a href="/hundmat/blotmat" class="block py-3 ">Blötmat</a></li> 818 819 820 821 <li><a href="/hundmat/torrfoder" class="block py-3 ">Torrfoder</a></li> 822 823 824 825 <li><a href="/hundmat/allergivanlig-hundmat" class="block py-3 ">Allergi</a></li> 826 827 828 829 <li><a href="/hundmat/glutenfritt" class="block py-3 ">Glutenfritt</a></li> 830 831 832 833 <li><a href="/hundmat/fettsnalt" class="block py-3 ">Fettsnålt</a></li> 834 835 836 837 <li><a href="/hundmat/spannmalsfritt" class="block py-3 ">Spannmålsfritt</a></li> 838 839 840 841 <li><a href="/hundmat/senior" class="block py-3 ">Senior</a></li> 842 843 844 845 <li><a href="/hundmat/valp" class="block py-3 ">Valp</a></li> 846 847 848 849 <li><a href="/hundmat/mini" class="block py-3 ">Mini</a></li> 850 851 852 </ul> 853 </li> 854 </ul> 855 856 857 <ul> 858 859 <li x-data="{expanded:false}" class=""> 860 <div class="flex"> 861 <a href="/hundgodis" 862 class="flex flex-1 py-3 font-bold hover:text-gray-900 ">
863 <span id="navMenuItem146">Hundgodis</span> 864 </a> 865 <div class="flex h-full flex-col py-3"> 866 <svg @click="expanded = !expanded" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" 867 stroke="currentColor" class="w-6 h-6 cursor-pointer transform duration-default" :class="{'rotate-90' : expanded}"> 868 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"></path> 869 </svg> 870 </div> 871 </div> 872 <ul x-show="expanded" 873 x-transition:enter='transition ease-out duration-default transform' 874 x-transition:enter-start='opacity-0 -translate-y-4' 875 x-transition:enter-end='opacity-100 translate-y-0' 876 x-transition:leave='transition ease-in duration-default transform' 877 x-transition:leave-start='opacity-100 translate-y-0' 878 x-transition:leave-end='opacity-0 -translate-y-4' 879 class="pl-4" aria-labelledby="navMenuItem146"> 880 881 882 <li><a href="/hundgodis/tuggben" class="block py-3 ">Tuggben</a></li> 883 884 885 886 <li><a href="/hundgodis/beloningsgodis" class="block py-3 ">Belöningsgodis</a></li> 887 888 889 890 <li><a href="/hundgodis/allergi-hundgodis" class="block py-3 ">Allergi</a></li> 891 892 893 894 <li><a href="/hundgodis/glutenfritt-hundgodis" class="block py-3 ">Glutenfritt</a></li> 895 896 897 898 <li><a href="/hundgodis/fettsnalt-hundgodis" class="block py-3 ">FettsnÃ¥lt</a></li> 899 900 901 902 <li><a href="/hundgodis/spannmalsfritt-hundgodis" class="block py-3 ">SpannmÃ¥lsfritt</a></li> 903 904 905 906 <li><a href="/hundgodis/dentalgodis" class="block py-3 ">Dentalgodis</a></li> 907 908 909 910 <li><a href="/hundgodis/seniorhundgodis" class="block py-3 ">Senior</a></li> 911 912 913 914 <li><a href="/hundgodis/valp-hundgodis" class="block py-3 ">Valp</a></li> 915 916 917 918 <li><a href="/hundgodis/mini" class="block py-3 ">Mini</a></li> 919 920 921 922 <li><a href="/hundgodis/jumbo" class="block py-3 ">Jumbo</a></li> 923 924 925 926 <li><a href="/ovrigt" class="block py-3 ">Ãvrigt</a></li> 927 928 929 </ul> 930 </li> 931 </ul> 932 933 934 <ul> 935 936 <li x-data="{expanded:false}" class=""> 937 <div class="flex"> 938 <a href="/tillskott" 939 class="flex flex-1 py-3 font-bold hover:text-gray-900 "> 940 <span id="navMenuItem105">Tillskott</span> 941 </a> 942 <div class="flex h-full flex-col py-3"> 943 <svg @click="expanded = !expanded" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" 944 stroke="currentColor" class="w-6 h-6 cursor-pointer transform duration-default" :class="{'rotate-90' : expanded}"> 945 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"></path> 946 </svg> 947 </div> 948 </div> 949 <ul x-show="expanded" 950 x-transition:enter='transition ease-out duration-default transform' 951 x-transition:enter-start='opacity-0 -translate-y-4' 952 x-transition:enter-end='opacity-100 translate-y-0' 953 x-transition:leave='transition ease-in duration-default transform' 954 x-transition:leave-start='opacity-100 translate-y-0' 955 x-transition:leave-end='opacity-0 -translate-y-4' 956 class="pl-4" aria-labelledby="navMenuItem105"> 957 958 959 <li><a href="/tillskott/26-munhygien" class="block py-3 ">Munhygien</a></li> 960 961 962 963 <li><a href="/tillskott/pals-hud" class="block py-3 ">Päls & Hud</a></li> 964 965 966 967 <li><a href="/tillskott/leder-rorlighet" class="block py-3 ">Leder & Rörlighet</a></li> 968 969 970 971 <li><a href="/tillskott/mage" class="block py-3 ">Mage & Tarm</a></li> 972 973 974 975 <li><a href="/tillskott/multivitamin" class="block py-3 ">Multivitamin</a></li> 976 977 978 979 <li><a href="/tillskott/omega" class="block py-3 ">Omega</a></li> 980 981 982 983 <li><a href="/tillskott/allmanhalsa" class="block py-3 ">Allmänhälsa</a></li> 984 985 986 987 <li><a href="/tillskott/valp-tillskott" class="block py-3 ">Valp</a></li> 988 989 990 991 <li><a href="/tillskott/lugnande" class="block py-3 ">Lugnande</a></li> 992 993 994 </ul> 995 </li> 996 </ul> 997 998 999 <ul> 1000 1001 <li x-data="{expanded:false}" class=""> 1002 <div class="flex"> 1003 <a href="/tillbehor" 1004 class="flex flex-1 py-3 font-bold hover:text-gray-900 ">
1005 <span id="navMenuItem107">Tillbehör</span> 1006 </a> 1007 <div class="flex h-full flex-col py-3"> 1008 <svg @click="expanded = !expanded" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" 1009 stroke="currentColor" class="w-6 h-6 cursor-pointer transform duration-default" :class="{'rotate-90' : expanded}"> 1010 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"></path> 1011 </svg> 1012 </div> 1013 </div> 1014 <ul x-show="expanded" 1015 x-transition:enter='transition ease-out duration-default transform' 1016 x-transition:enter-start='opacity-0 -translate-y-4' 1017 x-transition:enter-end='opacity-100 translate-y-0' 1018 x-transition:leave='transition ease-in duration-default transform' 1019 x-transition:leave-start='opacity-100 translate-y-0' 1020 x-transition:leave-end='opacity-0 -translate-y-4' 1021 class="pl-4" aria-labelledby="navMenuItem107"> 1022 1023 1024 <li><a href="/tillbehor/hud-palsvard" class="block py-3 ">Hud & Pälsvård</a></li> 1025 1026 1027 1028 <li><a href="/tillbehor/bajspasar" class="block py-3 ">Bajspåsar</a></li> 1029 1030 1031 1032 <li><a href="/tillbehor/munhygien" class="block py-3 ">Munhygien</a></li> 1033 1034 1035 </ul> 1036 </li> 1037 </ul> 1038 1039 1040 <ul> 1041 <li class=""> 1042 <a class="block py-3 font-bold hover:text-gray-900 " href="/erbjudanden" 1043 title="Erbjudanden"> 1044 <span>Erbjudanden</span> 1045 </a> 1046 </li> 1047 1048 </ul> 1049 1050 1051 <ul> 1052 <li class=""> 1053 <a class="block py-3 font-bold hover:text-gray-900 " href="/sidor/information" 1054 title="Information"> 1055 <span>Information</span> 1056 </a> 1057 </li> 1058 1059 </ul> 1060 1061 1062 <ul> 1063 <li class=""> 1064 <a class="block py-3 font-bold hover:text-gray-900 " href="/blogg" 1065 title="Blogg"> 1066 <span>Blogg</span> 1067 </a> 1068 </li> 1069 1070 </ul> 1071 1072 1073 <ul> 1074 <li class=""> 1075 <a class="block py-3 font-bold hover:text-gray-900 " href="/presentkort" 1076 title="Presentkort"> 1077 <span>Presentkort</span> 1078 </a> 1079 </li> 1080 1081 </ul> 1082 1083 </div> 1084 1085 <div class="p-6 mt-auto"> 1086 1087 <!-- Login --> 1088 <a class="block px-6 py-3 text-center button-rounding bg-foreground text-white font-bold bg-fore hover:opacity-90" 1089 href="/customer/login">Logga in</a> 1090 1091 </div> 1092 </nav> 1093 </div> <!-- Settings modal --> 1094 <template x-teleport='body'> 1095 <div 1096 x-data 1097 x-cloak 1098 x-show='$store.settings.settingsOpen' 1099 @keydown.escape='$store.settings.hideSettings()' 1100 class='fixed inset-0 z-50 overflow-y-auto' 1101 aria-labelledby='modal-title' 1102 role='dialog' 1103 aria-modal='true' 1104 > 1105 <div class='flex items-end justify-center min-h-screen text-center md:items-center sm:block'> 1106 1107 <!-- Overlay --> 1108 <div 1109 @click='$store.settings.hideSettings()' 1110 x-show='$store.settings.settingsOpen' 1111 x-transition:enter='transition ease-out duration-default transform' 1112 x-transition:enter-start='opacity-0' 1113 x-transition:enter-end='opacity-100' 1114 x-transition:leave='transition ease-in duration-default transform' 1115 x-transition:leave-start='opacity-100' 1116 x-transition:leave-end='opacity-0' 1117 class='fixed inset-0 transition-opacity bg-overlay bg-opacity-40' 1118 aria-hidden='true'></div> 1119 1120 <!-- Modal --> 1121 <div x-show='$store.settings.settingsOpen' 1122 x-transition:enter='transition ease-out duration-default transform' 1123 x-transition:enter-start='opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95' 1124 x-transition:enter-end='opacity-100 translate-y-0 sm:scale-100' 1125 x-transition:leave='transition ease-in duration-default transform' 1126 x-transition:leave-start='opacity-100 translate-y-0 sm:scale-100' 1127 x-transition:leave-end='opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95' 1128 class='flex flex-col sm:inline-block w-full max-w-xl fixed top-0 bottom-0 sm:top-auto sm:bottom-auto sm:static sm:my-20 overflow-hidden text-left transition-all transform bg-body roundednone shadow-xl 2xl:max-w-2xl'> 1129 1130 <!-- Header --> 1131 <header class='flex items-center justify-between p-6 mb-2'> 1132 1133 <!-- Title -->
1134 <span class='text-3xl font-bold font-heading'>Inställningar</span> 1135 1136 <!-- Close button --> 1137 <button @click='$store.settings.hideSettings()' class='text-gray-600 focus:outline-none hover:text-gray-700'> 1138 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> 1139 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path> 1140 </svg> 1141 </button> 1142 </header> 1143 1144 <div class="px-6 pb-6 mb-6 h-full overflow-y-auto"> 1145 1146 <!-- Languages --> 1147 <div class="mb-4"> 1148 <h4 class="text-lg font-heading font-bold">Språk</h4> 1149 <div> 1150 <a href="https://vecanis.se/customer/login" class="flex mt-2 px-4 py-2 bg-primary text-white button-rounding hover:bg-gray-200 duration-default transition-shadow"> 1151 <img loading="lazy" class="w-auto" src="/assets/shopassets/flags/24/sv.png" alt="sv Flag"> 1152 <span class="pl-2">Svenska</span> 1153 </a> 1154 <a href="https://vecanis.se/en/customer/login" class="flex mt-2 px-4 py-2 bg-gray-100 button-rounding hover:bg-gray-200 duration-default transition-shadow"> 1155 <img loading="lazy" class="w-auto" src="/assets/shopassets/flags/24/en.png" alt="en Flag"> 1156 <span class="pl-2">English</span> 1157 </a> 1158 </div> 1159 </div> 1160 1161 <!-- Tax --> 1162 1163 <!-- Currencies--> 1164 <div> 1165 <h4 class="text-lg font-heading font-bold">Valuta</h4> 1166 <div class="flex flex-wrap"> 1167 <a href="https://vecanis.se/customer/login?currency=DKK" 1168 class="dropdown-item mt-2 mr-2 px-4 py-2 bg-gray-100 button-rounding hover:bg-gray-200 duration-default transition-colors">DKK</a> 1169 <a href="https://vecanis.se/customer/login?currency=EUR" 1170 class="dropdown-item mt-2 mr-2 px-4 py-2 bg-gray-100 button-rounding hover:bg-gray-200 duration-default transition-colors">EUR</a> 1171 <a href="https://vecanis.se/customer/login?currency=SEK" 1172 class="dropdown-item mt-2 mr-2 px-4 py-2 bg-primary text-white button-rounding hover:bg-gray-200 duration-default transition-colors">SEK</a> 1173 </div> 1174 </div> 1175 1176 </div> 1177 </div> 1178 </div> 1179 </div> 1180 </template> <!-- Search modal --> 1181 <template x-teleport='body'> 1182 <div 1183 x-data='appSearchModal' 1184 x-cloak 1185 x-show='$store.search.searchOpen' 1186 @keydown.escape='$store.search.hideSearch()' 1187 class='fixed inset-0 z-50 overflow-y-auto' 1188 aria-labelledby='modal-title' 1189 role='dialog' 1190 aria-modal='true' 1191 > 1192 <div class='flex items-end justify-center min-h-screen px-4 text-center md:items-center sm:block sm:p-0'> 1193 1194 <!-- Overlay --> 1195 <div 1196 @click='$store.search.hideSearch()' 1197 x-show='$store.search.searchOpen' 1198 x-transition:enter='transition ease-out duration-default transform' 1199 x-transition:enter-start='opacity-0' 1200 x-transition:enter-end='opacity-100' 1201 x-transition:leave='transition ease-in duration-default transform' 1202 x-transition:leave-start='opacity-100' 1203 x-transition:leave-end='opacity-0' 1204 class='fixed inset-0 transition-opacity bg-overlay bg-opacity-40' 1205 aria-hidden='true' 1206 ></div> 1207 1208 <!-- Modal --> 1209 <div 1210 modal="search" 1211 x-show='$store.search.searchOpen' 1212 x-transition:enter='transition ease-out duration-default transform' 1213 x-transition:enter-start='opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95' 1214 x-transition:enter-end='opacity-100 translate-y-0 sm:scale-100' 1215 x-transition:leave='transition ease-in duration-default transform' 1216 x-transition:leave-start='opacity-100 translate-y-0 sm:scale-100' 1217 x-transition:leave-end='opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95' 1218 class='flex flex-col h-screen sm:inline-block w-full h-max-screen sm:max-w-xl 2xl:max-w-2xl fixed top-0 bottom-0 sm:top-auto sm:bottom-auto sm:static sm:my-20 overflow-hidden text-left transition-all transform' 1219 style='@media (min-width: 576px) { max-height: calc(80vh) }' 1220 > 1221 <div class="pointer-events-auto relative flex w-full flex-col max-h-full overflow-hidden border-none bg-body shadow-xl"> 1222 1223 <!-- Header --> 1224 <header class='flex flex-col flex-shrink-0 p-6'> 1225 1226 <div class="flex items-center justify-between pb-6"> 1227 1228 <!-- Title -->
1229 <span class='text-3xl font-bold font-heading'>Sök</span> 1230 1231 <!-- Close button --> 1232 <button 1233 @click='$store.search.hideSearch()' 1234 class='text-gray-600 focus:outline-none hover:text-gray-700'> 1235 <svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='currentColor' class='w-6 h-6'> 1236 <path stroke-linecap='round' stroke-linejoin='round' d='M6 18L18 6M6 6l12 12'></path> 1237 </svg> 1238 </button> 1239 </div> 1240 1241 <form 1242 action='/shop/search' 1243 method='get' class='flex'> 1244 <div class='flex w-full relative'> 1245 <div class="flex h-full flex-col justify-center absolute top-0 left-0 pl-2"> 1246 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 opacity-40"> 1247 <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /> 1248 </svg> 1249 </div> 1250 <input 1251 type='search' 1252 name='s' 1253 x-model='searchTerm' 1254 id='searchInput' 1255 @input.debounce.500ms="search" 1256 placeholder='Sök produkter' 1257 aria-label='Search' 1258 aria-describedby='button-search-desktop' 1259 class='flex flex-1 py-3 pr-3 pl-14 input-bg input-text input-border border input-rounding focus:outline-none' 1260 > 1261 </div> 1262 </form> 1263 </header> 1264 1265 <!-- No results found --> 1266 <div x-show='searchTerm && noResults' class="px-6 mb-8"> 1267 <p class="mt-8 text-center"> 1268 Inga sökresultat hittades 1269 </p> 1270 </div> 1271 1272 <!-- Search results --> 1273 <div x-show='!noResults && searchTerm != ""' class="relative overflow-y-auto pb-6"> 1274 <template x-for='result in results'> 1275 <a :href='result.product.url'> 1276 <div class="flex py-2 px-6 border-b border-gray-100 hover:bg-gray-100"> 1277 <div class="flex flex-0 w-16 h-16 mr-6"> 1278 <img :src="result.product.firstimage + '?w=100&h=100'" ix-sizes="auto" :alt="result.product.title" class="w-full object-contain object-center"> 1279 </div> 1280 <div class="flex flex-1 flex-col justify-center"> 1281 <span x-html='highlightTitle(result.product.title)' class=""></span> 1282 </div> 1283 <div class="flex flex-col justify-center"> 1284 <span x-text='result.product.price' class=""></span> 1285 </div> 1286 </div> 1287 </a> 1288 </template> 1289 </div> 1290 1291 </div> 1292 1293 </div> 1294 </div> 1295 </div> 1296 </template> 1297 1298 </section> 1299 1300 1301 <!-- Breadcrumbs --> 1302 <section data-qb-area="general" data-qb-setting="show_breadcrumbs" class="py-4"> 1303 <div class="container mx-auto px-4"> 1304 <ul class="flex flex-wrap justify-center"> 1305 1306 <li class="flex items-center mr-3"> 1307 1308 1309 <a class="inline-block mr-3 text-sm opacity-70" href="/">Hem</a> 1310 <span class="inline-block opacity-40"> 1311 <svg class="h-2 w-2" viewbox="0 0 6 11" fill="none" xmlns="http://www.w3.org/2000/svg"> 1312 <path d="M0.131073 0.755835C-0.0436907 0.581885 -0.0436907 0.302414 0.131073 0.129744C0.305837 -0.0429273 0.588197 -0.
13120435662 0.76296 0.129744L5.86893 5.18707C6.04369 5.35975 6.04369 5.63922 5.86893 5.81317L0.762959 10.8705C0.588196 11.0432 0.305836 11.0432 0.131072 10.8705C-0.0436916 10.6972 -0.0436916 10.4171 0.131072 10.2444L4.78774 5.4998L0.131073 0.755835Z" fill="#151515"></path> 1313 </svg> 1314 </span> 1315 1316 </li> 1317 1318 </ul> 1319 </div> 1320 </section> 1321 1322 <section class="py-16"> 1323 <div class="container mx-auto px-4"> 1324 <div class="flex flex-wrap items-center"> 1325 <div class="w-full max-w-4xl mx-auto"> 1326 1327 <!-- Title --> 1328 1329 1330 <!-- Content --> 1331 <div class="applyTailwindClasses"> 1332 <div class="login"> 1333 <div class="customerarea_login_message" style="margin: 0 auto; text-align: center;"> 1334 <h2>Logga in på ditt konto hos oss</h2><p><strong>Har du inget konto? </strong></p><p><strong>Dittt konto skapas i samband med ditt första köp!</strong></p> </div> 1335 1336 <div style="width: 320px; margin: 0 auto;"> 1337 <div class="text-center"> 1338 1339 1340 1341 <form action="/customer/login" method="post" accept-charset="utf-8"> 1342 <label>E-postadress</label><br /> 1343 <input type="text" name="email" value="" class="form-control" /> <br /> 1344 <label>Lösenord</label><br /> 1345 <input type="password" name="password" class="form-control" /> <br /><br /> 1346 <input type="submit" name="login" value="Logga in" class="btn btn-inverse" /> </form> </div> 1347 1348 1349 1350 1351 </div> 1352 <hr /> 1353 <div class="text-center"> 1354 <a href="/customer/forgot" class="customerarea_forgot_link" style="font-size: 12px;">Har du glömt ditt lösenord?</a> <div> 1355 <a href="/customer/register" class="customerarea_signup_link btn btn-inverse" style="margin-top: 15px; font-size: 12px;">Skapa nytt konto</a> </div> 1356 </div> 1357 1358</div> 1359 </div> 1360 1361 </div> 1362 </div> 1363 </div> 1364 </section> 1365 <section data-qb-area="footer" class="bg-footer text-center lg:text-left"> 1366 <div class="container px-4 py-8 md:py-12 mx-auto "> 1367 <div class="flex flex-wrap justify-between"> 1368 <div class="w-full lg:w-1/3 mb-10 lg:mb-0 px-4"> 1369 1370 <!-- Logotype --> 1371 <a data-qb-setting="use_footer_logo" class="inline-block mb-6 w-full h-8" href="/"> 1372 <img src="https://cdn.quickbutik.com/images/2666L/templates/lua/assets/logo.png?s=96971&auto=format&fit=clip&h=160&w=600" alt="Vecanis" class="h-8 w-full max-w-xs mx-auto lg:mx-0 object-contain object-center lg:object-left" > 1373 </a> 1374 1375 <!-- Text below logotype --> 1376 1377 <!-- Social media link --> 1378 <div data-qb-setting="show_footer_socialicons" class="text-footer opacity-70 mb-3"> 1379 1380 <a class="inline-block mr-4 hover:opacity-70" aria-label="Facebook" href="https://www.facebook.com/vecanis.se/" target="_blank"> 1381 <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24"> 1382 <path fill="currentColor" d="M9 8h-3v4h3v12h5v-12h3.642l.358-4h-4v-1.667c0-.955.192-1.333 1.115-1.333h2.885v-5h-3.808c-3.596 0-5.192 1.583-5.192 4.615v3.385z"></path> 1383 </svg> 1384 </a> 1385 <a 1386 class="inline-block mr-4 hover:opacity-70" 1387 aria-label="Instagram" 1388 href="https://instagram.com/vecanis/" target="_blank"> 1389 <svg 1390 xmlns="http://www.w3.org/2000/svg" 1391 class="w-4 h-4"
1392 viewBox="0 0 24 24" 1393 > 1394 <path 1395 fill="currentColor" 1396 d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" 1397 ></path> 1398 </svg> 1399 </a> 1400 </div> 1401 </div> 1402 1403 <div class="w-full lg:w-1/3 lg:pl-24 px-4 mb-10 lg:mb-0"> 1404 1405 <!-- Footer menu --> 1406 <h5 data-qb-setting="footer_menu_heading" class="text-lg font-bold mb-5 text-footer"> 1407 Läs mer 1408 </h5> 1409 1410 <ul data-qb-setting="use_footer_menu" class="opacity-70"> 1411 <li class="mb-2"> 1412 <a class="inline-block text-footer hover:opacity-70" href="/contact">Kontakt</a> 1413 </li> 1414 <li class="mb-2"> 1415 <a class="inline-block text-footer hover:opacity-70" href="/sidor/terms-and-conditions">Köpvillkor</a> 1416 </li> 1417 <li class="mb-2"> 1418 <a class="inline-block text-footer hover:opacity-70" href="/sidor/faq">Vanliga frågor</a> 1419 </li> 1420 <li class="mb-2"> 1421 <a class="inline-block text-footer hover:opacity-70" href="/sidor/cookies">Cookies</a> 1422 </li> 1423 <li class="mb-2"> 1424 <a class="inline-block text-footer hover:opacity-70" href="/sidor/gdpr">GDPR</a> 1425 </li> 1426 </ul> 1427 </div> 1428 1429 <!-- Newsletter signup --> 1430 <div class="w-full lg:w-1/3 px-4"> 1431 <h5 data-qb-setting="footer_newsletter_header" class="text-lg font-bold mb-5 text-footer">Sugen på rabatter och uppdateringar? Prenumerera på vårt nyhetsbrev</h5> 1432 1433 <form id="footer_newsletter_form" action="/shop/newsletter" 1434 data-txt-failed='Ange din e-postadress' 1435 data-txt-error='Ett fel uppstod, försök igen senare' 1436 data-txt-success='Du har nu anmält dig till vårt nyhetsbrev!'> 1437 1438 <div class="flex items-center pl-3 pr-2 py-2 bg-white button-rounding"> 1439 <input 1440 data-qb-setting="footer_newsletter_placeholder_text" 1441 class="w-full px-2 inline-block bg-transparent border-none text-base outline-none" 1442 type="email" name="email" placeholder="Ange e-postadress" 1443 aria-label="Ange e-postadress"> 1444 <button data-qb-setting="footer_newsletter_button_text" type="submit" class="ml-auto inline-block px-3 py-2 bg-black hover:opacity-90 text-white font-bold transition duration-default button-rounding" href="#">Prenumerera</button> 1445 </div> 1446 </form> 1447 <p id="footer_newsletter_status" class="text-sm text-footer" role="status" aria-live="polite"></p> 1448 1449
1449<script> 1450 function showNewsletterStatus(text) { 1451 const line = document.createElement('span') 1452 line.className = 'block mt-4' 1453 line.textContent = text 1454 newsletterStatus.replaceChildren(line) 1455 } 1456 1457 function logSubmit(event) { 1458 event.preventDefault() 1459 newsletterStatus.replaceChildren() 1460 1461 fetch(event.target.action, { 1462 method: 'POST', 1463 body: new FormData(event.target), 1464 }) 1465 .then(function(response) { 1466 if (response.ok) { 1467 return response.json() 1468 } 1469 return Promise.reject(response) 1470 }) 1471 .then(function(data) { 1472 if (data.success) { 1473 form.querySelector('input[name="email"]').value = '' 1474 1475 showNewsletterStatus(event.target.dataset.txtSuccess); 1476 } else if (data.error) { 1477 showNewsletterStatus(event.target.dataset.txtFailed); 1478 } else { 1479 showNewsletterStatus(event.target.dataset.txtError); 1480 } 1481 }).catch(function (error) { 1482 showNewsletterStatus(event.target.dataset.txtError); 1483 }); 1484 } 1485 1486 const form = document.getElementById("footer_newsletter_form"); 1487 const newsletterStatus = document.getElementById("footer_newsletter_status"); 1488 form.addEventListener("submit", logSubmit); 1489 </script>
1489 1490 1491 </div> 1492 1493 </div> 1494 1495 <div> 1496 <!-- Payment methods --> 1497 <div data-qb-setting="use_footer_paymentmethods" class="flex justify-center items-center gap-default flex-wrap mt-10"> 1498 1499 1500 1501 <img loading="lazy" class="h-10 p-2 w-auto" src="/assets/shopassets/paylogos/payment-visa-blue.svg" alt="Payment with Visa"> 1502 1503 1504 <img loading="lazy" class="h-10 p-2 w-auto" src="/assets/shopassets/paylogos/payment-mastercard.svg" alt="Payment with Mastercard"> 1505 1506 <img loading="lazy" class="h-10 p-2 w-auto" src="/assets/shopassets/paylogos/payment-klarna.svg" alt="Payment with Klarna"> 1507 1508 1509 1510 1511 1512 1513 <img loading="lazy" class="h-10 p-2 w-auto" src="/assets/shopassets/paylogos/payment-paypal-color.svg" alt="Payment with Paypal"> 1514 1515 1516 1517 <img loading="lazy" class="h-10 p-2 w-auto" src="/assets/shopassets/paylogos/payment-swish.png" alt="Payment with Swish"> 1518 1519 </div> 1520 </div> 1521 </div> 1522 1523 <div> 1524 <hr class="border-foreground opacity-40"> 1525 <div class="container px-4 mx-auto"> 1526 <div class="py-8 text-center"> 1527 <p class="text-footer text-sm opacity-50"> 1528 <!-- Copyright --> 1529 © 2026 Vecanis 1530 <span data-qb-setting="use_footer_poweredby">– <a style="text-decoration: none;" target="_blank" rel="nofollow sponsored" title="Powered by Quickbutik" href="https://quickbutik.com">Powered by Quickbutik</a></span> 1531 </p> 1532 </div> 1533 </div> 1534 </div> 1535 </section> 1536 <div x-data x-cloak 1537 x-show="$store.cart.cartOpen" 1538 @keydown.escape="$store.cart.hideCart()" 1539 @pageshow.window="$store.cart.init()" 1540 class="navbar-menu fixed top-0 right-0 bottom-0 w-full sm:max-w-sm z-50"> 1541 1542 <!-- Overlay --> 1543 <div 1544 x-show='$store.cart.cartOpen' 1545 @click='$store.cart.hideCart()' 1546 x-transition:enter='transition ease-out duration-default transform' 1547 x-transition:enter-start='opacity-0' 1548 x-transition:enter-end='opacity-100' 1549 x-transition:leave='transition ease-in duration-default transform' 1550 x-transition:leave-start='opacity-100' 1551 x-transition:leave-end='opacity-0' 1552 class='fixed inset-0 transition-opacity bg-overlay bg-opacity-40' 1553 aria-hidden='true' 1554 ></div> 1555 1556 <!-- Cart --> 1557 <div 1558 x-show='$store.cart.cartOpen' 1559 @keydown.escape="$store.cart.hideCart()" 1560 class="relative flex flex-col h-full bg-body" 1561 x-transition:enter='transition ease-out duration-default transform' 1562 x-transition:enter-start='opacity-0 translate-x-10 sm:translate-x-10' 1563 x-transition:enter-end='opacity-100 translate-x-0' 1564 x-transition:leave='transition ease-in duration-default transform' 1565 x-transition:leave-start='opacity-100 translate-x-0' 1566 x-transition:leave-end='opacity-0 translate-x-10 sm:translate-x-10' 1567 > 1568 <header class="flex items-center justify-between p-6 mb-4"> 1569 1570 <!-- Title --> 1571 <span class="items-center font-heading text-3xl"> 1572 Varukorg 1573 </span> 1574 1575 <div class="flex"> 1576 1577 <!-- Close button --> 1578 <button 1579 @click="$store.cart.hideCart()" 1580 class="inline-block cart-close cursor-pointer focus:outline-none hover:opacity-50" 1581 type="button" 1582 aria-label="Close" 1583 > 1584 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> 1585 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path> 1586 </svg> 1587 </button> 1588 1589 </div> 1590 1591 </header> 1592 1593 <!-- Cart contents --> 1594 <div class="h-full p-6 overflow-y-auto"> 1595 1596 <!-- Cart messages --> 1597 <div class="qs-cart-message"></div> 1598 1599 <!-- Empty cart snippet--> 1600 <!-- In case of empty cart - display this snippet + remove .d-none--> 1601 <div class="hidden qs-cart-empty flex items-center h-full opacity-50 text-center"> 1602 <div> 1603 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="inline-block w-16 h-16 mb-6"> 1604 <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5
1604h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"></path> 1605 </svg> 1606 <p> 1607 Varukorgen är tom 1608 </p> 1609 </div> 1610 </div> 1611 1612 <!-- Cart items --> 1613 <template 1614 x-for="product in $store.cart.products" 1615 :key="`${product.item.id}-${product.qty}-${product.uid}`" 1616 > 1617 <div class="mb-6"> 1618 1619 <div class="flex w-full"> 1620 1621 <!-- Product image --> 1622 <div class="flex justify-center items-center pr-4 w-24"> 1623 <a :href="product.item.url"> 1624 <img loading="lazy" :src="product.item.firstimage + '?w=100&h=100'" class="object-contain object-center w-full h-full roundednone" :alt="product.item.title"> 1625 </a> 1626 </div> 1627 <div class="flex flex-1 flex-col"> 1628 1629 <!-- Product title --> 1630 <a :href="product.item.url"> 1631 <h4 class="font-body font-bold" x-text="product.item.title"></h4> 1632 </a> 1633 1634 <!-- Product variant --> 1635 <template x-if="product.variant_name"> 1636 <small x-text="product.variant_name" class="opacity-50"></small> 1637 </template> 1638 1639 <!-- Product propertiers --> 1640 <template x-if="product.properties"> 1641 <template x-for="property in Object.values(product.properties)"> 1642 <small class="break-words" x-text="property"></small> 1643 </template> 1644 </template> 1645 1646 <!-- Price --> 1647 <!-- Current price --> 1648 <span class="text-sm"> 1649 <span 1650 :class="product.item.has_discount ? 'text-discounted-price' : ''" 1651 x-text="product.item.price" 1652 ></span> 1653 1654 <!-- Original price (only show if has discount) --> 1655 <template x-if="product.item.has_discount"> 1656 <span 1657 class="opacity-50 line-through ml-1" 1658 x-text="product.item.original_price" 1659 ></span> 1660 </template> 1661 </span> 1662 1663 <div 1664 class="flex-1 flex items-center justify-between mt-2" 1665 x-data="{ 1666 quantity: product.qty, 1667 }" 1668 x-init="$watch('quantity', Alpine.debounce((value) => { 1669 if (value && value > 0) { 1670 $store.cart.update({ 1671 productID: product.item.id, 1672 quantity: value 1673 }) 1674 } 1675 }, 500))" 1676 > 1677 <div class="flex border border-gray-200 input-rounding overflow-hidden"> 1678 1679 <!-- Decrease quantity --> 1680 <button 1681 type="button" 1682 class="w-8 font-bold cursor-pointer input-bg focus:outline-none hover:bg-gray-100 py-1 px-2" 1683 @click="quantity = Math.max(1, quantity - 1)" 1684 > 1685 - 1686 </button> 1687 1688 <!-- Quantity input --> 1689 <input 1690 required 1691 min="1" 1692 step="1" 1693 type="number" 1694 class="w-12 qs-cart-qty navbar-cart-product-qty input-bg border-0 input-items text-center focus:outline-none" 1695 x-model="quantity"
1696 @input="quantity = Math.floor(Number(quantity))" 1697 > 1698 1699 <!-- Increase quantity --> 1700 <button 1701 type="button" 1702 class="w-8 font-bold cursor-pointer input-bg focus:outline-none hover:bg-gray-100 py-1 px-2" 1703 @click="quantity = Number(quantity) + 1" 1704 > 1705 + 1706 </button> 1707 </div> 1708 1709 <!-- Remove from cart --> 1710 <button 1711 type="button" 1712 class="font-medium text-baseThird qs-cart-delete btn hover:text-red-700" 1713 data-pid="" 1714 @click="$store.cart.delete({ 1715 productID: product.item.id, 1716 })" 1717 > 1718 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> 1719 <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /> 1720 </svg> 1721 </button> 1722 </div> 1723 1724 </div> 1725 </div> 1726 1727 </div> 1728 </template> 1729 </div> 1730 1731 <!-- Footer --> 1732 1733 <!-- Tax --> 1734 1735 <footer class="mt-auto p-8 bg-gray-400 bg-opacity-5"> 1736 1737 <!-- Total discount (only show if there's a discount) --> 1738 <div class="flex items-end mb-2" x-show="$store.cart.totalDiscountRaw && $store.cart.totalDiscountRaw > 0"> 1739 <div class="flex-1"> 1740 <span class="text-sm font-medium text-discounted-price">Rabatt</span> 1741 </div> 1742 <div class="flex-1 text-right"> 1743 <span 1744 class="text-sm font-medium text-discounted-price" 1745 x-text="'-' + $store.cart.totalDiscount" 1746 ></span> 1747 </div> 1748 </div> 1749 1750 <div class="flex mb-4"> 1751 <div class="flex-1"> 1752 <span class="text-lg font-bold">Total summa:</span> 1753 </div> 1754 <div class="flex-1 text-right"> 1755 <span 1756 class="text-lg font-bold text-primary" 1757 x-text="$store.cart.totalAmount" 1758 ></span> 1759 </div> 1760 </div> 1761 <div> 1762 <button @click='$store.cart.hideCart()' class="block w-full button-px button-py mb-2 text-center text-primary font-bold hover:text-body button-rounding focus:outline-none"> 1763 Fortsätt att handla 1764 </button> 1765 <a href="/cart/index" class="block button-px button-py text-center text-white font-bold bg-primary hover:bg-gray-900 button-rounding focus:outline-none"> 1766 Till kassan 1767 </a> 1768 </div> 1769 </footer> 1770 1771 </div> 1772 </div> 1773 <section 1774 x-data 1775 x-cloak 1776 x-show="$store.modal.modalVisible" 1777 x-transition.opacity 1778 class="fixed inset-0 py-24 bg-black bg-opacity-50 z-50 h-full overflow-y-scroll"> 1779 <div class="container px-4 mx-auto relative"> 1780 <div x-on:click.outside="$store.modal.closeModal()" :style="{'background-color': $store.modal.backgroundColor || '#fff'}" 1781 class="relative max-w-xl mx-auto text-center py-12 md:py-16 px-6 md:px-14 roundednone"> 1782 <a class="absolute top-4 right-4 inline-block ml-auto cursor-pointer hover:opacity-50" x-on:click="$store.modal.closeModal()" href="#" rel="nofollow"> 1783 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> 1784 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path> 1785 </svg> 1786 </a> 1787 <div x-html="$store.modal.content"></div> 1788 </div> 1789 </div> 1790 </section>
1791<script type="text/javascript"> var recaptchaSiteKey="6Lc-2GcUAAAAAMXG8Lz5S_MpVshTUeESW6iJqK0_"; </script>
1791<script type="text/javascript">window.registerApp({ 1792 name: 'Store Notifications', 1793 identifier: 'QuickbutikStoreNotifications', 1794 description: 'Show store notifications', 1795 version: '1.0.1', 1796 pages: ['list', 'start', 'product'], 1797 data: {}, 1798 config: {}, 1799 onMount: function ({ appData, page }) { 1800 console.debug(`${this.identifier}@onMount`) 1801 1802 const content = appData?.storenotices?.text 1803 const nid = appData?.storenotices?.nid 1804 1805 if (content) { 1806 Alpine.store('notifications').addNotification({ 1807 type: 'html', 1808 htmlContent: content, 1809 lifeTime: appData.storenotices.timer_seconds === "0" || appData.storenotices.timer_seconds === 0 1810 ? null 1811 : appData.storenotices.timer_seconds 1812 ? appData.storenotices.timer_seconds * 1000 1813 : 5000, 1814 backgroundColor: appData.storenotices.bg_color, 1815 textColor: appData.storenotices.text_color, 1816 onRemove: () => { 1817 if (nid) { 1818 const formData = new FormData() 1819 formData.append('nid', nid) 1820 1821 fetch('/apps/storenotices/shown', { 1822 method: 'POST', 1823 body: formData, 1824 keepalive: true, 1825 }) 1826 } 1827 }, 1828 }) 1829 } 1830 }, 1831}) 1832</script>
1832<script type="text/javascript">window.registerApp({ 1833 name: 'Popups', 1834 identifier: 'QuickbutikPopups', 1835 description: 'Display popups when page loads', 1836 version: '2.0.0', 1837 pages: ['list', 'start', 'product'], 1838 data: {}, 1839 config: { 1840 style: { 1841 popup: { 1842 containerClasses: 'qb-popup-container', 1843 titleClasses: 'qb-popup-title', 1844 textClasses: 'qb-popup-text', 1845 buttonClasses: 'qb-popup-button', 1846 inputClasses: 'qb-popup-input', 1847 imageClasses: 'qb-popup-image', 1848 }, 1849 }, 1850 renderPopup: null, 1851 renderSecondPage: null, 1852 }, 1853 _cssInjected: false, 1854 _minimizeTab: null, 1855 _goalCompleted: false, 1856 onMount: function ({ appData, page }) { 1857 if (new URLSearchParams(window.location.search).has('_langtags_scan')) return 1858 1859 this._injectStyles() 1860 this._attachSubmitHandler() 1861 1862 appData.storepopups = appData.storepopups 1863 .sort((a, b) => a.on_exit - b.on_exit) 1864 .filter((p) => !sessionStorage.getItem('qb_popup_shown_' + p.ppid)) 1865 1866 if (appData?.storepopups?.length) { 1867 this.data.popup = appData.storepopups[0] 1868 const { on_exit } = this?.data?.popup ?? {} 1869 1870 var ppid = this.data.popup.ppid 1871 var popupData = this._buildPopupData(this.data.popup) 1872 1873 if (popupData.minimizeOnClose && sessionStorage.getItem('qb_popup_minimized_' + ppid)) { 1874 this._showMinimizeTab(popupData, ppid) 1875 return 1876 } 1877 1878 const openModalIfNeeded = () => { 1879 if (on_exit === true && !this.mouseLeftDocument) { 1880 return 1881 } 1882 1883 if (this.data.popup) { 1884 this._openPopup(false, popupData) 1885 } 1886 } 1887 1888 if (on_exit === true) { 1889 this.attachMouseLeaveListener(openModalIfNeeded) 1890 } else { 1891 openModalIfNeeded() 1892 } 1893 } 1894 }, 1895 _openPopup: function (skipDelay, cachedPopupData) { 1896 var popup = this.data.popup 1897 if (!popup) return 1898 1899 var delay_seconds = popup.delay_seconds 1900 var p_bg_color = popup.p_bg_color 1901 var ppid = popup.ppid 1902 1903 var popupData = cachedPopupData || this._buildPopupData(popup) 1904 1905 Alpine.store('modal').openPopup( 1906 popupData, 1907 (data, state) => this._render(data, state), 1908 { 1909 delay: skipDelay ? 0 : parseInt(delay_seconds ?? 0) * 1000, 1910 backgroundColor: p_bg_color, 1911 position: popupData.position, 1912 modalSize: popupData.modalSize, 1913 imagePosition: popupData.imagePosition, 1914 appearEffect: popupData.appearEffect, 1915 image: popupData.image, 1916 onClose: () => { 1917 if (popupData.minimizeOnClose && !this._goalCompleted) { 1918 this._showMinimizeTab(popupData, ppid) 1919 } else { 1920 this._goalCompleted = false 1921 sessionStorage.removeItem('qb_popup_minimized_' + ppid) 1922 this._markShown(ppid) 1923 this.data.popup = null 1924 } 1925 }, 1926 } 1927 ) 1928 }, 1929 _markShown: function (ppid) { 1930 var formData = new FormData() 1931 formData.append('ppid', ppid) 1932 fetch('/apps/storepopups/shown', { method: 'POST', body: formData }) 1933 }, 1934 _showMinimizeTab: function (popupData, ppid) { 1935 this._hideMinimizeTab() 1936 sessionStorage.setItem('qb_popup_minimized_' + ppid, '1') 1937 1938 var tab = document.createElement('button') 1939 tab.className = 'qb-popup-minimize-tab' 1940 tab.setAttribute('data-qb-minimize-tab', '') 1941 tab.style.backgroundColor = popupData.bgColor || '#fff' 1942 tab.style.color = popupData.textColor || '#000' 1943 1944 var chevron = document.createElement('span') 1945 chevron.className = 'qb-popup-minimize-tab__chevron' 1946 chevron.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="2 9 7 4 12 9"/></svg>' 1947 1948 var textSpan = document.createElement('span') 1949 textSpan.textContent = popupData.minimizeText || popupData.title || '' 1950 1951 var closeBtn = document.createElement('span') 1952 closeBtn.className = 'qb-popup-minimize-tab__close' 1953 closeBtn.setAttribute('role', 'button') 1954 closeBtn.setAttribute('aria-label', 'Close') 1955 closeBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="2" y1="2" x2="12" y2="12"/><line x1="12" y1="2" x2="2" y2="12"/></svg>' 1956 closeBtn.addEventListener('click', function (e) { 1957 e.stopPropagation() 1958 this._dismissPopup(ppid) 1959 }.bind(this)) 1960 1961 tab.appendChild(chevron) 1962 tab.appendChild(textSpan) 1963 tab.appendChild(closeBtn) 1964 1965 tab.addEventListener('click', function () { 1966 this._reopenPopup() 1967 }.bind(this)) 1968 1969 document.body.appendChild(tab) 1970 this._minimizeTab = tab 1971 }, 1972 _hideMinimizeTab: function () { 1973 if (this._minimizeTab) { 1974 this._minimizeTab.remove() 1975 this._minimizeTab = null 1976 } 1977 }, 1978 _reopenPopup: function () { 1979 this._hideMinimizeTab() 1980 this._openPopup(true) 1981 }, 1982 _dismissPopup: function (ppid) { 1983 var tab = this._minimizeTab 1984 this._minimizeTab = null 1985 if (tab) { 1986 tab.classList.add('qb-popup-minimize-tab--exit') 1987 setTimeout(function () { tab.remove() }, 200) 1988 } 1989 1990 sessionStorage.removeItem('qb_popup_minimized_' + ppid) 1991 this._goalCompleted = false 1992 this._markShown(ppid) 1993 this.data.popup = null 1994 }, 1995 attachMouseLeaveListener: function (callback) { 1996 document.addEventListener('mouseleave', () => { 1997 this.mouseLeftDocument = true 1998 callback() 1999 }) 2000 }, 2001 _buildPopupData: function (popup) { 2002 return { 2003 title: popup.p_title, 2004 text: popup.p_text, 2005 textColor: popup.p_text_color, 2006 bgColor: popup.p_bg_color, 2007 btnText: popup.p_btn_text, 2008 btnBgColor: popup.p_btn_bg_color, 2009 btnTextColor: popup.p_btn_text_color, 2010 hasGoal: popup.has_goal, 2011 goalType: popup.goal_type, 2012 textGoal: popup.p_text_goal, 2013 image: popup.p_image || null, 2014 position: popup.position || 'center', 2015 modalSize: popup.modal_size || 'medium', 2016 imagePosition: popup.image_position || 'top', 2017 imageWidth: popup.image_width || 50,
2018 textAlign: popup.text_align || 'center', 2019 showLogo: popup.show_logo == 1, 2020 logoUrl: popup.p_logo_url || null, 2021 appearEffect: popup.appear_effect || 'fade-up', 2022 showImageOnSuccess: popup.show_image_on_success != 0 && popup.show_image_on_success !== false, 2023 minimizeOnClose: popup.minimize_on_close == true || popup.minimize_on_close == 1, 2024 minimizeText: popup.minimize_text || null, 2025 finePrint: popup.fine_print || null, 2026 } 2027 }, 2028 _render: function (popupData, state) { 2029 if (state === 'success') { 2030 if (this.config.renderSecondPage) { 2031 return this.config.renderSecondPage(popupData) 2032 } 2033 return this._renderSuccess(popupData) 2034 } 2035 2036 if (this.config.renderPopup) { 2037 return this.config.renderPopup(popupData) 2038 } 2039 return this._renderPopup(popupData) 2040 }, 2041 _renderPopup: function (popupData) { 2042 const { 2043 title, 2044 text, 2045 textColor, 2046 btnText, 2047 btnBgColor, 2048 btnTextColor, 2049 hasGoal, 2050 goalType, 2051 image, 2052 imagePosition, 2053 imageWidth, 2054 } = popupData 2055 2056 const cls = this.config.style.popup 2057 const isHorizontal = image && (imagePosition === 'left' || imagePosition === 'right') 2058 const isTopImage = image && imagePosition === 'top' 2059 const isTakeover = image && imagePosition === 'background' 2060 const isCover = image && imagePosition === 'cover' 2061 const align = popupData.textAlign || 'center' 2062 const imgW = imageWidth || 40 2063 2064 let html = `<div class="${cls.containerClasses}${isHorizontal ? ' qb-popup-horizontal' : ''}${isTopImage ? ' qb-popup-has-top-image' : ''}${isTakeover ? ' qb-popup-takeover' : ''}${isCover ? ' qb-popup-cover' : ''}" data-qb-popup data-qb-image-position="${imagePosition || 'top'}" style="color: ${textColor || '#000'}; text-align: ${align}${isCover ? '; background-image: url("' + image + '"); background-size: cover; background-position: center' : ''}">` 2065 2066 if (isTopImage) { 2067 html += `<img class="${cls.imageClasses} qb-popup-image--top" data-qb-popup-part="image" src="${image}" alt="" style="max-height: ${imgW * 0.4}rem" />` 2068 } 2069 2070 if (image && imagePosition === 'left') { 2071 html += `<img class="${cls.imageClasses} qb-popup-image--side" data-qb-popup-part="image" src="${image}" alt="" style="width: ${imageWidth}%" />` 2072 } 2073 2074 html += `<div class="qb-popup-body">` 2075 2076 if (popupData.showLogo && popupData.logoUrl) { 2077 const logoAlign = align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center' 2078 html += `<img class="qb-popup-logo" data-qb-popup-part="logo" src="${popupData.logoUrl}" alt="" style="align-self: ${logoAlign}" />` 2079 } 2080 2081 html += `<h2 class="${cls.titleClasses}" data-qb-popup-part="title">${this._escapeHtml(title || '')}</h2>` 2082 html += `<div class="${cls.textClasses}" data-qb-popup-part="text">${text || ''}</div>` 2083 2084 if (hasGoal) { 2085 var formAlign = align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center' 2086 html += `<form class="qb-popup-form" data-qb-popup-part="form" style="align-items: ${formAlign}">` 2087 2088 if (goalType === '1') { 2089 html += `<input class="${cls.inputClasses}" data-qb-popup-part="input" type="email" id="popupNewsletterEmail" placeholder="${this.config?.modal?.inputPlaceholder ?? 'Email'}" required />` 2090 } 2091 2092 html += `<button class="${cls.buttonClasses}" data-qb-popup-part="button" type="submit" style="background-color: ${btnBgColor || '#000'}; color: ${btnTextColor || '#fff'}">${this._escapeHtml(btnText || '')}</button>` 2093 html += `</form>` 2094 } 2095 2096 if (popupData.finePrint) { 2097 html += `<div class="qb-popup-fine-print" data-qb-popup-part="fine-print">${this._escapeHtml(popupData.finePrint)}</div>` 2098 } 2099 2100 html += `</div>` 2101 2102 if (image && imagePosition === 'right') { 2103 html += `<img class="${cls.imageClasses} qb-popup-image--side" data-qb-popup-part="image" src="${image}" alt="" style="width: ${imageWidth}%" />` 2104 } 2105 2106 html += `</div>` 2107 2108 return html 2109 }, 2110 _renderSuccess: function (popupData) { 2111 const { title, textGoal, textColor, image, imagePosition, imageWidth, showImageOnSuccess } = popupData 2112 const cls = this.config.style.popup 2113 const showImage = image && showImageOnSuccess 2114 const isHorizontal = showImage && (imagePosition === 'left' || imagePosition === 'right') 2115 const isTopImage = showImage && imagePosition === 'top' 2116 const isTakeover = showImage && imagePosition === 'background' 2117 const isCover = showImage && imagePosition === 'cover' 2118 const align = popupData.textAlign || 'center' 2119 const imgW = imageWidth || 40 2120 2121 let html = `<div class="${cls.containerClasses} qb-popup-success${isHorizontal ? ' qb-popup-horizontal' : ''}${isTopImage ? ' qb-popup-has-top-image' : ''}${isTakeover ? ' qb-popup-takeover' : ''}${isCover ? ' qb-popup-cover' : ''}" data-qb-popup data-qb-popup-state="success" data-qb-image-position="${imagePosition || 'top'}" style="color: ${textColor || '#000'}; text-align: ${align}${isCover ? ';
2121 background-image: url("' + image + '"); background-size: cover; background-position: center' : ''}">` 2122 2123 if (isTopImage) { 2124 html += `<img class="${cls.imageClasses} qb-popup-image--top" data-qb-popup-part="image" src="${image}" alt="" style="max-height: ${imgW * 0.4}rem" />` 2125 } 2126 2127 if (showImage && imagePosition === 'left') { 2128 html += `<img class="${cls.imageClasses} qb-popup-image--side" data-qb-popup-part="image" src="${image}" alt="" style="width: ${imageWidth}%" />` 2129 } 2130 2131 html += `<div class="qb-popup-body">` 2132 2133 html += `<div class="qb-popup-success-icon" data-qb-popup-part="success-icon">` 2134 html += `<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="24" cy="24" r="24" fill="currentColor" opacity="0.1"/><path d="M15 24.5L21 30.5L33 18.5" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>` 2135 html += `</div>` 2136 2137 html += `<h2 class="${cls.titleClasses}" data-qb-popup-part="title">${this._escapeHtml(title || '')}</h2>` 2138 html += `<div class="${cls.textClasses}" data-qb-popup-part="text">${textGoal || ''}</div>` 2139 2140 html += `</div>` 2141 2142 if (showImage && imagePosition === 'right') { 2143 html += `<img class="${cls.imageClasses} qb-popup-image--side" data-qb-popup-part="image" src="${image}" alt="" style="width: ${imageWidth}%" />` 2144 } 2145 2146 html += `</div>` 2147 2148 return html 2149 }, 2150 _escapeHtml: function (str) { 2151 const div = document.createElement('div') 2152 div.textContent = str 2153 return div.innerHTML 2154 }, 2155 _attachSubmitHandler: function () { 2156 document.addEventListener('submit', (e) => { 2157 if (e.target.classList.contains('qb-popup-form')) { 2158 e.preventDefault() 2159 this.onButtonClick(e.target) 2160 } 2161 }) 2162 2163 document.addEventListener('click', (e) => { 2164 var link = e.target.closest('[data-qb-popup-part="text"] a') 2165 if (link && this.data.popup) { 2166 var ppid = this.data.popup.ppid 2167 if (ppid) { 2168 sessionStorage.setItem('qb_popup_shown_' + ppid, '1') 2169 var formData = new FormData() 2170 formData.append('ppid', ppid) 2171 navigator.sendBeacon('/apps/storepopups/shown', formData) 2172 } 2173 } 2174 }) 2175 }, 2176 onButtonClick: function (form) { 2177 const { goal_type } = this?.data?.popup ?? {} 2178 2179 if (goal_type !== '1') { 2180 this._showSuccess() 2181 return 2182 } 2183 2184 const input = form.querySelector('input[data-qb-popup-part="input"]') 2185 const email = input.value.trim() 2186 2187 input.style.border = '' 2188 form.querySelector('[data-qb-popup-part="error"]')?.remove() 2189 2190 if (!email) { 2191 this._markInvalid(input) 2192 return 2193 } 2194 2195 const formData = new FormData() 2196 formData.append('email_address', email) 2197 2198 fetch('/apps/storepopups/add', { method: 'POST', body: formData }) 2199 .then((res) => res.json()) 2200 .then((data) => { 2201 if (data && data.success) { 2202 this._showSuccess() 2203 } else if (data && data.reason === 'invalid_email') { 2204 this._markInvalid(input) 2205 } else { 2206 this._showError(form) 2207 } 2208 }) 2209 .catch(() => this._showError(form)) 2210 }, 2211 _markInvalid: function (input) { 2212 if (input) input.style.border = '1px solid red' 2213 }, 2214 _showError: function (form) { 2215 let error = form.querySelector('[data-qb-popup-part="error"]') 2216 2217 if (!error) { 2218 error = document.createElement('div') 2219 error.className = 'qb-popup-error' 2220 error.setAttribute('data-qb-popup-part', 'error') 2221 form.appendChild(error) 2222 } 2223 2224 error.textContent = 2225 this?.data?.popup?.signup_error_text || 2226 'The signup could not be completed. Please try again later.' 2227 }, 2228 _showSuccess: function () { 2229 this._goalCompleted = true 2230 Alpine.store('modal').setPopupState( 2231 'success', 2232 (data, state) => this._render(data, state) 2233 ) 2234 }, 2235 _injectStyles: function () { 2236 if (this._cssInjected) return 2237 this._cssInjected = true 2238 2239 const style = document.createElement('style') 2240 style.setAttribute('data-qb-popup-styles', '') 2241 style.textContent = ` 2242 :root { 2243 --qb-popup-radius: 1rem; 2244 --qb-popup-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); 2245 --qb-popup-max-width: 28rem; 2246 --qb-popup-padding: 2rem 2.5rem; 2247 --qb-popup-enter-duration: 0.3s; 2248 --qb-popup-enter-easing: cubic-bezier(0.16, 1, 0.3, 1); 2249 --qb-popup-btn-radius: 0.5rem; 2250 --qb-popup-btn-padding: 0.75rem 2rem; 2251 --qb-popup-btn-font-weight: 600; 2252 --qb-popup-input-radius: 0.5rem; 2253 --qb-popup-input-padding: 0.75rem 1rem; 2254 --qb-popup-input-border: 1px solid #e5e7eb; 2255 --qb-popup-gap: 1.25rem; 2256 --qb-popup-title-size: clamp(1.5em, 4vw, 2em); 2257 --qb-popup-text-size: 1em; 2258 --qb-popup-content-enter: qb-popup-content-enter; 2259 } 2260 2261 .qb-popup-logo { 2262 max-height: 1.75rem; 2263 max-width: 8rem; 2264 object-fit: contain; 2265 } 2266 2267 /* Anchor popup font-size so children using em units do not inherit
2268 theme-level html font-size quirks (e.g. Charme sets html to 10px). */ 2269 .qb-popup-container, 2270 .qb-popup-cover, 2271 .qb-popup-horizontal { 2272 font-size: 16px; 2273 } 2274 2275 .qb-popup-container { 2276 text-align: center; 2277 padding: var(--qb-popup-padding); 2278 animation-name: var(--qb-popup-content-enter); 2279 animation-duration: var(--qb-popup-enter-duration); 2280 animation-timing-function: var(--qb-popup-enter-easing); 2281 animation-fill-mode: both; 2282 } 2283 2284 .qb-popup-title { 2285 font-size: var(--qb-popup-title-size); 2286 font-weight: 700; 2287 line-height: 1.2; 2288 margin: 0; 2289 } 2290 2291 .qb-popup-text { 2292 font-size: var(--qb-popup-text-size); 2293 line-height: 1.6; 2294 margin: 0; 2295 opacity: 0.85; 2296 } 2297 2298 .qb-popup-text a { text-decoration: underline; } 2299 .qb-popup-text .fr-green { color: #38a169; } 2300 .qb-popup-text .fr-strong { font-weight: bold; } 2301 2302 .qb-popup-form { 2303 display: flex; 2304 flex-direction: column; 2305 gap: 0.75rem; 2306 } 2307 2308 .qb-popup-input { 2309 width: 100%; 2310 padding: var(--qb-popup-input-padding); 2311 border: var(--qb-popup-input-border); 2312 border-radius: var(--qb-popup-input-radius); 2313 font-size: 1em; 2314 text-align: inherit; 2315 background: rgba(0, 0, 0, 0.03); 2316 color: inherit; 2317 outline: none; 2318 transition: border-color 0.2s, box-shadow 0.2s; 2319 box-sizing: border-box; 2320 } 2321 2322 .qb-popup-input:focus { 2323 border-color: currentColor; 2324 box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05); 2325 } 2326 2327 .qb-popup-button { 2328 display: inline-block; 2329 padding: var(--qb-popup-btn-padding); 2330 border: none; 2331 border-radius: var(--qb-popup-btn-radius); 2332 font-size: 1em; 2333 font-weight: var(--qb-popup-btn-font-weight); 2334 cursor: pointer; 2335 transition: opacity 0.2s, transform 0.1s; 2336 line-height: 1.4; 2337 } 2338 2339 .qb-popup-button:hover { 2340 opacity: 0.85; 2341 } 2342 2343 .qb-popup-button:active { 2344 transform: scale(0.98); 2345 } 2346 2347 .qb-popup-body { 2348 display: flex; 2349 flex-direction: column; 2350 gap: var(--qb-popup-gap); 2351 flex: 1; 2352 min-width: 0; 2353 } 2354 2355 .qb-popup-image { 2356 width: 100%; 2357 border-radius: calc(var(--qb-popup-radius) - 0.25rem); 2358 margin-bottom: var(--qb-popup-gap); 2359 object-fit: contain; 2360 max-height: var(--qb-popup-image-max-height, none); 2361 } 2362 2363 .qb-popup-has-top-image { 2364 padding: 0; 2365 } 2366 2367 .qb-popup-image--top { 2368 width: 100%; 2369 display: block; 2370 border-radius: 0; 2371 margin-bottom: 0; 2372 object-fit: cover; 2373 max-height: none; 2374 } 2375 2376 .qb-popup-has-top-image .qb-popup-body { 2377 padding: var(--qb-popup-padding); 2378 } 2379 2380 .qb-popup-cover { 2381 position: relative; 2382 display: flex; 2383 align-items: center; 2384 justify-content: center; 2385 text-align: center; 2386 min-height: 16rem; 2387 } 2388 2389 .qb-popup-cover .qb-popup-body { 2390 position: relative; 2391 z-index: 1; 2392 padding: var(--qb-popup-padding); 2393 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); 2394 } 2395 2396 .qb-popup-horizontal { 2397 display: flex; 2398 padding: 0; 2399 gap: 0; 2400 } 2401 2402 .qb-popup-horizontal .qb-popup-body { 2403 padding: var(--qb-popup-padding); 2404 justify-content: center; 2405 } 2406 2407 .qb-popup-image--side { 2408 width: auto; 2409 margin: 0; 2410 border-radius: 0; 2411 flex-shrink: 0; 2412 object-fit: cover; 2413 max-height: none; 2414 } 2415 2416 .qb-popup-horizontal[data-qb-image-position="left"] .qb-popup-image--side { 2417 border-radius: calc(var(--qb-popup-radius) - 0.125rem) 0 0 calc(var(--qb-popup-radius) - 0.125rem); 2418 } 2419 2420 .qb-popup-horizontal[data-qb-image-position="right"] .qb-popup-image--side { 2421 border-radius: 0 calc(var(--qb-popup-radius) - 0.125rem) calc(var(--qb-popup-radius) - 0.125rem) 0; 2422 } 2423 2424 @media (max-width: 480px) { 2425 .qb-popup-horizontal { 2426 flex-direction: column; 2427 padding: 0; 2428 } 2429 .qb-popup-image--side { 2430 width: 100% !important; 2431 max-width: none !important; 2432 max-height: 12rem; 2433 order: -1; 2434 object-fit: cover; 2435 border-radius: 0 !important; 2436 } 2437 .qb-popup-horizontal .qb-popup-body { 2438 padding: var(--qb-popup-padding); 2439 } 2440 } 2441 2442 .qb-popup-success { 2443 animation-name: var(--qb-popup-content-enter); 2444 animation-duration: var(--qb-popup-enter-duration); 2445 animation-timing-function: var(--qb-popup-enter-easing); 2446 animation-fill-mode: both; 2447 } 2448 2449 .qb-popup-success-icon { 2450 margin-bottom: 1rem; 2451 color: #38a169; 2452 } 2453 2454 .qb-popup-success-icon svg { 2455 display: inline-block; 2456 } 2457 2458 .qb-popup-fine-print { 2459 font-size: 0.8125em; 2460 line-height: 1.4; 2461 opacity: 0.5; 2462 margin-top: 0.25rem; 2463 color: inherit; 2464 } 2465 2466 .qb-popup-fine-print a { text-decoration: underline; } 2467 2468 .qb-popup-error { 2469 font-size: 0.875em; 2470 color: #dc2626; 2471 margin-top: 0.25rem; 2472 } 2473 2474 @keyframes qb-popup-content-enter { 2475 from { 2476 opacity: 0; 2477 transform: translateY(8px); 2478 } 2479 to { 2480 opacity: 1; 2481 transform: translateY(0); 2482 } 2483 } 2484 2485 @media (max-width: 640px) { 2486 :root { 2487 --qb-popup-padding: 1.5rem 1.25rem; 2488 --qb-popup-max-width: 100%; 2489 } 2490 } 2491 2492 /* Minimize tab */ 2493 .qb-popup-minimize-tab { 2494 position: fixed; 2495 bottom: 1rem; 2496 left: 50%; 2497 transform: translateX(-50%); 2498 z-index: 9999; 2499 display: flex; 2500 align-items: center; 2501 gap: 0.5rem; 2502 padding: 0.625rem 1.25rem; 2503 border-radius: 9999px; 2504 cursor: pointer; 2505 font-size: 0.875rem; 2506 font-weight: 600; 2507 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 2508 animation: qb-tab-slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; 2509 border: none; 2510 font-family: inherit; 2511 line-height: 1; 2512 white-space: nowrap; 2513 max-width: calc(100% - 2rem); 2514 } 2515 2516 .qb-popup-minimize-tab:hover { 2517 filter: brightness(1.1); 2518 } 2519 2520 .qb-popup-minimize-tab--exit { 2521 animation: qb-tab-slide-down 0.2s ease forwards !important; 2522 } 2523 2524 .qb-popup-minimize-tab__chevron { 2525 display: flex; 2526 align-items: center; 2527 flex-shrink: 0; 2528 } 2529 2530 .qb-popup-minimize-tab__close { 2531 display: flex; 2532 align-items: center; 2533 justify-content: center; 2534 background: none; 2535 border: none; 2536 cursor: pointer; 2537 color: inherit; 2538 opacity: 0.6; 2539 padding: 0.125rem; 2540 margin-left: 0.25rem; 2541 flex-shrink: 0; 2542 transition: opacity 0.15s; 2543 } 2544 2545 .qb-popup-minimize-tab__close:hover { 2546 opacity: 1; 2547 } 2548 2549 @keyframes qb-tab-slide-up { 2550 from { transform: translateX(-50%) translateY(calc(100% + 1rem)); } 2551 to { transform: translateX(-50%) translateY(0); } 2552 } 2553 2554 @keyframes qb-tab-slide-down { 2555 from { transform: translateX(-50%) translateY(0); } 2556 to { transform: translateX(-50%) translateY(calc(100% + 1rem)); } 2557 } 2558 ` 2559 document.head.prepend(style) 2560 }, 2561}) 2562</script>
2562<script type="text/javascript">window.registerApp({ 2563 name: 'Free Shipping Timer', 2564 identifier: 'QuickbutikFreeShippingTimer', 2565 description: 'Show free shipping timer', 2566 version: '1.0.0', 2567 pages: ['list', 'start', 'product'], 2568 data: {}, 2569 config: {}, 2570 onMount: function ({ appData, page }) { 2571 console.debug(`${this.identifier}@onMount`) 2572 2573 if (appData?.freeshippingtimer) { 2574 setTimeout(() => { 2575 Alpine.effect(() => { 2576 const currentAmount = Alpine.store('cart').totalAmountRaw 2577 const previousAmount = this.data.amount 2578 2579 // Skip if cart data hasn't loaded yet (null = initial state) 2580 if (currentAmount === null) { 2581 return 2582 } 2583 2584 // Detect if this is initial load (previous was null/undefined) or a cart change 2585 const isInitialLoad = previousAmount === null || previousAmount === undefined 2586 const hasCartChanged = previousAmount !== currentAmount 2587 2588 // Update stored amount 2589 this.data.amount = currentAmount 2590 2591 // Show notification on initial load (if cart has items) or when cart changes 2592 if ((isInitialLoad && currentAmount > 0) || (!isInitialLoad && hasCartChanged)) { 2593 fetch(qs_store_url + '/apps/freeshippingtimer/fetch', { 2594 method: 'POST', 2595 }) 2596 .then((response) => response.json()) 2597 .then((response) => { 2598 if (response?.message) { 2599 Alpine.store('notifications').addNotification({ 2600 type: 'info', 2601 category: 'freeshippingtimer', 2602 clearCategory: true, 2603 message: response.message, 2604 lifeTime: 5000, 2605 backgroundColor: appData?.freeshippingtimer?.bg_color, 2606 textColor: appData?.freeshippingtimer?.text_color, 2607 }) 2608 } 2609 }) 2610 } 2611 }) 2612 }, 2000) 2613 } 2614 }, 2615}) 2616</script>
2616<script type="text/javascript">window.registerApp({ 2617 name: 'Product Sort', 2618 identifier: 'QuickbutikProductSort', 2619 description: 'Product sort', 2620 version: '1.1.1', 2621 pages: ['list'], 2622 data: {}, 2623 config: { 2624 productElementSelector: '[data-qb-selector="product-item"]', 2625 sortElementTargetSelector: '[data-qb-selector="sort-container"]', 2626 styling: { 2627 sortElementClasses: '', 2628 }, 2629 }, 2630 onMount: function ({ appData, page }) { 2631 console.debug(`${this.identifier}@onMount`) 2632 2633 if (appData?.productsort) { 2634 if (appData.productsort?.sortoptions) { 2635 this.config.options = appData.productsort.sortoptions 2636 } 2637 2638 if (this.config.options) { 2639 this.injectSortElement({appData}) 2640 } 2641 } 2642 }, 2643 injectSortElement: function ({ appData }) { 2644 const container = document.querySelector( 2645 this.config.sortElementTargetSelector 2646 ) 2647 2648 if (container) { 2649 container.appendChild(this.renderSortElement({appData})) 2650 } 2651 }, 2652 renderSortElement: function ({ appData }) { 2653 const sortElement = document.createElement('select') 2654 2655 if (this.config.styling.sortElementClasses) { 2656 sortElement.classList.add( 2657 ...this.config.styling.sortElementClasses.split(' ') 2658 ) 2659 } 2660 2661 // Get sort param from URL 2662 var preselectedSort = new URL(location.href).searchParams.get('sort') 2663 2664 // If no sort param from URL, use category's default sorting 2665 if (preselectedSort === null && appData.productsort?.set_default != '') { 2666 var preselectedSort = appData.productsort?.set_default 2667 } 2668 2669 Object.keys(this.config.options).forEach((optionKey) => { 2670 const optionElement = document.createElement('option') 2671 2672 const option = { 2673 value: optionKey, 2674 label: this.config.options[optionKey], 2675 } 2676 2677 optionElement.value = option.value 2678 optionElement.textContent = option.label 2679 2680 // Select default option (from category or URL) 2681 if (option.value == preselectedSort) { 2682 optionElement.defaultSelected = true 2683 } 2684 2685 sortElement.appendChild(optionElement) 2686 }) 2687 2688 sortElement.addEventListener('change', (event) => { 2689 this.onSort(event.target.value) 2690 }) 2691 2692 return sortElement 2693 }, 2694 onSort: function (sort) { 2695 2696 // Redirect when paginating 2697 if (qs_store_apps_data.pagination.pagination_activated) { 2698 var url = new URL(location.href); 2699 url.searchParams.set('sort', sort); 2700 url.searchParams.delete('page'); 2701 2702 window.location.href = url.href; 2703 2704 // Normal on-page filtering without pagination 2705 } else { 2706 const sortBy = sort.split('_')[0] 2707 const sortDirection = sort.split('_')[1] 2708 2709 const containerElement = document.getElementById('productList') 2710 const childElements = Array.from( 2711 containerElement.querySelectorAll(this.config?.productElementSelector) 2712 ) 2713 2714 const bucket = [] 2715 2716 childElements.forEach((child) => { 2717 const sortValue = child.getAttribute(`data-${sortBy}`) 2718 2719 bucket.push({ 2720 element: child, 2721 sortValue: sortValue 2722 }) 2723 }) 2724 2725 const isNumeric = sortBy.includes('price') 2726 2727 const locale = document.querySelector('html').getAttribute('lang') || 'en' 2728 2729 const sortedKeys = bucket.sort((a, b) => { 2730 return sortDirection === '1' 2731 ? a.sortValue.localeCompare(b.sortValue, locale, { 2732 numeric: isNumeric 2733 }) 2734 : b.sortValue.localeCompare(a.sortValue, locale, { 2735 numeric: isNumeric 2736 }) 2737 }) 2738 2739 const sortedElements = sortedKeys.map((item) => item.element) 2740 2741 sortedElements.forEach((child) => containerElement.appendChild(child)) 2742 2743 } 2744 }, 2745 2746}) 2747</script>
2747<script type="text/javascript">window.registerApp({ 2748 name: "ProductWatch", 2749 identifier: "QuickbutikProductWatch", 2750 description: "Add products to watchlist", 2751 version: "1.0.0", 2752 pages: ["product"], 2753 data: {}, 2754 config: { 2755 showProductWatchButton: true, 2756 productSelector: "[data-qb-selector='product']", 2757 button: { 2758 appsContainer: '[data-qb-selector="product-apps"]', 2759 insertPosition: "beforeend", // 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend' 2760 buttonTag: "a", 2761 buttonClasses: 2762 "flex-shrink-0 flex w-full flex-wrap items-center justify-center w-16 h-16 font-bold bg-white text-black border", 2763 }, 2764 }, 2765 onMount: function ({ appData, page }) { 2766 console.debug(`${this.identifier}@onMount`); 2767 2768 if (page === "product" && appData?.productwatchers) { 2769 this.data = appData?.productwatchers ?? {}; 2770 const productId = document.querySelector(this.config.productSelector) 2771 ?.dataset?.productId; 2772 2773 if (productId) { 2774 this.insertWatchProductButton({ productId }); 2775 } 2776 } 2777 }, 2778 insertWatchProductButton: function ({ productId }) { 2779 const { appsContainer, buttonTag, buttonClasses, insertPosition } = 2780 this.config.button; 2781 2782 const { btn_text, btn_bg_color, btn_text_color } = this.data ?? {}; 2783 2784 const { showProductWatchButton } = this.config; 2785 2786 if (!showProductWatchButton) { 2787 return; 2788 } 2789 2790 const template = document.createElement("template"); 2791 template.setAttribute("x-if", "!available"); 2792 2793 const watchProductButton = document.createElement(buttonTag); 2794 2795 watchProductButton.classList.add(...buttonClasses.split(" ")); 2796 watchProductButton.innerHTML = btn_text; 2797 2798 watchProductButton.style.backgroundColor = btn_bg_color; 2799 watchProductButton.style.color = btn_text_color; 2800 2801 watchProductButton.setAttribute( 2802 "x-on:click", 2803 `window.apps.${this.identifier}.onButtonClick($data.variant?.id)` 2804 ); 2805 2806 template.content.appendChild(watchProductButton); 2807 2808 let container = document.querySelector(appsContainer); 2809 if (container) { 2810 container.insertAdjacentElement(insertPosition, template); 2811 } 2812 }, 2813 onButtonClick: function (variantId) { 2814 const { popup_bg_color } = this.data ?? {}; 2815 2816 Alpine.store("modal").openModal(this.renderModal(variantId), { 2817 backgroundColor: popup_bg_color, 2818 }); 2819 2820 setTimeout(() => { 2821 document.querySelector("#watchlistEmail").focus(); 2822 }, 500); 2823 }, 2824 renderModal: function (variantId) { 2825 const { 2826 popup_title, 2827 popup_subtitle, 2828 btn_text, 2829 popup_text_color, 2830 btn_text_color, 2831 btn_bg_color, 2832 } = this.data ?? {}; 2833 2834 const form = document.createElement("form"); 2835 form.classList.add(); 2836 form.style.color = popup_text_color ?? "#000"; 2837 2838 const h2 = document.createElement("h2"); 2839 2840 const titleClasses = this.config?.modal?.titleClasses; 2841 if (titleClasses) { 2842 h2.classList.add(...titleClasses.split(" ")); 2843 } 2844 2845 h2.textContent = popup_title; 2846 form.appendChild(h2); 2847 2848 const paragraph = document.createElement("p"); 2849 paragraph.innerHTML = popup_subtitle; 2850 form.appendChild(paragraph); 2851 2852 const emailInput = document.createElement("input"); 2853 emailInput.type = "email"; 2854 emailInput.id = "watchlistEmail"; 2855 2856 const inputClasses = this.config?.modal?.inputClasses; 2857 if (inputClasses) { 2858 emailInput.classList.add(...inputClasses.split(" ")); 2859 } 2860 2861 emailInput.placeholder = this.config?.modal?.inputPlaceholder ?? "Email"; 2862 form.appendChild(emailInput); 2863 2864 const submitButton = document.createElement("button"); 2865 2866 const submitButtonClasses = this.config?.modal?.submitButtonClasses; 2867 if (submitButtonClasses) { 2868 submitButton.classList.add(...submitButtonClasses.split(" ")); 2869 } 2870 2871 submitButton.style.backgroundColor = btn_bg_color ?? "#000"; 2872 submitButton.style.color = btn_text_color ?? "#fff"; 2873 submitButton.textContent = btn_text; 2874 submitButton.type = "submit"; 2875 2876 form.appendChild(submitButton); 2877 2878 form.setAttribute( 2879 "x-on:submit.prevent", 2880 `window.apps.QuickbutikProductWatch.submitEmail(document.querySelector('#watchlistEmail').value, ${variantId})` 2881 ); 2882 2883 return form.outerHTML; 2884 }, 2885 submitEmail: function (email, variantId) { 2886 const productId = document.querySelector(this.config.productSelector) 2887 .dataset.productId; 2888 2889 const UNKNOWKN_ERROR_MESSAGE = 2890 this.config?.unknowknErrorMessage ?? 2891 "<p>Vänligen försö
2891k igen senare.</p>"; 2892 2893 const bodyFormData = new FormData(); 2894 bodyFormData.set("email_address", email); 2895 bodyFormData.set("product_id", productId); 2896 2897 if (variantId) { 2898 bodyFormData.set("variant_id", variantId); 2899 } 2900 2901 fetch( 2902 this.config?.productPage?.addProductWatchUrl ?? 2903 "/apps/productwatchers/add", 2904 { 2905 method: "POST", 2906 body: bodyFormData, 2907 } 2908 ) 2909 .then((response) => response.json()) 2910 .then((response) => { 2911 if (response?.success) { 2912 Alpine.store("modal").setContent( 2913 this.renderSuccessPage(response?.text_thankyou || "<p>Tack</p>") 2914 ); 2915 2916 setTimeout(() => { 2917 Alpine.store("modal").closeModal(); 2918 }, 3000); 2919 } else { 2920 console.error(response); 2921 2922 Alpine.store("modal").setContent( 2923 this.renderErrorPage(response?.text_error || UNKNOWKN_ERROR_MESSAGE) 2924 ); 2925 } 2926 }) 2927 .catch((error) => { 2928 console.error("Submit email error", error); 2929 2930 Alpine.store("modal").setContent( 2931 this.renderErrorPage(error?.text_error || UNKNOWKN_ERROR_MESSAGE) 2932 ); 2933 }); 2934 }, 2935 renderSuccessPage: function (message) { 2936 const { popup_title, popup_text_color } = this.data ?? {}; 2937 2938 const h2 = document.createElement("h2"); 2939 2940 const titleClasses = this.config?.modal?.titleClasses; 2941 if (titleClasses) { 2942 h2.classList.add(...titleClasses.split(" ")); 2943 } 2944 2945 h2.textContent = popup_title; 2946 2947 const paragraph = document.createElement("p"); 2948 paragraph.innerHTML = message; 2949 2950 const container = document.createElement("div"); 2951 2952 const containerClasses = this.config?.modal?.containerClasses; 2953 if (containerClasses) { 2954 container.classList.add(...containerClasses.split(" ")); 2955 } 2956 2957 container.appendChild(h2); 2958 container.appendChild(paragraph); 2959 container.style.color = popup_text_color || "#000"; 2960 2961 return container.outerHTML; 2962 }, 2963 renderErrorPage: function (message) { 2964 const h2 = document.createElement("h2"); 2965 h2.classList.add("font-heading", "text-5xl", "mb-4"); 2966 h2.textContent = this.config?.modal?.errorTitle ?? "Something went wrong"; 2967 2968 const paragraph = document.createElement("p"); 2969 paragraph.innerHTML = message; 2970 2971 const container = document.createElement("div"); 2972 2973 const containerClasses = this.config?.modal?.containerClasses; 2974 if (containerClasses) { 2975 container.classList.add(...containerClasses.split(" ")); 2976 } 2977 2978 container.appendChild(h2); 2979 container.appendChild(paragraph); 2980 2981 return container.outerHTML; 2982 }, 2983}); 2984</script>
2984<script type="text/javascript">window.registerApp({ 2985 name: 'Product Filter', 2986 identifier: 'QuickbutikProductFilter', 2987 description: 'Product filter', 2988 version: '2.0.0', 2989 pages: ['list'], 2990 data: { 2991 filters: {}, 2992 products: [], 2993 initialized: false, 2994 }, 2995 2996 // XSS Protection: Sanitize HTML content 2997 sanitizeHTML: function(html) { 2998 if (!html) return '' 2999 3000 // Create a temporary div to parse and sanitize HTML 3001 const temp = document.createElement('div') 3002 temp.textContent = html 3003 return temp.innerHTML 3004 }, 3005 3006 // XSS Protection: Safe HTML insertion 3007 safeSetHTML: function(element, html) { 3008 if (!element || !html) return 3009 3010 // For trusted content (like SVG icons), we can use innerHTML 3011 // For user content, we sanitize it 3012 if (this.isTrustedContent(html)) { 3013 element.innerHTML = html 3014 } else { 3015 element.innerHTML = this.sanitizeHTML(html) 3016 } 3017 }, 3018 3019 // Check if content is trusted (like SVG icons, static HTML) 3020 isTrustedContent: function(html) { 3021 // Trust SVG icons and static HTML templates 3022 return html.includes('<svg') || 3023 html.includes('class=') && !html.includes('onclick') && 3024 !html.includes('onload') && !html.includes('onerror') && 3025 !html.includes('javascript:') && !html.includes('data:') 3026 }, 3027 config: { 3028 filterElementTargetSelector: '[data-qb-selector="filter-container"]', 3029 styling: { 3030 filterElementClasses: '', 3031 classes: { 3032 // Main containers 3033 filterButtonContainer: 'pf-filter-button-container', 3034 filtersWrapper: 'pf-inline-filters-wrapper relative z-10 flex flex-col items-start', 3035 filtersContainer: 'pf-inline-filters flex flex-wrap items-start justify-start w-full', 3036 activeFiltersSection: 'pf-active-filters-section w-full', 3037 activeFiltersRow: 'pf-active-filters-row flex flex-wrap gap-2 items-start', 3038 activeFiltersContainer: 'pf-active-filters flex gap-2 items-center flex-1', 3039 3040 // Filter dropdowns 3041 filterDropdown: 'pf-filter-dropdown relative min-w-48', 3042 filterButton: 'pf-filter-button relative flex items-center justify-between cursor-pointer border px-4 py-3 button-bg button-text button-py button-px button-border button-rounding', 3043 filterButtonText: 'pf-filter-button-text flex-1 text-left input-text whitespace-nowrap overflow-hidden text-ellipsis', 3044 filterButtonCount: 'pf-filter-button-count rounded-full px-2 py-2 text-center mr-2', 3045 filterButtonBadge: 'pf-filter-button-badge text-white text-xs rounded-full w-5 h-5 flex items-center justify-center ml-2', 3046 filterArrow: 'pf-filter-arrow ml-2 text-xs transition-transform duration-200', 3047 3048 // Dropdown content 3049 dropdownContent: 'pf-filter-dropdown-content absolute top-full left-0 bg-white border border-gray-300 shadow-lg z-50 max-h-75 overflow-y-auto mt-1 hidden min-w-full w-max', 3050 3051 // Filter options 3052 filterGroup: 'pf-filter-group pb-4', 3053 filterOption: 'pf-filter-option block p-0 transition-colors', 3054 filterOptionCheckbox: 'sr-only', 3055 filterOptionLabel: 'pf-filter-option-label flex items-center justify-between cursor-pointer px-4 py-1 relative transition-all whitespace-nowrap', 3056 3057 // Active filter badges 3058 filterBadge: 'pf-filter-badge inline-flex items-center text-xs', 3059 filterBadgeRemove: 'pf-filter-badge-remove ml-2 inline-flex items-center justify-center cursor-pointer rounded-full text-sm transition-colors hover:bg-opacity-30', 3060 3061 // Reset button 3062 resetButton: 'pf-reset-filters-btn cursor-pointer px-4 py-2 text-sm transition-all whitespace-nowrap', 3063 clearFiltersButton: 'pf-clear-filters-btn cursor-pointer px-4 py-2 text-sm font-medium transition-all whitespace-nowrap text-gray-600 hover:text-gray-800', 3064 3065 // No results message 3066 noResults: 'pf-no-results', 3067 noResultsContainer: 'pf-no-results-container', 3068 }, 3069 }, 3070 }, 3071 onMount: function ({ appData }) { 3072 // Cleanup any existing listeners before reinitializing 3073 if (this.data.initialized) { 3074 this.cleanup() 3075 } 3076 3077 if (appData?.productfilters && appData.productfilters?.products) { 3078 this.config.filters = [] 3079 this.data.products = appData.productfilters.products 3080 this.applyDataTagsToProducts() 3081 3082 // Ensure the data is available globally 3083 window.qs_store_apps = window.qs_store_apps || {} 3084 window.qs_store_apps.productfilters = appData.productfilters 3085 3086 // Check if sidebar container exists, otherwise use drawer 3087 const sidebarContainer = document.querySelector('[data-qb-selector="filter-sidebar-container"]') 3088 if (sidebarContainer) { 3089 this.createSidebarFilters(appData.productfilters, sidebarContainer) 3090 } else { 3091 this.createDrawerFilters(appData.productfilters) 3092 } 3093 3094 // Add pageshow event listener only when we use client-side filtering 3095 if (this.shouldUseClientSideFiltering()) { 3096 this.handlePageShow = () => { 3097 const filterElements = document.querySelectorAll('[data-filter-id]') 3098 for (const element of filterElements) { 3099 const originalOnchange = element.onchange 3100 element.onchange = null 3101 element.onchange = originalOnchange 3102 } 3103 } 3104 window.addEventListener('pageshow', this.handlePageShow) 3105 } 3106 3107 // Mark as initialized 3108 this.data.initialized = true 3109 3110 // Add beforeunload cleanup to ensure listeners are removed on page navigation 3111 this.handleBeforeUnload = () => { 3112 this.cleanup() 3113 } 3114 window.addEventListener('beforeunload', this.handleBeforeUnload) 3115 } 3116 }, 3117 3118 onUnmount: function () { 3119 // Cleanup event listeners when component is unmounted 3120 this.cleanup() 3121 this.data.initialized = false 3122 }, 3123 3124 // Global cleanup function that can be called externally 3125 globalCleanup: function() { 3126 if (window.QuickbutikProductFilter && window.QuickbutikProductFilter.cleanup) { 3127 window.QuickbutikProductFilter.cleanup() 3128 } 3129 }, 3130 3131 // Helper function to determine if we should use client-side filtering 3132 // Returns true only if pagination is not activated 3133 shouldUseClientSideFiltering: function () { 3134 const pagination = qs_store_apps_data?.pagination || {} 3135 3136 // If pagination is not activated, use client-side filtering 3137 if (!pagination.pagination_activated) { 3138 return true 3139 } 3140 3141 // If pagination is activated, always use server-side filtering (reload page) 3142 return false 3143 }, 3144 applyDataTagsToProducts: function () { 3145 // Index the product elements once instead of querying the document per product. 3146 const productElementsById = new Map() 3147 3148 document 3149 .querySelectorAll('[data-qb-selector="product-item"][data-product-id]') 3150 .forEach((element) => { 3151 const productId = element.getAttribute('data-product-id') 3152 3153 if (!productElementsById.has(productId)) { 3154 productElementsById.set(productId, element) 3155 } 3156 }) 3157
3158 Object.keys(this.data.products).forEach((productKey) => { 3159 const productFilter = this.data.products[productKey] 3160 const productElement = productElementsById.get(String(productFilter.id)) 3161 3162 if (productElement) { 3163 Object.keys(productFilter?.filters).forEach((filterKey) => { 3164 const filterData = productFilter.filters[filterKey] 3165 // Parse composite filter id to get filter group and value 3166 const compositeIdParts = filterData.id ? filterData.id.split('-') : [] 3167 3168 if (compositeIdParts.length === 2) { 3169 const actualFilterGroupId = compositeIdParts[0] 3170 const actualFilterValueId = compositeIdParts[1] 3171 3172 // Set specific filter attribute: data-filter-${filterGroupId}-${filterValueId} 3173 productElement.setAttribute( 3174 `data-filter-${actualFilterGroupId}-${actualFilterValueId}`, 3175 '1' 3176 ) 3177 } 3178 }) 3179 } 3180 3181 }) 3182 }, 3183 injectFilter: function (renderedFilterElement) { 3184 const container = document.querySelector( 3185 this.config.filterElementTargetSelector 3186 ) 3187 3188 if (container) { 3189 container.appendChild(renderedFilterElement) 3190 } 3191 }, 3192 renderFilterElement: function (filter) { 3193 const filterElement = document.createElement('select') 3194 filterElement.setAttribute('data-filter-id', filter.id) 3195 filterElement.setAttribute('data-hide-on-soldout', filter.variant_outofstock_check) 3196 3197 const url = new URL(location.href) 3198 const preselectedFilter = url.searchParams.get('filter_' + filter.id); 3199 3200 if (this.config.styling.filterElementClasses) { 3201 filterElement.classList.add( 3202 ...this.config.styling.filterElementClasses.split(' ') 3203 ) 3204 } 3205 3206 const options = [ 3207 { 3208 id: '', 3209 title: `-- ${filter.title} --`, 3210 }, 3211 ...filter.values, 3212 ] 3213 3214 options.forEach((option) => { 3215 const optionElement = document.createElement('option') 3216 3217 optionElement.value = option.id 3218 optionElement.textContent = option.title 3219 3220 // Select default option (from category or URL) 3221 if (option.id === preselectedFilter) { 3222 optionElement.defaultSelected = true 3223 } 3224 3225 filterElement.appendChild(optionElement) 3226 }) 3227 3228 filterElement.addEventListener('change', (event) => { 3229 this.onFilter(event.target) 3230 }) 3231 3232 return filterElement 3233 }, 3234 onFilter: function (filter) { 3235 3236 // Use client-side filtering or redirect based on pagination state 3237 if (this.shouldUseClientSideFiltering()) { 3238 // Normal on-page filtering without pagination or with single page 3239 if (filter.value === '') { 3240 delete this.data.filters[filter.dataset.filterId] 3241 } else { 3242 this.data.filters[filter.dataset.filterId] = filter.value 3243 } 3244 3245 this.updateProductList() 3246 } else { 3247 // Redirect when paginating with multiple pages 3248 const url = new URL(location.href) 3249 if (filter.value === '') { 3250 url.searchParams.delete('filter_' + filter.dataset.filterId) 3251 } else { 3252 url.searchParams.set('filter_' + filter.dataset.filterId, filter.value) 3253 } 3254 url.searchParams.delete('page') 3255 3256 window.location.href = url.href; 3257 } 3258 3259 }, 3260 updateProductList: function () { 3261 const containerElement = document.getElementById('productList') 3262 const childElements = Array.from( 3263 containerElement.querySelectorAll('[data-qb-selector="product-item"]') 3264 ) 3265 3266 childElements.forEach((child) => { 3267 let show = true 3268 3269 Object.keys(this.data.filters).forEach((filterKey) => { 3270 const targetDataKey = `filter-${filterKey}-${this.data.filters[filterKey]}` 3271 3272 if (!Object.keys(child.dataset).includes(targetDataKey)) { 3273 show = false 3274 } 3275 }) 3276 3277 if (show) { 3278 child.classList.remove('hidden') 3279 } else { 3280 child.classList.add('hidden') 3281 } 3282 }) 3283 }, 3284 3285 // Drawer Filter System Functions 3286 createDrawerFilters: function (data) { 3287 if (!data || !data.filters) { 3288 return 3289 } 3290 3291 // Create the main drawer filter container 3292 const drawerContainer = this.renderDrawerFilterContainer(data) 3293 3294 // Inject into the target container 3295 this.injectDrawerFilters(drawerContainer) 3296 3297 // Create drawer elements directly in body for highest stacking context 3298 this.createDrawerElements(data) 3299 3300 // Set up drawer filter functionality 3301 this.setupDrawerFilterHandlers(data) 3302 }, 3303 3304 // Sidebar Filter System Functions 3305 createSidebarFilters: function (data, sidebarContainer) { 3306 if (!data || !data.filters) { 3307 return 3308 } 3309 3310 // Create sidebar filter content 3311 const sidebarContent = this.renderSidebarFilterContent(data) 3312 3313 // Inject into the sidebar container 3314 sidebarContainer.appendChild(sidebarContent) 3315 3316 // First create the drawer content (temporarily) 3317 this.createDrawerFilters(data) 3318 3319 // Then populate the sidebar with the drawer content 3320 this.populateSidebarContent(data.filters) 3321 3322 // Hide the filter button since we have sidebar 3323 this.hideFilterButton() 3324 3325 // Set up sidebar filter functionality 3326 this.setupSidebarFilterHandlers(data) 3327 }, 3328 3329 setupSidebarFilterHandlers: function (data) { 3330 // Initialize state variables 3331 this.currentSelections = this.parseUrlFilters() 3332 this.pendingSelections = { ...this.currentSelections } 3333 3334 // Populate sidebar content 3335 this.populateSidebarContent(data.filters) 3336 3337 // Set up event handlers 3338 this.setupSidebarCheckboxHandlers() 3339 this.setupSidebarActionButtons() 3340 3341 // Apply initial selections from URL 3342 this.applyInitialSelections() 3343 this.updateActiveFiltersDisplay() 3344 3345 // Apply filtering if we have selections and should use client-side filtering 3346 if (this.shouldUseClientSideFiltering() && Object.keys(this.currentSelections).length > 0) { 3347 this.applyClientSideFiltering() 3348 } 3349 }, 3350 3351 renderDrawerFilterContainer: function (data) { 3352 const container = document.createElement('div') 3353 container.className = this.config.styling.classes.filtersWrapper 3354 3355 // Add CSS styles 3356 const style = document.createElement('style') 3357 style.textContent = this.getDrawerFilterCSS() 3358 container.appendChild(style) 3359 3360 // Create filter button container 3361 const buttonContainer = document.createElement('div') 3362 buttonContainer.className = this.config.styling.classes.filterButtonContainer 3363 buttonContainer.innerHTML = ` 3364 <button id="pfFilterButton" class="${this.config.styling.classes.filterButton}" type="button" aria-expanded="false" aria-haspopup="dialog" aria-controls="pfFilterDrawer" aria-describedby="pfFilterDescription">
3365 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="pf-filter-icon" aria-hidden="true"> 3366 <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" stroke="currentColor" stroke-width="1.5"/> 3367 </svg> 3368 <span class="flex items-center"> 3369 ${data.i18n?.filter || 'Filtrera'} 3370 <span id="pfFilterButtonBadge" class="${this.config.styling.classes.filterButtonBadge}" style="display: none;" aria-label="Antal aktiva filter"></span> 3371 </span> 3372 </button> 3373 <div id="pfFilterDescription" class="sr-only">${data.i18n?.filterBy || 'Filtrera efter'} produkter. Klicka för att öppna filteralternativ.</div> 3374 <div class="${this.config.styling.classes.activeFiltersContainer}" id="pfActiveFilters" role="region" aria-label="Aktiva filter" aria-live="polite" aria-atomic="true"></div> 3375 <div class="${this.config.styling.classes.resetButton}" id="pfResetAllFilters" style="display: none;" role="button" tabindex="0" aria-label="Rensa alla filter" aria-describedby="pfResetDescription">${data.i18n?.clearAllFilters || 'Rensa alla filter'}</div> 3376 <div id="pfResetDescription" class="sr-only">Tar bort alla aktiva filter och visar alla produkter.</div> 3377 ` 3378 container.appendChild(buttonContainer) 3379 3380 return container 3381 }, 3382 3383 renderSidebarFilterContent: function (data) { 3384 const container = document.createElement('div') 3385 container.className = 'pf-sidebar-filters' 3386 3387 // Add CSS styles 3388 const style = document.createElement('style') 3389 style.textContent = this.getSidebarFilterCSS() 3390 container.appendChild(style) 3391 3392 // Create sidebar header 3393 const header = document.createElement('div') 3394 header.className = 'pf-sidebar-header mb-6' 3395 header.innerHTML = ` 3396 <h3 class="text-lg font-semibold mb-4">${data.i18n?.filterBy || 'Filtrera efter'}</h3> 3397 <div class="pf-active-filters mb-4" id="pfActiveFilters" role="region" aria-label="Aktiva filter" aria-live="polite" aria-atomic="true"></div> 3398 <div class="pf-reset-all mb-4" id="pfResetAllFilters" style="display: none;" role="button" tabindex="0" aria-label="Rensa alla filter">${data.i18n?.clearAllFilters || 'Rensa alla filter'}</div> 3399 ` 3400 container.appendChild(header) 3401 3402 // Create filter content 3403 const content = document.createElement('div') 3404 content.className = 'pf-sidebar-content' 3405 content.id = 'pfSidebarContent' 3406 container.appendChild(content) 3407 3408 return container 3409 }, 3410 3411 populateSidebarContent: function (filters) { 3412 const sidebarContent = document.getElementById('pfSidebarContent') 3413 if (!sidebarContent) return 3414 3415 // Use the existing populateDrawerContent function with sidebar-specific container 3416 this.populateDrawerContent(filters) 3417 3418 // Move the content to sidebar container 3419 const drawerContent = document.getElementById('pfDrawerContent') 3420 if (drawerContent) { 3421 // XSS Protection: Use safe HTML transfer instead of direct innerHTML 3422 this.safeSetHTML(sidebarContent, drawerContent.innerHTML) 3423 drawerContent.innerHTML = '' 3424 3425 // Set up accordion functionality for the sidebar content 3426 this.setupSidebarAccordion() 3427 } 3428 }, 3429 3430 hideFilterButton: function () { 3431 // Hide the filter button and related elements when sidebar is active 3432 const filterButton = document.getElementById('pfFilterButton') 3433 const filterButtonContainer = document.querySelector('.pf-filter-button-container') 3434 const filtersWrapper = document.querySelector('.pf-inline-filters-wrapper') 3435 3436 if (filterButton) { 3437 filterButton.style.display = 'none' 3438 } 3439 3440 if (filterButtonContainer) { 3441 filterButtonContainer.style.display = 'none' 3442 } 3443 3444 if (filtersWrapper) { 3445 filtersWrapper.style.display = 'none' 3446 } 3447 }, 3448 3449 setupSidebarAccordion: function () { 3450 const sidebarContent = document.getElementById('pfSidebarContent') 3451 if (!sidebarContent) return 3452 3453 // Add accordion functionality to sidebar filter group titles 3454 sidebarContent.querySelectorAll('.pf-filter-group-title').forEach(title => { 3455 title.addEventListener('click', function () { 3456 const optionsContainer = this.nextElementSibling 3457 const icon = this.querySelector('.pf-accordion-icon') 3458 const isExpanded = this.getAttribute('aria-expanded') === 'true' 3459 3460 if (optionsContainer && optionsContainer.classList.contains('pf-filter-options-container')) { 3461 if (isExpanded) { 3462 // Collapse 3463 optionsContainer.style.display = 'none' 3464 this.setAttribute('aria-expanded', 'false') 3465 // Show plus icon (collapsed) 3466 if (icon) { 3467 icon.innerHTML = `
3468 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 3469 <path d="M12 4.5v15m7.5-7.5h-15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 3470 </svg> 3471 ` 3472 } 3473 } else { 3474 // Expand 3475 optionsContainer.style.display = 'block' 3476 this.setAttribute('aria-expanded', 'true') 3477 // Show minus icon (expanded) 3478 if (icon) { 3479 icon.innerHTML = ` 3480 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 3481 <path d="M5 12h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 3482 </svg> 3483 ` 3484 } 3485 } 3486 } 3487 }) 3488 }) 3489 }, 3490 3491 setupSidebarCheckboxHandlers: function () { 3492 // Use the existing drawer checkbox handler logic 3493 this.setupDrawerCheckboxHandlers() 3494 }, 3495 3496 setupSidebarActionButtons: function () { 3497 // Use the existing drawer action buttons logic 3498 this.setupDrawerActionButtons() 3499 }, 3500 3501 resetSidebarFilters: function () { 3502 // Use the existing drawer reset logic 3503 this.resetDrawerFilters() 3504 }, 3505 3506 getSidebarFilterCSS: function () { 3507 return ` 3508 /* Sidebar-specific overrides - leverages existing drawer CSS */ 3509 .pf-sidebar-filters { 3510 background: white; 3511 border-radius: 8px; 3512 padding: 20px; 3513 } 3514 3515 .pf-sidebar-header h3 { 3516 margin: 0 0 16px 0; 3517 font-size: 18px; 3518 font-weight: 600; 3519 color: #333; 3520 } 3521 3522 /* Override drawer styles for sidebar */ 3523 .pf-sidebar-filters .pf-filter-group { 3524 margin-bottom: 8px; 3525 padding-bottom: 8px; 3526 } 3527 3528 .pf-sidebar-filters .pf-filter-group:last-child { 3529 border-bottom: none; 3530 margin-bottom: 0; 3531 } 3532 3533 .pf-sidebar-filters .pf-filter-group-title { 3534 font-size: 14px; 3535 padding: 8px 0; 3536 } 3537 3538 .pf-sidebar-filters .pf-filter-option { 3539 padding: 0.25rem 1rem; 3540 } 3541 3542 .pf-sidebar-filters .pf-filter-options-container { 3543 padding-top: 0.5rem; 3544 } 3545 3546 .pf-sidebar-filters .pf-accordion-icon { 3547 width: 20px; 3548 height: 20px; 3549 margin-left: 8px; 3550 } 3551 3552 /* Handle long checkbox labels with proper text wrapping */ 3553 .pf-sidebar-filters .pf-filter-option label { 3554 white-space: normal; 3555 word-wrap: break-word; 3556 overflow-wrap: break-word; 3557 hyphens: auto; 3558 line-height: 1.4; 3559 padding-right: 8px; 3560 } 3561 ` 3562 }, 3563 3564 createDrawerElements: function (data) { 3565 // Create drawer HTML with backdrop 3566 const drawerHTML = ` 3567 <div id="pfDrawerBackdrop" class="pf-drawer-backdrop" role="presentation" aria-hidden="true"></div> 3568 <div id="pfFilterDrawer" class="pf-drawer" role="dialog" aria-modal="true" aria-labelledby="pfDrawerTitle" aria-describedby="pfDrawerDescription" aria-hidden="true"> 3569 <div class="pf-drawer-header"> 3570 <h3 id="pfDrawerTitle">${data.i18n?.filterBy || 'Filtrera efter'}</h3> 3571 <div id="pfDrawerDescription" class="sr-only">Välj filteralternativ för att begränsa produktlistan. Använd tab för att navigera mellan alternativen.</div> 3572 <button id="pfCloseFilterDrawerBtn" class="pf-drawer-close-btn" aria-label="Stäng filterdialog" type="button"> 3573 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> 3574 <path d="M6 18L18 6M6 6l12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 3575 </svg> 3576 </button> 3577 </div> 3578 <div id="pfDrawerContent" class="pf-drawer-content" role="region" aria-label="Filteralternativ" aria-live="polite"> 3579 <!-- Filter options will be populated here --> 3580 </div> 3581 <div class="pf-drawer-footer"> 3582 <button id="pfClearFiltersBtn" class="${this.config.styling.classes.clearFiltersButton}" type="button" aria-describedby="pfClearDescription">${data.i18n?.clearAllFilters || 'Rensa alla filter'}</button> 3583 <div id="pfClearDescription" class="sr-only">Tar bort alla valda filter och återställer till ursprungsläge.</div> 3584 <button id="pfApplyFiltersBtn" class="pf-drawer-apply-btn" type="button" aria-describedby="pfApplyDescription">${data.i18n?.showProducts || 'Visa produkter'}</button> 3585 <div id="pfApplyDescription" class="sr-only">Tillämpar valda filter och visar matchande produkter.</div> 3586 </div> 3587 </div> 3588 ` 3589 3590 // Create a temporary div to parse the HTML 3591 const tempDiv = document.createElement('div') 3592 tempDiv.innerHTML = drawerHTML 3593 3594 // Append both backdrop and drawer directly to body for highest stacking context 3595 const backdrop = tempDiv.querySelector('#pfDrawerBackdrop') 3596 const drawer = tempDiv.querySelector('#pfFilterDrawer') 3597 3598 document.body.appendChild(backdrop) 3599 document.body.appendChild(drawer) 3600 }, 3601 3602 populateDrawerContent: function (filters) { 3603 const drawerContent = document.getElementById('pfDrawerContent') 3604 if (!drawerContent) return 3605 3606 drawerContent.innerHTML = '' 3607 3608 // Count total filter options across all categories 3609 const totalFilterOptions = filters.reduce((total, filter) => total + filter.values.length, 0) 3610 const shouldExpandAll = totalFilterOptions <= 10 3611 // Auto-expand if there's only one filter (drawer mode only)
3612 const hasOnlyOneFilter = filters.length === 1 3613 3614 filters.forEach(filter => { 3615 const groupDiv = document.createElement('div') 3616 groupDiv.className = this.config.styling.classes.filterGroup 3617 3618 // Check if this filter group has any active selections 3619 const hasActiveSelections = this.currentSelections[filter.id] && this.currentSelections[filter.id].length > 0 3620 const shouldStartExpanded = shouldExpandAll || hasActiveSelections || hasOnlyOneFilter 3621 3622 const titleElement = document.createElement('button') 3623 titleElement.className = 'pf-filter-group-title' 3624 titleElement.textContent = filter.title 3625 titleElement.setAttribute('aria-expanded', shouldStartExpanded ? 'true' : 'false') 3626 titleElement.setAttribute('aria-controls', `pf-filter-group-${filter.id}`) 3627 titleElement.setAttribute('type', 'button') 3628 3629 const iconSpan = document.createElement('span') 3630 iconSpan.className = 'pf-accordion-icon' 3631 iconSpan.innerHTML = shouldStartExpanded ? ` 3632 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 3633 <path d="M5 12h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 3634 </svg> 3635 ` : ` 3636 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 3637 <path d="M12 4.5v15m7.5-7.5h-15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 3638 </svg> 3639 ` 3640 titleElement.appendChild(iconSpan) 3641 3642 groupDiv.appendChild(titleElement) 3643 3644 const optionsContainer = document.createElement('div') 3645 optionsContainer.className = 'pf-filter-options-container' 3646 optionsContainer.id = `pf-filter-group-${filter.id}` 3647 optionsContainer.style.display = shouldStartExpanded ? 'block' : 'none' 3648 3649 filter.values.forEach(value => { 3650 const optionDiv = document.createElement('div') 3651 optionDiv.className = this.config.styling.classes.filterOption 3652 3653 const checkbox = document.createElement('input') 3654 checkbox.type = 'checkbox' 3655 checkbox.className = this.config.styling.classes.filterOptionCheckbox 3656 checkbox.id = `pf-filter-${filter.id}-${value.id}` 3657 checkbox.value = value.id 3658 checkbox.dataset.filterId = filter.id 3659 checkbox.dataset.filterValueTitle = value.title 3660 checkbox.setAttribute('aria-describedby', `pf-filter-${filter.id}-${value.id}-description`) 3661 3662 const label = document.createElement('label') 3663 label.className = this.config.styling.classes.filterOptionLabel 3664 label.htmlFor = checkbox.id 3665 label.textContent = value.title 3666 label.setAttribute('aria-hidden', 'true') // We'll use the checkbox's accessible name 3667 3668 const description = document.createElement('div') 3669 description.id = `pf-filter-${filter.id}-${value.id}-description` 3670 description.className = 'sr-only' 3671 description.textContent = `Välj ${value.title} för att filtrera produkter i kategorin ${filter.title}` 3672 3673 optionDiv.appendChild(checkbox) 3674 optionDiv.appendChild(label) 3675 optionDiv.appendChild(description) 3676 optionsContainer.appendChild(optionDiv) 3677 }) 3678 3679 groupDiv.appendChild(optionsContainer) 3680 drawerContent.appendChild(groupDiv) 3681 }) 3682 3683 // Add accordion functionality 3684 drawerContent.querySelectorAll('.pf-filter-group-title').forEach(title => { 3685 title.addEventListener('click', function () { 3686 const optionsContainer = this.nextElementSibling 3687 const icon = this.querySelector('.pf-accordion-icon') 3688 const isExpanded = this.getAttribute('aria-expanded') === 'true' 3689 3690 if (optionsContainer && optionsContainer.classList.contains('pf-filter-options-container')) { 3691 if (isExpanded) { 3692 // Collapse 3693 optionsContainer.style.display = 'none' 3694 this.setAttribute('aria-expanded', 'false') 3695 // Show plus icon (collapsed) 3696 if (icon) { 3697 icon.innerHTML = `
3698 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 3699 <path d="M12 4.5v15m7.5-7.5h-15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 3700 </svg> 3701 ` 3702 } 3703 } else { 3704 // Expand 3705 optionsContainer.style.display = 'block' 3706 this.setAttribute('aria-expanded', 'true') 3707 // Show minus icon (expanded) 3708 if (icon) { 3709 icon.innerHTML = ` 3710 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 3711 <path d="M5 12h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 3712 </svg> 3713 ` 3714 } 3715 } 3716 } 3717 }) 3718 }) 3719 }, 3720 3721 injectDrawerFilters: function (drawerContainer) { 3722 let container = document.querySelector(this.config.filterElementTargetSelector) 3723 3724 if (container) { 3725 container.appendChild(drawerContainer) 3726 return 3727 } 3728 3729 // Try common filter container selectors 3730 const fallbackSelectors = [ 3731 '.filter-container', 3732 '.filters', 3733 '#filters', 3734 '.product-filters', 3735 '.category-filters', 3736 '[data-filter-container]', 3737 '.products-header', 3738 '.category-header' 3739 ] 3740 3741 for (const selector of fallbackSelectors) { 3742 container = document.querySelector(selector) 3743 if (container) { 3744 container.appendChild(drawerContainer) 3745 return 3746 } 3747 } 3748 3749 // Try to find a good spot before the product list 3750 const productList = document.querySelector('#productList, .product-list, .products, [data-qb-selector="product-list"]') 3751 if (productList) { 3752 productList.parentNode.insertBefore(drawerContainer, productList) 3753 return 3754 } 3755 3756 // Last resort - inject at top of main content or body 3757 const main = document.querySelector('main, .main, .content, .page-content') 3758 if (main) { 3759 main.insertBefore(drawerContainer, main.firstChild) 3760 } else { 3761 document.body.insertBefore(drawerContainer, document.body.firstChild) 3762 } 3763 }, 3764 3765 setupDrawerFilterHandlers: function (data) { 3766 // Initialize state variables 3767 this.currentSelections = this.parseUrlFilters() 3768 this.pendingSelections = { ...this.currentSelections } 3769 3770 // Populate drawer content 3771 this.populateDrawerContent(data.filters) 3772 3773 // Set up event handlers 3774 this.setupDrawerToggles() 3775 this.setupDrawerCheckboxHandlers() 3776 this.setupDrawerActionButtons() 3777 3778 3779 // Apply initial selections from URL 3780 this.applyInitialSelections() 3781 this.updateActiveFiltersDisplay() 3782 3783 // Apply filtering if we have selections and should use client-side filtering 3784 if (this.shouldUseClientSideFiltering() && Object.keys(this.currentSelections).length > 0) { 3785 this.applyClientSideFiltering() 3786 } 3787 }, 3788 3789 parseUrlFilters: function () { 3790 const urlParams = new URLSearchParams(window.location.search) 3791 const filters = {} 3792 3793 for (const [key, value] of urlParams.entries()) { 3794 if (key.startsWith('filter_')) { 3795 const filterId = key.replace('filter_', '') 3796 filters[filterId] = value.split(',') 3797 } 3798 } 3799 3800 return filters 3801 }, 3802 3803 3804 setupDrawerToggles: function () { 3805 const openDrawerBtn = document.getElementById('pfFilterButton') 3806 const closeDrawerBtn = document.getElementById('pfCloseFilterDrawerBtn') 3807 const drawer = document.getElementById('pfFilterDrawer') 3808 const backdrop = document.getElementById('pfDrawerBackdrop') 3809 3810 if (openDrawerBtn && drawer && backdrop) { 3811 openDrawerBtn.addEventListener('click', (event) => { 3812 event.stopPropagation() 3813 drawer.classList.add('pf-open') 3814 backdrop.classList.add('pf-open') 3815 document.body.classList.add('pf-drawer-open') 3816 3817 // Update ARIA states 3818 openDrawerBtn.setAttribute('aria-expanded', 'true') 3819 drawer.setAttribute('aria-hidden', 'false') 3820 backdrop.setAttribute('aria-hidden', 'false') 3821 3822 // Focus management - focus first interactive element in drawer 3823 setTimeout(() => { 3824 const firstFocusable = drawer.querySelector('button, input, [tabindex]:not
3824([tabindex="-1"])') 3825 if (firstFocusable) { 3826 firstFocusable.focus() 3827 } 3828 }, 100) 3829 }) 3830 } 3831 3832 if (closeDrawerBtn && drawer && backdrop) { 3833 closeDrawerBtn.addEventListener('click', (event) => { 3834 event.stopPropagation() 3835 this.closeDrawer() 3836 }) 3837 } 3838 3839 // Close drawer when clicking on backdrop 3840 if (backdrop) { 3841 backdrop.addEventListener('click', (event) => { 3842 event.stopPropagation() 3843 this.closeDrawer() 3844 }) 3845 } 3846 3847 // Close drawer when clicking outside 3848 this.handleDocumentClick = (event) => { 3849 if (drawer && drawer.classList.contains('pf-open') && 3850 !drawer.contains(event.target) && 3851 !openDrawerBtn?.contains(event.target)) { 3852 this.closeDrawer() 3853 } 3854 } 3855 document.addEventListener('click', this.handleDocumentClick) 3856 3857 // Prevent drawer clicks from closing it 3858 if (drawer) { 3859 drawer.addEventListener('click', (event) => { 3860 event.stopPropagation() 3861 }) 3862 } 3863 3864 // Add keyboard navigation support 3865 this.handleDocumentKeydown = (event) => { 3866 if (drawer && drawer.classList.contains('pf-open')) { 3867 // Close drawer with Escape key 3868 if (event.key === 'Escape') { 3869 this.closeDrawer() 3870 } 3871 3872 // Trap focus within drawer when open 3873 if (event.key === 'Tab') { 3874 const focusableElements = drawer.querySelectorAll( 3875 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])' 3876 ) 3877 const firstFocusable = focusableElements[0] 3878 const lastFocusable = focusableElements[focusableElements.length - 1] 3879 3880 if (event.shiftKey) { 3881 // Shift + Tab 3882 if (document.activeElement === firstFocusable) { 3883 event.preventDefault() 3884 lastFocusable.focus() 3885 } 3886 } else { 3887 // Tab 3888 if (document.activeElement === lastFocusable) { 3889 event.preventDefault() 3890 firstFocusable.focus() 3891 } 3892 } 3893 } 3894 } 3895 } 3896 document.addEventListener('keydown', this.handleDocumentKeydown) 3897 }, 3898 3899 setupDrawerCheckboxHandlers: function () { 3900 this.handleCheckboxChange = (event) => { 3901 if (event.target.matches('input[type="checkbox"]') && event.target.closest('.pf-filter-option')) { 3902 const checkbox = event.target 3903 const filterId = checkbox.getAttribute('data-filter-id') 3904 const value = checkbox.value 3905 3906 if (!this.pendingSelections[filterId]) { 3907 this.pendingSelections[filterId] = [] 3908 } 3909 3910 if (checkbox.checked) { 3911 if (!this.pendingSelections[filterId].includes(value)) { 3912 this.pendingSelections[filterId].push(value) 3913 } 3914 } else { 3915 this.pendingSelections[filterId] = this.pendingSelections[filterId].filter(v => v !== value) 3916 if (this.pendingSelections[filterId].length === 0) { 3917 delete this.pendingSelections[filterId] 3918 } 3919 } 3920 3921 // Check if we're in sidebar mode (immediate filtering) or drawer mode (pending selections) 3922 const isSidebar = event.target.closest('#pfSidebarContent') 3923 3924 if (isSidebar || this.shouldUseClientSideFiltering()) { 3925 // Sidebar or client-side filtering: apply immediately 3926 this.currentSelections = { ...this.pendingSelections } 3927 this.updateActiveFiltersDisplay() 3928 this.updateUrlAndFilter() 3929 } 3930 // Otherwise, for drawer design with pagination (multiple pages), only update pending selections 3931 // and wait for "Visa produkter" button 3932 } 3933 } 3934 document.addEventListener('change', this.handleCheckboxChange) 3935 }, 3936 3937 setupDrawerActionButtons: function () { 3938 // Use a small delay to ensure DOM elements are ready 3939 setTimeout(() => { 3940 const applyBtn = document.getElementById('pfApplyFiltersBtn') 3941 const resetBtn = document.getElementById('pfResetAllFilters') 3942 const clearBtn = document.getElementById('pfClearFiltersBtn') 3943
3944 if (applyBtn) { 3945 applyBtn.addEventListener('click', (event) => { 3946 event.preventDefault() 3947 event.stopPropagation() 3948 this.applyDrawerFilters() 3949 }) 3950 } else { 3951 console.warn('Apply filters button not found') 3952 } 3953 3954 if (resetBtn) { 3955 resetBtn.addEventListener('click', (event) => { 3956 event.preventDefault() 3957 event.stopPropagation() 3958 this.resetDrawerFilters() 3959 }) 3960 } else { 3961 console.warn('Reset filters button not found') 3962 } 3963 3964 if (clearBtn) { 3965 clearBtn.addEventListener('click', (event) => { 3966 event.preventDefault() 3967 event.stopPropagation() 3968 this.clearDrawerFilters() 3969 }) 3970 } else { 3971 console.warn('Clear filters button not found') 3972 } 3973 }, 100) 3974 3975 // Keep the delegated event handler for filter badge removal 3976 document.addEventListener('click', (event) => { 3977 // Remove individual filter badge 3978 if (event.target.matches('.pf-filter-badge-remove') || event.target.closest('.pf-filter-badge-remove')) { 3979 const removeBtn = event.target.matches('.pf-filter-badge-remove') ? event.target : event.target.closest('.pf-filter-badge-remove') 3980 const filterId = removeBtn.getAttribute('data-filter-id') 3981 const value = removeBtn.getAttribute('data-filter-value') 3982 this.removeFilterValue(filterId, value) 3983 return 3984 } 3985 }) 3986 }, 3987 3988 3989 3990 resetDrawerFilters: function () { 3991 this.currentSelections = {} 3992 this.pendingSelections = {} 3993 3994 // Uncheck all checkboxes 3995 const checkboxes = document.querySelectorAll('input[type="checkbox"]:checked') 3996 checkboxes.forEach(checkbox => { 3997 checkbox.checked = false 3998 }) 3999 4000 this.updateActiveFiltersDisplay() 4001 this.updateUrlAndFilterForClear() 4002 this.closeDrawer() 4003 }, 4004 4005 clearDrawerFilters: function () { 4006 // Clear pending selections (what's currently selected in the drawer) 4007 this.pendingSelections = {} 4008 4009 // Uncheck all checkboxes in the drawer 4010 const checkboxes = document.querySelectorAll('#pfFilterDrawer input[type="checkbox"]:checked') 4011 checkboxes.forEach(checkbox => { 4012 checkbox.checked = false 4013 }) 4014 4015 // Apply the cleared filters immediately 4016 this.currentSelections = {} 4017 this.updateActiveFiltersDisplay() 4018 this.updateUrlAndFilterForClear() 4019 this.closeDrawer() 4020 }, 4021 4022 applyDrawerFilters: function () { 4023 // Apply all pending selections 4024 this.currentSelections = { ...this.pendingSelections } 4025 this.updateActiveFiltersDisplay() 4026 this.updateUrlAndFilter() 4027 4028 // Apply client-side filtering if we should use it 4029 if (this.shouldUseClientSideFiltering()) { 4030 this.applyClientSideFiltering() 4031 } 4032 4033 this.closeDrawer() 4034 }, 4035 4036 closeDrawer: function () { 4037 const drawer = document.getElementById('pfFilterDrawer') 4038 const backdrop = document.getElementById('pfDrawerBackdrop') 4039 const openDrawerBtn = document.getElementById('pfFilterButton') 4040 4041 if (drawer) { 4042 drawer.classList.remove('pf-open') 4043 } 4044 4045 if (backdrop) { 4046 backdrop.classList.remove('pf-open') 4047 } 4048 4049 document.body.classList.remove('pf-drawer-open') 4050 4051 // Update ARIA states 4052 if (openDrawerBtn) { 4053 openDrawerBtn.setAttribute('aria-expanded', 'false') 4054 } 4055 if (drawer) { 4056 drawer.setAttribute('aria-hidden', 'true') 4057 } 4058 if (backdrop) { 4059 backdrop.setAttribute('aria-hidden', 'true') 4060 } 4061 4062 // Return focus to the filter button 4063 if (openDrawerBtn) { 4064 openDrawerBtn.focus() 4065 } 4066 }, 4067 4068 4069 updateUrlAndFilter: function () { 4070 const url = new URL(location.href) 4071 4072 // Clear existing filter params 4073 const paramKeys = [] 4074 for (const key of url.searchParams.keys()) { 4075 if (key.startsWith('filter_')) { 4076 paramKeys.push(key) 4077 } 4078 } 4079 paramKeys.forEach(key => url.searchParams.delete(key)) 4080 4081 // Add current filter params 4082 Object.keys(this.currentSelections).forEach(filterId => { 4083 const values = this.currentSelections[filterId] 4084 if (values && values.length > 0) { 4085 url.searchParams.set('filter_' + filterId, values.join(',')) 4086 } 4087 }) 4088 4089 url.searchParams.delete('page') 4090 4091 // Handle pagination vs client-side filtering 4092 if (this.shouldUseClientSideFiltering()) { 4093 history.pushState({}, '', url.toString()) 4094 this.applyClientSideFiltering() 4095 } else { 4096 window.location.href = url.href 4097 } 4098 }, 4099 4100 updateUrlAndFilterForClear: function () { 4101 const url = new URL(location.href) 4102 4103 // Clear all filter params 4104 const paramKeys = [] 4105 for (const key of url.searchParams.keys()) { 4106 if (key.startsWith('filter_')) { 4107 paramKeys.push(key) 4108 } 4109 } 4110 paramKeys.forEach(key => url.searchParams.delete(key)) 4111 4112 url.searchParams.delete('page') 4113 4114 // When clearing filters, always reload if pagination is activated 4115 // because clearing filters might change the total number of products and pages 4116 const pagination = qs_store_apps_data?.pagination || {} 4117 if (pagination.pagination_activated) { 4118 // Always reload page when pagination is activated and filters are cleared 4119 window.location.href = url.href 4120 } else { 4121 // Use client-side filtering when pagination is not activated 4122 history.pushState({}, '', url.toString()) 4123 this.applyClientSideFiltering() 4124 } 4125 }, 4126 4127 applyClientSideFiltering: function () { 4128 // Since products are directly in the page, find them directly 4129 const productElements = document.querySelectorAll('[data-qb-selector="product-item"]') 4130 4131 if (productElements.length === 0) { 4132 return 4133 } 4134
4135 productElements.forEach((productElement) => { 4136 let shouldShow = true 4137 4138 // Check each filter category 4139 Object.keys(this.currentSelections).forEach(filterId => { 4140 const selectedValues = this.currentSelections[filterId] 4141 if (!selectedValues || selectedValues.length === 0) { 4142 return // Skip empty filters 4143 } 4144 4145 // Check if product has any of the selected values for this filter 4146 let hasMatchingValue = false 4147 selectedValues.forEach(value => { 4148 const dataAttribute = `data-filter-${filterId}-${value}` 4149 if (productElement.hasAttribute(dataAttribute)) { 4150 hasMatchingValue = true 4151 } 4152 }) 4153 4154 // If no matching value found for this required filter, hide the product 4155 if (!hasMatchingValue) { 4156 shouldShow = false 4157 } 4158 }) 4159 4160 // Show/hide the product 4161 if (shouldShow) { 4162 productElement.classList.remove('hidden') 4163 productElement.style.display = '' // Also clear any inline style 4164 } else { 4165 productElement.classList.add('hidden') 4166 productElement.style.display = 'none' // Force hide with inline style 4167 } 4168 }) 4169 4170 // Check if any products are visible 4171 const visibleProducts = document.querySelectorAll('[data-qb-selector="product-item"]:not(.hidden)') 4172 if (visibleProducts.length === 0) { 4173 this.showNoResultsMessage() 4174 } else { 4175 // Hide any existing "no results" message 4176 const existingNoResults = document.querySelector(`.${this.config.styling.classes.noResults}`) 4177 if (existingNoResults) { 4178 existingNoResults.remove() 4179 } 4180 } 4181 }, 4182 4183 showNoResultsMessage: function () { 4184 // Remove any existing "no results" message 4185 const existingNoResults = document.querySelector(`.${this.config.styling.classes.noResults}`) 4186 if (existingNoResults) { 4187 existingNoResults.remove() 4188 } 4189 4190 // Find the product container 4191 const productContainer = document.querySelector('#productList, .product-list, .products, [data-qb-selector="product-list"]') 4192 if (!productContainer) return 4193 4194 // Get translation data 4195 const data = window.qs_store_apps?.productfilters || {} 4196 4197 // Create "no results" message 4198 const noResultsDiv = document.createElement('div') 4199 noResultsDiv.className = this.config.styling.classes.noResults 4200 noResultsDiv.innerHTML = ` 4201 <div class="${this.config.styling.classes.noResultsContainer}"> 4202 <h3>${data.i18n?.noResultsHeading || 'No products found'}</h3> 4203 <p>${data.i18n?.noResultsDescription || 'Try adjusting your filters to see more products.'}</p> 4204 <button onclick="window.apps.QuickbutikProductFilter.resetDrawerFilters()" class="pf-clear-filters-btn">${data.i18n?.clearAllFilters || 'Clear all filters'}</button> 4205 </div> 4206 ` 4207 4208 // Insert the message after the product container 4209 productContainer.appendChild(noResultsDiv) 4210 }, 4211 4212 updateActiveFiltersDisplay: function () { 4213 const container = document.getElementById('pfActiveFilters') 4214 if (!container) return 4215 4216 // Clear all badges 4217 const badges = container.querySelectorAll('.pf-filter-badge') 4218 badges.forEach(badge => badge.remove()) 4219 4220 const data = window.qs_store_apps.productfilters 4221 let totalActiveFilters = 0 4222 4223 Object.keys(this.currentSelections).forEach(filterId => { 4224 const values = this.currentSelections[filterId] 4225 if (!values || values.length === 0) return 4226 4227 const filterData = data.filters.find(f => f.id === filterId) 4228 if (!filterData) return 4229 4230 values.forEach(value => { 4231 const valueData = filterData.values.find(v => v.id === value) 4232 if (!valueData) return 4233 4234 totalActiveFilters++ 4235 4236 const badge = document.createElement('div') 4237 badge.className = this.config.styling.classes.filterBadge 4238 badge.setAttribute('role', 'button') 4239 badge.setAttribute('tabindex', '0') 4240 // XSS Protection: Sanitize user content 4241 const sanitizedTitle = this.sanitizeHTML(valueData.title) 4242 badge.setAttribute('aria-label', `Ta bort filter: ${sanitizedTitle}`) 4243 badge.innerHTML = `
4244 <span>${sanitizedTitle}</span> 4245 <span class="${this.config.styling.classes.filterBadgeRemove}" data-filter-id="${filterId}" data-filter-value="${value}" role="button" tabindex="0" aria-label="Ta bort ${sanitizedTitle} filter"> 4246 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> 4247 <path d="M6 18L18 6M6 6l12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 4248 </svg> 4249 </span> 4250 ` 4251 container.appendChild(badge) 4252 }) 4253 }) 4254 4255 // Update filter button badge 4256 this.updateFilterButtonBadge(totalActiveFilters) 4257 4258 // Show/hide reset button 4259 const resetBtn = document.getElementById('pfResetAllFilters') 4260 4261 if (resetBtn) { 4262 const badgeCount = container.querySelectorAll('.pf-filter-badge').length 4263 const shouldShow = badgeCount > 0 4264 resetBtn.style.display = shouldShow ? 'block' : 'none' 4265 } 4266 }, 4267 4268 updateFilterButtonBadge: function (count) { 4269 const badge = document.getElementById('pfFilterButtonBadge') 4270 if (!badge) return 4271 4272 if (count > 0) { 4273 badge.textContent = count 4274 badge.style.display = 'flex' 4275 badge.setAttribute('aria-label', `${count} aktiva filter`) 4276 } else { 4277 badge.style.display = 'none' 4278 } 4279 }, 4280 4281 applyInitialSelections: function () { 4282 Object.keys(this.currentSelections).forEach(filterId => { 4283 const values = this.currentSelections[filterId] 4284 4285 values.forEach(value => { 4286 const checkboxId = `pf-filter-${filterId}-${value}` 4287 const checkbox = document.getElementById(checkboxId) 4288 if (checkbox) { 4289 checkbox.checked = true 4290 } else { 4291 console.warn(`Checkbox not found: ${checkboxId}`) 4292 } 4293 }) 4294 }) 4295 4296 // Sync pending selections with current selections 4297 this.pendingSelections = { ...this.currentSelections } 4298 }, 4299 4300 4301 removeFilterValue: function (filterId, value) { 4302 if (!this.currentSelections[filterId]) return 4303 4304 this.currentSelections[filterId] = this.currentSelections[filterId].filter(v => v !== value) 4305 if (this.currentSelections[filterId].length === 0) { 4306 delete this.currentSelections[filterId] 4307 } 4308 4309 // Update pending selections and UI 4310 this.pendingSelections = { ...this.currentSelections } 4311 this.updateActiveFiltersDisplay() 4312 this.updateUrlAndFilter() 4313 4314 // Update checkbox 4315 const checkbox = document.getElementById(`pf-filter-${filterId}-${value}`) 4316 if (checkbox) { 4317 checkbox.checked = false 4318 } 4319 }, 4320 4321 getDrawerFilterCSS: function () { 4322 return ` 4323 /* Color variables for consistency */ 4324 :root { 4325 --pf-primary-color: var(--color-primary, #000000); 4326 --pf-primary-dark: var(--color-primary, #000000); 4327 --pf-light-gray: var(--input-bg, var(--bg-header, #f8f8f8)); 4328 --pf-border-color: var(--color-text, #e2e2e2); 4329 --pf-badge-bg: var(--button-bg, var(--color-primary, #000000)); 4330 --pf-badge-border-color: var(--color-primary, #e2e2e2); 4331 --pf-text-dark: var(--color-text, var(--color-foreground, #333333)); 4332 --pf-text-light: var(--button-text-primary, #ffffff); 4333 --pf-button-rounding: var(--button-rounding, 0.5rem); 4334 --pf-button-py: calc(var(--button-py, 1rem) * 0.75); 4335 --pf-button-px: calc(var(--button-px, 2rem) * 0.75); 4336 --pf-input-rounding: var(--input-rounding, 0.5rem); 4337 --pf-gap: var(--gap, 1rem); 4338 --pf-drawer-bg: var(--bg-card, var(--bg-body, #ffffff)); 4339 } 4340 4341 /* Basic Reset & Body Style */ 4342 body.pf-drawer-open { 4343 /* Allow scrolling when drawer is open */ 4344 } 4345 4346 .pf-filter-button-container { 4347 display: flex; 4348 flex-wrap: wrap; 4349 align-items: center; 4350 gap: var(--pf-gap); 4351 } 4352 4353 .pf-filter-button { 4354 padding: var(--pf-button-py) var(--pf-button-px); 4355 cursor: pointer; 4356 background-color: var(--pf-light-gray); 4357 border-color: var(--pf-border-color); 4358 color: var(--pf-text-dark); 4359 border-radius: var(--pf-button-rounding); 4360 transition: all 0.2s ease; 4361 display: inline-flex; 4362 align-items: center; 4363 gap: 8px; 4364 } 4365 4366 .pf-filter-icon { 4367 flex-shrink: 0; 4368 opacity: 0.8; 4369 } 4370 4371 .pf-filter-button:hover { 4372 background-color: var(--pf-primary-color); 4373 color: var(--pf-text-light); 4374 border-color: var(--pf-primary-color); 4375 } 4376 4377 .pf-filter-button-badge { 4378 background-color: var(--pf-primary-color); 4379 } 4380
4381 /* Active filters display container */ 4382 .pf-active-filters { 4383 display: flex; 4384 gap: var(--pf-gap); 4385 align-items: center; 4386 } 4387 4388 .pf-filter-badge { 4389 display: inline-flex; 4390 align-items: center; 4391 padding: var(--pf-button-py) var(--pf-button-px); 4392 border: 1px solid var(--pf-badge-border-color); 4393 background-color: var(--pf-badge-bg); 4394 color: white; 4395 border-radius: var(--pf-button-rounding); 4396 font-size: 1rem; 4397 font-weight: 500; 4398 transition: all 0.2s ease; 4399 } 4400 4401 .pf-filter-badge-remove { 4402 margin-left: 10px; 4403 width: 20px; 4404 height: 20px; 4405 display: inline-flex; 4406 align-items: center; 4407 justify-content: center; 4408 cursor: pointer; 4409 font-weight: bold; 4410 border-radius: 50%; 4411 transition: background-color 0.2s; 4412 background-color: rgba(255, 255, 255, 0.2); 4413 font-size: 1rem; 4414 } 4415 4416 .pf-filter-badge-remove:hover { 4417 background-color: rgba(255, 255, 255, 0.3); 4418 } 4419 4420 .pf-reset-all { 4421 cursor: pointer; 4422 color: var(--pf-text-dark); 4423 text-decoration: underline; 4424 font-size: 1rem; 4425 padding: 8px 14px; 4426 display: inline-flex; 4427 align-items: center; 4428 margin: 0; 4429 font-weight: 500; 4430 transition: color 0.2s; 4431 white-space: nowrap; 4432 } 4433 4434 .pf-reset-all:hover { 4435 color: var(--pf-primary-color); 4436 } 4437 4438 /* Hide when no active filters */ 4439 .pf-active-filters:empty+.pf-reset-all { 4440 display: none; 4441 } 4442 4443 /* Drawer backdrop */ 4444 .pf-drawer-backdrop { 4445 position: fixed; 4446 top: 0; 4447 left: 0; 4448 width: 100%; 4449 height: 100%; 4450 z-index: 9998; 4451 opacity: 0; 4452 visibility: hidden; 4453 transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out; 4454 } 4455 4456 .pf-drawer-backdrop.pf-open { 4457 opacity: 1; 4458 visibility: visible; 4459 } 4460 4461 /* Drawer styling */ 4462 .pf-drawer { 4463 position: fixed; 4464 top: 0; 4465 right: -350px; 4466 width: 350px; 4467 height: 100%; 4468 background-color: var(--pf-drawer-bg); 4469 box-shadow: -2px 0 10px rgba(0, 0, 0, 0.15); 4470 transition: right 0.3s ease-in-out; 4471 z-index: 9999; 4472 display: flex; 4473 flex-direction: column; 4474 overflow-x: hidden; 4475 } 4476 4477 .pf-drawer.pf-open { 4478 right: 0; 4479 } 4480 4481 .pf-drawer-header { 4482 padding: 16px; 4483 border-bottom: 1px solid var(--pf-border-color); 4484 display: flex; 4485 justify-content: space-between; 4486 align-items: center; 4487 } 4488 4489 .pf-drawer-header h3 { 4490 margin: 0; 4491 font-size: 1.2rem; 4492 font-weight: 600; 4493 } 4494 4495 .pf-drawer-close-btn { 4496 background: none; 4497 border: none; 4498 font-size: 1.5rem; 4499 cursor: pointer; 4500 width: 40px; 4501 height: 40px; 4502 display: flex; 4503 align-items: center; 4504 justify-content: center; 4505 border-radius: 50%; 4506 transition: background-color 0.2s; 4507 color: var(--pf-text-dark); 4508 font-weight: bold; 4509 line-height: 1; 4510 } 4511 4512 .pf-drawer-close-btn:hover { 4513 background-color: var(--pf-light-gray); 4514 color: var(--pf-primary-color); 4515 } 4516 4517 .pf-drawer-content { 4518 padding: 16px; 4519 overflow-y: auto; 4520 overflow-x: hidden; 4521 flex-grow: 1; 4522 max-width: 100%; 4523 } 4524 4525 .pf-drawer-footer { 4526 padding: 16px; 4527 border-top: 1px solid var(--pf-border-color); 4528 text-align: center; 4529 display: flex; 4530 flex-direction: column; 4531 gap: 12px; 4532 } 4533 4534 .pf-drawer-apply-btn { 4535 padding: 12px 20px; 4536 font-size: 1rem; 4537 cursor: pointer; 4538 background-color: var(--pf-primary-color); 4539 color: white; 4540 border: none; 4541 border-radius: 25px; 4542 width: 100%; 4543 font-weight: 500; 4544 transition: background-color 0.2s; 4545 box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); 4546 } 4547 4548 .pf-drawer-apply-btn:hover { 4549 background-color: var(--pf-primary-dark); 4550 } 4551 4552 .pf-clear-filters-btn { 4553 background: none; 4554 border: none; 4555 color: var(--pf-text-dark); 4556 opacity: 0.7; 4557 transition: opacity 0.2s ease; 4558 } 4559 4560 .pf-clear-filters-btn:hover { 4561 opacity: 1; 4562 color: var(--pf-primary-color); 4563 } 4564 4565 /* Styling for filter groups and options in drawer */ 4566 .pf-filter-group { 4567 border-bottom: 1px solid var(--pf-border-color); 4568 } 4569 4570 .pf-filter-group:last-child { 4571 border-bottom: none; 4572 margin-bottom: 0; 4573 padding-bottom: 0; 4574 } 4575 4576 .pf-filter-group-title { 4577 margin-top: 0; 4578 font-size: 1.1rem; 4579 padding: 10px 0; 4580 cursor: pointer; 4581 display: flex; 4582 justify-content: space-between; 4583 align-items: center; 4584 font-weight: 600; 4585 color: var(--pf-text-dark); 4586 border: none; 4587 background: none; 4588 width: 100%; 4589 text-align: left; 4590 } 4591 4592 .pf-accordion-icon { 4593 margin-left: 10px; 4594 width: 24px; 4595 height: 24px; 4596 display: flex; 4597 align-items: center; 4598 justify-content: center; 4599 border-radius: 50%; 4600 background-color: var(--pf-light-gray); 4601 color: var(--pf-text-dark); 4602 transition: background-color 0.2s ease; 4603 } 4604 4605 .pf-accordion-icon:hover { 4606 background-color: var(--pf-primary-color); 4607 color: white; 4608 } 4609 4610 .pf-accordion-icon svg { 4611 width: 16px; 4612 height: 16px; 4613 } 4614 4615 .pf-filter-options-container { 4616 padding-top: 0.5rem; 4617 } 4618 4619 /* Custom Styling for Filter Options (Checkboxes) */ 4620 .pf-filter-option { 4621 display: block; 4622 position: relative; 4623 padding: 0.25rem 1rem; 4624 transition: background-color 0.15s ease-in-out; 4625 } 4626 4627 .pf-filter-option:hover { 4628 background-color: var(--pf-light-gray); 4629 } 4630 4631 .pf-filter-option input[type="checkbox"] { 4632 opacity: 0; 4633 position: absolute; 4634 width: 0; 4635 height: 0; 4636 cursor: pointer; 4637 } 4638 4639 .pf-filter-option label { 4640 display: flex; 4641 align-items: center; 4642 cursor: pointer; 4643 position: relative; 4644 padding-left: 32px; 4645 transition: font-weight 0.15s ease-in-out, color 0.15s ease-in-out; 4646 } 4647 4648 /* Custom Checkbox Box */ 4649 .pf-filter-option label::before { 4650 content: ''; 4651 position: absolute; 4652 left: 0; 4653 top: 50%; 4654 transform: translateY(-50%);
4655 width: 20px; 4656 height: 20px; 4657 border: 2px solid var(--pf-border-color); 4658 border-radius: 4px; 4659 background-color: var(--pf-input-bg, transparent); 4660 transition: border-color 0.15s ease-in-out, background-color 0.15s ease-in-out; 4661 } 4662 4663 /* Custom Checkmark */ 4664 .pf-filter-option input[type="checkbox"]:checked+label::after { 4665 content: ''; 4666 position: absolute; 4667 left: 7px; 4668 top: 50%; 4669 width: 7px; 4670 height: 14px; 4671 border-style: solid; 4672 border-color: #fff; 4673 border-width: 0 3px 3px 0; 4674 transform: translateY(-65%) rotate(45deg); 4675 } 4676 4677 /* When checkbox is checked: style the custom box and label text */ 4678 .pf-filter-option input[type="checkbox"]:checked+label::before { 4679 background-color: var(--pf-primary-color); 4680 border-color: var(--pf-primary-color); 4681 } 4682 4683 .pf-filter-option input[type="checkbox"]:checked+label { 4684 font-weight: 600; 4685 } 4686 4687 /* Focus style for accessibility */ 4688 .pf-filter-option input[type="checkbox"]:focus-visible+label::before { 4689 box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); 4690 } 4691 4692 /* Screen reader only class */ 4693 .sr-only { 4694 position: absolute; 4695 width: 1px; 4696 height: 1px; 4697 padding: 0; 4698 margin: -1px; 4699 overflow: hidden; 4700 clip: rect(0, 0, 0, 0); 4701 white-space: nowrap; 4702 border: 0; 4703 } 4704 4705 /* Responsive adjustments for mobile */ 4706 @media (max-width: 767px) { 4707 .pf-filter-button-container { 4708 flex-direction: column; 4709 } 4710 4711 .pf-filter-button { 4712 text-align: center; 4713 } 4714 4715 .pf-active-filters { 4716 display: none; 4717 } 4718 4719 .pf-reset-all { 4720 text-align: center; 4721 font-weight: 500; 4722 padding: 10px; 4723 margin-top: 5px; 4724 border-top: 1px solid var(--pf-border-color); 4725 } 4726 4727 .pf-drawer { 4728 width: 100%; 4729 right: -100%; 4730 } 4731 4732 .pf-drawer-backdrop { 4733 z-index: 9998; 4734 } 4735 } 4736 4737 /* No results message - break out of grid and center full width */ 4738 .pf-no-results { 4739 grid-column: 1 / -1; /* Span all grid columns */ 4740 width: 100%; 4741 display: flex; 4742 justify-content: center; 4743 align-items: center; 4744 min-height: 200px; 4745 } 4746 4747 .pf-no-results-container { 4748 text-align: center; 4749 padding: 40px 20px; 4750 max-width: 500px; 4751 width: 100%; 4752 } 4753 4754 .pf-no-results-container h3 { 4755 margin: 0 0 10px 0; 4756 font-weight: 700; 4757 } 4758 4759 .pf-no-results-container p { 4760 margin: 0 0 20px 0; 4761 line-height: 1.5; 4762 } 4763 4764 .pf-no-results-container .pf-clear-filters-btn { 4765 background: var(--color-primary, #000000); 4766 color: white; 4767 border: none; 4768 padding: 10px 20px; 4769 border-radius: 4px; 4770 cursor: pointer; 4771 transition: background-color 0.2s ease; 4772 } 4773 4774 .pf-no-results-container .pf-clear-filters-btn:hover { 4775 background: var(--color-primary, #000000); 4776 } 4777 ` 4778 }, 4779 4780 // Cleanup method to remove event listeners 4781 cleanup: function() { 4782 if (this.handleDocumentClick) { 4783 document.removeEventListener('click', this.handleDocumentClick) 4784 this.handleDocumentClick = null 4785 } 4786 if (this.handleDocumentKeydown) { 4787 document.removeEventListener('keydown', this.handleDocumentKeydown) 4788 this.handleDocumentKeydown = null 4789 } 4790 if (this.handlePageShow) { 4791 window.removeEventListener('pageshow', this.handlePageShow) 4792 this.handlePageShow = null 4793 } 4794 if (this.handleCheckboxChange) { 4795 document.removeEventListener('change', this.handleCheckboxChange) 4796 this.handleCheckboxChange = null 4797 } 4798 if (this.handleBeforeUnload) { 4799 window.removeEventListener('beforeunload', this.handleBeforeUnload) 4800 this.handleBeforeUnload = null 4801 } 4802 }, 4803 4804}) 4805 4806// Global cleanup function that can be called from anywhere 4807window.cleanupProductFilter = function() { 4808 if (window.QuickbutikProductFilter && window.QuickbutikProductFilter.cleanup) { 4809 window.QuickbutikProductFilter.cleanup() 4810 } 4811}</script>
4811<script type="text/javascript">window.registerApp({ 4812 name: 'ProductProperties', 4813 identifier: 'QuickbutikProductProperties', 4814 description: 'Show product properties', 4815 version: '1.1.0', 4816 pages: ['product'], 4817 data: {}, 4818 config: { 4819 insertTarget: { 4820 selector: '#product-properties', 4821 position: 'afterbegin', // beforebegin | afterbegin | beforeend | afterend 4822 }, 4823 fieldsAttributes: { 4824 fileInputAccept: 'image/*,application/pdf', 4825 }, 4826 styling: { 4827 formClasses: 'flex gap-4 flex-col pb-4', 4828 inputContainerClasses: 'flex flex-col gap-2', 4829 labelClasses: 'text-sm font-bold text-gray-700', 4830 labelErrorClasses: 'ml-1 text-red-500', 4831 checkboxInputClasses: 'form-checkbox h-5 w-5 text-gray-600', 4832 shortTextInputClasses: 4833 'block w-full p-3 text-xs text-black placeholder-gray-500 transition-all bg-white border border-gray-200 focus:border-gray-700 focus:outline-none', 4834 longTextInputClasses: 4835 'block w-full p-3 text-xs text-black placeholder-gray-500 transition-all bg-white border border-gray-200 focus:border-gray-700 focus:outline-none', 4836 fileContainerClasses: 'flex w-full input-bg input-border input-px input-py input-rounding w-full', 4837 fileInputClasses: 'input-bg input-border input-rounding input-px input-py', 4838 fileInputButtonClasses: 'shrink-0 cursor-pointer bg-primary text-center button-text-primary font-bold button-py button-px button-rounding mr-4 transition duration-100 hover:opacity-70', 4839 fileInputFilenameClasses: 'truncate button-py', 4840 fileUploadProgressContainerClasses: 'block relative w-full mt-2 rounded',
4841 fileUploadProgressBarBackgroundClasses: 'block absolute w-full h-full bg-black opacity-10 text-white rounded', 4842 fileUploadProgressBarClasses: 'block relative bg-primary py-1 px-4 text-xs font-bold text-white rounded', 4843 addonPriceClasses: 'text-sm text-gray-600 ml-0', 4844 }, 4845 }, 4846 onMount: function ({ appData, page }) { 4847 console.debug(`${this.identifier}@onMount`) 4848 4849 const productID = document 4850 .querySelector('[data-product-id]') 4851 .getAttribute('data-product-id') 4852 4853 if (productID) { 4854 fetch(`${qs_store_url}/apps/productproperties/view?product_id=${productID}`) 4855 .then((response) => response.json()) 4856 .then((data) => { 4857 this.data = data 4858 this.renderProductProperties() 4859 }) 4860 .catch((error) => { 4861 console.error('Error:', error); 4862 }) 4863 } 4864 4865 }, 4866 generageInputId: function (id) { 4867 return `ProductProperties-${id}` 4868 }, 4869 generateInputName: function (id) { 4870 return `product_properties_${id}` 4871 }, 4872 renderProductProperties: function () { 4873 const productPropertiesContainer = document.querySelector(this.data.identifier) 4874 4875 const insertPosition = { 4876 before: 'beforebegin', 4877 after: 'afterend', 4878 in: 'afterbegin', 4879 }[this.data.apply] 4880 4881 if (productPropertiesContainer && insertPosition && this.data.properties) { 4882 const form = document.createElement('form') 4883 form.classList.add(...this.config.styling.formClasses.split(' ')) 4884 form.setAttribute('id', 'ProductPropertiesForm') 4885 4886 this.data.properties.forEach((property) => { 4887 const [key] = 4888 Object.entries(property) 4889 .filter(([key]) => 4890 ['isCheckbox', 'isFile', 'isLongtext', 'isShorttext', 'isAddonProduct'].includes( 4891 key 4892 ) 4893 ) 4894 .find(([_, value]) => value) || [] 4895 4896 let input = null 4897 4898 switch (key) { 4899 case 'isCheckbox': 4900 input = this.renderCheckbox(property) 4901 break 4902 case 'isFile': 4903 input = this.renderFile(property) 4904 break 4905 case 'isLongtext': 4906 input = this.renderLongtext(property) 4907 break 4908 case 'isShorttext': 4909 input = this.renderShorttext(property) 4910 break 4911 case 'isAddonProduct': 4912 input = this.renderAddonProduct(property) 4913 break 4914 default: 4915 console.log('No valid type found') 4916 } 4917 4918 if (input) { 4919 const inputContainer = document.createElement('div') 4920 inputContainer.classList.add( 4921 ...this.config.styling.inputContainerClasses.split(' ') 4922 ) 4923 inputContainer.classList.add('product-property-container') // Add a class for easier selection 4924 form.appendChild(inputContainer) 4925 4926 // Only add label for non-addon products and checkboxes 4927 if (!['isAddonProduct', 'isCheckbox'].includes(key)) { 4928 4929 // Label 4930 const label = document.createElement('label') 4931 label.classList.add(...this.config.styling.labelClasses.split(' ')) 4932 4933 // File element 4934 if (key == 'isFile') { 4935 label.setAttribute('for', 'file-' + property.id) 4936 } else { 4937 label.setAttribute('for', this.generageInputId(property.id)) 4938 } 4939 label.innerText = property.title 4940 4941 // Add addon price display next to the property title 4942 if (property.addonPriceRaw && parseFloat(property.addonPriceRaw) > 0) { 4943 const addonPriceSpan = document.createElement('span') 4944 addonPriceSpan.classList.add(...this.config.styling.addonPriceClasses.split(' ')) 4945 addonPriceSpan.classList.add('addon-price') // Add a class for easier selection 4946 addonPriceSpan.innerText = ` (+${property.addonPrice})` 4947 addonPriceSpan.setAttribute('data-addon-price', property.addonPriceRaw) 4948 label.appendChild(addonPriceSpan) 4949 } 4950 4951 if (property.required === '1') { 4952 const requiredStar = document.createElement('span') 4953 requiredStar.classList.add(...this.config.styling.labelErrorClasses.split(' ')) 4954 requiredStar.innerText = '*' 4955 label.appendChild(requiredStar) 4956 } 4957 inputContainer.appendChild(label) 4958 } 4959 4960 // All elements except files and addon products, which need specific structure and attributes 4961 if (key !== 'isFile' && key !== 'isAddonProduct') { 4962 input.setAttribute('id', this.generageInputId(property.id)) 4963 input.setAttribute('name', this.generateInputName(property.id)) 4964 input.setAttribute('data-qb-property', property.id) 4965 4966 if (property.required === '1') { 4967 input.setAttribute('required', true) 4968 } 4969 } 4970 4971 inputContainer.appendChild(input) 4972 4973 form.appendChild(inputContainer) 4974 } 4975 }) 4976 4977 productPropertiesContainer.insertAdjacentElement(insertPosition, form) 4978 4979 // Only render ReCaptcha if we have a File property 4980 if (this.data.hasFileProperties) { 4981 this.renderReCaptcha() 4982 } 4983 } 4984 }, 4985 renderCheckbox: function (property) { 4986 const checkboxWrapper = document.createElement('div') 4987 checkboxWrapper.classList.add('flex', 'items-center') 4988 4989 const checkbox = document.createElement('input') 4990 checkbox.classList.add( 4991 ...this.config.styling.checkboxInputClasses.split(' ') 4992 ) 4993 checkbox.setAttribute('type', 'checkbox') 4994 checkbox.setAttribute('name', this.generateInputName(property.id)) 4995 checkbox.setAttribute('value', '1') 4996 checkbox.setAttribute('data-qb-property', property.id) 4997 4998 // Label 4999 const label = document.createElement('label') 5000 label.classList.add(...this.config.styling.labelClasses.split(' ')) 5001 label.classList.add('ml-4') 5002 label.setAttribute('for', this.generageInputId(property.id)) 5003 label.innerText = property.title 5004 5005 if (property.required === '1') { 5006 const requiredStar = document.createElement('span') 5007 requiredStar.classList.add(...this.config.styling.labelErrorClasses.split(' ')) 5008 requiredStar.innerText = '*' 5009 label.appendChild(requiredStar) 5010 5011 checkbox.setAttribute('required', true) 5012 } 5013 5014 checkboxWrapper.appendChild(checkbox) 5015 checkboxWrapper.appendChild(label) 5016 5017 return checkboxWrapper 5018 }, 5019 renderAddonProduct: function (property) { 5020 const addonContainer = document.createElement('div') 5021 addonContainer.classList.add('addon-product-container', 'flex', 'items-center', 'p-4', 'border', 'rounded-md') 5022 5023 // Checkbox 5024 const checkbox = document.createElement('input') 5025 checkbox.classList.add( 5026 ...this.config.styling.checkboxInputClasses.split(' '), 5027 'mr-4' 5028 ) 5029 checkbox.setAttribute('type', 'checkbox') 5030 checkbox.setAttribute('name', this.generateInputName(property.id)) 5031 checkbox.setAttribute('value', '1') 5032 checkbox.setAttribute('data-qb-property', property.id) 5033 5034 if (property.required === '1') { 5035 checkbox.setAttribute('required', true) 5036 } 5037 5038 // Add checkbox to the container first 5039 addonContainer.appendChild(checkbox) 5040 5041 // Image 5042 if (property.addonProductImage) { 5043 const imageContainer = document.createElement('div') 5044 imageContainer.classList.add('w-16', 'h-16', 'mr-4') 5045 5046 const image = document.createElement('img') 5047 image.src = property.addonProductImage 5048 image.alt = property.addonProductTitle || property.title 5049 image.classList.add('w-full', 'h-full', 'object-cover', 'rounded') 5050 5051 imageContainer.appendChild(image) 5052 addonContainer.appendChild(imageContainer) 5053 } 5054 5055 // Content (Title, Price, Link) 5056 const contentContainer = document.createElement('div') 5057 contentContainer.classList.add('flex', 'flex-col', 'flex-grow') 5058 5059 const titleElement = document.createElement('span') 5060 titleElement.classList.add('font-bold', 'mb-1') 5061 titleElement.textContent = property.addonProductTitle || property.title 5062 5063 const priceElement = document.createElement('span') 5064 priceElement.classList.add('text-sm', 'text-gray-600') 5065 priceElement.textContent = '+' + (property.addonProductPrice || property.addonPrice) 5066 5067 contentContainer.appendChild(titleElement) 5068 contentContainer.appendChild(priceElement) 5069 5070 if (property.addonProductLink) { 5071 const link = document.createElement('a') 5072 link.href = property.addonProductLink 5073 link.target = '_blank' 5074 link.classList.add('text-blue-600', 'hover:underline', 'text-sm', 'mt-1') 5075 link.textContent = property.addonProductLinkText || 'Read More' 5076 contentContainer.appendChild(link) 5077 } 5078 5079 // Add content container last 5080 addonContainer.appendChild(contentContainer) 5081 5082 return addonContainer 5083 }, 5084 renderFile: function (property) { 5085 5086 // Wrapper 5087 const fileContainer = document.createElement('div') 5088 fileContainer.classList.add(...this.config.styling.fileContainerClasses.split(' ')) 5089
5090 // Input 5091 const file = document.createElement('input') 5092 file.classList.add(...this.config.styling.fileInputClasses.split(' ')) 5093 file.classList.add('qs-cart-product-file') 5094 file.setAttribute('id', 'file-' + property.id) 5095 file.setAttribute('type', 'file') 5096 file.setAttribute('name', 'product_fields_files[' + property.id + ']') 5097 file.setAttribute('accept', this.config.fieldsAttributes.fileInputAccept) 5098 if (property.required === '1') { 5099 file.setAttribute('required', true) 5100 file.setAttribute('data-is-required', true) 5101 } 5102 file.style.display = 'none' 5103 5104 file.setAttribute('data-qb-property', property.id) 5105 fileContainer.append(file) 5106 5107 const button = document.createElement('label') 5108 button.setAttribute('for', 'file-' + property.id) 5109 button.classList.add(...this.config.styling.fileInputButtonClasses.split(' ')) 5110 button.innerText = this.data.texts.chooseFile 5111 file.addEventListener('change', function () { 5112 filename.innerText = file.files[0].name 5113 }) 5114 5115 fileContainer.append(button) 5116 5117 const filename = document.createElement('span') 5118 filename.innerText = this.data.texts.noFileChosen 5119 filename.classList.add(...this.config.styling.fileInputFilenameClasses.split(' ')) 5120 fileContainer.append(filename) 5121 5122 return fileContainer 5123 }, 5124 renderLongtext: function (property) { 5125 const longtext = document.createElement('textarea') 5126 longtext.classList.add( 5127 ...this.config.styling.longTextInputClasses.split(' ') 5128 ) 5129 longtext.setAttribute('name', property.name) 5130 longtext.setAttribute('maxlength', 250) 5131 5132 return longtext 5133 }, 5134 renderShorttext: function (property) { 5135 const shorttext = document.createElement('input') 5136 shorttext.classList.add( 5137 ...this.config.styling.shortTextInputClasses.split(' ') 5138 ) 5139 shorttext.setAttribute('type', 'text') 5140 shorttext.setAttribute('name', property.name) 5141 shorttext.setAttribute('maxlength', 250) 5142 5143 return shorttext 5144 }, 5145 renderReCaptcha: function () { 5146 5147 /* PRODUCT FIELD FILE UPLOAD START */ 5148 if (document.readyState === "complete" || document.readyState === "interactive") { 5149 5150 const propertiesForm = document.querySelector('#ProductPropertiesForm') 5151 5152 if (!propertiesForm) { 5153 console.error('Could not find #ProductPropertiesForm') 5154 return false 5155 } 5156 5157 propertiesForm.dataset.prevent = '1' 5158 5159 var recaptchaDiv = document.createElement('div') 5160 recaptchaDiv.setAttribute('class', 'g-recaptcha') 5161 recaptchaDiv.setAttribute('data-sitekey', recaptchaSiteKey) 5162 recaptchaDiv.setAttribute('data-callback', 'recaptchaSubmit') 5163 recaptchaDiv.setAttribute('data-size', 'invisible') 5164 propertiesForm.prepend(recaptchaDiv) 5165 5166 var url = "https://www.google.com/recaptcha/api.js" 5167 var script = document.createElement('script') 5168 script.src = url 5169 script.type = 'text/javascript' 5170 document.getElementsByTagName('head')[0].appendChild(script) 5171 } 5172 }, 5173 uploadFiles: function () { 5174 return new Promise((resolve, reject) => { 5175 const form = document.querySelector('#ProductPropertiesForm') 5176 5177 if (form && form.querySelectorAll('input[type="file"]').length > 0) { 5178 var filesToUpload = new FormData() 5179 Array.from(form.querySelectorAll('input[type="file"]')).forEach(function (tag) { 5180 Array.from(tag.files).forEach(function (file) { 5181 filesToUpload.append(tag.name, file) 5182 }) 5183 }) 5184 5185 var buy_btn_text = null, wait_btn_text = null 5186 var buybtn_selector = document.querySelector('.qs-cart-submit') 5187 5188 var xhr = new XMLHttpRequest() 5189 xhr.open('POST', '/cart/misc', true) 5190 xhr.setRequestHeader('cache-control', 'no-cache') 5191 xhr.responseType = 'json' 5192 5193 var app = this 5194 5195 xhr.onload = function () { 5196 if (xhr.status >= 200 && xhr.status < 300) { 5197 var data = xhr.response 5198 5199 // Reset button 5200 if (buy_btn_text && buy_btn_text.length !== 0) { 5201 buybtn_selector.value = buy_btn_text 5202 buybtn_selector.innerHTML = buy_btn_text 5203 } 5204 buybtn_selector.removeAttribute('disabled') 5205 5206 // Remove progress bar 5207 if (form.querySelector('.productproperties-file-progress')) { 5208 form.querySelector('.productproperties-file-progress').remove() 5209 } 5210 5211 if (data?.properties) { 5212 if (data.properties.errors) { 5213 alert(data.properties.errors) 5214 5215 return false 5216 } else {
5217 data.properties.forEach(function (property, key) { 5218 // document.querySelector('.product-fields-file:eq('+property.property_id+')').value = property.value 5219 }) 5220 5221 document.querySelector('.qs-cart').dataset.prevent = 0 5222 document.querySelector('.qs-cart').dataset.fileuploadstatus = 1 5223 // document.querySelector('.qs-cart').submit() 5224 5225 resolve(true) 5226 } 5227 } else { 5228 5229 // Handle error 5230 console.error('No properties returned from file upload.') 5231 reject(false) 5232 } 5233 } else { 5234 5235 // Handle error 5236 console.error(xhr.statusText) 5237 5238 reject(false) 5239 } 5240 } 5241 5242 xhr.upload.onloadstart = function (e) { 5243 5244 // Progress bar 5245 if (form.querySelectorAll('.productproperties-file-progress').length == 0) { 5246 5247 // Container 5248 const progressContainer = document.createElement('div') 5249 if (app.config?.styling?.fileUploadProgressContainerClasses) { 5250 progressContainer.classList.add(...app.config.styling.fileUploadProgressContainerClasses.split(' ')) 5251 } 5252 progressContainer.classList.add('progress', 'productproperties-file-progress') 5253 5254 // Background
5255 const progressBarBackground = document.createElement('div') 5256 if (app.config?.styling?.fileUploadProgressBarBackgroundClasses) { 5257 progressBarBackground.classList.add(...app.config.styling.fileUploadProgressBarBackgroundClasses.split(' ')) 5258 } 5259 progressBarBackground.style.zIndex = 1 5260 progressContainer.appendChild(progressBarBackground) 5261 5262 // Bar 5263 const progressBar = document.createElement('div') 5264 if (app.config?.styling?.fileUploadProgressBarClasses) { 5265 progressBar.classList.add(...app.config.styling.fileUploadProgressBarClasses.split(' ')) 5266 progressBar.innerText = " " 5267 } 5268 progressBar.style.zIndex = 2 5269 progressBar.classList.add('progress-bar') 5270 progressContainer.appendChild(progressBar) 5271 5272 form.querySelectorAll('input[type="file"]')[form.querySelectorAll('input[type="file"]').length - 1].parentNode.parentNode.insertAdjacentElement('beforeEnd', progressContainer) 5273 } 5274 } 5275 5276 xhr.upload.onloadend = function (e) { } 5277 5278 xhr.upload.onprogress = function (e) { 5279 var percentComplete = Math.ceil((e.loaded / e.total) * 100) 5280 } 5281 5282 xhr.upload.onprogress = function (evt) { 5283 5284 let progressBar 5285 if (form.querySelectorAll('.productproperties-file-progress').length == 0) { 5286 form.querySelectorAll('input[type="file"]')[0].insertAdjacentHTML('afterend', '<div class="progress productproperties-file-progress ' + app.config.styling.fileUploadProgressContainerClasses + '"><div class="progress-bar ' + app.config.styling.fileUploadProgressBarClasses + '"></div></div>') 5287 progressBar = form.querySelectorAll('.productproperties-file-progress') 5288 } 5289 5290 if (evt.lengthComputable) { 5291 var percentComplete = evt.loaded / evt.total 5292 percentComplete = Math.round(percentComplete * 100) 5293 5294 5295 let progressBarBar = form.querySelectorAll('.productproperties-file-progress')[0].querySelector('.progress-bar') 5296 progressBarBar.style.width = percentComplete + '%' 5297 progressBarBar.innerHTML = percentComplete + '%' 5298 } 5299 } 5300 5301 5302 xhr.onerror = function () { 5303 console.error(xhr.statusText) 5304 } 5305 5306 xhr.send(filesToUpload) 5307 5308 // output = false // set as false when this function is executed as sync. 5309 } else { 5310 return true 5311 } 5312 5313 // console.log('Returns: ' + output) 5314 // return output 5315 }) 5316 }, 5317 5318}) 5319 5320function recaptchaSubmit(token) { 5321 fetch('/apps/productproperties/recaptcha', { 5322 method: 'POST', 5323 body: 'recaptchatoken=' + token, 5324 headers: { 5325 'Content-Type': 'application/x-www-form-urlencoded' 5326 } 5327 }) 5328 .then((response) => response.json()) 5329 .then((data) => { 5330 if (data.success) { 5331 document.querySelector('#ProductPropertiesForm').dataset.prevent = 0 5332 5333 // Verification successful. Upload the file and add the item to the cart 5334 window.apps.QuickbutikProductProperties.uploadFiles().then(async (uploadSuccess) => { 5335 Alpine.store('cart').addItemToCart(window.bodyFormData) 5336 }) 5337 5338 try { 5339 // Reset after submit 5340 grecaptcha.reset() 5341 } catch (e) { 5342 console.log(e) 5343 } 5344 5345 document.querySelector('#ProductPropertiesForm').dataset.prevent = 1 5346 5347 return true 5348 } else { 5349 alert('ReCaptcha error: Please try again.') 5350 } 5351 }) 5352} 5353 5354 5355 5356</script>
5356<script type="text/javascript">window.registerApp({ 5357 name: 'Ribbons', 5358 identifier: 'QuickbutikRibbons', 5359 description: 'Display ribbons on the top of product items', 5360 version: '2.0.0', 5361 pages: ['list', 'start', 'product'], 5362 data: {}, 5363 config: { 5364 productItems: { 5365 productItemSelector: '[data-qb-selector="product-item"]', 5366 appsContainer: '[data-qb-selector="product-item"]', 5367 }, 5368 productPage: { 5369 productSelector: '[data-qb-selector="product"]', 5370 appsContainer: '[data-qb-selector="product"]', 5371 }, 5372 style: { 5373 // NOTE: legacy client-side fallbacks, superseded by the server-side 5374 // $ribbon_style_defaults in shop/apps.php (the source of truth). These are 5375 // only used as `font_size || fontSize` / `font_weight || fontWeight`, and 5376 // the server now always sends non-empty values, so they're unreachable. 5377 // Don't "fix" the stale `modern.fontWeight: 600` here in isolation — the 5378 // canonical default is bold; change it in apps.php if you change it at all. 5379 ribbons: { 5380 classic: { 5381 offset: 8, 5382 ribbonHeight: 30, 5383 squareSize: 120, 5384 fontSize: 12, 5385 fontWeight: 700, 5386 textClasses: 'text-white', 5387 }, 5388 rectangle: { 5389 borderRadius: 4, 5390 offsetX: -8, 5391 offsetY: 10, 5392 paddingX: 8, 5393 paddingY: 4, 5394 fontSize: 14, 5395 fontWeight: 700, 5396 textClasses: 'text-white', 5397 }, 5398 circle: { 5399 offsetX: -8, 5400 offsetY: 10, 5401 paddingX: 20, 5402 paddingY: 10, 5403 fontSize: 14, 5404 fontWeight: 700, 5405 textClasses: 'text-white', 5406 }, 5407 modern: { 5408 paddingX: 12, 5409 paddingY: 6, 5410 fontSize: 12, 5411 fontWeight: 600, 5412 textClasses: 'text-white', 5413 }, 5414 vertical: { 5415 paddingX: 4, 5416 paddingY: 16, 5417 fontSize: 12, 5418 fontWeight: 700, 5419 textClasses: 'text-white', 5420 }, 5421 }, 5422 }, 5423 }, 5424 onMount: function ({ appData, page }) { 5425 console.debug(`${this.identifier}@onMount`) 5426 5427 const bodyFormData = new FormData() 5428 let itemSelector = '' 5429 5430 if (['list', 'start'].includes(page)) { 5431 itemSelector = this.config.productItems.productItemSelector 5432 } 5433 if (page == 'product') { 5434 itemSelector = this.config.productPage.productSelector 5435 } 5436 5437 bodyFormData.set( 5438 'products', 5439 [...document.querySelectorAll(itemSelector)].map( 5440 (product) => product.dataset.productId 5441 ) 5442 ) 5443 bodyFormData.set('currentPage', document.body.dataset?.qbPage) 5444 5445 fetch(qs_store_url + '/apps/productribbons/fetch', { 5446 method: 'POST', 5447 body: bodyFormData, 5448 }) 5449 .then((response) => response.json()) 5450 .then((data) => { 5451 if (data?.products) { 5452 const stackingDirection = data.stacking_direction || 'horizontal' 5453 5454 data.products.forEach((productData) => { 5455 let ribbonQuerySelector = '' 5456 if (['list', 'start'].includes(page)) { 5457 ribbonQuerySelector = `${this.config.productItems.appsContainer}[data-product-id="${productData.id}"]` 5458 } else if (page == 'product') { 5459 ribbonQuerySelector = `${this.config.productPage.appsContainer}[data-product-id="${productData.id}"], ${this.config.productItems.appsContainer}[data-product-id="${productData.id}"]` 5460 } 5461 5462 document 5463 .querySelectorAll(ribbonQuerySelector) 5464 .forEach((product) => { 5465 // Separate vertical ribbons from others (they position independently) 5466 const sides = { left: [], right: [] } 5467 const standalone = [] 5468 productData.ribbons.forEach((ribbon) => { 5469 if (ribbon.type === 'vertical') { 5470 standalone.push(ribbon) 5471 } else { 5472 const side = ribbon.position === 'left' ? 'left' : 'right' 5473 sides[side].push(ribbon) 5474 } 5475 }) 5476 5477 // Create a stacking container for each side that has ribbons
5478 Object.entries(sides).forEach(([side, ribbons]) => { 5479 if (ribbons.length === 0) return 5480 5481 const stack = this._createRibbonStack(side, ribbons, stackingDirection) 5482 if (stack) { 5483 product.insertAdjacentElement('beforeend', stack) 5484 } 5485 }) 5486 5487 // Render vertical ribbons with their own absolute positioning 5488 standalone.forEach((ribbon) => { 5489 const el = this.renderRibbon(ribbon) 5490 if (el) { 5491 el.style.position = 'absolute' 5492 el.style.top = '0' 5493 el.style[ribbon.position === 'left' ? 'left' : 'right'] = '0' 5494 el.style.pointerEvents = 'none' 5495 product.insertAdjacentElement('beforeend', el) 5496 } 5497 }) 5498 }) 5499 }) 5500 } 5501 }) 5502 .catch((error) => { 5503 console.error('Error fetching product ribbons:', error) 5504 }) 5505 }, 5506 _createRibbonStack: function (side, ribbons, stackingDirection) { 5507 const flexDirection = stackingDirection === 'horizontal' ? 'row' : 'column' 5508 const stack = document.createElement('div') 5509 stack.setAttribute('data-qb-ribbon-stack', side) 5510 stack.style.position = 'absolute' 5511 stack.style.top = '0' 5512 stack.style[side] = '0' 5513 stack.style.display = 'flex' 5514 stack.style.flexDirection = flexDirection 5515 stack.style.alignItems = side === 'left' ? 'flex-start' : 'flex-end' 5516 stack.style.gap = '6px' 5517 stack.style.pointerEvents = 'none' 5518 5519 ribbons.forEach((ribbon, index) => { 5520 const el = this.renderRibbon({ 5521 text: ribbon.text, 5522 bg_start_color: ribbon.bg_start_color, 5523 bg_end_color: ribbon.bg_end_color, 5524 text_color: ribbon.text_color, 5525 position: ribbon.position, 5526 type: ribbon.type, 5527 font_size: ribbon.font_size, 5528 font_weight: ribbon.font_weight, 5529 custom_css: ribbon.custom_css, 5530 }) 5531 5532 if (el) { 5533 // Convert from absolute to relative for flex stacking 5534 el.classList.remove('absolute') 5535 el.style.position = 'relative' 5536 5537 // First ribbon: preserve original offsets as margin for backward compatibility 5538 if (index === 0) { 5539 if (el.style.top && el.style.top !== '0px') { 5540 el.style.marginTop = el.style.top 5541 } 5542 const sideValue = side === 'left' ? el.style.left : el.style.right 5543 if (sideValue && sideValue !== '0px' && sideValue !== '0') { 5544 el.style[side === 'left' ? 'marginLeft' : 'marginRight'] = sideValue 5545 } 5546 } 5547 5548 el.style.top = '' 5549 el.style.left = '' 5550 el.style.right = '' 5551 5552 stack.appendChild(el) 5553 } else { 5554 console.warn(`Failed to render ribbon with type: ${ribbon.type}`) 5555 } 5556 }) 5557 5558 return stack 5559 }, 5560 renderRibbon: function ({ 5561 bg_end_color, 5562 bg_start_color, 5563 text_color, 5564 position, 5565 text, 5566 type, 5567 font_size, 5568 font_weight, 5569 custom_css, 5570 }) { 5571 5572 if (type === 'classic') { 5573 const { offset, squareSize, ribbonHeight, fontSize, fontWeight, textClasses } = 5574 this.config.style.ribbons.classic 5575 5576 const container = document.createElement('div') 5577 container.setAttribute('data-qb-ribbon', '') 5578 container.classList.add('absolute', 'overflow-hidden') 5579 container.style.pointerEvents = 'none' 5580 container.style.display = 'block' 5581 container.style.top = `-${offset}px` 5582 container.style[ 5583 `${position === 'left' ? 'left' : 'right'}` 5584 ] = `-${offset}px` 5585 container.style.height = `${squareSize}px` 5586 container.style.width = `${squareSize}px` 5587 if (position === 'left') { 5588 container.style.transform = 'rotate(270deg)' 5589 } 5590 5591 const tinySquare1 = document.createElement('div') 5592 tinySquare1.classList.add('absolute', 'top-0', 'left-0') 5593 tinySquare1.style.backgroundColor = bg_start_color 5594 tinySquare1.style.display = 'block' 5595 tinySquare1.style.height = `${offset}px` 5596 tinySquare1.style.width = `${offset}px` 5597 5598 const tinySquare2 = document.createElement('div') 5599 tinySquare2.classList.add('absolute', 'bottom-0', 'right-0') 5600 tinySquare2.style.pointerEvents = 'none' 5601 tinySquare2.style.backgroundColor = bg_end_color 5602 tinySquare2.style.display = 'block' 5603 tinySquare2.style.height = `${offset}px` 5604 tinySquare2.style.width = `${offset}px` 5605 5606 const ribbon = document.createElement('div') 5607 ribbon.classList.add('absolute', 'flex', 'items-center', 'justify-center') 5608 5609 if (textClasses) { 5610 textClasses.split(' ').forEach((cls) => { 5611 ribbon.classList.add(cls) 5612 }) 5613 } 5614 5615 ribbon.style.pointerEvents = 'none' 5616 ribbon.style.height = `${ribbonHeight}px` 5617 ribbon.style.width = `${squareSize}px` 5618 ribbon.style.transformOrigin = 'bottom left' 5619 ribbon.style.top = `-${ribbonHeight}px` 5620 ribbon.style.left = '0' 5621 ribbon.style.transform = 'rotate(45deg)' 5622 ribbon.style.width = `${Math.ceil(squareSize * Math.sqrt(2))}px` 5623 ribbon.style.background = `linear-gradient(${bg_start_color} 0%, ${bg_end_color} 100%)` 5624 ribbon.style.boxShadow = '0 3px 10px -5px rgba(0, 0, 0, 1)' 5625 ribbon.style.color = text_color 5626 ribbon.style.fontSize = `${font_size || fontSize}px` 5627 ribbon.style.fontWeight = font_weight || fontWeight 5628 5629 const textNode = document.createTextNode(text) 5630 ribbon.appendChild(textNode) 5631 5632 container.appendChild(tinySquare1) 5633 container.appendChild(tinySquare2) 5634 container.appendChild(ribbon) 5635 5636 if (custom_css) { 5637 custom_css.split(' ').forEach((cls) => { 5638 if (cls) container.classList.add(cls) 5639 }) 5640 } 5641 5642 return container 5643 } 5644 5645 if (type === 'rectangle') { 5646 const { 5647 borderRadius, 5648 offsetX, 5649 offsetY, 5650 paddingX, 5651 paddingY, 5652 fontSize, 5653 fontWeight, 5654 textClasses, 5655 } = this.config.style.ribbons.rectangle 5656 5657 const container = document.createElement('div') 5658 container.setAttribute('data-qb-ribbon', '') 5659 container.classList.add( 5660 'absolute', 5661 'flex', 5662 'justify-center', 5663 'items-center' 5664 ) 5665 container.style.pointerEvents = 'none' 5666 container.style.top = `${offsetY}px` 5667 container.style[position === 'left' ? 'left' : 'right'] = `${offsetX}px` 5668 container.style.padding = `${paddingY}px ${paddingX}px` 5669 container.style.background = `linear-gradient(${bg_start_color} 0%, ${bg_end_color} 100%)` 5670 container.style.borderRadius = `${borderRadius}px` 5671 5672 const textNode = document.createTextNode(text) 5673 5674 const span = document.createElement('span') 5675 span.appendChild(textNode) 5676 5677 if (textClasses) { 5678 textClasses.split(' ').forEach((cls) => { 5679 span.classList.add(cls) 5680 }) 5681 } 5682 5683 span.style.color = text_color 5684 span.style.fontSize = `${font_size || fontSize}px` 5685 span.style.fontWeight = font_weight || fontWeight 5686 container.appendChild(span) 5687 5688 if (custom_css) { 5689 custom_css.split(' ').forEach((cls) => { 5690 if (cls) container.classList.add(cls) 5691 }) 5692 } 5693 5694 return container 5695 } 5696 5697 if (type === 'circle') { 5698 const { offsetX, offsetY, paddingX, paddingY, fontSize, fontWeight, textClasses } = 5699 this.config.style.ribbons.circle 5700 5701 const container = document.createElement('div') 5702 container.setAttribute('data-qb-ribbon', '') 5703 container.classList.add('absolute') 5704 container.style.pointerEvents = 'none' 5705 container.style.display = 'block' 5706 container.style.top = `${offsetY}px` 5707 container.style[position === 'left' ? 'left' : 'right'] = `${offsetX}px` 5708 container.style.padding = `${paddingY}px ${paddingX}px` 5709 container.style.borderRadius = '50%' 5710 container.style.background = `linear-gradient(${bg_start_color} 0%, ${bg_end_color} 100%)` 5711 5712 const textNode = document.createTextNode(text) 5713 5714 const span = document.createElement('span') 5715 span.appendChild(textNode) 5716 5717 if (textClasses) { 5718 textClasses.split(' ').forEach((cls) => { 5719 span.classList.add(cls) 5720 }) 5721 } 5722 5723 span.style.color = text_color 5724 span.style.fontSize = `${font_size || fontSize}px` 5725 span.style.fontWeight = font_weight || fontWeight 5726 container.appendChild(span) 5727 5728 if (custom_css) { 5729 custom_css.split(' ').forEach((cls) => { 5730 if (cls) container.classList.add(cls) 5731 }) 5732 } 5733 5734 return container 5735 } 5736 5737 if (type === 'modern') { 5738 const { 5739 paddingX, 5740 paddingY, 5741 fontSize, 5742 fontWeight, 5743 textClasses, 5744 } = this.config.style.ribbons.modern 5745 5746 const container = document.createElement('div') 5747 container.setAttribute('data-qb-ribbon', '') 5748 container.classList.add( 5749 'absolute', 5750 'flex', 5751 'justify-center', 5752 'items-center' 5753 ) 5754 container.style.pointerEvents = 'none' 5755 container.style.top = '0px' 5756 container.style[position === 'left' ? 'left' : 'right'] = '0px' 5757 container.style.padding = `${paddingY}px ${paddingX}px` 5758 container.style.backgroundColor = bg_start_color 5759 container.style.backdropFilter = 'blur(4px)' 5760 5761 const textNode = document.createTextNode(text) 5762 5763 const span = document.createElement('span') 5764 span.appendChild(textNode) 5765 5766 if (textClasses) { 5767 textClasses.split(' ').forEach((cls) => { 5768 span.classList.add(cls) 5769 }) 5770 } 5771 5772 span.style.color = text_color 5773 span.style.fontSize = `${font_size || fontSize}px` 5774 span.style.fontWeight = font_weight || fontWeight 5775 span.style.letterSpacing = '0.025em' 5776 container.appendChild(span) 5777 5778 if (custom_css) { 5779 custom_css.split(' ').forEach((cls) => { 5780 if (cls) container.classList.add(cls) 5781 }) 5782 } 5783 5784 return container 5785 } 5786 5787 if (type === 'vertical') { 5788 const { 5789 paddingX, 5790 paddingY, 5791 fontSize, 5792 fontWeight, 5793 textClasses, 5794 } = this.config.style.ribbons.vertical 5795 5796 const container = document.createElement('div') 5797 container.setAttribute('data-qb-ribbon', '') 5798 container.classList.add( 5799 'absolute', 5800 'flex', 5801 'justify-center', 5802 'items-center' 5803 ) 5804 container.style.pointerEvents = 'none' 5805 container.style.top = '0px' 5806 container.style[position === 'left' ? 'left' : 'right'] = '0px' 5807 container.style.padding = `${paddingY}px ${paddingX}px` 5808 container.style.minHeight = '80px' 5809 container.style.backgroundColor = bg_start_color 5810 container.style.writingMode = 'vertical-lr' 5811 container.style.textOrientation = 'mixed' 5812 5813 const textNode = document.createTextNode(text) 5814 5815 const span = document.createElement('span') 5816 span.appendChild(textNode) 5817 5818 if (textClasses) { 5819 textClasses.split(' ').forEach((cls) => { 5820 span.classList.add(cls) 5821 }) 5822 } 5823 5824 span.style.color = text_color 5825 span.style.fontSize = `${font_size || fontSize}px` 5826 span.style.fontWeight = font_weight || fontWeight 5827 container.appendChild(span) 5828 5829 if (custom_css) { 5830 custom_css.split(' ').forEach((cls) => { 5831 if (cls) container.classList.add(cls) 5832 }) 5833 } 5834 5835 return container 5836 } 5837 5838 // Fallback for unknown ribbon types 5839 console.warn(`Unknown ribbon type: ${type}. Expected: classic, rectangle, circle, modern, or vertical`) 5840 return null 5841 }, 5842}) 5843</script>
5843<script type="text/javascript">(function () { 5844 window.registerApp({ 5845 name: "LanguageModal", 5846 identifier: "QuickbutikLanguageModal", 5847 description: "Language Modal", 5848 version: "1.0.0", 5849 pages: ["list", "start", "product"], 5850 data: {}, 5851 config: {}, 5852 onMount: function ({ appData, page }) { 5853 this.loadDependencies().then(function () { 5854 new LanguageModal(appData).check(); 5855 }); 5856 }, 5857 loadDependencies: function () { 5858 return Promise.all([ 5859 loadScript("/assets/shopassets/modal/modal.js"), 5860 loadStylesheet("/assets/shopassets/modal/modal.css"), 5861 5862 loadScript("/assets/shopassets/language-modal/language-modal-v4.js"), 5863 ]) 5864 } 5865 }); 5866 5867 function loadScript(src) { 5868 if (document.querySelector(`script[src="${src}"]`)) { 5869 return Promise.resolve(); 5870 } 5871 5872 return new Promise(function (resolve, reject) { 5873 const script = document.createElement("script"); 5874 script.src = src; 5875 script.async = true; 5876 script.onload = resolve; 5877 script.onerror = reject; 5878 document.head.append(script); 5879 }); 5880 } 5881 5882 function loadStylesheet(href) { 5883 if (document.querySelector(`link[href="${href}"]`)) { 5884 return Promise.resolve(); 5885 } 5886 5887 return new Promise(function (resolve, reject) { 5888 const link = document.createElement("link"); 5889 link.href = href; 5890 link.rel = "stylesheet"; 5891 link.onload = resolve; 5892 link.onerror = reject; 5893 document.head.append(link); 5894 }); 5895 } 5896})(); 5897 5898</script>
5898<script type="text/javascript">window.registerApp({ 5899 name: "ProductReviews", 5900 identifier: "QuickbutikProductReviews", 5901 description: "Show product reviews", 5902 version: "1.3.0", 5903 pages: ["product", "list", "start"], 5904 data: {}, 5905 config: { 5906 containerSelector: "#productReviews", 5907 listItemContainerSelector: 5908 '[data-qb-selector="product-apps-reviews-item-reviews"]', 5909 themeBasedReviews: true, 5910 appContainerSelector: '[data-qb-selector="product-apps-reviews"]', 5911 appContainerFormSelector: '[data-qb-selector="product-apps-reviews-form"]', 5912 appContainerStarsSelector: 5913 '[data-qb-selector="product-apps-reviews-total"]', 5914 appContainerButtonSelector: 5915 '[data-qb-selector="product-apps-reviews-write-review"]', 5916 appContainerReviewsSelector: 5917 '[data-qb-selector="product-apps-reviews-items"]', 5918 star: { 5919 path: "M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z", 5920 color: { 5921 active: "#F59E0B", 5922 inactive: "#E5E7EB", 5923 }, 5924 }, 5925 stars: { 5926 size: "w-5 h-5", 5927 productPageSize: "w-5 h-5", 5928 }, 5929 styling: { 5930 articleWidth: 85, 5931 classes: { 5932 headerContainer: "flex items-center justify-between py-6 border-b", 5933 addButton: 5934 "inline-flex items-center justify-center w-12 h-12 border hover:border-gray-500", 5935 headerRatingContainer: "flex items-center gap-3", 5936 headerText: "text-xl font-bold font-heading", 5937 ratingCount: "ml-2", 5938 reviewsContainer: "mt-4", 5939 reviewsArticleContainer: "flex gap-4 overflow-hidden", 5940 article: "pb-12 border-b", 5941 articleNameContainer: "flex items-center mb-4 space-x-4", 5942 articleName: "space-y-2 font-medium", 5943 articleRatingContainer: "flex items-center mb-2", 5944 articleContent: "mb-2 text-gray-500 dark:text-gray-400", 5945 articleShowMoreButton: 5946 "text-xs text-xs text-gray-500 ml-2 cursor-pointer", 5947 replyContainer: "border mt-3 ml-3 p-3", 5948 replyText: "text-xs text-gray-500 dark:text-gray-400", 5949 starsContainer: "flex flex-col gap-4", 5950 form: "my-4 flex flex-col gap-6", 5951 formRatingDiv: "flex flex-col gap-4", 5952 formRatingLabel: "text-sm font-bold text-gray-700", 5953 formGroup: "flex flex-col gap-4", 5954 textInput: 5955 "block w-full p-3 text-xs text-black placeholder-gray-500 transition-all bg-white border border-gray-200 focus:border-gray-700 focus:outline-none", 5956 formSubmitButton: 5957 "block w-full cursor-pointer bg-primary text-center text-white font-bold py-5 px-8 uppercase transition duration-200 hover:opacity-70", 5958 formErrorMessageContainer: "text-red-500 text-sm font-bold", 5959 formThankYouMessageContainer: "flex flex-col gap-4 my-12", 5960 formThankYouMessageLabel: "text-sm font-bold text-gray-700", 5961 }, 5962 gradientOverlay: `linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1) 80%)`, 5963 articleFade: { 5964 background: 5965 "linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1))", 5966 classes: "h-12", 5967 }, 5968 articleMaxHeight: "100px", 5969 }, 5970 }, 5971 5972 onMount: function ({ appData, page }) { 5973 console.debug(`${this.identifier}@onMount`); 5974 5975 const bodyFormData = new FormData(); 5976 let itemSelector = ""; 5977 5978 // Respect backend flag: show_in_lists 5979 const normalizeShowInLists = (val) => { 5980 if (typeof val === "boolean") return val; 5981 if (val === 1 || val === "1" || val === "true") return true; 5982 if (val === 0 || val === "0" || val === "false") return false; 5983 return true; // default to true if missing 5984 }; 5985 this.data.showInLists = normalizeShowInLists(appData?.productreviews?.show_in_lists); 5986 5987 // Store review settings 5988 this.data.reviewSectionDescription = appData?.productreviews?.review_section_description || null; 5989 this.data.reviewTextPlaceholder = appData?.productreviews?.review_text_placeholder || null; 5990 5991 // Set star color from settings (backend resolves "primary" to actual color) 5992 if (appData?.productreviews?.star_color) { 5993 this.config.star.color.active = appData.productreviews.star_color; 5994 this.injectStarColorStyles(appData.productreviews.star_color); 5995 } 5996 5997 if (["list", "start"].includes(page)) { 5998 itemSelector = '[data-qb-selector="product-item"]'; 5999 } else if (page === "product") { 6000 // Main product + related product cards 6001 itemSelector = '[data-qb-selector="product"], [data-qb-selector="product-item"]'; 6002 } 6003 // If needed, we could scope to only main product: 6004 // if (page === "product") { itemSelector = '[data-qb-selector="product"]'; } 6005 6006 // On product page, render main product UI from top-level meta 6007 if (page === "product") { 6008 const mainProductEl = document.querySelector('[data-qb-selector="product"]'); 6009 const mainMeta = appData?.productreviews || {}; 6010 const mainProductId = mainProductEl?.getAttribute("data-product-id"); 6011 if (mainProductEl && mainProductId) { 6012 const mainProductData = { 6013 id: mainProductId, 6014 rating: mainMeta.rating,
6015 rating_decimal: mainMeta.rating_decimal, 6016 amountTotal: mainMeta.amountTotal, 6017 reviews: mainMeta.reviews, 6018 has_rated: mainMeta.has_rated, 6019 }; 6020 this.renderProductReviews(mainProductData, page, mainProductEl); 6021 } 6022 } 6023 6024 if ((page === "list" || page === "start") && !this.data.showInLists) { 6025 return; 6026 } 6027 const existingProducts = appData?.productreviews?.products; 6028 if (Array.isArray(existingProducts) && existingProducts.length > 0) { 6029 if (page === "product" && !this.data.showInLists) { 6030 // Do not render related items when disabled 6031 return; 6032 } 6033 existingProducts.forEach((productData) => { 6034 let productQuerySelector = ""; 6035 if (["list", "start"].includes(page)) { 6036 productQuerySelector = `[data-qb-selector="product-item"][data-product-id="${productData.id}"]`; 6037 } else if (page === "product") { 6038 // Only related product cards; main product handled from top-level meta above 6039 productQuerySelector = `[data-qb-selector="product-item"][data-product-id="${productData.id}"]`; 6040 } 6041 6042 document 6043 .querySelectorAll(productQuerySelector) 6044 .forEach((product) => { 6045 this.renderProductReviews(productData, page, product); 6046 }); 6047 }); 6048 return; 6049 } 6050 6051 // Get all product IDs on the page (same as productribbons) 6052 const productElements = document.querySelectorAll(itemSelector); 6053 if (productElements.length === 0) { 6054 return; 6055 } 6056 6057 let productIds = [...productElements] 6058 .map((product) => product.dataset.productId) 6059 .filter((id) => id && id !== "0"); 6060 6061 // On product page, exclude main product ID (we render it from top-level meta) 6062 if (page === "product") { 6063 const mainProductId = document.querySelector('[data-qb-selector="product"]')?.getAttribute('data-product-id'); 6064 if (mainProductId) { 6065 productIds = productIds.filter((id) => id !== mainProductId); 6066 } 6067 } 6068 6069 if (page === 'product' && !this.data.showInLists) { 6070 return; 6071 } 6072 6073 if (productIds.length === 0) { 6074 return; 6075 } 6076 6077 bodyFormData.set("products", productIds.join(",")); 6078 bodyFormData.set("currentPage", document.body.dataset?.qbPage); 6079 6080 // Fetch reviews data from endpoint (same pattern as productribbons) 6081 fetch(qs_store_url + "/apps/productreviews/fetch", { 6082 method: "POST", 6083 body: bodyFormData, 6084 }) 6085 .then((response) => response.json()) 6086 .then((data) => { 6087 console.debug( 6088 `${this.identifier}: Received data for ${ 6089 data?.products?.length || 0 6090 } products` 6091 ); 6092 6093 // Store settings if fetched 6094 if (data?.review_section_description !== undefined) { 6095 this.data.reviewSectionDescription = data.review_section_description; 6096 } 6097 if (data?.review_text_placeholder !== undefined) { 6098 this.data.reviewTextPlaceholder = data.review_text_placeholder; 6099 } 6100 6101 // Set star color from fetched settings (backend resolves "primary" to actual color) 6102 if (data?.star_color) { 6103 this.config.star.color.active = data.star_color; 6104 this.injectStarColorStyles(data.star_color); 6105 } 6106 6107 if (data?.products) { 6108 data?.products.forEach((productData) => { 6109 let productQuerySelector = ""; 6110 if (["list", "start", ].includes(page)) { 6111 productQuerySelector = `[data-qb-selector="product-item"][data-product-id="${productData.id}"]`; 6112 } else if (page === "product") { 6113 // Only related product cards; main product handled from top-level meta 6114 productQuerySelector = `[data-qb-selector="product-item"][data-product-id="${productData.id}"]`; 6115 } 6116 6117 document 6118 .querySelectorAll(productQuerySelector) 6119 .forEach((product) => { 6120 this.renderProductReviews(productData, page, product); 6121 }); 6122 }); 6123 } 6124 }) 6125 .catch((error) => { 6126 console.error( 6127 `${this.identifier}: Error fetching product reviews:`, 6128 error 6129 ); 6130 }); 6131 }, 6132 6133 injectStarColorStyles: function (starColor) { 6134 // Check if style element already exists 6135 const existingStyle = document.getElementById('qb-review-star-styles'); 6136 if (existingStyle) { 6137 // Update existing style 6138 existingStyle.textContent = `.qb-review-star-active { color: ${starColor} !important; }`; 6139 } else { 6140 // Create new style element 6141 const styleElement = document.createElement('style'); 6142 styleElement.id = 'qb-review-star-styles'; 6143 styleElement.textContent = `.qb-review-star-active { color: ${starColor} !important; }`; 6144 document.head.appendChild(styleElement); 6145 } 6146 }, 6147 6148 renderProductReviews: function (productData, page, product) { 6149 const { rating } = productData; 6150 6151 if (this.config.themeBasedReviews) { 6152 // Only render full UI for the main product container on product pages 6153 if (page === 'product' && product?.matches?.('[data-qb-selector="product"]')) { 6154 this.renderThemeBasedProductReviews(productData, page); 6155 } 6156 } else { 6157 // Traditional mode: only render full UI for the main product container 6158 if (page === 'product' && product?.matches?.('[data-qb-selector="product"]')) { 6159 this.renderTraditionalProductReviews(productData, page); 6160 } 6161 } 6162 6163 // Always render product item ratings for list/start pages 6164 // On product page, render ratings for related product cards as well 6165 if ((["list", "start"].includes(page) || page === 'product') && rating > 0 && product?.matches?.('[data-qb-selector="product-item"]')) { 6166 this.renderProductItemRating(productData, product, page); 6167 } 6168 }, 6169 6170 renderThemeBasedProductReviews: function (productData, page) { 6171 const { rating, rating_decimal, reviews, amountTotal } = productData; 6172 6173 // For product page, render full review interface 6174 if (page === "product") { 6175 // Total review score/stars (if any, in multiple places) 6176 if (rating > 0) { 6177 const containers = document.querySelectorAll( 6178 this.config.appContainerStarsSelector 6179 );
6180 if (containers) { 6181 containers.forEach((container) => { 6182 // Only add if not already populated 6183 if (container.children.length === 0) { 6184 container.appendChild( 6185 this.renderRating(parseFloat(rating_decimal ?? rating), page) 6186 ); 6187 const reviewsCount = document.createElement("div"); 6188 reviewsCount.classList.add( 6189 ...this.config.styling.classes.ratingCount.split(" ") 6190 ); 6191 reviewsCount.textContent = "(" + (amountTotal || 0) + ")"; 6192 container.appendChild(reviewsCount); 6193 } 6194 }); 6195 } 6196 } 6197 6198 // Write review form 6199 const container = document.querySelector( 6200 this.config.appContainerFormSelector 6201 ); 6202 if (container && container.children.length === 0) { 6203 // Extract language tags 6204 const language = container.getAttribute("data-language") 6205 ? JSON.parse(container.getAttribute("data-language")) 6206 : {}; 6207 6208 container.appendChild(this.renderForm(language)); 6209 } 6210 6211 // Reviews 6212 if (reviews && reviews.length > 0) { 6213 // Reviews container 6214 const reviewsContainer = document.querySelector( 6215 this.config.appContainerReviewsSelector 6216 ); 6217 if (reviewsContainer) { 6218 // Add reviews to x-data 6219 reviewsContainer.setAttribute( 6220 "x-init", 6221 "reviews = " + JSON.stringify(reviews) 6222 ); 6223 } 6224 } 6225 } 6226 }, 6227 6228 renderTraditionalProductReviews: function (productData, page) { 6229 // Only render for product page in traditional mode 6230 if (page === "product") { 6231 const selector = this.config.containerSelector; 6232 const container = document.querySelector(selector); 6233 6234 if (container && container.children.length === 0) { 6235 container.appendChild(this.renderProductReviewsUI(productData, page)); 6236 } 6237 } 6238 }, 6239 6240 renderProductReviewsUI: function (productData, page) { 6241 const { rating, rating_decimal, has_rated, reviews } = productData; 6242 6243 const mainContainer = document.createElement("div"); 6244 mainContainer.setAttribute("x-data", JSON.stringify({ showForm: false })); 6245 6246 const headerContainer = document.createElement("div"); 6247 headerContainer.classList.add( 6248 ...this.config.styling.classes.headerContainer.split(" ") 6249 ); 6250 6251 const headerRatingContainer = document.createElement("div"); 6252 headerRatingContainer.classList.add( 6253 ...this.config.styling.classes.headerRatingContainer.split(" ") 6254 ); 6255 6256 const h4 = document.createElement("h4"); 6257 h4.textContent = "Reviews"; 6258 h4.classList.add(...this.config.styling.classes.headerText.split(" ")); 6259 6260 headerRatingContainer.appendChild(h4); 6261 6262 if (rating > 0) { 6263 headerRatingContainer.appendChild( 6264 this.renderRating(parseFloat(rating_decimal ?? rating), page) 6265 ); 6266 } 6267 6268 const showRatingFormButton = document.createElement("a"); 6269 showRatingFormButton.classList.add( 6270 ...this.config.styling.classes.addButton.split(" ") 6271 ); 6272 showRatingFormButton.setAttribute("x-on:click", "showForm = !showForm"); 6273 showRatingFormButton.id = "productReviewShowFormButton"; 6274 6275 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 6276 svg.setAttribute("width", "12"); 6277 svg.setAttribute("height", "12"); 6278 svg.setAttribute("viewBox", "0 0 12 12"); 6279 svg.setAttribute("fill", "none"); 6280 6281 const rect1 = document.createElementNS( 6282 "http://www.w3.org/2000/svg", 6283 "rect" 6284 ); 6285 rect1.setAttribute("x", "5"); 6286 rect1.setAttribute("width", "2"); 6287 rect1.setAttribute("height", "12"); 6288 rect1.setAttribute("fill", "#161616"); 6289 svg.appendChild(rect1); 6290 6291 const rect2 = document.createElementNS( 6292 "http://www.w3.org/2000/svg", 6293 "rect" 6294 ); 6295 rect2.setAttribute("x", "12"); 6296 rect2.setAttribute("y", "5"); 6297 rect2.setAttribute("width", "2"); 6298 rect2.setAttribute("height", "12"); 6299 rect2.setAttribute("transform", "rotate(90 12 5)"); 6300 rect2.setAttribute("fill", "#161616"); 6301 svg.appendChild(rect2); 6302 6303 showRatingFormButton.appendChild(svg); 6304
6305 headerContainer.appendChild(headerRatingContainer); 6306 6307 if (!has_rated) { 6308 headerContainer.appendChild(showRatingFormButton); 6309 } 6310 6311 mainContainer.appendChild(headerContainer); 6312 mainContainer.appendChild(this.renderForm()); 6313 6314 if (reviews && reviews.length > 0) { 6315 const reviewsContainer = document.createElement("div"); 6316 reviewsContainer.className = "relative"; 6317 reviewsContainer.classList.add( 6318 ...this.config.styling.classes.reviewsContainer.split(" ") 6319 ); 6320 6321 const reviewsArticleContainer = document.createElement("div"); 6322 reviewsArticleContainer.className = 6323 this.config.styling.classes.reviewsArticleContainer; 6324 reviewsArticleContainer.style.overflowX = "auto"; 6325 reviewsArticleContainer.style.scrollSnapType = "x mandatory"; 6326 6327 reviews.forEach((review) => { 6328 reviewsArticleContainer.appendChild(this.renderReview(review)); 6329 }); 6330 6331 reviewsContainer.appendChild(reviewsArticleContainer); 6332 6333 if (reviews.length > 1) { 6334 reviewsArticleContainer.lastElementChild.style.marginRight = `${ 6335 100 - this.config.styling.articleWidth 6336 }%`; 6337 6338 const gradientOverlay = document.createElement("div"); 6339 gradientOverlay.style.position = "absolute"; 6340 gradientOverlay.style.top = "0"; 6341 gradientOverlay.style.bottom = "0"; 6342 gradientOverlay.style.right = "0"; 6343 gradientOverlay.style.width = `${ 6344 100 - this.config.styling.articleWidth 6345 }%`; 6346 gradientOverlay.style.pointerEvents = "none"; 6347 gradientOverlay.style.backgroundImage = 6348 this.config.styling.gradientOverlay; 6349 6350 reviewsContainer.appendChild(gradientOverlay); 6351 } 6352 6353 mainContainer.appendChild(reviewsContainer); 6354 } 6355 6356 return mainContainer; 6357 }, 6358 6359 renderReview: function ({ content, name, rating, reply }) { 6360 const article = document.createElement("article"); 6361 article.classList.add(...this.config.styling.classes.article.split(" ")); 6362 article.style.scrollSnapAlign = "start"; 6363 article.style.flex = "0 0 auto"; 6364 article.style.width = `${ 6365 this.data.reviews?.length > 1 ? this.config.styling.articleWidth : 100 6366 }%`; 6367 6368 const articleNameContainer = document.createElement("div"); 6369 articleNameContainer.classList.add( 6370 ...this.config.styling.classes.articleNameContainer.split(" ") 6371 ); 6372 6373 const articleName = document.createElement("div"); 6374 articleName.classList.add( 6375 ...this.config.styling.classes.articleName.split(" ") 6376 ); 6377 const p1 = document.createElement("p"); 6378 p1.textContent = name; 6379 articleName.appendChild(p1); 6380 articleNameContainer.appendChild(articleName); 6381 article.appendChild(articleNameContainer); 6382 6383 const div3 = document.createElement("div"); 6384 div3.classList.add( 6385 ...this.config.styling.classes.articleRatingContainer.split(" ") 6386 ); 6387 div3.appendChild(this.renderRating(rating, "product")); 6388 6389 article.appendChild(div3); 6390 6391 const contentContainer = document.createElement("div"); 6392 contentContainer.setAttribute( 6393 "x-data", 6394 `{ 6395 showMore: false, 6396 isOverflowing(element) { 6397 return element.scrollHeight > element.clientHeight; 6398 } 6399}` 6400 ); 6401 6402 const p2 = document.createElement("p"); 6403 p2.className = "relative"; 6404 p2.classList.add(...this.config.styling.classes.articleContent.split(" ")); 6405 6406 p2.setAttribute("x-bind:class", '{ "overflow-hidden": !showMore }'); 6407 p2.setAttribute( 6408 "x-bind:style", 6409 `{ maxHeight: showMore ? "none" : "${this.config.styling.articleMaxHeight}" }` 6410 ); 6411 6412 p2.innerHTML = content; 6413 6414 const showMoreLink = document.createElement("a"); 6415 showMoreLink.classList.add( 6416 ...this.config.styling.classes.articleShowMoreButton.split(" ") 6417 ); 6418 6419 showMoreLink.setAttribute("x-text", 'showMore ? "Show less" : "Show more"'); 6420 showMoreLink.setAttribute("x-show", "isOverflowing($refs.content)"); 6421 showMoreLink.setAttribute("x-on:click", "showMore = !showMore"); 6422 showMoreLink.setAttribute("x-ref", "showMoreLink"); 6423 6424 const fadeOverlay = document.createElement("div"); 6425 fadeOverlay.className = 6426 "absolute bottom-0 left-0 right-0 pointer-events-none"; 6427 fadeOverlay.classList.add( 6428 ...this.config.styling.articleFade.classes.split(" ") 6429 ); 6430 fadeOverlay.style.backgroundImage = 6431 this.config.styling.articleFade.background; 6432 6433 fadeOverlay.setAttribute( 6434 "x-show", 6435 "!showMore && isOverflowing($refs.content)" 6436 ); 6437 6438 p2.appendChild(fadeOverlay); 6439 6440 contentContainer.appendChild(p2); 6441 p2.setAttribute("x-ref", "content"); 6442 contentContainer.appendChild(showMoreLink); 6443 article.appendChild(contentContainer); 6444 6445 if (reply) { 6446 const aside = document.createElement("aside"); 6447 aside.classList.add( 6448 ...this.config.styling.classes.replyContainer.split(" ") 6449 ); 6450 6451 const p3 = document.createElement("p"); 6452 6453 p3.classList.add(...this.config.styling.classes.replyText.split(" ")); 6454 p3.textContent = reply; 6455 aside.appendChild(p3); 6456 6457 article.appendChild(aside); 6458 } 6459 6460 return article; 6461 }, 6462 renderProductItemRating: function (productData, productItem, page) { 6463 const { id, rating, rating_decimal, amountTotal } = productData; 6464 6465 try { 6466 if (rating && parseFloat(rating) > 0) { 6467 const productItemRatingContainer = document.createElement("div"); 6468 6469 const containerClass = 6470 this.config.styling.classes.productItemRatingContainer; 6471 if (containerClass && containerClass.trim() !== "") { 6472 productItemRatingContainer.classList.add( 6473 ...containerClass.split(" ") 6474 ); 6475 } 6476 6477 // Create rating with review count 6478 const ratingElement = this.renderRating( 6479 parseFloat(rating_decimal ?? rating), 6480 page 6481 ); 6482 6483 // Add review count in parentheses if available 6484 if (amountTotal && parseInt(amountTotal) > 0) { 6485 const reviewCountSpan = document.createElement("span"); 6486 reviewCountSpan.classList.add( 6487 "review-count", 6488 "text-sm", 6489 "text-gray-600", 6490 "ml-2" 6491 ); 6492 reviewCountSpan.textContent = `(${amountTotal})`; 6493 6494 const ratingWrapper = document.createElement("div"); 6495 ratingWrapper.classList.add("flex", "items-center"); 6496 ratingWrapper.appendChild(ratingElement); 6497 ratingWrapper.appendChild(reviewCountSpan); 6498 6499 productItemRatingContainer.appendChild(ratingWrapper); 6500 } else { 6501 productItemRatingContainer.appendChild(ratingElement); 6502 } 6503 6504 // Check if listItemContainerSelector is defined and not blank 6505 let targetContainer = productItem; 6506 if ( 6507 this.config.listItemContainerSelector && 6508 this.config.listItemContainerSelector.trim() !== "" 6509 ) { 6510 const listItemContainer = productItem.querySelector( 6511 this.config.listItemContainerSelector 6512 ); 6513 if (listItemContainer) { 6514 targetContainer = listItemContainer; 6515 } 6516 } 6517 6518 // Only add if not already present (avoid duplicates) 6519 if ( 6520 !targetContainer.querySelector(".product-reviews-rating") && 6521 !targetContainer.querySelector(".review-count") 6522 ) { 6523 productItemRatingContainer.classList.add("product-reviews-rating"); 6524 targetContainer.appendChild(productItemRatingContainer); 6525 } 6526 } 6527 } catch (error) { 6528 console.error( 6529 `${this.identifier}: Error processing product item ${id}:`, 6530 error 6531 ); 6532 } 6533 }, 6534 renderRating: function (rating, page = null) { 6535 const uniqueId = Math.floor(Math.random() * 100000); 6536 6537 const div = document.createElement("div"); 6538 div.classList.add(...this.config.styling.classes.starsContainer.split(" ")); 6539 6540 const ratingDiv = document.createElement("div"); 6541 ratingDiv.className = "flex items-center"; 6542 6543 // Use productPageSize if on product page, otherwise use default size 6544 const starSize = (page === "product") ? this.config.stars.productPageSize : this.config.stars.size; 6545 6546 for (let starIndex = 1; starIndex <= 5; starIndex++) { 6547 const gradientId = `rating-gradient-${uniqueId}-${starIndex}`; 6548 6549 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 6550 svg.setAttribute("aria-hidden", "true"); 6551 svg.setAttribute("class", starSize); 6552 svg.setAttribute("viewBox", "0 0 20 20"); 6553 svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); 6554 6555 const title = document.createElementNS( 6556 "http://www.w3.org/2000/svg", 6557 "title" 6558 ); 6559 title.textContent = `Star ${starIndex}`; 6560 svg.appendChild(title); 6561 6562 const defs = document.createElementNS( 6563 "http://www.w3.org/2000/svg", 6564 "defs" 6565 ); 6566 const linearGradient = document.createElementNS( 6567 "http://www.w3.org/2000/svg", 6568 "linearGradient" 6569 ); 6570 linearGradient.setAttribute("id", gradientId); 6571 6572 const remainder = rating - starIndex + 1; 6573 const fillPercentage = 6574 remainder > 0 && remainder < 1 6575 ? remainder * 100 6576 : starIndex <= Math.floor(rating) 6577 ? 100 6578 : 0; 6579 6580 const stop1 = document.createElementNS( 6581 "http://www.w3.org/2000/svg", 6582 "stop" 6583 ); 6584 stop1.setAttribute("offset", "0%"); 6585 stop1.setAttribute("stop-color", this.config.star.color.active); 6586 linearGradient.appendChild(stop1); 6587 6588 const stop2 = document.createElementNS( 6589 "http://www.w3.org/2000/svg", 6590 "stop" 6591 ); 6592 stop2.setAttribute("offset", fillPercentage + "%"); 6593 stop2.setAttribute("stop-color", this.config.star.color.active); 6594 linearGradient.appendChild(stop2); 6595 6596 const stop3 = document.createElementNS( 6597 "http://www.w3.org/2000/svg", 6598 "stop" 6599 ); 6600 stop3.setAttribute("offset", fillPercentage + "%"); 6601 stop3.setAttribute("stop-color", this.config.star.color.inactive); 6602 linearGradient.appendChild(stop3); 6603 6604 const stop4 = document.createElementNS( 6605 "http://www.w3.org/2000/svg", 6606 "stop" 6607 ); 6608 stop4.setAttribute("offset", "100%"); 6609 stop4.setAttribute("stop-color", this.config.star.color.inactive); 6610 linearGradient.appendChild(stop4); 6611 6612 defs.appendChild(linearGradient); 6613 svg.appendChild(defs); 6614 6615 const path = document.createElementNS( 6616 "http://www.w3.org/2000/svg", 6617 "path" 6618 ); 6619 path.setAttribute("fill", `url(#${gradientId})`); 6620 path.setAttribute("d", this.config.star.path); 6621 svg.appendChild(path); 6622 6623 ratingDiv.appendChild(svg); 6624 } 6625 6626 div.appendChild(ratingDiv); 6627 6628 return div; 6629 }, 6630 renderForm: function (language) { 6631 const form = document.createElement("form"); 6632 6633 form.id = "productReviewForm"; 6634 form.style.position = "relative"; 6635 6636 form.setAttribute( 6637 "x-on:submit.prevent", 6638 `window.apps.${this.identifier}.onSubmitReview()` 6639 ); 6640 6641 form.classList.add(...this.config.styling.classes.form.split(" ")); 6642 form.setAttribute("x-show", "showForm"); 6643 form.setAttribute("x-transition", ""); 6644 form.setAttribute("x-cloak", ""); 6645 6646 // Add description if available 6647 if (this.data.reviewSectionDescription) { 6648 const descriptionP = document.createElement("p"); 6649 descriptionP.className = "review-section-description text-sm text-gray-700 mb-4"; 6650 descriptionP.textContent = this.data.reviewSectionDescription; 6651 form.appendChild(descriptionP); 6652 } 6653 6654 const ratingDiv = document.createElement("div"); 6655 ratingDiv.classList.add( 6656 ...this.config.styling.classes.formRatingDiv.split(" ") 6657 ); 6658 6659 const ratingLabel = document.createElement("label"); 6660 ratingLabel.classList.add( 6661 ...this.config.styling.classes.formRatingLabel.split(" ") 6662 ); 6663
6664 ratingLabel.textContent = language?.yourRating ?? "Your rating"; 6665 ratingDiv.appendChild(ratingLabel); 6666 6667 ratingDiv.appendChild(this.renderRatingInput()); 6668 6669 form.appendChild(ratingDiv); 6670 6671 const nameDiv = document.createElement("div"); 6672 nameDiv.classList.add(...this.config.styling.classes.formGroup.split(" ")); 6673 6674 const nameLabel = document.createElement("label"); 6675 nameLabel.setAttribute("for", "reviewName"); 6676 nameLabel.className = "text-sm font-bold text-gray-700"; 6677 nameLabel.textContent = language?.name ?? "Name"; 6678 nameDiv.appendChild(nameLabel); 6679 6680 const nameInput = document.createElement("input"); 6681 nameInput.required = true; 6682 nameInput.type = "text"; 6683 nameInput.id = "reviewName"; 6684 nameInput.classList.add( 6685 ...this.config.styling.classes.textInput.split(" ") 6686 ); 6687 nameDiv.appendChild(nameInput); 6688 6689 form.appendChild(nameDiv); 6690 6691 const emailDiv = document.createElement("div"); 6692 emailDiv.classList.add(...this.config.styling.classes.formGroup.split(" ")); 6693 6694 const emailLabel = document.createElement("label"); 6695 emailLabel.setAttribute("for", "reviewEmail"); 6696 emailLabel.className = "text-sm font-bold text-gray-700"; 6697 emailLabel.textContent = language?.email ?? "Email"; 6698 emailDiv.appendChild(emailLabel); 6699 6700 const emailInput = document.createElement("input"); 6701 emailInput.required = true; 6702 emailInput.type = "email"; 6703 emailInput.id = "reviewEmail"; 6704 emailInput.classList.add( 6705 ...this.config.styling.classes.textInput.split(" ") 6706 ); 6707 emailDiv.appendChild(emailInput); 6708 6709 form.appendChild(emailDiv); 6710 6711 const messageDiv = document.createElement("div"); 6712 messageDiv.classList.add( 6713 ...this.config.styling.classes.formGroup.split(" ") 6714 ); 6715 6716 const messageLabel = document.createElement("label"); 6717 messageLabel.setAttribute("for", "reviewMessage"); 6718 messageLabel.className = "text-sm font-bold text-gray-700"; 6719 messageLabel.textContent = language?.message ?? "Message"; 6720 messageDiv.appendChild(messageLabel); 6721 6722 const messageTextarea = document.createElement("textarea"); 6723 messageTextarea.required = true; 6724 messageTextarea.id = "reviewMessage"; 6725 messageTextarea.rows = 10; 6726 messageTextarea.classList.add( 6727 ...this.config.styling.classes.textInput.split(" ") 6728 ); 6729 if (this.data.reviewTextPlaceholder) { 6730 messageTextarea.placeholder = this.data.reviewTextPlaceholder; 6731 } 6732 messageDiv.appendChild(messageTextarea); 6733 6734 form.appendChild(messageDiv); 6735 6736 const submitButton = document.createElement("button"); 6737 submitButton.classList.add( 6738 ...this.config.styling.classes.formSubmitButton.split(" ") 6739 ); 6740 submitButton.textContent = language?.submitReview ?? "Submit review"; 6741 form.appendChild(submitButton); 6742 6743 const errorMessageContainer = document.createElement("div"); 6744 errorMessageContainer.classList.add( 6745 ...this.config.styling.classes.formErrorMessageContainer.split(" ") 6746 ); 6747 errorMessageContainer.id = "productReviewErrorMessage"; 6748 6749 form.appendChild(errorMessageContainer); 6750 6751 return form; 6752 }, 6753 renderRatingInput: function () { 6754 const ratingDiv = document.createElement("div"); 6755 ratingDiv.className = "flex items-center mb-2"; 6756 ratingDiv.setAttribute( 6757 "x-data", 6758 "{ currentRating: null, hoverRating: null }" 6759 ); 6760 6761 // Use productPageSize since this is only used on product page forms 6762 const starSize = this.config.stars.productPageSize; 6763 6764 ratingDiv.innerHTML = ` 6765<template x-for="starIndex in 5"> 6766 <div x-id="['reviewStar']"> 6767 <input required class="opacity-0 absolute -z-50" type="radio" :id="$id('reviewStar')" name="rating" :value="starIndex" x-model="currentRating" /> 6768 6769 <label 6770 :for="$id('reviewStar')" 6771 @mouseover="hoverRating = starIndex" 6772 @mouseleave="hoverRating = null" 6773 > 6774 <svg aria-hidden="true" 6775 :style="{color: !hoverRating && currentRating >= starIndex || hoverRating >= starIndex ? '${this.config.star.color.active}' : '${this.config.star.color.inactive}'}" 6776 class="${starSize}" 6777 fill="currentColor"
6778 viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><title x-text="['One star', 'Two stars', 'Three stars', 'Four stars', 'Five stars'][starIndex - 1]"></title><path d="${this.config.star.path}"></path> 6779 </svg> 6780 6781 </label> 6782 </div> 6783</template> 6784 `; 6785 6786 return ratingDiv; 6787 }, 6788 onSubmitReview: function () { 6789 const errorMessageContainer = document.getElementById( 6790 "productReviewErrorMessage" 6791 ); 6792 6793 try { 6794 this.submitReview(); 6795 errorMessageContainer.textContent = ""; 6796 } catch (error) { 6797 this.handleError(error); 6798 } 6799 }, 6800 handleError: function (error) { 6801 console.error("QuickbutikProductReviews@handleError", error); 6802 6803 const errorMessageContainer = document.getElementById( 6804 "productReviewErrorMessage" 6805 ); 6806 6807 const errorMessage = `An error occurred${ 6808 error ? `: ${JSON.stringify(error, null, 2)}` : "" 6809 }.`; 6810 6811 errorMessageContainer.textContent = errorMessage; 6812 }, 6813 submitReview: function () { 6814 const data = [ 6815 { 6816 key: "product_id", 6817 value: document 6818 .querySelector("[data-product-id]") 6819 .getAttribute("data-product-id"), 6820 }, 6821 { 6822 key: "name", 6823 value: document.getElementById("reviewName").value, 6824 }, 6825 { 6826 key: "email", 6827 value: document.getElementById("reviewEmail").value, 6828 }, 6829 { 6830 key: "rating", 6831 value: document.querySelector('input[name="rating"]:checked').value, 6832 }, 6833 { 6834 key: "message", 6835 value: document.getElementById("reviewMessage").value, 6836 }, 6837 ]; 6838 6839 const formData = new FormData(); 6840 6841 data.forEach(({ key, value }) => { 6842 if (!value) { 6843 throw new Error(`Missing ${key}`); 6844 } 6845 6846 formData.append(key, value); 6847 }); 6848 6849 fetch("/apps/productreviews/add", { 6850 method: "POST", 6851 body: formData, 6852 }) 6853 .then((response) => { 6854 if (!response.ok) { 6855 this.handleError(response.statusText); 6856 } 6857 return response.json(); 6858 }) 6859 .then((data) => { 6860 if (data.success === 1) { 6861 this.renderThankYouMessage(data); 6862 } else { 6863 this.handleError(data?.error); 6864 } 6865 }) 6866 .catch((error) => { 6867 this.handleError(error); 6868 }); 6869 }, 6870 renderThankYouMessage: function (data) { 6871 const { message_lbl, message_txt } = data; 6872 6873 const thankYouDiv = document.createElement("div"); 6874 thankYouDiv.classList.add( 6875 ...this.config.styling.classes.formThankYouMessageContainer.split(" ") 6876 ); 6877 6878 const thankYouLabel = document.createElement("label"); 6879 thankYouLabel.classList.add( 6880 ...this.config.styling.classes.formThankYouMessageLabel.split(" ") 6881 ); 6882 thankYouLabel.textContent = message_lbl; 6883 thankYouDiv.appendChild(thankYouLabel); 6884 6885 const thankYouMessage = document.createElement("p"); 6886 thankYouMessage.classList.add( 6887 ...this.config.styling.classes.formThankYouMessageLabel.split(" ") 6888 ); 6889 thankYouMessage.textContent = message_txt; 6890 thankYouDiv.appendChild(thankYouMessage); 6891 6892 const form = document.getElementById("productReviewForm"); 6893 6894 form.parentNode.replaceChild(thankYouDiv, form); 6895 6896 setTimeout(() => { 6897 thankYouDiv.parentNode.removeChild(thankYouDiv); 6898 6899 const productReviewButton = document.getElementById( 6900 "productReviewShowFormButton" 6901 ); 6902 6903 if (productReviewButton) { 6904 productReviewButton.parentNode.removeChild(productReviewButton); 6905 } 6906 }, 5000); 6907 }, 6908 renderErrorMessage: function (error) { 6909 const errorDiv = document.querySelector("#productReviewErrorMessage"); 6910 errorDiv.textContent = error || "Something went wrong"; // TODO: Translate 6911 }, 6912}); 6913</script>
vendor: 1 bytes, line 6913
6913
6914<script> 6915 qb_cookieconsent.run({ 6916 current_lang: 'en', 6917 autoclear_cookies: true, 6918 cookie_expiration: 90, 6919 autoload_css: true, 6920 theme_css: '/assets/shopassets/cookieconsent/cookieconsent.css?v=231', 6921 languages: { 6922 en: { 6923 consent_modal: { 6924 title: "Vi använder cookies", 6925 description: 'Vecanis använder cookies - inte från ugnen, utan de digitala. Genom att fortsätta på sidan godkänner du användandet av cookies.', 6926 primary_btn: { 6927 text: 'Godkänn alla', 6928 role: 'accept_all' 6929 }, 6930 secondary_btn: { 6931 text: 'Inställningar', 6932 role: 'settings' 6933 } 6934 }, 6935 settings_modal: { 6936 title: 'Cookie inställningar', 6937 save_settings_btn: "Spara", 6938 accept_all_btn: "Godkänn alla", 6939 cookie_table_headers: [ 6940 { col1: "Name" }, 6941 { col2: "Domain" }, 6942 { col3: "Expiration" }, 6943 { col4: "Description" } 6944 ], 6945 blocks: qb_cookieconsent_blocks 6946 } 6947 } 6948 }, 6949 onAccept: function (cookie) { 6950 var event = new CustomEvent("QB_CookieConsentAccepted", { 6951 detail: { 6952 cookie: cookie 6953 } 6954 }); 6955 6956 document.dispatchEvent(event); 6957 }, 6958 onChange: function (cookie) { 6959 var event = new CustomEvent("QB_CookieConsentAccepted", { 6960 detail: { 6961 cookie: cookie 6962 } 6963 }); 6964 6965 document.dispatchEvent(event); 6966 }, 6967 }); 6968 6969</script>
6969 6970
6971<script type="text/javascript">// Autocomplete Search App loaded 6972</script>
6972<script type="text/javascript">window.registerApp({ 6973 name: 'Recaptcha', 6974 identifier: 'QuickbutikRecaptcha', 6975 description: 'Recaptcha', 6976 version: '1.1.0', 6977 pages: ['contact'], 6978 data: {}, 6979 config: {}, 6980 onMount: function ({ appData, page }) { 6981 console.debug(`${this.identifier}@onMount`) 6982 6983 let form = document.querySelector('form[role=form]') 6984 if (!form) { 6985 console.error('Aborting recaptcha, form not found.') 6986 return 6987 } 6988 6989 const createCaptchaDiv = () => { 6990 let captchaDiv = document.createElement('div'); 6991 captchaDiv.className = 'g-recaptcha' 6992 captchaDiv.dataset.sitekey = recaptchaSiteKey 6993 captchaDiv.style.marginBottom = '15px' 6994 captchaDiv.style.overflow = 'hidden' 6995 return captchaDiv 6996 } 6997 6998 let button = form.querySelector('button'); 6999 let inputSubmit = form.querySelector('input[type=submit]'); 7000 if (button) { 7001 button.insertAdjacentElement('beforebegin', createCaptchaDiv()) 7002 } else if (inputSubmit) { 7003 inputSubmit.insertAdjacentElement('beforebegin', createCaptchaDiv()) 7004 } 7005 7006 // Honeypot for contact page 7007 let contactForm = document.querySelector('form[role=form]#contact-form') 7008 if (contactForm) { 7009 let pottyDiv = document.createElement('div') 7010 pottyDiv.style.position = 'absolute' 7011 pottyDiv.style.left = '-5000px' 7012 pottyDiv.setAttribute('aria-hidden', true) 7013 7014 let codeLabel = document.createElement('label') 7015 codeLabel.innerText = 'Code' 7016 codeLabel.for = 'code' 7017 codeLabel.className = contactForm.querySelector('label').className 7018 codeLabel.setAttribute('aria-hidden', true) 7019 pottyDiv.appendChild(codeLabel) 7020 7021 let codeInput = document.createElement('input') 7022 codeInput.type = 'text' 7023 codeInput.id = 'code' 7024 codeInput.name = 'code' 7025 codeInput.tabIndex = -1 7026 codeInput.autocomplete = 'off' 7027 codeInput.className = contactForm.querySelector('input[name="name"]').className 7028 codeInput.setAttribute('aria-hidden', true) 7029 pottyDiv.appendChild(codeInput) 7030 7031 contactForm.querySelector('textarea').insertAdjacentElement('afterend', pottyDiv) 7032 } 7033 7034 // Inject Recaptcha script 7035 let existingScript = document.querySelector('script[src="https://www.google.com/recaptcha/api.js"]') 7036 if (!existingScript) { 7037 let script = document.createElement('script') 7038 script.src = 'https://www.google.com/recaptcha/api.js' 7039 script.type = 'text/javascript' 7040 script.async = true 7041 document.querySelector('head').appendChild(script) 7042 } 7043 7044 }, 7045}) 7046</script>
7046
7046<script src="/assets/shopassets/misc/magicbean.js?v=3" data-shop-id="2666" data-url="https://magic-bean.services.quickbutik.com/v1/sow" data-site-section="1" data-key="1239533aeb0308a5c546278f39963e04a28c0cc3987c54b70d8d044791e12102" data-timestamp="1791438375" defer></script>
7046<script> 7047 document.addEventListener('QB_CookieConsentAccepted', function(event) { 7048 const cookie = qbGetEvent(event).cookie; 7049 7050 const getConsentLevel = (level) => (cookie && cookie.level && cookie.level.includes(level)) ? 'granted' : 'denied'; 7051 7052 gtag('consent', 'update', { 7053 'analytics_storage': getConsentLevel('analytics'), 7054 'ad_storage': getConsentLevel('marketing'), 7055 'ad_user_data': getConsentLevel('marketing'), 7056 'ad_personalization': getConsentLevel('marketing'), 7057 }); 7058 }); 7059 7060 document.addEventListener('QB_CartAdded', function(e) { 7061 const itemData = qbGetEvent(e).itemData; 7062 7063 // item.id is the CART-LINE key — an md5 of the line (see 7064 // Model_Shop_Basketnew), unique per product+variant+options and 7065 // meaningless outside this basket. item.raw_id is the product id, 7066 // which is what view_item sends, what <g:id> carries in the Google 7067 // Shopping feed, and what GA4 needs to join the item funnel. 7068 let eventData = { 7069 currency: 'SEK', 7070 value: parseFloat(itemData.item.price_raw), 7071 items: [{ 7072 id: itemData.item.raw_id || itemData.item.id, 7073 item_id: itemData.item.raw_id || itemData.item.id, 7074 name: itemData.item.title.replaceAll('"', ''), 7075 item_name: itemData.item.title.replaceAll('"', ''), 7076 affiliation: `Vecanis`, 7077 ...(itemData.variant_name && { 7078 name: itemData.item.title.replaceAll('"', '') + ' ' + itemData.variant_name.replaceAll('"', ''), 7079 item_name: itemData.item.title.replaceAll('"', '') + ' ' + itemData.variant_name.replaceAll('"', ''), 7080 variant: itemData.variant_name.replaceAll('"', ''), 7081 item_variant: itemData.variant_name.replaceAll('"', ''), 7082 }), 7083 ...(itemData.item.item_category && { 7084 item_category: itemData.item.item_category.replaceAll('"', ''), 7085 }), 7086 price: parseFloat(itemData.item.price_raw), 7087 quantity: itemData.qty 7088 }], 7089 }; 7090 'undefined' !== typeof gtag && gtag('event', 'add_to_cart', eventData); 7091 }); 7092</script>
7092<script type="text/javascript"> 7093 if(qb_cookieconsent.allowedCategory('analytics')) 7094 { 7095 !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]); 7096 posthog.init('phc_mPzWNLM3JN9i34tiiQ6LlKfr6uoMArp0NOwHdMLOg63',{api_host:'https://eu.i.posthog.com'}) 7097 } 7098 </script>
7098<script id="mcjs">if (typeof qb_cookieconsent == 'undefined' || qb_cookieconsent.allowedCategory('marketing')) { !function(c,h,i,m,p){m=c.createElement(h),p=c.getElementsByTagName(h)[0],m.async=1,m.src=i,p.parentNode.insertBefore(m,p)}(document,"script","https://chimpstatic.com/mcjs-connected/js/users/172f2db97a5ff2a4bd7008db8/c92311638d4901fb3786b162f.js"); } </script>
7098<script>;(function () { 7099 if (window.parent === window) return; 7100 7101 const urlParams = new URLSearchParams(window.location.search); 7102 if (urlParams.has('_qbdev')) { 7103 try { sessionStorage.setItem('_qbdev', '1'); } catch(e) {} 7104 } 7105 try { if (!sessionStorage.getItem('_qbdev')) return; } catch(e) { return; } 7106 7107 const report = () => { 7108 try { 7109 const params = new URLSearchParams(window.location.search); 7110 params.delete('_qbdev'); 7111 params.delete('_qbtid'); 7112 const qs = params.toString(); 7113 window.parent.postMessage( 7114 { type: 'navigation', path: window.location.pathname + (qs ? '?' + qs : '') }, 7115 '*' 7116 ); 7117 } catch (e) {} 7118 }; 7119 7120 report(); 7121 7122 const { pushState, replaceState } = history; 7123 history.pushState = function (...args) { 7124 const result = pushState.apply(this, args); 7125 report(); 7126 return result; 7127 }; 7128 history.replaceState = function (...args) { 7129 const result = replaceState.apply(this, args); 7130 report(); 7131 return result; 7132 }; 7133 window.addEventListener('popstate', report); 7134})()</script>
vendor: 1 bytes, line 7134
7134
7135<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v4bc70e2c01a94c73b74392e4234840661791215815920" integrity="sha512-L0ha0OXavK/8okipN9F8BtP84dg9DUhPERbBXzwI6dgTA55d2+yweo3pn5CSFYs45/r8md2+xvUPtTdvTNRfjA==" data-cf-beacon='{"version":"2024.11.0","token":"7a304e0c4e854d16bca3b20c50bbf8db","spa":2}' crossorigin="anonymous"></script>
7135 7136</body> 7137 7138</html>
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.