Imported from stevenbian9266-cyber/pallastrade (
ai/skills/pallastrade-storefront/SKILL.md). Install upstream withnpx skills add stevenbian9266-cyber/pallastrade --skill pallastrade-storefront. Copyright stays with the author.
PallasTrade Storefront (Next.js)
The PallasTrade Next.js storefront talks to the PallasTrade backend over the v3 Store API. Its canonical source is the fixed storefront/ directory in https://github.com/stevenbian9266-cyber/pallastrade; create-pallastrade-app clones the canonical repository once and copies that directory into apps/storefront/.
The storefront is optional. Headless deployments may use a custom frontend — React Native, Astro, Remix, or hand-rolled. PallasTrade's job is to expose a clean API; what consumes it is your choice. This skill assumes the official Next.js storefront, but the API contract is identical for any frontend.
How it connects to PallasTrade
Browser ──HTTPS──> Next.js storefront ──API──> PallasTrade backend (Rails)
│
└── @pallastrade/sdk for typed API calls
The storefront authenticates against the PallasTrade backend via a publishable API key (pk_… prefix). Customer-bound operations (their cart, their account) use additional auth — JWT for logged-in customers, cart tokens for guest carts.
# .env.local — server-side only (the storefront makes all API calls via Server Actions)
PALLASTRADE_API_URL=http://localhost:3000
PALLASTRADE_PUBLISHABLE_KEY=pk_…
@pallastrade/sdk — the canonical client
Don't hand-write fetch calls. Use @pallastrade/sdk for typed access to the Store API:
import { createClient } from '@pallastrade/sdk'
const pallastrade = createClient({
baseUrl: process.env.PALLASTRADE_API_URL!,
publishableKey: process.env.PALLASTRADE_PUBLISHABLE_KEY!,
})
// List products
const { data, meta } = await pallastrade.products.list({
expand: ['media', 'default_variant'],
})
// Get a single product by slug or prefixed ID
const product = await pallastrade.products.get('cool-shirt')
// Create a cart
const cart = await pallastrade.carts.create()
// Add item to cart — cart ID positional, token via options.guestToken
await pallastrade.carts.items.create(cart.id, {
variant_id: 'variant_k5nR8xLq',
quantity: 1,
}, { guestToken: cart.token })
The SDK includes:
- Full TypeScript types generated from the PallasTrade serializers (
Product,Order,Cart, etc.) - Runtime Zod schemas in
@pallastrade/sdk/zodif you want validation - Automatic retry with exponential backoff
- Ransack query param transformation
- Webhook signature verification in
@pallastrade/sdk/webhooks
Request timeout(修复:storefront API 请求缺超时导致预渲染挂起/构建失败): The SDK's fetch has no built-in timeout and retries GET network errors (maxRetries=2, exponential backoff). When the API is unreachable (deployment/rebuild window, DNS/network fault), a single request can hang for minutes — Next.js prerender "use cache" cache-fill then times out (
USE_CACHE_TIMEOUT) and the build fails.lib/pallastrade/config.tstherefore wraps the SDK client'sfetchwithcreateFetchWithTimeout()(8sAbortSignal.timeout), so every API call fails fast and bubbles to the existing.catch(() => …)degradation instead of hanging. Keep this timeout when touchinggetClient()/initPallasTradeNext().
Authentication modes
| Who | How | Use for |
|---|---|---|
| Anonymous browser | Publishable key | Browsing products, viewing categories |
| Guest cart | Publishable key + cart token | Cart operations for not-yet-signed-up customers |
| Logged-in customer | Publishable key + JWT (customer login) | Order history, saved addresses, account pages |
The customer login flow:
const { token, refresh_token, user } = await pallastrade.auth.login({
email: 'jane@example.com',
password: 'secret',
})
// Pass the JWT per request via options.token
const orders = await pallastrade.customer.orders.list({}, { token })
const me = await pallastrade.customer.get({ token })
// Refresh later
const { token: newToken } = await pallastrade.auth.refresh({ refresh_token })
Channels — which sales surface
If the merchant has multiple channels (website, mobile app, in-store POS), the storefront should identify which one it represents. Set the channel via the SDK config:
const pallastrade = createClient({
baseUrl: process.env.PALLASTRADE_API_URL!,
publishableKey: process.env.PALLASTRADE_PUBLISHABLE_KEY!,
channel: 'online', // channel code; or prefixed ID like 'ch_…'
})
The PallasTrade backend uses PallasTrade::Current.channel to scope queries — only products published on that channel surface in API responses. If channel is omitted, the store's default channel is used.
Common storefront patterns
Server-rendered PDP
// app/products/[slug]/page.tsx
import { pallastrade } from '@/lib/pallastrade'
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await pallastrade.products.get(params.slug, {
expand: ['default_variant', 'variants', 'media', 'categories'],
})
return (
<main>
<h1>{product.name}</h1>
<img src={product.media?.[0]?.large_url ?? undefined} alt={product.media?.[0]?.alt ?? ''} />
<AddToCartButton variantId={product.default_variant_id} />
</main>
)
}
The v3 Store API uses flat responses — product.name, not product.data.attributes.name. Related records appear as either ID fields (e.g. default_variant_id) or, when expanded, as nested objects (e.g. product.default_variant, product.media[]).
SEO / metadata
The storefront ships a shared SEO layer under storefront/src/lib/:
seo.ts— shared helpers (canonical URLs, Open Graph tags, structured data).metadata/— per-route metadata builders, one per content type:home.ts,category.ts,product.ts,store.ts—generateMetadatadata for each route.alternates.ts— hreflang/locale alternates (per-country/per-locale URL variants).
Page routes under [country]/[locale]/... use these builders so every page emits
canonical + localized <head> metadata. When adding a new page route, extend the
matching builder in metadata/ rather than inlining metadata in the page.
Key components:
ProductCard(components/products/ProductCard.tsx) — product grid card; consumes the product + media via the SDK and links to the PDP.product-image(components/ui/product-image.tsx) — shared image renderer with srcset/fallback handling. Whensrcis missing or fails to load it renders an accessible placeholder: a<div role="img">with an icon +aria-label(NOT a<img>element) — tests must assert on that placeholder (e.g.getAllByRole("img")→tagName === "DIV"), not on the absence of an image role. Pass a multi-size webpsrcSet(built withlib/image-srcset.tsbuildImageSrcSet(media)from the media record'ssmall/medium/large/xlarge_urlCDN variants) to get a responsive plain<img>— the backend already produced optimized webp variants, so they must NOT be run through the Next.js optimizer again.ProductCardandMediaGalleryfeedsrcSet; leavesrcSetunset to keep the existingnext/imagepath. CI enforcespnpm check(Biome lint + format) on every push — new files must pass locally (pnpm check/pnpm check --write) before committing.CategoryBanner(app/[country]/[locale]/(storefront)/c/[...permalink]/CategoryBanner.tsx) — category hero banner in the category listing route.BackInStockNotify(components/products/BackInStockNotify.tsx) — client-side "notify me when back in stock" form. Shown on the PDP (ProductDetails) only when the selected variant is out of stock; callssdk.backInStockSubscriptions.create(productId, { email })(Store API, guest-accessible) and shows success/error states with i18n labels (backInStock*keys).ProductReviews(components/products/ProductReviews.tsx, P0-4) — PDP review section: rating summary (stars + count), approved review list (author, verified-purchase badge, date) and a submit form for signed-in customers. The server component page fetches reviews + auth state vialib/data/reviews.ts(getProductReviewspublic,createProductReviewposts with the customer JWT) and passes them into the clientProductDetails; the form renders only whenisAuthenticated. i18n labels live under a top-levelreviewsnamespace inmessages/*.json. Only admin-approved reviews are returned by the Store API, so a fresh submission won't appear until moderation. ⚠️average_ratingis serialized as a string (BigDecimal → string in the Store API) — always guard withNumber()before calling.toFixed()(e.g.{Number(averageRating).toFixed(1)}). Calling"4.5".toFixed(1)directly crashes the PDP withTypeError: b?.toFixed is not a function(bugfix 2026-08-25).BuyNowButton(components/products/BuyNowButton.tsx, P5 2026-08-27) — PDP quick-purchase button. Creates a standalone cart with the current variant vialib/data/buy-now.tscreateBuyNowCartand routes straight to/checkout/{id}(does not touch the cart). On the PDP it renders in the same row as the Add to Cart button at equal width:ProductDetailswraps both in<div className="flex flex-1 gap-4">with each action asflex-1(thew-fulloutline button fills itsflex-1wrapper). The outer actions row isflex flex-col gap-4 sm:flex-row sm:items-center, so on mobile the quantity picker wraps to its own line while the two buttons share a row (bugfix 2026-08-29). i18n label:products.buyNow.
Client-component import rule (build breaker): a "use client" component MUST NOT import from the @/lib/pallastrade barrel (index.ts) — the barrel re-exports server-only cookie/next/headers helpers, and pulling them into the client bundle fails next build with "Ecmascript file had an error" on import { cookies } from "next/headers". Import the specific client-safe module instead, e.g. getClient from @/lib/pallastrade/config. Server components / route handlers may keep using the barrel.
Client-component SDK calls go through server actions. PALLASTRADE_API_URL / PALLASTRADE_PUBLISHABLE_KEY are server-only env (no NEXT_PUBLIC_ prefix), so getClient() throws in the browser. A client component that needs the Store API must call a "use server" action in src/lib/data/ (e.g. cart.ts, backInStock.ts) that runs getClient() server-side; the action returns a { success, error } result (via actionResult). Never build an SDK client directly in a client component.
TawkToWidget(components/layout/TawkToWidget.tsx) — optional Tawk.to live-chat widget, mounted in the root layout<body>. Enabled only when BOTHNEXT_PUBLIC_TAWK_TO_PROPERTY_IDandNEXT_PUBLIC_TAWK_TO_WIDGET_IDare set (public IDs, like publishable keys — safe forNEXT_PUBLIC_); loads vianext/scriptafterInteractiveso it never blocks first paint. Rendersnull(no third-party script) when either var is missing.TurnstileWidget(components/auth/TurnstileWidget.tsx) — optional Cloudflare Turnstile human-verification widget, used on the registration form (account/register/page.tsx). Enabled only whenNEXT_PUBLIC_TURNSTILE_SITE_KEYis set (the site key is PUBLIC — it is not a secret); loads the script from the exact official URLhttps://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit(explicit rendering — the path MUST include/v0/, omitting it returns 404 +Cross-Origin-Resource-Policy: same-originwhich blocks loading) and reports thecf-turnstile-responsetoken throughonTokenChange. The component ALWAYS renders a visible wrapper (border + status area) with loading → ready/error states; if the script fails to load (network/region blocking) it shows an error message + retry button (retry reloads the script with a native<script>tag so next/script dedup can't swallow it). Labels are passed via thelabelsprop (i18n lives in the parent). The page must gate submission on the token when the widget is enabled; the backend validates the token server-side viaPallasTrade::Api::Turnstile(secret key only fromENV['TURNSTILE_SECRET_KEY'], never committed).
Cookie consent (2026-08, PRD-20260812)
组件与测试文件统一遵循 Biome 格式(
pnpm format/pnpm check),CI 的pnpm check强制校验格式与 lint。
The storefront has a GDPR/CCPA-style cookie consent system. Consent is stored in a
plain-JS-readable cookie pallastrade_cookie_consent (JSON: necessary + functional
/ analytics / marketing booleans + version + updatedAt). Categories and the
cookie name are defined in lib/constants/cookies.ts; pure parse/serialize helpers
and the document.cookie read/write layer live in lib/cookie-consent.ts
(unit-testable without a DOM). Key pieces:
CookieConsentProvider(contexts/CookieConsentContext.tsx) — client provider in the root layout; reads the consent cookie in an effect. It exposesacceptAll/rejectAll/savePreferences. Do NOT put "client mounted" state in the provider — it crosses streaming boundaries and causes React hydration mismatches. Each consumer keeps its own localuseState+useEffectmountedflag in the same component that conditionally renders.CookieBanner(components/cookie/CookieBanner.tsx) — first-visit banner (Accept all / Necessary only / Customize). Rendersnulluntil mounted AND undecided, so returning visitors never see a flash. Mounted inapp/[country]/[locale]/layout.tsx.CookieSettings(components/cookie/CookieSettings.tsx) — the shared category toggle panel (used by the banner's "Customize" and the standalone settings page). Necessary is always enabled and disabled.Cookie settings page(app/[country]/[locale]/(storefront)/cookies/page.tsx) — server component route withcookiei18n metadata; footer links to it (footer.cookieSettings).GatedScripts(components/cookie/GatedScripts.tsx) — client gate in the root layout that mounts third-party scripts only after consent: GTM (NEXT_PUBLIC_GTM_ID, analytics), Vercel Analytics / Speed Insights (NEXT_PUBLIC_VERCEL_ANALYTICS+NODE_ENV=production, analytics), Tawk.to (marketing). Loads nothing before consent.- Sentry client reporting (
instrumentation-client.ts) is gated per-event viabeforeSend/beforeSendTransactioncheckingreadConsentFromDocument()?.analytics.
Necessary cookies (cart token, auth JWT, locale/country, the consent cookie itself) are
never gated. All banner/settings copy lives in the cookie i18n namespace across all
5 locale files.
Home page sections (2026-08 redesign, PRD-20260810)
The home page (app/[country]/(storefront)/page.tsx) composes 8 sections in components/home/:
HeroSection— brand tagline + value prop + primary/secondary CTAs (no demo links).FeaturedProductsSection— product grid + "view all".PromoBanner— wide gradient band, retargeted as a "limited-time offers" sale banner (distinct from featured products).ValueProps— 4 trust props (shipping / authenticity / returns / support).BrandStory— GEO-friendly "answer-ready" brand paragraph.FaqSection— visible Q&A + matchingFAQPageJSON-LD (structured data always mirrors visible content).ContactForm(components/home/ContactForm.tsx, client component) — complaint / feedback / inquiry form behind the footer#contactanchor. Classifies bykind(complaint / feedback / inquiry), validates email + body, and submits viacreateContactMessageserver action (Store APIPOST /api/v3/store/contact_messages, guest-accessible). Messages surface in the admin Email → Inbox & Feedback page. Client-safe import: the action lives inlib/data/contact.ts("use server"), so the component never imports the server-only barrel. i18n under thecontactnamespace in all 5 locale files.NewsletterSignup— client component, front-end validation + success state (no backend yet).
CategoryNav (components/layout/CategoryNav.tsx) is a persistent desktop category bar — a client component (receives categories as props from the server layout). Hovering a root category opens its sub-category mega panel (grid of all level-2 children, each column listing level-3 grandchildren inline, plus a "View all" footer link); clicking locks the panel open (click again / click outside closes). hidden md:block, overflow-x-auto for many categories. The mobile drawer MobileMenu (md:hidden trigger) remains the small-screen entry point. There is deliberately no separate home "shop by category" section and no sr-only category nav — the visible nav bar already covers category browsing.
SEO / GEO (2026-08)
- JSON-LD helpers in
lib/seo.ts:buildOrganizationJsonLd,buildWebsiteJsonLd(WebSite + SearchAction →{basePath}/products?q={search_term_string}),buildProductJsonLd,buildBreadcrumbJsonLd,buildCategoryItemListJsonLd. The storefront layout injects Organization + WebSite; pages inject Product / Breadcrumb / ItemList / FAQPage.buildProductJsonLdadds anAggregateRating(ratingValue=product.average_rating,reviewCount=product.review_count,bestRating: 5) whenever the product has at least one approved review (P0-4) — the fields come from the Store API Product serializer. /llms.txt(app/llms.txt/route.ts) — llmstxt.org site overview (title, about, categories, key pages, structured-data note). Route handlers are dynamic by default; do NOT addexport const dynamic(incompatible with Cache Components mode).- Semantic HTML rules: exactly one
h1per page; sections usesection[aria-labelledby]; images carry meaningfulalt.
SEO 301 redirects (2026-08, phase-1)
src/lib/pallastrade/middleware.ts (createPallasTradeMiddleware, wired via Next.js 16
src/proxy.ts) resolves every storefront pathname against the store's SEO redirects via
GET /api/v3/store/redirects/resolve?path=... (60s revalidate cache, 3s timeout). On a hit it
issues NextResponse.redirect(target, status) (guarded against A→A loops); on API failure it
degrades open (continues normal rendering — Turnstile-style). Redirects are managed in the
admin (Developers → Redirects) as PallasTrade::Redirect records. Static assets, _next/*
and api/* are excluded by the proxy matcher. Do NOT add a separate src/middleware.ts —
Next.js 16 errors when both a middleware and a proxy file are present.
Blog posts — CMS (2026-08, PRD-20260816-other-新增cms博客)
Blog is rendered from the PallasTrade CMS Post model (published only):
- Routes:
/blog(list) and/blog/[slug](detail) underapp/[country]/[locale]/(storefront)/blog/. - Data layer:
src/lib/data/posts.ts(listPosts/getPost) usesclient.posts.list/client.posts.getfrom@pallastrade/sdk. - Components:
src/components/blog/PostCard.tsx(server component; cover image, title, excerpt, author, publish date). - SEO: detail page
generateMetadatausesseo_title/seo_description(fall back to title/excerpt) + JSON-LDArticle; published posts are added tosrc/app/sitemap.tsviaclient.posts.list(Posttype from SDK). - Locale messages:
blognamespace in all 5messages/*.jsonfiles. - Do NOT use inline
style={{}}— Tailwind classes only (AP-001).
Client-side cart
Carts are server-state, so use SWR or React Query. The cart ID + token persist in a cookie:
'use client'
import useSWR from 'swr'
export function MiniCart({ cartId, token }: { cartId: string; token: string }) {
const { data: cart } = useSWR(
['cart', cartId],
() => pallastrade.carts.get(cartId, { guestToken: token })
)
if (!cart) return null
return <span>{cart.items.length} items</span>
}
Checkout
The Store API exposes payment sessions for the checkout flow — a single, provider-agnostic endpoint that works with any session-based gateway (Stripe, Adyen, PayPal); the provider is selected via payment_method_id. The pattern:
- Customer hits checkout —
POST /api/v3/store/carts/:cart_id/payment_sessionswith a payment method choice (cart token inX-PallasTrade-Tokenheader). - Backend returns a session with provider-specific data (Stripe Checkout URL, Adyen drop-in token, etc.).
- Storefront redirects to the provider OR renders the provider's embedded form.
- Customer completes — provider posts back to the PallasTrade backend, which fires
payment_session.completedevents. - Storefront calls
pallastrade.carts.paymentSessions.complete(cartId, sessionId, { session_result: 'success' }, options)once the customer confirms, thenpallastrade.carts.complete(cartId, options)to get the Order — or relies on the provider webhook, in which case the backend completes the cart → order transition automatically.
The pallastrade_stripe / pallastrade_adyen / pallastrade_paypal_checkout gems ship reference checkout flows. Don't roll your own unless you're integrating a new provider.
Standard e-commerce flow (P1 2026-08-30, PRD-20260829-checkout + PRD-20260830-checkout 下单链路统一化)
New Cart entity (pallastrade_carts, independent table — see pallastrade-data-model) with a standard flow. Since 2026-08-30 the flow is unified (阿里国际站风格:一页确认+支付 / 收银台弹窗):
- Cart page
/{country}/{locale}/cart(lib/data/shopping-cart.ts): line-itemselectedcheckboxes, select-all, quantity, remove. Only selected items flow into the order. Client component — all SDK calls go through"use server"actions (updateCartItemSelection,setAllCartItemsSelected,updateCartItemQuantity,removeCartItem); never importgetClient()in a client component. Current-cart resolution (getCart()/getShoppingCart()without an explicit ID) must acceptstatus === "active"only: after submit changes the cart toconverted, the cart page renders empty and the next Add to Cart creates a new active cart instead of surfacing the expected converted-cart authorization rejection. Explicit-ID reads remain available to the checkout recovery path. 去结算 →/checkout/[cartId](统一下单页,不再有独立的/checkout-info确认页——该目录已删除)。 - Unified checkout
/{country}/{locale}/checkout/[id](components/checkout/UnifiedCheckout.tsx,购物车模式):main column = email +AddressFormFields+ itemized lines + delivery-method radio + payment-method radio;order summary 通过CheckoutContext#setSummaryContent发布到 desktop sticky sidebar。StripeCardPaymentFormis rendered immediately but creates no provider object until Pay. One Pay calls same-originPOST /api/checkout/start(update Cart → idempotent submit → start/reuse Order session), confirms the returned PaymentIntent in the same handler, best-effort PATCHes completion, then opens/payment-result/[orderId]?session=.... Never redirect tocheckout/or_for a second Pay. - Order payment
/{country}/{locale}/checkout/[id](components/checkout/OrderPaymentContent.tsx,or_订单模式):read-only shipping and direct card form. It creates/reuses the existing Order session and ends at the unified payment result. Non-session methods remain pending and also use the result page. - Buy Now creates an isolated Cart tagged with
checkout_source=buy_now+previous_cart_id; after submit the BFF restores the previous regular Cart cookie fromsuccessor_cart. - Cashier modal(个人中心场景 D)
components/checkout/PaymentCheckoutModal.tsx:only payment UI, never Cart submit/Order create. Single Order uses Order sessions; multi-order uses PaymentCombination. Every provider success/failure/cancel/pending outcome navigates to the same server-authoritative result page instead of closing and guessing viarouter.refresh().
Keys: cart items use selected; submitted Orders get short-lived HttpOnly checkout-token cookies because the current-cart cookie may switch to a successor. Totals always come from the API (display_* fields). Result text lives under paymentResult.* in all five locales.
BFF 错误契约 + /api 路由所有权(bugfix 2026-09-06):storefront BFF(app/api/checkout/start、app/api/checkout/coupon、app/api/webhooks/pallastrade)错误统一为后端 v3 envelope { error: { code, message } }(顶层 order_id 保留供失败恢复)。UI 展示前一律经 lib/errors.ts#normalizeErrorMessage 归一化为字符串,禁止把 unknown/object 直传 sonner toast.error 或 JSX——否则 sonner 渲染对象触发 React error #31 → global-error 整页崩溃(本 bug 现场根因)。nginx 反代所有权:/api/v3/* → Rails,其余 /api/* → Next(storefront BFF 默认即达);权威配置版本化于 deploy/nginx/dev.pallastrade.cn.conf,由 pull-deploy 在每次部署后经 deploy/nginx/sync-and-smoke.sh 原子同步并做路由归属 smoke。不要在 BFF 手工加 location /api/xxx 例外——默认已进 Next。
Account orders: single vs combined payment (2026-08-29, PRD-20260829-checkout 订单模块;2026-08-30 改收银台弹窗)
OrderCombinedPay(components/account/OrderCombinedPay.tsx) opensPaymentCheckoutModalon Pay selected: 1 unpaid order → 单笔弹窗(Orders::PaymentSessions);2+ → 组合弹窗(弹窗内POST /payment_combinations+ 各单分摊 +PaymentCombinations::Complete)。弹窗打开/切换支付方式即创建 session 并直接显示StripePaymentForm,不需要先点 Pay 揭示表单。个人中心订单列表只消费 ownership-scopedGET /customers/me/orders的结果,不按 email 做前端补偿过滤,也不读取当前 cart 来决定订单支付方式;弹窗从订单自己的payment_methods选择,服务端仍按当前 store + JWT customer 验证每一笔订单。个人中心订单可能是completed_at已有但仍balance_due;order payment-session API 必须按 ownership-scoped show 权限解析,不能复用排除 completed order 的普通:update权限。不再跳/combined-payment/[pcom_id]两步页。OrderDetail(components/account/OrderDetail.tsx) 补 Pay Now(components/account/OrderPayButton.tsx,balance_due且非子订单)→ 打开单笔收银台弹窗(AC-007)。CombinedPaymentCheckout(components/checkout/CombinedPaymentCheckout.tsx) 保留为/combined-payment/[pcom_id]两步页(存量链接兼容):step 1 收货 (per-member-orderAddressFormFields+ save vialib/data/payment-combination.tsupdateOrderShippingAddress→PATCH /customers/me/orders/:id/shipping_address; saved-address dropdown; no-address orders forced before continuing) → step 2 商品 + 支付 (per-order itemized lines + combined total +StripePaymentForm; no address inputs in the payment card). UsespaymentCombinations.get(id, { expand: ['orders'] })for member order items/addresses.
Recovering a guest cart from an emailed checkout link (abandoned-cart recovery)
Recovery emails (e.g. abandoned_cart_mailer.recovery_email) link back to /{country}/{locale}/checkout/{cart_id}?token=…. The checkout page must restore the cart token cookie before fetching the cart, otherwise the guest cart is treated as anonymous:
// page.tsx — Server Component
export default async function CheckoutPage({ params, searchParams }) {
const cartId = (await params).id
const token = searchParams?.token
if (token) await setCartCookies(cartId, token) // writes cart token cookie for this guest cart
const cart = await pallastrade.carts.get(cartId, { guestToken: token })
// …
}
Use setCartCookies(cartId, token) (shared cookie helper) so subsequent client-side fetches and the payment session calls carry the token.
⚠️ NEXT_PUBLIC_* build/runtime divergence → React #418 hydration mismatch (PALLAS-CUSTOM bugfix 2026-08-25)
Symptom: checkout page blank / stuck on the loading skeleton; console shows Minified React error #418 (hydration text mismatch) on production builds; the page occasionally recovers after 30–60s or never does. Product/PDP pages (server-component layout) are unaffected — only pages whose layout or header/footer are client components (e.g. the (checkout)/layout.tsx, Header.tsx, Footer.tsx, StoreContext.tsx) throw it.
Root cause: client components read NEXT_PUBLIC_* (e.g. getStoreName() in lib/store.ts) — those are inlined into the client bundle at build time. Server components read the runtime process.env. If the build-time value differs from the runtime env file value (.env.storefront.dev → NEXT_PUBLIC_STORE_NAME=PallasTrade-Dev), the server SSR-renders one string while the client hydrates another → #418 → React aborts hydration → the mounted-gate useEffect never runs → permanent skeleton / blank page.
Correct fix (not just a mounted gate): bake every NEXT_PUBLIC_* that client components read as a Docker build arg, and make it exactly match the runtime env file:
storefront/Dockerfile— declareARG+ENVforNEXT_PUBLIC_STORE_NAME,NEXT_PUBLIC_SITE_URL,NEXT_PUBLIC_DEFAULT_LOCALE,NEXT_PUBLIC_DEFAULT_COUNTRY(in addition to the existing Tawk/Turnstile/Stripe args)..github/workflows/deploy.yml+deploy/docker-compose.dev.yml— pass the same values as build args.- Runtime
.env.storefront.*must keep the identical values.
A mounted gate (render the checkout body only after client mount, SSR outputs the skeleton) is a fallback that stops the checkout body from participating in hydration, but it does NOT fix the underlying mismatch — always fix the env consistency first. Verify: after deploy, the SSR HTML and the client bundle must contain the same store name; console must be free of #418.
⚠️ Stripe PaymentElement silently empty when NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not baked (PALLAS-CUSTOM bugfix 2026-08-26)
Symptom: checkout renders fully (Pay Now visible, "Test card: 4242…" note shown) but the Stripe payment form area is empty — no card-number input, no Stripe iframe. Console has no obvious error; backend logs show POST /carts/:id/payment_sessions 201 with a valid client_secret. The StripePaymentForm mounts (<div class="p-4"><div><div></div></div></div>) but PaymentElement renders nothing.
Root cause: @stripe/stripe-js's loadStripe(publishableKey) runs on the client and reads process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY — a NEXT_PUBLIC_* var that must be inlined at build time. If the Dockerfile does not declare ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY (e.g. after a rollback that dropped the original b68095f fix), Docker silently ignores the --build-arg, the bundle ships with an empty key, isStripeConfigured is false, stripePromise resolves to null, and Elements renders an empty container. The backend still creates PaymentIntents (201) — the failure is purely the client key.
Correct fix: storefront/Dockerfile must declare ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY + ENV, and the build (deploy.yml via vars.STRIPE_PUBLISHABLE_KEY, docker-compose.dev.yml, or the manual docker build) must pass the key. Verify after deploy: grep -rl pk_ /app/.next/static/chunks/ inside the image must match the key; in the browser the Stripe iframe (iframe[src*="stripe"]) must appear under Payment Method.
Webhook handling
For Next.js storefronts, @pallastrade/sdk/webhooks provides HMAC signature verification with typed event payloads:
// app/api/webhooks/pallastrade/route.ts
import { verifyWebhookSignature, type WebhookEvent } from '@pallastrade/sdk/webhooks'
export async function POST(req: Request) {
const body = await req.text()
const signature = req.headers.get('x-pallastrade-webhook-signature') ?? ''
const timestamp = req.headers.get('x-pallastrade-webhook-timestamp') ?? ''
if (!verifyWebhookSignature(body, signature, timestamp, process.env.PALLASTRADE_WEBHOOK_SECRET!)) {
return new Response('Invalid signature', { status: 401 })
}
const event: WebhookEvent = JSON.parse(body)
switch (event.name) {
case 'order.completed':
await sendCustomThankYouEmail(event.data)
break
case 'order.shipped':
await pushShippingNotification(event.data)
break
}
return Response.json({ received: true })
}
The PallasTrade backend ships outbound webhooks as PallasTrade::WebhookEndpoint records. Configure URL + events under Settings → Webhooks in the admin.
Storefront vs backend — where does the change belong
| Want to... | Belongs in |
|---|---|
| Change how a product is displayed (layout, colors, copy) | Storefront |
| Add a new field to product responses | Backend (model + serializer) |
| Add a custom page like /about, /shipping | Storefront |
| Change pricing logic | Backend (service swap or extension) |
| Add a country to checkout | Backend (Markets / Country config) |
| Customize the checkout UI flow | Storefront |
| Add an A/B test to the PDP | Storefront |
| Sync orders to a CRM | Backend (subscriber) |
| Custom analytics events | Storefront (client-side tracking) OR backend (subscriber) — depends on what triggers them |
| Customize the cart total calculation | Backend (service swap on PallasTrade.cart_recalculate_service) |
| Send a custom transactional email | Backend (subscriber + ActionMailer) |
The rule: anything customer-visible is the storefront. Anything that touches data, money, or business logic is the backend. When in doubt, backend — keeping logic centralized makes it consistent across all frontends if you ever ship a second one.
Common gotchas
- Dynamic inline styles are valid AP-001/AP-006 exceptions. Data-driven styles (backgroundImage from
image_url, backgroundColor fromoption.color_code, percentage widths, animation delays), SDK config objects (PayPalButtonsstyle, Stripe Elementsvariables), and CSS-variable injection (sonner--normal-bg) are acceptable — do NOT rewrite them into Tailwind classes. Only genuinely static styles (fixed width/height/color) should become Tailwind classes. Email templates (lib/emails/) must keep inline styles (email clients don't support external CSS). - Don't ship secret keys to the browser. Secret API keys (
sk_…) never belong inNEXT_PUBLIC_*env vars. In the official storefront even the PallasTrade publishable key stays server-side (PALLASTRADE_PUBLISHABLE_KEY, noNEXT_PUBLIC_prefix) since all API calls run in Server Actions —NEXT_PUBLIC_*is only for third-party client SDK keys (Stripe/PayPal publishable keys). - Cart tokens are not credentials — they identify a cart, not a user. But they grant cart access, so treat them like a session token: HTTPS only, set as an httpOnly cookie when possible.
- Cache aggressively but invalidate on cart/auth changes. Product catalog can sit in CDN; cart calls must always hit fresh.
- Pricing displayed must match what the API will charge. Don't compute totals client-side. Always pull the cart's
totalfrom the API after add/remove operations — the backend applies promotions, taxes, shipping rules. - i18n is the storefront's job. The Store API returns translated strings based on the
x-pallastrade-localeheader (or alocalequery param) — notAccept-Language. Set it via the SDK:createClient({ ..., locale })for a default, or pass{ locale }in per-request options; the SDK sends it asx-pallastrade-locale.
Where to read further
- SDK docs:
node_modules/@pallastrade/docs/dist/developer/sdk/quickstart.md(also at https://pallastrade.cn/docs/developer/sdk/quickstart) - Storefront docs:
node_modules/@pallastrade/docs/dist/developer/storefront/nextjs/architecture.md,customization.md,deployment.md - Storefront tutorial:
node_modules/@pallastrade/docs/dist/developer/tutorial/api.md,sdk.md - Storefront source: https://github.com/stevenbian9266-cyber/pallastrade — reference implementations for product listing, cart, checkout, account pages
Changelog (P0 Payment, 2026-09-03)
- P0 (2026-09-03): Express(Apple/Google Pay) 金额/行项目改由服务端 Cart#express_payment 权威提供(expressAmount/expressLineItems;legacy buildLineItems 仅 fallback);Legacy cart 支付=Compatibility Only。
- CHK-P1-4 (2026-09-03): SDK
orders.checkout.get(手写 CheckoutView 类型)→lib/data/order-checkout.ts(server,null 安全);OrderPaymentContent(or_ 纯支付页)改为服务端 CheckoutView 投影驱动金额/商品/地址(view 缺失回退 order 快照)+ready=false禁用 Pay(i18n checkoutNotReady);死代码清理submitCartOrder/submitCartAndGoToCheckout;legacy 边界注释(CheckoutPageContent/PaymentSection/CombinedPaymentCheckout)。mutation PATCH 消费(4B)、legacy 退役(4C)留后续。 - CHK-P1-4B (2026-09-04): SDK
orders.checkout.update+lib/data/order-checkout.ts#updateOrderCheckout(409/业务错误 code 透传);OrderPaymentContent(or_ 页)物流 rate/收货地址可内联编辑(PATCH → 服务端最新 view;复用 AddressFormFields + useCountryStates);会话创建遇checkout_version_conflict→ 提示 + 重取 view(不自动支付);createOrderPaymentSession 失败透传 code。 - CHK-P1-4C (2026-09-04): 移除孤儿两步合并支付页
combined-payment/[id]+CombinedPaymentCheckout.tsx(账户弹窗 PaymentCheckoutModal 为现行入口);删除死代码lib/data/payment-combination.ts#updateOrderShippingAddress;legacy 一页式(CheckoutPageContent/PaymentSection/Express/confirm-payment)保留(4C-4 后续)。 - CHK-P1-4C4 (2026-09-04): legacy 一页式支付页退役——删除
checkout/[id]/CheckoutPageContent+CheckoutSidebar+components/checkout下AddressSection/DeliveryMethodSection/PaymentSection/AddressSelector/Summary/AdyenPaymentForm/PayPalPaymentForm(9 文件,Adyen/PayPal 内嵌表单仅 legacy 用,新流程走网关跳转 + confirm-payment);checkout/[id]/page.tsx兜底改为 redirect 首页/{country}/{locale}(cart_→UnifiedCheckout、or_→OrderPaymentContent 覆盖全部有效 id,后端整数 id 序列化恒 or_ 前缀);data 孤儿清理checkout.ts#applyCode/removeDiscountCode/removeGiftCard+payment.ts#createDirectPayment(UnifiedCheckout 折扣码走 BFF /api/checkout/coupon);barrel 保留 CouponCode/ExpressCheckoutButton/StripePaymentForm。共享组件 AddOnsSection/SaveInfoSection/AddressEditModal/AddressFormFields/CouponCode/CardPaymentForm/StripePaymentForm/ExpressCheckoutButton/PolicyConsent/CheckoutSectionTitle 全部保留。 - TXN-P2-6 轮3 (2026-09-05, PRD-20260905-checkout-txn-p2-6-轮3-storefront-transaction-first): 订单域支付入口 payment-session-first → transaction-first(P2 §42/§57)。
/api/checkout/start#POST的session_required分支改用orders.transactions.create(后端 Transactions::Start:quote 同意/幂等/快照冻结 + PaymentSessions::Start 绑定 transaction_id),返回payment_execution作为会话 + 响应新增transaction:{id,state};PATCH complete 仍走orders.paymentSessions.complete(session=transaction 的支付 attempt,AC-2006)。lib/data/order-payment.ts#createOrderPaymentSession(OrderPaymentContent server action)内部同样改走 transactions.create,返回payment_execution作为 session +transactionmeta;completeOrderPaymentSession*不变。OrderPaymentContent409 映射:quote_changed与既有checkout_version_conflict同处理(toast「报价已更新」+ refreshView,不自动支付,INV-07)。Provider UI(Stripe 自绘卡字段/会话跳转)独立不变。SDK 依赖 dist 需重建(pnpm --filter @pallastrade/sdk build)后 storefront 才解析到新方法。