Реактивная корзина и кнопки изменения количества товара на Alpine.js

Я уже писал, как сделать кнопки изменения количества товара как на Озоне и многих других сайтах: modx.pro/howto/22877 Но это решение имеет некоторые ограничения, к тому же оно довольно костыльное. Ещё. в рамках этой задачи я сделал динамическую реактивную корзину. Для реактивности я использовал Alpine.js. Если знаете Vue, то всё должно быть понятно.


Создадим контроллер для получения корзины. Если вы не пользуетесь 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>

Пока что корзина не работает с опциями, но я не представляю, как можно совместить кнопки +- и опции)
Лёша
07 апреля 2023, 06:38
modx.pro
2
580
+9

Комментарии: 17

Баха Волков
07 апреля 2023, 11:04
+4
Отличная идея от Автора, но с плохой реализацией. Поэтому предлагаю улучшить код, прежде чем люди скопипастят.

Начнём с метода 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);
},

Стало лучше?
    Лёша
    07 апреля 2023, 21:37
    0
    Я вроде пробовал так, не заработало, так как cart.items не массив. Надо ещё раз попробовать, тип проверить. А если это объект, можно подобным образом сделать? Не нашёл инфы.
      Баха Волков
      07 апреля 2023, 21:56
      0
      Я вроде пробовал так, не заработало, так как cart.items не массив.
      Это не должно быть проблемой, конвертируй объект в массив и всё.

      А если это объект, можно подобным образом сделать? Не нашёл инфы.
      Если это объект, то и твой вариант не рекомендуется, т.к. у объекта могут быть свойства и методы не относящийся к данным товара и цикл for...in проходит и по ним тоже.
    Баха Волков
    07 апреля 2023, 11:06
    +2
    Почему store.items инициализирован/определён как объект, а потом переопределяется в массив?!

    Alpine.store('cart', {
            status: { },
            items: { },  // <-- Вот тут почему объект, когда должен быть массив
            loaded: false,
    ...
      Лёша
      22 апреля 2023, 16:59
      0
      Там не массив, а proxy object. Да, с сервера массив приходит, но в итоге он proxy object становится.
      Баха Волков
      07 апреля 2023, 11:22
      +3
      Что не так с этим методом?

      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');
        });
      }

      1. Любая ошибка на стороне сервера приведёт пользователя к плохому экспириенсу, а точнее к бесконечной загрузке
      2. Следовательно, нужно приручать разработчиков обрабатывать ошибки, тем более если учите других
      Как можно улучшить?
      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, чтобы это произошло в любом случае
      }
        Баха Волков
        07 апреля 2023, 12:26
        +3
        Ну и до кучи, лучше использовать 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, чтобы это произошло в любом случае
          }
          Лёша
          07 апреля 2023, 21:30
          +2
          Спасибо большое за ревью, поправлю. По поводу fetch и async/await. У меня обычно без async/await всё работает, но недавно делал фронт на проекте, у которого бэк то ли на ноде, то ли на питоне. И вот там работает только через async/await. С чем это может быть связано?
            Баха Волков
            07 апреля 2023, 21:44
            0
            Единственная причина которую я могу представить — это невозможность использования await на верхнем уровне в случае если скрипт не является модулем. Читать тут.
            Лёша
            22 апреля 2023, 16:56
            0
            А как эта конструкция называется? Первый раз вижу)

            { data: { status, items } }
              Баха Волков
              23 апреля 2023, 08:53
              +3
              Это деструктуризация (Деструктурирующее присваивание) которая пришла из 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
              А конкретно то, что я написал — это вложенная деструктуризация
          Баха Волков
          07 апреля 2023, 11:31
          +2
          Добавление класса 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'); // Вы тут и так добавляете класс
            ...
          }
            Лёша
            07 апреля 2023, 21:34
            0
            get() срабатывает либо при старте, либо после получения ответа от сервера что корзина изменена. А cartChange() срабатывает при нажатии на кнопку. Можно из get() в init() вынести
              Баха Волков
              07 апреля 2023, 21:59
              0
              Ты не понял, обрати внимание на то, что у тебя класс msr-loading каждый раз добавляется 2 раза (тебя спасает только метод classList.add который прощает твою ошибку).

              Просто удали document.body.classList.add('msr-loading'); из методов cartAdd() и cartChange() и я тебя уверяю — ничего не изменится.
            Баха Волков
            07 апреля 2023, 11:47
            +3
            Есть простой метод init():

            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());
              });
            },

            Теперь сломать что-то будет сложнее
              Баха Волков
              07 апреля 2023, 12:10
              +2
              Разрешите заменить:

              let qty = +item.count;
              qty = qty + n

              на это:

              const qty = +item.count + n
                srs
                srs
                07 апреля 2023, 20:34
                +3
                @Баха Волков отличное ревью 👏
                  Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.
                  17