Создание магазина цифровых товаров с помощью Next.js и Medusa
В этом уроке вы узнаете, как создать магазин по продаже цифровых товаров (например, электронных книг) с помощью Next.js и Medusa.
- Мы будем использовать стартовый шаблон Medusa Next.js и рецепт цифровых продуктов, чтобы приступить к работе.
- Мы обновим страницы продуктов для поддержки цифровых продуктов
Добавьте кнопку предварительного просмотра мультимедиа
Отображать соответствующую информацию о продукте
- Мы упростим оформление заказа, чтобы оно соответствовало процессу доставки цифровых продуктов.
- Мы создадим маршруты 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.