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
Cascara de Café
Cascara de Café
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
Café Geisha
Café Geisha
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
Cacao Blend
Cacao 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
Cacao Chuncho
Cacao Chuncho
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
Cacao CCN51
Cacao CCN51
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
Cacao Criollo
Cacao Criollo
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;
});
">
"Su modelo de gestión es un referente. La perfecta trazabilidad desde la finca hasta el empaque garantiza una calidad excepcional en su café y cacao, apoyando el comercio justo."
Christian Peñaloza
Agronegocios, UNALM
starstarstarstarstar
"En la mesa de cata, el Café Herrera destaca por su asombrosa limpieza y acidez brillante. Sus notas frutales y cuerpo sedoso demuestran un tostado magistral. De especialidad indiscutible."
Keyns Valdez
Catador Q Grader
starstarstarstarstar
"El manejo agronómico de sus parcelas es impecable. El uso de prácticas sostenibles y la selección genética en sus cacaotales se refleja directamente en la pureza superior de sus derivados."
Carolina Ibarra
Esp. Agrícola, INIA
starstarstarstarstar
"Evaluar estos lotes es un deleite. Mantienen una consistencia notable cosecha tras cosecha. El balance de sabores y la intensidad de sus perfiles aromáticos rozan la perfección."