跳转到内容

购物车加购保险按钮

顾客如果在商品页未选购延保险(PP),仍可在购物车中补加。SDK 把「哪些主行还能补加」和「可选方案」放在 getCartQuotes(),把写车拆成明确入口:offerProtection({ mainLineId }) 由 SDK 弹窗选择并写入(只有一个方案也弹窗,取消零写入),addProtection({ mainLineId, selection }) 接收站点自绘列表已确认的方案,removeProtection({ mainLineId }) 按主商品行取消保障。SDK 不判断你的按钮如何呈现,也不借用商品页的选择。

购物车中哪些主商品行可以补加保障,由 getCartQuotes() 返回:

{
"enabled": true,
"quotes": [
{
"mainLineId": "gid://shopify/CartLine/abc123",
"quantity": 2,
"options": [
{
"mainVariantId": "gid://shopify/ProductVariant/1234567890",
"plan": {
"variantId": 45678901234,
"name": "1 Year",
"priceText": "$4.99",
"properties": { "Plan ID": "1 Year", "Reference": "1234567890" }
},
"mainLineAttributes": [{ "key": "_PlanID", "value": "1 Year" }]
}
]
}
]
}
  • enabled: false 表示客户经理(AM)已在后台关闭购物车补购入口,quotes 恒为 [],该查询不会为了结果去读取购物车。
  • enabled: true 且 quotes: [] 表示开关开启、但当前购物车确实没有可补加的主行。两种情况不要混用。
  • 每条 option 都是完整可选方案,可直接作为 addProtection 的 selection 提交;quantity 取自该主行当前数量。
  • plan.properties 携带关联所需的 Plan ID 与 Reference,mainLineAttributes 只含写入主商品行的 _PlanID。两者都原样使用。
  • 未绑定购物车、尚未同步、同步进行中、快照失效或当前代次同步失败都会 reject 具体错误,不要用空数组判断失败。
  • 店铺已开启 PP,且当前购物车已通过 init({ cartId }) 或 syncCart(cartId) 完成同步。
  • onCartChange 回调会刷新站点的共享购物车状态,详见 购物车同步。
  • 站点提供 showProtectionError(error),用于展示补加保障失败时的错误提示。
  1. 读取候选:在组件首次挂载、商品行变化以及 SDK 同步完成后调用 getCartQuotes();需要「移除保障」入口时同时调用 getRelations()。
  2. 按主行 ID 匹配渲染:通过 quote.mainLineId === line.id 匹配购物车行,仅为存在可用方案(quote.options.length > 0)的行渲染按钮。切勿按商品规格 ID 匹配,同一规格在购物车中可能拆分为多行。
  3. 顾客选定方案后写入:
    • SDK 弹窗(推荐):调用 offerProtection({ mainLineId: line.id })。SDK 检查补购开关、主行与有效报价后弹出方案窗(单方案也弹窗),顾客确认后复用 addProtection 写入;取消返回 { status: 'cancelled' } 且不写车。已有有效保障时以 PP_ALREADY_PROTECTED 拒绝,不弹窗。
    • 站点自绘列表:调用 addProtection({ mainLineId: line.id, selection: quote.options[index] })。selection 必填,缺省会以 INVALID_ARGUMENT 拒绝;SDK 不会弹窗,也不会借用商品页的面板选择。 请求完成前锁定该行按钮,忽略重复点击。
  4. 移除保障:调用 removeProtection({ mainLineId: line.id }),返回 { status: 'removed' };目标行本来就没有保障与关联标记时返回 { status: 'already_unprotected' }。主商品行会保留,SDK 只清除该行的 _PlanID、保留站点其他属性,并在同组还有其他被保主行时重算保障数量。移除成功后,先等待 syncCart 完成并按最新 Cart 重取主行 ID;若补购开关开启且仍有可用方案,getCartQuotes() 会再次返回该主行。只有在投影里还没有该行候选时,SDK 才补一次报价;这次补报价失败不会推翻已完成的移除,但候选查询会以 PP_CART_QUOTES_SYNC_FAILED 拒绝,直到下一次 syncCart 恢复。
// 方式一:SDK 弹窗编排(单方案也弹窗,取消零写入)
const result = await window.xcotton.pp.offerProtection({ mainLineId: line.id })
if (result.status === 'added') showAdded()
// 方式二:站点自绘列表,顾客点选后再提交对应 option
const { quotes } = await window.xcotton.pp.getCartQuotes()
const quote = quotes.find((item) => item.mainLineId === line.id)
const selection = await pickPlan(quote?.options ?? []) // 站点自绘方案列表,返回顾客确认的 option
if (selection) {
await window.xcotton.pp.addProtection({
mainLineId: line.id,
selection
})
}
await window.xcotton.pp.removeProtection({ mainLineId: line.id })

pickPlan 由站点实现,负责展示方案列表并返回顾客确认的 option;顾客未选择时返回 null,不要默认提交第一个方案。

  1. 多视图联动刷新:确保购物车页与购物车抽屉同时订阅共享模块的 onXcottonCartChange,卸载时取消订阅。站点查询可能保留同一个 lines 数组引用,仅共享购物车状态不足以通知候选更新。SDK 写入保障行后会触发 onCartChange,两处视图同步刷新,已加保商品行的按钮随之同时消失。

注意:站点自身修改购物车后,必须先等待 syncCart(cart.id) 完成,再读取 getCartQuotes(),否则读到的仍是上一次同步的旧候选。

components/cart-line-protection.tsx
'use client'
import { useEffect, useState } from 'react'
import type { PPCartQuote } from '@/lib/xcotton-types'
import { xcottonInit, onXcottonCartChange } from '@/lib/xcotton'
import { showProtectionError } from '@/lib/cart'
const addingLineIds = new Set<string>()
async function onAddProtectionClick(mainLineId: string) {
if (addingLineIds.has(mainLineId)) return
addingLineIds.add(mainLineId)
try {
await xcottonInit()
// 单方案也弹窗;顾客取消时零写入。
return await window.xcotton?.pp?.offerProtection({ mainLineId })
} catch (error) {
showProtectionError(error)
} finally {
addingLineIds.delete(mainLineId)
}
}
export function CartLineProtection({ lines }: { lines: { id: string }[] }) {
const [offers, setOffers] = useState<PPCartQuote[]>([])
async function reloadOffers() {
await xcottonInit()
try {
setOffers((await window.xcotton?.pp?.getCartQuotes())?.quotes ?? [])
} catch (error) {
// 投影失败或失效时旧候选不再可操作,先撤下按钮再交给调用方提示。
setOffers([])
throw error
}
}
useEffect(() => {
const reload = () => {
void reloadOffers().catch(showProtectionError)
}
const unsubscribe = onXcottonCartChange(reload)
reload()
return unsubscribe
}, [lines])
return lines.map((line) =>
offers.some(
(item) => item.mainLineId === line.id && item.options.length,
) ? (
<button
key={line.id}
type="button"
onClick={() =>
void onAddProtectionClick(line.id).catch(showProtectionError)
}
>
Add protection
</button>
) : null,
)
}
操作预期结果
同一规格拆成两行加入购物车两行各自按 CartLine.id 独立显示按钮,互不干扰
在购物车页点击某行的「加购保障」写入保障行,购物车页与抽屉中该行的按钮同时消失
该主行只有一个可用方案offerProtection 仍弹窗,顾客确认后才写入
顾客取消补购弹窗返回 { status: 'cancelled' },不写车、不改 PDP 选择
该主行已有有效保障offerProtection 以 PP_ALREADY_PROTECTED 拒绝,不弹窗
调用 addProtection 时不传 selection以 INVALID_ARGUMENT 拒绝,不写车、不弹窗
点击已加保行的「移除保障」只清除该主行的保障与 _PlanID,主商品行与同组其他行保留
补购开关开启且仍有可用方案,移除保障并同步后再次读取候选该主行重新出现在 getCartQuotes() 中,可以再次补购
写入失败后再次点击重试按实际已加保数量恢复,不会重复写入多余保障行
后台关闭购物车补购入口getCartQuotes() 返回 { enabled: false, quotes: [] }
现象原因与处理方法
所有购物车行均不显示按钮后台关闭了补购入口(enabled: false),当前购物车内的商品均无可用 PP 方案(quotes: []),或所有主行都已加保(候选按已确认的 Cart 过滤)
加购成功后购物车抽屉中的按钮未消失抽屉未与购物车页共用同一份购物车状态,或在 onCartChange 触发后未重新调用 getCartQuotes()
站点新增商品后,新商品行未显示按钮在 syncCart 完成之前过早读取了 getCartQuotes()
读取候选直接报错先处理具体错误:未绑车、未同步、同步中、快照失效或同步失败,不要把它当成「没有候选」
主行已有保障行,但想确认是否仍为当前报价getRelations() 只确认实际关联,不判断报价是否过期;已保障主行不在补购候选中,Plan ID 相同也不代表保障价格相同

getRelations() 只返回主行与保障行的 ID,不提供价格或报价过期标记。SDK 按配置的 PP 商品识别保障行,再按 _PlanID、Plan ID(兼容 Cov)与 Reference 匹配关系,不比较保障行变体是否属于当前报价。只读查询不改价;syncCart 仍按 V4 返回的变更调和购物车。

若已确认需要主动换方案,先调用 removeProtection,等待 syncCart 成功,再按最新 Cart 重取主行 ID 和 getCartQuotes() 候选,由顾客确认后重新加保。不要直接重复添加,也不要沿用移除前的行 ID 或候选。

常见错误:

code含义
CART_NOT_BOUND还没有绑定购物车;先取得购物车并调用 syncCart(cartId)
PP_CART_QUOTES_NOT_READY尚未同步 / 同步中 / 快照失效,或补购弹窗期间购物车已变化;等待同步或重新 syncCart 后再查询
PP_CART_QUOTES_SYNC_FAILED当前代次同步失败,旧候选不可用;按 cause 处理后重新 syncCart
PP_NO_OPTIONS补购开关关闭,或当前报价没有该主行的候选;不弹窗
CART_LINE_NOT_FOUND目标主行不存在,或传入的是保障行;刷新购物车后重新取得行身份
PP_ALREADY_PROTECTED该主行已有实际保障行;刷新购物车后隐藏补购入口,不重复添加。主动换方案需先移除保障,再使用当前有效且经顾客确认的方案重新加保
INVALID_ARGUMENT缺少 selection,或方案不属于该主行;使用候选里的原始 option
CART_SNAPSHOT_INCONSISTENT关联歧义或保障数量与共享组不一致;先完成一次 syncCart 再排查

提示:不同商品的方案名称(如 1 Year)可能相同,请勿通过方案名称来判断保障行与主商品行的关联关系;请使用 getRelations() 返回的行 ID。