I'm working on a web project that uses Alpine.js for dynamic updates on a product page. The product initially loads with a price, and users have the option to add a quantity selector dynamically. However, I've encountered an issue when the quantity selector is added dynamically during the same session.
The problem is that when the quantity selector is added after the page loads, and I increase the quantity, the price does not update as expected. The strange thing is that if the quantity selector is present when the page loads, everything works as intended.
I suspect there may be an issue with how Alpine.js handles dynamic changes in this context, but I'm not sure how to fix it. Can anyone help me understand what might be causing this issue and provide a solution or workaround?
video: https://youtu.be/RhcejGGEeZc
Here's the relevant HTML and Alpine.js code for the product, as well as the separate components for price and quantity that I'm using:
<div
x-data="product"
class="component component--product product not-product-page text-center app-pad gjs-selected">
<div
class="product__content">
<div
class="component component--product-price product__prices text-left"
x-data="price">
<span
class="component__item product__price"
x-bind="priceField">
1380
</span>
</div>
</div>
</div>
HTML of added quantity element:
<div
x-ref="quantityBlock"
x-effect="$root.querySelector('.product__quantity__wrapper').priceLiveUpdate = priceLiveUpdate;"
x-data='{"priceLiveUpdate":true}'
class="component component--quantity product__quantity product__quantity--stretch">
<div
x-data="quantity"
class="product__quantity__wrapper justify-content-start">
<label
class="component__item product__quantity__label text-left">Quantity</label>
<button
class="component__item product__quantity__button product__quantity__button--minus"
x-bind="quantityBtn"
value="-">
<span
class="component component--icon icon-block text-center m-x-auto">
<i class="fa fa-minus"></i>
</span>
</button>
<input
min="1"
max="10"
step="1"
type="number"
name="quantity"
pattern="[0-9]*"
class="component__item product__quantity__input"
x-bind="quantityField" />
<button
class="component__item product__quantity__button product__quantity__button--plus"
x-bind="quantityBtn"
value="+">
<span class="component component--icon icon-block text-center m-x-auto">
<i class="fa fa-plus"></i>
</button>
</div>
</div>
Alpine.js product file:
import Alpine, { AlpineComponent } from "alpinejs";
import {
ProductInterface,
ProductComponentInterface,
} from "@/alpinejs/Product/core/interfaces";
/**
* AlpineJS Product Component
* There is placed general data, that should be used by partials
*
* @package AlpineJS\Product
*/
Alpine.data(
"product",
(): AlpineComponent<ProductComponentInterface> => ({
root: undefined,
__payload: undefined,
quantity: 1,
selectedVariant: undefined,
init() {
this.root = this.$root;
},
async payload(): Promise<ProductInterface | undefined> {
const handle: string | undefined = this.root?.dataset?.productHandle;
if (handle) {
return this.$store.products.fetch(handle);
}
return undefined;
},
})
);
quantity file:
import Alpine, { AlpineComponent } from "alpinejs";
import type {
QuantityComponentInterface,
QuantityValidationOptionsInterface,
} from "./core";
Alpine.data(
"quantity",
(): AlpineComponent<QuantityComponentInterface> => ({
/**
* Quantity value
*/
quantityValue: 1,
/**
* Quantity button
*/
quantityBtn: {
["@click"](): void {
this.quanty();
},
},
/**
* Quantity field
*/
quantityField: {
"x-bind:value": "quantityValue",
"x-on:input": "quanty($event.target.value)",
},
/**
*
*/
init(): void {
if (this.$root.quantityValue) {
this.quantityValue = this.$root.quantityValue;
}
this.$watch("$data.value", (value: number, oldValue: number): void => {
this.validate(value, oldValue);
});
this.updateParentQuantity();
},
/**
* Get quantity validation options
*/
getValidationOptions(): QuantityValidationOptionsInterface {
const field: HTMLInputElement | undefined = this.$root.querySelector(
".product__quantity__input"
);
return {
min: field ? +field.min : 1,
step: field ? +field?.step : 1,
max: field ? +field?.max : Infinity,
};
},
/**
* quantity update logic
*
* @param value
*/
quanty(value: number | undefined): void {
if (!value) {
value = this.getQuantity();
}
const { step }: QuantityValidationOptionsInterface =
this.getValidationOptions();
switch ((this.$el as HTMLButtonElement)?.value) {
case "+": {
value += step;
break;
}
case "-": {
value -= step;
break;
}
}
this.validate(value);
},
/**
* When quantity changed with input get Quantity input value
*
*/
getQuantity(): number {
let value: number | undefined = this.quantityValue;
if (!value || isNaN(value)) {
value = 1;
}
return value;
},
/**
* validate quantity value
*
* @param value
* @param oldValue
*/
validate(value: number, oldValue?: number | undefined): void {
if (value === oldValue) {
return;
}
const { min, max }: QuantityValidationOptionsInterface =
this.getValidationOptions();
if (value < min) {
value = min;
}
if (value > max) {
value = max;
}
this.quantityValue = value;
this.$root.quantityValue = value;
this.updateParentQuantity();
},
/**
* Update parent quantity
*/
updateParentQuantity(): void {
this.quantity = this.$root.priceLiveUpdate ? this.quantityValue : 1;
},
})
);
price file:
import { moneyFormatted, PriceComponentInterface } from "./core";
import Alpine, { AlpineComponent } from "alpinejs";
Alpine.data(
"price",
(): AlpineComponent<PriceComponentInterface> => ({
/**
* Main price value
*/
priceValue: "1380",
/**
* Compare price value
*/
comparePriceValue: "1480",
/**
* Default Price Value
*/
defaultPriceValue: 1380,
/**
* Default Compare price value
*/
defaultComparePriceValue: 1480,
/**
* Price field
*/
priceField: {
"x-text": "priceValue",
},
/**
* Price field
*/
comparePriceField: {
"x-text": "comparePriceValue",
},
/**
* Init
*/
init(): void {
this.updatePrices();
this.$watch("$data.quantity", (): void => {
this.updatePrices();
});
this.$watch("$data.selectedVariant", (): void => {
this.updatePrices();
});
},
/**
* Update prices
*/
updatePrices(): void {
let priceValue: number =
this.selectedVariant?.price ?? this.defaultPriceValue;
let comparePriceValue: number =
this.selectedVariant?.compare_at_price ?? this.defaultComparePriceValue;
priceValue *= this.quantity;
comparePriceValue *= this.quantity;
this.priceValue = moneyFormatted(priceValue);
this.comparePriceValue = moneyFormatted(comparePriceValue);
},
})
);
After some debugging, I've found, what the watcher in price file doesn't work when I'm adding quantity, but it works after I delete and add price:
this.$watch("$data.quantity", (): void => {
this.updatePrices();
});
I've tried to find solution for this case, but couldn't