使用 Next.js 和 Medusa 构建数字产品商店
太长不看
在本教程中,您将学习如何使用 Next.js 和 Medusa 构建一个销售数字产品(如电子书)的商店。
- 我们将使用 Medusa Next.js 入门模板和数字产品配方来快速上手。
- 我们将更新产品页面以支持数字产品
- 添加媒体预览按钮
- 显示相关产品信息
- 我们将简化结账流程,使其与数字产品交付流程相匹配。
- 我们将创建 Next.js API 路由来验证产品下载并隐藏文件路径。
Medusa:用于商业的开源构建模块
Medusa 简介:我们创建开源构建模块,用于构建出色的电子商务网站或在任何产品中启用电子商务功能。
开始使用
- 使用 Next.js starter 创建一个新的 Medusa 应用,运行以下命令:
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/lib/context/checkout-context.tsx
"use client"
import { medusaClient } from "@lib/config"
import useToggleState, { StateType } from "@lib/hooks/use-toggle-state"
import { Cart, Customer, StorePostCartsCartReq } from "@medusajs/medusa"
import Wrapper from "@modules/checkout/components/payment-wrapper"
import { isEqual } from "lodash"
import {
formatAmount,
useCart,
useCartShippingOptions,
useMeCustomer,
useRegions,
useSetPaymentSession,
useUpdateCart,
} from "medusa-react"
import { useRouter } from "next/navigation"
import React, { createContext, useContext, useEffect, useMemo } from "react"
import { FormProvider, useForm, useFormContext } from "react-hook-form"
import { useStore } from "./store-context"
type AddressValues = {
first_name: string
last_name: string
country_code: string
}
export type CheckoutFormValues = {
shipping_address: AddressValues
billing_address?: AddressValues
email: string
}
interface CheckoutContext {
cart?: Omit<Cart, "refundable_amount" | "refunded_total">
shippingMethods: { label?: string; value?: string; price: string }[]
isLoading: boolean
readyToComplete: boolean
sameAsBilling: StateType
editAddresses: StateType
initPayment: () => Promise<void>
setAddresses: (addresses: CheckoutFormValues) => void
setSavedAddress: (address: AddressValues) => void
setShippingOption: (soId: string) => void
setPaymentSession: (providerId: string) => void
onPaymentCompleted: () => void
}
const CheckoutContext = createContext<CheckoutContext | null>(null)
interface CheckoutProviderProps {
children?: React.ReactNode
}
const IDEMPOTENCY_KEY = "create_payment_session_key"
export const CheckoutProvider = ({ children }: CheckoutProviderProps) => {
const {
cart,
setCart,
addShippingMethod: {
mutate: setShippingMethod,
isLoading: addingShippingMethod,
},
completeCheckout: { mutate: complete, isLoading: completingCheckout },
} = useCart()
const { customer } = useMeCustomer()
const { countryCode } = useStore()
const methods = useForm<CheckoutFormValues>({
defaultValues: mapFormValues(customer, cart, countryCode),
reValidateMode: "onChange",
})
const {
mutate: setPaymentSessionMutation,
isLoading: settingPaymentSession,
} = useSetPaymentSession(cart?.id!)
const { mutate: updateCart, isLoading: updatingCart } = useUpdateCart(
cart?.id!
)
const { shipping_options } = useCartShippingOptions(cart?.id!, {
enabled: !!cart?.id,
})
const { regions } = useRegions()
const { resetCart, setRegion } = useStore()
const { push } = useRouter()
const editAddresses = useToggleState()
const sameAsBilling = useToggleState(
cart?.billing_address && cart?.shipping_address
? isEqual(cart.billing_address, cart.shipping_address)
: true
)
/**
* Boolean that indicates if a part of the checkout is loading.
*/
const isLoading = useMemo(() => {
return (
addingShippingMethod ||
settingPaymentSession ||
updatingCart ||
completingCheckout
)
}, [
addingShippingMethod,
completingCheckout,
settingPaymentSession,
updatingCart,
])
/**
* Boolean that indicates if the checkout is ready to be completed. A checkout is ready to be completed if
* the user has supplied a email, shipping address, billing address, shipping method, and a method of payment.
*/
const readyToComplete = useMemo(() => {
return (
!!cart &&
!!cart.email &&
!!cart.shipping_address &&
!!cart.billing_address &&
!!cart.payment_session &&
cart.shipping_methods?.length > 0
)
}, [cart])
const shippingMethods = useMemo(() => {
if (shipping_options && cart?.region) {
return shipping_options?.map((option) => ({
value: option.id,
label: option.name,
price: formatAmount({
amount: option.amount || 0,
region: cart.region,
}),
}))
}
return []
}, [shipping_options, cart])
/**
* Resets the form when the cart changed.
*/
useEffect(() => {
if (cart?.id) {
methods.reset(mapFormValues(customer, cart, countryCode))
}
}, [customer, cart, methods, countryCode])
useEffect(() => {
if (!cart) {
editAddresses.open()
return
}
if (cart?.shipping_address && cart?.billing_address) {
editAddresses.close()
return
}
editAddresses.open()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cart])
/**
* Method to set the selected shipping method for the cart. This is called when the user selects a shipping method, such as UPS, FedEx, etc.
*/
const setShippingOption = (soId: string) => {
if (cart) {
setShippingMethod(
{ option_id: soId },
{
onSuccess: ({ cart }) => setCart(cart),
}
)
}
}
/**
* Method to create the payment sessions available for the cart. Uses a idempotency key to prevent duplicate requests.
*/
const createPaymentSession = async (cartId: string) => {
return medusaClient.carts
.createPaymentSessions(cartId, {
"Idempotency-Key": IDEMPOTENCY_KEY,
})
.then(({ cart }) => cart)
.catch(() => null)
}
/**
* Method that calls the createPaymentSession method and updates the cart with the payment session.
*/
const initPayment = async () => {
if (cart?.id && !cart.payment_sessions?.length && cart?.items?.length) {
const paymentSession = await createPaymentSession(cart.id)
if (!paymentSession) {
setTimeout(initPayment, 500)
} else {
setCart(paymentSession)
return
}
}
}
/**
* Method to set the selected payment session for the cart. This is called when the user selects a payment provider, such as Stripe, PayPal, etc.
*/
const setPaymentSession = (providerId: string) => {
if (cart) {
setPaymentSessionMutation(
{
provider_id: providerId,
},
{
onSuccess: ({ cart }) => {
setCart(cart)
},
}
)
}
}
const prepareFinalSteps = () => {
initPayment()
if (shippingMethods?.length && shippingMethods?.[0]?.value) {
setShippingOption(shippingMethods[0].value)
}
}
const setSavedAddress = (address: AddressValues) => {
const setValue = methods.setValue
setValue("shipping_address", {
country_code: address.country_code || "",
first_name: address.first_name || "",
last_name: address.last_name || "",
})
}
/**
* Method that validates if the cart's region matches the shipping address's region. If not, it will update the cart region.
*/
const validateRegion = (countryCode: string) => {
if (regions && cart) {
const region = regions.find((r) =>
r.countries.map((c) => c.iso_2).includes(countryCode)
)
if (region && region.id !== cart.region.id) {
setRegion(region.id, countryCode)
}
}
}
/**
* Method that sets the addresses and email on the cart.
*/
const setAddresses = (data: CheckoutFormValues) => {
const { shipping_address, billing_address, email } = data
const payload: StorePostCartsCartReq = {
shipping_address,
email,
}
if (isEqual(shipping_address, billing_address)) {
sameAsBilling.open()
}
if (sameAsBilling.state) {
payload.billing_address = shipping_address
} else {
payload.billing_address = billing_address
}
updateCart(payload, {
onSuccess: ({ cart }) => {
setCart(cart)
prepareFinalSteps()
},
})
}
/**
* Method to complete the checkout process. This is called when the user clicks the "Complete Checkout" button.
*/
const onPaymentCompleted = () => {
complete(undefined, {
onSuccess: ({ data }) => {
resetCart()
push(`/order/confirmed/${data.id}`)
},
})
}
return (
<FormProvider {...methods}>
<CheckoutContext.Provider
value={{
cart,
shippingMethods,
isLoading,
readyToComplete,
sameAsBilling,
editAddresses,
initPayment,
setAddresses,
setSavedAddress,
setShippingOption,
setPaymentSession,
onPaymentCompleted,
}}
>
<Wrapper paymentSession={cart?.payment_session}>{children}</Wrapper>
</CheckoutContext.Provider>
</FormProvider>
)
}
export const useCheckout = () => {
const context = useContext(CheckoutContext)
const form = useFormContext<CheckoutFormValues>()
if (context === null) {
throw new Error(
"useProductActionContext must be used within a ProductActionProvider"
)
}
return { ...context, ...form }
}
/**
* Method to map the fields of a potential customer and the cart to the checkout form values. Information is assigned with the following priority:
* 1. Cart information
* 2. Customer information
* 3. Default values - null
*/
const mapFormValues = (
customer?: Omit<Customer, "password_hash">,
cart?: Omit<Cart, "refundable_amount" | "refunded_total">,
currentCountry?: string
): CheckoutFormValues => {
const customerShippingAddress = customer?.shipping_addresses?.[0]
const customerBillingAddress = customer?.billing_address
return {
shipping_address: {
first_name:
cart?.shipping_address?.first_name ||
customerShippingAddress?.first_name ||
"",
last_name:
cart?.shipping_address?.last_name ||
customerShippingAddress?.last_name ||
"",
country_code:
currentCountry ||
cart?.shipping_address?.country_code ||
customerShippingAddress?.country_code ||
"",
},
billing_address: {
first_name:
cart?.billing_address?.first_name ||
customerBillingAddress?.first_name ||
"",
last_name:
cart?.billing_address?.last_name ||
customerBillingAddress?.last_name ||
"",
country_code:
cart?.shipping_address?.country_code ||
customerBillingAddress?.country_code ||
"",
},
email: cart?.email || customer?.email || "",
}
}
现在上下文已更新,我们将从结账表单中删除冗余的输入字段。
代码示例
// 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}。
您可以添加自己的逻辑,使令牌在一定时间或下载次数达到 X 次后失效。
完毕!
你已经看到最后了!如果你对本指南有任何疑问或意见,请告诉我。
查看我们的其他配方,了解更多美杜莎的使用案例。
文章来源:https://dev.to/medusajs/building-a-store-for-digital-products-with-nextjs-and-medusa-1o6m



