March 19, 2023

Как создать интернет-магазин с 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:

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 │
└─────────────────────────────┘

Потребление памяти до 512Мб

Создаем пользователя

Для создания пользователя открываем страничку http://localhost:1337/admin с панелью администратора. Так как мы установили strapi на удаленном хосте, то при попытке подключится по адресу отличному от localhost`а приводит к ошибке:

Во избежании этого необходимо пробросить порт 1337 с удаленного сервера на localhost:

ssh -L 1337:localhost:1337 [email protected]
В пароле должно быть не менее 8 символов, 1 прописная, 1 строчная и 1 цифра.

Получаем публичный адрес

Для получения публичного адреса 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

Запускаем сервис strapi-app:

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

И перезапустим haproxy:

systemctl restart haproxy
systemctl status haproxy

Настраиваем TLS

Устанавливаем пакет cerbot:

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

  • Name - short text
  • Description - short text
  • Price - decimal number
  • Image - multiple media
  • Slug - UID

2. Создаем collection type для категорий

Аналогично товарам создаем collection type для категорий товаров с полями:

  • Name - short text
  • Slug - UID
  • Связь - один ко многим товарам

2. Создаем collection type для заказов

Аналогично товарам создаем collection type для заказов с полями:

  • Item as JSON

2. Создаем collection type для клиентов

Аналогично товарам создаем collection type для заказов с полями:

  • Name - short text
  • Phone - big int
  • Email - email

Сохраняем все 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/:

Создаем фронтенд интернет-магазина

Перед тем как приступить к создания внешнего интерфейса нашего интернет-магазина необходимо:

  1. Подготовить среду для локальной разработки.
    • Установить IDE - это может быть WebStorm. PyCharm, VS Code + Vetur
    • Установить git, nodejs v18, yarn, nuxt
    • Создать приватный репозиторий на https://github.com/
    • Настроить деплой на наш удаленный сервер store.my-domain.ru.
  2. Получить макет сайта для верстки в 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