Как создать интернет-магазин с 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 root@192.168.1.10
Получаем публичный адрес
Для получения публичного адреса 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:1337systemctl 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