前言

商家建立 Shopify 商品折扣後,通常不只要求 Checkout 最後算對。他們還希望顧客在 Collection 與 Product 頁就能看到原價、促銷價與省下多少;登入特定 Customer Segment 的顧客看到會員價;到購物車與實體 POS 時也要得到一致結果。

這聽起來像同一個「顯示折扣」功能,實際跨越了四個不同執行環境:

  • Theme 裡的商品價格 DOM。
  • Storefront App Proxy。
  • Cart/Checkout 的 Shopify Discount Function。
  • POS cart 的 Function input。

這次實作最大的轉折,是不再追求用同一段 JavaScript 控制所有畫面,而是先決定每個 surface 的責任,以及哪一層才是交易來源。

上一篇先說明了 Promotion 與 Automatic App Discount 的一對一生命週期,這篇接著處理活動建立後,顧客在不同接觸點看到什麼。

原本的做法與問題症狀

第一版最直覺的方向,是讓 Theme App Extension 讀取促銷 JSON,直接找出頁面上的價格元素,再把原價改成:

原價刪除線 + 預估促銷價 + 折扣標籤

這能快速支援 Product 與 Collection 頁,但很快出現三種不一致。

第一,Theme JavaScript 只是修改畫面,沒有改變 Shopify Cart。顧客看到促銷價,不代表 Checkout 一定會套用。

第二,Customer Segment 是伺服器端的 Shopify 顧客資料。若 Theme 只看到 Promotion 選了某個 Segment,卻不知道目前顧客是否為成員,就可能把會員價顯示給所有人。

第三,Cart drawer 也包含價格 DOM。如果 Extension 繼續替 Cart 改字,Shopify 自己的折扣結果與前端預估可能同時出現,造成重複顯示或錯誤小計。

POS 則是另一個環境。它不執行 Online Store Theme 的 app embed,卻可能執行同一個 Discount Function。因此「Theme 看起來正常」完全不能證明 POS 正常。

問題是怎麼推敲出來的?

排查時先替四個 surface 定義權威程度:

Surface 可以做什麼 不能保證什麼
Product/Collection 頁 提前顯示活動與預估價格 不能決定最終成交價
App Proxy 查登入顧客的顯示資格 不能替 Checkout 套用折扣
Cart/Checkout 由 Shopify 執行 Function 並重算 totals 不負責任意 Theme DOM
POS 由 Function 讀取零售交易訊號 不會執行 Online Store app embed

Shopify 官方將 Discount Function 描述為即時執行的 backend logic:顧客把商品加入 Cart 時,Shopify 取得 Function input、執行 Function,再處理輸出的 discount operations。

Theme App Extension 則是 storefront integration。App embed 可以載入 Liquid、CSS 與 JavaScript,但預設需要商家在 Theme Editor 啟用,而且不能渲染在 Checkout 頁面。

因此兩者不應互相假裝。Theme 顯示必須標示為 eligibility preview;真正金額以 Shopify Cart 與 Checkout 的計算為準。

最後採用的架構

最後採用雙軌資料流:

                     ┌─ Theme App Embed
完整 Promotion 設定 ┤     └─ 商品頁價格預覽
                     └─ App Proxy
                           └─ Customer Segment 顯示資格

精簡 runtime 設定 ── Automatic App Discount metafield
                           └─ Discount Function
                                 ├─ Cart/Checkout 成交折扣
                                 └─ POS 交易折扣

Web App 保存完整 Promotion 設定,提供標題、商品 handle、顯示樣式與客群等資訊。Theme App Embed 將必要資料輸出成 JSON,JavaScript 再依 Theme selector 尋找商品卡與價格節點。

如果活動限制 Customer Segment,Extension 不直接相信前端狀態,而是呼叫同網域的 App Proxy。Shopify proxy request 帶入 logged_in_customer_id,React Router 的 app proxy authentication 驗證請求後,Server 再使用 Admin GraphQL 查詢 customer segment membership。

Cart 與 Checkout 則完全不依賴這個顯示結果。Discount Function 從 function-owner metafield 與 Shopify Function input 判斷 Product、Collection 與執行環境,再回傳真正的 product discount operation。

關鍵實作

Theme App Embed 會輸出完整 Promotion、目前 Product GID、顯示設定與 eligibility endpoint:

<script type="application/json" id="discount-preview-config">
  {
    "promotion": {{ promotion_config | json }},
    "currentProductGid": "gid://shopify/Product/example",
    "eligibilityEndpoint": "/apps/example/customer-eligibility"
  }
</script>

遇到 Customer Segment Promotion 時,前端只送需要確認的 Promotion IDs:

const response = await fetch(eligibilityEndpointUrl, {
  credentials: 'same-origin'
});

const {eligiblePromotionIds = []} = await response.json();

Proxy route 先經過 Shopify app proxy authentication,再讀取 Shopify 加上的登入顧客 ID。無登入顧客、無有效 Promotion ID、GraphQL 錯誤或 membership query 失敗時,都回傳空的 eligible list,採 fail closed。

Theme JavaScript 另外明確排除 Cart:

if (isCartContext(priceNode)) {
  clearRenderedDiscount(priceNode);
  return;
}

這個判斷很重要。Cart drawer 與 Cart page 應顯示 Shopify 實際計算結果,而不是再次套用商品頁的預估價格。

實際驗證

這次驗證分成三層:

Theme 顯示測試確認:

  • App embed 會載入完整 Promotion 與顯示設定。
  • 商品頁和商品卡能找到對應 Product GID 或 handle。
  • Cart page、cart drawer 與 cart item 不套用預覽價格。
  • Theme selector 不合法時不讓整個腳本中止。
  • Customer Segment Promotion 在查詢完成前不顯示會員價。

App Proxy 與客群測試確認:

  • Proxy 使用 Shopify 提供的登入顧客 ID。
  • 多個 Segment IDs 可在一次 customerSegmentMembership query 中檢查。
  • 只回傳顧客符合且前端實際要求確認的 Promotion IDs。
  • Admin GraphQL 拒絕或連線錯誤時回傳不符合資格。

Function 測試則確認:

  • 無效或缺少 runtime config 時回傳空操作。
  • Product 或 Collection 命中時產生 product discount candidate。
  • Online cart 與帶有 retail location 的 cart 能被分開測試。
  • Cart 重新計算後的折扣金額才是交易結果。

仍然存在的限制

商品頁價格預覽仍依賴 Theme DOM selector。即使提供可調整 selector,不同 Theme、第三方價格元件、Markets 幣別格式與動態 section rendering 都可能造成解析差異。

前端從文字解析金額再計算預覽,也不等於 Shopify Money、四捨五入與折扣疊加後的最終結果。因此介面不應把它描述成結帳保證。

App Proxy 的 logged_in_customer_id 在沒有登入顧客時會是空值。Proxy request 也不能靠 Cookie 維持自己的 storefront session,必須正確驗證 Shopify proxy signature 或使用框架提供的 authentication。

完整 Promotion 設定與每個 discount node 的精簡 Function 設定是兩份資料。如果同步只成功一半,顯示與交易可能短暫不一致;需要記錄版本、同步結果與可重試狀態。

最後,Theme App Extension、App Proxy 設定與 Discount Function 都屬於 Shopify app version 或平台設定。只部署 Web App hosting,不會自動更新這些元件。

可以延伸到哪些情境?

當一個功能同時出現在「預覽」與「交易」畫面時,可以先問兩個問題:

  1. 哪個 surface 只負責幫助使用者預期結果?
  2. 哪個 surface 是最後能改變訂單狀態的權威來源?

相同原則也適用運費預估、庫存提示、會員資格、稅額預覽與多幣別價格。前端可以改善理解,但不能取代平台最後的交易計算。

下一篇會進一步拆解 POS 的 runtime signal:Shopify POS 折扣條件怎麼判斷?從 retailLocation 找出執行場景

參考資料