Как создать интернет-магазин с Nuxt3 и Strapi
В этом руководстве мы собираемся создать интернет-магазин с фронтендом на Nuxt и бэкендом на Strapi, используя официального руководство How to build an E-commerce Store with Nuxt.js and Strapi.
- Обязательно пройденное руководство по vuejs или курс
- Знакомство с nodejs(рекомендованная версия v18)
- Знакомство с фреймворком Bootstrap
Установка Strapi
Из документации основные преимущества Strapi - это гибкость, открытый исходный код, современный Headless подход к системе управления контентом, где вам не потребуется писать много кода при этом эффективно доставлять контент. Перед локальной установкой вы можете попробовать демонстрационную версию.
Для установки воспользуемся актуальными требованиями и рекомендация на github`е , соответственно нам потребуется операционная система Ubuntu и рекомендуется установить nodejs v18.x.
Установка nodejs v18 с помощью nvm
Переходим на страничку с документацией по установке nvm и выполняем скрипт установки предварительно создав пользователя strapi
useradd strapi sudo su - strapi curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash nvm install 18
node --version v18.15.0
npm install -g npm npm --version 9.6.2
Устанавливаем yarn и проверяем:
npm install -g yarn yarn --version 1.22.19
Создаем и запустим strapi приложение
Ранее мы установили nodejs из под пользователя strapi то и запускать необходимо из-под него.
strapi@rock-5b:~$ pwd /home/strapi strapi@rock-5b:~$ yarn create strapi-app app --quickstart
Видим что приложение запустилось:
┌────────────────────┬──────────────────────────────────────────────────┐ │ Time │ Sun Mar 19 2023 15:14:22 GMT+0500 (Yekaterinbur… │ │ Launched in │ 6130 ms │ │ Environment │ development │ │ Process PID │ 47802 │ │ Version │ 4.8.2 (node v18.15.0) │ │ Edition │ Community │ │ Database │ sqlite │ └────────────────────┴──────────────────────────────────────────────────┘ Actions available One more thing... Create your first administrator 💻 by going to the administration panel at: ┌─────────────────────────────┐ │ http://localhost:1337/admin │ └─────────────────────────────┘
Создаем пользователя
Для создания пользователя открываем страничку http://localhost:1337/admin с панелью администратора. Так как мы установили strapi на удаленном хосте, то при попытке подключится по адресу отличному от localhost`а приводит к ошибке:
Во избежании этого необходимо пробросить порт 1337 с удаленного сервера на localhost:
ssh -L 1337:localhost:1337 [email protected]
Получаем публичный адрес
Для получения публичного адреса https://api.my-domain.ru, добавляем параметр url в конфиг app/config/server.js:
module.exports = ({ env }) => ({ host: env('HOST', '0.0.0.0'), port: env.int('PORT', 1337), url: 'https://api.my-domain.ru/', app: { keys: env.array('APP_KEYS'), }, webhooks: { populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false), }, });
И обязательно пересобираем админский интерфейс
yarn strapi build
И настраиваем systemd сервис /etc/systemd/system/strapi-app.service:
[Unit] Description=Strapi app After=network.target [Service] ExecStart=/home/strapi/.nvm/versions/node/v18.15.0/bin/node /home/strapi/app/node_modules/.bin/strapi start User=strapi WorkingDirectory=/home/strapi/app Environment=NODE_ENV=production Environment=PATH=/home/strapi/.nvm/versions/node/v18.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin Restart=always [Install] WantedBy=multi-user.target
systemctl daemon-reload systemctl start strapi-app systemctl status strapi-app root@rock-5b:~# systemctl status strapi-app ● strapi-app.service - Strapi app Loaded: loaded (/etc/systemd/system/strapi-app.service; disabled; vendor preset: enabled) Active: active (running) since Mon 2023-03-20 00:08:33 +05; 10s ago Main PID: 53474 (node) Tasks: 11 (limit: 18504) Memory: 135.3M CPU: 4.565s CGroup: /system.slice/strapi-app.service └─53474 /home/strapi/.nvm/versions/node/v18.15.0/bin/node /home/strapi/app/node_modules/.bin/strapi start Mar 20 00:08:36 rock-5b node[53474]: │ Version │ 4.8.2 (node v18.15.0) │ Mar 20 00:08:36 rock-5b node[53474]: │ Edition │ Community │ Mar 20 00:08:36 rock-5b node[53474]: │ Database │ sqlite │ Mar 20 00:08:36 rock-5b node[53474]: └────────────────────┴──────────────────────────────────────────────────┘ Mar 20 00:08:36 rock-5b node[53474]: Actions available Mar 20 00:08:36 rock-5b node[53474]: Welcome back! Mar 20 00:08:36 rock-5b node[53474]: To manage your project 🚀, go to the administration panel at: Mar 20 00:08:36 rock-5b node[53474]: https://api.my-domain.ru/admin Mar 20 00:08:36 rock-5b node[53474]: To access the server ⚡️, go to: Mar 20 00:08:36 rock-5b node[53474]: https://api.my-domain.ru
Устанавливаем HAProxy
Прокси используется для получения безопасного TLS соединения и получения публичного url https://api.my-domain.ru
Устанавливаем пакет haproxy по инструкции, но как альтернативу можно рассмотреть Nginx, Caddy, PM2:
apt-get install haproxy
Настраиваем бэкенд для strapi добавим кониг ниже /etc/haproxy/haproxy.cfg
frontend api.example.com bind *:80 default_backend strapi-backend backend strapi-backend server local 127.0.0.1:1337
systemctl restart haproxy systemctl status haproxy
Настраиваем TLS
apt-get install certbot
Настраиваем бэкенд для certbot в /etc/haproxy/haproxy.cfg:
frontend api.my-domain.ru bind *:80 #bind *:443 ssl crt /etc/letsencrypt/api.my-domain.ru.pem #http-request redirect scheme https unless { ssl_fc } acl letsencrypt-acl path_beg /.well-known/acme-challenge/ use_backend letsencrypt-backend if letsencrypt-acl backend letsencrypt-backend server certbot 127.0.0.1:8899
Далее перезапускаем haproxy и запрашиваем сертификат:
systemctl restart haproxy certbot certonly --standalone -d api.my-domain.ru --non-interactive --agree-tos --http-01-port=8899 cat /etc/letsencrypt/live/api.sh3h.ru/fullchain.pem /etc/letsencrypt/live/api.my-domain.ru/privkey.pem > /etc/letsencrypt/api.my-domain.ru.pem
После раскомментируем строчки с настройкой SSL и перезапускаем haproxy снова
Открываем в браузере публичный url https://api.my-domain.ru/admin и убеждаемся, что haproxy создает безопасное соединение и панель администратора успешно открывается.
Создаем бэкенд API
У нас уже запущен и работает Strapi; следующим шагом будет создание content-types нашего приложения.
1. Создаем collection type для товаров
Создайте следующие поля в разделе product content-type
2. Создаем collection type для категорий
Аналогично товарам создаем collection type для категорий товаров с полями:
2. Создаем collection type для заказов
Аналогично товарам создаем collection type для заказов с полями:
2. Создаем collection type для клиентов
Аналогично товарам создаем collection type для заказов с полями:
Сохраняем все collection type и получаем токен к API Settings -> API Tokens-> Create new API Token
Теперь можно проверить нам API в формате JSON:
export STRAPI_TOKEN=токен полученный выше curl -sH "Authorization: bearer $STRAPI_TOKEN" http://api.my-domain.ru/api/products | jq . { "data": [], "meta": { "pagination": { "page": 1, "pageSize": 25, "pageCount": 0, "total": 0 } } }
Устанавливаем Nuxt
Мы будет использовать Nuxt в качестве фреймворка в режиме рендеринга на стороне сервера универсальных приложение Vue.js.
Для установки необходимо выполнить команды из-под пользователя strapi:
npx nuxi init front cd front yarn install
Запуск сервера разработки фронта:
yarn dev -o
Наш фронт запустится на порту 3000. Аналогично бэкенду создадим systemd сервис nuxt-front и настроим бэкенд для haproxy:
Далее запускаем сервис и перезагружаем haproxy:
systemctl start nuxt-front systemctl restart haproxy
И открываем в браузере наш фронтенд по адресу https://store.my-domain.ru/:
Создаем фронтенд интернет-магазина
Перед тем как приступить к создания внешнего интерфейса нашего интернет-магазина необходимо:
- Подготовить среду для локальной разработки.
- Установить IDE - это может быть WebStorm. PyCharm, VS Code + Vetur
- Установить git, nodejs v18, yarn, nuxt
- Создать приватный репозиторий на https://github.com/
- Настроить деплой на наш удаленный сервер store.my-domain.ru.
- Получить макет сайта для верстки в HTML или воспользоваться готовым шаблоном на bootstrap v4
- Получить табличку с ассортиментом и ценами интернет-магазина
- Получить и обработать фотографии для каталога товаров. Картинки должны быть выровнены по высоте и ширине, иметь одинаковое разрешение по высоте и ширине в пикселя в соответствии с максимальным разрешением в макете сайта. Далее необходимо оптимизировать все картинки по размеру используя например инструмент https://tinypng.com/.
- Загрузить все товары в ручную через strapi бэкенд сайта https://api.my-domain.ru/. Так же можно воспользоваться API для импорта большого объема данных из таблички или мигрировать данные из одного из конструкторов сайтов для e-commerce.
В итоге мы должны получить либо готовый шаблон сайта на Bootstrap или вам потребуется создать свой шаблон, что заслуживает написание отдельной статьи, где за основу можно взять статью How to Create a Custom Bootstrap Theme from Scratch.
Для демонстрации возможностей strapi и nuxt в данной статье, возьмем готовый бесплатный шаблон сайта для онлайн коммерции.
Переносим статику Bootstrap в nuxt
После того как распаковали архив с шаблоном сайта раскладываем полученные файлы в проект front:
olog-ecommerce-responsive-html-template-1.0/dist => front/public/dist olog-ecommerce-responsive-html-template-1.0/src/scss/vendors => front/public/vendors olog-ecommerce-responsive-html-template-1.0/src/scss => front/assets/scss
Подключаем scss стили в конфиге front/nuxt.config.ts:
export default defineNuxtConfig({ css: [ '@/assets/scss/main.scss' ], }
Воспользуемся IDE и откроем index.html файл шаблона, где свернем все основные секции HTML блоков
Перенесем полученные секции в vue компоненты во внутрь тэга <template>:
Header Area => front/components/HeaderArea.vue Banner Area => front/components/BannerArea.vue Features Section => front/components/FeaturesSection.vue About Area => front/components/AboutArea.vue Populer Product => front/components/PopulerProduct.vue Categorys Section => front/components/CategorysSection.vue Features Section of customersreview => front/components/FeaturesSectionCustomer.vue Footer => front/components/Footer.vue
Используя полученные компоненты HeaderArea и Footer создадим основной слой front/layouts/default.vue:
<template> <HeaderArea /> <main> <slot /> </main> <Footer /> </template>
Из оставшихся компонентов создадим главную страничку front/pages/index.vue:
<template> <BannerArea /> <FeaturesSection /> <AboutArea /> <PopulerProduct /> <CategorysSection /> <FeaturesSectionCustomer /> </template>
Далее подключим слои и странички приложении front/app.vue:
<template> <NuxtLayout> <NuxtPage /> </NuxtLayout> </template>
Перед запуском приложение устанавливаем пакет sass:
yarn add --dev sass sass yarn dev -o
Подключаем бэкенд strapi
Первым делом необходимо загрузить карточки продуктов и категорий в наш бэкенд
Используя пример useFetch из документации nuxt, добавляем javascript код отвечающий за асинхронное получения данных из API strapi front/components/CategorysSection.vue:
<script setup> const config = useRuntimeConfig(); const { data: categories } = await useFetch(() => `/api/categories`, { baseURL: config.API_URL, headers: {"Authorization": "bearer " + config.API_TOKEN}, query: { "populate": "Image"}, }) </script>
Так же используя пример отрисовки списков и документации vue добавляем динамический рендеринг с помощью директивой v-for из полученных данных карточки каталогов.
Аналогичным образом настроим динамический рендеринг компоненты PopulerProduct.vue с популярными продуктами.
Далее настраиваем передачу параметров через конфиг front/nuxt.config.ts:
export default defineNuxtConfig({ runtimeConfig: { API_TOKEN: "", // NUXT_API_TOKEN public: { API_URL: "", // NUXT_PUBLIC_API_URL }, }, css: [ '@/assets/scss/main.scss' ], })
И настраиваем эти параметры в IDE конфигурации запуска фронта
Запустим фронт и проверяем рендеринг главной странички
Теперь добавим динамическую страничку с детализацией продукта front/pages/product/[...slug].vue:
<template> <BreadCrumb /> <ProductDetails /> <FeaturesSection /> </template>
И в компоненту ProductDetails добавим асинхронное получение данных о продукте по его slug из параметров маршрутизации запросов front/components/ProductDetails.vue:
<script setup> const slug = useRoute().params.slug const config = useRuntimeConfig() const { data: products } = await useFetch(() => `/api/products`, { baseURL: config.API_URL, headers: { "Authorization": "bearer " + config.API_TOKEN }, query: { "filters\[Slug\][$eq]": slug, "populate": "Image" } }) </script>
И аналогично помощью директивой v-for отрисуем список и передадим парамеры продукта в шаблонизатор
Так как в компоненте FeaturesSection используется автозапуск карусели на клиенте, то для корректного отображения установим и настроим карусель в nuxt
yarn install vue3-carousel
Подключим Carousel в компоненте front/components/FeaturesSection.vue
<script> import { Carousel, Navigation, Slide } from 'vue3-carousel' import 'vue3-carousel/dist/carousel.css' export default defineComponent({ name: 'WrapAround', components: { Carousel, Slide, Navigation, }, }) </script>
Проверяем рендеринг странички с продуктом
Создаем корзину покупок
При нажатии на кнопку добавить в корзину будем добавлять ID - идентификатор продукта в список хранимый в локальном хранилище и на сервера.
Использует документацию по библиотеке Pinia настроим хранилище для списка покупок front/store/cart.ts:
import { defineStore, skipHydrate } from 'pinia' import { useLocalStorage } from '@vueuse/core' export const useCartStore = defineStore('cartStore', () => { const cartItems = useLocalStorage('pinia/cart', []) function addValueToCartList(id: never) { cartItems.value.push(id) } return { addValueToCartList, cartItems: skipHydrate(cartItems)} })
Далее в компоненту PopulerProduct импортируем функцию addValueToCartList - добавления товаров в корзину покупок в компоненту front/components/PopulerProduct.vue:
<script setup> import { useCartStore } from '~/store/cart' const cartStore = useCartStore() const { addValueToCartList } = cartStore ...
И в шаблоне вызовем ее при клике на иконку с корзиной с помощь директивы @click.prevent="addValueToCartList(item.id)"
Аналогично подключим функцию addValueToCartList в компонент с детализацией продукта front/components/ProductDetails.vue:
<a class="btn cart-bg " href="#" @click.prevent="addValueToCartList(item.id)">Add to cart
Так же импортируем переменную cartItems для отображения числа товаров в компоненте front/components/HeaderArea.vue:
<script setup> import { storeToRefs } from 'pinia' import { useCartStore } from '~/store/cart.ts' const cartStore = useCartStore() const { cartItems } = storeToRefs(cartStore) </script>
И в шаблон подставим переменную с функцией длинны списка:
<span class="cart">{{ cartItems.length }}</span>
Проверяем работу кнопок, отображения счетчика товаров и убеждаемся, что после обновления странички счетчик товаров не изменился.
Теперь добавляем на страничку с покупками, где этот список будет использоваться для асинхронного получения данных о товарах в корзине. front/pages/cart.vue:
<template> <BreadCrumb /> <Cart /> </template>
Создаем компонуемый файл с товарами в корзине front/composables/useProducts.ts:
import { stringify } from 'qs'; export default async function (items: number[]) { const config = useRuntimeConfig(); const {data: products} = await useFetch(() => `/api/products?` + stringify({ filters: { id: { $in: items.length > 0 ? items : [-1]}}, populate: "Image", }, { encodeValuesOnly: true}), { baseURL: config.API_URL, headers: {"Authorization": "bearer " + config.API_TOKEN}, }) return products.value }
Так как список покупок хранится в локальном хранилище браузера нам потребуется серверный API для сохранения и получения списка покупок на стороне сервера. Создадим сильно упрощенный вариант без привязки к уникальной сессии пользователя.
front/server/api/cart.get.ts:
export default defineEventHandler(async (event) => { const body = await readBody(event) await useStorage().setItem('db:cart', body) return true })
front/server/api/cart.post.ts:
export default defineEventHandler(async (event) => { const body = await readBody(event) await useStorage().setItem('db:cart', body) return true })
Соответственно добавляем эти функции, функции удаления и количества в хранилище для списка покупок front/store/cart.ts:
... function removeCartItem(id: never) { cartItems.value = cartItems.value.filter((number, i) => number != id) pushCart() } function quantity(id: never) { return cartItems.value.filter((number, i) => number == id).length } async function pushCart() { console.log("pull cart", cartItems.value) await useFetch('/api/cart', { method: 'post', body: { items: cartItems.value } }) } async function fetchCart() { const { data: resData } = await useFetch('/api/cart') if (!resData.value) { return } cartItems.value.length = 0 cartItems.value.push(...resData.value.items) } return { removeCartItem, addValueToCartList, pushCart, fetchCart, quantity, cartItems: skipHydrate(cartItems)} })
Далее создаем компонент с корзиной покупок front/components/Cart.vue:
<script setup> import { storeToRefs } from 'pinia' import { useCartStore } from '~/store/cart.ts' const cartStore = useCartStore() const { cartItems } = storeToRefs(cartStore) const { fetchCart } = cartStore const { quantity, removeCartItem } = cartStore const config = useRuntimeConfig() if (process.server) { await fetchCart()} const products = await useProducts(cartItems.value.concat()) function removeProductItem(idx) { const id = products.data[idx].id products.data.splice(idx, 1) removeCartItem(id) } </script>
И полученные данные отрисовываем с помощью директивы v-for и при клике на Delete вызываем функцию removeProductItem, которая реактивно перерисовывает список товаров, удаляет все товары с данным id из хранилища покупок с сохранением его на сервере.
Еще необходимо вызывать функцию pushCart отправки списка покупок на сервер перед переходом на страничку корзины из компоненты front/components/HeaderArea.vue:
... const { pushCart } = cartStore </script>
<a href="/cart" @click="pushCart">
Проверим рендеринг странички с корзиной и кнопки удаления
Так как для демонстрации мы взяли случайны шаблон магазина, а в вашем случае он может сильно отличатся по UX, то доводить это шаблон до полной функциональности смысла не вижу.
Все примеры кода можно найти в моем репозитории https://github.com/kmlebedev/e-commerce-store-with-nuxt3-and-strapi