Платформа электронной коммерции с открытым исходным кодом Node.js для Remix

Введение

В этом руководстве вы узнаете, как создать внешний пользовательский интерфейс для Medusa с помощью Remix.

Medusa — это платформа электронной коммерции с открытым исходным кодом на Node.js, которая предоставляет вам множество функций электронной коммерции, таких как управление заказами, потоки RMA, управление клиентами и многое другое. Medusa также фокусируется на обеспечении хорошего опыта для разработчиков, позволяя вам начать работу в течение нескольких минут и имея сильное сообщество поддержки и документацию для поддержки.

Remix — это полнофункциональный веб-фреймворк, который позволяет создавать элегантные пользовательские интерфейсы с устойчивым пользовательским опытом. Он рендерит страницы на сервере, в отличие от большинства фреймворков React.

В этом руководстве мы сосредоточимся только на основах, которые включают в себя:

  • Настройка макета витрины магазина
  • Вывод списка товаров
  • Отображение страницы одного товара с опциями

Ниже приведен снимок того, что мы будем создавать:

Вы можете найти полный проект в этом репозитории GitHub.

Предварительные условия

Эта статья предназначена для средних и продвинутых разработчиков React. Вы должны быть знакомы со следующим:

  • учебник Remix Blog
  • Учебник по Remix Jokes

Почему Remix

Remix — это новый фреймворк React, который быстро набирает популярность в последние пару лет. Он был создан авторами популярной библиотеки React Router.

Для электронной коммерции рекомендуется использовать серверные фреймворки, чтобы обеспечить лучшие возможности поисковой оптимизации, повышенную безопасность API и более быстрые динамические страницы для конечных пользователей. Remix обладает множеством ключевых преимуществ, включая:

  • Он очень быстро отображает динамический контент, поскольку обработка контента и вызовы API сторонних разработчиков выполняются на сервере, а не на клиенте.
  • Он отлично работает в медленных сетях, таких как 2G и 3G.
  • Веб-сайты Remix работают, даже если в браузере отключен JavaScript
  • Время создания и производительность не зависят от размера данных.

Почему именно Medusa

Безголовая архитектура Medusa облегчает создание витрины магазина на выбранном вами языке или фреймворке. Выбираете ли вы Remix, Gatsby, Next.js или любой другой фреймворк, вы можете использовать API Medusa для создания витрины магазина, обладающей всеми основными возможностями электронной коммерции.

Вы также можете подключить свой сервер Medusa к администратору Medusa, чтобы получить полную власть над своим магазином электронной коммерции. Администратор Medusa позволяет операторам магазина управлять товарами, заказами, клиентами, скидками и многим другим.

Настройка сервера Medusa

Первым шагом в этом руководстве будет настройка сервера Medusa, на котором будет осуществляться хранение данных и обработка бэкенда. Сначала установите программное обеспечение локально следующим образом:

# Install Medusa CLI
npm install -g @medusajs/medusa-cli

# Create a new Medusa project
medusa new my-medusa-store --seed
Войдите в полноэкранный режим Выйти из полноэкранного режима

Опция --seed добавляет фиктивные товары в ваш магазин, а также некоторые другие настройки.

Настройка Medusa Admin

Как уже упоминалось, Medusa предоставляет мощный интерфейс администратора, который вы можете использовать для управления вашим магазином, товарами, заказами и многим другим! Админку легко установить и использовать, однако она совершенно необязательна. Поэтому, если вас не интересует админка, вы можете перейти к следующему разделу.

В отдельной директории выполните следующую команду для установки админки:

git clone https://github.com/medusajs/admin medusa-admin
Войти в полноэкранный режим Выйти из полноэкранного режима

Это создаст новый каталог medusa-admin. Перейдите в эту директорию и установите зависимости:

npm install
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь запустите сервер Medusa из каталога my-medusa-store:

npm start
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем запустите администратора Medusa из каталога medusa-admin:

npm run develop
Войти в полноэкранный режим Выйти из полноэкранного режима

Если вы откроете localhost:7000 в браузере, вы увидите экран входа в систему. Опция --seed, которую вы использовали ранее при создании магазина Medusa, добавляет пользователя admin с электронной почтой «admin@medusa-test.com» и паролем «supersecret».

Затем на боковой панели выберите Продукты. Вы увидите список товаров, доступных в вашем магазине, и сможете добавить новый товар с помощью кнопки Новый товар в правом верхнем углу.

Если вы нажмете кнопку Новый продукт или нажмете на существующий продукт для его редактирования, вы сможете ввести много информации о продукте. Вы также сможете добавить варианты, загрузить изображения и многое другое.

Настройка Remix + Tailwind CSS

В этом разделе вы быстро создадите проект Remix и настроите Tailwind CSS для быстрого создания пользовательского интерфейса. Пользовательский интерфейс не будет полностью отзывчивым, чтобы упростить учебник.

Вы также будете использовать JavaScript для написания кода, однако я настоятельно рекомендую использовать TypeScript и фреймворк Test-Driven Development для реальных фронтендов.

Мы можем быстро создать наш проект Remix следующим образом:

npx create-remix@latest remix-medusa-storefront

? What type of app do you want to create? Just the basics
? Where do you want to deploy? Remix App Server
? TypeScript or JavaScript? JavaScript
? Do you want me to run `npm install`? (Y/n) Y
Войдите в полноэкранный режим Выход из полноэкранного режима

После установки перейдите в папку проекта через терминал и убедитесь, что все работает, выполнив команду npm run dev. Убедитесь, что localhost:3000 загружается правильно. Если все загружается нормально, убейте сервер dev, прежде чем переходить к следующему шагу.

Далее с помощью официального руководства по интеграции Tailwind CSS Remix установите Tailwind CSS в ваш проект remix-medusa-storefront следующим образом:

Шаг 1: Установите зависимости пакета

# Install Dev packages
npm install -D tailwindcss postcss autoprefixer concurrently

# Generate `tailwind.config.js` file
npx tailwindcss init -p
Войдите в полноэкранный режим Выйдите из полноэкранного режима

Шаг 2: Обновите поле content в tailwind.config.js, чтобы настроить файлы, используемые для процесса очистки Tailwind CSS.

module.exports = {
  content: ["./app/**/*.{js,jsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};
Вход в полноэкранный режим Выход из полноэкранного режима

Шаг 3: Измените скрипты dev и build в package.json, чтобы добавить шаги компиляции CSS:

"scripts": {
        ...,
    "build": "npm run build:css && remix build",
    "build:css": "tailwindcss -m -i ./styles/app.css -o app/styles/app.css",
    "dev": "concurrently "npm run dev:css" "remix dev"",
    "dev:css": "tailwindcss -w -i ./styles/app.css -o app/styles/app.css"
  },
Вход в полноэкранный режим Выход из полноэкранного режима

Шаг 4: Создайте файл ./styles/app.css в корне проекта со следующим содержимым:

@tailwind base;
@tailwind components;
@tailwind utilities;
Вход в полноэкранный режим Выйти из полноэкранного режима

Шаг 5: Добавьте этот код в app/root.jsx, чтобы разрешить загрузку скомпилированного Tailwind’ом CSS на страницы:

import styles from "~/styles/app.css";

export function links() {
  return [{ rel: "stylesheet", href: styles }];
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Шаг 6: Протестируйте настройку CSS Tailwind, заменив код в app/routes/index.jsx следующим кодом:

export default function Index() {
  return (
   <div className="container mx-auto mt-8">
    <h1 className="text-3xl font-bold text-gray-700 underline">
      Hello world!
    </h1>
   </div>
   );
 }
Войти в полноэкранный режим Выйти из полноэкранного режима

Выполните npm run dev и убедитесь, что стили Tailwind CSS загружаются на индексной странице на localhost:3000/.

Обратите внимание, что когда вы запускаете свой проект, будь то в режиме dev или build, файл /app/styles/app.css генерируется для вас на основе исходных данных ./styles/app.css. Следовательно, вы не должны трогать сгенерированный файл при внесении изменений в CSS.

Добавление /app/styles/app.css в .gitignore является хорошей идеей, поскольку этот файл будет сгенерирован на этапе развертывания.

Макет сайта

Теперь, когда вы успешно интегрировали Tailwind CSS в рабочий проект Remix, вы можете приступить к настройке базового макета витрины. Создайте папку app/layouts и создайте в ней следующие файлы:

  • footer.jsx
  • navbar.jsx
  • index.jsx

В app/layouts/footer.jsx добавьте следующий код:

export default function Footer() {
  const currentYear = new Date().getFullYear();

  return (
   <div className="py-4 text-sm text-center text-gray-200 bg-gray-800">
    &copy; Copyright {currentYear} [Brand name]. All Rights Reserved
   </div>
   );
 }
Вход в полноэкранный режим Выйти из полноэкранного режима

Этот фрагмент просто отображает информацию об авторских правах в текущем году.

Для Navbar необходимо отобразить:

  • Логотип
  • Навигационные ссылки
  • значок корзины

Для логотипа вы можете включить свой собственный логотип или пока скопировать этот. Переименуйте файл в logo.svg и поместите его в каталог /public.

Для навигационных ссылок вы будете использовать [<NavLink>](https://remix.run/docs/en/v1/api/remix#navlink), который является специальным видом <Link>, который знает, является ли страница текущей загруженной страницей. Вам потребуется стилизация для CSS-класса .active, чтобы обеспечить визуальную индикацию.

Для иконки корзины вы просто импортируете ее из пакета React Icons. Установите его следующим образом:

npm install react-icons
Войдите в полноэкранный режим Выйдите из полноэкранного режима

Теперь, когда все необходимые ресурсы установлены, вы можете вставить следующий код в app/layouts/navbar.jsx.

import { Link, NavLink } from "@remix-run/react";
import { BiShoppingBag } from "react-icons/bi";

export default function Navbar() {
 const links = [
   {
       label: "Home",
       url: "/",
   },
   {
       label: "Products",
       url: "/products",
   },
   {
       label: "About",
       url: "/about",
   },
  ];

 return (
  <nav className="flex items-center justify-between px-8 pt-2">
    {/* Site Logo */}
   <div className="font-mono text-3xl font-extrabold uppercase">
    <Link to="/">
     <img className="w-28" src="/logo.svg" alt="Medusa" />
    </Link>
   </div>

    {/* Navigation Links */}
   <div className="space-x-4">
     {links.map((link, index) => (
     <NavLink key={index} to={link.url} className="navlink">
       {link.label}
     </NavLink>
     ))}
   </div>

    {/* Shopping Cart Indicator/Checkout Link */}
   <div className="font-semibold text-gray-600 hover:text-emerald-500">
    <NavLink
     to="/checkout"
     className="inline-flex items-center space-x-1 transition-colors duration-300"
    >
     <BiShoppingBag className="text-xl" /> <span>0</span>
    </NavLink>
   </div>
  </nav>
  );
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Далее вставьте следующий код в app/layouts/index.jsx, который будет основным макетом вашего сайта:

import Footer from "./footer";
import Navbar from "./navbar";

export default function Layout({ children }) {
 return (
  <>
   <header className="border-b">
    <Navbar />
   </header>
   <main className="container flex justify-center flex-grow mx-auto">
     {children}
   </main>
   <Footer />
  </>
  );
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Добавьте этот код в ./styles/app.css после базовых стилей Tailwind, чтобы включить ваши пользовательские стили макета и навигации:

/*
Layout styling
*/
html {
  @apply antialiased font-sans text-gray-800 bg-gray-200;
 }

 body {
  @apply flex flex-col min-h-screen overflow-x-hidden;
 }

 /*
 Typography styling
 */

 h1 {
  @apply text-3xl font-bold;
 }

 h2 {
  @apply text-xl;
 }

 p {
  @apply text-gray-700;
 }

 /*
 Navigation menu styling
 */

 .navlink {
  @apply inline-block w-20 py-2 font-semibold text-center text-gray-500 hover:text-emerald-500;
 }

 .navlink:after {
  @apply block pb-2 border-b-2 border-emerald-400 transition ease-in-out duration-300 origin-[0%_50%] content-[""] scale-x-0;
 }

 .navlink:hover:after {
  @apply scale-x-100;
 }

 a.active {
  @apply font-bold text-gray-700;
 }
Войти в полноэкранный режим Выйти из полноэкранного режима

Наконец, замените весь код в app/root.jsx, который включает ваш новый макет сайта:

import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

import Layout from "./layouts";
import styles from "~/styles/app.css";

export function links() {
  return [{ rel: "stylesheet", href: styles }];
 }

 export function meta() {
  return {
   charset: "utf-8",
   title: "Medusa Remix StoreFront",
   viewport: "width=device-width,initial-scale=1",
   };
 }

 export default function App() {
  return (
   <Document>
    <Layout>
     <Outlet />
     <ScrollRestoration />
     <Scripts />
     <LiveReload />
    </Layout>
   </Document>
   );
 }

 function Document({ children }) {
  return (
   <html lang="en">
    <head>
     <Meta />
     <Links />
    </head>
    <body>{children}</body>
   </html>
   );
 }

 export function ErrorBoundary({ error }) {
  return (
   <Document>
    <Layout>
     <div className="text-red-500">
      <h1>Error</h1>
      <p>{error.message}</p>
     </div>
    </Layout>
   </Document>
   );
 }
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь вы можете снова запустить сервер dev, выполнив команду npm run dev. Теперь ваша индексная страница localhost:3000 должна выглядеть так, как показано на скриншоте ниже:

Маршруты страницы

Теперь вы добавите страницы продуктов, информации и оформления заказа. Создайте следующие файлы в папке app/routes:

  • products/index.jsx
  • about.jsx
  • checkout.jsx

Вы не будете реализовывать никакой логики для этого раздела. Вы просто разместите код, начиная с app/routes/products/index.jsx:

export default function ProductsIndexRoute() {
  return (
   <div className="w-full mt-8">
    <h1>Products Page</h1>
    <p>List of products</p>
   </div>
   );
 }
Вход в полноэкранный режим Выход из полноэкранного режима

Скопируйте следующий финальный код для app/routes/about.jsx:

export default function AboutRoute() {
  return (
   <div className="w-full mt-8">
    <h1>About</h1>
    <p className="mt-4 text-justify">
      Lorem ipsum dolor sit amet, consectetur adipisicing elit. Labore aperiam
      maxime assumenda dolore excepturi ipsam accusantium repudiandae ducimus
      eum, voluptatibus, adipisci nam temporibus vel ex! Non iure dolore at
      mollitia.
    </p>
   </div>
   );
 }
Войти в полноэкранный режим Выход из полноэкранного режима

Скопируйте следующий код заполнителя для app/routes/checkout.jsx:

export default function CheckoutRoute() {
  return (
   <div className="w-full mt-8">
    <h1>Checkout Page</h1>
   </div>
   );
 }
Войти в полноэкранный режим Выход из полноэкранного режима

Завершите главную страницу, реализовав простой баннер Hero, вдохновленный TailwindUI. Замените весь код в app/routes/index.jsx на следующий:

import { Link } from "@remix-run/react";

export default function IndexRoute() {
 return (
  <div>
    {/* Hero Banner */}
   <div className="px-12 py-32 text-center text-gray-200 bg-gray-800">
    <h1 className="text-5xl text-gray-100">New arrivals are here</h1>
    <p className="px-8 mt-2 font-semibold text-gray-300">
      The new arrivals have, well, newly arrived. Check out the latest
      options from our summer small-batch release while they're still in
      stock.
    </p>
    <Link
     to="/products"
     className="inline-block px-6 py-2 mt-8 text-sm font-semibold text-gray-700 transition duration-300 bg-gray-100 rounded-md hover:bg-white hover:text-gray-900 hover:scale-110 color"
    >
      Shop New Arrivals
    </Link>
   </div>
  </div>
  );
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Ваша домашняя страница должна выглядеть так, как показано на скриншоте ниже:

Перейдите и проверьте все страницы, чтобы убедиться, что код-заполнитель работает правильно. В следующем разделе вы начнете реализовывать логику для маршрута /products.

Страница продуктов

В этом разделе вы реализуете страницу «Товары» с помощью извлечения данных с сервера Medusa и CSS-сетки.

Во-первых, убедитесь, что ваш сервер Medusa Store работает по адресу localhost:9000. Если это не так, вы можете перейти в папку проекта Medusa в терминале и выполнить команду npm start. Когда она будет запущена, вы можете перейти к следующему шагу.

Вернувшись к проекту remix-medusa-storefront, установите пакет Medusa JS Client, чтобы обеспечить легкий доступ к API Medusa:

npm install @medusajs/medusa-js
Войдите в полноэкранный режим Выход из полноэкранного режима

Далее необходимо создать утилиту, которая поможет вам создать экземпляр клиента medusa-js и получить к нему доступ. Создайте файл app/utils/client.js со следующим кодом:

import Medusa from "@medusajs/medusa-js";

const BACKEND_URL = process.env.PUBLIC_MEDUSA_URL || "http://localhost:9000";

export const createClient = () => new Medusa({ baseUrl: BACKEND_URL });
Вход в полноэкранный режим Выйти из полноэкранного режима

Затем откройте файл apps/routes/products/index.js и замените его на следующий:

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import { createClient } from "~/utils/client";

export const loader = async () => {
 const client = createClient();
 const { products } = await client.products.list();
 return json(products);
};

export default function ProductsIndexRoute() {
 const products = useLoaderData();

 return (
  <div className="w-full mt-8">
   <h1>Latest Arrivals</h1>
   <ul>
     {products.map((product) => (
     <li key={product.id}>{product.title}</li>
     ))}
   </ul>
  </div>
  );
}
Войти в полноэкранный режим Выйти из полноэкранного режима

В приведенном выше коде вы используете функцию загрузки данных Remix для запроса данных с сервера Medusa. Эти данные передаются в функцию рендеринга через хук useLoaderData. Проверьте API продукта Medusa и посмотрите, как выглядит структура JSON. На странице /products вы должны ожидать следующего вывода:

Теперь, когда у вас есть данные, вы можете приступить к созданию пользовательского интерфейса с использованием CSS-сеток и карточек товаров. Но сначала нам нужно создать небольшой помощник, который будет отображать цену товара.

В Medusa продукт содержит несколько вариантов, и каждый вариант имеет разные цены для нескольких валют.

Данные, которые вы загрузили ранее при создании сервера Medusa, содержат цены в долларах США и евро для каждого варианта товара. Поскольку это вводный учебник, цель которого — быть простым, вы не сможете полностью реализовать всю необходимую логику для производственного приложения, которая включает в себя:

  • автоматическое определение региона и валюты пользователя с помощью геолокации
  • Предоставление пользователям опций для выбора региона и валюты
  • Отображение цен на товары в зависимости от выбранного региона/валюты.

Создайте файл app/utils/prices.js и скопируйте следующий упрощенный код:

// TODO: Detect user language
const locale = "en-US";

// TODO: Detect user currency/Allow currency selection (usd | eur)
const regionCurrency = "usd";

export function formatPrice(variant) {
  const price = variant.prices.find(
    (price) => price.currency_code == regionCurrency
  );
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency: regionCurrency,
  }).format(price.amount / 100);
}
Вход в полноэкранный режим Выйти из полноэкранного режима

В приведенном выше коде вместо настраиваемых переменных используются жестко закодированные константы. Функция formatPrice принимает в качестве входных данных вариант продукта и возвращает цену в виде отформатированной строковой валюты.

Далее необходимо создать компонент ProductCard, который будет отображать:

  • Эскиз
  • Название
  • Цена (для 1-го варианта)

Создайте файл app/components/product-card.jsx и скопируйте следующий код:

import { Link } from "@remix-run/react";
import { formatPrice } from "~/utils/prices";

export default function ProductCard({ product }) {
 const variant = product.variants[0];

 return (
  <section className="overflow-hidden bg-white rounded-lg shadow:md hover:shadow-lg w-80">
   <Link to={`/products/${product.id}`}>
    <img className="w-80" src={product.thumbnail} alt={product.title} />
    <div className="p-4">
     <h3 className="text-lg font-bold text-gray-700 hover:underline">
       {product.title}
     </h3>
     <p className="font-semibold text-teal-600">{formatPrice(variant)}</p>
    </div>
   </Link>
  </section>
  );
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Наконец, обновите код в apps/routes/products/index.js следующим образом:

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import ProductCard from "~/components/product-card";
import { createClient } from "~/utils/client";

export const loader = async () => {
 const client = createClient();
 const { products } = await client.products.list();
 return json(products);
};

export default function ProductsIndexRoute() {
 const products = useLoaderData();

 return (
  <div className="w-full p-4 my-8">
   <h1 className="text-center">Latest Arrivals</h1>
   <div className="grid grid-cols-1 gap-6 px-4 mt-8 md:px-12 lg:px-6 xl:px-4 xl:gap-6 2xl:px-24 2xl:gap-6 justify-items-center md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
     {products.map((product) => (
     <ProductCard key={product.id} product={product} />
     ))}
   </div>
  </div>
  );
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Внедрение этих обновлений должно дать следующий результат:

Страница одного продукта

Чтобы создать страницу одного продукта, необходимо использовать соглашение об именовании файлов slug. Создайте файл apps/routes/product/$productId.jsx со следующим содержимым:

import { useState } from "react";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { BiShoppingBag } from "react-icons/bi";

import { createClient } from "~/utils/client";
import { formatPrice } from "~/utils/prices";

export const loader = async ({ params }) => {
  const client = createClient();
  const { product } = await client.products.retrieve(params.productId);
  return json(product);
};

export default function ProductRoute() {
  const product = useLoaderData();
  const [variant, setVariant] = useState(product.variants[0]);
  const [image, setImage] = useState(product.images[0]);
  const [quantity, setQuantity] = useState(1);

  const handleVariantChange = (index) => {
    setVariant(product.variants[index]);
    setQuantity(1);
  };

  const handleQuantityChange = (action) => {
    switch (action) {
      case "inc":
        if (quantity < variant.inventory_quantity) 
          setQuantity(quantity + 1);
        break;

      case "dec":
        if (quantity > 1) setQuantity(quantity - 1);
        break;

      default:
        break;
    }
  };

  const handleImageChange = (id) => {
    setImage(product.images.find((img) => img.id === id));
  };

  return (
    <div className="w-full">
      <div className="grid items-center md:grid-cols-2">
        <div>
          <img
            className="w-full rounded-lg"
            src={image.url}
            alt={product.title}
          />
          <div className="flex justify-center p-4 space-x-2">
            {product.images.map((imageItem) => (
              <img
                className={`w-16 border-2 rounded-lg ${
                  imageItem.id === image.id ? "border-teal-400" :      null
                }`}
                key={imageItem.id}
                src={imageItem.url}
                alt={product.title}
                onClick={() => handleImageChange(imageItem.id)}
              />
            ))}
          </div>
        </div>
        <div className="flex flex-col px-16 py-4 space-y-8">
          <h1>{product.title} </h1>
          <p className="font-semibold text-teal-600">{formatPrice(variant)}</p>
          <div>
            <p className="font-semibold">Select Size</p>
            <div className="grid grid-cols-3 gap-2 mt-2 md:grid-cols-2 xl:grid-cols-4">
              {product.variants.map((variantItem, index) => (
                <button
                  key={variantItem.id}
                  className={`px-2 py-1 mr-2 text-sm hover:brightness-90 ${
                    variantItem.id === variant.id
                      ? "bg-gray-700 text-gray-100"
                      : "bg-gray-300 text-gray-700"
                  }`}
                  onClick={() => handleVariantChange(index)}
                >
                  {variantItem.title}
                </button>
              ))}
            </div>
          </div>
          <div>
            <p className="font-semibold">Select Quantity</p>
            <div className="flex items-center px-4 mt-2 space-x-4">
              <button
                className="px-4 py-2 hover:shadow-sm hover:text-teal-500 hover:font-bold"
                onClick={() => handleQuantityChange("dec")}
              >
                -
              </button>
              <span>{quantity}</span>
              <button
                className="px-4 py-2 hover:shadow-sm hover:text-teal-500 hover:font-bold"
                onClick={() => handleQuantityChange("inc")}
              >
                +
              </button>
            </div>
          </div>
          <div>
            <button className="inline-flex items-center px-4 py-2 font-semibold text-gray-200 bg-gray-700 rounded hover:text-white hover:bg-gray-900">
              <BiShoppingBag className="mr-2 text-lg" />{" "}
              <span>Add to Cart</span>
            </button>
          </div>
          <div>
            <p className="font-semibold">Product Description</p>
            <hr className="w-2/3 mt-2 border-t-2 border-gray-300" />
            <p className="mt-4 text-gray-700">{product.description}</p>
          </div>
        </div>
      </div>
    </div>
  );
}
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте разобьем логику на несколько этапов. Сначала вы загружаете один продукт, используя параметр маршрута productId.

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import { createClient } from "~/utils/client";
import { formatPrice } from "~/utils/prices";

export const loader = async ({ params }) => {
 const client = createClient();
 const { product } = await client.products.retrieve(params.productId);
 return json(product);
};

export default function ProductRoute() {
  const product = useLoaderData();

  return (
      <div className="w-full mt-8">
       <h1>{product.title}</h1>
       <p>{formatPrice(variant)}</p>
       <p>{product.description}</p>
      </div>
  );
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вы должны быть знакомы с этим кодом, поскольку он очень похож на app/components/product-card.jsx. Основное отличие заключается в том, что вы используете client.products.retrieve(id) Medusa для получения данных по одному товару.

Во-вторых, вам нужно дать клиентам возможность выбирать варианты товара, которыми в данном случае являются размеры. Вы можете реализовать эту возможность с помощью хука useState React:

import { useState } from "react";

export default function ProductRoute() {
    const product = useLoaderData();
    const [variant, setVariant] = useState(product.variants[0]);

      const handleVariantChange = (index) => {
        setVariant(product.variants[index]);
        setQuantity(1);
      };

    return (
        <div>
           ...
           <div>
                {product.variants.map((variantItem, index) => (
                <button
                 key={variantItem.id}
                 onClick={() => handleVariantChange(index)}
                >
                  {variantItem.title}
                </button>
                ))}
              </div>
        </div>
    )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Приведенный выше код создаст серию кликабельных кнопок, соответствующих каждому варианту, который есть у товара.

В-третьих, вам нужно дать конечным пользователям возможность просматривать различные изображения продукта. Вот логика для этой функции:

import { useState } from "react";

export default function ProductRoute() {
    ...
  const [image, setImage] = useState(product.images[0]);

  const handleImageChange = (id) => {
    setImage(product.images.find((img) => img.id === id));
  };

  return (
    <div>
        ...
        <div>
          <img src={image.url} alt={product.title}
          />
          <div>
            {product.images.map((imageItem) => (
              <img
                className={`w-16 border-2 rounded-lg ${
                  imageItem.id === image.id ? "border-teal-400" : null
                }`}
                key={imageItem.id}
                src={imageItem.url}
                alt={product.title}
                onClick={() => handleImageChange(imageItem.id)}
              />
            ))}
          </div>
        </div>
    </div>
  )
}
Войти в полноэкранный режим Выйти из полноэкранного режима

В приведенном выше фрагменте под основным изображением продукта будет отображаться список кликабельных миниатюр, на которые пользователи могут нажимать для переключения и просмотра различных изображений продукта.

В-четвертых, вам нужно предоставить конечным пользователям ввод quantity. Вам необходимо проверить этот ввод, чтобы убедиться, что:

  • Количество не меньше 0
  • Количество не больше, чем запасы варианта.

Вот логика для ввода количества:

import { useState } from "react";

export default function ProductRoute() {
    ...
  const [quantity, setQuantity] = useState(1);

  const handleQuantityChange = (action) => {
    switch (action) {
      case "inc":
        if (quantity < variant.inventory_quantity) setQuantity(quantity + 1);
        break;

      case "dec":
        if (quantity > 1) setQuantity(quantity - 1);
        break;

      default:
        break;
    }
  };

  return (
    <div>
        ...
        <div>
          <p>Select Quantity</p>
        <div>
          <button onClick={() => handleQuantityChange("dec")}>
            -
          </button>
          <span>{quantity}</span>
          <button onClick={() => handleQuantityChange("inc")}>
            +
          </button>
        </div>
      </div>
    </div>
  )
}
Войти в полноэкранный режим Выход из полноэкранного режима

Кнопки + и - позволят пользователям увеличивать или уменьшать желаемое количество определенного варианта. Функция handleQuantityChange выполняет проверку для этого ввода.

Теперь, когда вы разобрались с различными логическими секциями страницы Single Product, давайте посмотрим, как выглядит готовая страница в браузере:

Вы должны иметь возможность выбирать варианты (размер), эскизы и задавать количество. Убедитесь, что все страницы товаров загружаются без ошибок.

Что дальше

Есть и другие важные функции электронной коммерции, которые вам еще предстоит реализовать. К ним относятся оформление заказа, оплата, доставка, учетные записи клиентов и другие функции.

Вы можете ознакомиться с документацией Medusa для получения более подробной информации о дальнейших действиях, в том числе:

  • Как добавлять плагины. Вы также можете ознакомиться со списком плагинов, доступных в Medusa:
  • Добавление методов оплаты, таких как Stripe.
  • Добавление пользовательских методов доставки.
  • Добавьте поиск товаров с помощью Algolia.

Если вас интересует витрина со всеми готовыми функциями электронной коммерции, Medusa предлагает витрины Next.js и Gatsby, которые вы можете использовать. Эти витрины включают такие функции, как учетные записи клиентов, списки товаров, управление корзиной и полный рабочий процесс оформления заказа.

Если у вас возникнут какие-либо проблемы или вопросы, связанные с Medusa, обращайтесь к команде Medusa через Discord. Вы также можете обратиться за поддержкой к команде Remix через Discord.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *