1<!DOCTYPE html> 2<html lang="sv"> 3 4<head><meta property="ix:host" content="cdn.quickbutik.com/images"> 5 <title>Kontakta oss - Säkra forum</title> 6 <meta charset="utf-8"> 7 8 <meta name="author" content="Quickbutik"> 9 <meta name="description" content="Kontakta oss - Säkra forum"> 10 11 <meta property="og:description" content="Kontakta oss - Säkra forum"> 12 <meta property="og:title" content="Kontakta oss - Säkra forum"> 13 <meta property="og:url" content="https://sakraforum.se/contact"> 14 <meta property="og:site_name" content="Säkra forum"> 15 <meta property="og:type" content="website"> 16 <meta property="og:image" content=""> 17 18 <meta property="twitter:card" content="summary"> 19 <meta property="twitter:title" content="Säkra forum"> 20 <meta property="twitter:description" content="Kontakta oss - Säkra forum"> 21 <meta property="twitter:image" content=""> 22 23 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 24 25 <link rel="canonical" href="https://sakraforum.se/contact"> 26 27 <!-- Google Fonts --> 28 29 30 31 <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 32 <link rel="preload" href="https://fonts.googleapis.com/css?family=Lato:400,700%7CMerriweather:400,600,700&display=swap" as="style"> 33 <link href="https://fonts.googleapis.com/css?family=Lato:400,700%7CMerriweather:400,600,700&display=swap" rel="stylesheet" type="text/css" media="all"> 34 35 <!-- Preload images --> 36 <link rel="preload" as="image" href="https://cdn.quickbutik.com/images/100911C/templates/lejon-28/assets/logo.png?s=79548&auto=format&fit=clip&h=160&w=300"> 37 38 <!-- Favicon --> 39 <link rel="icon" type="image/png" href="https://cdn.quickbutik.com/images/100911C/templates/lejon-28/assets/favicon.png?s=79548&auto=format"> 40 41 <!-- Components --> 42
42<script defer src="https://storage.quickbutik.com/templates/lejon/js/settings.js?79548"></script>
42 43
43<script defer src="https://storage.quickbutik.com/templates/lejon/js/tailwind-styling.js?79548"></script>
43 44 45
45<script src="https://storage.quickbutik.com/templates/lejon/js/appsOverrides.js?79548"></script>
45 46 47 <!-- Quickbutik --> 48
49<script type="text/javascript">var qs_store_url = "https://sakraforum.se";</script>
vendor: 1 bytes, line 49
49
50<script type="text/javascript">var qs_store_apps_data = {"location":"\/contact","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 50
50
51<script defer type="text/javascript" src="/assets/qb_essentials_alpine.js?v=20260819a"></script>
51 52 53 <!-- Libraries --> 54
54<script defer type="text/javascript" src="/assets/imgix.min.js?v=290323"></script>
54 55
55<script defer src="https://storage.quickbutik.com/templates/lejon/js/swiper-bundle.min.js?79548"></script>
55 56
56<script defer src="https://storage.quickbutik.com/templates/lejon/js/photoswipe-lightbox.umd.min.js?79548"></script>
56 57
57<script defer src="https://storage.quickbutik.com/templates/lejon/js/photoswipe.umd.min.js?79548"></script>
57 58 59 <!-- Default parameters for Imgix --> 60
60<script>document.addEventListener("DOMContentLoaded", () => { imgix.config.defaultParams = { auto: 'format' } })</script>
60 61 62 <!-- Scripts in head --> 63 <meta name="google-site-verification" content="8p52JvAfHwT4Le0y1Cb1ZN8LEfTucgPO0uuSICutCBw" /> 64 65 <!-- Stylesheet --> 66 <style>[x-cloak]{display: none !important;}img[ix-sizes="auto"]{display:block;width:100%;}img[src=""],img[src="#"],img:not([src]){visibility: hidden;}</style> 67 <link rel="preload" href="https://storage.quickbutik.com/templates/lejon/css/swiper-bundle.min.css?79548" as="style" onload="this.onload=null;this.rel='stylesheet'"> 68 <noscript><link rel="stylesheet" href="https://storage.quickbutik.com/templates/lejon/css/swiper-bundle.min.css?79548"></noscript> 69 <link rel="preload" href="https://storage.quickbutik.com/templates/lejon/css/photoswipe.css?79548" as="style" onload="this.onload=null;this.rel='stylesheet'"> 70 <noscript><link rel="stylesheet" href="https://storage.quickbutik.com/templates/lejon/css/photoswipe.css?79548"></noscript> 71 <link type="text/css" rel="stylesheet" href="https://storage.quickbutik.com/stores/100911C/templates/lejon-28/css/theme.css?79548">
72<script type="text/javascript" defer>document.addEventListener('DOMContentLoaded', () => { 73 document.body.setAttribute('data-qb-page', 'contact'); 74});</script>
74<script type="text/javascript" defer>(function () { 75 const MAX_GET_QUERY_LENGTH = 2000; 76 77 window.apps = window.apps ?? {}; 78 79 window.registerApp = function (app) { 80 console.debug('registerApp', app); 81 window.apps[app.identifier] = app; 82 }; 83 84 document.addEventListener('DOMContentLoaded', () => { 85 const page = document.body.dataset?.qbPage; 86 const bodyFormData = new FormData(); 87 88 const productIds = [ 89 ...new Set( 90 [...document.querySelectorAll('[data-qb-selector="product-item"]')] 91 .map((product) => product.dataset.productId?.trim()) 92 .filter(Boolean) 93 ), 94 ]; 95 96 bodyFormData.set('location', window.location.pathname + window.location.search); 97 bodyFormData.set('products', productIds.join(',')); 98 bodyFormData.set( 99 'category_id', 100 document.querySelector('[data-qb-category-id]')?.dataset?.qbCategoryId || '' 101 ); 102 bodyFormData.set( 103 'product_id', 104 ['product'].includes(page) && document.querySelector('[data-product-id]')?.dataset?.productId || '' 105 ); 106 107 bodyFormData.set('fetch', 'fetch'); 108 bodyFormData.set('order', ''); 109 bodyFormData.set('search', ''); 110 bodyFormData.set('recentPurchaseId', ''); 111 112 const visitedProductId = bodyFormData.get('product_id'); 113 if (visitedProductId) { 114 const visit = new URLSearchParams({ product_id: visitedProductId }); 115 if (!(navigator.sendBeacon && navigator.sendBeacon(qs_store_url + '/apps/visit', visit))) { 116 fetch(qs_store_url + '/apps/visit', { method: 'post', body: visit, keepalive: true }).catch(() => {}); 117 } 118 } 119 120 const query = [...bodyFormData.entries()] 121 .map(([key, value]) => 122 encodeURIComponent(key) + '=' + 123 (key === 'products' ? productIds.map(encodeURIComponent).join(',') : encodeURIComponent(value)) 124 ) 125 .join('&'); 126 127 const postApps = () => { 128 const postData = new FormData(); 129 bodyFormData.forEach((value, key) => postData.append(key, value)); 130 postData.set('variants', JSON.stringify(typeof qs_options !== 'undefined' ? qs_options : [])); 131 132 return fetch(qs_store_url + '/apps/fetch', { method: 'post', body: postData }).then((res) => res.json()); 133 }; 134 135 const appsResponse = 136 query.length <= MAX_GET_QUERY_LENGTH 137 ? fetch(qs_store_url + '/apps/fetch?' + query, { method: 'get' }) 138 .then((res) => { 139 if (!res.ok || !res.headers.get('X-QB-Apps-Fetch')) { 140 throw new Error('GET /apps/fetch not accepted (' + res.status + ')'); 141 } 142 143 return res.json(); 144 }) 145 .catch(postApps) 146 : postApps(); 147 148 appsResponse 149 .then((data) => { 150 console.debug('Fetch apps success', data); 151 152 if (typeof Alpine === 'undefined') { 153 document.addEventListener('alpine:init', () => { 154 initApps(data); 155 }) 156 } else { 157 initApps(data); 158 } 159 }) 160 .catch((error) => { 161 console.debug('Fetch apps error', error); 162 }); 163 }); 164 165 function initApps(appData) { 166 const page = document.querySelector('[data-qb-page]')?.dataset?.qbPage; 167 168 console.debug('initApps', appData, page, window.apps); 169 170 if (page) { 171 Object.keys(window.apps).forEach((appIdentifier) => { 172 if (window.apps[appIdentifier].pages.includes(page)) { 173 let appOverrides = window?.appsOverrides?.[appIdentifier]; 174 175 if (appOverrides) { 176 window.apps[appIdentifier] = merge( 177 window.apps[appIdentifier], 178 appOverrides 179 ); 180 } 181 182 window.apps[appIdentifier].onMount({ appData, page }); 183 } 184 }); 185 } 186 } 187 188 function isObject(obj) { 189 return obj !== null && typeof obj === 'object'; 190 } 191 192 function merge(target, source) { 193 if (!isObject(target) || !isObject(source)) { 194 throw new Error('Both target and source must be objects.'); 195 } 196 197 for (const key in source) { 198 if (source.hasOwnProperty(key)) { 199 const targetValue = target[key]; 200 const sourceValue = source[key]; 201 202 if (isObject(sourceValue) && isObject(targetValue)) { 203 target[key] = merge(targetValue, sourceValue); 204 } else { 205 target[key] = sourceValue; 206 } 207 } 208 } 209 210 return target; 211 } 212})(); 213</script>
213<script src="/assets/shopassets/cookieconsent/cookieconsent.js?v=230"></script>
213 214<style> 215 :root { 216 --cc-btn-primary-bg: #303E80 !important; 217 --cc-btn-primary-text: #fff !important; 218 --cc-btn-primary-hover-bg: #303E80 !important; 219 --cc-btn-primary-hover-text: #fff !important; 220 } 221</style> 222<style> 223 html.force--consent, 224 html.force--consent body{ 225 height: auto!important; 226 width: 100vw!important; 227 } 228</style> 229 230
230<script type="text/javascript"> 231 var qb_cookieconsent = initCookieConsent(); 232 var qb_cookieconsent_blocks = [{"title":"Vi anv\u00e4nder cookies","description":"Vi anv\u00e4nder cookies f\u00f6r att anpassa det inneh\u00e5ll som visas f\u00f6r dig och f\u00f6r att du ska f\u00e5 b\u00e4sta t\u00e4nkbara upplevelse n\u00e4r du handlar hos oss."},{"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}}]; 233</script>
233 234<link rel="stylesheet" href="https://storage.quickbutik.com/stores/100911C/templates/lejon-28/css/tailwind.css?79548" /> <!-- Google Analytics 4 -->
vendor: 68 bytes, lines 234-235
234 235 <script async src="https://www.googletagmanager.com/gtag/js?id=
235G-E5QEP6118N
vendor: 16 bytes, lines 235-236
235"></script> 236
236<script> 237 window.dataLayer = window.dataLayer || []; 238 function gtag(){dataLayer.push(arguments);} 239 240 gtag('consent', 'default', { 241 'analytics_storage': 'denied', 242 'ad_storage': 'denied', 243 'ad_user_data': 'denied', 244 'ad_personalization': 'denied', 245 'wait_for_update': 500, 246 }); 247 248 // Update consent if already granted (returning visitor with cc_cookie) 249 (function(){ 250 try { 251 var m = document.cookie.match(/(?:^|;\s*)cc_cookie=([^;]+)/); 252 if (m) { 253 var c = JSON.parse(decodeURIComponent(m[1])); 254 if (c && c.level) { 255 var hasAnalytics = c.level.indexOf('analytics') !== -1; 256 var hasMarketing = c.level.indexOf('marketing') !== -1; 257 if (hasAnalytics || hasMarketing) { 258 gtag('consent', 'update', { 259 'analytics_storage': hasAnalytics ? 'granted' : 'denied', 260 'ad_storage': hasMarketing ? 'granted' : 'denied', 261 'ad_user_data': hasMarketing ? 'granted' : 'denied', 262 'ad_personalization': hasMarketing ? 'granted' : 'denied', 263 }); 264 } 265 } 266 } 267 } catch(e) {} 268 })(); 269 270 // Handle consent updates (user clicks Accept) 271 document.addEventListener('QB_CookieConsentAccepted', function(event) { 272 var cookie = qbGetEvent(event).cookie; 273 var getConsentLevel = function(level) { return (cookie && cookie.level && cookie.level.includes(level)) ? 'granted' : 'denied'; }; 274 275 gtag('consent', 'update', { 276 'analytics_storage': getConsentLevel('analytics'), 277 'ad_storage': getConsentLevel('marketing'), 278 'ad_user_data': getConsentLevel('marketing'), 279 'ad_personalization': getConsentLevel('marketing'), 280 }); 281 }); 282 283 gtag('js', new Date()); 284 gtag('config', 'G-E5QEP6118N', { 'allow_enhanced_conversions' : true } ); 285 286 const gaClientIdPromise = new Promise(resolve => { 287 gtag('get', 'G-E5QEP6118N', 'client_id', resolve); 288 }); 289 const gaSessionIdPromise = new Promise(resolve => { 290 gtag('get', 'G-E5QEP6118N', 'session_id', resolve); 291 }); 292 293 Promise.all([gaClientIdPromise, gaSessionIdPromise]).then(([gaClientId, gaSessionId]) => { 294 var checkoutButton = document.querySelector("a[href='/cart/index']"); 295 if (checkoutButton) { 296 checkoutButton.onclick = function() { 297 var ccLevel = []; 298 try { 299 var m = document.cookie.match(/(?:^|;\s*)cc_cookie=([^;]+)/); 300 if (m) { ccLevel = JSON.parse(decodeURIComponent(m[1])).level || []; } 301 } catch(e) {} 302 var hasAnalytics = ccLevel.indexOf('analytics') !== -1; 303 var hasMarketing = ccLevel.indexOf('marketing') !== -1; 304 var query = new URLSearchParams({ 305 ...(hasAnalytics ? { gaClientId, gaSessionId } : {}), 306 consentCategories: [ 307 ...(hasAnalytics ? ['analytics'] : []), 308 ...(hasMarketing ? ['marketing'] : []), 309 ], 310 }); 311 this.setAttribute("href", "/cart/index?" + query.toString()); 312 }; 313 } 314 }); 315 </script>
315 316 <!-- End Google Analytics 4 --></head> 317 318 319 320 321 322<body class="antialiased bg-body text-body font-body"> 323 <header 324 data-qb-area="header" data-qb-element="" 325 id="main-nav" 326 x-data="{navMenuOpen: false}" 327 @keydown.escape="navMenuOpen = false; $store.cart.hideCart(); $store.settings.hideSettings();" 328 class="sticky top-0 z-50 bg-header shadow-default"> 329 330 <section x-data class="w-full" data-qb-element="notifications"> 331 <template x-for="notification in $store.notifications.notifications" :key="notification.id"> 332 <div x-show="notification.visible" x-on:click="$store.notifications.removeNotification(notification.id)" 333 x-transition:enter='transition ease-out duration-default transform' 334 x-transition:enter-start='opacity-0 -translate-y-4' 335 x-transition:enter-end='opacity-100 translate-y-0' 336 x-transition:leave='transition ease-in duration-default transform' 337 x-transition:leave-start='opacity-100 translate-y-0' 338 x-transition:leave-end='opacity-0 -translate-y-4' 339 > 340 <div class="relative w-full px-4 sm:px-8 py-4 bg-black" :style="{'background-color': notification.backgroundColor || '#000'}"> 341 <div class="relative z-10 flex items-center justify-between -m-2"> 342 <div class="flex-1 p-2" :style="{'color': notification.textColor || '#fff'}"> 343 <template x-if="notification.message"> 344 <h3 class="font-heading font-semibold text-lg" x-html="notification.message"></h3> 345 </template> 346 <template x-if="notification.htmlContent"> 347 <div x-html="notification.htmlContent"></div> 348 </template> 349 </div> 350 <div class="p-2"> 351 <button 352 class="w-8 h-8 flex items-center justify-center rounded-full hover:opacity-75 transition-opacity duration-200 cursor-pointer" 353 :style="{'color': notification.textColor || '#fff'}" 354 x-on:click.stop="$store.notifications.removeNotification(notification.id)" 355 aria-label="Close notification" 356 > 357 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 358 <path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 359 </svg> 360 </button> 361 </div> 362 </div> 363 </div> 364 </div> 365 </template> 366 </section> 367 368 <nav class="text-navbar"> 369 <div class="container py-2 sm:py-4 mx-auto"> 370 <div class="flex items-center justify-between relative min-h-10 md:h-20"> 371 372 <!-- Logo (left-aligned) --> 373 <div class="flex grow items-center justify-start"> 374 <a data-qb-element="use_logo" href="https://sakraforum.se" class="content-center transition-all duration-200"> 375 <img src="https://cdn.quickbutik.com/images/100911C/templates/lejon-28/assets/logo.png?s=79548&auto=format&fit=clip&h=160&w=400&dpr=2" alt="Säkra forum" class="transition-all duration-200 h-10 md:h-20 object-contain object-left pr-4 w-full md:max-w-[400px]"> 376 </a> 377 </div> 378 379 <!-- Logo (centered, absolute positioning) --> 380 381 <!-- Smaller menu --> 382 <div data-qb-element="desktop_nav_large" class="hidden lg:flex grow shrink items-center justify-end"> 383 <div class="overflow-y-auto"> 384 385 386 387 <ul 388 x-data="{ navbarLevelsDepth: 3 }" 389 class="flex flex-wrap justify-end "> 390 391 392 <li class="mx-2"> 393 <a href="/" class="flex justify-center py-2 px-2 transition ease-in-out hover-opacity " 394 title="Hem"> 395 <span>Hem</span> 396 </a> 397 </li> 398 399 400 401 <li class="mx-2"> 402 <a href="/sidor/oppettider" class="flex justify-center py-2 px-2 transition ease-in-out hover-opacity " 403 title="Ãppettider">
404 <span>Ãppettider</span> 405 </a> 406 </li> 407 408 409 410 <li class="mx-2"> 411 <a href="/sidor/kopiera-nyckel-online" class="flex justify-center py-2 px-2 transition ease-in-out hover-opacity " 412 title="Hur gÃ¥r det till?"> 413 <span>Hur gÃ¥r det till?</span> 414 </a> 415 </li> 416 417 418 419 420 <li x-data="dropdown" @mouseover="open = navbarLevelsDepth >= 1 && true" @mouseleave="open = false" class="mx-2 group"> 421 <div class="relative flex justify-center items-center"> 422 <a href="/products" id="menu-item-2" 423 class="flex justify-center py-2 px-2 transition ease-in-out hover-opacity "> 424 <span>Webbshop</span> 425 </a> 426 <svg x-show="navbarLevelsDepth >= 1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" 427 stroke-width="1.5" stroke="currentColor" 428 class="w-4 h-4 cursor-pointer transform rotate-90 " :class="{'rotate-0' : open}"> 429 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"></path> 430 </svg> 431 </div> 432 433 <!-- First drop down --> 434 <ul x-cloak 435 x-show="open" 436 " 437 x-transition:enter='transition ease-out transform' 438 x-transition:enter-start='opacity-0 -translate-y-4' 439 x-transition:enter-end='opacity-100 translate-y-0' 440 x-transition:leave='transition ease-in transform' 441 x-transition:leave-start='opacity-100 translate-y-0' 442 x-transition:leave-end='opacity-0 -translate-y-4' 443 class="absolute z-50 w-full max-w-xs px-6 py-2 bg-body text-body shadow-default rounded-md" aria-labelledby="menu-item-2"> 444 445 446 <li><a href="/products/nyckeltillbehor" class="block py-3 transition ease-in-out hover-opacity ">Nyckeltillbehör</a></li> 447 448 449 450 <li><a href="/products/lascylindrar" class="block py-3 transition ease-in-out hover-opacity ">LÃ¥scylindrar</a></li> 451 452 453 454 <li><a href="/products/lastillbehor" class="block py-3 transition ease-in-out hover-opacity ">LÃ¥stillbehör</a></li> 455 456 457 458 <li><a href="/products/nycklar" class="block py-3 transition ease-in-out hover-opacity ">Nycklar</a></li> 459 460 461 462 <li><a href="/products/hanglas" class="block py-3 transition ease-in-out hover-opacity ">HänglÃ¥s</a></li> 463 464 </ul> 465 </li> 466 467 468 </ul> 469 </div> 470 </div> 471 472 <div class="flex flex-none items-center justify-end"> 473 474 <!-- Search --> 475 <button data-qb-element="show_search" @click="$store.search.showSearch(); $nextTick(() => document.getElementById('searchInput').focus());" 476 aria-label="Sök" class="px-2 hover:text-primary transition duration-200 focus:outline-none"> 477 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 sm:w-7 sm:h-7"> 478 <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" /> 479 </svg> 480 </button> 481 482 <!-- Login --> 483 484 <!-- Wishlist --> 485 486 <!-- Settings --> 487 488 <button @click="$store.settings.showSettings()" aria-label="Inställningar" 489 class="hidden lg:block px-2 hover:text-primary transition duration-200 focus:outline-none" 490 style="display:none"> 491 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 sm:w-7 sm:h-7"> 492 <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.
492074.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" /> 493 <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> 494 </svg> 495 </button> 496 497 <!-- Cart --> 498 <button @click="$store.cart.toggleCart()" aria-label="Varukorg" 499 class="relative px-2 hover:text-primary transition duration-200 focus:outline-none"> 500 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 sm:w-7 sm:h-7"> 501 <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.5h12.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> 502 </svg> 503 504 <!-- Cart bubble --> 505 <template x-if="$store.cart.totalQuantity > 0"> 506 <span x-text="$store.cart.totalQuantity" class="cart-bubble absolute flex items-center justify-center -top-2.5 lg:-top-1.5 -right-0.5 h-6 w-6 border-2 border-header bg-primary text-white font-bold text-xs rounded-full"></span> 507 </template> 508 509 </button> 510 511 <!-- Mobile menu button --> 512 <div class="flex justify-items-start -mr-2 lg:hidden"> 513 <button @click="navMenuOpen = !navMenuOpen" aria-label="Menu" 514 class="navbar-burger py-3 px-2 sm:px-4 hover-opacity transition duration-200 focus:outline-none"> 515 <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"> 516 <path fill-rule="evenodd" clip-rule="evenodd" 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" fill="currentColor"></path> 517 </svg> 518 </button> 519 </div> 520 521 </div> 522 523 </div> 524 </div> 525 </nav> 526 527 <!-- Larger menu --> 528 529 <!-- Menu --> 530 <div 531 x-cloak 532 @keydown.escape="navMenuOpen = false" 533 x-show="navMenuOpen" 534 class="navbar-menu fixed top-0 left-0 bottom-0 w-full sm:max-w-sm z-40"> 535 536 <!-- Overlay --> 537 <div 538 x-show='navMenuOpen' 539 @click='navMenuOpen = false' 540 x-transition:enter='transition ease-out duration-default transform' 541 x-transition:enter-start='opacity-0' 542 x-transition:enter-end='opacity-100' 543 x-transition:leave='transition ease-in duration-default transform' 544 x-transition:leave-start='opacity-100' 545 x-transition:leave-end='opacity-0' 546 class='fixed inset-0 transition-opacity bg-overlay bg-opacity-40' 547 aria-hidden='true' 548 ></div> 549 550 <!-- Menu --> 551 <nav 552 x-show='navMenuOpen' 553 @keydown.escape="navMenuOpen = false" 554 class="relative flex flex-col h-full bg-body" 555 x-transition:enter='transition ease-out duration-default transform' 556 x-transition:enter-start='opacity-0 -translate-x-10 sm:-translate-x-10' 557 x-transition:enter-end='opacity-100 translate-x-0' 558 x-transition:leave='transition ease-in duration-default transform' 559 x-transition:leave-start='opacity-100 translate-x-0' 560 x-transition:leave-end='opacity-0 -translate-x-10 sm:-translate-x-10' 561 > 562 <div class="flex items-center justify-between mb-12 p-6"> 563 564 <!-- Logo --> 565 <a class="items-center" href="https://sakraforum.se"> 566 567 <img alt="Säkra forum" class="img-fluid img-logo object-contain object-left w-full h-10 pr-8" 568 ix-src="https://cdn.quickbutik.com/images/100911C/templates/lejon-28/assets/logo.png?s=79548&auto=format"> 569 </a> 570 571 <!-- Close button --> 572 <button @click="navMenuOpen = false" class="inline-block navbar-close focus:outline-none" type="button" 573 aria-label="Close"> 574 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" 575 stroke="currentColor" class="w-6 h-6"> 576 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path> 577 </svg></button> 578 </div> 579 580 <!-- Menu --> 581 <div class="px-6 overflow-y-auto"> 582 583 <ul> 584 585 <li class=""> 586 <a class="block py-3 font-bold hover:text-gray-900 " href="/" 587 title="Hem">
588 <span>Hem</span> 589 </a> 590 </li> 591 592 593 594 <li class=""> 595 <a class="block py-3 font-bold hover:text-gray-900 " href="/sidor/oppettider" 596 title="Ãppettider"> 597 <span>Ãppettider</span> 598 </a> 599 </li> 600 601 602 603 <li class=""> 604 <a class="block py-3 font-bold hover:text-gray-900 " href="/sidor/kopiera-nyckel-online" 605 title="Hur gÃ¥r det till?"> 606 <span>Hur gÃ¥r det till?</span> 607 </a> 608 </li> 609 610 611 612 613 <li x-data="{expanded:false}" class=""> 614 <div class="flex"> 615 <a href="/products" 616 class="flex flex-1 py-3 font-bold hover:text-gray-900 "> 617 <span id="navMenuItem2">Webbshop</span> 618 </a> 619 <div class="flex h-full flex-col py-3"> 620 <svg @click="expanded = !expanded" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" 621 stroke="currentColor" class="w-6 h-6 cursor-pointer transform duration-default" :class="{'rotate-90' : expanded}"> 622 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"></path> 623 </svg> 624 </div> 625 </div> 626 <ul x-show="expanded" 627 x-transition:enter='transition ease-out duration-default transform' 628 x-transition:enter-start='opacity-0 -translate-y-4' 629 x-transition:enter-end='opacity-100 translate-y-0' 630 x-transition:leave='transition ease-in duration-default transform' 631 x-transition:leave-start='opacity-100 translate-y-0' 632 x-transition:leave-end='opacity-0 -translate-y-4' 633 class="pl-4" aria-labelledby="navMenuItem2"> 634 635 636 <li><a href="/products/nyckeltillbehor" class="block py-3 ">Nyckeltillbehör</a></li> 637 638 639 640 <li><a href="/products/lascylindrar" class="block py-3 ">LÃ¥scylindrar</a></li> 641 642 643 644 <li><a href="/products/lastillbehor" class="block py-3 ">LÃ¥stillbehör</a></li> 645 646 647 648 <li><a href="/products/nycklar" class="block py-3 ">Nycklar</a></li> 649 650 651 652 <li><a href="/products/hanglas" class="block py-3 ">HänglÃ¥s</a></li> 653 654 655 </ul> 656 </li> 657 658 </ul> 659 </div> 660 661 <div class="p-6 mt-auto"> 662 663 <!-- Wishlist --> 664 665 <!-- Settings --> 666 667 <span href="" @click="$store.settings.showSettings()" class="block px-6 py-3 text-center button-rounding border border-foreground bg-transparent text-body font-bold cursor-pointer hover-opacity mb-2" 668 style="display:none"> 669 Inställningar 670 </span> 671 672 <!-- Login --> 673 674 </div> 675 </nav> 676 </div> 677 <!-- Settings modal --> 678 <template x-teleport='body'> 679 <div 680 x-data 681 x-cloak 682 x-show='$store.settings.settingsOpen' 683 @keydown.escape='$store.settings.hideSettings()' 684 class='fixed inset-0 z-50 overflow-y-auto' 685 aria-labelledby='modal-title' 686 role='dialog' 687 aria-modal='true' 688 > 689 <div class='flex items-end justify-center min-h-screen text-center md:items-center sm:block'> 690 691 <!-- Overlay --> 692 <div 693 @click='$store.settings.hideSettings()' 694 x-show='$store.settings.settingsOpen' 695 x-transition:enter='transition ease-out duration-default transform' 696 x-transition:enter-start='opacity-0' 697 x-transition:enter-end='opacity-100' 698 x-transition:leave='transition ease-in duration-default transform' 699 x-transition:leave-start='opacity-100' 700 x-transition:leave-end='opacity-0' 701 class='fixed inset-0 transition-opacity bg-overlay bg-opacity-40' 702 aria-hidden='true'></div> 703 704 <!-- Modal --> 705 <div x-show='$store.settings.settingsOpen' 706 x-transition:enter='transition ease-out duration-default transform' 707 x-transition:enter-start='opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95' 708 x-transition:enter-end='opacity-100 translate-y-0 sm:scale-100' 709 x-transition:leave='transition ease-in duration-default transform' 710 x-transition:leave-start='opacity-100 translate-y-0 sm:scale-100' 711 x-transition:leave-end='opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95' 712 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 rounded-md shadow-xl 2xl:max-w-2xl'> 713 714 <!-- Header --> 715 <header class='flex items-center justify-between p-6 mb-2'> 716 717 <!-- Title -->
718 <span class='text-h2 font-bold font-heading'>Inställningar</span> 719 720 <!-- Close button --> 721 <button @click='$store.settings.hideSettings()' class='text-gray-600 focus:outline-none hover:text-gray-700'> 722 <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"> 723 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path> 724 </svg> 725 </button> 726 </header> 727 728 <div class="px-6 pb-6 mb-6 h-full overflow-y-auto"> 729 730 <!-- Languages --> 731 732 <!-- Tax --> 733 734 <!-- Currencies--> 735 736 </div> 737 </div> 738 </div> 739 </div> 740 </template> <!-- Search modal --> 741 <template x-teleport='body'> 742 <div 743 x-data='appSearchModal' 744 x-cloak 745 x-show='$store.search.searchOpen' 746 @keydown.escape='$store.search.hideSearch()' 747 class='fixed inset-0 z-50 overflow-y-auto' 748 aria-labelledby='modal-title' 749 role='dialog' 750 aria-modal='true' 751 > 752 <div class='flex items-end justify-center min-h-screen px-4 text-center md:items-center sm:block sm:p-0'> 753 754 <!-- Overlay --> 755 <div 756 @click='$store.search.hideSearch()' 757 x-show='$store.search.searchOpen' 758 x-transition:enter='transition ease-out duration-default transform' 759 x-transition:enter-start='opacity-0' 760 x-transition:enter-end='opacity-100' 761 x-transition:leave='transition ease-in duration-default transform' 762 x-transition:leave-start='opacity-100' 763 x-transition:leave-end='opacity-0' 764 class='fixed inset-0 transition-opacity bg-overlay bg-opacity-40' 765 aria-hidden='true' 766 ></div> 767 768 <!-- Modal --> 769 <div 770 modal="search" 771 x-show='$store.search.searchOpen' 772 x-transition:enter='transition ease-out duration-default transform' 773 x-transition:enter-start='opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95' 774 x-transition:enter-end='opacity-100 translate-y-0 sm:scale-100' 775 x-transition:leave='transition ease-in duration-default transform' 776 x-transition:leave-start='opacity-100 translate-y-0 sm:scale-100' 777 x-transition:leave-end='opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95' 778 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' 779 style='@media (min-width: 576px) { max-height: calc(80vh) }' 780 > 781 <div class="pointer-events-auto relative flex w-full flex-col max-h-full overflow-hidden border-none bg-body shadow-xl"> 782 783 <!-- Header --> 784 <header class='flex flex-col flex-shrink-0 p-6'> 785 786 <div class="flex items-center justify-between pb-6"> 787 788 <!-- Title --> 789 <span class='text-h2 font-bold font-heading'>Sök</span> 790 791 <!-- Close button --> 792 <button 793 @click='$store.search.hideSearch()' 794 class='text-body focus:outline-none hover-opacity'> 795 <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'> 796 <path stroke-linecap='round' stroke-linejoin='round' d='M6 18L18 6M6 6l12 12'></path> 797 </svg> 798 </button> 799 </div> 800 801 <form 802 action='/shop/search' 803 method='get' class='flex'> 804 <div class='flex w-full relative'> 805 <div class="flex h-full flex-col justify-center absolute top-0 left-0 pl-2"> 806 <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"> 807 <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" /> 808 </svg> 809 </div> 810 <input 811 type='search' 812 name='s' 813 x-model='searchTerm' 814 id='searchInput' 815 @input.debounce.500ms="search" 816 placeholder='Sök produkter' 817 aria-label='Sök' 818 aria-describedby='button-search-desktop' 819 class='flex flex-1 py-3 pr-3 pl-14 input-bg input-text input-border border input-rou
819nding focus:outline-none' 820 > 821 </div> 822 </form> 823 </header> 824 825 <!-- No results found --> 826 <div x-show='searchTerm && noResults' class="px-6 mb-8"> 827 <p class="mt-8 text-center"> 828 Inga sökresultat hittades 829 </p> 830 </div> 831 832 <!-- Search results --> 833 <div x-show='!noResults && searchTerm != ""' class="relative overflow-y-auto pb-6"> 834 <template x-for='result in results'> 835 <a :href='result.product.url'> 836 <div class="flex py-2 px-6 transition duration-default hover-opacity"> 837 <div class="flex flex-0 w-16 h-16 mr-6"> 838 <img :src="result.product.firstimage + '?w=100&h=100'" ix-sizes="auto" :alt="result.product.title" class="w-full object-contain object-center"> 839 </div> 840 <div class="flex flex-1 flex-col justify-center"> 841 <span x-html='highlightTitle(result.product.title)'></span> 842 </div> 843 <div class="flex flex-col justify-center"> 844 <span x-text='result.product.price'></span> 845 </div> 846 </div> 847 <hr class="border-foreground opacity-10"> 848 </a> 849 </template> 850 </div> 851 852 </div> 853 854 </div> 855 </div> 856 </div> 857 </template> 858 859 </header> 860 <!-- Campaign bar --> 861 <!-- Campaign bar --> 862 <div data-qb-area="campaignbar" data-qb-setting="show_campaignbar" id="campaignbar" class="flex bg-campaignbar group"> 863 <div class="container text-center"> 864 865 866 <div class="flex justify-center items-center"> 867 <span class="block py-4 font-bold text-campaignbar"> 868 FRI FRAKT TILL DE 1000 FÃRSTA KUNDERNA! ANGE KODEN "FRIFRAKT" I KASSAN 869 </span> 870 871 <!-- Icon (arrow) --> 872 </div> 873 874 </div> 875 </div> 876 877 878 879 880 <!-- Standard layout utan sidomeny --> 881 882 <!-- Breadcrumbs --> 883 <section data-qb-area="general" data-qb-setting="show_breadcrumbs" class="py-4 bg-gray-400 bg-opacity-10 text-sm"> 884 <div class="container mx-auto"> 885 <ul class="flex flex-wrap"> 886 887 <li class="flex items-center mr-3"> 888 889 890 <a class="inline-block mr-3 opacity-70 transition duration-default hover:opacity-100" href="/">Hem</a> 891 <span class="inline-block opacity-30"> 892 / 893 </span> 894 895 </li> 896 <li class="flex items-center mr-3"> 897 898 <span class="inline-block opacity-70" href="#">Kontakta oss</span> 899 900 901 </li> 902 903 </ul> 904 </div> 905 </section> 906 <section data-qb-area="contact" x-data class="py-16"> 907 <div class="container mx-auto"> 908 <div class="w-full mx-auto max-w-3xl"> 909 910 <!-- Title --> 911 <h1 data-qb-setting="contact_title" class="mb-8 font-heading text-center text-h1">Hur kan vi hjälpa dig?</h1> 912 913 <!-- Text --> 914 915 <!-- Contact form --> 916 917 <div data-qb-setting="contact_show_form" class="mx-auto"> 918 <form role="form" id="contact-form" method="post"> 919 920 <!-- Form post success --> 921 922 923 <!-- Form post failed--> 924 925 926 <label for="name" class="block mb-2 font-bold">Namn</label> 927 <input class="block w-full input-bg input-text input-border border input-rounding input-py input-px mb-6 text-lg placeholder-gray-300" 928 type="text" name="name" id="name" required="required" placeholder="Namn"> 929 930 <label for="email" class="block font-bold mb-2">E-postadress</label> 931 <input class="block w-full input-bg input-text input-border border input-rounding input-py input-px mb-6 text-lg placeholder-gray-300" 932 type="email" name="email" id="email" required="required" placeholder="E-postadress"> 933 934 <label for="message" class="block font-bold mb-2">Meddelande</label> 935 <textarea 936 class="block w-full input-bg input-text input-border border input-rou
936nding input-py input-px mb-6 text-lg placeholder-gray-300 resize-none" 937 name="message" id="message" required="required" cols="30" rows="10" 938 placeholder="Meddelande"></textarea> 939 940 <button type="submit" name="send" value="send" class="block w-full py-3 px-8 button-text-primary font-bold bg-primary button-rounding transition duration-default">Skicka</button> 941 </form> 942 </div> 943 944 945 </div> 946 </div> 947 </section> 948 949 <!-- Contact free text --> 950 951 <!-- Contact details --> 952 <section data-qb-area="contact" class="py-24"> 953 <div class="container mx-auto"> 954 <div class="flex flex-wrap justify-center"> 955 956 957 <!-- Email --> 958 <div data-qb-setting="contact_show_contact_details" class="w-full sm:w-1/2 md:w-1/4 px-4 text-center mb-10 md:mb-0"> 959 <h3 class="text-h3 font-heading mb-4">E-postadress</h3> 960 <a href="/cdn-cgi/l/email-protection#81e8efe7eec1f2e0eaf3e0e7eef3f4ecaff2e4" class="text-primary transition duration-default hover:text-body"><span class="__cf_email__" data-cfemail="f891969e97b88b99938a999e978a8d95d68b9d">[email protected]</span></a> 961 </div> 962 963 <!-- Phone --> 964 <div data-qb-setting="contact_show_contact_details" class="w-full sm:w-1/2 md:w-1/4 px-4 text-center mb-10 md:mb-0"> 965 <h3 class="text-h3 font-heading mb-4">Telefonnummer</h3> 966 <p> 967 <a href="tel:087185505" class="text-primary transition duration-default hover:text-body">087185505</a> 968 </p> 969 </div> 970 971 972 <!-- Address --> 973 <div data-qb-setting="contact_show_address" class="w-full sm:w-1/2 md:w-1/4 px-4 text-center mb-10 md:mb-0"> 974 <h3 class="text-h3 font-heading mb-4">Adress</h3> 975 <p> 976 Forumvägen 12<br> 977 978 13153 Nacka<br> 979 980 Sverige 981 </p> 982 </div> 983 984 <!-- Social Media --> 985 <div data-qb-setting="contact_show_socialicons" class="w-full sm:w-1/2 md:w-1/4 px-4 text-center"> 986 <h3 class="text-h3 font-heading mb-4">Sociala medier</h3> 987 <div> 988 <div class="flex justify-center flex-wrap mx-auto"> 989 990 <a class="inline-block mx-2 mb-4 text-primary hover-opacity" href="https://www.facebook.com/profile.php?id=100069590035185&locale=sv_SE" target="_blank"> 991 <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24"> 992 <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"> 993 </path> 994 </svg> 995 </a> 996 <a class="inline-block mx-2 mb-4 text-primary hover-opacity" href="https://instagram.com/sakraforum" target="_blank"> 997 <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24"> 998 <path fill="currentColor" 999 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"> 1000 </path> 1001 </svg> 1002 </a> 1003 </div> 1004 1005 </div> 1006 </div> 1007 1008 </div> 1009 </div> 1010 </section> 1011 1012 1013 1014 <section data-qb-area="footer" class="bg-footer text-center lg:text-left"> 1015 <div class="container py-8 md:py-12 mx-auto "> 1016 <div class="flex flex-wrap justify-between"> 1017 1018 <!-- Footer menu --> 1019 <div class="w-full sm:w-1/3 mb-10 lg:mb-0"> 1020 1021 <h5 data-qb-setting="footer_menu_heading" class="text-h5 font-bold mb-5 text-footer"> 1022 Läs mer 1023 </h5> 1024 1025 <ul data-qb-setting="use_footer_menu" class="opacity-70"> 1026 <li class="mb-2"> 1027 <a class="inline-block text-footer hover:opacity-50" href="/sidor/terms-and-conditions">Köpvillkor</a> 1028 </li> 1029 <li class="mb-2"> 1030 <a class="inline-block text-footer hover:opacity-50" href="/contact">Kontakt</a> 1031 </li> 1032 </ul> 1033 </div> 1034 1035 1036 <!-- Social media link --> 1037 <div class="w-full sm:w-1/3 mb-10 lg:mb-0"> 1038 <div data-qb-setting="show_footer_socialicons" class="text-footer mb-3"> 1039 1040 <h5 class="text-h5 font-bold mb-5 text-footer"> 1041 Sociala medier 1042 </h5> 1043 1044 <div class="opacity-70"> 1045 1046 <a class="inline-block mr-4 hover-opacity" aria-label="Facebook" href="https://www.facebook.com/profile.php?id=100069590035185&locale=sv_SE" target="_blank"> 1047 <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewB
1047ox="0 0 24 24"> 1048 <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> 1049 </svg> 1050 </a> 1051 <a class="inline-block mr-4 hover-opacity" aria-label="Instagram" href="https://instagram.com/sakraforum" target="_blank"> 1052 <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24"> 1053 <path fill="currentColor" 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"></path> 1054 </svg> 1055 </a> 1056 </div> 1057 1058 </div> 1059 </div> 1060 1061 <!-- Newsletter signup --> 1062 1063 </div> 1064 1065 <div> 1066 <!-- Payment methods --> 1067 <div data-qb-setting="use_footer_paymentmethods" class="flex justify-center items-center gap-size flex-wrap mt-10"> 1068 1069 1070 1071 <img loading="lazy" class="h-10 p-2 w-auto bg-white/10 rounded-md" src="/assets/shopassets/paylogos/payment-visa-blue.svg" width="70" height="23" alt="Payment with Visa"> 1072 1073 <img loading="lazy" class="h-10 p-2 w-auto bg-white/10 rounded-md" src="/assets/shopassets/paylogos/payment-mastercard.svg" width="37" height="23" alt="Payment with Mastercard"> 1074 1075 <img loading="lazy" class="h-10 p-2 w-auto bg-white/10 rounded-md" src="/assets/shopassets/paylogos/payment-klarna.svg" width="41" height="23" alt="Payment with Klarna"> 1076 1077 1078 1079 1080 1081 <img loading="lazy" class="h-10 p-2 w-auto bg-white/10 rounded-md" src="/assets/shopassets/paylogos/payment-nets.svg" width="77" height="23" alt="Payment with Nets"> 1082 1083 1084 1085 1086 1087 <img loading="lazy" class="h-10 p-2 w-auto bg-white/10 rounded-md" src="/assets/shopassets/paylogos/payment-swish.png" width="75" height="23" alt="Payment with Swish"> 1088 1089 </div> 1090 </div> 1091 </div> 1092 1093 <div> 1094 <hr class="border-foreground opacity-10"> 1095 <div class="container mx-auto"> 1096 <div class="py-8 text-center"> 1097 <p class="text-footer text-sm opacity-50"> 1098 <!-- Copyright --> 1099 © 2026 Säkra forum 1100 </p> 1101 </div> 1102 </div> 1103 </div> 1104 </section> 1105 <div x-data x-cloak 1106 x-show="$store.cart.cartOpen" 1107 @keydown.escape="$store.cart.hideCart()" 1108 @pageshow.window="$store.cart.init()" 1109 class="navbar-menu fixed top-0 right-0 bottom-0 w-full sm:max-w-md z-50"> 1110 1111 <!-- Overlay --> 1112 <div 1113 x-show='$store.cart.cartOpen' 1114 @click='$store.cart.hideCart()' 1115 x-transition:enter='transition ease-out duration-default transform' 1116 x-transition:enter-start='opacity-0' 1117 x-transition:enter-end='opacity-100' 1118 x-transition:leave='transition ease-in duration-default transform' 1119 x-transition:leave-start='opacity-100' 1120 x-transition:leave-end='opacity-0' 1121 class='fixed inset-0 transition-opacity bg-overlay bg-opacity-40' 1122 aria-hidden='true' 1123 ></div> 1124 1125 <!-- Cart --> 1126 <div 1127 x-show='$store.cart.cartOpen' 1128 @keydown.escape="$store.cart.hideCart()" 1129 class="relative flex flex-col h-full bg-body" 1130 x-transition:enter='transition ease-out duration-default transform' 1131 x-transition:enter-start='opacity-0 translate-x-10 sm:translate-x-10' 1132 x-transition:enter-end='opacity-100 translate-x-0' 1133 x-transition:leave='transition ease-in duration-default transform' 1134 x-transition:leave-start='opacity-100 translate-x-0' 1135 x-transition:leave-end='opacity-0 translate-x-10 sm:translate-x-10' 1136 > 1137 <header class="flex items-center justify-between p-6"> 1138 1139 <!-- Title -->
1140 <span class="items-center font-heading text-h3"> 1141 Varukorg 1142 </span> 1143 1144 <div class="flex"> 1145 1146 <!-- Close button --> 1147 <button 1148 @click="$store.cart.hideCart()" 1149 class="flex cart-close cursor-pointer py-2 px-4 -mr-4 focus:outline-none hover-opacity" 1150 type="button" 1151 aria-label="Stäng" 1152 > 1153 <span>Stäng</span> 1154 <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 ml-2"> 1155 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path> 1156 </svg> 1157 </button> 1158 1159 </div> 1160 1161 </header> 1162 1163 <!-- Cart contents --> 1164 <div class="h-full p-6 overflow-y-auto"> 1165 1166 <!-- Cart messages --> 1167 <div class="qs-cart-message"></div> 1168 1169 <!-- Empty cart snippet--> 1170 <!-- In case of empty cart - display this snippet + remove .d-none--> 1171 <div class="hidden qs-cart-empty flex items-center h-full opacity-50 text-center"> 1172 <div> 1173 <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"> 1174 <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.5h12.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> 1175 </svg> 1176 <p> 1177 Varukorgen är tom 1178 </p> 1179 </div> 1180 </div> 1181 1182 <!-- Cart items --> 1183 <div class="grid grid-cols-1 divide-y divide-gray-400/20"> 1184 1185 <template 1186 x-for="product in $store.cart.products" 1187 :key="`${product.item.id}-${product.qty}-${product.uid}`" 1188 > 1189 <div class="bg-card shadow-default rounded-md py-4"> 1190 1191 <div class="flex w-full"> 1192 1193 <!-- Product image --> 1194 <div class="flex shrink-0 justify-center items-start pr-4 w-36"> 1195 <a :href="product.item.url"> 1196 <img loading="lazy" :src="product.item.firstimage + '?w=150&h=150'" class="object-contain object-center w-full h-full rounded-md" :alt="product.item.title"> 1197 </a> 1198 </div> 1199 <div class="flex flex-1 flex-col overflow-hidden"> 1200 1201 <div class="flex flex-col"> 1202 1203 <!-- Product title --> 1204 <a :href="product.item.url"> 1205 <h4 class="font-body font-bold" x-text="product.item.title"></h4> 1206 </a> 1207 1208 <!-- Product variant --> 1209 <template x-if="product.variant_name"> 1210 <span x-text="product.variant_name" class="text-sm opacity-50"></span> 1211 </template> 1212 1213 <!-- Product propertiers --> 1214 <template x-if="product.properties"> 1215 <template x-for="property in Object.values(product.properties)"> 1216 <small class="truncate text-ellipsis" x-text="property"></small> 1217 </template> 1218 </template> 1219 1220 </div> 1221 1222 <!-- Current price --> 1223 <span class="text-sm"> 1224 <span 1225 :class="product.item.has_discount ? 'text-discounted-price' : ''" 1226 x-text="product.item.price" 1227 ></span> 1228 1229 <!-- Original price (only show if has discount) --> 1230 <template x-if="product.item.has_discount"> 1231 <span 1232 class="opacity-50 line-through ml-1" 1233 x-text="product.item.original_price" 1234 ></span> 1235 </template> 1236 </span> 1237 1238 <div class="flex flex-1 grow-1"> 1239 <div class="flex self-end flex-1 justify-between" 1240 x-data="{ 1241 quantity: product.qty, 1242 }" 1243 x-init="$watch('quantity', Alpine.debounce((value) => { 1244 if (value && value > 0) { 1245 $store.cart.update({ 1246 productID: product.item.id, 1247 quantity: value 1248 }) 1249 } 1250 }, 500))" 1251 > 1252 <div class="flex input-bg input-text border input-border input-rou
1252nding overflow-hidden"> 1253 1254 <!-- Decrease quantity --> 1255 <button 1256 type="button" 1257 class="w-8 font-bold cursor-pointer input-bg input-text focus:outline-none hover:bg-gray-100 py-1 px-2" 1258 @click="quantity = Math.max(1, quantity - 1)" 1259 > 1260 - 1261 </button> 1262 1263 <!-- Quantity input --> 1264 <input 1265 required 1266 min="1" 1267 step="1" 1268 type="number" 1269 class="w-12 qs-cart-qty navbar-cart-product-qty input-bg input-text border-0 input-items text-center focus:outline-none" 1270 @input="quantity = Math.floor(Number($event.target.value))" 1271 x-model="quantity" 1272 > 1273 1274 <!-- Increase quantity --> 1275 <button 1276 type="button" 1277 class="w-8 font-bold cursor-pointer input-bg input-text focus:outline-none hover:bg-gray-100 py-1 px-2" 1278 @click="quantity = Number(quantity) + 1" 1279 > 1280 + 1281 </button> 1282 </div> 1283 1284 <!-- Remove from cart --> 1285 <button 1286 type="button" 1287 class="font-medium text-baseThird qs-cart-delete py-2 px-4 -mr-2 hover:text-red-700" 1288 data-pid="" 1289 @click="$store.cart.delete({ 1290 productID: product.item.id, 1291 })" 1292 > 1293 <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"> 1294 <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" /> 1295 </svg> 1296 </button> 1297 </div> 1298 </div> 1299 1300 </div> 1301 </div> 1302 1303 </div> 1304 </template> 1305 1306 </div> 1307 </div> 1308 1309 <!-- Footer --> 1310 <footer class="mt-auto p-8 bg-body"> 1311 1312 <!-- Total discount (only show if there's a discount) --> 1313 <div class="flex items-end mb-2" x-show="$store.cart.totalDiscountRaw && $store.cart.totalDiscountRaw > 0"> 1314 <div class="flex-1"> 1315 <span class="text-sm font-medium text-discounted-price">Rabatt</span> 1316 </div> 1317 <div class="flex-1 text-right"> 1318 <span 1319 class="text-sm font-medium text-discounted-price" 1320 x-text="'-' + $store.cart.totalDiscount" 1321 ></span> 1322 </div> 1323 </div> 1324 1325 <div class="flex items-end mb-4"> 1326 <div class="w-8/12"> 1327 <span class="text-sm font-bold">Total summa 1328 1329 <!-- Tax --> 1330 1331 </div> 1332 <div class="w-6/12 text-right"> 1333 1334 <!-- Cart total amount --> 1335 <span 1336 class="text-lg font-bold text-primary" 1337 x-text="$store.cart.totalAmount" 1338 ></span> 1339 </div> 1340 </div> 1341 1342 <!-- Buttons --> 1343 <div> 1344 <a href="/cart/index" class="block button-px button-py mb-2 text-center text-white font-bold bg-primary hover:bg-gray-900 button-rounding focus:outline-none"> 1345 Till kassan 1346 </a> 1347 <button @click='$store.cart.hideCart()' class="block w-full button-px button-py text-center text-primary hover:text-body button-rounding focus:outline-none"> 1348 Fortsätt att handla 1349 </button> 1350 </div> 1351 </footer> 1352 1353 </div> 1354 </div> 1355 <section 1356 x-data 1357 x-cloak 1358 x-show="$store.modal.modalVisible" 1359 x-transition.opacity 1360 class="fixed inset-0 py-6 sm:py-24 bg-black bg-opacity-50 z-50 h-full overflow-y-auto"> 1361 <div class="container mx-auto relative"> 1362 <div x-on:click.outside="$store.modal.closeModal()" :style="{'background-color': $store.modal.backgroundColor || '#fff'}" 1363 class="relative max-w-xl mx-auto text-center py-12 md:py-16 px-6 md:px-14 roun
1363ded-md"> 1364 <a class="absolute top-4 right-4 inline-block ml-auto cursor-pointer hover-opacity" x-on:click="$store.modal.closeModal()" href="#" rel="nofollow"> 1365 <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"> 1366 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path> 1367 </svg> 1368 </a> 1369 <div x-html="$store.modal.content"></div> 1370 </div> 1371 </div> 1372 </section> 1373 1374 <svg width="0" height="0" style="display:none"> 1375 <symbol xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" id="icon--building"> 1376 <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z" /> 1377 </symbol> 1378 1379 <symbol xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" id="icon--card"> 1380 <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" /> 1381 </symbol> 1382 1383 <symbol xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" id="icon--chat"> 1384 <path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" /> 1385 </symbol> 1386 1387 <symbol xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" id="icon--people"> 1388 <path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /> 1389 </symbol> 1390 1391 <symbol xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" id="icon--store"> 1392 <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 01.75-.75h3a.75.75 0 01.75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349m-16.5 11.65V9.35m0 0a3.001 3.001 0 003.75-.615A2.993 2.993 0 009.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 002.25 1.016c.896 0 1.7-.393 2.25-1.016a3.001 3.001 0 003.75.614m-16.5 0a3.004 3.004 0 01-.621-4.72L4.318 3.44A1.5 1.5 0 015.378 3h13.243a1.5 1.5 0 011.06.44l1.19 1.189a3 3 0 01-.621 4.72m-13.5 8.65h3.75a.75.75 0 00.75-.75V13.5a.75.75 0 00-.75-.75H6.75a.75.75 0 00-.75.75v3.75c0 .415.336.75.75.75z" /> 1393 </symbol> 1394 1395 <symbol xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" id="icon--truck"> 1396 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" /> 1397 </symbol> 1398 1399 <symbol xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" id="icon--verified"> 1400 <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /> 1401 </symbol> 1402 1403 <symbol xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 61 92" stroke-width="1.5" stroke="currentColor" id="icon--marker"> 1404 <path fill-rule="evenodd" d="M33.04 89.474s27.607-35.842 27.607-54.635c0-18.792-13.431-34.027-30-34.027-16.568 0-30 15.235-30 34.027 0 18.793 27.621 54.652 27.621 54.652 1.314 1.764 3.456 1.765 4.771-.017Zm-2.393-43.737c8.285 0 15-6.704 15-14.975 0-8.27-6.715-14.975-15-14.975-8.284 0-15 6.705-15 14.975s6.716 14.975 15 14.975Z"/> 1405 </symbol> 1406 1407 <symbol xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 1200 1200" stroke-width="1.5" stroke="currentColor" id="icon--marker-2"> 1408 <path d="M617.65 0c233.9 0 412.94 176.3 412.94 410.21v13.316c0 308.25-272.78 691.77-420 776.47h-3.531c-147.22-84.707-437.65-468.23-437.65-776.47v-13.312c0-233.92 196.69-410.22 430.59-410.22h17.648z"/> 1409 <path d="M616.77 28.234c222.21 0 385.58 169.26 385.58 391.47v12.648c0 292.83-252.44 654.71-392.3 739.41h-3.348c-139.85-84.707-409.05-446.59-409.05-739.41v-12.648c0-222.21 180.14-391.47 402.35-391.47h16.773z"/>
1409 1410 </symbol> 1411 1412 <symbol xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 -1 11 29" stroke-width="1.5" stroke="currentColor" id="icon--marker-3"> 1413 <path d="M6 26V10.975A5.499 5.499 0 0 0 5.5 0 5.5 5.5 0 0 0 5 10.975V26h1zM3 5.5c0 .171.018.338.051.5h-1C2.026 5.835 2 5.671 2 5.5c0-1.759 1.309-3.204 3-3.449v1C3.86 3.283 3 4.292 3 5.5z"/> 1414 </symbol> 1415 1416 <symbol xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" id="icon--chevron-left"> 1417 <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /> 1418 </symbol> 1419 1420 <symbol xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" id="icon--chevron-right"> 1421 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> 1422 </symbol> 1423 </svg>
vendor: 109 bytes, lines 1423-1424
1423 1424<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script>
1424<script type="text/javascript"> var recaptchaSiteKey="6Lc-2GcUAAAAAMXG8Lz5S_MpVshTUeESW6iJqK0_"; </script>
1424<script type="text/javascript">window.registerApp({ 1425 name: 'ProductProperties', 1426 identifier: 'QuickbutikProductProperties', 1427 description: 'Show product properties', 1428 version: '1.1.0', 1429 pages: ['product'], 1430 data: {}, 1431 config: { 1432 insertTarget: { 1433 selector: '#product-properties', 1434 position: 'afterbegin', // beforebegin | afterbegin | beforeend | afterend 1435 }, 1436 fieldsAttributes: { 1437 fileInputAccept: 'image/*,application/pdf', 1438 }, 1439 styling: { 1440 formClasses: 'flex gap-4 flex-col pb-4', 1441 inputContainerClasses: 'flex flex-col gap-2', 1442 labelClasses: 'text-sm font-bold text-gray-700', 1443 labelErrorClasses: 'ml-1 text-red-500', 1444 checkboxInputClasses: 'form-checkbox h-5 w-5 text-gray-600', 1445 shortTextInputClasses: 1446 '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', 1447 longTextInputClasses: 1448 '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', 1449 fileContainerClasses: 'flex w-full input-bg input-border input-px input-py input-rounding w-full', 1450 fileInputClasses: 'input-bg input-border input-rounding input-px input-py', 1451 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', 1452 fileInputFilenameClasses: 'truncate button-py', 1453 fileUploadProgressContainerClasses: 'block relative w-full mt-2 rounded',
1454 fileUploadProgressBarBackgroundClasses: 'block absolute w-full h-full bg-black opacity-10 text-white rounded', 1455 fileUploadProgressBarClasses: 'block relative bg-primary py-1 px-4 text-xs font-bold text-white rounded', 1456 addonPriceClasses: 'text-sm text-gray-600 ml-0', 1457 }, 1458 }, 1459 onMount: function ({ appData, page }) { 1460 console.debug(`${this.identifier}@onMount`) 1461 1462 const productID = document 1463 .querySelector('[data-product-id]') 1464 .getAttribute('data-product-id') 1465 1466 if (productID) { 1467 fetch(`${qs_store_url}/apps/productproperties/view?product_id=${productID}`) 1468 .then((response) => response.json()) 1469 .then((data) => { 1470 this.data = data 1471 this.renderProductProperties() 1472 }) 1473 .catch((error) => { 1474 console.error('Error:', error); 1475 }) 1476 } 1477 1478 }, 1479 generageInputId: function (id) { 1480 return `ProductProperties-${id}` 1481 }, 1482 generateInputName: function (id) { 1483 return `product_properties_${id}` 1484 }, 1485 renderProductProperties: function () { 1486 const productPropertiesContainer = document.querySelector(this.data.identifier) 1487 1488 const insertPosition = { 1489 before: 'beforebegin', 1490 after: 'afterend', 1491 in: 'afterbegin', 1492 }[this.data.apply] 1493 1494 if (productPropertiesContainer && insertPosition && this.data.properties) { 1495 const form = document.createElement('form') 1496 form.classList.add(...this.config.styling.formClasses.split(' ')) 1497 form.setAttribute('id', 'ProductPropertiesForm') 1498 1499 this.data.properties.forEach((property) => { 1500 const [key] = 1501 Object.entries(property) 1502 .filter(([key]) => 1503 ['isCheckbox', 'isFile', 'isLongtext', 'isShorttext', 'isAddonProduct'].includes( 1504 key 1505 ) 1506 ) 1507 .find(([_, value]) => value) || [] 1508 1509 let input = null 1510 1511 switch (key) { 1512 case 'isCheckbox': 1513 input = this.renderCheckbox(property) 1514 break 1515 case 'isFile': 1516 input = this.renderFile(property) 1517 break 1518 case 'isLongtext': 1519 input = this.renderLongtext(property) 1520 break 1521 case 'isShorttext': 1522 input = this.renderShorttext(property) 1523 break 1524 case 'isAddonProduct': 1525 input = this.renderAddonProduct(property) 1526 break 1527 default: 1528 console.log('No valid type found') 1529 } 1530 1531 if (input) { 1532 const inputContainer = document.createElement('div') 1533 inputContainer.classList.add( 1534 ...this.config.styling.inputContainerClasses.split(' ') 1535 ) 1536 inputContainer.classList.add('product-property-container') // Add a class for easier selection 1537 form.appendChild(inputContainer) 1538 1539 // Only add label for non-addon products and checkboxes 1540 if (!['isAddonProduct', 'isCheckbox'].includes(key)) { 1541 1542 // Label 1543 const label = document.createElement('label') 1544 label.classList.add(...this.config.styling.labelClasses.split(' ')) 1545 1546 // File element 1547 if (key == 'isFile') { 1548 label.setAttribute('for', 'file-' + property.id) 1549 } else { 1550 label.setAttribute('for', this.generageInputId(property.id)) 1551 } 1552 label.innerText = property.title 1553 1554 // Add addon price display next to the property title 1555 if (property.addonPriceRaw && parseFloat(property.addonPriceRaw) > 0) { 1556 const addonPriceSpan = document.createElement('span') 1557 addonPriceSpan.classList.add(...this.config.styling.addonPriceClasses.split(' ')) 1558 addonPriceSpan.classList.add('addon-price') // Add a class for easier selection 1559 addonPriceSpan.innerText = ` (+${property.addonPrice})` 1560 addonPriceSpan.setAttribute('data-addon-price', property.addonPriceRaw) 1561 label.appendChild(addonPriceSpan) 1562 } 1563 1564 if (property.required === '1') { 1565 const requiredStar = document.createElement('span') 1566 requiredStar.classList.add(...this.config.styling.labelErrorClasses.split(' ')) 1567 requiredStar.innerText = '*' 1568 label.appendChild(requiredStar) 1569 } 1570 inputContainer.appendChild(label) 1571 } 1572 1573 // All elements except files and addon products, which need specific structure and attributes 1574 if (key !== 'isFile' && key !== 'isAddonProduct') { 1575 input.setAttribute('id', this.generageInputId(property.id)) 1576 input.setAttribute('name', this.generateInputName(property.id)) 1577 input.setAttribute('data-qb-property', property.id) 1578 1579 if (property.required === '1') { 1580 input.setAttribute('required', true) 1581 } 1582 } 1583 1584 inputContainer.appendChild(input) 1585 1586 form.appendChild(inputContainer) 1587 } 1588 }) 1589 1590 productPropertiesContainer.insertAdjacentElement(insertPosition, form) 1591 1592 // Only render ReCaptcha if we have a File property 1593 if (this.data.hasFileProperties) { 1594 this.renderReCaptcha() 1595 } 1596 } 1597 }, 1598 renderCheckbox: function (property) { 1599 const checkboxWrapper = document.createElement('div') 1600 checkboxWrapper.classList.add('flex', 'items-center') 1601 1602 const checkbox = document.createElement('input') 1603 checkbox.classList.add( 1604 ...this.config.styling.checkboxInputClasses.split(' ') 1605 ) 1606 checkbox.setAttribute('type', 'checkbox') 1607 checkbox.setAttribute('name', this.generateInputName(property.id)) 1608 checkbox.setAttribute('value', '1') 1609 checkbox.setAttribute('data-qb-property', property.id) 1610 1611 // Label 1612 const label = document.createElement('label') 1613 label.classList.add(...this.config.styling.labelClasses.split(' ')) 1614 label.classList.add('ml-4') 1615 label.setAttribute('for', this.generageInputId(property.id)) 1616 label.innerText = property.title 1617 1618 if (property.required === '1') { 1619 const requiredStar = document.createElement('span') 1620 requiredStar.classList.add(...this.config.styling.labelErrorClasses.split(' ')) 1621 requiredStar.innerText = '*' 1622 label.appendChild(requiredStar) 1623 1624 checkbox.setAttribute('required', true) 1625 } 1626 1627 checkboxWrapper.appendChild(checkbox) 1628 checkboxWrapper.appendChild(label) 1629 1630 return checkboxWrapper 1631 }, 1632 renderAddonProduct: function (property) { 1633 const addonContainer = document.createElement('div') 1634 addonContainer.classList.add('addon-product-container', 'flex', 'items-center', 'p-4', 'border', 'rounded-md') 1635 1636 // Checkbox 1637 const checkbox = document.createElement('input') 1638 checkbox.classList.add( 1639 ...this.config.styling.checkboxInputClasses.split(' '), 1640 'mr-4' 1641 ) 1642 checkbox.setAttribute('type', 'checkbox') 1643 checkbox.setAttribute('name', this.generateInputName(property.id)) 1644 checkbox.setAttribute('value', '1') 1645 checkbox.setAttribute('data-qb-property', property.id) 1646 1647 if (property.required === '1') { 1648 checkbox.setAttribute('required', true) 1649 } 1650 1651 // Add checkbox to the container first 1652 addonContainer.appendChild(checkbox) 1653 1654 // Image 1655 if (property.addonProductImage) { 1656 const imageContainer = document.createElement('div') 1657 imageContainer.classList.add('w-16', 'h-16', 'mr-4') 1658 1659 const image = document.createElement('img') 1660 image.src = property.addonProductImage 1661 image.alt = property.addonProductTitle || property.title 1662 image.classList.add('w-full', 'h-full', 'object-cover', 'rounded') 1663 1664 imageContainer.appendChild(image) 1665 addonContainer.appendChild(imageContainer) 1666 } 1667 1668 // Content (Title, Price, Link) 1669 const contentContainer = document.createElement('div') 1670 contentContainer.classList.add('flex', 'flex-col', 'flex-grow') 1671 1672 const titleElement = document.createElement('span') 1673 titleElement.classList.add('font-bold', 'mb-1') 1674 titleElement.textContent = property.addonProductTitle || property.title 1675 1676 const priceElement = document.createElement('span') 1677 priceElement.classList.add('text-sm', 'text-gray-600') 1678 priceElement.textContent = '+' + (property.addonProductPrice || property.addonPrice) 1679 1680 contentContainer.appendChild(titleElement) 1681 contentContainer.appendChild(priceElement) 1682 1683 if (property.addonProductLink) { 1684 const link = document.createElement('a') 1685 link.href = property.addonProductLink 1686 link.target = '_blank' 1687 link.classList.add('text-blue-600', 'hover:underline', 'text-sm', 'mt-1') 1688 link.textContent = property.addonProductLinkText || 'Read More' 1689 contentContainer.appendChild(link) 1690 } 1691 1692 // Add content container last 1693 addonContainer.appendChild(contentContainer) 1694 1695 return addonContainer 1696 }, 1697 renderFile: function (property) { 1698 1699 // Wrapper 1700 const fileContainer = document.createElement('div') 1701 fileContainer.classList.add(...this.config.styling.fileContainerClasses.split(' ')) 1702
1703 // Input 1704 const file = document.createElement('input') 1705 file.classList.add(...this.config.styling.fileInputClasses.split(' ')) 1706 file.classList.add('qs-cart-product-file') 1707 file.setAttribute('id', 'file-' + property.id) 1708 file.setAttribute('type', 'file') 1709 file.setAttribute('name', 'product_fields_files[' + property.id + ']') 1710 file.setAttribute('accept', this.config.fieldsAttributes.fileInputAccept) 1711 if (property.required === '1') { 1712 file.setAttribute('required', true) 1713 file.setAttribute('data-is-required', true) 1714 } 1715 file.style.display = 'none' 1716 1717 file.setAttribute('data-qb-property', property.id) 1718 fileContainer.append(file) 1719 1720 const button = document.createElement('label') 1721 button.setAttribute('for', 'file-' + property.id) 1722 button.classList.add(...this.config.styling.fileInputButtonClasses.split(' ')) 1723 button.innerText = this.data.texts.chooseFile 1724 file.addEventListener('change', function () { 1725 filename.innerText = file.files[0].name 1726 }) 1727 1728 fileContainer.append(button) 1729 1730 const filename = document.createElement('span') 1731 filename.innerText = this.data.texts.noFileChosen 1732 filename.classList.add(...this.config.styling.fileInputFilenameClasses.split(' ')) 1733 fileContainer.append(filename) 1734 1735 return fileContainer 1736 }, 1737 renderLongtext: function (property) { 1738 const longtext = document.createElement('textarea') 1739 longtext.classList.add( 1740 ...this.config.styling.longTextInputClasses.split(' ') 1741 ) 1742 longtext.setAttribute('name', property.name) 1743 longtext.setAttribute('maxlength', 250) 1744 1745 return longtext 1746 }, 1747 renderShorttext: function (property) { 1748 const shorttext = document.createElement('input') 1749 shorttext.classList.add( 1750 ...this.config.styling.shortTextInputClasses.split(' ') 1751 ) 1752 shorttext.setAttribute('type', 'text') 1753 shorttext.setAttribute('name', property.name) 1754 shorttext.setAttribute('maxlength', 250) 1755 1756 return shorttext 1757 }, 1758 renderReCaptcha: function () { 1759 1760 /* PRODUCT FIELD FILE UPLOAD START */ 1761 if (document.readyState === "complete" || document.readyState === "interactive") { 1762 1763 const propertiesForm = document.querySelector('#ProductPropertiesForm') 1764 1765 if (!propertiesForm) { 1766 console.error('Could not find #ProductPropertiesForm') 1767 return false 1768 } 1769 1770 propertiesForm.dataset.prevent = '1' 1771 1772 var recaptchaDiv = document.createElement('div') 1773 recaptchaDiv.setAttribute('class', 'g-recaptcha') 1774 recaptchaDiv.setAttribute('data-sitekey', recaptchaSiteKey) 1775 recaptchaDiv.setAttribute('data-callback', 'recaptchaSubmit') 1776 recaptchaDiv.setAttribute('data-size', 'invisible') 1777 propertiesForm.prepend(recaptchaDiv) 1778 1779 var url = "https://www.google.com/recaptcha/api.js" 1780 var script = document.createElement('script') 1781 script.src = url 1782 script.type = 'text/javascript' 1783 document.getElementsByTagName('head')[0].appendChild(script) 1784 } 1785 }, 1786 uploadFiles: function () { 1787 return new Promise((resolve, reject) => { 1788 const form = document.querySelector('#ProductPropertiesForm') 1789 1790 if (form && form.querySelectorAll('input[type="file"]').length > 0) { 1791 var filesToUpload = new FormData() 1792 Array.from(form.querySelectorAll('input[type="file"]')).forEach(function (tag) { 1793 Array.from(tag.files).forEach(function (file) { 1794 filesToUpload.append(tag.name, file) 1795 }) 1796 }) 1797 1798 var buy_btn_text = null, wait_btn_text = null 1799 var buybtn_selector = document.querySelector('.qs-cart-submit') 1800 1801 var xhr = new XMLHttpRequest() 1802 xhr.open('POST', '/cart/misc', true) 1803 xhr.setRequestHeader('cache-control', 'no-cache') 1804 xhr.responseType = 'json' 1805 1806 var app = this 1807 1808 xhr.onload = function () { 1809 if (xhr.status >= 200 && xhr.status < 300) { 1810 var data = xhr.response 1811 1812 // Reset button 1813 if (buy_btn_text && buy_btn_text.length !== 0) { 1814 buybtn_selector.value = buy_btn_text 1815 buybtn_selector.innerHTML = buy_btn_text 1816 } 1817 buybtn_selector.removeAttribute('disabled') 1818 1819 // Remove progress bar 1820 if (form.querySelector('.productproperties-file-progress')) { 1821 form.querySelector('.productproperties-file-progress').remove() 1822 } 1823 1824 if (data?.properties) { 1825 if (data.properties.errors) { 1826 alert(data.properties.errors) 1827 1828 return false 1829 } else {
1830 data.properties.forEach(function (property, key) { 1831 // document.querySelector('.product-fields-file:eq('+property.property_id+')').value = property.value 1832 }) 1833 1834 document.querySelector('.qs-cart').dataset.prevent = 0 1835 document.querySelector('.qs-cart').dataset.fileuploadstatus = 1 1836 // document.querySelector('.qs-cart').submit() 1837 1838 resolve(true) 1839 } 1840 } else { 1841 1842 // Handle error 1843 console.error('No properties returned from file upload.') 1844 reject(false) 1845 } 1846 } else { 1847 1848 // Handle error 1849 console.error(xhr.statusText) 1850 1851 reject(false) 1852 } 1853 } 1854 1855 xhr.upload.onloadstart = function (e) { 1856 1857 // Progress bar 1858 if (form.querySelectorAll('.productproperties-file-progress').length == 0) { 1859 1860 // Container 1861 const progressContainer = document.createElement('div') 1862 if (app.config?.styling?.fileUploadProgressContainerClasses) { 1863 progressContainer.classList.add(...app.config.styling.fileUploadProgressContainerClasses.split(' ')) 1864 } 1865 progressContainer.classList.add('progress', 'productproperties-file-progress') 1866 1867 // Background
1868 const progressBarBackground = document.createElement('div') 1869 if (app.config?.styling?.fileUploadProgressBarBackgroundClasses) { 1870 progressBarBackground.classList.add(...app.config.styling.fileUploadProgressBarBackgroundClasses.split(' ')) 1871 } 1872 progressBarBackground.style.zIndex = 1 1873 progressContainer.appendChild(progressBarBackground) 1874 1875 // Bar 1876 const progressBar = document.createElement('div') 1877 if (app.config?.styling?.fileUploadProgressBarClasses) { 1878 progressBar.classList.add(...app.config.styling.fileUploadProgressBarClasses.split(' ')) 1879 progressBar.innerText = " " 1880 } 1881 progressBar.style.zIndex = 2 1882 progressBar.classList.add('progress-bar') 1883 progressContainer.appendChild(progressBar) 1884 1885 form.querySelectorAll('input[type="file"]')[form.querySelectorAll('input[type="file"]').length - 1].parentNode.parentNode.insertAdjacentElement('beforeEnd', progressContainer) 1886 } 1887 } 1888 1889 xhr.upload.onloadend = function (e) { } 1890 1891 xhr.upload.onprogress = function (e) { 1892 var percentComplete = Math.ceil((e.loaded / e.total) * 100) 1893 } 1894 1895 xhr.upload.onprogress = function (evt) { 1896 1897 let progressBar 1898 if (form.querySelectorAll('.productproperties-file-progress').length == 0) { 1899 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>') 1900 progressBar = form.querySelectorAll('.productproperties-file-progress') 1901 } 1902 1903 if (evt.lengthComputable) { 1904 var percentComplete = evt.loaded / evt.total 1905 percentComplete = Math.round(percentComplete * 100) 1906 1907 1908 let progressBarBar = form.querySelectorAll('.productproperties-file-progress')[0].querySelector('.progress-bar') 1909 progressBarBar.style.width = percentComplete + '%' 1910 progressBarBar.innerHTML = percentComplete + '%' 1911 } 1912 } 1913 1914 1915 xhr.onerror = function () { 1916 console.error(xhr.statusText) 1917 } 1918 1919 xhr.send(filesToUpload) 1920 1921 // output = false // set as false when this function is executed as sync. 1922 } else { 1923 return true 1924 } 1925 1926 // console.log('Returns: ' + output) 1927 // return output 1928 }) 1929 }, 1930 1931}) 1932 1933function recaptchaSubmit(token) { 1934 fetch('/apps/productproperties/recaptcha', { 1935 method: 'POST', 1936 body: 'recaptchatoken=' + token, 1937 headers: { 1938 'Content-Type': 'application/x-www-form-urlencoded' 1939 } 1940 }) 1941 .then((response) => response.json()) 1942 .then((data) => { 1943 if (data.success) { 1944 document.querySelector('#ProductPropertiesForm').dataset.prevent = 0 1945 1946 // Verification successful. Upload the file and add the item to the cart 1947 window.apps.QuickbutikProductProperties.uploadFiles().then(async (uploadSuccess) => { 1948 Alpine.store('cart').addItemToCart(window.bodyFormData) 1949 }) 1950 1951 try { 1952 // Reset after submit 1953 grecaptcha.reset() 1954 } catch (e) { 1955 console.log(e) 1956 } 1957 1958 document.querySelector('#ProductPropertiesForm').dataset.prevent = 1 1959 1960 return true 1961 } else { 1962 alert('ReCaptcha error: Please try again.') 1963 } 1964 }) 1965} 1966 1967 1968 1969</script>
1969<script type="text/javascript">window.registerApp({ 1970 name: "ProductReviews", 1971 identifier: "QuickbutikProductReviews", 1972 description: "Show product reviews", 1973 version: "1.3.0", 1974 pages: ["product", "list", "start"], 1975 data: {}, 1976 config: { 1977 containerSelector: "#productReviews", 1978 listItemContainerSelector: 1979 '[data-qb-selector="product-apps-reviews-item-reviews"]', 1980 themeBasedReviews: true, 1981 appContainerSelector: '[data-qb-selector="product-apps-reviews"]', 1982 appContainerFormSelector: '[data-qb-selector="product-apps-reviews-form"]', 1983 appContainerStarsSelector: 1984 '[data-qb-selector="product-apps-reviews-total"]', 1985 appContainerButtonSelector: 1986 '[data-qb-selector="product-apps-reviews-write-review"]', 1987 appContainerReviewsSelector: 1988 '[data-qb-selector="product-apps-reviews-items"]', 1989 star: { 1990 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", 1991 color: { 1992 active: "#F59E0B", 1993 inactive: "#E5E7EB", 1994 }, 1995 }, 1996 stars: { 1997 size: "w-5 h-5", 1998 productPageSize: "w-5 h-5", 1999 }, 2000 styling: { 2001 articleWidth: 85, 2002 classes: { 2003 headerContainer: "flex items-center justify-between py-6 border-b", 2004 addButton: 2005 "inline-flex items-center justify-center w-12 h-12 border hover:border-gray-500", 2006 headerRatingContainer: "flex items-center gap-3", 2007 headerText: "text-xl font-bold font-heading", 2008 ratingCount: "ml-2", 2009 reviewsContainer: "mt-4", 2010 reviewsArticleContainer: "flex gap-4 overflow-hidden", 2011 article: "pb-12 border-b", 2012 articleNameContainer: "flex items-center mb-4 space-x-4", 2013 articleName: "space-y-2 font-medium", 2014 articleRatingContainer: "flex items-center mb-2", 2015 articleContent: "mb-2 text-gray-500 dark:text-gray-400", 2016 articleShowMoreButton: 2017 "text-xs text-xs text-gray-500 ml-2 cursor-pointer", 2018 replyContainer: "border mt-3 ml-3 p-3", 2019 replyText: "text-xs text-gray-500 dark:text-gray-400", 2020 starsContainer: "flex flex-col gap-4", 2021 form: "my-4 flex flex-col gap-6", 2022 formRatingDiv: "flex flex-col gap-4", 2023 formRatingLabel: "text-sm font-bold text-gray-700", 2024 formGroup: "flex flex-col gap-4", 2025 textInput: 2026 "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", 2027 formSubmitButton: 2028 "block w-full cursor-pointer bg-primary text-center text-white font-bold py-5 px-8 uppercase transition duration-200 hover:opacity-70", 2029 formErrorMessageContainer: "text-red-500 text-sm font-bold", 2030 formThankYouMessageContainer: "flex flex-col gap-4 my-12", 2031 formThankYouMessageLabel: "text-sm font-bold text-gray-700", 2032 }, 2033 gradientOverlay: `linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1) 80%)`, 2034 articleFade: { 2035 background: 2036 "linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1))", 2037 classes: "h-12", 2038 }, 2039 articleMaxHeight: "100px", 2040 }, 2041 }, 2042 2043 onMount: function ({ appData, page }) { 2044 console.debug(`${this.identifier}@onMount`); 2045 2046 const bodyFormData = new FormData(); 2047 let itemSelector = ""; 2048 2049 // Respect backend flag: show_in_lists 2050 const normalizeShowInLists = (val) => { 2051 if (typeof val === "boolean") return val; 2052 if (val === 1 || val === "1" || val === "true") return true; 2053 if (val === 0 || val === "0" || val === "false") return false; 2054 return true; // default to true if missing 2055 }; 2056 this.data.showInLists = normalizeShowInLists(appData?.productreviews?.show_in_lists); 2057 2058 // Store review settings 2059 this.data.reviewSectionDescription = appData?.productreviews?.review_section_description || null; 2060 this.data.reviewTextPlaceholder = appData?.productreviews?.review_text_placeholder || null; 2061 2062 // Set star color from settings (backend resolves "primary" to actual color) 2063 if (appData?.productreviews?.star_color) { 2064 this.config.star.color.active = appData.productreviews.star_color; 2065 this.injectStarColorStyles(appData.productreviews.star_color); 2066 } 2067 2068 if (["list", "start"].includes(page)) { 2069 itemSelector = '[data-qb-selector="product-item"]'; 2070 } else if (page === "product") { 2071 // Main product + related product cards 2072 itemSelector = '[data-qb-selector="product"], [data-qb-selector="product-item"]'; 2073 } 2074 // If needed, we could scope to only main product: 2075 // if (page === "product") { itemSelector = '[data-qb-selector="product"]'; } 2076 2077 // On product page, render main product UI from top-level meta 2078 if (page === "product") { 2079 const mainProductEl = document.querySelector('[data-qb-selector="product"]'); 2080 const mainMeta = appData?.productreviews || {}; 2081 const mainProductId = mainProductEl?.getAttribute("data-product-id"); 2082 if (mainProductEl && mainProductId) { 2083 const mainProductData = { 2084 id: mainProductId, 2085 rating: mainMeta.rating,
2086 rating_decimal: mainMeta.rating_decimal, 2087 amountTotal: mainMeta.amountTotal, 2088 reviews: mainMeta.reviews, 2089 has_rated: mainMeta.has_rated, 2090 }; 2091 this.renderProductReviews(mainProductData, page, mainProductEl); 2092 } 2093 } 2094 2095 if ((page === "list" || page === "start") && !this.data.showInLists) { 2096 return; 2097 } 2098 const existingProducts = appData?.productreviews?.products; 2099 if (Array.isArray(existingProducts) && existingProducts.length > 0) { 2100 if (page === "product" && !this.data.showInLists) { 2101 // Do not render related items when disabled 2102 return; 2103 } 2104 existingProducts.forEach((productData) => { 2105 let productQuerySelector = ""; 2106 if (["list", "start"].includes(page)) { 2107 productQuerySelector = `[data-qb-selector="product-item"][data-product-id="${productData.id}"]`; 2108 } else if (page === "product") { 2109 // Only related product cards; main product handled from top-level meta above 2110 productQuerySelector = `[data-qb-selector="product-item"][data-product-id="${productData.id}"]`; 2111 } 2112 2113 document 2114 .querySelectorAll(productQuerySelector) 2115 .forEach((product) => { 2116 this.renderProductReviews(productData, page, product); 2117 }); 2118 }); 2119 return; 2120 } 2121 2122 // Get all product IDs on the page (same as productribbons) 2123 const productElements = document.querySelectorAll(itemSelector); 2124 if (productElements.length === 0) { 2125 return; 2126 } 2127 2128 let productIds = [...productElements] 2129 .map((product) => product.dataset.productId) 2130 .filter((id) => id && id !== "0"); 2131 2132 // On product page, exclude main product ID (we render it from top-level meta) 2133 if (page === "product") { 2134 const mainProductId = document.querySelector('[data-qb-selector="product"]')?.getAttribute('data-product-id'); 2135 if (mainProductId) { 2136 productIds = productIds.filter((id) => id !== mainProductId); 2137 } 2138 } 2139 2140 if (page === 'product' && !this.data.showInLists) { 2141 return; 2142 } 2143 2144 if (productIds.length === 0) { 2145 return; 2146 } 2147 2148 bodyFormData.set("products", productIds.join(",")); 2149 bodyFormData.set("currentPage", document.body.dataset?.qbPage); 2150 2151 // Fetch reviews data from endpoint (same pattern as productribbons) 2152 fetch(qs_store_url + "/apps/productreviews/fetch", { 2153 method: "POST", 2154 body: bodyFormData, 2155 }) 2156 .then((response) => response.json()) 2157 .then((data) => { 2158 console.debug( 2159 `${this.identifier}: Received data for ${ 2160 data?.products?.length || 0 2161 } products` 2162 ); 2163 2164 // Store settings if fetched 2165 if (data?.review_section_description !== undefined) { 2166 this.data.reviewSectionDescription = data.review_section_description; 2167 } 2168 if (data?.review_text_placeholder !== undefined) { 2169 this.data.reviewTextPlaceholder = data.review_text_placeholder; 2170 } 2171 2172 // Set star color from fetched settings (backend resolves "primary" to actual color) 2173 if (data?.star_color) { 2174 this.config.star.color.active = data.star_color; 2175 this.injectStarColorStyles(data.star_color); 2176 } 2177 2178 if (data?.products) { 2179 data?.products.forEach((productData) => { 2180 let productQuerySelector = ""; 2181 if (["list", "start", ].includes(page)) { 2182 productQuerySelector = `[data-qb-selector="product-item"][data-product-id="${productData.id}"]`; 2183 } else if (page === "product") { 2184 // Only related product cards; main product handled from top-level meta 2185 productQuerySelector = `[data-qb-selector="product-item"][data-product-id="${productData.id}"]`; 2186 } 2187 2188 document 2189 .querySelectorAll(productQuerySelector) 2190 .forEach((product) => { 2191 this.renderProductReviews(productData, page, product); 2192 }); 2193 }); 2194 } 2195 }) 2196 .catch((error) => { 2197 console.error( 2198 `${this.identifier}: Error fetching product reviews:`, 2199 error 2200 ); 2201 }); 2202 }, 2203 2204 injectStarColorStyles: function (starColor) { 2205 // Check if style element already exists 2206 const existingStyle = document.getElementById('qb-review-star-styles'); 2207 if (existingStyle) { 2208 // Update existing style 2209 existingStyle.textContent = `.qb-review-star-active { color: ${starColor} !important; }`; 2210 } else { 2211 // Create new style element 2212 const styleElement = document.createElement('style'); 2213 styleElement.id = 'qb-review-star-styles'; 2214 styleElement.textContent = `.qb-review-star-active { color: ${starColor} !important; }`; 2215 document.head.appendChild(styleElement); 2216 } 2217 }, 2218 2219 renderProductReviews: function (productData, page, product) { 2220 const { rating } = productData; 2221 2222 if (this.config.themeBasedReviews) { 2223 // Only render full UI for the main product container on product pages 2224 if (page === 'product' && product?.matches?.('[data-qb-selector="product"]')) { 2225 this.renderThemeBasedProductReviews(productData, page); 2226 } 2227 } else { 2228 // Traditional mode: only render full UI for the main product container 2229 if (page === 'product' && product?.matches?.('[data-qb-selector="product"]')) { 2230 this.renderTraditionalProductReviews(productData, page); 2231 } 2232 } 2233 2234 // Always render product item ratings for list/start pages 2235 // On product page, render ratings for related product cards as well 2236 if ((["list", "start"].includes(page) || page === 'product') && rating > 0 && product?.matches?.('[data-qb-selector="product-item"]')) { 2237 this.renderProductItemRating(productData, product, page); 2238 } 2239 }, 2240 2241 renderThemeBasedProductReviews: function (productData, page) { 2242 const { rating, rating_decimal, reviews, amountTotal } = productData; 2243 2244 // For product page, render full review interface 2245 if (page === "product") { 2246 // Total review score/stars (if any, in multiple places) 2247 if (rating > 0) { 2248 const containers = document.querySelectorAll( 2249 this.config.appContainerStarsSelector 2250 );
2251 if (containers) { 2252 containers.forEach((container) => { 2253 // Only add if not already populated 2254 if (container.children.length === 0) { 2255 container.appendChild( 2256 this.renderRating(parseFloat(rating_decimal ?? rating), page) 2257 ); 2258 const reviewsCount = document.createElement("div"); 2259 reviewsCount.classList.add( 2260 ...this.config.styling.classes.ratingCount.split(" ") 2261 ); 2262 reviewsCount.textContent = "(" + (amountTotal || 0) + ")"; 2263 container.appendChild(reviewsCount); 2264 } 2265 }); 2266 } 2267 } 2268 2269 // Write review form 2270 const container = document.querySelector( 2271 this.config.appContainerFormSelector 2272 ); 2273 if (container && container.children.length === 0) { 2274 // Extract language tags 2275 const language = container.getAttribute("data-language") 2276 ? JSON.parse(container.getAttribute("data-language")) 2277 : {}; 2278 2279 container.appendChild(this.renderForm(language)); 2280 } 2281 2282 // Reviews 2283 if (reviews && reviews.length > 0) { 2284 // Reviews container 2285 const reviewsContainer = document.querySelector( 2286 this.config.appContainerReviewsSelector 2287 ); 2288 if (reviewsContainer) { 2289 // Add reviews to x-data 2290 reviewsContainer.setAttribute( 2291 "x-init", 2292 "reviews = " + JSON.stringify(reviews) 2293 ); 2294 } 2295 } 2296 } 2297 }, 2298 2299 renderTraditionalProductReviews: function (productData, page) { 2300 // Only render for product page in traditional mode 2301 if (page === "product") { 2302 const selector = this.config.containerSelector; 2303 const container = document.querySelector(selector); 2304 2305 if (container && container.children.length === 0) { 2306 container.appendChild(this.renderProductReviewsUI(productData, page)); 2307 } 2308 } 2309 }, 2310 2311 renderProductReviewsUI: function (productData, page) { 2312 const { rating, rating_decimal, has_rated, reviews } = productData; 2313 2314 const mainContainer = document.createElement("div"); 2315 mainContainer.setAttribute("x-data", JSON.stringify({ showForm: false })); 2316 2317 const headerContainer = document.createElement("div"); 2318 headerContainer.classList.add( 2319 ...this.config.styling.classes.headerContainer.split(" ") 2320 ); 2321 2322 const headerRatingContainer = document.createElement("div"); 2323 headerRatingContainer.classList.add( 2324 ...this.config.styling.classes.headerRatingContainer.split(" ") 2325 ); 2326 2327 const h4 = document.createElement("h4"); 2328 h4.textContent = "Reviews"; 2329 h4.classList.add(...this.config.styling.classes.headerText.split(" ")); 2330 2331 headerRatingContainer.appendChild(h4); 2332 2333 if (rating > 0) { 2334 headerRatingContainer.appendChild( 2335 this.renderRating(parseFloat(rating_decimal ?? rating), page) 2336 ); 2337 } 2338 2339 const showRatingFormButton = document.createElement("a"); 2340 showRatingFormButton.classList.add( 2341 ...this.config.styling.classes.addButton.split(" ") 2342 ); 2343 showRatingFormButton.setAttribute("x-on:click", "showForm = !showForm"); 2344 showRatingFormButton.id = "productReviewShowFormButton"; 2345 2346 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 2347 svg.setAttribute("width", "12"); 2348 svg.setAttribute("height", "12"); 2349 svg.setAttribute("viewBox", "0 0 12 12"); 2350 svg.setAttribute("fill", "none"); 2351 2352 const rect1 = document.createElementNS( 2353 "http://www.w3.org/2000/svg", 2354 "rect" 2355 ); 2356 rect1.setAttribute("x", "5"); 2357 rect1.setAttribute("width", "2"); 2358 rect1.setAttribute("height", "12"); 2359 rect1.setAttribute("fill", "#161616"); 2360 svg.appendChild(rect1); 2361 2362 const rect2 = document.createElementNS( 2363 "http://www.w3.org/2000/svg", 2364 "rect" 2365 ); 2366 rect2.setAttribute("x", "12"); 2367 rect2.setAttribute("y", "5"); 2368 rect2.setAttribute("width", "2"); 2369 rect2.setAttribute("height", "12"); 2370 rect2.setAttribute("transform", "rotate(90 12 5)"); 2371 rect2.setAttribute("fill", "#161616"); 2372 svg.appendChild(rect2); 2373 2374 showRatingFormButton.appendChild(svg); 2375
2376 headerContainer.appendChild(headerRatingContainer); 2377 2378 if (!has_rated) { 2379 headerContainer.appendChild(showRatingFormButton); 2380 } 2381 2382 mainContainer.appendChild(headerContainer); 2383 mainContainer.appendChild(this.renderForm()); 2384 2385 if (reviews && reviews.length > 0) { 2386 const reviewsContainer = document.createElement("div"); 2387 reviewsContainer.className = "relative"; 2388 reviewsContainer.classList.add( 2389 ...this.config.styling.classes.reviewsContainer.split(" ") 2390 ); 2391 2392 const reviewsArticleContainer = document.createElement("div"); 2393 reviewsArticleContainer.className = 2394 this.config.styling.classes.reviewsArticleContainer; 2395 reviewsArticleContainer.style.overflowX = "auto"; 2396 reviewsArticleContainer.style.scrollSnapType = "x mandatory"; 2397 2398 reviews.forEach((review) => { 2399 reviewsArticleContainer.appendChild(this.renderReview(review)); 2400 }); 2401 2402 reviewsContainer.appendChild(reviewsArticleContainer); 2403 2404 if (reviews.length > 1) { 2405 reviewsArticleContainer.lastElementChild.style.marginRight = `${ 2406 100 - this.config.styling.articleWidth 2407 }%`; 2408 2409 const gradientOverlay = document.createElement("div"); 2410 gradientOverlay.style.position = "absolute"; 2411 gradientOverlay.style.top = "0"; 2412 gradientOverlay.style.bottom = "0"; 2413 gradientOverlay.style.right = "0"; 2414 gradientOverlay.style.width = `${ 2415 100 - this.config.styling.articleWidth 2416 }%`; 2417 gradientOverlay.style.pointerEvents = "none"; 2418 gradientOverlay.style.backgroundImage = 2419 this.config.styling.gradientOverlay; 2420 2421 reviewsContainer.appendChild(gradientOverlay); 2422 } 2423 2424 mainContainer.appendChild(reviewsContainer); 2425 } 2426 2427 return mainContainer; 2428 }, 2429 2430 renderReview: function ({ content, name, rating, reply }) { 2431 const article = document.createElement("article"); 2432 article.classList.add(...this.config.styling.classes.article.split(" ")); 2433 article.style.scrollSnapAlign = "start"; 2434 article.style.flex = "0 0 auto"; 2435 article.style.width = `${ 2436 this.data.reviews?.length > 1 ? this.config.styling.articleWidth : 100 2437 }%`; 2438 2439 const articleNameContainer = document.createElement("div"); 2440 articleNameContainer.classList.add( 2441 ...this.config.styling.classes.articleNameContainer.split(" ") 2442 ); 2443 2444 const articleName = document.createElement("div"); 2445 articleName.classList.add( 2446 ...this.config.styling.classes.articleName.split(" ") 2447 ); 2448 const p1 = document.createElement("p"); 2449 p1.textContent = name; 2450 articleName.appendChild(p1); 2451 articleNameContainer.appendChild(articleName); 2452 article.appendChild(articleNameContainer); 2453 2454 const div3 = document.createElement("div"); 2455 div3.classList.add( 2456 ...this.config.styling.classes.articleRatingContainer.split(" ") 2457 ); 2458 div3.appendChild(this.renderRating(rating, "product")); 2459 2460 article.appendChild(div3); 2461 2462 const contentContainer = document.createElement("div"); 2463 contentContainer.setAttribute( 2464 "x-data", 2465 `{ 2466 showMore: false, 2467 isOverflowing(element) { 2468 return element.scrollHeight > element.clientHeight; 2469 } 2470}` 2471 ); 2472 2473 const p2 = document.createElement("p"); 2474 p2.className = "relative"; 2475 p2.classList.add(...this.config.styling.classes.articleContent.split(" ")); 2476 2477 p2.setAttribute("x-bind:class", '{ "overflow-hidden": !showMore }'); 2478 p2.setAttribute( 2479 "x-bind:style", 2480 `{ maxHeight: showMore ? "none" : "${this.config.styling.articleMaxHeight}" }` 2481 ); 2482 2483 p2.innerHTML = content; 2484 2485 const showMoreLink = document.createElement("a"); 2486 showMoreLink.classList.add( 2487 ...this.config.styling.classes.articleShowMoreButton.split(" ") 2488 ); 2489 2490 showMoreLink.setAttribute("x-text", 'showMore ? "Show less" : "Show more"'); 2491 showMoreLink.setAttribute("x-show", "isOverflowing($refs.content)"); 2492 showMoreLink.setAttribute("x-on:click", "showMore = !showMore"); 2493 showMoreLink.setAttribute("x-ref", "showMoreLink"); 2494 2495 const fadeOverlay = document.createElement("div"); 2496 fadeOverlay.className = 2497 "absolute bottom-0 left-0 right-0 pointer-events-none"; 2498 fadeOverlay.classList.add( 2499 ...this.config.styling.articleFade.classes.split(" ") 2500 ); 2501 fadeOverlay.style.backgroundImage = 2502 this.config.styling.articleFade.background; 2503 2504 fadeOverlay.setAttribute( 2505 "x-show", 2506 "!showMore && isOverflowing($refs.content)" 2507 ); 2508 2509 p2.appendChild(fadeOverlay); 2510 2511 contentContainer.appendChild(p2); 2512 p2.setAttribute("x-ref", "content"); 2513 contentContainer.appendChild(showMoreLink); 2514 article.appendChild(contentContainer); 2515 2516 if (reply) { 2517 const aside = document.createElement("aside"); 2518 aside.classList.add( 2519 ...this.config.styling.classes.replyContainer.split(" ") 2520 ); 2521 2522 const p3 = document.createElement("p"); 2523 2524 p3.classList.add(...this.config.styling.classes.replyText.split(" ")); 2525 p3.textContent = reply; 2526 aside.appendChild(p3); 2527 2528 article.appendChild(aside); 2529 } 2530 2531 return article; 2532 }, 2533 renderProductItemRating: function (productData, productItem, page) { 2534 const { id, rating, rating_decimal, amountTotal } = productData; 2535 2536 try { 2537 if (rating && parseFloat(rating) > 0) { 2538 const productItemRatingContainer = document.createElement("div"); 2539 2540 const containerClass = 2541 this.config.styling.classes.productItemRatingContainer; 2542 if (containerClass && containerClass.trim() !== "") { 2543 productItemRatingContainer.classList.add( 2544 ...containerClass.split(" ") 2545 ); 2546 } 2547 2548 // Create rating with review count 2549 const ratingElement = this.renderRating( 2550 parseFloat(rating_decimal ?? rating), 2551 page 2552 ); 2553 2554 // Add review count in parentheses if available 2555 if (amountTotal && parseInt(amountTotal) > 0) { 2556 const reviewCountSpan = document.createElement("span"); 2557 reviewCountSpan.classList.add( 2558 "review-count", 2559 "text-sm", 2560 "text-gray-600", 2561 "ml-2" 2562 ); 2563 reviewCountSpan.textContent = `(${amountTotal})`; 2564 2565 const ratingWrapper = document.createElement("div"); 2566 ratingWrapper.classList.add("flex", "items-center"); 2567 ratingWrapper.appendChild(ratingElement); 2568 ratingWrapper.appendChild(reviewCountSpan); 2569 2570 productItemRatingContainer.appendChild(ratingWrapper); 2571 } else { 2572 productItemRatingContainer.appendChild(ratingElement); 2573 } 2574 2575 // Check if listItemContainerSelector is defined and not blank 2576 let targetContainer = productItem; 2577 if ( 2578 this.config.listItemContainerSelector && 2579 this.config.listItemContainerSelector.trim() !== "" 2580 ) { 2581 const listItemContainer = productItem.querySelector( 2582 this.config.listItemContainerSelector 2583 ); 2584 if (listItemContainer) { 2585 targetContainer = listItemContainer; 2586 } 2587 } 2588 2589 // Only add if not already present (avoid duplicates) 2590 if ( 2591 !targetContainer.querySelector(".product-reviews-rating") && 2592 !targetContainer.querySelector(".review-count") 2593 ) { 2594 productItemRatingContainer.classList.add("product-reviews-rating"); 2595 targetContainer.appendChild(productItemRatingContainer); 2596 } 2597 } 2598 } catch (error) { 2599 console.error( 2600 `${this.identifier}: Error processing product item ${id}:`, 2601 error 2602 ); 2603 } 2604 }, 2605 renderRating: function (rating, page = null) { 2606 const uniqueId = Math.floor(Math.random() * 100000); 2607 2608 const div = document.createElement("div"); 2609 div.classList.add(...this.config.styling.classes.starsContainer.split(" ")); 2610 2611 const ratingDiv = document.createElement("div"); 2612 ratingDiv.className = "flex items-center"; 2613 2614 // Use productPageSize if on product page, otherwise use default size 2615 const starSize = (page === "product") ? this.config.stars.productPageSize : this.config.stars.size; 2616 2617 for (let starIndex = 1; starIndex <= 5; starIndex++) { 2618 const gradientId = `rating-gradient-${uniqueId}-${starIndex}`; 2619 2620 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 2621 svg.setAttribute("aria-hidden", "true"); 2622 svg.setAttribute("class", starSize); 2623 svg.setAttribute("viewBox", "0 0 20 20"); 2624 svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); 2625 2626 const title = document.createElementNS( 2627 "http://www.w3.org/2000/svg", 2628 "title" 2629 ); 2630 title.textContent = `Star ${starIndex}`; 2631 svg.appendChild(title); 2632 2633 const defs = document.createElementNS( 2634 "http://www.w3.org/2000/svg", 2635 "defs" 2636 ); 2637 const linearGradient = document.createElementNS( 2638 "http://www.w3.org/2000/svg", 2639 "linearGradient" 2640 ); 2641 linearGradient.setAttribute("id", gradientId); 2642 2643 const remainder = rating - starIndex + 1; 2644 const fillPercentage = 2645 remainder > 0 && remainder < 1 2646 ? remainder * 100 2647 : starIndex <= Math.floor(rating) 2648 ? 100 2649 : 0; 2650 2651 const stop1 = document.createElementNS( 2652 "http://www.w3.org/2000/svg", 2653 "stop" 2654 ); 2655 stop1.setAttribute("offset", "0%"); 2656 stop1.setAttribute("stop-color", this.config.star.color.active); 2657 linearGradient.appendChild(stop1); 2658 2659 const stop2 = document.createElementNS( 2660 "http://www.w3.org/2000/svg", 2661 "stop" 2662 ); 2663 stop2.setAttribute("offset", fillPercentage + "%"); 2664 stop2.setAttribute("stop-color", this.config.star.color.active); 2665 linearGradient.appendChild(stop2); 2666 2667 const stop3 = document.createElementNS( 2668 "http://www.w3.org/2000/svg", 2669 "stop" 2670 ); 2671 stop3.setAttribute("offset", fillPercentage + "%"); 2672 stop3.setAttribute("stop-color", this.config.star.color.inactive); 2673 linearGradient.appendChild(stop3); 2674 2675 const stop4 = document.createElementNS( 2676 "http://www.w3.org/2000/svg", 2677 "stop" 2678 ); 2679 stop4.setAttribute("offset", "100%"); 2680 stop4.setAttribute("stop-color", this.config.star.color.inactive); 2681 linearGradient.appendChild(stop4); 2682 2683 defs.appendChild(linearGradient); 2684 svg.appendChild(defs); 2685 2686 const path = document.createElementNS( 2687 "http://www.w3.org/2000/svg", 2688 "path" 2689 ); 2690 path.setAttribute("fill", `url(#${gradientId})`); 2691 path.setAttribute("d", this.config.star.path); 2692 svg.appendChild(path); 2693 2694 ratingDiv.appendChild(svg); 2695 } 2696 2697 div.appendChild(ratingDiv); 2698 2699 return div; 2700 }, 2701 renderForm: function (language) { 2702 const form = document.createElement("form"); 2703 2704 form.id = "productReviewForm"; 2705 form.style.position = "relative"; 2706 2707 form.setAttribute( 2708 "x-on:submit.prevent", 2709 `window.apps.${this.identifier}.onSubmitReview()` 2710 ); 2711 2712 form.classList.add(...this.config.styling.classes.form.split(" ")); 2713 form.setAttribute("x-show", "showForm"); 2714 form.setAttribute("x-transition", ""); 2715 form.setAttribute("x-cloak", ""); 2716 2717 // Add description if available 2718 if (this.data.reviewSectionDescription) { 2719 const descriptionP = document.createElement("p"); 2720 descriptionP.className = "review-section-description text-sm text-gray-700 mb-4"; 2721 descriptionP.textContent = this.data.reviewSectionDescription; 2722 form.appendChild(descriptionP); 2723 } 2724 2725 const ratingDiv = document.createElement("div"); 2726 ratingDiv.classList.add( 2727 ...this.config.styling.classes.formRatingDiv.split(" ") 2728 ); 2729 2730 const ratingLabel = document.createElement("label"); 2731 ratingLabel.classList.add( 2732 ...this.config.styling.classes.formRatingLabel.split(" ") 2733 ); 2734
2735 ratingLabel.textContent = language?.yourRating ?? "Your rating"; 2736 ratingDiv.appendChild(ratingLabel); 2737 2738 ratingDiv.appendChild(this.renderRatingInput()); 2739 2740 form.appendChild(ratingDiv); 2741 2742 const nameDiv = document.createElement("div"); 2743 nameDiv.classList.add(...this.config.styling.classes.formGroup.split(" ")); 2744 2745 const nameLabel = document.createElement("label"); 2746 nameLabel.setAttribute("for", "reviewName"); 2747 nameLabel.className = "text-sm font-bold text-gray-700"; 2748 nameLabel.textContent = language?.name ?? "Name"; 2749 nameDiv.appendChild(nameLabel); 2750 2751 const nameInput = document.createElement("input"); 2752 nameInput.required = true; 2753 nameInput.type = "text"; 2754 nameInput.id = "reviewName"; 2755 nameInput.classList.add( 2756 ...this.config.styling.classes.textInput.split(" ") 2757 ); 2758 nameDiv.appendChild(nameInput); 2759 2760 form.appendChild(nameDiv); 2761 2762 const emailDiv = document.createElement("div"); 2763 emailDiv.classList.add(...this.config.styling.classes.formGroup.split(" ")); 2764 2765 const emailLabel = document.createElement("label"); 2766 emailLabel.setAttribute("for", "reviewEmail"); 2767 emailLabel.className = "text-sm font-bold text-gray-700"; 2768 emailLabel.textContent = language?.email ?? "Email"; 2769 emailDiv.appendChild(emailLabel); 2770 2771 const emailInput = document.createElement("input"); 2772 emailInput.required = true; 2773 emailInput.type = "email"; 2774 emailInput.id = "reviewEmail"; 2775 emailInput.classList.add( 2776 ...this.config.styling.classes.textInput.split(" ") 2777 ); 2778 emailDiv.appendChild(emailInput); 2779 2780 form.appendChild(emailDiv); 2781 2782 const messageDiv = document.createElement("div"); 2783 messageDiv.classList.add( 2784 ...this.config.styling.classes.formGroup.split(" ") 2785 ); 2786 2787 const messageLabel = document.createElement("label"); 2788 messageLabel.setAttribute("for", "reviewMessage"); 2789 messageLabel.className = "text-sm font-bold text-gray-700"; 2790 messageLabel.textContent = language?.message ?? "Message"; 2791 messageDiv.appendChild(messageLabel); 2792 2793 const messageTextarea = document.createElement("textarea"); 2794 messageTextarea.required = true; 2795 messageTextarea.id = "reviewMessage"; 2796 messageTextarea.rows = 10; 2797 messageTextarea.classList.add( 2798 ...this.config.styling.classes.textInput.split(" ") 2799 ); 2800 if (this.data.reviewTextPlaceholder) { 2801 messageTextarea.placeholder = this.data.reviewTextPlaceholder; 2802 } 2803 messageDiv.appendChild(messageTextarea); 2804 2805 form.appendChild(messageDiv); 2806 2807 const submitButton = document.createElement("button"); 2808 submitButton.classList.add( 2809 ...this.config.styling.classes.formSubmitButton.split(" ") 2810 ); 2811 submitButton.textContent = language?.submitReview ?? "Submit review"; 2812 form.appendChild(submitButton); 2813 2814 const errorMessageContainer = document.createElement("div"); 2815 errorMessageContainer.classList.add( 2816 ...this.config.styling.classes.formErrorMessageContainer.split(" ") 2817 ); 2818 errorMessageContainer.id = "productReviewErrorMessage"; 2819 2820 form.appendChild(errorMessageContainer); 2821 2822 return form; 2823 }, 2824 renderRatingInput: function () { 2825 const ratingDiv = document.createElement("div"); 2826 ratingDiv.className = "flex items-center mb-2"; 2827 ratingDiv.setAttribute( 2828 "x-data", 2829 "{ currentRating: null, hoverRating: null }" 2830 ); 2831 2832 // Use productPageSize since this is only used on product page forms 2833 const starSize = this.config.stars.productPageSize; 2834 2835 ratingDiv.innerHTML = ` 2836<template x-for="starIndex in 5"> 2837 <div x-id="['reviewStar']"> 2838 <input required class="opacity-0 absolute -z-50" type="radio" :id="$id('reviewStar')" name="rating" :value="starIndex" x-model="currentRating" /> 2839 2840 <label 2841 :for="$id('reviewStar')" 2842 @mouseover="hoverRating = starIndex" 2843 @mouseleave="hoverRating = null" 2844 > 2845 <svg aria-hidden="true" 2846 :style="{color: !hoverRating && currentRating >= starIndex || hoverRating >= starIndex ? '${this.config.star.color.active}' : '${this.config.star.color.inactive}'}" 2847 class="${starSize}" 2848 fill="currentColor"
2849 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> 2850 </svg> 2851 2852 </label> 2853 </div> 2854</template> 2855 `; 2856 2857 return ratingDiv; 2858 }, 2859 onSubmitReview: function () { 2860 const errorMessageContainer = document.getElementById( 2861 "productReviewErrorMessage" 2862 ); 2863 2864 try { 2865 this.submitReview(); 2866 errorMessageContainer.textContent = ""; 2867 } catch (error) { 2868 this.handleError(error); 2869 } 2870 }, 2871 handleError: function (error) { 2872 console.error("QuickbutikProductReviews@handleError", error); 2873 2874 const errorMessageContainer = document.getElementById( 2875 "productReviewErrorMessage" 2876 ); 2877 2878 const errorMessage = `An error occurred${ 2879 error ? `: ${JSON.stringify(error, null, 2)}` : "" 2880 }.`; 2881 2882 errorMessageContainer.textContent = errorMessage; 2883 }, 2884 submitReview: function () { 2885 const data = [ 2886 { 2887 key: "product_id", 2888 value: document 2889 .querySelector("[data-product-id]") 2890 .getAttribute("data-product-id"), 2891 }, 2892 { 2893 key: "name", 2894 value: document.getElementById("reviewName").value, 2895 }, 2896 { 2897 key: "email", 2898 value: document.getElementById("reviewEmail").value, 2899 }, 2900 { 2901 key: "rating", 2902 value: document.querySelector('input[name="rating"]:checked').value, 2903 }, 2904 { 2905 key: "message", 2906 value: document.getElementById("reviewMessage").value, 2907 }, 2908 ]; 2909 2910 const formData = new FormData(); 2911 2912 data.forEach(({ key, value }) => { 2913 if (!value) { 2914 throw new Error(`Missing ${key}`); 2915 } 2916 2917 formData.append(key, value); 2918 }); 2919 2920 fetch("/apps/productreviews/add", { 2921 method: "POST", 2922 body: formData, 2923 }) 2924 .then((response) => { 2925 if (!response.ok) { 2926 this.handleError(response.statusText); 2927 } 2928 return response.json(); 2929 }) 2930 .then((data) => { 2931 if (data.success === 1) { 2932 this.renderThankYouMessage(data); 2933 } else { 2934 this.handleError(data?.error); 2935 } 2936 }) 2937 .catch((error) => { 2938 this.handleError(error); 2939 }); 2940 }, 2941 renderThankYouMessage: function (data) { 2942 const { message_lbl, message_txt } = data; 2943 2944 const thankYouDiv = document.createElement("div"); 2945 thankYouDiv.classList.add( 2946 ...this.config.styling.classes.formThankYouMessageContainer.split(" ") 2947 ); 2948 2949 const thankYouLabel = document.createElement("label"); 2950 thankYouLabel.classList.add( 2951 ...this.config.styling.classes.formThankYouMessageLabel.split(" ") 2952 ); 2953 thankYouLabel.textContent = message_lbl; 2954 thankYouDiv.appendChild(thankYouLabel); 2955 2956 const thankYouMessage = document.createElement("p"); 2957 thankYouMessage.classList.add( 2958 ...this.config.styling.classes.formThankYouMessageLabel.split(" ") 2959 ); 2960 thankYouMessage.textContent = message_txt; 2961 thankYouDiv.appendChild(thankYouMessage); 2962 2963 const form = document.getElementById("productReviewForm"); 2964 2965 form.parentNode.replaceChild(thankYouDiv, form); 2966 2967 setTimeout(() => { 2968 thankYouDiv.parentNode.removeChild(thankYouDiv); 2969 2970 const productReviewButton = document.getElementById( 2971 "productReviewShowFormButton" 2972 ); 2973 2974 if (productReviewButton) { 2975 productReviewButton.parentNode.removeChild(productReviewButton); 2976 } 2977 }, 5000); 2978 }, 2979 renderErrorMessage: function (error) { 2980 const errorDiv = document.querySelector("#productReviewErrorMessage"); 2981 errorDiv.textContent = error || "Something went wrong"; // TODO: Translate 2982 }, 2983}); 2984</script>
vendor: 1 bytes, line 2984
2984
2985<script> 2986 qb_cookieconsent.run({ 2987 current_lang: 'en', 2988 autoclear_cookies: true, 2989 cookie_expiration: 90, 2990 autoload_css: true, 2991 theme_css: '/assets/shopassets/cookieconsent/cookieconsent.css?v=231', 2992 languages: { 2993 en: { 2994 consent_modal: { 2995 title: "Vi använder cookies", 2996 description: 'Vi använder cookies för att anpassa det innehåll som visas för dig och för att du ska få bästa tänkbara upplevelse när du handlar hos oss.', 2997 primary_btn: { 2998 text: 'Godkänn alla', 2999 role: 'accept_all' 3000 }, 3001 secondary_btn: { 3002 text: 'Endast nödvändiga', 3003 role: 'accept_necessary' 3004 } 3005 }, 3006 settings_modal: { 3007 title: 'Cookie inställningar', 3008 save_settings_btn: "Spara", 3009 accept_all_btn: "Godkänn alla", 3010 cookie_table_headers: [ 3011 { col1: "Name" }, 3012 { col2: "Domain" }, 3013 { col3: "Expiration" }, 3014 { col4: "Description" } 3015 ], 3016 blocks: qb_cookieconsent_blocks 3017 } 3018 } 3019 }, 3020 onAccept: function (cookie) { 3021 var event = new CustomEvent("QB_CookieConsentAccepted", { 3022 detail: { 3023 cookie: cookie 3024 } 3025 }); 3026 3027 document.dispatchEvent(event); 3028 }, 3029 onChange: function (cookie) { 3030 var event = new CustomEvent("QB_CookieConsentAccepted", { 3031 detail: { 3032 cookie: cookie 3033 } 3034 }); 3035 3036 document.dispatchEvent(event); 3037 }, 3038 }); 3039 3040</script>
3040 3041
3042<script type="text/javascript">window.registerApp({ 3043 name: 'PriceHistory', 3044 identifier: 'QuickbutikPriceHistory', 3045 description: 'Price history', 3046 version: '1.0.0', 3047 pages: ['product'], 3048 data: {}, 3049 config: { 3050 appContainerSelector: '[data-qb-selector="product-price-container"]', 3051 appInsertPosition: 'beforeend', 3052 styling: { 3053 textClasses: 'text-sm opacity-50', 3054 tooltipClasses: 3055 'w-64 bg-card shadow-default rounded py-4 px-6 m-2 absolute sm:left-1/2 sm:-translate-x-1/2 mx-auto z-10 pointer-events-auto', 3056 svgIcon: 3057 '<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />', 3058 svgIconClasses: 'ml-1 w-5 h-5', 3059 }, 3060 }, 3061 3062 onMount: function ({ appData, page }) { 3063 const productLowestPrices = appData && appData.pricehistory ? appData.pricehistory.lowest_prices : null; 3064 const productId = window?.qs_store_apps_data?.product_id 3065 let productLowestPrice = null 3066 3067 const container = document.querySelector(this.config.appContainerSelector) 3068 if (!container) { 3069 console.log( 3070 'Could not find app container: ', 3071 this.config.appContainerSelector, 3072 ) 3073 return 3074 } 3075 3076 // Return lowest price for no variants 3077 switch(typeof productLowestPrices[productId]) { 3078 case 'string': 3079 productLowestPrice = productLowestPrices[productId]; 3080 break; 3081 case 'object': 3082 productLowestPrice = this.getLowestPrice(productLowestPrices, productId); 3083 break; 3084 default: 3085 return; 3086 } 3087 3088 if (productLowestPrice) { 3089 container.insertAdjacentHTML(this.config.appInsertPosition, this.renderLowestPrice( 3090 productLowestPrice, 3091 appData.pricehistory, 3092 )); 3093 } 3094 }, 3095 3096 getLowestPrice(productLowestPrices, productId) { 3097 const variantPrices = productLowestPrices[productId]; 3098 3099 // Add lowest prices to qs_options 3100 Object.entries(variantPrices).forEach(([variantId, lowestPrice]) => { 3101 const variant = Object.values(qs_options).find(option => option.id === variantId); 3102 if (variant) { 3103 variant.lowest_price = lowestPrice; 3104 } 3105 }); 3106 3107 // Find the appropriate variant 3108 const selectedVariant = 3109 Object.values(qs_options).find(option => option.preselected) || 3110 Object.values(qs_options).find(option => option.available) || 3111 Object.values(qs_options)[0]; 3112 3113 return selectedVariant ? variantPrices[selectedVariant.id] : null; 3114 }, 3115 3116 renderLowestPrice(price, appData) { 3117 if (!price) { 3118 return ''; // Return empty string if no price 3119 } 3120 3121 return ` 3122 <div x-data="{ 3123 showLowestPrice: true, 3124 currentLowestPrice: '${price}', 3125 showTooltip: false 3126 }" 3127 x-init="$watch('variant', (value) => { 3128 if (value && value.lowest_price) { 3129 currentLowestPrice = value.lowest_price; 3130 showLowestPrice = true; 3131 } else { 3132 showLowestPrice = false; 3133 } 3134 })"> 3135 <div x-show="showLowestPrice" class="block relative"> 3136 <div class="inline-block relative"> 3137 <div class="flex ${this.config.styling.textClasses}"> 3138 <span>${appData.texts.label}:</span> 3139 <span class="ml-1" x-text="currentLowestPrice"></span> 3140 <svg 3141 @mouseenter="showTooltip = true" 3142 @mouseleave="showTooltip = false" 3143 @click="showTooltip = !showTooltip" 3144 xmlns="http://www.w3.org/2000/svg" 3145 fill="none"
3146 viewBox="0 0 24 24" 3147 stroke-width="1.5" 3148 stroke="currentColor" 3149 class="${this.config.styling.svgIconClasses} cursor-pointer"> 3150 ${this.config.styling.svgIcon} 3151 </svg> 3152 </div> 3153 <div 3154 x-show="showTooltip" 3155 @mouseenter="showTooltip = false" 3156 @click="showTooltip = false" 3157 x-transition:enter="transition ease-out duration-200" 3158 x-transition:enter-start="opacity-0" 3159 x-transition:enter-end="opacity-100" 3160 x-transition:leave="transition ease-in duration-150" 3161 x-transition:leave-start="opacity-100" 3162 x-transition:leave-end="opacity-0" 3163 class="${this.config.styling.tooltipClasses}">${appData.texts.tooltip}</div> 3164 </div> 3165 </div> 3166 </div> 3167 ` 3168 }, 3169 3170}) 3171</script>
3171<script type="text/javascript">window.registerApp({ 3172 name: 'Recaptcha', 3173 identifier: 'QuickbutikRecaptcha', 3174 description: 'Recaptcha', 3175 version: '1.1.0', 3176 pages: ['contact'], 3177 data: {}, 3178 config: {}, 3179 onMount: function ({ appData, page }) { 3180 console.debug(`${this.identifier}@onMount`) 3181 3182 let form = document.querySelector('form[role=form]') 3183 if (!form) { 3184 console.error('Aborting recaptcha, form not found.') 3185 return 3186 } 3187 3188 const createCaptchaDiv = () => { 3189 let captchaDiv = document.createElement('div'); 3190 captchaDiv.className = 'g-recaptcha' 3191 captchaDiv.dataset.sitekey = recaptchaSiteKey 3192 captchaDiv.style.marginBottom = '15px' 3193 captchaDiv.style.overflow = 'hidden' 3194 return captchaDiv 3195 } 3196 3197 let button = form.querySelector('button'); 3198 let inputSubmit = form.querySelector('input[type=submit]'); 3199 if (button) { 3200 button.insertAdjacentElement('beforebegin', createCaptchaDiv()) 3201 } else if (inputSubmit) { 3202 inputSubmit.insertAdjacentElement('beforebegin', createCaptchaDiv()) 3203 } 3204 3205 // Honeypot for contact page 3206 let contactForm = document.querySelector('form[role=form]#contact-form') 3207 if (contactForm) { 3208 let pottyDiv = document.createElement('div') 3209 pottyDiv.style.position = 'absolute' 3210 pottyDiv.style.left = '-5000px' 3211 pottyDiv.setAttribute('aria-hidden', true) 3212 3213 let codeLabel = document.createElement('label') 3214 codeLabel.innerText = 'Code' 3215 codeLabel.for = 'code' 3216 codeLabel.className = contactForm.querySelector('label').className 3217 codeLabel.setAttribute('aria-hidden', true) 3218 pottyDiv.appendChild(codeLabel) 3219 3220 let codeInput = document.createElement('input') 3221 codeInput.type = 'text' 3222 codeInput.id = 'code' 3223 codeInput.name = 'code' 3224 codeInput.tabIndex = -1 3225 codeInput.autocomplete = 'off' 3226 codeInput.className = contactForm.querySelector('input[name="name"]').className 3227 codeInput.setAttribute('aria-hidden', true) 3228 pottyDiv.appendChild(codeInput) 3229 3230 contactForm.querySelector('textarea').insertAdjacentElement('afterend', pottyDiv) 3231 } 3232 3233 // Inject Recaptcha script 3234 let existingScript = document.querySelector('script[src="https://www.google.com/recaptcha/api.js"]') 3235 if (!existingScript) { 3236 let script = document.createElement('script') 3237 script.src = 'https://www.google.com/recaptcha/api.js' 3238 script.type = 'text/javascript' 3239 script.async = true 3240 document.querySelector('head').appendChild(script) 3241 } 3242 3243 }, 3244}) 3245</script>
3245
3245<script src="/assets/shopassets/misc/magicbean.js?v=3" data-shop-id="100911" data-url="https://magic-bean.services.quickbutik.com/v1/sow" data-site-section="1" data-key="31379dea6afaca4fe6f7cac0111c6022b1bd493ffafbc180aa0d6f4fe8caedd0" data-timestamp="1791435917" defer></script>
3245<script> 3246 document.addEventListener('QB_CookieConsentAccepted', function(event) { 3247 const cookie = qbGetEvent(event).cookie; 3248 3249 const getConsentLevel = (level) => (cookie && cookie.level && cookie.level.includes(level)) ? 'granted' : 'denied'; 3250 3251 gtag('consent', 'update', { 3252 'analytics_storage': getConsentLevel('analytics'), 3253 'ad_storage': getConsentLevel('marketing'), 3254 'ad_user_data': getConsentLevel('marketing'), 3255 'ad_personalization': getConsentLevel('marketing'), 3256 }); 3257 }); 3258 3259 document.addEventListener('QB_CartAdded', function(e) { 3260 const itemData = qbGetEvent(e).itemData; 3261 3262 // item.id is the CART-LINE key — an md5 of the line (see 3263 // Model_Shop_Basketnew), unique per product+variant+options and 3264 // meaningless outside this basket. item.raw_id is the product id, 3265 // which is what view_item sends, what <g:id> carries in the Google 3266 // Shopping feed, and what GA4 needs to join the item funnel. 3267 let eventData = { 3268 currency: 'SEK', 3269 value: parseFloat(itemData.item.price_raw), 3270 items: [{ 3271 id: itemData.item.raw_id || itemData.item.id, 3272 item_id: itemData.item.raw_id || itemData.item.id, 3273 name: itemData.item.title.replaceAll('"', ''), 3274 item_name: itemData.item.title.replaceAll('"', ''), 3275 affiliation: `Säkra forum`, 3276 ...(itemData.variant_name && { 3277 name: itemData.item.title.replaceAll('"', '') + ' ' + itemData.variant_name.replaceAll('"', ''), 3278 item_name: itemData.item.title.replaceAll('"', '') + ' ' + itemData.variant_name.replaceAll('"', ''), 3279 variant: itemData.variant_name.replaceAll('"', ''), 3280 item_variant: itemData.variant_name.replaceAll('"', ''), 3281 }), 3282 ...(itemData.item.item_category && { 3283 item_category: itemData.item.item_category.replaceAll('"', ''), 3284 }), 3285 price: parseFloat(itemData.item.price_raw), 3286 quantity: itemData.qty 3287 }], 3288 }; 3289 'undefined' !== typeof gtag && gtag('event', 'add_to_cart', eventData); 3290 }); 3291</script>
3291<script>;(function () { 3292 if (window.parent === window) return; 3293 3294 const urlParams = new URLSearchParams(window.location.search); 3295 if (urlParams.has('_qbdev')) { 3296 try { sessionStorage.setItem('_qbdev', '1'); } catch(e) {} 3297 } 3298 try { if (!sessionStorage.getItem('_qbdev')) return; } catch(e) { return; } 3299 3300 const report = () => { 3301 try { 3302 const params = new URLSearchParams(window.location.search); 3303 params.delete('_qbdev'); 3304 params.delete('_qbtid'); 3305 const qs = params.toString(); 3306 window.parent.postMessage( 3307 { type: 'navigation', path: window.location.pathname + (qs ? '?' + qs : '') }, 3308 '*' 3309 ); 3310 } catch (e) {} 3311 }; 3312 3313 report(); 3314 3315 const { pushState, replaceState } = history; 3316 history.pushState = function (...args) { 3317 const result = pushState.apply(this, args); 3318 report(); 3319 return result; 3320 }; 3321 history.replaceState = function (...args) { 3322 const result = replaceState.apply(this, args); 3323 report(); 3324 return result; 3325 }; 3326 window.addEventListener('popstate', report); 3327})()</script>
vendor: 1 bytes, line 3327
3327
3328<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>
3328 3329</body> 3330 3331</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.