Podstawy Hyvä – szablony PHTML z Alpine.js, Tailwind, eventy przez CustomEvent – opisałem w 2024. Po kilku latach wdrożeń mam zebrany zestaw wzorców które powtarzają się w zaawansowanych projektach: kompozycja wielu komponentów Alpine.js, komunikacja między izolowanymi widgetami, lazy loading danych przez Magento REST API i wzorzec store’u dla globalnego stanu koszyka. Pokazuję te wzorce z kodem.
Alpine.js Store – globalny stan bez Vuex
Gdy kilka komponentów na stronie musi dzielić ten sam stan (koszyk, ulubione, porównanie produktów), Alpine.js ma wbudowany mechanizm store:
// Inicjalizacja store w layout - jeden raz dla całej strony
// Magento PHTML layout: Magento_Theme/templates/root.phtml lub dedykowany blok
<script>
document.addEventListener('alpine:init', () => {
// Globalny store koszyka
Alpine.store('cart', {
itemCount: 0,
items: [],
subtotal: 0,
isOpen: false,
isLoading: false,
async init() {
// Załaduj dane koszyka z Magento customer sections
await this.reload();
// Nasłuchuj na zmiany koszyka
window.addEventListener('cart-updated', (e) => {
this.itemCount = e.detail.summary_count ?? 0;
this.subtotal = e.detail.subtotal ?? 0;
});
},
async reload() {
this.isLoading = true;
try {
const response = await fetch('/customer/section/load?sections=cart', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
this.itemCount = data.cart?.summary_count ?? 0;
this.items = data.cart?.items ?? [];
this.subtotal = data.cart?.subtotal ?? 0;
} finally {
this.isLoading = false;
}
},
open() { this.isOpen = true; },
close() { this.isOpen = false; },
});
// Store ulubionych produktów
Alpine.store('wishlist', {
productIds: JSON.parse(localStorage.getItem('wishlist') ?? '[]'),
toggle(productId) {
const idx = this.productIds.indexOf(productId);
if (idx === -1) {
this.productIds.push(productId);
} else {
this.productIds.splice(idx, 1);
}
localStorage.setItem('wishlist', JSON.stringify(this.productIds));
window.dispatchEvent(new CustomEvent('wishlist-changed', {
detail: { count: this.productIds.length }
}));
},
has(productId) {
return this.productIds.includes(productId);
},
get count() {
return this.productIds.length;
}
});
});
</script>
<?php
// Użycie store w PHTML szablonach
?>
<!-- Header z ikonką koszyka - korzysta z globalnego store -->
<div x-data>
<button
@click="$store.cart.open()"
class="relative p-2"
>
<!-- Ikona koszyka -->
<svg>...</svg>
<!-- Licznik - reaktywny na zmiany store -->
<span
x-show="$store.cart.itemCount > 0"
x-text="$store.cart.itemCount"
class="absolute -top-1 -right-1 bg-primary text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"
></span>
</button>
</div>
<!-- Ikona ulubionych na kafelku produktu -->
<div x-data>
<button
@click="$store.wishlist.toggle(= (int)$product->getId() ?>)"
:class="$store.wishlist.has(= (int)$product->getId() ?>) ? 'text-red-500' : 'text-gray-400'"
class="p-1 hover:text-red-500 transition-colors"
>
<svg>...</svg> <!-- ikona serca -->
</button>
</div>
Komunikacja między komponentami przez eventy
<?php
// Wzorzec: event-driven komunikacja między Alpine.js widgetami
// Zamiast bezpośrednich referencji - luźne sprzężenie przez eventy
?>
<!-- Filtr cen - emituje event po zmianie -->
<div x-data="{
min: 0,
max: 1000,
apply() {
// Emituj event - nie wie kto nasłuchuje
window.dispatchEvent(new CustomEvent('price-filter-changed', {
detail: { min: this.min, max: this.max }
}));
}
}">
<input type="range" x-model="min" @change="apply()" min="0" :max="max">
<input type="range" x-model="max" @change="apply()" :min="min" max="1000">
<span x-text="`${min} - ${max} PLN`"></span>
</div>
<!-- Lista produktów - nasłuchuje na event bez wiedzy o filtrze -->
<div x-data="{
products: [],
isLoading: false,
init() {
this.loadProducts();
// Nasłuchuj na filtry
window.addEventListener('price-filter-changed', (e) => {
this.loadProducts(e.detail);
});
window.addEventListener('sort-changed', (e) => {
this.loadProducts({ sort: e.detail.sort });
});
},
async loadProducts(filters = {}) {
this.isLoading = true;
const params = new URLSearchParams({
min_price: filters.min ?? 0,
max_price: filters.max ?? 9999,
sort: filters.sort ?? 'position',
});
const response = await fetch(`/rest/V1/products?${params}`, {
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
this.products = data.items ?? [];
this.isLoading = false;
}
}">
<template x-for="product in products" :key="product.id">
<div x-text="product.name"></div>
</template>
</div>
Lazy Loading komponentów – defer pattern
<?php
// Deferuj renderowanie drogich komponentów do momentu wejścia w viewport
// Przydatne dla "Polecane produkty", "Ostatnio oglądane" na dole strony
?>
<div
x-data="{
products: [],
loaded: false,
// Intersection Observer - ładuj dopiero gdy widoczny
init() {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.loaded) {
this.loadRecommendations();
observer.disconnect();
}
}, { threshold: 0.1 });
observer.observe(this.$el);
},
async loadRecommendations() {
this.loaded = true;
const currentSku = '= $escaper->escapeJs($product->getSku()) ?>';
const response = await fetch(`/rest/V1/products?searchCriteria[filterGroups][0][filters][0][field]=related_sku&searchCriteria[filterGroups][0][filters][0][value]=${currentSku}&searchCriteria[pageSize]=8`);
const data = await response.json();
this.products = data.items ?? [];
}
}"
class="related-products"
>
<!-- Skeleton loader podczas ładowania -->
<div x-show="loaded && products.length === 0" class="grid grid-cols-4 gap-4">
<template x-for="i in 4">
<div class="animate-pulse bg-gray-200 h-48 rounded"></div>
</template>
</div>
<!-- Produkty -->
<div x-show="products.length > 0" class="grid grid-cols-4 gap-4">
<template x-for="product in products" :key="product.id">
<a :href="product.custom_attributes?.find(a => a.attribute_code === 'url_key')?.value ?? '#'"
class="product-card">
<span x-text="product.name"></span>
<span x-text="`${product.price} PLN`"></span>
</a>
</template>
</div>
</div>
Reużywalne komponenty Alpine.js przez x-data functions
// Zdefiniuj komponent raz, używaj wielokrotnie
// Umieść w pliku js ładowanym w head lub layout
function productCard(config) {
return {
qty: config.minQty ?? 1,
maxQty: config.maxQty ?? 999,
productId: config.productId,
isAddingToCart: false,
addedFeedback: false,
increment() { this.qty = Math.min(this.qty + 1, this.maxQty); },
decrement() { this.qty = Math.max(this.qty - 1, config.minQty ?? 1); },
async addToCart() {
this.isAddingToCart = true;
try {
const response = await fetch('/checkout/cart/add', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
product: this.productId,
qty: this.qty,
form_key: hyva.getFormKey(),
}),
});
if (response.ok) {
this.addedFeedback = true;
setTimeout(() => { this.addedFeedback = false; }, 2000);
window.dispatchEvent(new CustomEvent('reload-customer-section-data'));
}
} finally {
this.isAddingToCart = false;
}
}
};
}
<?php
// Użycie reużywalnego komponentu w PHTML
// Ten sam komponent na liście kategorii i stronie produktu
?>
<div x-data="productCard({
productId: = (int)$product->getId() ?>,
minQty: = (int)($product->getExtensionAttributes()->getStockItem()?->getMinSaleQty() ?? 1) ?>,
maxQty: = (int)($product->getExtensionAttributes()->getStockItem()?->getMaxSaleQty() ?? 999) ?>
})">
<div class="flex items-center gap-2">
<button @click="decrement()" :disabled="qty <= 1">-</button>
<span x-text="qty"></span>
<button @click="increment()" :disabled="qty >= maxQty">+</button>
</div>
<button
@click="addToCart()"
:disabled="isAddingToCart"
:class="addedFeedback ? 'bg-green-500' : 'bg-primary'"
class="w-full py-2 text-white rounded transition-colors"
>
<span x-show="!isAddingToCart && !addedFeedback">Dodaj do koszyka</span>
<span x-show="isAddingToCart">Dodaję...</span>
<span x-show="addedFeedback">Dodano! ✓</span>
</button>
</div>
Podsumowanie
Alpine.js Store eliminuje potrzebę przekazywania danych między komponentami przez atrybuty – globalny stan koszyka jest dostępny wszędzie z $store.cart. Eventy przez CustomEvent dają luźne sprzężenie między filtrami a listą produktów – każdy komponent robi swoje bez wiedzy o innych. Reużywalne funkcje x-data pozwalają pisać komponent raz i używać na liście kategorii, w sliderze i na stronie produktu. Razem te wzorce dają architekturę która rośnie bez bałaganu.
