mcpField
S
spree-storefront

spree-storefront

github.com/spree开发10230 次安装4 周前更新
代码仓库

关于 spree-storefront

面向开发者的 Spree Next.js 店面应用集成参考,说明如何通过 @spree/sdk 连接 Spree 后端 Store API。内容重点包括服务端数据请求、购物车与结账、多地区路由及可选的批发界面。

主要功能

  1. 01通过 @spree/sdk 调用 Store API
  2. 02服务端处理 API 请求与身份验证
  3. 03实现购物车、支付和订单流程
  4. 04按国家与语言组织店面路由
  5. 05配置批发渠道与 Webhook

适用场景

  1. 01为 Spree 后端构建 Next.js 店面
  2. 02实现购物车到订单的结账流程
  3. 03按市场提供多地区店面页面
  4. 04在批发渠道中配置专用购物体验

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/spree/agent-skills --skill spree-storefront

安装后在 Claude Code 中输入/spree-storefront确认已加载。

详细介绍

Spree Storefront(Next.js)

Spree Next.js storefront 是一个独立应用,通过 v3 Store API(/api/v3/store/*)和 @spree/sdk 2.x 与 Spree 后端通信。create-spree-app 会将其放在 apps/storefront/ 中(与 server/ 和 apps/dashboard/ 并列);也可以 fork https://github.com/spree/storefront。使用 Next.js 16、React 19 和 Tailwind 4。

storefront 为可选项——任何前端(Astro、Remix、React Native 等)都可以使用相同的 Store API。本技能以官方 storefront 为前提;对于任何客户端,API 契约都相同。

架构——服务端优先

Browser → Server Action (src/lib/data/*) → @spree/sdk → Spree Store API
          httpOnly cookies (JWT, refresh token, cart token, country/locale) via src/lib/spree helpers
  • 所有 API 调用都在服务端运行(src/lib/data/ 中的 Server Actions / Server Components)。浏览器不会直接调用 Spree;即使是可发布密钥也仅在服务端使用(SPREE_PUBLISHABLE_KEY,不使用 NEXT_PUBLIC_)。
  • src/lib/spree/ 存放集成代码:getClient()、cookie helpers、withAuthRefresh()、getLocaleOptions()、getCartOptions()、middleware、surfaces(DTC 与批发)、webhooks。
  • 客户端组件(CartContext、CheckoutContext 等)负责保存 UI 状态并调用 Server Actions——不要在浏览器中添加通过 SWR/React Query 向 Store API 发起的请求。
// src/lib/data/customer.ts (pattern)
import { getClient, withAuthRefresh, setAccessToken, setRefreshToken } from '@/lib/spree'

export async function login(email: string, password: string) {
  const { token, refresh_token, user } = await getClient().auth.login({ email, password })
  await setAccessToken(token)
  await setRefreshToken(refresh_token)
  return { success: true, user }
}

export async function getCustomer() {
  return withAuthRefresh((options) => getClient().customer.get(options))
}

必需的环境变量(.env.local):SPREE_API_URL、SPREE_PUBLISHABLE_KEY。可选项:NEXT_PUBLIC_DEFAULT_COUNTRY / NEXT_PUBLIC_DEFAULT_LOCALE、NEXT_PUBLIC_SITE_URL、SPREE_WHOLESALE_CHANNEL、SPREE_WHOLESALE_PUBLISHABLE_KEY、SPREE_WEBHOOK_SECRET、RESEND_API_KEY、EMAIL_FROM。NEXT_PUBLIC_* 仅用于浏览器确实需要的值(默认地区、Stripe/PayPal 可发布密钥)。

@spree/sdk 基础要点

import { createClient } from '@spree/sdk'

const spree = createClient({
  baseUrl: process.env.SPREE_API_URL!,
  publishableKey: process.env.SPREE_PUBLISHABLE_KEY!,
  locale: 'en',          // sent as x-spree-locale (also per request: { locale })
  channel: 'online',     // sent as X-Spree-Channel; omit for the store default
})

const { data, meta } = await spree.products.list({ expand: ['media', 'default_variant'] })
const product = await spree.products.get('cool-shirt', { expand: ['variants', 'media'] })  // slug or prod_ id

const cart = await spree.carts.create()
await spree.carts.items.create(cart.id, { variant_id: 'variant_k5nR8xLq', quantity: 1 }, { spreeToken: cart.token })

const orders = await spree.customer.orders.list({}, { token })   // logged-in customer (JWT)
  • 响应为扁平结构(product.name);关联关系通过 *_id 字段表示,或使用 expand 获取嵌套对象(product.default_variant、product.primary_media、product.media[])。价格以 Price 对象返回(product.price.display_amount)——切勿在客户端计算金额。
  • 认证模式:仅使用可发布密钥(浏览)· + spreeToken(访客购物车,x-spree-token)· + token JWT(客户)。类型定义在 @spree/sdk(也可从 @spree/sdk/zod 获取),webhook 辅助函数在 @spree/sdk/webhooks 中。
  • ID 是带前缀的字符串(prod_、variant_、cart_、or_、cust_、ful_)。购物车使用 cart.id——cart.number 是 id 的弃用镜像字段,为 5.x 客户端保留(在 6.1 中移除)。

购物车 → 结账 → 订单

Spree 6 将可变的 购物车(cart_…)与购物车完成后创建的不可变 订单(or_…)分开。无需推进结账状态机:你可以按任意顺序更新购物车(邮箱、地址、配送费率、付款),购物车会报告仍缺少哪些内容。

const cart = await spree.carts.get(cartId, { spreeToken })
cart.requirements
// [{ step: 'address', field: 'ship_address', code: 'ship_address_required', message: '…' },
//  { step: 'delivery', field: 'delivery_method', code: 'delivery_method_required', message: '…' }, …]
  • requirements[] 条目格式为 { step, field, code, message }。代码包括 line_items_required、email_required、ship_address_required、delivery_method_required、payment_required、po_number_required、guest_checkout_not_allowed、quantity_rule_violated、order_minimum_not_met、discontinued、out_of_stock。步骤(cart、address、delivery、payment)是用于 UI 分组的参考信息——将它们映射到结账各部分并显示 message。只有完成操作才是硬性门槛。自定义要求来自后端(Spree::Checkout::Registry.add_requirement,参见 spree-checkout)。
  • 配送:spree.carts.fulfillments.update(cartId, fulfillmentId, { selected_delivery_rate_id }, opts) 会返回重新计算后的购物车。
  • 付款(基于会话的网关——Stripe、Adyen、PayPal):
    1. spree.carts.paymentSessions.create(cartId, { payment_method_id }, opts) → 提供商数据(客户端密钥、drop-in 令牌、重定向 URL)。
    2. 渲染提供商 UI/执行重定向。
    3. spree.carts.paymentSessions.complete(cartId, sessionId, { session_result }, opts)。
    4. spree.carts.complete(cartId, opts) → 返回 订单(类型为 Order | OrderGroup——多卖家商城购物车会返回 ogrp_… 分组;使用 @spree/sdk 中的 isOrderGroup 进行类型缩窄)。此操作具有幂等性:如果网关 webhook 已完成购物车,则会返回已有结果。 非会话方式(支票、货到付款、银行转账):spree.carts.payments.create(...)。
  • 完成后,无法再通过 carts.get 获取购物车——使用 spree.orders.get(id, params, { spreeToken | token }) 读取结果。它接受 or_… ID 或原始的 cart_… ID,因此确认页面(order-placed/[id])可以继续使用购物车 ID 作为标识。订单的 status 为 draft/placed/canceled,另有独立的 payment_status 和 fulfillment_status。
  • 参考流程:src/lib/data/checkout.ts、payment.ts(completeCheckoutOrder 也能处理 403/422 竞争情况)、components/checkout/* 和网关 gem(例如 spree_stripe)。

多区域 URL

每个路由都位于 src/app/[country]/[locale]/… 下(/us/en/products、/de/de/products),并映射到一个 Spree 市场(国家 + 货币 + 区域设置)。src/proxy.ts(Next 16 的中间件文件)会配置 @/lib/spree/middleware 中的 createSpreeMiddleware({ defaultCountry, defaultLocale, staticRoutes? }),它会:

  • 将不带前缀的路径重定向到 /{country}/{locale}/…;
  • 从 spree_country cookie → x-vercel-ip-country/cf-ipcountry → 默认值检测国家,并从 spree_locale → Accept-Language → 默认值检测区域设置;
  • 将 spree_country/spree_locale cookie 与 URL 保持同步。

数据函数不接受区域参数——它们会调用 getLocaleOptions(),读取这些 cookie 并将区域设置/国家传给 SDK。新页面应放在 src/app/[country]/[locale]/(storefront)/your-page/page.tsx 下,以继承布局。CountrySwitcher 通过导航到新的前缀来切换区域。

批发/渠道界面

/wholesale 是一个启用加入的 B2B 门户,使用独立的 渠道;仅当 SPREE_WHOLESALE_CHANNEL 设置为受限渠道的代码时才会启用(未设置 → 隐藏所有批发 UI,路由返回 404)。界面 = 与渠道绑定的 SDK 客户端(channel: 'wholesale' → X-Spree-Channel),拥有独立的购物车 cookie 和缓存标签;客户 JWT 由两者共享。API 会强制执行渠道的 storefront_access(login_required | prices_hidden | public)(返回 401,或将金额字段设为 null);买家获批条件 = 加入 Wholesale 客户组;批发价来自后端价格表。请将其视为可改造的参考实现(例如仅限会员的 DTC、仅面向 B2B 的商店)——参见 spree-b2b。

Webhook 和事务性邮件

店面可以负责发送客户邮件(src/lib/emails/ 中的 react-email 模板,通过 Resend 发送;开发环境下写入 .next/emails/;可在 /dev/emails 预览)。路由 src/app/api/webhooks/spree/route.ts 使用 @/lib/spree/webhooks 中的 createWebhookHandler,该函数会验证 X-Spree-Webhook-Signature(对 "{timestamp}.{body}" 计算 HMAC-SHA256,使用时序安全比较,并设置 5 分钟的重放保护窗口),然后按事件名称进行分发:

import { createWebhookHandler } from '@/lib/spree/webhooks'

const handler = createWebhookHandler({
  secret: process.env.SPREE_WEBHOOK_SECRET!,
  handlers: {
    'order.placed': handleOrderPlaced,
    'order.canceled': handleOrderCanceled,
    'order.fulfilled': handleOrderFulfilled,        // or 'fulfillment.fulfilled' for per-parcel emails
    'customer.password_reset_requested': handlePasswordReset, // must be subscribed by name — `*` never receives it
  },
  // waitUntil, toleranceSeconds optional
})
export const POST = handler
  • 事件名称采用 Spree 6 的命名:order.placed(而非 order.completed)、order.fulfilled / fulfillment.fulfilled(而非 order.shipped)。已发布的店面代码仍注册了 order.completed 和 order.shipped——这些是已弃用的别名,Spree 6.0 仍会与新名称一并发送,但从 6.1 起将停止发送。请将处理程序和端点订阅都改为新名称。
  • 市场(拆分)结账:子订单会发布 order.placed,并带有 notify_customer: false;购买确认事件属于 order_group.completed——如果要发送订单邮件,请为其添加处理程序(参见 spree-marketplace)。
  • 如果由店面负责发送客户邮件,请通过商店偏好设置 send_consumer_transactional_emails(设为 false)关闭 Spree 自带的邮件,以免客户收到重复邮件。此开关(以及订单上的 notify_customer: false)只会关闭客户邮件——只要设置了 new_order_notifications_email 地址,商店的新订单通知仍会发送。
  • 在控制面板的 Settings → Developer → Webhooks 下创建端点并订阅事件,然后将其密钥复制到 SPREE_WEBHOOK_SECRET。如需本地测试,可公开店面(cloudflared tunnel --url http://localhost:3001)。
  • 投递失败后会进行退避重试(约 6 分钟内尝试 5 次,事件 id 相同);连续 15 次投递失败后,端点会被禁用——请尽快返回 2xx,按 event.id 去重,并通过 waitUntil/队列执行耗时操作。未处理的事件会返回 200 { handled: false }。
  • 在官方店面以外的项目中,请使用 @spree/sdk/webhooks 中的 verifyWebhookSignature(body, signature, timestamp, secret) 和类型 WebhookEvent<T>。完整事件列表:spree-events-webhooks。

测试

  • **单元/集成测试:**Vitest — pnpm test / pnpm test:watch(测试与代码放在一起,例如 src/lib/data/__tests__)。
  • **端到端测试:**使用 Playwright,通过 Docker 连接真实后端:导出匹配的一对 Stripe 测试密钥(STRIPE_PUBLISHABLE_KEY、STRIPE_SECRET_KEY),运行 pnpm e2e:up(启动 Postgres/Redis 和 ghcr.io/spree/spree:latest,通过 @spree/cli 填充数据,并写入 .env.e2e),运行 pnpm test:e2e(交互模式使用 :ui),最后运行 pnpm e2e:down。
  • 针对自己的后端测试:SPREE_IMAGE=<image> pnpm e2e:up。在 create-spree-app 项目中,先在根目录运行 docker build . -f server/Dockerfile -t project-spree:e2e 进行构建;生成的 CI 会自动执行此操作。

商店前端还是后端——应该在哪里进行更改

想要……应在……中进行
调整布局、文案、样式或新增内容页面商店前端(src/app/[country]/[locale]/(storefront)/…,src/app/globals.css 中的 @theme 令牌)
在商品/购物车响应中添加字段后端——替换模型和序列化器(spree-resource、spree-api-v3)
更改定价后端——价格列表/规则(spree-pricing)
更改购物车总额的计算方式后端——Spree::Carts::RecalculateTotals(DI 键 cart_recalculate_totals_workflow)或工作流钩子(spree-order-totals、spree-workflows)
添加结账要求(例如“接受条款”)后端的 Spree::Checkout::Registry.add_requirement;商店前端负责渲染新的 requirements[] 代码
添加国家/货币/语言后端 Markets;商店前端只需遵循 URL 和 Cookie
自定义结账 UI商店前端(components/checkout/*)
对 PDP 进行 A/B 测试、客户端分析商店前端(src/lib/analytics)
将订单同步到 CRM/ERP后端订阅 order.placed 事件(spree-events-webhooks)
客户邮件的外观和风格商店前端邮件(webhooks)或后端 mailers——选择其中一方负责

规则:任何客户可见的内容都属于商店前端;任何涉及数据、资金或业务规则的内容都属于后端,这样每个前端才能获得一致的行为。

注意事项

  • 密钥(sk_…)绝不能放在商店前端——即使是服务端代码也不行;Store API 使用可发布密钥。管理任务应在控制面板中完成,或通过后端作业执行。
  • 购物车令牌可授予购物车访问权限——请将其保存在 httpOnly、安全的 Cookie 中(辅助函数会处理)。
  • 缓存商品目录,绝不缓存购物车/结账。 商店前端使用 Next 缓存标签区分不同表面(updateTag(cartTag(surface)))——购物车发生变更后应使缓存失效。
  • 不要假设结账步骤有固定顺序——根据 requirements[] 驱动 UI;并处理 carts.complete 返回错误(代码 cart_cannot_complete)的情况,重新读取购物车的 requirements。
  • 完成结账后不要调用 carts.get——使用购物车或订单 ID 调用 orders.get。
  • 本地化由请求头驱动(x-spree-locale、X-Spree-Channel),而非 Accept-Language;商店前端通过 Cookie 从 URL 路径段获取本地化信息。
  • 跟进上游版本:将商店前端仓库添加为 upstream 远程仓库,并定期合并;将自定义内容保存在你维护的组件/数据文件中。

延伸阅读

评论

支持 Markdown