]> git.ipfire.org Git - thirdparty/bulma.git/commitdiff
Init shop
authorJeremy Thomas <bbxdesign@gmail.com>
Mon, 10 Jun 2024 22:20:16 +0000 (23:20 +0100)
committerJeremy Thomas <bbxdesign@gmail.com>
Mon, 10 Jun 2024 22:20:16 +0000 (23:20 +0100)
docs/_includes/docs/hero.html
docs/_layouts/default.html
docs/assets/css/main.css
docs/assets/javascript/shop.js [new file with mode: 0644]
docs/shop.html [new file with mode: 0644]

index 5a417b1f5e4f8cafe98a09bca10a98b3aa428dde..9331c382a5754ddc910728561d656909df440b06 100644 (file)
@@ -24,7 +24,9 @@
     {% endif %}
   </div>
 
-  <div class="bd-hero-carbon">
-    {% include website/carbon.html %}
-  </div>
+  {% unless include.hide_carbon == true %}
+    <div class="bd-hero-carbon">
+      {% include website/carbon.html %}
+    </div>
+  {% endunless %}
 </section>
index c5cd131fb78fda21dd8a93c9e630187f6b76eec3..1dab741ffc5ea8ef9544e6a5275411d4eb49d76a 100644 (file)
       {% endfor %}
     {% endif %}
 
-    {% include global/support.html %}
-    {% include global/native.html %}
+    {% unless page.hide_footer %}
+      {% include global/support.html %}
+      {% include global/native.html %}
+    {% endunless %}
+
     {% include global/about.html %}
 
     <script src="{{ site.url }}/assets/vendor/clipboard-2.0.11.min.js"></script>
index f3e28544fc00890b809e771fac4f2be26fa9564f..ae1bb9caecf86ffe4f87422229a2ab482f1dcc0c 100644 (file)
@@ -1,15 +1,3 @@
-:root {
-  --zlog-h: 221deg;
-  --zlog-s: 40%;
-  --zlog-l: 30%;
-  --zlog: hsl(var(--zlog-h), var(--zlog-s), var(--zlog-l));
-}
-
-.zlog {
-  color: var(--zlog);
-  display: none;
-}
-
 body {
   align-content: flex-start;
   /* display: grid; */
diff --git a/docs/assets/javascript/shop.js b/docs/assets/javascript/shop.js
new file mode 100644 (file)
index 0000000..91aa6a8
--- /dev/null
@@ -0,0 +1,1053 @@
+document.addEventListener("DOMContentLoaded", () => {
+  // Utils
+  const isObject = (obj) => {
+    return obj !== null && typeof obj === "object" && !Array.isArray(obj);
+  };
+
+  const isEmpty = (obj) => {
+    return Object.keys(obj).length === 0;
+  };
+
+  const humanizeGraphQLResponse = (input) => {
+    if (!input) return null;
+    const output = {};
+
+    Object.keys(input).forEach((key) => {
+      if (input[key] && input[key].edges) {
+        output[key] = input[key].edges.map((edge) =>
+          humanizeGraphQLResponse(edge.node),
+        );
+      } else if (isObject(input[key])) {
+        output[key] = humanizeGraphQLResponse(input[key]);
+      } else {
+        output[key] = input[key];
+      }
+    });
+
+    return output;
+  };
+
+  const formatPrice = (price) => {
+    const { amount, currencyCode } = price;
+    return `${CURRENCIES[currencyCode]}${Math.trunc(amount)}`;
+  };
+
+  const getId = (id) => {
+    const parts = id.split("/");
+    return parts[parts.length - 1];
+  };
+
+  const getProductFromVariant = (variantId) => {
+    let product = {};
+    let variant = {};
+
+    state.products.forEach((p) => {
+      const foundVariant = p.variants.find((v) => {
+        return v.id === variantId;
+      });
+
+      if (foundVariant) {
+        product = p;
+        variant = foundVariant;
+      }
+    });
+
+    return {
+      product,
+      variant,
+    };
+  };
+
+  const STORAGE_CART_ID = "bulma-shop-cart-id";
+
+  const CURRENCIES = {
+    AED: "د.إ",
+    AFN: "؋",
+    ALL: "L",
+    AMD: "֏",
+    ANG: "ƒ",
+    AOA: "Kz",
+    ARS: "$",
+    AUD: "$",
+    AWG: "ƒ",
+    AZN: "₼",
+    BAM: "KM",
+    BBD: "$",
+    BDT: "৳",
+    BGN: "лв",
+    BHD: ".د.ب",
+    BIF: "FBu",
+    BMD: "$",
+    BND: "$",
+    BOB: "$b",
+    BOV: "BOV",
+    BRL: "R$",
+    BSD: "$",
+    BTC: "₿",
+    BTN: "Nu.",
+    BWP: "P",
+    BYN: "Br",
+    BYR: "Br",
+    BZD: "BZ$",
+    CAD: "$",
+    CDF: "FC",
+    CHE: "CHE",
+    CHF: "CHF",
+    CHW: "CHW",
+    CLF: "CLF",
+    CLP: "$",
+    CNH: "¥",
+    CNY: "¥",
+    COP: "$",
+    COU: "COU",
+    CRC: "₡",
+    CUC: "$",
+    CUP: "₱",
+    CVE: "$",
+    CZK: "Kč",
+    DJF: "Fdj",
+    DKK: "kr",
+    DOP: "RD$",
+    DZD: "دج",
+    EEK: "kr",
+    EGP: "£",
+    ERN: "Nfk",
+    ETB: "Br",
+    ETH: "Ξ",
+    EUR: "€",
+    FJD: "$",
+    FKP: "£",
+    GBP: "£",
+    GEL: "₾",
+    GGP: "£",
+    GHC: "₵",
+    GHS: "GH₵",
+    GIP: "£",
+    GMD: "D",
+    GNF: "FG",
+    GTQ: "Q",
+    GYD: "$",
+    HKD: "$",
+    HNL: "L",
+    HRK: "kn",
+    HTG: "G",
+    HUF: "Ft",
+    IDR: "Rp",
+    ILS: "₪",
+    IMP: "£",
+    INR: "₹",
+    IQD: "ع.د",
+    IRR: "﷼",
+    ISK: "kr",
+    JEP: "£",
+    JMD: "J$",
+    JOD: "JD",
+    JPY: "¥",
+    KES: "KSh",
+    KGS: "лв",
+    KHR: "៛",
+    KMF: "CF",
+    KPW: "₩",
+    KRW: "₩",
+    KWD: "KD",
+    KYD: "$",
+    KZT: "₸",
+    LAK: "₭",
+    LBP: "£",
+    LKR: "₨",
+    LRD: "$",
+    LSL: "M",
+    LTC: "Ł",
+    LTL: "Lt",
+    LVL: "Ls",
+    LYD: "LD",
+    MAD: "MAD",
+    MDL: "lei",
+    MGA: "Ar",
+    MKD: "ден",
+    MMK: "K",
+    MNT: "₮",
+    MOP: "MOP$",
+    MRO: "UM",
+    MRU: "UM",
+    MUR: "₨",
+    MVR: "Rf",
+    MWK: "MK",
+    MXN: "$",
+    MXV: "MXV",
+    MYR: "RM",
+    MZN: "MT",
+    NAD: "$",
+    NGN: "₦",
+    NIO: "C$",
+    NOK: "kr",
+    NPR: "₨",
+    NZD: "$",
+    OMR: "﷼",
+    PAB: "B/.",
+    PEN: "S/.",
+    PGK: "K",
+    PHP: "₱",
+    PKR: "₨",
+    PLN: "zł",
+    PYG: "Gs",
+    QAR: "﷼",
+    RMB: "¥",
+    RON: "lei",
+    RSD: "Дин.",
+    RUB: "₽",
+    RWF: "R₣",
+    SAR: "﷼",
+    SBD: "$",
+    SCR: "₨",
+    SDG: "ج.س.",
+    SEK: "kr",
+    SGD: "S$",
+    SHP: "£",
+    SLL: "Le",
+    SOS: "S",
+    SRD: "$",
+    SSP: "£",
+    STD: "Db",
+    STN: "Db",
+    SVC: "$",
+    SYP: "£",
+    SZL: "E",
+    THB: "฿",
+    TJS: "SM",
+    TMT: "T",
+    TND: "د.ت",
+    TOP: "T$",
+    TRL: "₤",
+    TRY: "₺",
+    TTD: "TT$",
+    TVD: "$",
+    TWD: "NT$",
+    TZS: "TSh",
+    UAH: "₴",
+    UGX: "USh",
+    USD: "$",
+    UYI: "UYI",
+    UYU: "$U",
+    UYW: "UYW",
+    UZS: "лв",
+    VEF: "Bs",
+    VES: "Bs.S",
+    VND: "₫",
+    VUV: "VT",
+    WST: "WS$",
+    XAF: "FCFA",
+    XBT: "Ƀ",
+    XCD: "$",
+    XOF: "CFA",
+    XPF: "₣",
+    XSU: "Sucre",
+    XUA: "XUA",
+    YER: "﷼",
+    ZAR: "R",
+    ZMW: "ZK",
+    ZWD: "Z$",
+    ZWL: "$",
+  };
+
+  const CART_QL = `
+    id
+    createdAt
+    updatedAt
+    checkoutUrl
+    buyerIdentity {
+      countryCode
+    }
+    cost {
+      totalAmount {
+        amount
+        currencyCode
+      }
+    }
+    lines(first: 20) {
+      edges {
+        node {
+          id
+          quantity
+          cost {
+            subtotalAmount {
+              amount
+              currencyCode
+            }
+            totalAmount {
+              amount
+              currencyCode
+            }
+          }
+          merchandise {
+            ... on ProductVariant {
+              id
+            }
+          }
+        }
+      }
+    }
+  `;
+
+  const COST_QL = `
+    cost {
+      totalAmount {
+        amount
+        currencyCode
+      }
+      # The estimated amount, before taxes and discounts, for the customer to pay at checkout.
+      subtotalAmount {
+        amount
+        currencyCode
+      }
+      # The estimated tax amount for the customer to pay at checkout.
+      totalTaxAmount {
+        amount
+        currencyCode
+      }
+      # The estimated duty amount for the customer to pay at checkout.
+      totalDutyAmount {
+        amount
+        currencyCode
+      }
+    }
+  `;
+
+  // State
+  const state = {
+    cart: {},
+    products: [],
+    isLoading: false,
+    hasFetchedProducts: false,
+    countryCode: null,
+  };
+
+  // UI
+  const $cart = document.getElementById("cart");
+  const $cartClose = document.querySelectorAll(".shop-cart-close");
+  const $openCart = document.getElementById("open-cart");
+  const $emptyCart = document.getElementById("empty-cart");
+  const $fullCart = document.getElementById("full-cart");
+  const $cartItems = document.getElementById("cart-items");
+  const $products = document.getElementById("products");
+  const $modal = document.getElementById("shop-modal");
+  const $modalClose = document.querySelectorAll(".shop-modal-close");
+
+  $cartClose.forEach((el) => {
+    el.addEventListener("click", (event) => {
+      event.preventDefault();
+      $cart.classList.remove("is-active");
+    });
+  });
+
+  $openCart.addEventListener("click", (event) => {
+    event.preventDefault();
+    $cart.classList.add("is-active");
+  });
+
+  $modalClose.forEach((el) => {
+    el.addEventListener("click", (event) => {
+      event.preventDefault();
+      closeModal();
+    });
+  });
+
+  document.addEventListener("keydown", (event) => {
+    if (event.key === "Escape") {
+      $cart.classList.remove("is-active");
+      closeModal();
+    }
+  });
+
+  const closeModal = () => {
+    $modal.classList.remove("is-active");
+  };
+
+  const openModal = (product) => {
+    $title = $modal.querySelector(".modal-title");
+    $body = $modal.querySelector(".modal-body");
+    $buttons = $modal.querySelector(".modal .buttons");
+    $close = $modal.querySelector(".modal .buttons .button.is-close");
+
+    $title.replaceChildren();
+    buildHeading($title, product);
+
+    $body.replaceChildren();
+    $body.className = `modal-body block shop-product shop-product-${getId(product.id)}`;
+
+    buildDescription($body, product, false);
+    buildOptions($body, product);
+
+    $buttons.replaceChildren();
+    buildAddButton($buttons, product);
+
+    $modal.classList.add("is-active");
+
+    update();
+  };
+
+  const buildHeading = (el, product) => {
+    const { priceRange, title } = product;
+    const { minVariantPrice: min } = priceRange;
+
+    const $heading = El("shop-product-heading");
+
+    const $h3 = El("shop-product-title", "h3");
+    $h3.innerText = title;
+    $heading.appendChild($h3);
+
+    const $price = El("shop-product-price");
+    $price.appendChild(Price(min));
+    $heading.appendChild($price);
+
+    el.appendChild($heading);
+  };
+
+  const buildSizeGuide = (desc) => {
+    if (!desc) {
+      return;
+    }
+
+    const parts = desc.split('<p><strong class="size-guide-title">');
+    const first = parts[0];
+
+    if (parts.length === 1) {
+      return first;
+    }
+
+    const items = parts[1].split("Size guide</strong></p>");
+
+    if (parts.length === 1) {
+      return items[0];
+    }
+
+    return `
+      ${first}
+      <details>
+        <summary>Size Guide</summary>
+        ${items[1]}
+      </details>
+    `;
+  };
+
+  const buildDescription = (el, product) => {
+    const { descriptionHtml } = product;
+
+    const $description = El("shop-product-description");
+    const $tagline = El("shop-product-tagline");
+    const $rest = El("shop-product-rest content");
+
+    const { first, rest } = truncateDescription(descriptionHtml);
+
+    $tagline.innerHTML = first;
+    $rest.innerHTML = buildSizeGuide(rest);
+
+    $description.appendChild($tagline);
+    $description.appendChild($rest);
+    el.appendChild($description);
+  };
+
+  const buildOptions = (el, product) => {
+    const { variants } = product;
+
+    const $options = El("buttons has-addons are-small variants");
+    $options.className += variants.length > 1 ? " multiple" : " single";
+
+    if (variants.length > 1) {
+      variants.forEach((variant) => {
+        const { id, title } = variant;
+
+        const $option = El("button", "button");
+        $option.dataset.id = id;
+        $option.innerText = title;
+
+        $option.addEventListener("click", (event) => {
+          event.preventDefault();
+          product.selectedVariant = id;
+          update();
+        });
+
+        $options.appendChild($option);
+      });
+
+      el.appendChild($options);
+    }
+  };
+
+  const buildAddButton = (el, product) => {
+    const $buy = El("button is-primary is-medium", "button");
+    $buy.innerText = "Add to cart";
+
+    $buy.addEventListener("click", async (event) => {
+      event.preventDefault();
+      await addToCart(product.selectedVariant);
+      closeModal();
+    });
+
+    el.appendChild($buy);
+  };
+
+  // Update Cycle
+  const updateProducts = () => {
+    if (state.hasFetchedProducts) {
+      $products.classList.add("has-loaded");
+    }
+
+    if ($products.childElementCount > 4) {
+      return;
+    }
+
+    state.products.forEach((product) => {
+      const { id, availableForSale, featuredImage } = product;
+
+      if (!availableForSale) {
+        return;
+      }
+
+      const el = El(`shop-product shop-product-${getId(product.id)}`);
+      el.dataset.id = id;
+
+      const $figure = El("shop-product-image image is-square", "figure");
+      const $img = document.createElement("img");
+      $img.src = featuredImage.url;
+      $figure.appendChild($img);
+      el.appendChild($figure);
+
+      $figure.addEventListener("click", async (event) => {
+        event.preventDefault();
+        openModal(product);
+      });
+
+      buildHeading(el, product);
+      buildDescription(el, product);
+      buildOptions(el, product);
+
+      const $buttons = El("shop-product-buttons buttons");
+
+      buildAddButton($buttons, product);
+
+      const $more = El("button is-text", "button");
+      $more.innerText = "Learn more";
+      $buttons.appendChild($more);
+
+      $more.addEventListener("click", async (event) => {
+        event.preventDefault();
+        openModal(product);
+      });
+
+      el.appendChild($buttons);
+
+      $products.appendChild(el);
+    });
+  };
+
+  const updateCart = () => {
+    if (isEmpty(state.cart)) {
+      return;
+    }
+
+    const { checkoutUrl, cost, lines } = state.cart;
+
+    if (lines.length > 0) {
+      $openCart.classList.add("is-primary");
+      $cartItems.replaceChildren();
+
+      $emptyCart.style.display = "none";
+      $fullCart.style.display = "block";
+
+      lines.forEach((line) => {
+        const variantId = line.merchandise.id;
+        const { product, variant } = getProductFromVariant(variantId);
+
+        const $item = El("media shop-item");
+        $item.dataset.id = line.id;
+
+        const $left = El("media-left");
+        const $image = El("shop-item-image image is-64x64");
+        const $img = El("", "img");
+
+        if (product.featuredImage) {
+          $img.src = product.featuredImage.url;
+        }
+
+        $image.appendChild($img);
+        $left.appendChild($image);
+
+        const $right = El("media-content");
+
+        const $cost = El("shop-item-price");
+        $cost.innerText = formatPrice(line.cost.totalAmount);
+        $right.appendChild($cost);
+
+        const $title = El("shop-item-title");
+        $title.innerText = `${product.title}`;
+        $right.appendChild($title);
+
+        if (variant.title !== "Default Title") {
+          const $tag = El(
+            "shop-item-variant button is-primary is-small is-outlined",
+            "span",
+          );
+          $tag.innerText = `${variant.title}`;
+          $right.appendChild($tag);
+        }
+
+        const $quantity = El("shop-item-quantity button is-static", "span");
+        $quantity.innerText = `${line.quantity}`;
+        $right.appendChild($quantity);
+
+        const $buttons = El("shop-item-actions");
+
+        const $remove = El("button shop-item-remove is-small", "button");
+        const $icon = Icon("fa-solid fa-trash-can");
+        $remove.appendChild($icon);
+        $remove.addEventListener("click", async (event) => {
+          event.preventDefault();
+
+          if (
+            window.confirm(
+              `Are you sure you want to remove this item from your cart?`,
+            )
+          ) {
+            await removeFromCart(line.id);
+          }
+        });
+        $buttons.appendChild($remove);
+
+        const $addons = El("shop-item-buttons buttons are-small has-addons");
+
+        const $plus = El("button", "button");
+        const $plusIcon = Icon("fa-solid fa-plus");
+        $plus.appendChild($plusIcon);
+        $plus.addEventListener("click", async (event) => {
+          event.preventDefault();
+          await addToCart(variant.id);
+        });
+
+        const $minus = El("button", "button");
+        const $minusIcon = Icon("fa-solid fa-minus");
+        $minus.appendChild($minusIcon);
+        $minus.addEventListener("click", async (event) => {
+          event.preventDefault();
+
+          if (line.quantity === 1) {
+            if (
+              window.confirm(
+                `Are you sure you want to remove this item from your cart?`,
+              )
+            ) {
+              await removeFromCart(line.id);
+            }
+          } else {
+            await decreaseFromCart(line.id, line.quantity - 1);
+          }
+        });
+
+        $addons.appendChild($minus);
+        $addons.appendChild($quantity);
+        $addons.appendChild($plus);
+
+        $buttons.appendChild($addons);
+        $right.appendChild($buttons);
+
+        $item.appendChild($left);
+        $item.appendChild($right);
+
+        $cartItems.appendChild($item);
+      });
+
+      const $total = El("shop-total");
+      const $totalLeft = El("shop-total-left");
+      const $totalLabel = El("shop-total-label");
+      $totalLabel.innerText = "Total";
+      const $disclaimer = El("shop-total-disclaimer");
+      $disclaimer.innerText =
+        "Tax included and shipping and discounts calculated at checkout";
+      const $totalRight = El("shop-total-amount");
+      $totalRight.innerText = formatPrice(cost.totalAmount);
+      $totalLeft.appendChild($totalLabel);
+      $totalLeft.appendChild($disclaimer);
+      $total.appendChild($totalLeft);
+      $total.appendChild($totalRight);
+      $cartItems.appendChild($total);
+
+      const $checkout = El("button is-primary is-fullwidth", "a");
+      $checkout.innerText = "Checkout";
+      $checkout.href = checkoutUrl;
+      $cartItems.appendChild($checkout);
+    } else {
+      $openCart.classList.remove("is-primary");
+      $emptyCart.style.display = "block";
+      $fullCart.style.display = "none";
+    }
+  };
+
+  const updateButtons = () => {
+    const $buttons = document.querySelectorAll(
+      "#open-cart, #shop button.button, #shop-modal button.button",
+    );
+
+    $buttons.forEach((button) => {
+      if (state.isLoading) {
+        button.setAttribute("disabled", "");
+      } else {
+        button.removeAttribute("disabled");
+      }
+    });
+  };
+
+  const updateVariants = () => {
+    state.products.forEach((product) => {
+      const $blocs = document.querySelectorAll(
+        `.shop-product-${getId(product.id)}`,
+      );
+
+      $blocs.forEach(($bloc) => {
+        const $variants = $bloc.querySelectorAll(`.variants .button`);
+
+        $variants.forEach(($el) => {
+          if ($el.dataset.id === product.selectedVariant) {
+            $el.classList.add("is-primary");
+          } else {
+            $el.classList.remove("is-primary");
+          }
+        });
+      });
+    });
+  };
+
+  const update = () => {
+    updateProducts();
+    updateCart();
+    updateButtons();
+    updateVariants();
+  };
+
+  // HTML Elements
+  const El = (className = "", tag = "div") => {
+    const el = document.createElement(tag);
+    el.className = className;
+    return el;
+  };
+
+  const Icon = (icon) => {
+    const el = document.createElement("span");
+    el.className = "icon";
+    const i = document.createElement("i");
+    i.className = icon;
+    el.appendChild(i);
+    return el;
+  };
+
+  const Price = (price) => {
+    const { amount, currencyCode } = price;
+    const el = El("shop-price", "span");
+    el.innerText = `${CURRENCIES[currencyCode]}${Math.trunc(amount)}`;
+    return el;
+  };
+
+  const truncateDescription = (desc) => {
+    const parts = desc.split("<br>\n<br>\n");
+
+    return {
+      first: parts[0],
+      rest: parts.slice(1).join(" "),
+    };
+  };
+
+  // API calls
+  const client = window.ShopifyStorefrontAPIClient.createStorefrontApiClient({
+    storeDomain: "8df2f8-d5.myshopify.com",
+    apiVersion: "2024-04",
+    publicAccessToken: "e3764a4be9897a2d0531c4b5c2699c9f",
+  });
+
+  async function retrieveProducts() {
+    let context = "";
+
+    if (state.countryCode) {
+      context = `@inContext(country: ${state.countryCode})`;
+    }
+
+    const query = `
+      query allProducts ${context} {
+        products(first: 10) {
+          edges {
+            node {
+              id
+              availableForSale
+              description
+              descriptionHtml
+              featuredImage {
+                height
+                url
+                width
+              }
+              handle
+              images(first: 10) {
+                edges {
+                  node {
+                    height
+                    url
+                    width
+                  }
+                }
+              }
+              priceRange {
+                maxVariantPrice {
+                  amount
+                  currencyCode
+                }
+                minVariantPrice {
+                  amount
+                  currencyCode
+                }
+              }
+              title
+              variants(first: 10) {
+                edges {
+                  node {
+                    availableForSale
+                    id
+                    price {
+                      amount
+                      currencyCode
+                    }
+                    title
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+  `;
+
+    state.isLoading = true;
+    update();
+
+    try {
+      const { data, errors } = await client.request(query);
+
+      if (errors) {
+        return console.error(errors);
+      }
+
+      const clean = humanizeGraphQLResponse(data);
+      state.products = clean.products.map((product) => {
+        return {
+          ...product,
+          selectedVariant: product.variants[0].id,
+        };
+      });
+      state.hasFetchedProducts = true;
+    } catch (error) {
+      console.error("Error fetching products:", error);
+    }
+
+    state.isLoading = false;
+    update();
+  }
+
+  async function createCart() {
+    const query = `
+      mutation cartCreate {
+        cartCreate (
+          input: {}
+        ) {
+          cart {
+            ${CART_QL}
+            ${COST_QL}
+          }
+        }
+      }
+    `;
+
+    state.isLoading = true;
+    update();
+
+    try {
+      const { data, errors } = await client.request(query);
+
+      if (errors) {
+        return console.error(errors);
+      }
+
+      const clean = humanizeGraphQLResponse(data);
+      state.cart = clean.cartCreate.cart;
+
+      localStorage.setItem(STORAGE_CART_ID, clean.cartCreate.cart.id);
+    } catch (error) {
+      console.error("Error fetching products:", error);
+    }
+
+    state.isLoading = false;
+    update();
+  }
+
+  async function retrieveCart(cartId) {
+    const query = `
+      {
+        cart (
+          id: "${cartId}"
+        ) {
+          ${CART_QL}
+          ${COST_QL}
+        }
+      }
+    `;
+
+    state.isLoading = true;
+    update();
+
+    try {
+      const { data, errors } = await client.request(query);
+
+      if (errors) {
+        createCart();
+        return console.error(errors);
+      }
+
+      const clean = humanizeGraphQLResponse(data);
+      state.cart = clean.cart;
+
+      if (clean.cart.buyerIdentity.countryCode) {
+        state.countryCode = clean.cart.buyerIdentity.countryCode;
+      }
+    } catch (error) {
+      console.error("Error fetching products:", error);
+      createCart();
+    }
+
+    state.isLoading = false;
+    update();
+  }
+
+  async function addToCart(productId) {
+    const query = `
+      mutation cartAdd {
+        cartLinesAdd (
+          cartId: "${state.cart.id}"
+          lines: {
+            merchandiseId: "${productId}"
+            quantity: 1
+          }
+        ) {
+          cart {
+            ${CART_QL}
+            ${COST_QL}
+          }
+        }
+      }
+    `;
+
+    state.isLoading = true;
+    update();
+
+    try {
+      const { data, errors } = await client.request(query);
+
+      if (errors) {
+        return console.error(errors);
+      }
+
+      const clean = humanizeGraphQLResponse(data);
+      state.cart = clean.cartLinesAdd.cart;
+
+      $cart.classList.add("is-active");
+    } catch (error) {
+      console.error("Error fetching products:", error);
+    }
+
+    state.isLoading = false;
+    update();
+  }
+
+  async function removeFromCart(lineId) {
+    const query = `
+      mutation cartAdd {
+        cartLinesRemove (
+          cartId: "${state.cart.id}"
+          lineIds: ["${lineId}"]
+        ) {
+          cart {
+            ${CART_QL}
+            ${COST_QL}
+          }
+        }
+      }
+    `;
+
+    state.isLoading = true;
+    update();
+
+    try {
+      const { data, errors } = await client.request(query);
+
+      if (errors) {
+        return console.error(errors);
+      }
+
+      const clean = humanizeGraphQLResponse(data);
+      state.cart = clean.cartLinesRemove.cart;
+    } catch (error) {
+      console.error("Error fetching products:", error);
+    }
+
+    state.isLoading = false;
+    update();
+  }
+
+  async function decreaseFromCart(lineId, quantity) {
+    const query = `
+      mutation cartAdd {
+        cartLinesUpdate (
+          cartId: "${state.cart.id}"
+          lines: {
+            id: "${lineId}"
+            quantity: ${quantity}
+          }
+        ) {
+          cart {
+            ${CART_QL}
+            ${COST_QL}
+          }
+        }
+      }
+    `;
+
+    state.isLoading = true;
+    update();
+
+    try {
+      const { data, errors } = await client.request(query);
+
+      if (errors) {
+        return console.error(errors);
+      }
+
+      const clean = humanizeGraphQLResponse(data);
+      state.cart = clean.cartLinesUpdate.cart;
+    } catch (error) {
+      console.error("Error fetching products:", error);
+    }
+
+    state.isLoading = false;
+    update();
+  }
+
+  // Init
+  const init = async () => {
+    const storedCart = localStorage.getItem(STORAGE_CART_ID);
+
+    if (storedCart) {
+      await retrieveCart(storedCart);
+    } else {
+      await createCart();
+    }
+
+    await retrieveProducts();
+  };
+
+  init();
+});
diff --git a/docs/shop.html b/docs/shop.html
new file mode 100644 (file)
index 0000000..fedce3d
--- /dev/null
@@ -0,0 +1,396 @@
+---
+title: "The Bulma Shop"
+layout: default
+theme: primary
+route: shop
+hide_footer: true
+breadcrumb:
+  - home
+  - shop
+---
+
+<style type="text/css">
+  :root {
+    --shop-duration: 500ms;
+  }
+
+  .shop-open-cart {
+    margin-top: 1.5rem;
+  }
+
+  @media screen and (min-width: 800px) {
+    .shop-open-cart {
+      margin-top: 0;
+      position: absolute;
+      right: 3rem;
+      top: calc(50% - 1rem);
+    }
+  }
+
+  .shop-product-heading {
+    align-items: center;
+    gap: 1em;
+    justify-content: space-between;
+    display: flex;
+    font-size: 1.25em;
+    margin-bottom: 0.25em;
+  }
+
+  .shop-product-title {
+    color: var(--bulma-text-strong);
+    font-weight: 700;
+  }
+
+  .shop-product-price {
+    color: var(--bulma-text-strong);
+    font-size: 0.875em;
+  }
+
+  .shop-cart,
+  .shop-cart-overlay {
+    bottom: 0;
+    left: 0;
+    top: 0;
+    right: 0;
+    transition-duration: var(--shop-duration);
+    transition-property: opacity;
+  }
+
+  .shop-cart {
+    opacity: 0;
+    position: fixed;
+    z-index: 10;
+    pointer-events: none;
+  }
+
+  .shop-cart-overlay {
+    position: absolute;
+    background-color: rgb(0 0 0 / 80%);
+  }
+
+  .shop-cart-menu {
+    bottom: 0;
+    top: 0;
+    right: 0;
+    width: 100%;
+    background: var(--bulma-background);
+    max-width: 22rem;
+    padding: 2rem;
+    position: absolute;
+    z-index: 20;
+    transform: translateX(100%);
+    transition-duration: var(--shop-duration);
+    transition-property: transform;
+    overflow-y: auto;
+  }
+
+  .shop-empty-cart .notification {
+    padding: 1.25em 1.5em;
+  }
+
+  .shop-empty-cart .notification p {
+    color: var(--bulma-text-strong);
+    margin-bottom: 0.25rem;
+  }
+
+  .shop-cart.is-active {
+    pointer-events: auto;
+    opacity: 1;
+  }
+
+  .shop-cart.is-active .shop-cart-overlay {
+    opacity: 1;
+  }
+
+  .shop-cart.is-active .shop-cart-menu {
+    transform: none;
+  }
+
+  .shop-item-image {
+    border-radius: 0.5rem;
+    overflow: hidden;
+  }
+
+  .shop-item-title {
+    color: var(--bulma-text-strong);
+    font-weight: 600;
+  }
+
+  .shop-item-price {
+    float: right;
+    font-size: 0.875em;
+    margin-top: 0.125em;
+  }
+
+  .shop-item-remove {
+    float: right;
+  }
+
+  .shop-item-variant {
+    pointer-events: none;
+    padding: 0.125em 0.5em;
+  }
+
+  .shop-item-title {
+    margin-bottom: 0.25em;
+  }
+
+  .shop-item-buttons {
+    margin-top: 0.375em;
+  }
+
+  .shop-item-quantity {
+    color: var(--bulma-text-strong) !important;
+  }
+
+  .shop-total {
+    border-top: 1px solid
+      hsla(
+        var(--bulma-scheme-h),
+        var(--bulma-scheme-s),
+        var(--bulma-border-l),
+        0.5
+      );
+    padding: 1em 0;
+    display: flex;
+    gap: 1em;
+    align-items: start;
+    justify-content: space-between;
+  }
+
+  .shop-total-disclaimer {
+    font-size: 0.75em;
+    opacity: 0.5;
+  }
+
+  .shop-products {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
+    gap: 3rem;
+  }
+
+  .shop-products.has-loaded .shop-product.is-placeholder {
+    display: none;
+  }
+
+  .shop-product {
+    display: flex;
+    flex-direction: column;
+  }
+
+  .shop-product .variants {
+    margin-top: 1em;
+  }
+
+  .shop-product-description {
+    display: flex;
+    flex-grow: 1;
+    flex-direction: column;
+  }
+
+  .shop-product-image {
+    border-radius: 0.5rem;
+    cursor: pointer;
+    margin-bottom: 0.5em;
+    overflow: hidden;
+  }
+
+  .shop-product-image img {
+    transition-duration: var(--shop-duration);
+    transition-property: transform;
+    transform-origin: center;
+  }
+
+  .shop-product-image:hover img {
+    transform: scale(1.1);
+  }
+
+  .shop-product-heading {
+    font-size: 1.5em;
+  }
+
+  .shop-product-tagline {
+    font-size: 1.125em;
+  }
+
+  .shop-product-rest {
+    display: none;
+  }
+
+  .shop-product-rest p:not(:first-child) {
+    margin-top: var(--bulma-content-block-margin-bottom);
+  }
+
+  .shop-product-description details,
+  .shop-product-description .table-responsive {
+    margin-top: var(--bulma-content-block-margin-bottom);
+  }
+
+  .shop-product-description details {
+    cursor: pointer;
+  }
+
+  .shop-product-description .table-responsive table {
+    background-color: var(--bulma-scheme-main);
+  }
+
+  .shop-product-description .table-responsive td {
+    border-width: 1px !important;
+  }
+
+  .shop-product-buttons {
+    justify-content: space-between;
+  }
+
+  .shop-modal {
+    display: flex;
+    opacity: 0;
+    pointer-events: none;
+    transition-property: opacity;
+    transition-duration: var(--shop-duration);
+  }
+
+  .shop-modal-content {
+    border-radius: var(--bulma-radius-large);
+    background-color: var(--bulma-background);
+    padding: 3rem;
+    opacity: 0;
+    transform: translateY(3rem);
+    transition-property: opacity, transform;
+    transition-duration: var(--shop-duration);
+  }
+
+  .shop-modal-content .shop-product-tagline {
+    color: var(--bulma-text-strong);
+  }
+
+  .shop-modal-content .shop-product-rest {
+    display: block;
+  }
+
+  .shop-modal-buttons {
+    justify-content: space-between;
+  }
+
+  .shop-modal-close {
+    order: 2;
+  }
+
+  .shop-modal.is-active {
+    pointer-events: auto;
+    opacity: 1;
+  }
+
+  .shop-modal.is-active .shop-modal-content {
+    opacity: 1;
+    transform: none;
+  }
+
+  #empty-cart,
+  #full-cart {
+    display: none;
+  }
+</style>
+
+{% include global/header.html %}
+
+{% capture shop_placeholder %}
+<div class="shop-product is-placeholder">
+  <figure class="shop-product-image image is-square is-skeleton">
+    <img
+      src="https://cdn.shopify.com/s/files/1/0837/0451/2860/files/unisex-basic-softstyle-t-shirt-white-front-6665a0551b5c8.jpg?v=1717936223"
+    />
+  </figure>
+
+  <div class="shop-product-heading">
+    <h3 class="shop-product-title is-skeleton">The Bulma T-Shirt</h3>
+    <div class="shop-product-price">
+      <span class="shop-price is-skeleton">$15</span>
+    </div>
+  </div>
+  <div class="shop-product-description content">
+    <div class="shop-product-tagline content is-skeleton">
+      Show your CSS skills and add a little extra motivation with the
+      official Bulma sticker. A perfect reminder that design can be easy
+      with your favorite framework.
+    </div>
+  </div>
+  <div class="shop-product-buttons buttons">
+    <button class="button is-medium is-skeleton">Add to cart</button
+    ><button class="button is-text is-skeleton">Learn more</button>
+  </div>
+</div>
+{% endcapture %}
+
+<div style="min-height: calc(100vh - 6.5rem);">
+  <section class="bd-hero" style="position: relative;">
+    <div class="bd-hero-body">
+      <h1 class="bd-hero-title algolia-lvl0">
+        The Bulma Shop
+      </h1>
+
+      <hr class="bd-hr">
+
+      <h2 class="bd-hero-subtitle algolia-lvl1">
+        Get yourself some Bulma swag.
+      </h2>
+
+      <button id="open-cart" class="shop-open-cart button">
+        <span class="icon">
+          <i class="fa-solid fa-basket-shopping"></i>
+        </span>
+        <span>Open Cart</span>
+      </button>
+    </div>
+  </section>
+
+  <div id="shop" class="section">
+    <div id="cart" class="shop-cart">
+      <div id="cart-overlay" class="shop-cart-overlay shop-cart-close"></div>
+
+      <div class="shop-cart-menu">
+        <p class="title is-4">Cart</p>
+
+        <div id="empty-cart" class="shop-empty-cart">
+          <div class="notification is-dark">
+            <p>Your cart is empty!</p>
+            <button class="button is-primary shop-cart-close">
+              Get Shopping
+            </button>
+          </div>
+        </div>
+
+        <div id="full-cart">
+          <div id="cart-items"></div>
+        </div>
+      </div>
+    </div>
+
+    <div id="products" class="shop-products">
+      {{ shop_placeholder }}
+      {{ shop_placeholder }}
+      {{ shop_placeholder }}
+      {{ shop_placeholder }}
+    </div>
+  </div>
+</div>
+
+<div id="shop-modal" class="shop-modal modal">
+  <div class="shop-modal-close modal-background"></div>
+
+  <div class="shop-modal-content modal-content">
+    <div class="modal-title"></div>
+
+    <div class="modal-body block"></div>
+
+    <div class="shop-modal-buttons buttons"></div>
+  </div>
+
+  <button
+    class="shop-modal-close modal-close is-large"
+    aria-label="close"
+  ></button>
+</div>
+
+<script src="https://unpkg.com/@shopify/storefront-api-client@1.0.0/dist/umd/storefront-api-client.min.js"></script>
+<script type="text/javascript" src="{{ site.url }}/assets/javascript/shop.js"></script>