0 && !hasMultipleVariants) { expanded = true; pendingCollapse = false }"
@mouseleave="if (qty > 0 && !hasMultipleVariants) { if (loading) { pendingCollapse = true } else { expanded = false } }"
x-data="{
qty: 0,
hasQty: false,
maxQty: 20,
expanded: false,
loading: false,
pendingCollapse: false,
circleHover: false,
timer: null,
productId: 55,
quickAddCartId: '55',
hasMultipleVariants: false,
// Solo se usa con hasMultipleVariants -- mapea cartId real (id-variante)
// a qty, para poder sumar/restar por línea de carrito sin perder el
// conteo de las demás variantes ya agregadas cuando llega un evento de
// una sola de ellas. Ver el cast a objeto en el bloque @php de arriba.
variantQuantities: {},
// Evitan que el listener de window de más abajo vuelva a aplicar la propia
// mutación que esta misma instancia ya hizo localmente (Livewire.dispatch()
// llega también a esta instancia como evento de window, no solo a las demás).
suppressNextAdd: false,
suppressNextRemove: false,
// Suma todas las líneas de variantQuantities -- el número que ve el
// usuario en el círculo es cuántas unidades de este producto (en
// cualquier presentación) hay en el carrito, no de una variante puntual.
recalcQtyFromVariants() {
this.qty = Object.values(this.variantQuantities).reduce((sum, n) => sum + n, 0);
this.hasQty = this.qty > 0;
},
// window.dispatchEvent (NO Livewire.dispatch) a propósito -- el modal
// (variant-picker.blade.php) ya no es un componente Livewire que vuelve a
// consultar el producto al abrir; escucha este mismo evento de window y
// pinta con el payload que ya viaja acá, calculado una sola vez en el
// server al renderizar ESTA card (ver Product::quickViewPayload()). Así
// abre al instante, sin esperar ningún roundtrip -- Livewire.dispatch()
// sí implicaría uno (cualquier acción Livewire es una request).
//
// qtyInCart de cada variante SÍ se refresca acá antes de despachar --
// ese campo del payload es correcto recién al cargar la página, pero
// queda desactualizado si el cliente agrega/quita algo (de esta misma
// presentación o de otra) DESPUÉS, sin recargar (ej. reabre este mismo
// modal tras agregar una presentación una primera vez). variantQuantities
// de este círculo ya se mantiene sincronizado en vivo con el carrito real
// (ver los 3 listeners de window más abajo) -- se reusa esa MISMA fuente
// en vez de duplicarla, solo para pisar qtyInCart con el valor vigente
// justo antes de abrir.
openVariantPicker() {
const payload = null;
if (payload) {
payload.variants = payload.variants.map((variant) => ({
...variant,
qtyInCart: this.variantQuantities[this.productId + '-' + variant.name.replace(/ /g, '')] || 0,
}));
}
window.dispatchEvent(new CustomEvent('open-variant-picker', { detail: payload }));
},
addFirstTime() {
this.qty = 1;
this.hasQty = true;
this.expanded = true;
this.suppressNextAdd = true;
// dispatchId único por clic (no por instancia): deja que CartCounter
// descarte la segunda aplicación cuando sus 2 instancias montadas
// reciben este mismo evento -- ver nota en CartCounter::add().
const dispatchId = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
Livewire.dispatch('addToCart', { productId: this.quickAddCartId, qty: 1, dispatchId });
},
reopen() {
this.expanded = true;
},
changeQty(amount) {
// Tope real (ver maxQty arriba, Product::stockParaListado()): el botón
// de incrementar no debe pedir más de lo que hay -- respaldo del
// :disabled nativo (ver el markup más abajo), por si igual llega el
// click (estado de Alpine desfasado, DevTools). OJO al editar este
// comentario: todo este objeto x-data vive dentro de un atributo HTML
// entre comillas dobles, así que ninguna comilla doble literal puede
// aparecer acá adentro (ni en código ni en comentarios) -- cierra ese
// atributo antes de tiempo para el parser del navegador. BUG real ya
// encontrado y confirmado en vivo (screenshot: el resto del objeto
// x-data se pintaba como texto suelto encima de la card).
if (amount > 0 && this.qty >= this.maxQty) return;
const next = this.qty + amount;
clearTimeout(this.timer);
if (next < 1) {
this.timer = setTimeout(() => {
this.loading = true;
this.suppressNextRemove = true;
Livewire.dispatch('removeFromCart', { productId: this.quickAddCartId });
setTimeout(() => {
this.expanded = false; // inicia el leave del pill; loading sigue en true, el spinner tapa el número
setTimeout(() => {
this.hasQty = false; // circulo ya visible con el numero anterior; inicia su propio crossfade al icono mas
setTimeout(() => {
this.qty = 0;
this.loading = false;
this.pendingCollapse = false;
}, 150); // debe coincidir con la duración de x-transition.opacity.duration.150ms del span numérico
}, 250); // debe coincidir con la duración de product-card-add-pill-leave-active
}, 700);
}, 500);
return;
}
this.qty = next;
this.loading = true;
this.timer = setTimeout(() => {
Livewire.dispatch('updateCartQuantity', { productId: this.quickAddCartId, qty: this.qty });
setTimeout(() => {
this.loading = false;
if (this.pendingCollapse) {
this.expanded = false;
this.pendingCollapse = false;
}
}, 900);
}, 500);
}
}"
x-init="
$watch('expanded', (value) => {
if (value) { circleHover = false; }
$dispatch('cart-pill-toggle', { show: expanded || circleHover });
});
$watch('circleHover', () => $dispatch('cart-pill-toggle', { show: expanded || circleHover }));
// Mantiene sincronizado este botón cuando el carrito cambia desde otro
// lugar (drawer, VariantPicker, u otra instancia de esta misma card en
// otra sección de la página) -- Livewire.dispatch() llega a estos 3
// eventos como CustomEvent en window, con los mismos campos que ya usan
// CartCounter/CartDrawer (productId, qty).
//
// Con hasMultipleVariants, event.detail.productId es el cartId REAL
// con sufijo de variante (id-variante, ver VariantPicker::cartId() y
// VariantPicker::addToCart(), que dispara 'updateCartQuantity' al
// agregar desde el modal) -- nunca va a ser igual a quickAddCartId
// (que para estos productos es solo el id plano, sin variante, ver el
// bloque PHP de arriba). Por eso se compara por PREFIJO (id + guion)
// en vez de igualdad exacta, y se acumula por variante en
// variantQuantities en vez de pisar qty directo -- así una variante
// que cambia no borra el conteo de las demás ya agregadas.
window.addEventListener('addToCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
variantQuantities[event.detail.productId] = (variantQuantities[event.detail.productId] || 0) + event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
qty += event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('updateCartQuantity', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
variantQuantities[event.detail.productId] = event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
qty = event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('removeFromCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
delete variantQuantities[event.detail.productId];
recalcQtyFromVariants();
expanded = false;
loading = false;
pendingCollapse = false;
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextRemove) { suppressNextRemove = false; return; }
qty = 0;
hasQty = false;
expanded = false;
loading = false;
pendingCollapse = false;
});
">
Herrera
Coffee Cherry Pulp
Coffee Cherry Pulp
S/ 25.00
Edición Limitada
0 && !hasMultipleVariants) { expanded = true; pendingCollapse = false }"
@mouseleave="if (qty > 0 && !hasMultipleVariants) { if (loading) { pendingCollapse = true } else { expanded = false } }"
x-data="{
qty: 0,
hasQty: false,
maxQty: 50,
expanded: false,
loading: false,
pendingCollapse: false,
circleHover: false,
timer: null,
productId: 54,
quickAddCartId: '54',
hasMultipleVariants: false,
// Solo se usa con hasMultipleVariants -- mapea cartId real (id-variante)
// a qty, para poder sumar/restar por línea de carrito sin perder el
// conteo de las demás variantes ya agregadas cuando llega un evento de
// una sola de ellas. Ver el cast a objeto en el bloque @php de arriba.
variantQuantities: {},
// Evitan que el listener de window de más abajo vuelva a aplicar la propia
// mutación que esta misma instancia ya hizo localmente (Livewire.dispatch()
// llega también a esta instancia como evento de window, no solo a las demás).
suppressNextAdd: false,
suppressNextRemove: false,
// Suma todas las líneas de variantQuantities -- el número que ve el
// usuario en el círculo es cuántas unidades de este producto (en
// cualquier presentación) hay en el carrito, no de una variante puntual.
recalcQtyFromVariants() {
this.qty = Object.values(this.variantQuantities).reduce((sum, n) => sum + n, 0);
this.hasQty = this.qty > 0;
},
// window.dispatchEvent (NO Livewire.dispatch) a propósito -- el modal
// (variant-picker.blade.php) ya no es un componente Livewire que vuelve a
// consultar el producto al abrir; escucha este mismo evento de window y
// pinta con el payload que ya viaja acá, calculado una sola vez en el
// server al renderizar ESTA card (ver Product::quickViewPayload()). Así
// abre al instante, sin esperar ningún roundtrip -- Livewire.dispatch()
// sí implicaría uno (cualquier acción Livewire es una request).
//
// qtyInCart de cada variante SÍ se refresca acá antes de despachar --
// ese campo del payload es correcto recién al cargar la página, pero
// queda desactualizado si el cliente agrega/quita algo (de esta misma
// presentación o de otra) DESPUÉS, sin recargar (ej. reabre este mismo
// modal tras agregar una presentación una primera vez). variantQuantities
// de este círculo ya se mantiene sincronizado en vivo con el carrito real
// (ver los 3 listeners de window más abajo) -- se reusa esa MISMA fuente
// en vez de duplicarla, solo para pisar qtyInCart con el valor vigente
// justo antes de abrir.
openVariantPicker() {
const payload = null;
if (payload) {
payload.variants = payload.variants.map((variant) => ({
...variant,
qtyInCart: this.variantQuantities[this.productId + '-' + variant.name.replace(/ /g, '')] || 0,
}));
}
window.dispatchEvent(new CustomEvent('open-variant-picker', { detail: payload }));
},
addFirstTime() {
this.qty = 1;
this.hasQty = true;
this.expanded = true;
this.suppressNextAdd = true;
// dispatchId único por clic (no por instancia): deja que CartCounter
// descarte la segunda aplicación cuando sus 2 instancias montadas
// reciben este mismo evento -- ver nota en CartCounter::add().
const dispatchId = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
Livewire.dispatch('addToCart', { productId: this.quickAddCartId, qty: 1, dispatchId });
},
reopen() {
this.expanded = true;
},
changeQty(amount) {
// Tope real (ver maxQty arriba, Product::stockParaListado()): el botón
// de incrementar no debe pedir más de lo que hay -- respaldo del
// :disabled nativo (ver el markup más abajo), por si igual llega el
// click (estado de Alpine desfasado, DevTools). OJO al editar este
// comentario: todo este objeto x-data vive dentro de un atributo HTML
// entre comillas dobles, así que ninguna comilla doble literal puede
// aparecer acá adentro (ni en código ni en comentarios) -- cierra ese
// atributo antes de tiempo para el parser del navegador. BUG real ya
// encontrado y confirmado en vivo (screenshot: el resto del objeto
// x-data se pintaba como texto suelto encima de la card).
if (amount > 0 && this.qty >= this.maxQty) return;
const next = this.qty + amount;
clearTimeout(this.timer);
if (next < 1) {
this.timer = setTimeout(() => {
this.loading = true;
this.suppressNextRemove = true;
Livewire.dispatch('removeFromCart', { productId: this.quickAddCartId });
setTimeout(() => {
this.expanded = false; // inicia el leave del pill; loading sigue en true, el spinner tapa el número
setTimeout(() => {
this.hasQty = false; // circulo ya visible con el numero anterior; inicia su propio crossfade al icono mas
setTimeout(() => {
this.qty = 0;
this.loading = false;
this.pendingCollapse = false;
}, 150); // debe coincidir con la duración de x-transition.opacity.duration.150ms del span numérico
}, 250); // debe coincidir con la duración de product-card-add-pill-leave-active
}, 700);
}, 500);
return;
}
this.qty = next;
this.loading = true;
this.timer = setTimeout(() => {
Livewire.dispatch('updateCartQuantity', { productId: this.quickAddCartId, qty: this.qty });
setTimeout(() => {
this.loading = false;
if (this.pendingCollapse) {
this.expanded = false;
this.pendingCollapse = false;
}
}, 900);
}, 500);
}
}"
x-init="
$watch('expanded', (value) => {
if (value) { circleHover = false; }
$dispatch('cart-pill-toggle', { show: expanded || circleHover });
});
$watch('circleHover', () => $dispatch('cart-pill-toggle', { show: expanded || circleHover }));
// Mantiene sincronizado este botón cuando el carrito cambia desde otro
// lugar (drawer, VariantPicker, u otra instancia de esta misma card en
// otra sección de la página) -- Livewire.dispatch() llega a estos 3
// eventos como CustomEvent en window, con los mismos campos que ya usan
// CartCounter/CartDrawer (productId, qty).
//
// Con hasMultipleVariants, event.detail.productId es el cartId REAL
// con sufijo de variante (id-variante, ver VariantPicker::cartId() y
// VariantPicker::addToCart(), que dispara 'updateCartQuantity' al
// agregar desde el modal) -- nunca va a ser igual a quickAddCartId
// (que para estos productos es solo el id plano, sin variante, ver el
// bloque PHP de arriba). Por eso se compara por PREFIJO (id + guion)
// en vez de igualdad exacta, y se acumula por variante en
// variantQuantities en vez de pisar qty directo -- así una variante
// que cambia no borra el conteo de las demás ya agregadas.
window.addEventListener('addToCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
variantQuantities[event.detail.productId] = (variantQuantities[event.detail.productId] || 0) + event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
qty += event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('updateCartQuantity', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
variantQuantities[event.detail.productId] = event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
qty = event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('removeFromCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
delete variantQuantities[event.detail.productId];
recalcQtyFromVariants();
expanded = false;
loading = false;
pendingCollapse = false;
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextRemove) { suppressNextRemove = false; return; }
qty = 0;
hasQty = false;
expanded = false;
loading = false;
pendingCollapse = false;
});
">
Herrera
Geisha Coffee
Geisha Coffee
S/ 40.00
Edición Limitada
0 && !hasMultipleVariants) { expanded = true; pendingCollapse = false }"
@mouseleave="if (qty > 0 && !hasMultipleVariants) { if (loading) { pendingCollapse = true } else { expanded = false } }"
x-data="{
qty: 0,
hasQty: false,
maxQty: 50,
expanded: false,
loading: false,
pendingCollapse: false,
circleHover: false,
timer: null,
productId: 53,
quickAddCartId: '53',
hasMultipleVariants: false,
// Solo se usa con hasMultipleVariants -- mapea cartId real (id-variante)
// a qty, para poder sumar/restar por línea de carrito sin perder el
// conteo de las demás variantes ya agregadas cuando llega un evento de
// una sola de ellas. Ver el cast a objeto en el bloque @php de arriba.
variantQuantities: {},
// Evitan que el listener de window de más abajo vuelva a aplicar la propia
// mutación que esta misma instancia ya hizo localmente (Livewire.dispatch()
// llega también a esta instancia como evento de window, no solo a las demás).
suppressNextAdd: false,
suppressNextRemove: false,
// Suma todas las líneas de variantQuantities -- el número que ve el
// usuario en el círculo es cuántas unidades de este producto (en
// cualquier presentación) hay en el carrito, no de una variante puntual.
recalcQtyFromVariants() {
this.qty = Object.values(this.variantQuantities).reduce((sum, n) => sum + n, 0);
this.hasQty = this.qty > 0;
},
// window.dispatchEvent (NO Livewire.dispatch) a propósito -- el modal
// (variant-picker.blade.php) ya no es un componente Livewire que vuelve a
// consultar el producto al abrir; escucha este mismo evento de window y
// pinta con el payload que ya viaja acá, calculado una sola vez en el
// server al renderizar ESTA card (ver Product::quickViewPayload()). Así
// abre al instante, sin esperar ningún roundtrip -- Livewire.dispatch()
// sí implicaría uno (cualquier acción Livewire es una request).
//
// qtyInCart de cada variante SÍ se refresca acá antes de despachar --
// ese campo del payload es correcto recién al cargar la página, pero
// queda desactualizado si el cliente agrega/quita algo (de esta misma
// presentación o de otra) DESPUÉS, sin recargar (ej. reabre este mismo
// modal tras agregar una presentación una primera vez). variantQuantities
// de este círculo ya se mantiene sincronizado en vivo con el carrito real
// (ver los 3 listeners de window más abajo) -- se reusa esa MISMA fuente
// en vez de duplicarla, solo para pisar qtyInCart con el valor vigente
// justo antes de abrir.
openVariantPicker() {
const payload = null;
if (payload) {
payload.variants = payload.variants.map((variant) => ({
...variant,
qtyInCart: this.variantQuantities[this.productId + '-' + variant.name.replace(/ /g, '')] || 0,
}));
}
window.dispatchEvent(new CustomEvent('open-variant-picker', { detail: payload }));
},
addFirstTime() {
this.qty = 1;
this.hasQty = true;
this.expanded = true;
this.suppressNextAdd = true;
// dispatchId único por clic (no por instancia): deja que CartCounter
// descarte la segunda aplicación cuando sus 2 instancias montadas
// reciben este mismo evento -- ver nota en CartCounter::add().
const dispatchId = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
Livewire.dispatch('addToCart', { productId: this.quickAddCartId, qty: 1, dispatchId });
},
reopen() {
this.expanded = true;
},
changeQty(amount) {
// Tope real (ver maxQty arriba, Product::stockParaListado()): el botón
// de incrementar no debe pedir más de lo que hay -- respaldo del
// :disabled nativo (ver el markup más abajo), por si igual llega el
// click (estado de Alpine desfasado, DevTools). OJO al editar este
// comentario: todo este objeto x-data vive dentro de un atributo HTML
// entre comillas dobles, así que ninguna comilla doble literal puede
// aparecer acá adentro (ni en código ni en comentarios) -- cierra ese
// atributo antes de tiempo para el parser del navegador. BUG real ya
// encontrado y confirmado en vivo (screenshot: el resto del objeto
// x-data se pintaba como texto suelto encima de la card).
if (amount > 0 && this.qty >= this.maxQty) return;
const next = this.qty + amount;
clearTimeout(this.timer);
if (next < 1) {
this.timer = setTimeout(() => {
this.loading = true;
this.suppressNextRemove = true;
Livewire.dispatch('removeFromCart', { productId: this.quickAddCartId });
setTimeout(() => {
this.expanded = false; // inicia el leave del pill; loading sigue en true, el spinner tapa el número
setTimeout(() => {
this.hasQty = false; // circulo ya visible con el numero anterior; inicia su propio crossfade al icono mas
setTimeout(() => {
this.qty = 0;
this.loading = false;
this.pendingCollapse = false;
}, 150); // debe coincidir con la duración de x-transition.opacity.duration.150ms del span numérico
}, 250); // debe coincidir con la duración de product-card-add-pill-leave-active
}, 700);
}, 500);
return;
}
this.qty = next;
this.loading = true;
this.timer = setTimeout(() => {
Livewire.dispatch('updateCartQuantity', { productId: this.quickAddCartId, qty: this.qty });
setTimeout(() => {
this.loading = false;
if (this.pendingCollapse) {
this.expanded = false;
this.pendingCollapse = false;
}
}, 900);
}, 500);
}
}"
x-init="
$watch('expanded', (value) => {
if (value) { circleHover = false; }
$dispatch('cart-pill-toggle', { show: expanded || circleHover });
});
$watch('circleHover', () => $dispatch('cart-pill-toggle', { show: expanded || circleHover }));
// Mantiene sincronizado este botón cuando el carrito cambia desde otro
// lugar (drawer, VariantPicker, u otra instancia de esta misma card en
// otra sección de la página) -- Livewire.dispatch() llega a estos 3
// eventos como CustomEvent en window, con los mismos campos que ya usan
// CartCounter/CartDrawer (productId, qty).
//
// Con hasMultipleVariants, event.detail.productId es el cartId REAL
// con sufijo de variante (id-variante, ver VariantPicker::cartId() y
// VariantPicker::addToCart(), que dispara 'updateCartQuantity' al
// agregar desde el modal) -- nunca va a ser igual a quickAddCartId
// (que para estos productos es solo el id plano, sin variante, ver el
// bloque PHP de arriba). Por eso se compara por PREFIJO (id + guion)
// en vez de igualdad exacta, y se acumula por variante en
// variantQuantities en vez de pisar qty directo -- así una variante
// que cambia no borra el conteo de las demás ya agregadas.
window.addEventListener('addToCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
variantQuantities[event.detail.productId] = (variantQuantities[event.detail.productId] || 0) + event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
qty += event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('updateCartQuantity', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
variantQuantities[event.detail.productId] = event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
qty = event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('removeFromCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
delete variantQuantities[event.detail.productId];
recalcQtyFromVariants();
expanded = false;
loading = false;
pendingCollapse = false;
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextRemove) { suppressNextRemove = false; return; }
qty = 0;
hasQty = false;
expanded = false;
loading = false;
pendingCollapse = false;
});
">
Herrera
Cocoa Blend
Cocoa Blend
S/ 12.00
Edición Limitada
0 && !hasMultipleVariants) { expanded = true; pendingCollapse = false }"
@mouseleave="if (qty > 0 && !hasMultipleVariants) { if (loading) { pendingCollapse = true } else { expanded = false } }"
x-data="{
qty: 0,
hasQty: false,
maxQty: 50,
expanded: false,
loading: false,
pendingCollapse: false,
circleHover: false,
timer: null,
productId: 52,
quickAddCartId: '52',
hasMultipleVariants: false,
// Solo se usa con hasMultipleVariants -- mapea cartId real (id-variante)
// a qty, para poder sumar/restar por línea de carrito sin perder el
// conteo de las demás variantes ya agregadas cuando llega un evento de
// una sola de ellas. Ver el cast a objeto en el bloque @php de arriba.
variantQuantities: {},
// Evitan que el listener de window de más abajo vuelva a aplicar la propia
// mutación que esta misma instancia ya hizo localmente (Livewire.dispatch()
// llega también a esta instancia como evento de window, no solo a las demás).
suppressNextAdd: false,
suppressNextRemove: false,
// Suma todas las líneas de variantQuantities -- el número que ve el
// usuario en el círculo es cuántas unidades de este producto (en
// cualquier presentación) hay en el carrito, no de una variante puntual.
recalcQtyFromVariants() {
this.qty = Object.values(this.variantQuantities).reduce((sum, n) => sum + n, 0);
this.hasQty = this.qty > 0;
},
// window.dispatchEvent (NO Livewire.dispatch) a propósito -- el modal
// (variant-picker.blade.php) ya no es un componente Livewire que vuelve a
// consultar el producto al abrir; escucha este mismo evento de window y
// pinta con el payload que ya viaja acá, calculado una sola vez en el
// server al renderizar ESTA card (ver Product::quickViewPayload()). Así
// abre al instante, sin esperar ningún roundtrip -- Livewire.dispatch()
// sí implicaría uno (cualquier acción Livewire es una request).
//
// qtyInCart de cada variante SÍ se refresca acá antes de despachar --
// ese campo del payload es correcto recién al cargar la página, pero
// queda desactualizado si el cliente agrega/quita algo (de esta misma
// presentación o de otra) DESPUÉS, sin recargar (ej. reabre este mismo
// modal tras agregar una presentación una primera vez). variantQuantities
// de este círculo ya se mantiene sincronizado en vivo con el carrito real
// (ver los 3 listeners de window más abajo) -- se reusa esa MISMA fuente
// en vez de duplicarla, solo para pisar qtyInCart con el valor vigente
// justo antes de abrir.
openVariantPicker() {
const payload = null;
if (payload) {
payload.variants = payload.variants.map((variant) => ({
...variant,
qtyInCart: this.variantQuantities[this.productId + '-' + variant.name.replace(/ /g, '')] || 0,
}));
}
window.dispatchEvent(new CustomEvent('open-variant-picker', { detail: payload }));
},
addFirstTime() {
this.qty = 1;
this.hasQty = true;
this.expanded = true;
this.suppressNextAdd = true;
// dispatchId único por clic (no por instancia): deja que CartCounter
// descarte la segunda aplicación cuando sus 2 instancias montadas
// reciben este mismo evento -- ver nota en CartCounter::add().
const dispatchId = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
Livewire.dispatch('addToCart', { productId: this.quickAddCartId, qty: 1, dispatchId });
},
reopen() {
this.expanded = true;
},
changeQty(amount) {
// Tope real (ver maxQty arriba, Product::stockParaListado()): el botón
// de incrementar no debe pedir más de lo que hay -- respaldo del
// :disabled nativo (ver el markup más abajo), por si igual llega el
// click (estado de Alpine desfasado, DevTools). OJO al editar este
// comentario: todo este objeto x-data vive dentro de un atributo HTML
// entre comillas dobles, así que ninguna comilla doble literal puede
// aparecer acá adentro (ni en código ni en comentarios) -- cierra ese
// atributo antes de tiempo para el parser del navegador. BUG real ya
// encontrado y confirmado en vivo (screenshot: el resto del objeto
// x-data se pintaba como texto suelto encima de la card).
if (amount > 0 && this.qty >= this.maxQty) return;
const next = this.qty + amount;
clearTimeout(this.timer);
if (next < 1) {
this.timer = setTimeout(() => {
this.loading = true;
this.suppressNextRemove = true;
Livewire.dispatch('removeFromCart', { productId: this.quickAddCartId });
setTimeout(() => {
this.expanded = false; // inicia el leave del pill; loading sigue en true, el spinner tapa el número
setTimeout(() => {
this.hasQty = false; // circulo ya visible con el numero anterior; inicia su propio crossfade al icono mas
setTimeout(() => {
this.qty = 0;
this.loading = false;
this.pendingCollapse = false;
}, 150); // debe coincidir con la duración de x-transition.opacity.duration.150ms del span numérico
}, 250); // debe coincidir con la duración de product-card-add-pill-leave-active
}, 700);
}, 500);
return;
}
this.qty = next;
this.loading = true;
this.timer = setTimeout(() => {
Livewire.dispatch('updateCartQuantity', { productId: this.quickAddCartId, qty: this.qty });
setTimeout(() => {
this.loading = false;
if (this.pendingCollapse) {
this.expanded = false;
this.pendingCollapse = false;
}
}, 900);
}, 500);
}
}"
x-init="
$watch('expanded', (value) => {
if (value) { circleHover = false; }
$dispatch('cart-pill-toggle', { show: expanded || circleHover });
});
$watch('circleHover', () => $dispatch('cart-pill-toggle', { show: expanded || circleHover }));
// Mantiene sincronizado este botón cuando el carrito cambia desde otro
// lugar (drawer, VariantPicker, u otra instancia de esta misma card en
// otra sección de la página) -- Livewire.dispatch() llega a estos 3
// eventos como CustomEvent en window, con los mismos campos que ya usan
// CartCounter/CartDrawer (productId, qty).
//
// Con hasMultipleVariants, event.detail.productId es el cartId REAL
// con sufijo de variante (id-variante, ver VariantPicker::cartId() y
// VariantPicker::addToCart(), que dispara 'updateCartQuantity' al
// agregar desde el modal) -- nunca va a ser igual a quickAddCartId
// (que para estos productos es solo el id plano, sin variante, ver el
// bloque PHP de arriba). Por eso se compara por PREFIJO (id + guion)
// en vez de igualdad exacta, y se acumula por variante en
// variantQuantities en vez de pisar qty directo -- así una variante
// que cambia no borra el conteo de las demás ya agregadas.
window.addEventListener('addToCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
variantQuantities[event.detail.productId] = (variantQuantities[event.detail.productId] || 0) + event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
qty += event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('updateCartQuantity', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
variantQuantities[event.detail.productId] = event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
qty = event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('removeFromCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
delete variantQuantities[event.detail.productId];
recalcQtyFromVariants();
expanded = false;
loading = false;
pendingCollapse = false;
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextRemove) { suppressNextRemove = false; return; }
qty = 0;
hasQty = false;
expanded = false;
loading = false;
pendingCollapse = false;
});
">
Herrera
Chuncho Cacao
Chuncho Cacao
S/ 15.00
Edición Limitada
0 && !hasMultipleVariants) { expanded = true; pendingCollapse = false }"
@mouseleave="if (qty > 0 && !hasMultipleVariants) { if (loading) { pendingCollapse = true } else { expanded = false } }"
x-data="{
qty: 0,
hasQty: false,
maxQty: 50,
expanded: false,
loading: false,
pendingCollapse: false,
circleHover: false,
timer: null,
productId: 51,
quickAddCartId: '51',
hasMultipleVariants: false,
// Solo se usa con hasMultipleVariants -- mapea cartId real (id-variante)
// a qty, para poder sumar/restar por línea de carrito sin perder el
// conteo de las demás variantes ya agregadas cuando llega un evento de
// una sola de ellas. Ver el cast a objeto en el bloque @php de arriba.
variantQuantities: {},
// Evitan que el listener de window de más abajo vuelva a aplicar la propia
// mutación que esta misma instancia ya hizo localmente (Livewire.dispatch()
// llega también a esta instancia como evento de window, no solo a las demás).
suppressNextAdd: false,
suppressNextRemove: false,
// Suma todas las líneas de variantQuantities -- el número que ve el
// usuario en el círculo es cuántas unidades de este producto (en
// cualquier presentación) hay en el carrito, no de una variante puntual.
recalcQtyFromVariants() {
this.qty = Object.values(this.variantQuantities).reduce((sum, n) => sum + n, 0);
this.hasQty = this.qty > 0;
},
// window.dispatchEvent (NO Livewire.dispatch) a propósito -- el modal
// (variant-picker.blade.php) ya no es un componente Livewire que vuelve a
// consultar el producto al abrir; escucha este mismo evento de window y
// pinta con el payload que ya viaja acá, calculado una sola vez en el
// server al renderizar ESTA card (ver Product::quickViewPayload()). Así
// abre al instante, sin esperar ningún roundtrip -- Livewire.dispatch()
// sí implicaría uno (cualquier acción Livewire es una request).
//
// qtyInCart de cada variante SÍ se refresca acá antes de despachar --
// ese campo del payload es correcto recién al cargar la página, pero
// queda desactualizado si el cliente agrega/quita algo (de esta misma
// presentación o de otra) DESPUÉS, sin recargar (ej. reabre este mismo
// modal tras agregar una presentación una primera vez). variantQuantities
// de este círculo ya se mantiene sincronizado en vivo con el carrito real
// (ver los 3 listeners de window más abajo) -- se reusa esa MISMA fuente
// en vez de duplicarla, solo para pisar qtyInCart con el valor vigente
// justo antes de abrir.
openVariantPicker() {
const payload = null;
if (payload) {
payload.variants = payload.variants.map((variant) => ({
...variant,
qtyInCart: this.variantQuantities[this.productId + '-' + variant.name.replace(/ /g, '')] || 0,
}));
}
window.dispatchEvent(new CustomEvent('open-variant-picker', { detail: payload }));
},
addFirstTime() {
this.qty = 1;
this.hasQty = true;
this.expanded = true;
this.suppressNextAdd = true;
// dispatchId único por clic (no por instancia): deja que CartCounter
// descarte la segunda aplicación cuando sus 2 instancias montadas
// reciben este mismo evento -- ver nota en CartCounter::add().
const dispatchId = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
Livewire.dispatch('addToCart', { productId: this.quickAddCartId, qty: 1, dispatchId });
},
reopen() {
this.expanded = true;
},
changeQty(amount) {
// Tope real (ver maxQty arriba, Product::stockParaListado()): el botón
// de incrementar no debe pedir más de lo que hay -- respaldo del
// :disabled nativo (ver el markup más abajo), por si igual llega el
// click (estado de Alpine desfasado, DevTools). OJO al editar este
// comentario: todo este objeto x-data vive dentro de un atributo HTML
// entre comillas dobles, así que ninguna comilla doble literal puede
// aparecer acá adentro (ni en código ni en comentarios) -- cierra ese
// atributo antes de tiempo para el parser del navegador. BUG real ya
// encontrado y confirmado en vivo (screenshot: el resto del objeto
// x-data se pintaba como texto suelto encima de la card).
if (amount > 0 && this.qty >= this.maxQty) return;
const next = this.qty + amount;
clearTimeout(this.timer);
if (next < 1) {
this.timer = setTimeout(() => {
this.loading = true;
this.suppressNextRemove = true;
Livewire.dispatch('removeFromCart', { productId: this.quickAddCartId });
setTimeout(() => {
this.expanded = false; // inicia el leave del pill; loading sigue en true, el spinner tapa el número
setTimeout(() => {
this.hasQty = false; // circulo ya visible con el numero anterior; inicia su propio crossfade al icono mas
setTimeout(() => {
this.qty = 0;
this.loading = false;
this.pendingCollapse = false;
}, 150); // debe coincidir con la duración de x-transition.opacity.duration.150ms del span numérico
}, 250); // debe coincidir con la duración de product-card-add-pill-leave-active
}, 700);
}, 500);
return;
}
this.qty = next;
this.loading = true;
this.timer = setTimeout(() => {
Livewire.dispatch('updateCartQuantity', { productId: this.quickAddCartId, qty: this.qty });
setTimeout(() => {
this.loading = false;
if (this.pendingCollapse) {
this.expanded = false;
this.pendingCollapse = false;
}
}, 900);
}, 500);
}
}"
x-init="
$watch('expanded', (value) => {
if (value) { circleHover = false; }
$dispatch('cart-pill-toggle', { show: expanded || circleHover });
});
$watch('circleHover', () => $dispatch('cart-pill-toggle', { show: expanded || circleHover }));
// Mantiene sincronizado este botón cuando el carrito cambia desde otro
// lugar (drawer, VariantPicker, u otra instancia de esta misma card en
// otra sección de la página) -- Livewire.dispatch() llega a estos 3
// eventos como CustomEvent en window, con los mismos campos que ya usan
// CartCounter/CartDrawer (productId, qty).
//
// Con hasMultipleVariants, event.detail.productId es el cartId REAL
// con sufijo de variante (id-variante, ver VariantPicker::cartId() y
// VariantPicker::addToCart(), que dispara 'updateCartQuantity' al
// agregar desde el modal) -- nunca va a ser igual a quickAddCartId
// (que para estos productos es solo el id plano, sin variante, ver el
// bloque PHP de arriba). Por eso se compara por PREFIJO (id + guion)
// en vez de igualdad exacta, y se acumula por variante en
// variantQuantities en vez de pisar qty directo -- así una variante
// que cambia no borra el conteo de las demás ya agregadas.
window.addEventListener('addToCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
variantQuantities[event.detail.productId] = (variantQuantities[event.detail.productId] || 0) + event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
qty += event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('updateCartQuantity', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
variantQuantities[event.detail.productId] = event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
qty = event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('removeFromCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
delete variantQuantities[event.detail.productId];
recalcQtyFromVariants();
expanded = false;
loading = false;
pendingCollapse = false;
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextRemove) { suppressNextRemove = false; return; }
qty = 0;
hasQty = false;
expanded = false;
loading = false;
pendingCollapse = false;
});
">
Herrera
CCN-51 Cacao
CCN-51 Cacao
S/ 12.00
Edición Limitada
0 && !hasMultipleVariants) { expanded = true; pendingCollapse = false }"
@mouseleave="if (qty > 0 && !hasMultipleVariants) { if (loading) { pendingCollapse = true } else { expanded = false } }"
x-data="{
qty: 0,
hasQty: false,
maxQty: 50,
expanded: false,
loading: false,
pendingCollapse: false,
circleHover: false,
timer: null,
productId: 50,
quickAddCartId: '50',
hasMultipleVariants: false,
// Solo se usa con hasMultipleVariants -- mapea cartId real (id-variante)
// a qty, para poder sumar/restar por línea de carrito sin perder el
// conteo de las demás variantes ya agregadas cuando llega un evento de
// una sola de ellas. Ver el cast a objeto en el bloque @php de arriba.
variantQuantities: {},
// Evitan que el listener de window de más abajo vuelva a aplicar la propia
// mutación que esta misma instancia ya hizo localmente (Livewire.dispatch()
// llega también a esta instancia como evento de window, no solo a las demás).
suppressNextAdd: false,
suppressNextRemove: false,
// Suma todas las líneas de variantQuantities -- el número que ve el
// usuario en el círculo es cuántas unidades de este producto (en
// cualquier presentación) hay en el carrito, no de una variante puntual.
recalcQtyFromVariants() {
this.qty = Object.values(this.variantQuantities).reduce((sum, n) => sum + n, 0);
this.hasQty = this.qty > 0;
},
// window.dispatchEvent (NO Livewire.dispatch) a propósito -- el modal
// (variant-picker.blade.php) ya no es un componente Livewire que vuelve a
// consultar el producto al abrir; escucha este mismo evento de window y
// pinta con el payload que ya viaja acá, calculado una sola vez en el
// server al renderizar ESTA card (ver Product::quickViewPayload()). Así
// abre al instante, sin esperar ningún roundtrip -- Livewire.dispatch()
// sí implicaría uno (cualquier acción Livewire es una request).
//
// qtyInCart de cada variante SÍ se refresca acá antes de despachar --
// ese campo del payload es correcto recién al cargar la página, pero
// queda desactualizado si el cliente agrega/quita algo (de esta misma
// presentación o de otra) DESPUÉS, sin recargar (ej. reabre este mismo
// modal tras agregar una presentación una primera vez). variantQuantities
// de este círculo ya se mantiene sincronizado en vivo con el carrito real
// (ver los 3 listeners de window más abajo) -- se reusa esa MISMA fuente
// en vez de duplicarla, solo para pisar qtyInCart con el valor vigente
// justo antes de abrir.
openVariantPicker() {
const payload = null;
if (payload) {
payload.variants = payload.variants.map((variant) => ({
...variant,
qtyInCart: this.variantQuantities[this.productId + '-' + variant.name.replace(/ /g, '')] || 0,
}));
}
window.dispatchEvent(new CustomEvent('open-variant-picker', { detail: payload }));
},
addFirstTime() {
this.qty = 1;
this.hasQty = true;
this.expanded = true;
this.suppressNextAdd = true;
// dispatchId único por clic (no por instancia): deja que CartCounter
// descarte la segunda aplicación cuando sus 2 instancias montadas
// reciben este mismo evento -- ver nota en CartCounter::add().
const dispatchId = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
Livewire.dispatch('addToCart', { productId: this.quickAddCartId, qty: 1, dispatchId });
},
reopen() {
this.expanded = true;
},
changeQty(amount) {
// Tope real (ver maxQty arriba, Product::stockParaListado()): el botón
// de incrementar no debe pedir más de lo que hay -- respaldo del
// :disabled nativo (ver el markup más abajo), por si igual llega el
// click (estado de Alpine desfasado, DevTools). OJO al editar este
// comentario: todo este objeto x-data vive dentro de un atributo HTML
// entre comillas dobles, así que ninguna comilla doble literal puede
// aparecer acá adentro (ni en código ni en comentarios) -- cierra ese
// atributo antes de tiempo para el parser del navegador. BUG real ya
// encontrado y confirmado en vivo (screenshot: el resto del objeto
// x-data se pintaba como texto suelto encima de la card).
if (amount > 0 && this.qty >= this.maxQty) return;
const next = this.qty + amount;
clearTimeout(this.timer);
if (next < 1) {
this.timer = setTimeout(() => {
this.loading = true;
this.suppressNextRemove = true;
Livewire.dispatch('removeFromCart', { productId: this.quickAddCartId });
setTimeout(() => {
this.expanded = false; // inicia el leave del pill; loading sigue en true, el spinner tapa el número
setTimeout(() => {
this.hasQty = false; // circulo ya visible con el numero anterior; inicia su propio crossfade al icono mas
setTimeout(() => {
this.qty = 0;
this.loading = false;
this.pendingCollapse = false;
}, 150); // debe coincidir con la duración de x-transition.opacity.duration.150ms del span numérico
}, 250); // debe coincidir con la duración de product-card-add-pill-leave-active
}, 700);
}, 500);
return;
}
this.qty = next;
this.loading = true;
this.timer = setTimeout(() => {
Livewire.dispatch('updateCartQuantity', { productId: this.quickAddCartId, qty: this.qty });
setTimeout(() => {
this.loading = false;
if (this.pendingCollapse) {
this.expanded = false;
this.pendingCollapse = false;
}
}, 900);
}, 500);
}
}"
x-init="
$watch('expanded', (value) => {
if (value) { circleHover = false; }
$dispatch('cart-pill-toggle', { show: expanded || circleHover });
});
$watch('circleHover', () => $dispatch('cart-pill-toggle', { show: expanded || circleHover }));
// Mantiene sincronizado este botón cuando el carrito cambia desde otro
// lugar (drawer, VariantPicker, u otra instancia de esta misma card en
// otra sección de la página) -- Livewire.dispatch() llega a estos 3
// eventos como CustomEvent en window, con los mismos campos que ya usan
// CartCounter/CartDrawer (productId, qty).
//
// Con hasMultipleVariants, event.detail.productId es el cartId REAL
// con sufijo de variante (id-variante, ver VariantPicker::cartId() y
// VariantPicker::addToCart(), que dispara 'updateCartQuantity' al
// agregar desde el modal) -- nunca va a ser igual a quickAddCartId
// (que para estos productos es solo el id plano, sin variante, ver el
// bloque PHP de arriba). Por eso se compara por PREFIJO (id + guion)
// en vez de igualdad exacta, y se acumula por variante en
// variantQuantities en vez de pisar qty directo -- así una variante
// que cambia no borra el conteo de las demás ya agregadas.
window.addEventListener('addToCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
variantQuantities[event.detail.productId] = (variantQuantities[event.detail.productId] || 0) + event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
qty += event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('updateCartQuantity', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
variantQuantities[event.detail.productId] = event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
qty = event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('removeFromCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
delete variantQuantities[event.detail.productId];
recalcQtyFromVariants();
expanded = false;
loading = false;
pendingCollapse = false;
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextRemove) { suppressNextRemove = false; return; }
qty = 0;
hasQty = false;
expanded = false;
loading = false;
pendingCollapse = false;
});
">
Herrera
Criollo Cacao
Criollo Cacao
S/ 12.00
Edición Limitada
0 && !hasMultipleVariants) { expanded = true; pendingCollapse = false }"
@mouseleave="if (qty > 0 && !hasMultipleVariants) { if (loading) { pendingCollapse = true } else { expanded = false } }"
x-data="{
qty: 0,
hasQty: false,
maxQty: 60,
expanded: false,
loading: false,
pendingCollapse: false,
circleHover: false,
timer: null,
productId: 49,
quickAddCartId: '49',
hasMultipleVariants: false,
// Solo se usa con hasMultipleVariants -- mapea cartId real (id-variante)
// a qty, para poder sumar/restar por línea de carrito sin perder el
// conteo de las demás variantes ya agregadas cuando llega un evento de
// una sola de ellas. Ver el cast a objeto en el bloque @php de arriba.
variantQuantities: {},
// Evitan que el listener de window de más abajo vuelva a aplicar la propia
// mutación que esta misma instancia ya hizo localmente (Livewire.dispatch()
// llega también a esta instancia como evento de window, no solo a las demás).
suppressNextAdd: false,
suppressNextRemove: false,
// Suma todas las líneas de variantQuantities -- el número que ve el
// usuario en el círculo es cuántas unidades de este producto (en
// cualquier presentación) hay en el carrito, no de una variante puntual.
recalcQtyFromVariants() {
this.qty = Object.values(this.variantQuantities).reduce((sum, n) => sum + n, 0);
this.hasQty = this.qty > 0;
},
// window.dispatchEvent (NO Livewire.dispatch) a propósito -- el modal
// (variant-picker.blade.php) ya no es un componente Livewire que vuelve a
// consultar el producto al abrir; escucha este mismo evento de window y
// pinta con el payload que ya viaja acá, calculado una sola vez en el
// server al renderizar ESTA card (ver Product::quickViewPayload()). Así
// abre al instante, sin esperar ningún roundtrip -- Livewire.dispatch()
// sí implicaría uno (cualquier acción Livewire es una request).
//
// qtyInCart de cada variante SÍ se refresca acá antes de despachar --
// ese campo del payload es correcto recién al cargar la página, pero
// queda desactualizado si el cliente agrega/quita algo (de esta misma
// presentación o de otra) DESPUÉS, sin recargar (ej. reabre este mismo
// modal tras agregar una presentación una primera vez). variantQuantities
// de este círculo ya se mantiene sincronizado en vivo con el carrito real
// (ver los 3 listeners de window más abajo) -- se reusa esa MISMA fuente
// en vez de duplicarla, solo para pisar qtyInCart con el valor vigente
// justo antes de abrir.
openVariantPicker() {
const payload = null;
if (payload) {
payload.variants = payload.variants.map((variant) => ({
...variant,
qtyInCart: this.variantQuantities[this.productId + '-' + variant.name.replace(/ /g, '')] || 0,
}));
}
window.dispatchEvent(new CustomEvent('open-variant-picker', { detail: payload }));
},
addFirstTime() {
this.qty = 1;
this.hasQty = true;
this.expanded = true;
this.suppressNextAdd = true;
// dispatchId único por clic (no por instancia): deja que CartCounter
// descarte la segunda aplicación cuando sus 2 instancias montadas
// reciben este mismo evento -- ver nota en CartCounter::add().
const dispatchId = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
Livewire.dispatch('addToCart', { productId: this.quickAddCartId, qty: 1, dispatchId });
},
reopen() {
this.expanded = true;
},
changeQty(amount) {
// Tope real (ver maxQty arriba, Product::stockParaListado()): el botón
// de incrementar no debe pedir más de lo que hay -- respaldo del
// :disabled nativo (ver el markup más abajo), por si igual llega el
// click (estado de Alpine desfasado, DevTools). OJO al editar este
// comentario: todo este objeto x-data vive dentro de un atributo HTML
// entre comillas dobles, así que ninguna comilla doble literal puede
// aparecer acá adentro (ni en código ni en comentarios) -- cierra ese
// atributo antes de tiempo para el parser del navegador. BUG real ya
// encontrado y confirmado en vivo (screenshot: el resto del objeto
// x-data se pintaba como texto suelto encima de la card).
if (amount > 0 && this.qty >= this.maxQty) return;
const next = this.qty + amount;
clearTimeout(this.timer);
if (next < 1) {
this.timer = setTimeout(() => {
this.loading = true;
this.suppressNextRemove = true;
Livewire.dispatch('removeFromCart', { productId: this.quickAddCartId });
setTimeout(() => {
this.expanded = false; // inicia el leave del pill; loading sigue en true, el spinner tapa el número
setTimeout(() => {
this.hasQty = false; // circulo ya visible con el numero anterior; inicia su propio crossfade al icono mas
setTimeout(() => {
this.qty = 0;
this.loading = false;
this.pendingCollapse = false;
}, 150); // debe coincidir con la duración de x-transition.opacity.duration.150ms del span numérico
}, 250); // debe coincidir con la duración de product-card-add-pill-leave-active
}, 700);
}, 500);
return;
}
this.qty = next;
this.loading = true;
this.timer = setTimeout(() => {
Livewire.dispatch('updateCartQuantity', { productId: this.quickAddCartId, qty: this.qty });
setTimeout(() => {
this.loading = false;
if (this.pendingCollapse) {
this.expanded = false;
this.pendingCollapse = false;
}
}, 900);
}, 500);
}
}"
x-init="
$watch('expanded', (value) => {
if (value) { circleHover = false; }
$dispatch('cart-pill-toggle', { show: expanded || circleHover });
});
$watch('circleHover', () => $dispatch('cart-pill-toggle', { show: expanded || circleHover }));
// Mantiene sincronizado este botón cuando el carrito cambia desde otro
// lugar (drawer, VariantPicker, u otra instancia de esta misma card en
// otra sección de la página) -- Livewire.dispatch() llega a estos 3
// eventos como CustomEvent en window, con los mismos campos que ya usan
// CartCounter/CartDrawer (productId, qty).
//
// Con hasMultipleVariants, event.detail.productId es el cartId REAL
// con sufijo de variante (id-variante, ver VariantPicker::cartId() y
// VariantPicker::addToCart(), que dispara 'updateCartQuantity' al
// agregar desde el modal) -- nunca va a ser igual a quickAddCartId
// (que para estos productos es solo el id plano, sin variante, ver el
// bloque PHP de arriba). Por eso se compara por PREFIJO (id + guion)
// en vez de igualdad exacta, y se acumula por variante en
// variantQuantities en vez de pisar qty directo -- así una variante
// que cambia no borra el conteo de las demás ya agregadas.
window.addEventListener('addToCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
variantQuantities[event.detail.productId] = (variantQuantities[event.detail.productId] || 0) + event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextAdd) { suppressNextAdd = false; return; }
qty += event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('updateCartQuantity', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
variantQuantities[event.detail.productId] = event.detail.qty;
recalcQtyFromVariants();
return;
}
if (event.detail.productId !== quickAddCartId) return;
qty = event.detail.qty;
hasQty = qty > 0;
});
window.addEventListener('removeFromCart', (event) => {
if (hasMultipleVariants) {
if (!event.detail.productId.startsWith(productId + '-')) return;
delete variantQuantities[event.detail.productId];
recalcQtyFromVariants();
expanded = false;
loading = false;
pendingCollapse = false;
return;
}
if (event.detail.productId !== quickAddCartId) return;
if (suppressNextRemove) { suppressNextRemove = false; return; }
qty = 0;
hasQty = false;
expanded = false;
loading = false;
pendingCollapse = false;
});
">
"Their management model is a benchmark. Perfect traceability from farm to package guarantees exceptional quality in their coffee and cacao, supporting fair trade."
Christian Peñaloza
Agribusiness, UNALM
starstarstarstarstar
"At the cupping table, Herrera Coffee stands out for its remarkable clarity and bright acidity. Its fruity notes and silky body show masterful roasting. Undeniably specialty grade."
Keyns Valdez
Q Grader Cupper
starstarstarstarstar
"Their plots' agronomic management is impeccable. The use of sustainable practices and genetic selection in their cacao groves is directly reflected in the superior purity of their by-products."
Carolina Ibarra
Agricultural Specialist, INIA
starstarstarstarstar
"Evaluating these lots is a delight. They maintain remarkable consistency harvest after harvest. The flavor balance and intensity of their aromatic profiles border on perfection."