购物车加购保险按钮
顾客如果在商品页未选购延保险(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),用于展示补加保障失败时的错误提示。
- 读取候选:在组件首次挂载、商品行变化以及 SDK 同步完成后调用
getCartQuotes();需要「移除保障」入口时同时调用getRelations()。 - 按主行 ID 匹配渲染:通过
quote.mainLineId === line.id匹配购物车行,仅为存在可用方案(quote.options.length > 0)的行渲染按钮。切勿按商品规格 ID 匹配,同一规格在购物车中可能拆分为多行。 - 顾客选定方案后写入:
- SDK 弹窗(推荐):调用
offerProtection({ mainLineId: line.id })。SDK 检查补购开关、主行与有效报价后弹出方案窗(单方案也弹窗),顾客确认后复用addProtection写入;取消返回{ status: 'cancelled' }且不写车。已有有效保障时以PP_ALREADY_PROTECTED拒绝,不弹窗。 - 站点自绘列表:调用
addProtection({ mainLineId: line.id, selection: quote.options[index] })。selection必填,缺省会以INVALID_ARGUMENT拒绝;SDK 不会弹窗,也不会借用商品页的面板选择。 请求完成前锁定该行按钮,忽略重复点击。
- SDK 弹窗(推荐):调用
- 移除保障:调用
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()
// 方式二:站点自绘列表,顾客点选后再提交对应 optionconst { quotes } = await window.xcotton.pp.getCartQuotes()const quote = quotes.find((item) => item.mainLineId === line.id)const selection = await pickPlan(quote?.options ?? []) // 站点自绘方案列表,返回顾客确认的 optionif (selection) { await window.xcotton.pp.addProtection({ mainLineId: line.id, selection })}
await window.xcotton.pp.removeProtection({ mainLineId: line.id })pickPlan 由站点实现,负责展示方案列表并返回顾客确认的 option;顾客未选择时返回 null,不要默认提交第一个方案。
- 多视图联动刷新:确保购物车页与购物车抽屉同时订阅共享模块的
onXcottonCartChange,卸载时取消订阅。站点查询可能保留同一个lines数组引用,仅共享购物车状态不足以通知候选更新。SDK 写入保障行后会触发onCartChange,两处视图同步刷新,已加保商品行的按钮随之同时消失。
注意:站点自身修改购物车后,必须先等待 syncCart(cart.id) 完成,再读取 getCartQuotes(),否则读到的仍是上一次同步的旧候选。
'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, )}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, )}<script setup lang="ts">import { ref, watch, onMounted, onUnmounted } from 'vue'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) }}
const props = defineProps<{ lines: { id: string }[] }>()const offers = ref<PPCartQuote[]>([])
async function reloadOffers() { await xcottonInit() try { offers.value = (await window.xcotton?.pp?.getCartQuotes())?.quotes ?? [] } catch (error) { // 投影失败或失效时旧候选不再可操作,先撤下按钮再交给调用方提示。 offers.value = [] throw error }}
let unsubscribe: (() => void) | undefinedonMounted(() => { const reload = () => { void reloadOffers().catch(showProtectionError) } unsubscribe = onXcottonCartChange(reload) watch(() => props.lines, reload, { immediate: true })})onUnmounted(() => unsubscribe?.())</script>
<template> <template v-for="line in lines" :key="line.id"> <button v-if=" offers.some( (item) => item.mainLineId === line.id && item.options.length, ) " type="button" @click="onAddProtectionClick(line.id).catch(showProtectionError)" > Add protection </button> </template></template>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, )}<script setup lang="ts">import { ref, watch, onMounted, onUnmounted } from 'vue'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) }}
const props = defineProps<{ lines: { id: string }[] }>()const offers = ref<PPCartQuote[]>([])
async function reloadOffers() { await xcottonInit() try { offers.value = (await window.xcotton?.pp?.getCartQuotes())?.quotes ?? [] } catch (error) { // 投影失败或失效时旧候选不再可操作,先撤下按钮再交给调用方提示。 offers.value = [] throw error }}
let unsubscribe: (() => void) | undefinedonMounted(() => { const reload = () => { void reloadOffers().catch(showProtectionError) } unsubscribe = onXcottonCartChange(reload) watch(() => props.lines, reload, { immediate: true })})onUnmounted(() => unsubscribe?.())</script>
<template> <template v-for="line in lines" :key="line.id"> <button v-if=" offers.some( (item) => item.mainLineId === line.id && item.options.length, ) " type="button" @click="onAddProtectionClick(line.id).catch(showProtectionError)" > Add protection </button> </template></template><script lang="ts"> 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) } }
let { lines }: { lines: { id: string }[] } = $props() let offers = $state<PPCartQuote[]>([])
async function reloadOffers() { await xcottonInit() try { offers = (await window.xcotton?.pp?.getCartQuotes())?.quotes ?? [] } catch (error) { // 投影失败或失效时旧候选不再可操作,先撤下按钮再交给调用方提示。 offers = [] throw error } }
$effect(() => onXcottonCartChange(() => { void reloadOffers().catch(showProtectionError) }), ) $effect(() => { void lines void reloadOffers().catch(showProtectionError) })</script>
{#each lines as line (line.id)} {#if offers.some((item) => item.mainLineId === line.id && item.options.length)} <button type="button" onclick={() => onAddProtectionClick(line.id).catch(showProtectionError)} >Add protection</button > {/if}{/each}import { createEffect, createSignal, onMount, onCleanup } from 'solid-js'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(props: { lines: { id: string }[] }) { const [offers, setOffers] = createSignal<PPCartQuote[]>([])
async function reloadOffers() { await xcottonInit() try { setOffers((await window.xcotton?.pp?.getCartQuotes())?.quotes ?? []) } catch (error) { // 投影失败或失效时旧候选不再可操作,先撤下按钮再交给调用方提示。 setOffers([]) throw error } }
onMount(() => { onCleanup( onXcottonCartChange(() => { void reloadOffers().catch(showProtectionError) }), ) }) createEffect(() => { void props.lines void reloadOffers().catch(showProtectionError) })
return ( <> {props.lines.map((line) => offers().some( (item) => item.mainLineId === line.id && item.options.length, ) ? ( <button 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。