PageSourceSearch

https://vecanis.se/customer/login

html vecanis.se collected 2026-10-08 05:46:16 UTC 272,766 bytes, 7,138 lines download raw bytes

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

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.