Реактивная корзина и кнопки изменения количества товара на Alpine.js
Я уже писал, как сделать кнопки изменения количества товара как на Озоне и многих других сайтах: modx.pro/howto/22877 Но это решение имеет некоторые ограничения, к тому же оно довольно костыльное. Ещё. в рамках этой задачи я сделал динамическую реактивную корзину. Для реактивности я использовал Alpine.js. Если знаете Vue, то всё должно быть понятно.
Создадим контроллер для получения корзины. Если вы не пользуетесь ZoomX, просто создайте php-файл, подключите в нём MODX и возьмите код из метода контроллера.
Создайте и подключите js-файл, я назвал его msreactive. В методе get замените url на нужный.
Подключите Alpine.js после всех остальных скриптов (обязательно). Cdn на проде использовать не рекомендую.
Теперь добавим кнопки в карточку (tpl) товара. Родителю (например .row) пропишите атрибут x-data=«msReactive»
Корзина будет выглюдеть примерно так:
А вот мини-корзина:
Пока что корзина не работает с опциями, но я не представляю, как можно совместить кнопки +- и опции)
Создадим контроллер для получения корзины. Если вы не пользуетесь ZoomX, просто создайте php-файл, подключите в нём MODX и возьмите код из метода контроллера.
<?php
namespace App\Controllers;
use Zoomx\Controllers;
class Ms extends \Zoomx\Controllers\Controller
{
public function cart_get()
{
zoomx()->autoloadResource(false);
$ms2 = $this->modx->getService('minishop2');
$ms2->initialize($this->modx->context->key ?? 'web');
$out = [];
$out['status'] = $ms2->cart->status();
$out['items'] = $ms2->cart->get();
foreach($out['items'] as $k => $item) {
$out['items'][$k] = array_merge($item, $this->modx->getObject('msProduct', $item['id'])->toArray());
}
return jsonx($out);
}
}
Создайте и подключите js-файл, я назвал его msreactive. В методе get замените url на нужный.
document.addEventListener('alpine:init', () => {
Alpine.store('cart', {
status: { },
items: { },
loaded: false,
init() {
this.get();
window.onload = () => {
miniShop2.Callbacks.add('Cart.add.response.success', 'alpine-cart', (resp) => this.get());
miniShop2.Callbacks.add('Cart.change.response.success', 'alpine-cart', (resp) => this.get());
miniShop2.Callbacks.add('Cart.remove.response.success', 'alpine-cart', (resp) => this.get());
};
},
get() {
document.body.classList.add('msr-loading');
fetch('/api/ms/cart/get')
.then(resp => resp.json())
.then((resp) => {
this.status = resp.data.status;
this.items = resp.data.items;
this.loaded = true;
document.body.classList.remove('msr-loading');
});
}
})
Alpine.data('msReactive', () => ({
cart: Alpine.store('cart'),
getItem(idOrKey) {
for (let i in this.cart.items) {
if (this.cart.items[i].key == idOrKey || this.cart.items[i].id == idOrKey) {
return this.cart.items[i];
}
}
return false;
},
cartAdd(id) {
document.body.classList.add('msr-loading');
const data = new FormData();
data.append('action', 'cart/add');
data.append('id', id);
data.append('count', 1);
miniShop2.Cart.add(data);
},
cartChange(idOrKey, n) {
document.body.classList.add('msr-loading');
const item = this.getItem(idOrKey);
if (!item) alert('error');
let qty = +item.count;
qty = qty + n
const data = new FormData();
data.append('key', item.key)
if (!n || !qty) {
data.append('action', 'cart/remove');
miniShop2.Cart.remove(data);
}else {
data.append('action', 'cart/change');
data.append('count', qty)
miniShop2.Cart.change(data);
}
},
getQty(idOrKey) {
const item = this.getItem(idOrKey);
return item ? item.count : 0;
},
priceFormat(price) {
return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' }).format(price)
.replace(',00', '');
}
}))
})
Подключите Alpine.js после всех остальных скриптов (обязательно). Cdn на проде использовать не рекомендую.
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
Теперь добавим кнопки в карточку (tpl) товара. Родителю (например .row) пропишите атрибут x-data=«msReactive»
<div class="">
<template x-if="getQty({$id})">
<div class="">
<a href="/cart" class="button">Оформить заказ</a>
<div class="">
<button class="" @click="cartChange({$id}, -1)">-</button>
<span x-text="getQty({$id})" class="font-bolder"></span>
<button class="" @click="cartChange({$id}, 1)">+</button>
</div>
</div>
</template>
<template x-if="!getQty({$item.id})">
<button class="" @click="cartAdd({$item.id})"><span>В корзину</span></button>
</template>
</div>
Корзина будет выглюдеть примерно так:
<main class="pg-cart | section container" x-data="msReactive">
<div class="pg-cart__columns">
<ul class="pg-cart__list">
<template x-for="item in cart.items">
<li class="pg-cart__row | font-bolder">
<a :href="item.uri">
<img width="80" height="80" :src="item.thumb" alt="">
</a>
<a class="pg-cart__row-title" :href="item.uri">
<div x-text="item.pagetitle"></div>
</a>
<div class="pg-cart__row-prices">
<div class="pg-cart__row-price-old | prod-price-old" x-show="item.old_price" x-text="item.old_price + ' ₽'"></div>
<div class="pg-cart__row-price" x-text="item.price + ' ₽'"></div>
</div>
<div class="pg-cart__row-qty | prod-qty">
<button class="" @click="cartChange(item.key, -1)">-</button>
<span x-text="item.count" class="font-bolder"></span>
<button class="" @click="cartChange(item.key, 1)">+</button>
</div>
<div class="pg-cart__row-sum">
<div class="font-bold" x-text="(item.price * item.count) + ' ₽'"></div>
</div>
<div class="pg-cart__row-remove">
<button @click="cartChange(item.key, 0)">x</button>
</div>
</li>
</template>
</ul>
<div class="pg-cart__result">
<div class="pg-cart__result-header">
<div class="pg-cart__result-title | h4">В корзине</div>
<div class="pg-cart__result-count"><span x-text="cart.status.total_count"></span> товаров</div>
</div>
<div>
<a class="button _fullwidth" href="/checkout">Перейти к оформлению</a>
</div>
</div>
</div>
</main>
А вот мини-корзина:
<div class="pg-checkout__total-row" x-data="msReactive" x-show="cart.loaded">
<div class="pg-checkout__total-row-count">Итого <span x-text="cart.status.total_count"></span> шт. на сумму</div>
<div class="pg-checkout__total-row-sum | font-bold" x-text="priceFormat(cart.status.total_cost)"></div>
</div>
Пока что корзина не работает с опциями, но я не представляю, как можно совместить кнопки +- и опции)
Комментарии: 17
Отличная идея от Автора, но с плохой реализацией. Поэтому предлагаю улучшить код, прежде чем люди скопипастят.
Начнём с метода getItem:
Заменим это:
на это:
Стало лучше?
Начнём с метода getItem:
Заменим это:
getItem(idOrKey) {
for (let i in this.cart.items) {
if (this.cart.items[i].key == idOrKey || this.cart.items[i].id == idOrKey) {
return this.cart.items[i];
}
}
return false;
},
на это:
getItem(idOrKey) {
return this.cart.items.find(item => item.id === idOrKey || item.key === idOrKey);
},
Стало лучше?
Я вроде пробовал так, не заработало, так как cart.items не массив. Надо ещё раз попробовать, тип проверить. А если это объект, можно подобным образом сделать? Не нашёл инфы.
Я вроде пробовал так, не заработало, так как cart.items не массив.Это не должно быть проблемой, конвертируй объект в массив и всё.
А если это объект, можно подобным образом сделать? Не нашёл инфы.Если это объект, то и твой вариант не рекомендуется, т.к. у объекта могут быть свойства и методы не относящийся к данным товара и цикл for...in проходит и по ним тоже.
Почему store.items инициализирован/определён как объект, а потом переопределяется в массив?!
Alpine.store('cart', {
status: { },
items: { }, // <-- Вот тут почему объект, когда должен быть массив
loaded: false,
...
Там не массив, а proxy object. Да, с сервера массив приходит, но в итоге он proxy object становится.
Что не так с этим методом?
get() {
document.body.classList.add('msr-loading');
fetch('/api/ms/cart/get')
.then(resp => resp.json())
.then((resp) => {
this.status = resp.data.status;
this.items = resp.data.items;
this.loaded = true;
document.body.classList.remove('msr-loading');
});
}
- Любая ошибка на стороне сервера приведёт пользователя к плохому экспириенсу, а точнее к бесконечной загрузке
- Следовательно, нужно приручать разработчиков обрабатывать ошибки, тем более если учите других
get() {
document.body.classList.add('msr-loading');
fetch('/api/ms/cart/get')
.then(resp => resp.json())
.then(({ data: { status, items } }) => {
this.status = status;
this.items = items;
this.loaded = true;
})
.catch((e) => {
// Выводим сообщение пользователю об ошибке и просим повторить действие снова
})
.finally(() => document.body.classList.remove('msr-loading')); // Удаляем класс отвечающий за загрузку в блоке finally, чтобы это произошло в любом случае
}
Ну и до кучи, лучше использовать async/await:
async get() {
document.body.classList.add('msr-loading');
try {
const response = await fetch('/api/ms/cart/get');
const { data: { status, items } } = await response.json();
this.status = status;
this.items = items;
this.loaded = true;
} catch (e) {
// Выводим сообщение пользователю об ошибке и просим повторить действие снова
} finally {
document.body.classList.remove('msr-loading')); // Удаляем класс отвечающий за загрузку в блоке finally, чтобы это произошло в любом случае
}
Спасибо большое за ревью, поправлю. По поводу fetch и async/await. У меня обычно без async/await всё работает, но недавно делал фронт на проекте, у которого бэк то ли на ноде, то ли на питоне. И вот там работает только через async/await. С чем это может быть связано?
Единственная причина которую я могу представить — это невозможность использования await на верхнем уровне в случае если скрипт не является модулем. Читать тут.
А как эта конструкция называется? Первый раз вижу)
{ data: { status, items } }
Это деструктуризация (Деструктурирующее присваивание) которая пришла из ES6, предназначена она для упрощения написания кода и предотвращения некоторых ошибок, пример работы без и с деструктуризацией:
Без:
С использованием деструктуризации:
Это как если бы мы написали вот так:
Усложним пример для наглядности:
Без:
С использованием:
Можно указывать параметр по умолчанию:
Без:
const person = {
name: 'Bakha',
age: 27
}
console.log(person.name) // Bakha
console.log(person.age) // 27
С использованием деструктуризации:
const person = {
name: 'Bakha',
age: 27
}
const { name, age } = person;
console.log(name) // Bakha
console.log(age) // 27
Это как если бы мы написали вот так:
const person = {
name: 'Bakha',
age: 27
}
const name = person.name;
const age = person.age;
console.log(name) // Bakha
console.log(age) // 27
Усложним пример для наглядности:
Без:
const person = {
name: 'Bakha',
age: 27
}
function printPerson(person) {
console.log(person.name); // Bakha
console.log(person.age); // 27
}
С использованием:
const person = {
name: 'Bakha',
age: 27
}
function printPerson({ name, age }) {
console.log(name); // Bakha
console.log(age); // 27
}
Можно указывать параметр по умолчанию:
const person = {
name: 'Bakha',
age: 27
}
function printPerson({ name, age, gender = 'unknown' }) {
console.log(name); // Bakha
console.log(age); // 27
console.log(gender); // unknown
}
Параметра gender в объекте person нет, поэтому выведется unknownА конкретно то, что я написал — это вложенная деструктуризация
Добавление класса msr-loading в методах cartAdd и cartChange бессмысленное, т.к.:
Вы же сами написали логику:
cartAdd(id) {
document.body.classList.add('msr-loading'); // не надо делать
...
miniShop2.Cart.add(data); // т.к. вызов данного метода запустит колбек обрабатываемый методом get(), в котором опять добавляется этот класс
},
cartChange(idOrKey, n) {
document.body.classList.add('msr-loading'); // не надо делать
...
miniShop2.Cart.remove(data); // т.к. вызов данного метода запустит колбек обрабатываемый методом get(), в котором опять добавляется этот класс
miniShop2.Cart.change(data); // т.к. вызов данного метода запустит колбек обрабатываемый методом get(), в котором опять добавляется этот класс
},
Вы же сами написали логику:
get() {
document.body.classList.add('msr-loading'); // Вы тут и так добавляете класс
...
}
get() срабатывает либо при старте, либо после получения ответа от сервера что корзина изменена. А cartChange() срабатывает при нажатии на кнопку. Можно из get() в init() вынести
Ты не понял, обрати внимание на то, что у тебя класс msr-loading каждый раз добавляется 2 раза (тебя спасает только метод classList.add который прощает твою ошибку).
Просто удали document.body.classList.add('msr-loading'); из методов cartAdd() и cartChange() и я тебя уверяю — ничего не изменится.
Просто удали document.body.classList.add('msr-loading'); из методов cartAdd() и cartChange() и я тебя уверяю — ничего не изменится.
Есть простой метод init():
Что с ним может быть не так? Ну например встроенное свойство onload очень просто (например случайно) перезаписать, т.е. если после кода товарища будет такой код:
то вся работа будет сломана.
Как сделать лучше? Очень просто, вместо встроенного колбека воспользоваться методом addEventListener:
Теперь сломать что-то будет сложнее
init() {
this.get();
window.onload = () => {
miniShop2.Callbacks.add('Cart.add.response.success', 'alpine-cart', (resp) => this.get());
miniShop2.Callbacks.add('Cart.change.response.success', 'alpine-cart', (resp) => this.get());
miniShop2.Callbacks.add('Cart.remove.response.success', 'alpine-cart', (resp) => this.get());
};
},
Что с ним может быть не так? Ну например встроенное свойство onload очень просто (например случайно) перезаписать, т.е. если после кода товарища будет такой код:
window.onload = () => console.log('Немного шалостей');
то вся работа будет сломана.
Как сделать лучше? Очень просто, вместо встроенного колбека воспользоваться методом addEventListener:
init() {
this.get();
window.addEventListener('load', () => {
miniShop2.Callbacks.add('Cart.add.response.success', 'alpine-cart', (resp) => this.get());
miniShop2.Callbacks.add('Cart.change.response.success', 'alpine-cart', (resp) => this.get());
miniShop2.Callbacks.add('Cart.remove.response.success', 'alpine-cart', (resp) => this.get());
});
},
Теперь сломать что-то будет сложнее
Разрешите заменить:
на это:
let qty = +item.count;
qty = qty + n
на это:
const qty = +item.count + n
@Баха Волков отличное ревью 👏
Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.