diff --git a/src/css/base/forms.css b/src/css/base/forms.css index 4c674dc0..a2970efd 100644 --- a/src/css/base/forms.css +++ b/src/css/base/forms.css @@ -26,7 +26,7 @@ &:hover { @apply border__input--hover; } - &:focus { + &:focus-visible { @apply border__input--focus color__bg-body; } } @@ -40,7 +40,7 @@ &:hover { @apply border__input--hover; } - &:focus { + &:focus-visible { @apply border__input--focus; } } @@ -52,7 +52,7 @@ &:hover { @apply border__input--hover; } - &:focus { + &:focus-visible { @apply border__input--focus; } } @@ -61,7 +61,7 @@ &:hover { @apply border__button--hover; } - &:focus { + &:focus-visible { @apply border__button--focus; } } diff --git a/src/css/base/typography.css b/src/css/base/typography.css index 9c8ff94a..10d95b77 100644 --- a/src/css/base/typography.css +++ b/src/css/base/typography.css @@ -88,7 +88,7 @@ @apply underline opacity-50; } - &:focus { + &:focus-visible { @apply border--focus; } } diff --git a/src/css/utilities/borders.css b/src/css/utilities/borders.css index 269f32e7..428f3009 100644 --- a/src/css/utilities/borders.css +++ b/src/css/utilities/borders.css @@ -6,20 +6,54 @@ /* ======= */ /* default borders */ /* - default border width */ + .outline--width { + outline-width: var(--sizes__border-element-width); + outline-style: solid; + } .border--width { - border-width: var(--sizes-border-width); + border-width: var(--sizes__border-element-width); } .border--t-width { - border-top-width: var(--sizes-border-width); + border-top-width: var(--sizes__border-element-width); } .border--r-width { - border-right-width: var(--sizes-border-width); + border-right-width: var(--sizes__border-element-width); } .border--b-width { - border-bottom-width: var(--sizes-border-width); + border-bottom-width: var(--sizes__border-element-width); } .border--l-width { - border-left-width: var(--sizes-border-width); + border-left-width: var(--sizes__border-element-width); + } + .border--button-width { + border-width: var(--sizes__border-button-width); + } + .border--t-button-width { + border-top-width: var(--sizes__border-button-width); + } + .border--r-button-width { + border-right-width: var(--sizes__border-button-width); + } + .border--b-button-width { + border-bottom-width: var(--sizes__border-button-width); + } + .border--l-button-width { + border-left-width: var(--sizes__border-button-width); + } + .border--input-width { + border-width: var(--sizes__border-input-width); + } + .border--t-input-width { + border-top-width: var(--sizes__border-input-width); + } + .border--r-input-width { + border-right-width: var(--sizes__border-input-width); + } + .border--b-input-width { + border-bottom-width: var(--sizes__border-input-width); + } + .border--l-input-width { + border-left-width: var(--sizes__border-input-width); } /* - default border raidus */ .border--radius { @@ -54,7 +88,7 @@ /* ======= */ /* button borders */ .border__button { - @apply border--width; + @apply border--button-width; } .border__button--focus { @apply border--focus; @@ -68,7 +102,7 @@ /* ======= */ /* input borders */ .border__input { - @apply border--width color__border-divider-2; + @apply border--input-width color__border-divider-2; } .border__input--focus { @apply border--focus color__border-divider-2; diff --git a/src/css/utilities/colors.css b/src/css/utilities/colors.css index fcc7f8e0..a8815051 100644 --- a/src/css/utilities/colors.css +++ b/src/css/utilities/colors.css @@ -152,7 +152,7 @@ /* - primary */ .color__button-primary { background-color: var(--color-primary-button-background-light); - border-color: var(--color-primary-button-border-light); + border-color: var(--color__border-primary); color: var(--color-primary-button-text-light); * { color: var(--color-primary-button-text-light); @@ -162,7 +162,7 @@ /* - secondary */ .color__button-secondary { background-color: var(--color-secondary-button-background-light); - border-color: var(--color-secondary-button-border-light); + border-color: var(--color__border-secondary); color: var(--color-secondary-button-text-light); * { color: var(--color-secondary-button-text-light); @@ -172,7 +172,7 @@ /* - tertiary */ .color__button-tertiary { background-color: var(--color-tertiary-button-background-light); - border-color: var(--color-tertiary-button-border-light); + border-color: var(--color__border-tertiary); color: var(--color-tertiary-button-text-light); * { color: var(--color-tertiary-button-text-light); @@ -192,7 +192,7 @@ /* - plain */ .color__button-plain { background-color: var(--color-plain-button-background-light); - border-color: var(--color-plain-button-border-light); + border-color: var(--color__border-plain); color: var(--color-plain-button-text-light); * { color: var(--color-plain-button-text-light); diff --git a/src/prompts/AddSummaryComment.md b/src/prompts/AddSummaryComment.md new file mode 100644 index 00000000..db42aa28 --- /dev/null +++ b/src/prompts/AddSummaryComment.md @@ -0,0 +1,70 @@ +Please update the top of this Shopify liquid file to include a comment that explains this code for other developers. This comment should include 1) a summary of the file 2) all global liquid variables that start with `{{ settings.`. These variables should include there type and a short description 3) Recomendations on how to best use this file 4) if the file is a snippet we'll want to also include a list of arguments that are accepted 5) if the file is a snippet we'll want to include a sample usage example. If the file is a section file please skip over the Accepts and Usage comments. + +Below is an example of this + +``` + +{% comment %} + Thumbnail for simple content in a product or collection grid. + + Accepts: + - heading: {string} Set content for heading text. + - content: {string} Set content for body text. + - button_label: {string} Set content for button text. + - url: {string} Set URL for this block. + - image: {object} Liquid object for image values. + - image_background: {object} Liquid object for background image values. + - video: {object} Liquid object for video values. + - enable_autoplay: {boolean} Indicates if video should autoplay. + - enable_mute_toggle: {boolean} Indicates if video should include mute buttons. + - enable_loop: {boolean} Indicates if video should loop. + - color_scheme: {string} Class string to set color. + - color_border: {string} Class string to set border color. + - color_text: {string} Class string to set text color. + - color_button: {string} Class string to set button color. + - enable_gradient: {boolean} Indicates if content should use a gradient. + - spacing_top: {integer} Set top padding within block. + - spacing_bottom: {integer} Set bottom padding within block. + - enable_padding: {boolean} Indicates if content should use padding. + - layout_col_span_desktop: {string} Class string to set column span on desktop. + - layout_col_span_mobile: {string} Class string to set column span on mobile. + - layout_row_span_desktop: {string} Class string to set row span on desktop. + - layout_row_span_mobile: {string} Class string to set row span on mobile. + - layout_y_alignment: {string} Class string to set vertical aligment. + - layout_x_alignment: {string} Class string to set horizontal aligment (left, center, right). + + Globals: + - settings.layout_horizontal: {string} Class string to set horizontal margin. + + Usage: + {% render 'component__content-item', + heading: block.settings.heading, + content: block.settings.content, + button_label: block.settings.button_label, + url: block.settings.url, + image: block.settings.image, + image_background: block.settings.image_background, + video: block.settings.video, + enable_autoplay: block.settings.enable_autoplay, + enable_mute_toggle: block.settings.enable_mute_toggle, + enable_loop: block.settings.enable_loop, + color_scheme: block.settings.color_scheme, + color_border: block.settings.color_border, + color_text: block.settings.color_text, + color_button: block.settings.color_button, + enable_gradient: block.settings.enable_gradient, + spacing_top: block.settings.spacing_top, + spacing_bottom: block.settings.spacing_bottom, + enable_padding: block.settings.enable_padding, + layout_col_span_desktop: 'md:col-span-2', + layout_col_span_mobile: 'col-span-2' , + layout_row_span_desktop: 'md:row-span-2', + layout_row_span_mobile: 'row-span-2', + layout_y_alignment: block.settings.layout_y_alignment, + layout_x_alignment: block.settings.layout_x_alignment + %} + + Recommendations: + - Use this snippet to display promotional content. +{% endcomment %} +``` \ No newline at end of file diff --git a/src/prompts/CheckForMatchingTranslations.md b/src/prompts/CheckForMatchingTranslations.md new file mode 100644 index 00000000..29b20b10 --- /dev/null +++ b/src/prompts/CheckForMatchingTranslations.md @@ -0,0 +1,7 @@ +Please review this Shopify liquid code and ensure all translation values are pulling from the correct places. + +- Within the {% schema %} we want to use translations for the name, label, info and content values. This should look something like this `"t:sections.all.colors.settings.color_border.label"` +- All translations should start with `"t:sections.all.` OR `"t:sections.all.[FILE_NAME]` +- [FILE_NAME] is replace with the file name. For example within `content-slider.liquid` the correct translation should start with either `"t:sections.all.` OR `"t:sections.content_slider.` + +Please share a list of all lines that are not following this standard. You do not need to explain what the lines should be replaced with in your output. diff --git a/src/prompts/CheckForMissingTranslations.md b/src/prompts/CheckForMissingTranslations.md new file mode 100644 index 00000000..a8431bf9 --- /dev/null +++ b/src/prompts/CheckForMissingTranslations.md @@ -0,0 +1,8 @@ +Please highlight any missing translations in this Shopify liquid code. This file is being used for a Shopify theme. All text that is displayed within the theme should pull from translated values. + +- Within the {% schema %} we want to use translations for the name, label, info and content values. This should look something like this `"t:sections.all.colors.settings.color_border.label"` +- Within the code any hardcoded text should be replaced with a translation liquid variable like `{{ 'products.general.options' | t }}` +- Block and section liquid variables are valid and don't need to be replaced. For example this `{{ block.settings.heading }}` and this `{{ section.settings.heading }}` are both valid + +Please share a list of all lines that are not following this standard. You do not need to explain what the lines should be replaced with in your output. + diff --git a/src/prompts/FormatCode.md b/src/prompts/FormatCode.md new file mode 100644 index 00000000..8868b7dd --- /dev/null +++ b/src/prompts/FormatCode.md @@ -0,0 +1,256 @@ + +Please update the follwing Shopify liquid code to improve the formatting and readability. The code is using Tailwind CSS and Alpine JS. Please format this with a standardized and documented approach. + +- Add comments to explain each main section of the code +- Each HTML attribute should be sorted and ordered on a new line based on the importance +- The contents of the class attribute should be indented +- Sort and order standard Tailwind CSS classes on the first indented line of the class attribute +- Liquid variables within class attributes should be moved to the end of the class order on their own line +- All contents within Alpine.js attributes should use standard JavaScript formatting +- Liquid variables with multiple options should be sorted onto new lines for improved readability +- All code should use standard 2 space indentation +- Code comments within the main code block should use liquid {% comment %} tags +- You can skip over the {% schema %} tag in your output + +Here is a sample to demostrate class sorting: + +``` +
+``` + +Here is a full sample to demostrate ideal formatting: + +``` +
+ + {% comment %} Image background {% endcomment %} +
+ + {% comment %} Classes for custom image crop {% endcomment %} + {% assign image_classes = '' %} + {% if section.settings.show_entire_image %} + {% assign image_classes = 'hidden md:block object-contain' %} + {% else %} + {% assign image_classes = 'hidden md:block object-cover' %} + {% endif %} + + {% comment %} Toggle to set lazy_loading {% endcomment %} + {% if section.settings.image_background_desktop %} +
+ {% render 'component__image', + image: section.settings.image_background_desktop, + aspect_ratio: '', + background: '', + crop: '', + max_width: 5760, + container_class: 'h-full z-10', + image_class: image_classes, + enable_lazy_load: section.settings.enable_lazy_loading, + include_2x: true + %} +
+ {% endif %} + {% if section.settings.image_background_mobile == blank %} + {% if section.settings.image_background_desktop %} +
+ {% render 'component__image', + image: section.settings.image_background_desktop, + aspect_ratio: '', + background: '', + crop: 'object-cover', + max_width: 5760, + container_class: 'md:hidden w-full h-full z-10', + image_class: '', + enable_lazy_load: section.settings.enable_lazy_loading, + include_2x: true + %} +
+ {% endif %} + {% else %} +
+ {% render 'component__image', + image: section.settings.image_background_mobile, + aspect_ratio: '', + background: '', + crop: 'object-cover', + max_width: 900, + container_class: 'md:hidden w-full h-full z-10', + image_class: '', + enable_lazy_load: section.settings.enable_lazy_loading, + include_2x: true + %} +
+ {% endif %} +
+ + {% comment %} Video background {% endcomment %} + {% unless section.settings.video_background == blank %} +
+ {% render 'component__video', + video: section.settings.video_background, + background: '', + container_class: 'max-w-none md:min-h-full min-w-full h-full', + video_class: '', + enable_controls: false, + enable_autoplay: true, + enable_loop: true, + enable_mute_toggle: false + %} +
+ {% endunless %} + + {% comment %} Banner content {% endcomment %} +
+ +
+ +
+ + {% for block in section.blocks %} + {% case block.type %} + {% when 'heading' %} +

+ {{ block.settings.content }} +

+ {% when 'content' %} +
+ {{ block.settings.content }} +
+ {% when 'buttons' %} +
+ {% unless block.settings.button_url == blank %} + + {{ block.settings.button_label }} + + {% endunless %} + {% unless block.settings.secondary_button_url == blank %} + + {{ block.settings.secondary_button_label }} + + {% endunless %} +
+ {% endcase %} + {% endfor %} + +
+ +
+
+ +
+``` \ No newline at end of file diff --git a/src/prompts/FormatSchema.md b/src/prompts/FormatSchema.md new file mode 100644 index 00000000..4b1adcb0 --- /dev/null +++ b/src/prompts/FormatSchema.md @@ -0,0 +1 @@ +Please update the follwing Shopify liquid code. The JSON within the {% schema %} tags should be formatted with standard JSON formatting. Please use standard 2 space indentation. \ No newline at end of file diff --git a/src/ts/animation/observer.ts b/src/ts/animation/observer.ts deleted file mode 100644 index 215e5a16..00000000 --- a/src/ts/animation/observer.ts +++ /dev/null @@ -1,45 +0,0 @@ -// credit: https://onesheep.org/insights/animate-on-scroll-with-tailwind-css -// credit: https://devdojo.com/tnylea/animating-tailwind-transitions-on-page-load - -// Setup IntersectionObserver to add classes on scroll -export default function initAnimationObserver() { - // observerCallback for IntersectionObserver - const observerCallback: IntersectionObserverCallback = function (entries) { - entries.forEach((entry) => { - let element = document.getElementById((entry.target as HTMLElement).dataset.id!); - - // Update classes - if (entry.isIntersecting) { - let replaceClasses = JSON.parse( - (entry.target as HTMLElement).dataset.replace!.replace(/'/g, '"') - ) as { [key: string]: string }; - let delay = (entry.target as HTMLElement).dataset.delay || ''; - - let callback = (entry.target as HTMLElement).dataset.callback!; - let x = eval(callback); - if (typeof x == "function") { - x(); - } - - Object.keys(replaceClasses).forEach(function (key) { - setTimeout(function () { - if (element) { - element.classList.remove(key); - element.classList.add(replaceClasses[key]); - } else { - entry.target.classList.remove(key); - entry.target.classList.add(replaceClasses[key]); - } - }, parseInt(delay, 10)); - }); - } - }); - }; - - const animationElements = document.querySelectorAll(".js\\:animation"); - const animationObserver = new IntersectionObserver(observerCallback); - animationElements.forEach(function (target) { - animationObserver.observe(target); - }); - -} diff --git a/src/ts/bsl.ts b/src/ts/bsl.ts index f2651d82..21782da3 100644 --- a/src/ts/bsl.ts +++ b/src/ts/bsl.ts @@ -1,42 +1,36 @@ -import formatMoney from "./shopify/formatMoney"; -import initAnimationObserver from "./animation/observer"; -import { IShopify } from "./models.interface"; +import { ShopifyInterface } from "./models.interface"; import { AppInterface } from "./models.interface"; + import { globals } from "./globals/globals"; import { cart } from "./cart/cart"; import { search } from "./search/search"; +import { products } from "./products/products"; import { collections } from "./collections/collections"; -import { utils } from "./util/util"; +import { utils } from "./utils/utils"; +import { Shopify } from "./shopify/shopify"; + +// Load images and replace classes +utils.loadImages(); // Extend window object with Shopify, app, and initial data declare global { interface Window { - Shopify: IShopify; app: () => AppInterface; __initialData: AppInterface; - webkitAudioContext: any + Shopify: ShopifyInterface; } } -// Set up Shopify stuff -let Shopify = window.Shopify || {}; -Shopify.formatMoney = formatMoney; - -// Watch for class changes for animation -initAnimationObserver(); - // Expose variables and functions to Alpine window.app = function () { return { // Spread globals ...globals, - ...cart, - ...search, - + ...products, ...collections, - ...utils, + ...Shopify, }; }; \ No newline at end of file diff --git a/src/ts/cart/cart.ts b/src/ts/cart/cart.ts index 6e964ab5..8a213c41 100644 --- a/src/ts/cart/cart.ts +++ b/src/ts/cart/cart.ts @@ -1,618 +1,548 @@ import { Product } from "../models.interface"; export const cart = { - // Update cart with note input - updateCartNote(note: string) { - this.cart_loading = true; - fetch(`${window.Shopify.routes.root}cart/update.js`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ note: note }), - }) - .then(async (response) => { - let data = await response.json(); - - // good response - if (response.status === 200) { - this.cart.items = data.items.map((item: Product) => { - return { - ...item, - }; - }); - this.updateCart(false); - } - - // error response - else { - (this.error_title = data.message), - (this.error_message = data.description), - (this.show_alert = true); - } - }) - .catch((error) => { - console.error("Error:", error); - this.cart_loading = false; - }); - }, - // Update cart with fetched data - updateCart(openCart: boolean) { - this.cart_loading = true; - - fetch(`${window.Shopify.routes.root}cart.js`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }) - .then((response) => response.json()) - .then((data) => { - // Shopify properties - this.cart.items = data.items; - this.cart.item_count = data.item_count; - this.cart.total_price = data.total_price; - this.cart.original_total_price = data.original_total_price; - this.cart.total_discount = data.total_discount; - this.cart.cart_level_discount_applications = data.cart_level_discount_applications; - this.cart.note = data.note; - - // Custom properties - let calcTotal; - if (this.cart_shipping_bar_total === 'total') { - calcTotal = this.cart.total_price - } else { - calcTotal = this.cart.original_total_price - } - this.cart.shipping_gap = this.progress_bar_threshold * (+window.Shopify.currency.rate || 1) - calcTotal; - this.cart.shipping_progress = (calcTotal / (this.progress_bar_threshold * (+window.Shopify.currency.rate || 1))) * 100 + "%"; - - // Finish loading - setTimeout(() => { - this.cart_loading = false; - this.button_loading = false; - }, 300); - - // Open cart if set - if (openCart == true) { - let cart_behavior; - if(window.screen.width < 768){ - cart_behavior = this.cart_behavior_mobile; - } - else { - cart_behavior = this.cart_behavior_desktop; - } - this.cart_delay_width = "0%"; - if (cart_behavior == "alert") { - this.cart_alert = true; - setTimeout(() => { - this.cart_delay_width = "100%"; - }, 10); - setTimeout(() => { - this.cart_alert = false; - }, this.cart_delay); - } - if (cart_behavior == "drawer") { - this.cart_drawer = true; - } - if (cart_behavior == "redirect") { - window.location.href = "/cart"; - } - } - }) - .catch((error) => { - console.error("Error:", error); - this.cart_loading = false; - }); - }, - - // Edit cart item - editCartItem( - oldKey: number, - newKey: number, - sellingPlanId: number, - quantity: number, - openCart: boolean - ) { - if (this.enable_audio) { - this.playSound(this.click_audio); - } - - this.cart_loading = true; - - // Remove old item - let formData = { - id: oldKey.toString(), - quantity: "0", - }; - fetch(`${window.Shopify.routes.root}cart/change.js`, { - method: "POST", - body: JSON.stringify(formData), - headers: { - "Content-Type": "application/json", - }, - }) - .then((response) => response.json()) - .then(async (data) => { - let formData; - if (sellingPlanId == 0) { - formData = { - id: newKey.toString(), - quantity: quantity.toString(), - }; - } else { - formData = { - id: newKey.toString(), - quantity: quantity.toString(), - selling_plan: sellingPlanId.toString(), - }; - } - // Add new item - - fetch(`${window.Shopify.routes.root}cart/add.js`, { - method: "POST", - body: JSON.stringify(formData), - headers: { - "Content-Type": "application/json", - }, - }) - .then(async (response) => { - let data = await response.json(); - - // Good response - if (response.status === 200) { - if (this.enable_audio) { - this.playSound(this.success_audio); - } - this.updateCart(openCart); - } - - // Error response - else { - (this.error_title = data.message), - (this.error_message = data.description), - (this.show_alert = true); - } - }) - .catch((error) => { - console.error("Error:", error); - this.cart_loading = false; - }); - }); - }, - - // Call change.js to update cart item then use updateCart() - changeCartItemQuantity( - key: number, - quantity: number, - openCart: boolean, - refresh: boolean - ) { - if (this.enable_audio) { - this.playSound(this.click_audio); - } - - this.cart_loading = true; - let formData = { - id: key.toString(), - quantity: quantity.toString(), - }; - fetch(`${window.Shopify.routes.root}cart/change.js`, { - method: "POST", - body: JSON.stringify(formData), - headers: { - "Content-Type": "application/json", - }, - }) - .then((response) => response.json()) - .then((data) => { - if (data.status === 422) { - (this.error_title = data.message), - (this.error_message = data.description), - (this.show_alert = true); - this.cart_loading = false; - } - this.cart.items = data.items.map((item: Product) => { - return { - ...item, - }; - }); - if (refresh) { - window.location.reload(); - } else { - this.updateCart(openCart); - } - }) - .catch((error) => { - console.error("Error:", error); - this.cart_loading = false; - }); - }, - - addCartItem(form: any, bundle = false) { - if (this.enable_audio) { - this.playSound(this.click_audio); - } - this.button_loading = true; - this.cart_loading = true; - let formData = new FormData(form); - - // if product is a bundle - let productArray = []; - if (bundle) { - // create array of product ids - for (var pair of formData.entries()) { - if (pair[0].includes("bundle_option")) { - productArray.push({ id: pair[1], quantity: 1 }); - } - } - } - - // check if selling plan is available - let sellingPlanId = formData.get("selling_plan") as number | string; - // get all custom properties from the formData - let propertiesArr = []; - let propertiesObj; - for (const pair of formData.entries()) { - if (pair[0].includes("properties")) { - let name = pair[0].replace("properties[", "").replace("]", ""); - propertiesArr.push([name, pair[1]]); - } - } - if (propertiesArr.length > 0) { - propertiesObj = Object.fromEntries(propertiesArr); - } - // gift card recipient - let recipientCheckbox = document.querySelector( - `#recipient-checkbox` - ) as HTMLInputElement; - let recipientObj; - if (recipientCheckbox && recipientCheckbox.checked) { - let recipientName = document.querySelector( - `#recipient-name` - ) as HTMLInputElement; - let recipientEmail = document.querySelector( - `#recipient-email` - ) as HTMLInputElement; - let recipientMessage = document.querySelector( - `#recipient-message` - ) as HTMLInputElement; - - // throw error if name or email are empty - if (!recipientName.value || !recipientEmail.value) { - (this.error_title = "Error"), - (this.error_message = - "Please fill out name and email of gift card recepient"), - (this.show_alert = true); - this.cart_loading = false; - return; - } - - recipientObj = { - "Recipient email": recipientEmail.value, - "Recipient name": recipientName.value, - Message: recipientMessage.value, - __shopify_send_gift_card_to_recipient: "on", - }; - } - let reqBody; - if (bundle) { - reqBody = { - items: [...productArray], - }; - } else if (sellingPlanId == 0) { - reqBody = { - items: [ - { - id: formData.get("id"), - quantity: formData.get("quantity"), - properties: { - ...propertiesObj, - ...recipientObj, - }, - }, - ], - }; - } else { - reqBody = { - items: [ - { - id: formData.get("id"), - quantity: formData.get("quantity"), - selling_plan: sellingPlanId, - properties: { - ...propertiesObj, - ...recipientObj, - }, - }, - ], - }; - } - - fetch(`${window.Shopify.routes.root}cart/add.js`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(reqBody), - }) - .then(async (response) => { - let data = await response.json(); - - // Good response - if (response.status === 200) { - if (this.enable_audio) { - this.playSound(this.success_audio); - } - this.updateCart(true); - } - - // Error response - else { - (this.error_title = data.message), - (this.error_message = data.description), - (this.show_alert = true); - this.cart_loading = false; - this.button_loading = false; - } - }) - .catch((error) => { - console.error("Error:", error); - this.cart_loading = false; - }); - }, - - // Call add.js to add cart item then use updateCart() - addWithId( - variantID: number, - sellingPlanId: number, - quantity: number, - openCart: boolean, - giftCardRecipient: boolean, - productHandle: string - ) { - if (this.enable_audio) { - this.playSound(this.click_audio); - } - - this.cart_loading = true; - let formData; - let propertiesArr = []; - let propertiesObj; - - let properties = document.getElementsByClassName( - `custom-input_${productHandle}` - ) as HTMLCollectionOf; - - if (properties) { - for (const property of properties) { - propertiesArr.push([property.name, property.value]); - } - if (propertiesArr.length > 0) { - propertiesObj = Object.fromEntries(propertiesArr); - } - } - - // gift card recipient - let recipientCheckbox = document.querySelector( - `#recipient-checkbox` - ) as HTMLInputElement; - let recipientObj; - if (giftCardRecipient && recipientCheckbox.checked) { - let recipientName = document.querySelector( - `#recipient-name` - ) as HTMLInputElement; - let recipientEmail = document.querySelector( - `#recipient-email` - ) as HTMLInputElement; - let recipientMessage = document.querySelector( - `#recipient-message` - ) as HTMLInputElement; - - // throw error if name or email are empty - if (!recipientName.value || !recipientEmail.value) { - (this.error_title = "Error"), - (this.error_message = - "Please fill out name and email of gift card recepient"), - (this.show_alert = true); - this.cart_loading = false; - return; - } - - recipientObj = { - "Recipient email": recipientEmail.value, - "Recipient name": recipientName.value, - Message: recipientMessage.value, - __shopify_send_gift_card_to_recipient: "on", - }; - } - - // Update formData if sellingPlanId is available - if (sellingPlanId == 0) { - formData = { - items: [ - { - id: variantID, - quantity: quantity, - properties: { - ...propertiesObj, - ...recipientObj, - }, - }, - ], - }; - } else { - formData = { - items: [ - { - id: variantID, - quantity: quantity, - selling_plan: sellingPlanId, - properties: propertiesObj, - }, - ], - }; - } - - fetch(`${window.Shopify.routes.root}cart/add.js`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(formData), - }) - .then(async (response) => { - let data = await response.json(); - - // Good response - if (response.status === 200) { - if (this.enable_audio) { - this.playSound(this.success_audio); - } - this.updateCart(openCart); - } - - // Error response - else { - (this.error_title = data.message), - (this.error_message = data.description), - (this.show_alert = true); - } - }) - .catch((error) => { - console.error("Error:", error); - this.cart_loading = false; - }); - }, - - // Add multiple items to cart, used for cart sharing - addCartItems(items: Product[], clear = false) { - this.cart_loading = true; - let formData = { - items: items, - }; - - if (clear) { - this.cart_loading = true; - - fetch(`${window.Shopify.routes.root}cart/clear.js`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - }) - .then((response) => response.json()) - .then((data) => { - this.updateCart(true); - }) - .then(() => { - fetch(`${window.Shopify.routes.root}cart/add.js`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(formData), - }).then(async (response) => { - let data = await response.json(); - - if (response.status === 200) { - this.updateCart(true); - } else { - (this.error_title = data.message), - (this.error_message = data.description), - (this.show_alert = true); - } - }); - }) - .catch((error) => { - console.error("Error:", error); - this.cart_loading = false; - }); - } else { - fetch(`${window.Shopify.routes.root}cart/add.js`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(formData), - }) - .then(async (response) => { - let data = await response.json(); - - if (response.status === 200) { - this.cart.items = data.items.map((item: Product) => { - return { - ...item, - }; - }); - this.updateCart(true); - } else { - (this.error_title = data.message), - (this.error_message = data.description), - (this.show_alert = true); - } - }) - .catch((error) => { - console.error("Error:", error); - this.cart_loading = false; - }); - } - }, - - // Add items to cart if cartshare url available - handleSharedCart() { - if (location.search.indexOf("cartshare") >= 0) { - let query = location.search.substring(1); - let qArray = query.split("&"); - let objArr = qArray.map((item) => { - if (item != "") { - var properties = item.split(","); - var obj: { [key: string]: string } = {}; - properties.forEach(function (property) { - var tup = property.split(":"); - obj[tup[0]] = tup[1]; - }); - return obj; - } - }); - this.addCartItems(objArr, true); - } - }, - - // Generate url with query string based on cart contents - - generateUrl(): string { - interface ICartItem { - cartshare: boolean; - id: number; - quantity: number; - } - - type StringIndexable = T & { [key: string]: string | number | boolean }; - let queryString = ""; - - const serialize = function (obj: StringIndexable): string { - const str = []; - for (const p in obj) { - if (obj.hasOwnProperty(p)) { - str.push( - `${encodeURIComponent(p)}:${encodeURIComponent(obj[p].toString())}` - ); - } - } - return str.join(",") + "&"; - }; - - const filteredCart = this.cart.items.map((item: Product) => { - return { - cartshare: true, - id: item.variant_id, - quantity: item.quantity, - }; - }); - - filteredCart.forEach((item: StringIndexable) => { - queryString = queryString.concat(serialize(item)); - }); - return window.location.origin + "?" + queryString; - }, -}; + + // Update cart with fetched data + async updateCart ( + openCart: boolean + ) { + + // Reset global properties + this.cart_loading = true; + this.enable_body_scrolling = true; + + // Get data from shopify + try { + const response = await fetch(`${window.Shopify.routes.root}cart.js`, { + method: "GET", + headers: { + "Content-Type":"application/json", + }, + }); + + // If response is not OK, throw an error + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Parse response data + const data = await response.json(); + + // Shopify properties + this.cart.items = data.items; + this.cart.item_count = data.item_count; + this.cart.total_price = data.total_price; + this.cart.original_total_price = data.original_total_price; + this.cart.total_discount = data.total_discount; + this.cart.cart_level_discount_applications = data.cart_level_discount_applications; + + // Progress bar calculation + let calcTotal; + if (this.cart.progress_bar_calculation === 'total') { + calcTotal = this.cart.total_price + } else { + calcTotal = this.cart.original_total_price + } + this.cart.progress_bar_remaining = this.progress_bar_threshold * (+window.Shopify.currency.rate || 1) - calcTotal; + this.cart.progress_bar_percent = (calcTotal /(this.progress_bar_threshold * (+window.Shopify.currency.rate || 1))) * 100 + "%"; + + // Reset cart loading + setTimeout(() => { + this.cart_loading = false; + }, 200); + + // Unhide upsells + const cartUpsells = document.querySelectorAll(".js-upsell"); + cartUpsells.forEach(function (target) { + target.style.display = "flex"; + }); + + // Hide upsells + this.cart.items.forEach((item) => { + const upsellElements = document.querySelectorAll('.js-upsell-' + item.product_id); + upsellElements.forEach((element) => { + element.style.display = "none"; + }); + }); + + // Open cart if set + if (openCart) { + + // Set cart behavior to alert, drawer or redirect + let cart_behavior; + if(window.innerWidth < 768){ + cart_behavior = this.cart_behavior_mobile; + } + else { + cart_behavior = this.cart_behavior_desktop; + } + + // Display different cart elements + switch (cart_behavior) { + case "alert": + this.cart_alert = true; + this.cart.alert_delay = "0%"; + setTimeout(() => { + this.cart.alert_delay = "100%"; + }, 100); + setTimeout(() => { + this.cart.alert_delay = "0%"; + this.cart_alert = false; + }, 4100); + break; + case "drawer": + this.cart_drawer = true; + break; + case "redirect": + window.location.href = "/cart"; + break; + } + } + } + + catch (error: any) { + console.error("Error:", error); + this.cart_loading = false; + } + }, + + // Call change.js to update cart item then use updateCart() + async changeCartItemQuantity ( + key: number, + quantity: number, + openCart: boolean, + refresh: boolean + ) { + + // Play audio + this.playAudioIfEnabled(this.click_audio); + + // Show loading state + this.cart_loading = true; + + // Set data for fetch call + let formData = { + id: key.toString(), + quantity: quantity.toString(), + }; + + // Get data from shopify + try { + const response = await fetch(`${window.Shopify.routes.root}cart/change.js`, { + method: "POST", + body: JSON.stringify(formData), + headers: { + "Content-Type": "application/json", + }, + }); + + // If response is not OK, throw an error + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Parse response data + const data = await response.json(); + + // Update cart items + this.cart.items = data.items.map((item: Product) => ({ ...item })); + + // Refresh and update cart + if (refresh) { + window.location.reload(); + } else { + this.playAudioIfEnabled(this.success_audio); + this.updateCart(openCart); + } + } + + catch (error: any) { + console.error("Error:", error); + this.cart_loading = false; + } + }, + + // Load quick add with section render + async fetchAndRenderQuickEdit ( + product_handle: string, + template: string, + variantId: number, + quantity: number, + ) { + + // Update global edit variables + this.edit_variant = variantId; + this.edit_quantity = quantity; + + // Update which edit popup is shown + this.quick_edit_handle = product_handle; + + // Get data from Shopify + try { + const response = await fetch( + `${window.Shopify.routes.root}products/${product_handle}?section_id=quick-edit` + ); + + // If response is not OK, throw an error + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + // catpure data from fetch + const responseHtml = await response.text(); + + // disable body scrolling when quick add is visible + this.enable_body_scrolling = false; + + // Get quick add container and inject new + const quickAddContainer = document.getElementById(`js-quickEdit-${template}-${product_handle}`); + if (quickAddContainer) { + this.quick_edit_popup = true; + quickAddContainer.innerHTML = responseHtml; + } else { + console.error(`Element 'js-quickEdit-${template}-${product_handle}' not found.`); + } + } + + catch (error) { + console.error(error); + } + }, + + // Load quick add with section render + async fetchAndRenderQuickAdd ( + product_handle: string, + template: string + ) { + + // Update which edit popup is shown + this.quick_add_handle = product_handle; + + // Get data from Shopify + try { + const response = await fetch( + `${window.Shopify.routes.root}products/${product_handle}?section_id=quick-add` + ); + + // If response is not OK, throw an error + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + // catpure data from fetch + const responseHtml = await response.text(); + + // disable body scrolling when quick add is visible + this.enable_body_scrolling = false; + + // Get quick add container and inject new + const quickAddContainer = document.getElementById(`js-quickAdd-${template}-${product_handle}`); + if (quickAddContainer) { + this.quick_add_popup = true; + quickAddContainer.innerHTML = responseHtml; + } else { + console.error(`Element 'js-quickAdd-${template}-${product_handle}' not found.`); + } + } + + catch (error) { + console.error(error); + } + }, + + // Call add.js to add cart item then use updateCart() + async editCartItem ( + oldQuantity: number, + oldVariantId: number, + newVariantId: number, + sellingPlanId: number + ) { + this.cart_loading = true; + this.enable_body_scrolling = true; + this.playAudioIfEnabled(this.click_audio); + + // Item to remove + let oldFormData = { + id: oldVariantId.toString(), + quantity: "0", + }; + + // Item to add + let newFormData = sellingPlanId == 0 ? { + id: newVariantId.toString(), + quantity: oldQuantity.toString(), + } : { + id: newVariantId.toString(), + quantity: oldQuantity.toString(), + selling_plan: sellingPlanId.toString(), + }; + + try { + + // Remove item + const oldResponse = await fetch(`${window.Shopify.routes.root}cart/change.js`, { + method: "POST", + body: JSON.stringify(oldFormData), + headers: { + "Content-Type": "application/json", + }, + }); + if (!oldResponse.ok) { + throw new Error(`HTTP error! status: ${oldResponse.status}`); + } + + // Add item + const addResponse = await fetch(`${window.Shopify.routes.root}cart/add.js`, { + method: "POST", + body: JSON.stringify(newFormData), + headers: { + "Content-Type": "application/json", + }, + }); + if (!addResponse.ok) { + throw new Error(`HTTP error! status: ${addResponse.status}`); + } + + const data = await addResponse.json(); + + // Good response + if (addResponse.status === 200) { + this.playAudioIfEnabled(this.success_audio); + this.updateCart(false); + } + + // Error response + else { + this.error_message = data.description; + this.show_alert = true; + } + } + + catch (error) { + console.error("Error:", error); + this.cart_loading = false; + } + }, + + // Add multiple items to cart, used for cart sharing + async addCartItems ( + items: CartItem[] + ) { + this.cart_loading = true; + this.playAudioIfEnabled(this.click_audio); + + // Loop through each item and add it to the cart + for (const item of items) { + await this.addCartItem(item.variantId, 0, item.quantity, false, false); + } + + this.cart_loading = false; + this.updateCart(true); + this.playAudioIfEnabled(this.success_audio); + }, + + // Call add.js to add cart item then use updateCart() + async addCartItem ( + variantID: number, + sellingPlanId: number, + quantity: number, + openCart: boolean + ) { + + this.playAudioIfEnabled(this.click_audio); + let formData; + + // Update formData if sellingPlanId is available + if (sellingPlanId == 0) { + formData = { + items: [ + { + id: variantID, + quantity: quantity + }, + ], + }; + } + else { + formData = { + items: [ + { + id: variantID, + quantity: quantity, + selling_plan: sellingPlanId + }, + ], + }; + } + + return fetch(`${window.Shopify.routes.root}cart/add.js`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + }) + .then(async (response) => { + let data = await response.json(); + + // Good response + if (response.status === 200) { + this.playAudioIfEnabled(this.success_audio); + this.updateCart(openCart); + } + + // Error response + else { + (this.error_message = data.description), + (this.error_alert = true); + } + }) + .catch((error) => { + console.error("Error:", error); + this.cart_loading = false; + }); + + }, + + // Add cart item by submitting form + submitCartForm( + form: HTMLFormElement + ) { + this.cart_loading = true; + this.enable_body_scrolling = true; + this.playAudioIfEnabled(this.click_audio); + let formData = new FormData(form); + + // Add properties to formData + let propertiesObj = Array.from(formData.entries()) + .filter(([key]) => key.includes("properties")) + .reduce((obj, [key, value]) => { + let name = key.replace("properties[", "").replace("]", ""); + obj[name] = value; + return obj; + }, {}); + if (Object.keys(propertiesObj).length > 0) { + for (const [key, value] of Object.entries(propertiesObj)) { + formData.append(`properties[${key}]`, value); + } + } + + // Remove selling_plan if it is set to 0 + for (let pair of formData.entries()) { + if (pair[0] === 'selling_plan' && pair[1] === '0') { + formData.delete(pair[0]); + } + } + + // Make a POST request to add item to cart + fetch(`${window.Shopify.routes.root}cart/add.js`, { + method: 'POST', + body: formData + }) + .then(async (response) => { + let data = await response.json(); + + // If the response status is 200, the item was added successfully + if (response.status === 200) { + // Play success audio if enabled + this.playAudioIfEnabled(this.success_audio); + + // Update the cart + this.updateCart(true); + } + + // If the response status is not 200, there was an error adding the item + else { + // Set the error message and show the error alert + this.error_message = data.description; + this.error_alert = true; + } + }) + .catch((error) => { + console.error("Error:", error); + this.cart_loading = false; + }); + + }, + + // Display discount alert if URL parameters contain '/discount' + // e.g. - .com/discount/13KS94BNGCS8?dt=Save+20percent+storewide + handleSharedDiscount () { + const discountCode = this.getCookie('discount_code'); + const urlParams = new URLSearchParams(window.location.search); + const discountText = urlParams.get('dt'); + if (discountText) { + this.discount_code = discountCode; + this.discount_text = discountText; + this.discount_popup = true; + } + }, + + // Add items to cart if cartshare url available + handleSharedCart () { + + // Check if URL contains cartshare + if (location.search.includes("cartshare")) { + const query = location.search.substring(1); + const queryArray = query.split("&"); + + // Use map to transform the array + const itemsArray = queryArray.map((item) => { + + // Create object with all items to add + if (item) { + const properties = item.split(","); + const obj: { [key: string]: string } = {}; + for (const property of properties) { + const tup = property.split(":"); + obj[tup[0]] = tup[1]; + } + return obj; + } + + // Return null if item is falsy to avoid undefined elements in the array + return null; + }).filter(Boolean); // Use filter(Boolean) to remove null elements from the array + + const itemsObject = itemsArray.map(obj => ({ variantId: Number(obj.id), quantity: Number(obj.q) || 1 })); + + // Add items and open cart + this.addCartItems(itemsObject); + } + + }, + + // Generate url with query string based on cart contents + generateUrl (): string { + + // Define an interface for the cart item + interface ICartItem { + cartshare: boolean; + id: number; + q: number; + } + + // Define a type that allows string indexing on the ICartItem interface + type StringIndexable = T & { [key: string]: string | number | boolean }; + + // This function serializes an object into a string + const serialize = (obj: StringIndexable): string => { + return Object.entries(obj) + .reduce((str, [key, value]) => str.concat(`${encodeURIComponent(key)}:${encodeURIComponent(value.toString())},`), '') + .slice(0, -1); + }; + + // Filter the cart items and map them to the ICartItem interface + const filteredCart = this.cart.items.map((item: Product) => { + return ({ + cartshare: true, + id: item.variant_id, + q: item.quantity, + }); + }); + + // Create a query string from the filtered cart items + const queryString = filteredCart.map(serialize).join('&'); + + // Return the generated URL + return `${window.location.origin}?${queryString}`; + }, + +}; \ No newline at end of file diff --git a/src/ts/collections/collections.ts b/src/ts/collections/collections.ts index 14bf8967..bf2f7466 100644 --- a/src/ts/collections/collections.ts +++ b/src/ts/collections/collections.ts @@ -1,55 +1,161 @@ export const collections = { - // Load quick add with section render - fetchAndRenderQuickAdd: function (product_handle: string, template: string, edit = false) { - if(this.enable_audio) { - this.playSound(this.click_audio); + // Call section render API with data from filter + async fetchAndRenderCollection ( + filterData: FormData + ) { + + // Go back to top + const element = document.getElementById("js-top"); + if (element) { + element.scrollIntoView(); } - fetch( - window.Shopify.routes.root + - "products/" + - product_handle + - "?section_id=quick-add" - ) - .then((response) => response.text()) - .then((responseText) => { - const html = responseText; - document.getElementById( - "js:quickAdd-" + template + "-" + product_handle - )!.innerHTML = html; - }); - }, + // Loop through form data and build url + const filterUrl = this.buildUrlFilter(filterData); - // Handle filter changes on max price - handleMaxPriceFilter: function () { - this.filter_max_price = Math.max( - this.filter_max_price, - this.filter_min_price + // Get search term + let searchUrl = new URL(location.href).searchParams.get("q"); + searchUrl = searchUrl ? `&q=${searchUrl}` : ''; + + // Update page url + history.pushState( + null, + "", + `${window.location.pathname}?${filterUrl}${searchUrl}` ); - this.filter_max_thumb = - 100 - - ((this.filter_max_price - this.filter_min) / - (this.filter_max - this.filter_min)) * - 100; + + // Listen to popstate event + window.addEventListener('popstate', () => { + this.fetchAndRenderCollection(filterData); + }); + + // Get data from Shopify + try { + const response = await fetch( + `${window.location.pathname}?section_id=${this.pagination_section}${filterUrl}${searchUrl}` + ); + + // If response is not OK, throw an error + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Parse response data + const data = await response.text(); + + // Replace section with new content + const sectionElement = document.getElementById(`shopify-section-${this.pagination_section}`); + if (sectionElement) { + sectionElement.innerHTML = data; + } + + // Reset loading + this.pagination_loading = false; + this.filter_popup = false; + } + + catch (error) { + console.error("Error:", error); + this.pagination_loading = false; + } }, - // Handle filter changes on min price - handleMinPriceFilter: function () { - this.filter_min_price = Math.min( - this.filter_min_price, - this.filter_max_price - ); - this.filter_min_thumb = - ((this.filter_min_price - this.filter_min) / - (this.filter_max - this.filter_min)) * - 100; + // Check if next page is avaible and inject more products + async fetchAndRenderNextPage () { + + // Show loading + this.pagination_loading = true; + + // Get filter data + const filter = document.getElementById("js-desktopFilter") as HTMLFormElement; + + // Get pagination count + const pageUrl = `&page=${this.pagination_current_page + 1}`; + + // Get search parameter + const searchUrl = new URL(location.href).searchParams.get("q") ? `&q=${new URL(location.href).searchParams.get("q")}` : ''; + + // Build fetch url + let fetchUrl = `${window.location.pathname}?section_id=${this.pagination_section}${pageUrl}${searchUrl}`; + + // If filter exists, add filter data to fetch url + if (filter) { + const filterData = new FormData(filter); + const filterUrl = this.buildUrlFilter(filterData); + fetchUrl += filterUrl; + } + + // Check if new page is available + if (this.pagination_current_page < this.pagination_total_pages) { + + // Get data from Shopify + try { + const response = await fetch(fetchUrl); + const data = await response.text(); + + // Create a new HTML element and set its innerHTML to the fetched data + const tempElement = document.createElement("div"); + tempElement.innerHTML = data; + + // Find the results within the fetched data + const fetchedElement = tempElement.querySelector("#js-results"); + + // If results are found, append its innerHTML to the existing element on the page + if (fetchedElement) { + const resultsElement = document.getElementById("js-results"); + if (resultsElement) { + resultsElement.insertAdjacentHTML("beforeend", fetchedElement.innerHTML); + } + } + + // Update next page url + this.pagination_current_page += 1; + + // Reset loading + // this.loadImages(); + this.pagination_loading = false; + } + + catch (error) { + console.error("Error:", error); + this.pagination_loading = false; + } + } + + // If last page, stop loading + else { + this.pagination_loading = false; + } + }, + + // Handle filter changes on price + handlePriceFilterChange ( + filterType: string + ) { + // Use destructuring to make the code cleaner and easier to read + const { filter_min_price, filter_max_price, filter_min, filter_max } = this; + + // Calculate the price range + const priceRange = filter_max - filter_min; + + // Check the filter type and update the appropriate values + if (filterType === 'max') { + this.filter_max_price = Math.max(filter_max_price, filter_min_price); + this.filter_max_thumb = 100 - ((this.filter_max_price - filter_min) / priceRange) * 100; + } + else if (filterType === 'min') { + this.filter_min_price = Math.min(filter_min_price, filter_max_price); + this.filter_min_thumb = ((this.filter_min_price - filter_min) / priceRange) * 100; + } else { + console.error('Invalid filter type. Expected "min" or "max".'); + } }, // Handle filter change handleFilterChange(id: string): void { // Show loading indication - this.collection_loading = true; + this.pagination_loading = true; // Reset pagination this.pagination_current_page = 1; @@ -69,10 +175,10 @@ export const collections = { // Handle deleting filters handleFilterDelete(filterToReset: string): void { // Show loading indication - this.collection_loading = true; + this.pagination_loading = true; // Get filter data - const filter = document.getElementById("js:desktopFilter") as HTMLFormElement | null; + const filter = document.getElementById("js-desktopFilter") as HTMLFormElement | null; if (filter) { const filterData = new FormData(filter); @@ -88,14 +194,14 @@ export const collections = { this.fetchAndRenderCollection(filterData); } else { - console.error("Filter element 'js:desktopFilter' not found."); + console.error("Filter element 'js-desktopFilter' not found."); } }, // Handle deleting all filters handleFilterDeleteAll: function () { // Show loading indication - this.collection_loading = true; + this.pagination_loading = true; // Reset filterData let filterData = new FormData(); @@ -105,135 +211,34 @@ export const collections = { }, // Build urlFilter - buildUrlFilter: function (filterData: FormData) { + buildUrlFilter ( + filterData: FormData + ) { + + // Reset filter URL let urlFilter = ""; - for (var pair of filterData.entries()) { - if (pair[0].indexOf("price") !== -1) { - if (pair[0] === "filter.v.price.lte") { - if (pair[1] < this.filter_max) { - urlFilter = urlFilter + "&" + pair[0] + "=" + pair[1]; - } + + // Loop through filterData form + for (let pair of filterData.entries()) { + const [key, value] = pair; + + // If filtering with price range + if (key.includes("price")) { + if (key === "filter.v.price.lte" && value < this.filter_max) { + urlFilter += `&${key}=${value}`; } - if (pair[0] === "filter.v.price.gte") { - if (pair[1] > this.filter_min) { - urlFilter = urlFilter + "&" + pair[0] + "=" + pair[1]; - } + if (key === "filter.v.price.gte" && value > this.filter_min) { + urlFilter += `&${key}=${value}`; } - } else { - urlFilter = urlFilter + "&" + pair[0] + "=" + encodeURIComponent(pair[1]); } - } - return urlFilter; - }, - - // Call section render API with data from filter - fetchAndRenderCollection: function (filterData: FormData) { - for (var pair of filterData.entries()) { - } - - // Go back to top - var collectionTop = document.getElementById("js:top").offsetTop; - window.scrollTo({ top: collectionTop, behavior: 'smooth'}); - // close filters - this.filter_popup = false; - - // Loop through form data and build url - let filterUrl = this.buildUrlFilter(filterData); - - // Get search term - let searchUrl = new URL(location.href).searchParams.get("q"); - searchUrl = "&q=" + searchUrl; - - // Update page url - history.replaceState( - null, - "", - window.location.pathname + "?" + filterUrl + searchUrl - ); - - fetch( - window.location.pathname + - "?section_id=" + - this.pagination_section + - filterUrl + - searchUrl - ) - .then((response) => response.text()) - .then((responseText) => { - const html = responseText; - document.getElementById( - "shopify-section-" + this.pagination_section + "" - )!.innerHTML = html; - setTimeout(() => { - this.collection_loading = false; - }, 100); - }); - }, - - // Check if next page is avaible and inject more products - fetchAndRenderNextPage: function () { - // check if new page is available - if (this.pagination_current_page < this.pagination_total_pages) { - // show loading - this.collection_loading = true; - - // get filter data - let filter = document.getElementById("js:desktopFilter"); - - // get pagination count - let pageUrl = "&page=" + (this.pagination_current_page + 1); - - // get search thingy - let searchUrl = new URL(location.href).searchParams.get("q"); - searchUrl = "&q=" + searchUrl; - - // build fetch url - let fetchUrl = ""; - if (filter) { - let filterData = new FormData(filter); - let filterUrl = this.buildUrlFilter(filterData); - - fetchUrl = - window.location.pathname + - "?section_id=" + - this.pagination_section + - filterUrl + - pageUrl + - searchUrl; - } else { - fetchUrl = - window.location.pathname + - "?section_id=" + - this.pagination_section + - pageUrl + - searchUrl; + // All other filters + else { + urlFilter += `&${key}=${encodeURIComponent(value)}`; } - - // load new page with filters and sort - fetch(fetchUrl) - .then((response) => response.text()) - .then((responseText) => { - // extract products and inject into grid - let html = document.createElement("div"); - html.innerHTML = responseText; - let htmlCleaned = html.querySelector("#js\\:results").innerHTML; - document - .getElementById("js:results") - .insertAdjacentHTML("beforeend", htmlCleaned); - setTimeout(() => { - this.collection_loading = false; - }, 100); - - // update next page url - this.pagination_current_page = this.pagination_current_page + 1; - }); - } - - // if last pgae - else { - this.collection_loading = false; } - }, + // Return url + return urlFilter; + } }; \ No newline at end of file diff --git a/src/ts/globals/globals.ts b/src/ts/globals/globals.ts index fcb43368..7493ee24 100644 --- a/src/ts/globals/globals.ts +++ b/src/ts/globals/globals.ts @@ -1,77 +1,88 @@ // Exporting global constants +const app = window.__initialData; export const globals = { - // Audio related properties - click_audio: window.__initialData.click_audio, - success_audio: window.__initialData.success_audio, - enable_audio: window.__initialData.enable_audio, - audio_popup: window.__initialData.audio_popup, - // Scroll related properties - is_scrolled: window.__initialData.is_scrolled, // Boolean to toggle the dynamic header when scrolling up or down - prev_scroll_pos: window.__initialData.prev_scroll_pos, // Calculates the scroll direction for the dynamic header - scroll_up: window.__initialData.scroll_up, // Boolean to toggle the the "Back to top" button - scroll_up_force: window.__initialData.scroll_up_force, // Boolean to force the "Back to top" to be hidden - hide_header: window.__initialData.hide_header, // Boolean to hide header when opening other overlays - reduce_product_zindex: window.__initialData.reduce_product_zindex, // Boolean to hide header when opening other overlays - - // Mouse position properties - mouse_x: window.__initialData.mouse_x, // Mouse position X to position zoomed images - mouse_y: window.__initialData.mouse_y, // Mouse position Y to position zoomed images + // Scroll + is_scrolled: app.is_scrolled, // {boolean} Used to toggle dynamic header bar + prev_scroll_pos: app.prev_scroll_pos, // {number} Pprevious scroll position of the page. + show_scroll_up: app.show_scroll_up, // {boolean} To show the 'back to top' button - // Menu related properties - menu_drawer: window.__initialData.menu_drawer, // Boolean to toggle the menu drawer - menu_nested: window.__initialData.menu_nested, // Boolean to toggle the nested menu drawer + // Audio + click_audio: app.click_audio, // {string} URL for click sound + success_audio: app.success_audio, // {string} URL for success sound + enable_audio: app.enable_audio, // {boolean} To enable or disable audio - // Popup related properties - age_popup: window.__initialData.age_popup, // Boolean to toggle the age popup - filter_popup: window.__initialData.filter_popup, // Boolean to toggle the filter popup - localization_popup: window.__initialData.localization_popup, // Boolean to toggle the localization popup + // Popups + age_popup: app.age_popup, // {boolean} To toggle the age popup + filter_popup: app.filter_popup, // {boolean} To toggle the filter popup + localization_popup: app.localization_popup, // {boolean} To toggle the localization popup + audio_popup: app.audio_popup, // {boolean} To toggle audio settings popup + cookie_popup: app.cookie_popup, // {boolean} To toggle the cookie compliance popup + discount_popup: app.discount_popup, // {boolean} To toggle the discount popup - // Alert related properties - show_alert: window.__initialData.show_alert, // Boolean to toggle the alert - error_title: window.__initialData.error_title, // String for alert title - error_message: window.__initialData.error_message, // String for alert message + // Quick sections + quick_add_popup: app.quick_add_popup, // {boolean} To toggle the quick add popup + quick_edit_popup: app.quick_edit_popup, // {boolean} To toggle the quick edit popup + quick_edit_handle: app.quick_edit_handle, // {string} The product handle of the product being edited + quick_add_handle: app.quick_add_handle, // {string} The product handle of the product being added + + // Menu + menu_drawer: app.menu_drawer, // {boolean} To toggle the menu drawer + menu_nested: app.menu_nested, // {boolean} To check if the menu is nested + + // Header + hide_header: app.hide_header, // {boolean} To hide the header - // Product related properties - recent_products: window.__initialData.recent_products, // Array of recently viewed products - incomplete_fields: window.__initialData.incomplete_fields, // Boolean to check if product options are incomplete - selectedImageIndex: window.__initialData.selectedImageIndex, // Index of the selected product image (used for fullscreen slider) - // Cart related properties - cart_shipping_bar_total: window.__initialData.cart_shipping_bar_total, // Total or subtotal - cart_drawer: window.__initialData.cart_drawer, // Boolean to toggle the cart drawer - cart_loading: window.__initialData.cart_loading, // Boolean to toggle the cart loading state - cart_alert: window.__initialData.cart_alert, // Boolean to toggle the cart alert - cart_delay: window.__initialData.cart_delay, // Set the delay for the cart alert to close - cart_delay_width: window.__initialData.cart_delay_width, // Set the width for the cart alert progress bar - cart_behavior_desktop: window.__initialData.cart_behavior_desktop, // Set to 'drawer' 'alert' or 'redirect' - cart_behavior_mobile: window.__initialData.cart_behavior_mobile, // Set to 'drawer' 'alert' or 'redirect' - cart: window.__initialData.cart, // Object to store the cart data - progress_bar_threshold: window.__initialData.progress_bar_threshold, // Set the threshold for the 'free shipping' progress bar + // Errors + error_alert: app.error_alert, // {boolean} To show the alert + error_message: app.error_message, // {string} Error message - // Search related properties - search_loading: window.__initialData.search_loading, // Boolean to toggle the search loading state - search_active: window.__initialData.search_active, // Boolean to toggle the search overlay - search_items: window.__initialData.search_items, // Array of product search results - search_items_pages: window.__initialData.search_items_pages, // Aray of page search results - search_items_collections: window.__initialData.search_items_collections, // Array of collection search results - search_items_articles: window.__initialData.search_items_articles, // Array of article search results - search_items_queries: window.__initialData.search_items_queries, // Array of query search results + // Prices + price_format_with_currency: app.price_format_with_currency, // {string} Format for price with currency + price_format_without_currency: app.price_format_without_currency, // {string} Format for price without currency + price_enable_zeros: app.price_enable_zeros, // {Boolean} Set to false to hide '.00' + price_enable_currency: app.price_enable_currency, // {Boolean} Set to false to hide 'CAD + + // Product + recent_products: app.recent_products, // {array} of recently viewed products + + // Cart + cart_alert: app.cart_alert, // {boolean} To show the cart alert + cart_drawer: app.cart_drawer, // {boolean} To toggle the cart drawer + cart_loading: app.cart_loading, // {boolean} To check if the cart is loading + cart_behavior_desktop: app.cart_behavior_desktop, // {string} Behavior of the cart on desktop + cart_behavior_mobile: app.cart_behavior_mobile, // {string} Behavior of the cart on mobile + cart: app.cart, // {object} Object to store the cart data + progress_bar_threshold: app.progress_bar_threshold, // {number} Set the threshold for the 'free shipping' progress bar + + // Search + search_active: app.search_active, // {boolean} to toggle the search overlay + search_loading: app.search_loading, // {boolean} To check if the search is loading + search_term: app.search_term, // {string} Term for the search + search_items: app.search_items, // {array} Array of search items + search_focus_index: app.search_focus_index, // {string} Index of the focused search item + search_focus_url: app.search_focus_url, // {string} URL of the focused search item + search_items_pages: app.search_items_pages, // {array} Array of search items in pages + search_items_collections: app.search_items_collections, // {array} Array of search items in collections + search_items_articles: app.search_items_articles, // {array} Array of search items in articles + search_items_queries: app.search_items_queries, // {array} Array of search items in queries - // Collection and pagination related properties - collection_loading: window.__initialData.collection_loading, // Boolean to toggle the collection loading state - pagination_total_pages: window.__initialData.pagination_total_pages, // Total number of pages for the current collection - pagination_current_page: window.__initialData.pagination_current_page, // Current page number in pagination - pagination_section: window.__initialData.pagination_section, // Points to a {{ section.id }} to paginate + // Pagination + pagination_loading: app.pagination_loading, // {boolean} To show loading state in pagination + pagination_total_pages: app.pagination_total_pages, // {number} Total number of pages for the current collection + pagination_current_page: app.pagination_current_page, // {number} Current page number in pagination + pagination_section: app.pagination_section, // {string} Points to a {{ section.id }} to paginate + + // Filter + filter_min_price: app.filter_min_price, // {number} Value of the min price input + filter_max_price: app.filter_max_price, // {number} Value of the max price input + filter_min: app.filter_min, // {number} Min price for the current collection + filter_max: app.filter_max, // {number} Max price for the current collection + filter_min_thumb: app.filter_min_thumb, // {number} Sets position of min price thumb + filter_max_thumb: app.filter_max_thumb, // {number} Sets position of max price thumb - // Filter related properties - filter_min_price: window.__initialData.filter_min_price, // Value of the min price input - filter_max_price: window.__initialData.filter_max_price, // Value of the max price input - filter_min: window.__initialData.filter_min, // Min price for the current collection - filter_max: window.__initialData.filter_max, // Max price for the current collection - filter_min_thumb: window.__initialData.filter_min_thumb, // Sets position of min price thumb - filter_max_thumb: window.__initialData.filter_max_thumb, // Sets position of max price thumb + // TODO: - Remove, merge and connect. Referce Space + show_alert: app.show_alert, + enable_body_scrolling: app.enable_body_scrolling, - // Store related properties - currency_symbol: window.__initialData.currency_symbol, - button_loading: window.__initialData.button_loading }; \ No newline at end of file diff --git a/src/ts/models.interface.ts b/src/ts/models.interface.ts index 3cf7ad64..0dda2156 100644 --- a/src/ts/models.interface.ts +++ b/src/ts/models.interface.ts @@ -1,4 +1,63 @@ -export interface IShopify { +// Global interfaces +export interface AppInterface { + is_scrolled: boolean; + prev_scroll_pos: number; + show_scroll_up: boolean; + click_audio: string; + success_audio: string; + enable_audio: boolean; + age_popup: boolean; + filter_popup: boolean; + localization_popup: boolean; + audio_popup: boolean; + cookie_popup: boolean; + discount_popup: boolean; + quick_add_popup: boolean; + quick_edit_popup: boolean; + quick_edit_handle: string; + quick_add_handle: string; + menu_drawer: boolean; + menu_nested: boolean; + hide_header: boolean; + error_alert: boolean; + error_message: string; + price_format_with_currency: string; + price_format_without_currency: string; + price_enable_zeros: boolean; + price_enable_currency: boolean; + recent_products: any[]; + cart_alert: boolean; + cart_drawer: boolean; + cart_loading: boolean; + cart_behavior_desktop: string; + cart_behavior_mobile: string; + cart: object; + progress_bar_threshold: number; + search_active: boolean; + search_loading: boolean; + search_term: string; + search_items: any[]; + search_focus_index: string; + search_focus_url: string; + search_items_pages: any[]; + search_items_collections: any[]; + search_items_articles: any[]; + search_items_queries: any[]; + pagination_loading: boolean; + pagination_total_pages: number; + pagination_current_page: number; + pagination_section: string; + filter_min_price: number; + filter_max_price: number; + filter_min: number; + filter_max: number; + filter_min_thumb: number; + filter_max_thumb: number; + show_alert: boolean; + enable_body_scrolling: boolean; +} + +export interface ShopifyInterface { shop: string; locale: string; currency: { @@ -28,74 +87,15 @@ export interface IShopify { formatMoney(cents: string | number, currency?: string): string; } -interface FeaturedImage { - id: number; - product_id: number; - position: number; - created_at: string; - updated_at: string; - alt: string; - width: number; - height: number; - src: string; - variant_ids: number[]; -} - -interface UnitPriceMeasurement { - measured_type: string; - quantity_value: string; - quantity_unit: string; - reference_value: number; - reference_unit: string; -} - -interface SellingPlanAllocation { - price_adjustments: { - position: number; - price: number; - }[]; - price: number; - compare_at_price: number; - per_delivery_price: number; - unit_price: number; - selling_plan_id: number; - selling_plan_group_id: string; -} - -interface Variant { +// Collection interfaces +interface Collection { id: number; + body: string; + handle: string; + published_at: string; title: string; - option1: string; - option2: string; - option3: null; - sku: string; - requires_shipping: boolean; - taxable: boolean; + url: string; featured_image: FeaturedImage; - available: boolean; - name: string; - public_title: string; - options: string[]; - price: number; - weight: number; - compare_at_price: null | number; - inventory_management: string; - barcode: string; - featured_media: { - alt: string; - id: number; - position: number; - preview_image: { - aspect_ratio: number; - height: number; - width: number; - src: string; - }; - }; - unit_price: number; - unit_price_measurement: UnitPriceMeasurement; - requires_selling_plan: boolean; - selling_plan_allocations: SellingPlanAllocation[]; } interface RecentProduct { @@ -172,115 +172,7 @@ interface RecentProduct { }[]; } -interface Cart { - items: Product[]; - item_count: number; - total_price: number; - original_total_price: number; - total_discount: number; - shipping_gap: number; - shipping_progress: string; - cart_level_discount_applications: { - type: string; - key: string; - title: string; - description: null | string; - value: string; - created_at: string; - value_type: string; - allocation_method: string; - target_selection: string; - target_type: string; - total_allocated_amount: number; - }[]; -} - -export interface AppInterface { - click_audio: string; - success_audio: string; - enable_audio: boolean; - is_scrolled: boolean; - prev_scroll_pos: number; - hide_header: boolean; - reduce_product_zindex: boolean; - scroll_up: boolean; - scroll_up_force: boolean; - mouse_x: number; - mouse_y: number; - menu_drawer: boolean; - menu_nested: boolean; - age_popup: boolean; - filter_popup: boolean; - localization_popup: boolean; - show_alert: boolean; - error_title: string; - error_message: string; - recent_products: RecentProduct[]; - incomplete_fields: boolean; - cart_drawer: boolean; - cart_loading: boolean; - cart_alert: boolean; - cart_delay: number; - cart_delay_width: number; - cart_behavior: string; - cart: Cart; - progress_bar_threshold: number; - search_loading: boolean; - search_active: boolean; - search_items: Product[]; - search_items_pages: Page[]; - search_items_collections: Collection[]; - search_items_articles: Article[]; - search_items_queries: SearchQuery[]; - collection_loading: boolean; - pagination_total_pages: number; - pagination_current_page: number; - pagination_section: number; - filter_min_price: number; - filter_max_price: number; - filter_min: number; - filter_max: number; - filter_min_thumb:number; - filter_max_thumb:number; -} - -interface Collection { - id: number; - body: string; - handle: string; - published_at: string; - title: string; - url: string; - featured_image: FeaturedImage; -} -interface Page { - id: number; - body: string; - handle: string; - published_at: string; - title: string; - url: string; -} -interface Article { - id: number; - body: string; - featured_image: FeaturedImage; - handle: string; - image: string; - summary_html: string; - tags: string[]; - published_at: string; - title: string; - url: string; -} - -interface SearchQuery { - styled_text: string; - text: string; - url: string; -} - - +// Product interfaces export interface Product { id: number; properties: null; @@ -338,6 +230,107 @@ export interface Product { line_level_total_discount: number; } +interface Variant { + id: number; + title: string; + option1: string; + option2: string; + option3: null; + sku: string; + requires_shipping: boolean; + taxable: boolean; + featured_image: FeaturedImage; + available: boolean; + name: string; + public_title: string; + options: string[]; + price: number; + weight: number; + compare_at_price: null | number; + inventory_management: string; + barcode: string; + featured_media: { + alt: string; + id: number; + position: number; + preview_image: { + aspect_ratio: number; + height: number; + width: number; + src: string; + }; + }; + unit_price: number; + unit_price_measurement: UnitPriceMeasurement; + requires_selling_plan: boolean; + selling_plan_allocations: SellingPlanAllocation[]; +} + +interface FeaturedImage { + id: number; + product_id: number; + position: number; + created_at: string; + updated_at: string; + alt: string; + width: number; + height: number; + src: string; + variant_ids: number[]; +} + +interface UnitPriceMeasurement { + measured_type: string; + quantity_value: string; + quantity_unit: string; + reference_value: number; + reference_unit: string; +} + +interface SellingPlanAllocation { + price_adjustments: { + position: number; + price: number; + }[]; + price: number; + compare_at_price: number; + per_delivery_price: number; + unit_price: number; + selling_plan_id: number; + selling_plan_group_id: string; +} + +// Cart interfaces +interface Cart { + items: Product[]; + item_count: number; + total_price: number; + original_total_price: number; + total_discount: number; + shipping_gap: number; + shipping_progress: string; + cart_level_discount_applications: { + type: string; + key: string; + title: string; + description: null | string; + value: string; + created_at: string; + value_type: string; + allocation_method: string; + target_selection: string; + target_type: string; + total_allocated_amount: number; + }[]; +} + +// Search interfaces +interface SearchQuery { + styled_text: string; + text: string; + url: string; +} + export interface Params { author: string; body: string; @@ -348,4 +341,28 @@ export interface Params { variants_sku: string; variants_title: string; vendor: string; -} \ No newline at end of file +} + +// Page and blog interfaces +interface Page { + id: number; + body: string; + handle: string; + published_at: string; + title: string; + url: string; +} + +interface Article { + id: number; + body: string; + featured_image: FeaturedImage; + handle: string; + image: string; + summary_html: string; + tags: string[]; + published_at: string; + title: string; + url: string; +} + diff --git a/src/ts/products/products.ts b/src/ts/products/products.ts new file mode 100644 index 00000000..7814b744 --- /dev/null +++ b/src/ts/products/products.ts @@ -0,0 +1,450 @@ +export const products = { + + // Update page when variant selection changes + handleProductFormChange ( + enableUrlParameters: boolean, + preselectedVariantId: number + ) { + + // Set variant based on what's passed to this function + this.setOptionsFromPreselectedVariantId(preselectedVariantId); + + // Find and set selectedVariant + let selectedVariant = this.setSelectedVariant(); + + // Update display values based on selectedVariant + this.setDefaultsFromSelectedVariant(selectedVariant); + + // Update all_options_selected if all options are selected + this.setallOptionsSelected(); + + // Update order of product gallery based on new selections + this.reorderProductGallery(); + + // Refresh pickup availability block + this.fetchAndRefreshPickup(); + + // Add variant id to URL parameters + if (enableUrlParameters && this.all_options_selected) { + this.updateUrlParameters(); + } + + // Update calculated price with quantity + this.calculated_price = this.quantity * this.current_variant_price; + + }, + + // Set selectedVariant based on selected options + // This will find the selectedVariant based on selected options + setSelectedVariant () { + let optionsSize = this.product.options.length; + let selectedVariant; + + switch (optionsSize) { + case 1: + selectedVariant = this.product.variants.find(variant => + (!this.option_1 || this.handleize(variant.option1) === this.option_1) + ); + break; + case 2: + selectedVariant = this.product.variants.find(variant => + (!this.option_1 || this.handleize(variant.option1) === this.option_1) && + (!this.option_2 || this.handleize(variant.option2) === this.option_2) + ); + break; + case 3: + selectedVariant = this.product.variants.find(variant => + (!this.option_1 || this.handleize(variant.option1) === this.option_1) && + (!this.option_2 || this.handleize(variant.option2) === this.option_2) && + (!this.option_3 || this.handleize(variant.option3) === this.option_3) + ); + break; + } + + return selectedVariant; + }, + + // Check if preselectedVariantId exists and set options + setOptionsFromPreselectedVariantId ( + preselectedVariantId: number + ) { + + let optionsSize = this.product.options.length; + + if (preselectedVariantId) { + this.current_variant_id = preselectedVariantId; + + // Find the matching variant in this.product.variants + const selectedVariant = this.product.variants.find((variant: { id: number }) => + variant.id === preselectedVariantId + ); + + // If a matching variant is found, update options to match the selected variant + if (selectedVariant) { + switch (optionsSize) { + case 1: + this.option_1 = this.handleize(selectedVariant.option1); + break; + case 2: + this.option_1 = this.handleize(selectedVariant.option1); + this.option_2 = this.handleize(selectedVariant.option2); + break; + case 3: + this.option_1 = this.handleize(selectedVariant.option1); + this.option_2 = this.handleize(selectedVariant.option2); + this.option_3 = this.handleize(selectedVariant.option3); + break; + } + } + + } + + }, + + // Update values based on selected variant + setDefaultsFromSelectedVariant ( + selectedVariant: number + ) { + // Get product form container + let formContainer = this.$refs.formContainer; + + // If variant exists + if (selectedVariant) { + + // Update basics + this.current_variant_available = selectedVariant.available; + this.current_variant_exists = true; + this.current_variant_id = selectedVariant.id; + this.current_variant_price = selectedVariant.price; + this.current_variant_compare_price = selectedVariant.compare_at_price; + this.current_variant_sku = selectedVariant.sku; + this.current_variant_title = selectedVariant.title; + + // Find the matching variant in this.variants + const customSelectedVariant = this.variants[this.current_variant_id]; + + // If a matching variant is found, update current_variant_inventory_quantity + if (customSelectedVariant && customSelectedVariant.length > 0) { + this.current_variant_inventory_quantity = customSelectedVariant[0].inventory_quantity; + this.current_variant_inventory_policy = customSelectedVariant[0].inventory_policy; + } + + // Set featured image id if available + this.current_variant_featured_image_id = selectedVariant.featured_image ? selectedVariant.featured_image.id : null; + + // Update unit price + if (selectedVariant.unit_price) { + this.current_variant_unit_price = selectedVariant.unit_price; + this.current_variant_unit_label = selectedVariant.unit_price_measurement.reference_unit; + } + + // Set selling plan to true if allocations are available + this.current_variant_has_selling_plan = Array.isArray(selectedVariant.selling_plan_allocations) && selectedVariant.selling_plan_allocations.length > 0; + if (this.current_variant_has_selling_plan && this.enable_selling_plan_widget) { + + // Update if variant requires plan + this.current_variant_requires_selling_plan = selectedVariant.requires_selling_plan; + + // Set array of available groups + this.current_variant_selling_group_ids = selectedVariant.selling_plan_allocations.map(allocation => allocation.selling_plan_group_id); + this.current_variant_selling_group_ids.push('0'); + + // Update current_variant_selling_group_id if it is not within current_variant_selling_group_ids + this.current_variant_selling_group_id = this.current_variant_selling_group_ids.includes(this.current_variant_selling_group_id) ? this.current_variant_selling_group_id : this.current_variant_selling_group_ids[0]; + + // Check if allocation exists with matching group and plan + let matchingAllocation = selectedVariant.selling_plan_allocations.find( + allocation => allocation.selling_plan_group_id === this.current_variant_selling_group_id && + allocation.selling_plan_id === parseInt(this.current_variant_selling_plan_id) + ); + + // Set values to first plan if matching allocation not found + if (!matchingAllocation) { + const firstAllocationInGroup = selectedVariant.selling_plan_allocations.find( + allocation => allocation.selling_plan_group_id === this.current_variant_selling_group_id + ); + if (firstAllocationInGroup) { + matchingAllocation = firstAllocationInGroup; + this.current_variant_selling_plan_id = firstAllocationInGroup.selling_plan_id; + } + } + + // Update prices to matchingAllocation + if (matchingAllocation) { + this.defaultSellingPlanPrice = matchingAllocation.per_delivery_price; + this.current_variant_price = matchingAllocation.per_delivery_price; + this.current_variant_compare_price = matchingAllocation.compare_at_price; + this.current_variant_unit_price = matchingAllocation.unit_price; + } + + // Update defaults and summary from selling plan data + if (this.current_variant_selling_plan_id !== 0) { + + // Update plan basics + let sellingPlanInput = formContainer.querySelector('.js-' + this.current_variant_selling_plan_id); + let sellingPlanData = JSON.parse(sellingPlanInput.getAttribute('data-selling-plan')); + this.current_variant_selling_plan_name = sellingPlanData.name.trim() + '.'; + this.current_variant_selling_plan_description = sellingPlanData.description.trim(); + + // Update plan savings from price adjustment array + let savingSummary = ''; + let savingHighlight = ''; + sellingPlanData.price_adjustments.forEach((price_adjustment, index, array) => { + let savingValue = price_adjustment.value; + if (savingValue <= 0) return; + let savingsPercentLabel = ''; + let savingsCount = price_adjustment.order_count || 'ongoing'; + let punctuation = index === (array.length - 1) ? '. ' : ''; + let sentenceStart = 'Save '; + switch (price_adjustment.value_type) { + case 'percentage': + savingsPercentLabel = '%'; + break; + case 'price': + savingValue = Shopify.formatMoney(sellingPlanData.compare_at_price - savingValue); + sentenceStart = ''; + savingHighlight = `Save ${savingValue}${savingsPercentLabel}`; + break; + case 'fixed_amount': + savingValue = Shopify.formatMoney(savingValue); + break; + } + + savingSummary += `${sentenceStart}${savingValue}${savingsPercentLabel} for ${savingsCount} orders${punctuation}`; + if (index === 0) { + savingHighlight = `Save ${savingValue}${savingsPercentLabel}`; + } + }); + + this.current_variant_selling_plan_savings_description = savingSummary; + this.current_variant_selling_plan_savings_summary = savingHighlight; + } + + // If no group selected reset plan selection + if (this.current_variant_selling_group_id == "0") { + this.current_variant_selling_plan_id = 0; + } + + } + + } + + // If variant does not exist + else { + this.current_variant_exists = false; + } + + }, + + // Update all_options_selected if all options are selected + setallOptionsSelected () { + let optionsSize = this.product.options.length; + this.all_options_selected = + (optionsSize === 1 && this.option_1) || + (optionsSize === 2 && this.option_1 && this.option_2) || + (optionsSize === 3 && this.option_1 && this.option_2 && this.option_3); + if(optionsSize === 1) { + this.all_options_selected = true; + } + }, + + // Update order of product gallery images + reorderProductGallery () { + let formContainer = this.$refs.formContainer; + + // Check if enable_variant_images is enabled - this checks if store is using "Only show media associated with the selected variant" + // If so we scroll to start of slider + if (this.enable_variant_images) { + setTimeout(() => { + this.galleryScrollToStart(0); + }, 100); + } + + // If store is not using enable_variant_images + // Scroll to first featured image + else { + const featuredImages = formContainer.querySelectorAll('.js-' + this.current_variant_featured_media_id); + if (featuredImages.length > 0) { + const slideIndex = featuredImages[0].getAttribute('data-slide'); + if (slideIndex) { + this.galleryScrollToIndex(parseInt(slideIndex)); + } + } + } + + }, + + // Add variant id to URL parameters + updateUrlParameters () { + const searchParams = new URLSearchParams(window.location.search); + searchParams.set('variant', this.current_variant_id); + const newRelativePathQuery = window.location.pathname + '?' + searchParams.toString(); + history.replaceState(null, '', newRelativePathQuery); + }, + + // Refresh pickup availability block + fetchAndRefreshPickup () { + const formContainer = this.$refs.formContainer; + const pickupContainer = formContainer.querySelector('.js-pickup'); + + if (pickupContainer) { + fetch(window.location + '§ion_id=product__pickup') + .then( async (response) => { + const data = await response.text(); + if (response.status === 200) { + const html = document.createElement('div'); html.innerHTML = data; + const htmlCleaned = html.querySelector('.js-pickup'); + if(htmlCleaned){ + pickupContainer.innerHTML = htmlCleaned.innerHTML; + } + } + else { + console.error('Error:', error); + } + }) + .catch((error) => { + console.error('Error:', error); + }); + } + }, + + // Scroll to next gallery item + galleryScrollNext () { + + // Unzoom the gallery + this.galleryResetZoom(); + + // Set the next index + this.gallery_next = this.gallery_index + 1; + if (this.gallery_next > this.gallery_size){ + this.gallery_next = 0; + } + + // Go to new slide + this.galleryScrollToIndex(this.gallery_next); + }, + + // Scroll to previous gallery item + galleryScrollBack () { + + // Unzoom the gallery + this.galleryResetZoom(); + + // Set the next index + this.gallery_next = this.gallery_index - 1; + if (this.gallery_next < 0){ + this.gallery_next = this.gallery_size; + } + + // Go to new slide + this.galleryScrollToIndex(this.gallery_next); + }, + + // Scroll to a specific gallery item + galleryScrollToIndex ( + index: number + ) { + + // Unzoom the gallery + this.galleryResetZoom(); + + // Get product form container + let formContainer = this.$refs.formContainer; + + // Get sliders + let slider = formContainer.querySelector('.js-slider'); + let thumbnailSlider = formContainer.querySelector('.js-thumbnailSlider'); + let zoomSlider = formContainer.querySelector('.js-zoomSlider'); + + // Go to slide + let currentSlide = slider.querySelector('[data-slide="' + index +'"]'); + if (currentSlide) { + let currentSlidePosition = currentSlide.offsetLeft; + slider.scrollTo({ + top: 0, + left: currentSlidePosition, + behavior: 'smooth' + }); + } + + // Go to slide on thumbnail + if (thumbnailSlider){ + let currentThumb = thumbnailSlider.querySelector('[data-slide="' + index +'"]'); + if (currentThumb) { + let currentThumbPosition = currentThumb.offsetTop; + thumbnailSlider.scrollTo({ + top: currentThumbPosition-200, + left: 0, + behavior: 'smooth' + }); + } + } + + // Go to slide on fullscreen gallery + setTimeout(() => { + if (zoomSlider){ + let currentSlide = zoomSlider.querySelector('[data-slide="' + index +'"]'); + let currentSlidePosition = currentSlide.offsetLeft; + if (currentSlide) { + zoomSlider.scrollTo({ + top: 0, + left: currentSlidePosition, + behavior: 'smooth' + }); + } + } + }, 100); + + // Update index + this.gallery_index = index; + }, + + // Scroll to start of gallery slider + galleryScrollToStart () { + + // Unzoom the gallery + this.galleryResetZoom(); + + // Get product form container + let formContainer = this.$refs.formContainer; + + // Get sliders + let slider = formContainer.querySelector('.js-slider'); + let thumbnailSlider = formContainer.querySelector('.js-thumbnailSlider'); + + // Go to slide + slider.scrollTo({ + top: 0, + left: 0, + behavior: 'smooth' + }); + + // Go to slide on thumbnail + if (thumbnailSlider){ + thumbnailSlider.scrollTo({ + top: 0, + left: 0, + behavior: 'smooth' + }); + } + + // Update index + this.gallery_index = 0; + }, + + // Unzoom all images + galleryResetZoom () { + for(let i = 0; i < this.gallery_size; i++) { + this['gallery_zoom_' + i] = false; + } + }, + + galleryZoomIn() { + this['gallery_zoom_'+this.gallery_index] = true; + }, + + galleryZoomOut() { + this['gallery_zoom_'+this.gallery_index] = false; + } + +}; \ No newline at end of file diff --git a/src/ts/search/search.ts b/src/ts/search/search.ts index 5c51d86d..754a9c04 100644 --- a/src/ts/search/search.ts +++ b/src/ts/search/search.ts @@ -2,19 +2,24 @@ import { Params } from "../models.interface"; export const search = { // Fetch search suggestions and update alpine variables - fetchAndUpdateSearch(event: InputEvent, params: Params, resources: any) { - // Build query parameters string + async fetchAndUpdateSearch ( + event: InputEvent, + params: Params, + resources: any + ) { + + // Reset search focus variables + this.search_focus_index = ''; + this.search_focus_url = ''; + + // Function to build query parameters string const buildParams = () => { - let paramsArr = []; - for (const [key, value] of Object.entries(params)) { - if (value) { - paramsArr.push(key.toString()); - } - } - const paramsString = paramsArr.join(); - return paramsString; + return Object.entries(params) + .reduce((acc, [key, value]) => value ? acc.concat(key) : acc, []) + .join(); }; + // Function to build resource string const buildResources= () => { let resourceArr = []; for (const [key, value] of Object.entries(resources)) { @@ -26,50 +31,97 @@ export const search = { return resourcesString; }; - const searchTerm = (event.target as HTMLInputElement).value; - + // Extract search term and trim white spaces + this.search_term = event.target.value.trim(); this.search_loading = true; - if (searchTerm.length === 0 || !searchTerm.replace(/\s/g, "").length) { + + // If search term is empty or only contains whitespaces, reset search items and stop execution + if (!this.search_term) { this.search_loading = false; this.search_items = []; - this.search_items_collections = []; - this.search_items_pages = []; - this.search_items_articles = []; - this.search_items_queries = []; + this.search_items_collections = []; + this.search_items_pages = []; + this.search_items_articles = []; + this.search_items_queries = []; return; } - fetch( - `${window.Shopify.routes.root}search/suggest.json?q=${searchTerm}&resources[type]=${buildResources()}&resources[limit]=6&[options][fields]=${buildParams()}§ion_id=predictive-search` - ) - .then((response) => { - if (!response.ok) { - var error = new Error(response.status.toString()); - // this.close(); - throw error; - } - return response.json(); - }) - .then((data) => { - let collections = data.resources.results.collections; - let pages = data.resources.results.pages; - let products = data.resources.results.products; - let articles = data.resources.results.articles; - let queries = data.resources.results.queries; - this.search_items = products ? products : []; - this.search_items_collections = collections ? collections : []; - this.search_items_pages = pages ? pages : []; - this.search_items_articles = articles ? articles : []; - this.search_items_queries = queries ? queries : []; - this.search_loading = false; - }) - .catch((error) => { - (this.error_title = error.message), - (this.error_message = error.description), - (this.show_alert = true); - // this.close(); - throw error; - }); + // Fetch request URL + const requestUrl = `${window.Shopify.routes.root}search/suggest.json?q=${this.search_term}&resources[type]=${buildResources()}&resources[limit]=6&[options][fields]=${buildParams()}`; + + // Get data from shopify + try { + const response = await fetch(requestUrl); + + // If response is not OK, throw an error + if (!response.ok) { + throw new Error(response.status.toString()); + } + + // Parse response data + const data = await response.json(); + + + // Assign data to relevant variables + const { products, collections, pages, articles, queries } = data.resources.results; + this.search_items = products ? products : []; + this.search_items_collections = collections ? collections : []; + this.search_items_pages = pages ? pages : []; + this.search_items_articles = articles ? articles : []; + this.search_items_queries = queries ? queries : []; + this.search_loading = false; + } + + // If an error occurred, set the error message and show an alert + catch (error: any) { + this.error_message = error.description; + this.error_alert = true; + } + + // Stop loading regardless of whether an error occurred + finally { + this.search_loading = false; + this.search_focus_index = ''; + this.search_focus_url = ''; + } + + }, + + // Get total search results + getSearchItems () { + const totalResults = [ + ...this.search_items_queries, + ...this.search_items_pages, + ...this.search_items_articles, + ...this.search_items_collections, + ...this.search_items + ]; + return totalResults; }, + // Update selected search index when using arrow keys + updateSelectedSearch ( + increment: number + ) { + const searchItems = this.getSearchItems(); + if (this.search_focus_index === "") { + this.search_focus_index = 0; + } else { + this.search_focus_index = (this.search_focus_index + increment + searchItems.length) % searchItems.length; + } + this.search_focus_url = searchItems[this.search_focus_index].url; + }, + + // Go to selected search item when using arrow keys + goToSelectedItem ( + formElement: HTMLFormElement + ) { + if (this.search_focus_url === '') { + formElement.submit(); + } + else { + window.location.href = this.search_focus_url; + } + } + }; \ No newline at end of file diff --git a/src/ts/shopify/formatMoney.ts b/src/ts/shopify/formatMoney.ts deleted file mode 100644 index 8c8fd23b..00000000 --- a/src/ts/shopify/formatMoney.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { createLogger } from "vite"; - -export default function formatMoney( - cents: string | number, - currency: string, - showCurrency?: boolean, - format = "amount", - subunits = '100' -) { - // Convert string cents to number - if (typeof cents == "string") { - cents = cents.replace(".", ""); - } - - let value = ""; - const placeholderRegex = /\{\{\s*(\w+)\s*\}\}/; - let formatString; - let isISO = false; - const isoCurrencyRegex = /^[A-Z]{3}$/; - if(isoCurrencyRegex.test(currency)) { - isISO = true; - } - - if (currency && showCurrency && !isISO) { - formatString = `${currency + `{{ ${format} }}`}`; - } else if(currency && showCurrency && isISO) { - formatString = `${`{{ ${format} }}` + ' ' + currency}`; - } else if (showCurrency) { - formatString = "$" + `{{ ${format} }}`; - } else { - formatString = `{{ ${format} }}`; - } - - // Set default options - function defaultOption(opt: T | undefined, def: T): T { - return typeof opt == "undefined" ? def : opt; - } - - // Format number with delimiters - function formatWithDelimiters( - number: number, - precision?: number, - thousands?: string, - decimal?: string - ) { - precision = defaultOption(precision, 2); - thousands = defaultOption(thousands, ","); - decimal = defaultOption(decimal, "."); - - if (isNaN(number) || number == null) { - return "0"; - } - - if(subunits === '1000'){ - precision = 3 - } - - // Adjust the number to account for subunits - number = number / 100; - - const numberString = number.toFixed(precision); - - const parts = numberString.split("."), - dollars = parts[0].replace(/(\d)(?=(\d\d\d)+(?!\d))/g, "$1" + thousands), - cents = parts[1] ? decimal + parts[1] : ""; - - return String(dollars + cents); - } - - // Determine the format based on the placeholder - switch (formatString.match(placeholderRegex)![1]) { - case "amount": - value = formatWithDelimiters(cents as number, 2); - break; - case "amount_no_decimals": - // Check if the numberString ends with "00" - const numberString = (cents as number / 100).toFixed(2); - if (numberString.endsWith("00")) { - value = formatWithDelimiters(cents as number, 0); - } else { - // Format as if format was "amount" since it doesn't end with "00" - value = formatWithDelimiters(cents as number, 2); - } - break; - case "amount_with_comma_separator": - value = formatWithDelimiters(cents as number, 2, ".", ","); - break; - case "amount_no_decimals_with_comma_separator": - value = formatWithDelimiters(cents as number, 0, ".", ","); - break; - } - - // Replace the placeholder with the formatted value - return formatString.replace(placeholderRegex, value); -} diff --git a/src/ts/shopify/shopify.ts b/src/ts/shopify/shopify.ts new file mode 100644 index 00000000..adeeb29f --- /dev/null +++ b/src/ts/shopify/shopify.ts @@ -0,0 +1,91 @@ +import { ShopifyInterface } from "../models.interface"; +import { globals } from "../globals/globals"; +let Shopify: ShopifyInterface = window.Shopify ?? {}; + +// Format money from subunits to standard format +function formatMoney ( + cents: number | string, + forceEnableCurrency: boolean +): string { + + // If cents is a string, remove the decimal point + if (typeof cents == 'string') { cents = cents.replace('.',''); } + + // Get variables we need + let value = ''; + let placeholderRegex = /\{\{\s*(\w+)\s*\}\}/; + let formatStringWithoutCurrency = globals.price_format_without_currency; + let formatStringWithCurrency = globals.price_format_with_currency; + let formatString = ''; + let enableZeros = globals.price_enable_zeros; + let enableCurrency = globals.price_enable_currency; + + // Use strict equality '===' for comparison to avoid type coercion + // Also, use ternary operator for cleaner and more concise code + formatString = enableCurrency === false ? formatStringWithCurrency : formatStringWithoutCurrency; + + // Update formatString based on forceEnableCurrency. If this is true we'll use formatStringWithCurrency + if (forceEnableCurrency === false) { + formatString = formatStringWithCurrency; + } else if (forceEnableCurrency === true) { + formatString = formatStringWithoutCurrency; + } + + // Function to return the default value if the option is undefined + function defaultOption(opt, def) { + return (typeof opt == 'undefined' ? def : opt); + } + + // Function to format the number with delimiters + function formatWithDelimiters(number, precision, thousands, decimal) { + + // Set default values for precision, thousands, and decimal if they are not provided + precision = defaultOption(precision, 2); + thousands = defaultOption(thousands, ','); + decimal = defaultOption(decimal, '.'); + + // If the number is not a number or null, return 0 + if (isNaN(number) || number == null) { return 0; } + + // Convert the number from subunits to standard format + number = (number/100.0).toFixed(precision); + + // Split the number into dollars and cents + var parts = number.split('.'), + dollars = parts[0].replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1' + thousands), + cents = parts[1] ? (decimal + parts[1]) : ''; + + return dollars + cents; + } + + // Determine the format string and format the value accordingly + switch(formatString.match(placeholderRegex)[1]) { + case 'amount': + value = formatWithDelimiters(cents, 2); + break; + case 'amount_no_decimals': + value = formatWithDelimiters(cents, 0); + break; + case 'amount_with_comma_separator': + value = formatWithDelimiters(cents, 2, '.', ','); + break; + case 'amount_no_decimals_with_comma_separator': + value = formatWithDelimiters(cents, 0, '.', ','); + break; + } + + // Replace the placeholder in the format string with the value + value = formatString.replace(placeholderRegex, value); + + // If enableZeros is false, remove ".00" from the price + if (enableZeros === false) { + value = value.replace('.00', ''); + } + + return value; +} + +// Update Shopify object with new function +// Then export to rest of app +Shopify.formatMoney = formatMoney; +export { Shopify }; \ No newline at end of file diff --git a/src/ts/util/util.ts b/src/ts/util/util.ts deleted file mode 100644 index 02480eb5..00000000 --- a/src/ts/util/util.ts +++ /dev/null @@ -1,77 +0,0 @@ -export const utils = { - // set query params - setQuery: (query: string) => { - - if(query === "") { - window.history.replaceState({}, '', window.location.pathname); - return; - } else { - const path = window.location.pathname; - const params = new URLSearchParams(window.location.search); - const hash = window.location.hash; - - // Update query string values - params.set('edit', query); - - // Update URL - window.history.replaceState({}, '', `${path}?${params.toString()}${hash}`); - } - }, - - - // Initiate header scrolling (unless in preview mode) - initScroll: function () { - if(!Shopify.visualPreviewMode){ - const body = document.querySelector('body'); - - body!.setAttribute('x-on:scroll.window', `() => { - if (window.pageYOffset > 400) { - is_scrolled = window.pageYOffset > prev_scroll_pos ? true : false; - prev_scroll_pos = window.pageYOffset; - $refs.header.style.transform = 'none' - } else { - is_scrolled = false; - prev_scroll_pos = window.pageYOffset; - $refs.header.style.transform = 'none' - } - }`); - body!.setAttribute('x-intersect.half:enter', 'scroll_up = false'); - body!.setAttribute('x-intersect.half:leave', 'scroll_up = true'); - } - }, - // Play sound - playSound: function (audio: HTMLAudioElement) { - if (!this.isMobileDevice()) { - audio.play(); - } - }, - - // Check if device is mobile - isMobileDevice: function() { - return (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)); - }, - - // Copy value of input to clipboard and focus element - copyToClipboard: function (id: string) { - var copyText = document.getElementById(id) as HTMLInputElement; - copyText.select(); - copyText.setSelectionRange(0, 99999); - navigator.clipboard.writeText(copyText.value); - }, - - // Check line items values - checkLineItems(handle: string): void { - const inputs = document.querySelectorAll( - `.custom-input_${handle}` - ); - const inputsArr = Array.from(inputs); - const requiredFields = inputsArr.filter((inp) => inp.required); - - if (requiredFields.some((field) => field.value === "")) { - this.incomplete_fields = true; - } else { - this.incomplete_fields = false; - } - }, - -}; \ No newline at end of file diff --git a/src/ts/utils/utils.ts b/src/ts/utils/utils.ts new file mode 100644 index 00000000..840dcfc5 --- /dev/null +++ b/src/ts/utils/utils.ts @@ -0,0 +1,278 @@ +export const utils = { + // Initiate animation setup - classes will swap when elements scroll into view + initAnimationObserver () { + + // observerCallback for IntersectionObserver + const observerCallback: IntersectionObserverCallback = function (entries) { + entries.forEach((entry) => { + let element = document.getElementById((entry.target as HTMLElement).dataset.id!); + + // Update classes + if (entry.isIntersecting) { + + // Set delay for animation + const delay = (entry.target as HTMLElement).dataset.delay || ''; + + // Use try-catch to handle JSON parsing errors + let replaceClasses: { [key: string]: string }; + try { + replaceClasses = JSON.parse( + (entry.target as HTMLElement).dataset.replace!.replace(/'/g, '"') + ) as { [key: string]: string }; + } + catch (error) { + console.error('Error parsing replaceClasses:', error); + return; + } + + // Avoid using eval due to security risks, instead use a safer alternative + const callback = (entry.target as HTMLElement).dataset.callback!; + if (callback && (window as any)[callback] && typeof (window as any)[callback] === "function") { + (window as any)[callback](); + } + + Object.keys(replaceClasses).forEach(function (key) { + setTimeout(function () { + if (element) { + element.classList.remove(key); + if (replaceClasses[key]) { + element.classList.add(replaceClasses[key]); + } + } else { + entry.target.classList.remove(key); + if (replaceClasses[key]) { + entry.target.classList.add(replaceClasses[key]); + } + } + }, parseInt(delay, 10)); + }); + } + }); + }; + + // Get elements with .js-animation + const animationElements = document.querySelectorAll(".js-animation"); + if (animationElements.length > 0) { + const animationObserver = new IntersectionObserver(observerCallback); + animationElements.forEach(function (target) { + animationObserver.observe(target); + }); + } + }, + + // set query params + // NEEDS TO GET REWORKED + setQuery: (query: string) => { + + if(query === "") { + window.history.replaceState({}, '', window.location.pathname); + return; + } else { + const path = window.location.pathname; + const params = new URLSearchParams(window.location.search); + const hash = window.location.hash; + + // Update query string values + params.set('edit', query); + + // Update URL + window.history.replaceState({}, '', `${path}?${params.toString()}${hash}`); + } + }, + + // Refresh when using back button when history states were changed + initPopstate () { + window.addEventListener('popstate', async () => { + window.location.href = location.href; + }); + }, + + debounce( + func: (...args: any[]) => void, + wait: number + ) { + let timeout: ReturnType | null; + return function executedFunction(...args: any[]) { + const later = () => { + clearTimeout(timeout!); + func(...args); + }; + clearTimeout(timeout!); + timeout = setTimeout(later, wait); + }; + }, + + // Match to liquid handle filter + handleize ( + str: string + ) { + return str + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + }, + + // Add classes to images after loading + loadImages () { + + // Function to add 'loaded' classes to images and image containers + const loadImage = (img: HTMLImageElement) => { + img.classList.add('loaded'); + img.parentElement?.parentElement?.classList.add('loaded'); + }; + + // Iterate over each image with .js-image + const images = document.querySelectorAll('img.js-image'); + images.forEach((img: Element) => { + const imageElement = img as HTMLImageElement; + + // When image is cached + if(imageElement.complete){ + loadImage(imageElement); + } + + // Check when image loads + else { + imageElement.onload = () => { + loadImage(imageElement); + } + } + + // Set a fallback for adding the classes + setTimeout(() => { + loadImage(imageElement); + }, 2000); + }); + }, + + // Copy value of input to clipboard and focus element + copyToClipboard ( + id: string + ) { + const copyText = document.getElementById(id) as HTMLInputElement; + + // Check if copyText is not null before proceeding to avoid potential errors + if(copyText) { + copyText.select(); + copyText.setSelectionRange(0, 99999); + + // Use try-catch to handle potential errors when writing to clipboard + try { + navigator.clipboard.writeText(copyText.value); + } + catch (err) { + console.error('Failed to copy text: ', err); + } + } else { + console.error('Element not found: ', id); + } + }, + + // Get cookie by name + getCookie ( + name: string + ) { + + // Using RegExp for more efficient and accurate cookie name matching + const cookieMatch = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)'); + return cookieMatch ? cookieMatch.pop() : null; + }, + + // Handle audio playing + playAudioIfEnabled ( + audioFile: string + ) { + if (this.enable_audio) { + const audio = new Audio(audioFile); + if (window.innerWidth > 768) { + audio.play(); + } + } + }, + + // Open menu drawer + openMenu () { + this.menu_drawer = true; + this.enable_body_scrolling = false; + this.playAudioIfEnabled(this.click_audio); + }, + + // Open cart drawer + openCart () { + this.cart_drawer = true; + this.cart_alert = false; + this.enable_body_scrolling = false; + this.playAudioIfEnabled(this.click_audio); + }, + + // Open search drawer + openSearch () { + this.menu_drawer = false; + this.search_drawer = true; + this.enable_body_scrolling = false; + setTimeout(() => { + let searchField = document.querySelector('#search-field') as HTMLInputElement; + if (searchField) { + searchField.focus(); + } + }, 400); + }, + + // Close cart drawer + closeCart () { + this.cart_drawer = false; + this.cart_alert = false; + this.enable_body_scrolling = true; + }, + + // Close menu drawer + closeMenu (){ + this.menu_drawer = false; + this.enable_body_scrolling = true; + }, + + // Close menu drawer + closeSearch () { + this.search_drawer = false; + this.enable_body_scrolling = true; + }, + + // Initiate header scrolling (unless in preview mode) + initScroll: function () { + if(!Shopify.visualPreviewMode){ + const body = document.querySelector('body'); + + body!.setAttribute('x-on:scroll.window', `() => { + if (window.pageYOffset > 400) { + is_scrolled = window.pageYOffset > prev_scroll_pos ? true : false; + prev_scroll_pos = window.pageYOffset; + $refs.header.style.transform = 'none' + } else { + is_scrolled = false; + prev_scroll_pos = window.pageYOffset; + $refs.header.style.transform = 'none' + } + }`); + body!.setAttribute('x-intersect.half:enter', 'scroll_up = false'); + body!.setAttribute('x-intersect.half:leave', 'scroll_up = true'); + } + }, + + // Check line items values + // REWORK + checkLineItems(handle: string): void { + const inputs = document.querySelectorAll( + `.custom-input_${handle}` + ); + const inputsArr = Array.from(inputs); + const requiredFields = inputsArr.filter((inp) => inp.required); + + if (requiredFields.some((field) => field.value === "")) { + this.incomplete_fields = true; + } else { + this.incomplete_fields = false; + } + }, + +}; \ No newline at end of file