DevGang
Авторизоваться

Создание магазина цифровых товаров с помощью Next.js и Medusa

В этом уроке вы узнаете, как создать магазин по продаже цифровых товаров (например, электронных книг) с помощью Next.js и Medusa.

Добавьте кнопку предварительного просмотра мультимедиа

Отображать соответствующую информацию о продукте

  • Мы упростим оформление заказа, чтобы оно соответствовало процессу доставки цифровых продуктов.
  • Мы создадим маршруты API Next.js для проверки загрузки продуктов и скрытия путей к файлам.

Medusa: строительные блоки с открытым исходным кодом для коммерции

Краткая информация о Medusa: мы создаем стандартные блоки с открытым исходным кодом, необходимые для создания потрясающих веб-сайтов электронной коммерции или включения коммерческих функций в любой продукт.

Начнём

  • Создайте новое приложение Medusa с помощью стартера Next.js, выполнив:
npx create-medusa-app@latest --with-nextjs-starter
  • Создайте пользователя и убедитесь, что вы можете войти в систему с правами администратора.
  • Подготовьте серверную часть, следуя нашему рецепту цифровых продуктов.
  • Создайте один или два примера продуктов в администраторе Medusa. Убедитесь, что к ним прикреплены цифровые медиафайлы (как файлы предварительного просмотра, так и основные файлы). Также добавьте некоторые значения метаданных продукта. Вы можете выбрать любые пары ключ/значение, которые могут иметь отношение к вашему продукту.

Добавьте определения типов

Если вы используете обычный JavaScript вы можете пропустить этот шаг. 

Прежде чем идти дальше, давайте добавим необходимые определения типов Typescript для наших цифровых продуктов в наш проект витрины Next.js.

Пример кода

// src/types/product-media.ts

import { Product } from "@medusajs/medusa"
import { ProductVariant } from "@medusajs/product"

export enum ProductMediaVariantType {
  PREVIEW = "preview",
  MAIN = "main",
}

export type ProductMedia = {
  id: string
  name?: string
  file?: string
  mime_type?: string
  created_at?: Date
  updated_at?: Date
  attachment_type?: ProductMediaVariantType
  variant_id?: string
  variants?: ProductMediaVariant[]
}

export type ProductMediaVariant = {
  id: string
  variant_id: string
  product_media_id: string
  type: string
  created_at: Date
  updated_at: Date
}

export type DigitalProduct = Omit<Product, "variants"> & {
  product_medias?: ProductMedia[]
  variants?: DigitalProductVariant[]
}

export type DigitalProductVariant = ProductVariant & {
  product_medias?: ProductMedia
}

Добавьте предварительный просмотр цифрового мультимедиа в ответ на продукт

Мы собираемся добавить предварительный просмотр наших электронных книг на страницу сведений о продукте. Чтобы включить это, нам нужно получить предварительный просмотр мультимедиа продукта, который принадлежит варианту продукта, который просматривается в данный момент. В src/lib/data/index.ts мы добавим функцию для получения предварительного просмотра мультимедиа продукта по варианту.

Пример кода

// src/lib/data/index.ts

// ... other imports
import { DigitalProduct, ProductMedia } from "types/product-media"

// ... rest of the functions

export async function getProductMediaPreviewByVariant(
  variant: Variant
): Promise<ProductMedia> {
  const { product_medias } = await medusaRequest("GET", `/product-media`, {
    query: {
      variant_ids: variant.id,
      expand: ["variants"],
    },
  })
    .then((res) => res.body)
    .catch((err) => {
      throw err
    })

  return product_medias[0]
}

Добавьте кнопку загрузки предварительного просмотра

Чтобы дать клиентам представление о том, о чем эта электронная книга, мы раздаем предварительный PDF-файл, содержащий первые несколько страниц. Во-первых, мы создадим маршрут Next API, который обрабатывает загрузку файлов, не показывая пользователю фактическое местоположение файла. После этого мы создадим компонент кнопки «скачать бесплатно предварительный просмотр», который будет вызывать новый маршрут API. Если для варианта продукта доступен предварительный просмотр, мы отобразим его внутри product-actions продукта.

Вы можете использовать недавно созданный DigitalProduct и типы DigitalProductVariant для исправления любых ошибок TypeScript 

Пример кода: маршрут API предварительной загрузки

// src/app/api/download/preview/route.ts

import { NextRequest, NextResponse } from "next/server"

export async function GET(req: NextRequest) {
  // Get the file info from the URL
  const { filepath, filename } = Object.fromEntries(req.nextUrl.searchParams)

  // Fetch the PDF file
  const pdfResponse = await fetch(filepath)

  // Handle the case where the PDF could not be fetched
  if (!pdfResponse.ok) return new NextResponse("PDF not found", { status: 404 })

  // Get the PDF content as a buffer
  const pdfBuffer = await pdfResponse.arrayBuffer()

  // Define response headers
  const headers = {
    "Content-Type": "application/pdf",
    "Content-Disposition": `attachment; filename="${filename}"`, // This sets the file name for the download
  }

  // Create a NextResponse with the PDF content and headers
  const response = new NextResponse(pdfBuffer, {
    status: 200,
    headers,
  })

  return response
}

Пример кода: компонент кнопки загрузки

// src/modules/products/components/product-media-preview/index.tsx

import Button from "@modules/common/components/button"
import { ProductMedia } from "types/product-media"

type Props = {
  media: ProductMedia
}

const ProductMediaPreview: React.FC<Props> = ({ media }) => {
  const downloadPreview = () => {
    window.location.href = `${process.env.NEXT_PUBLIC_BASE_URL}/api/download/preview?filepath=${media.file}&filename=${media.name}`
  }

  return (
    <div>
      <Button variant="secondary" onClick={downloadPreview}>
        Download free preview
      </Button>
    </div>
  )
}

export default ProductMediaPreview

Пример кода: кнопка рендеринга в product-actions

// src/modules/products/components/product-actions/index.tsx

// ...other imports
import ProductMediaPreview from "../product-media-preview"
import { getProductMediaPreviewByVariant } from "@lib/data"

const ProductActions: React.FC<ProductActionsProps> = ({ product }) => {
    // ...other code

  const [productMedia, setProductMedia] = useState({} as ProductMedia)

  useEffect(() => {
    const getProductMedia = async () => {
      if (!variant) return
      await getProductMediaPreviewByVariant(variant).then((res) => {
        setProductMedia(res)
      })
    }
    getProductMedia()
  }, [variant])

  return (
            // ...other code

      {productMedia && <ProductMediaPreview media={productMedia} />}

      <Button onClick={addToCart}>
        {!inStock ? "Out of stock" : "Add to cart"}
      </Button>
    </div>
  )
}

export default ProductActions

Обновить информацию о продукте и доставке

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

Информация о продукте

Я добавил соответствующие атрибуты продукта в электронную книгу, используя раздел метаданных продукта в администраторе Medusa. Поскольку мы не хотим использовать стандартные атрибуты, мы проведем рефакторинг компонента ProductInfoTab для отображения любых добавляемых нами метаданных.

В ответе по умолчанию метаданные структурированы как объект. Мы собираемся сопоставить его с массивом, чтобы можно было легко перебирать пары ключ/значение для создания списка атрибутов. В этом примере мы отобразим 4 атрибута из метаданных, по 2 в каждом столбце. Отредактируйте значения в slice(), если вы хотите отображать больше или меньше атрибутов.

Пример кода

// src/modules/products/components/product-tabs/index.tsx

// ... other components 

const ProductInfoTab = ({ product }: ProductTabsProps) => {
  // map the metadata object to an array 
  const metadata = useMemo(() => {
    if (!product.metadata) return []
    return Object.keys(product.metadata).map((key) => {
      return [key, product.metadata?.[key]]
    })
  }, [product])

  return (
    <Tab.Panel className="text-small-regular py-8">
      <div className="grid grid-cols-2 gap-x-8">
        <div className="flex flex-col gap-y-4">
                {/* Map the metadata as product information */}
          {metadata &&
            metadata.slice(0, 2).map(([key, value], i) => (
              <div key={i}>
                <span className="font-semibold">{key}</span>
                <p>{value}</p>
              </div>
            ))}
        </div>
        <div className="flex flex-col gap-y-4">
          {metadata.length > 2 &&
            metadata.slice(2, 4).map(([key, value], i) => {
              return (
                <div key={i}>
                  <span className="font-semibold">{key}</span>
                  <p>{value}</p>
                </div>
              )
            })}
        </div>
      </div>
      {product.tags?.length ? (
        <div>
          <span className="font-semibold">Tags</span>
        </div>
      ) : null}
    </Tab.Panel>
  )
}

// ... other components 

Информация о доставке

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

Пример кода

// src/modules/products/components/product-tabs/index.tsx

// ... other components 

const ProductTabs = ({ product }: ProductTabsProps) => {
  const tabs = useMemo(() => {
    return [
      {
        label: "Product Information",
        component: <ProductInfoTab product={product} />,
      },
      {
        label: "E-book delivery",
        component: <ShippingInfoTab />,
      },
    ]
  }, [product])
    // ... rest of code
}

// ... other components 

const ShippingInfoTab = () => {
  return (
    <Tab.Panel className="text-small-regular py-8">
      <div className="grid grid-cols-1 gap-y-8">
        <div className="flex items-start gap-x-2">
          <FastDelivery />
          <div>
            <span className="font-semibold">Instant delivery</span>
            <p className="max-w-sm">
              Your e-book will be delivered instantly via email. You can also
              download it from your account anytime.
            </p>
          </div>
        </div>
        <div className="flex items-start gap-x-2">
          <Refresh />
          <div>
            <span className="font-semibold">Free previews</span>
            <p className="max-w-sm">
              Get a free preview of the e-book before you buy it. Just click the
              button above to download it.
            </p>
          </div>
        </div>
      </div>
    </Tab.Panel>
  )
}

// ... other components 

Обновить оформление заказа

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

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

Пример кода

Вы можете скопировать/вставить это в src/lib/context/checkout-context.tsx.

Теперь, когда контекст обновлен, мы удалим избыточные поля ввода из формы оформления заказа.

Теперь, когда контекст обновлен, мы удалим избыточные поля ввода из формы оформления заказа.

Пример кода

// src/modules/checkout/components/addresses/index.tsx

import { useCheckout } from "@lib/context/checkout-context"
import Button from "@modules/common/components/button"
import Spinner from "@modules/common/icons/spinner"
import ShippingAddress from "../shipping-address"

const Addresses = () => {
  const {
    editAddresses: { state: isEdit, toggle: setEdit },
    setAddresses,
    handleSubmit,
    cart,
  } = useCheckout()
  return (
    <div className="bg-white">
      <div className="text-xl-semi flex items-center gap-x-4 px-8 pb-6 pt-8">
        <div className="bg-gray-900 w-8 h-8 rounded-full text-white flex justify-center items-center text-sm">
          1
        </div>
        <h2>Shipping address</h2>
      </div>
      {isEdit ? (
        <div className="px-8 pb-8">
          <ShippingAddress />
          <Button
            className="max-w-[200px] mt-6"
            onClick={handleSubmit(setAddresses)}
          >
            Continue to delivery
          </Button>
        </div>
      ) : (
        <div>
          <div className="bg-gray-50 px-8 py-6 text-small-regular">
            {cart && cart.shipping_address ? (
              <div className="flex items-start gap-x-8">
                <div className="bg-green-400 rounded-full min-w-[24px] h-6 flex items-center justify-center text-white text-small-regular">
                  ✓
                </div>
                <div className="flex items-start justify-between w-full">
                  <div className="flex flex-col">
                    <span>
                      {cart.shipping_address.first_name}{" "}
                      {cart.shipping_address.last_name}
                      {cart.shipping_address.country}
                    </span>
                    <div className="mt-4 flex flex-col">
                      <span>{cart.email}</span>
                    </div>
                  </div>
                  <div>
                    <button onClick={setEdit}>Edit</button>
                  </div>
                </div>
              </div>
            ) : (
              <div className="">
                <Spinner />
              </div>
            )}
          </div>
        </div>
      )}
    </div>
  )
}

export default Addresses

И, наконец, мы обновим компонент shipping-details, чтобы он отображал соответствующие значения при завершении заказа. В примере удалены все избыточные значения и добавлен адрес электронной почты покупателя.

Пример кода

// src/modules/order/components/shipping-details/index.tsx

import { Address, ShippingMethod } from "@medusajs/medusa"

type ShippingDetailsProps = {
  address: Address
  shippingMethods: ShippingMethod[]
  email: string
}

const ShippingDetails = ({
  address,
  shippingMethods,
  email,
}: ShippingDetailsProps) => {
  return (
    <div className="text-base-regular">
      <h2 className="text-base-semi">Delivery</h2>
      <div className="my-2">
        <h3 className="text-small-regular text-gray-700">Details</h3>
        <div className="flex flex-col">
          <span>{`${address.first_name} ${address.last_name}`}</span>
          <span>{email}</span>
        </div>
      </div>
      <div className="my-2">
        <h3 className="text-small-regular text-gray-700">Delivery method</h3>
        <div>
          {shippingMethods.map((sm) => {
            return <div key={sm.id}>{sm.shipping_option.name}</div>
          })}
        </div>
      </div>
    </div>
  )
}

export default ShippingDetails

Доставка цифрового продукта

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

Во всех случаях мы хотим проверить, действительно ли пользователь, пытающийся получить доступ к продукту, купил его. Для этого я настроил серверную часть для генерации уникального токена для каждой цифровой позиции в заказе. Мы можем GET /store/:token, чтобы проверить токен и вернуть связанный файл пользователю. Однако при этом пользователю будет показан URL-адрес файла, а нам этого не нужно из соображений пиратства. Мы собираемся настроить маршрут Next API в src/app/api/download/main/[token]/route.ts для передачи токена и прокси-сервера файла пользователю, чтобы он мог получить прямую загрузку, не видя точное местоположение файла.

Пример кода

// src/app/api/download/main/[token]/route.ts

import { NextRequest, NextResponse } from "next/server"

export async function GET(
  req: NextRequest,
  { params }: { params: Record<string, any> }
) {
  // Get the token from the URL
  const { token } = params

  // Define the URL to fetch the PDF file data from
  const pdfUrl = `${process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL}/store/product-media/${token}`

  // Fetch the PDF file data
  const { file, filename } = await fetch(pdfUrl).then((res) => res.json())

  // Handle the case where the token is invalid
  if (!file) return new NextResponse("Invalid token", { status: 401 })

  // Fetch the PDF file
  const pdfResponse = await fetch(file)

  // Handle the case where the PDF could not be fetched
  if (!pdfResponse.ok) return new NextResponse("PDF not found", { status: 404 })

  // Get the PDF content as a buffer
  const pdfBuffer = await pdfResponse.arrayBuffer()

  // Define response headers
  const headers = {
    "Content-Type": "application/pdf",
    "Content-Disposition": `attachment; filename="${filename}"`, // This sets the file name for the download
  }

  // Create a NextResponse with the PDF content and headers
  const response = new NextResponse(pdfBuffer, {
    status: 200,
    headers,
  })

  return response
}

Теперь мы можем ссылаться на этот маршрут API из письма о доставке, например: {your_store_url}/api/download/main/{token}.

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

Готово!

Вы дошли до конца! Если у вас есть какие-либо вопросы или комментарии по поводу этого руководства, дайте мне знать.

Ознакомьтесь с другими нашими рецептами, чтобы узнать больше о вариантах использования Medusa.

Источник:

#JavaScript #NestJS
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу