Cart add-protection button
Customers who skip Product Protection (PP) on the product page can still add it later from the cart. getCartQuotes() tells you which main lines can still be protected and which plans each line offers. offerProtection({ mainLineId }) runs the SDK modal and write (it opens the modal even for a single plan, and cancelling writes nothing), addProtection({ mainLineId, selection }) writes a plan your own list already confirmed, and removeProtection({ mainLineId }) cancels protection for one main line. The SDK never borrows a selection from the product page.
Call getCartQuotes() to find out which cart lines are eligible for protection:
{ "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: falsemeans your account manager (AM) disabled the cart add-protection feature.quotesis always[]and the query does not read the cart just to answer this.enabled: truewithquotes: []means the feature is on but the current cart genuinely has no eligible main line. Do not conflate the two.- Every
optionis a complete selection you can pass toaddProtection.quantityis the current quantity of that main line. plan.propertiescarries thePlan IDandReferenceused to link the lines, whilemainLineAttributescontains only the_PlanIDwritten to the main line. Pass both through unchanged.- No bound cart, an unsynchronized cart, an in-flight sync, a stale snapshot, or a failed sync for the current cart generation each reject with a specific error. Never treat an empty array as a failure signal.
Before you start
Section titled “Before you start”- PP is enabled for the store, and the active cart has been synchronized via
init({ cartId })orsyncCart(cartId). onCartChangeupdates your shared storefront cart state. See Keep the cart in sync.- Your storefront provides
showProtectionError(error)to surface errors if adding protection fails.
- Load cart quotes: call
getCartQuotes()when the cart component mounts, cart lines change, and SDK synchronization completes. Also callgetRelations()if you offer a “Remove protection” action. - Match by line ID: match each cart line using
quote.mainLineId === line.idand render the button only whenquote.options.length > 0. Never match by variant ID. The same variant can exist as multiple separate lines in the cart. - Add protection after the customer picks a plan:
- SDK modal (recommended): call
offerProtection({ mainLineId: line.id }). After checking the feature switch, the main line, and the current quote, the SDK opens the plan modal (even for one plan) and reusesaddProtectionafter confirmation. Cancelling resolves{ status: 'cancelled' }without writing. A line that already has real protection rejects withPP_ALREADY_PROTECTEDand opens no modal. - Storefront-rendered list: call
addProtection({ mainLineId: line.id, selection: quote.options[index] }).selectionis required. Omitting it rejects withINVALID_ARGUMENT. The SDK opens no modal and never borrows the product page panel selection. Guard the clicked line against duplicate clicks until the request settles.
- SDK modal (recommended): call
- Remove protection: call
removeProtection({ mainLineId: line.id }). It resolves to{ status: 'removed' }, or{ status: 'already_unprotected' }when the line has neither protection nor a link marker. The main product line stays in the cart: the SDK clears only that line’s_PlanID, keeps your other attributes, and recomputes the shared protection quantity while other protected lines remain in the group. After a successful removal, awaitsyncCartand read the current main line ID from the latest Cart. ThengetCartQuotes()returns the main line again if cart add-protection is enabled and an eligible plan remains available. The SDK re-quotes only when the projection has no candidates for that line. If that refresh fails, the removal still stands, but quote reads keep failing until the nextsyncCartrecovers.
// Option 1: SDK modal orchestration (single plan also opens the modal; cancel writes nothing)const result = await window.xcotton.pp.offerProtection({ mainLineId: line.id })if (result.status === 'added') showAdded()
// Option 2: storefront-rendered list; submit the option the customer pickedconst { quotes } = await window.xcotton.pp.getCartQuotes()const quote = quotes.find((item) => item.mainLineId === line.id)const selection = await pickPlan(quote?.options ?? []) // your plan list; resolves the option the customer confirmedif (selection) { await window.xcotton.pp.addProtection({ mainLineId: line.id, selection })}
await window.xcotton.pp.removeProtection({ mainLineId: line.id })pickPlan is storefront code: it renders the plan list and resolves the option the customer confirmed, or null when they do not choose. Never submit the first option by default.
- Keep all cart views synchronized: subscribe both views to
onXcottonCartChangefrom the shared module and unsubscribe on unmount. Storefront queries may preserve the samelinesarray identity, so shared cart state alone cannot notify components that quotes changed. WhenaddProtectionfinishes writing the protection line, the SDK firesonCartChange, refreshing both views and hiding the button in both places simultaneously.
After any storefront cart mutation of your own, await syncCart(cart.id) before calling getCartQuotes(), or you will read quotes from the previous cart snapshot.
Full example
Section titled “Full example”'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() // The SDK opens the plan modal even for one plan; cancelling writes nothing. 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() // The SDK opens the plan modal even for one plan; cancelling writes nothing. 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() // The SDK opens the plan modal even for one plan; cancelling writes nothing. 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() // The SDK opens the plan modal even for one plan; cancelling writes nothing. 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() // The SDK opens the plan modal even for one plan; cancelling writes nothing. 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() // The SDK opens the plan modal even for one plan; cancelling writes nothing. 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() // The SDK opens the plan modal even for one plan; cancelling writes nothing. 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, )} </> )}Verify and troubleshoot
Section titled “Verify and troubleshoot”| Action | Expected result |
|---|---|
| Add the same variant as two separate cart lines | Each line displays its own button independently by CartLine.id |
| Click “Add protection” on the cart page | The protection line is written, and that line’s button disappears on both the cart page and the drawer |
| The main line has only one eligible plan | offerProtection still opens the modal and writes only after the customer confirms |
| Cancel the cart add-protection modal | Resolves { status: 'cancelled' }. No cart write and no PDP selection change |
| The main line already has real protection | offerProtection rejects PP_ALREADY_PROTECTED and opens no modal |
Call addProtection without selection | Rejects with INVALID_ARGUMENT. No cart write and no modal |
| Click “Remove protection” on a protected line | Only that main line’s protection and _PlanID are cleared. The main line and other group lines stay |
| Remove protection while cart add-protection is enabled and plans remain available, sync the cart, then read quotes | The main line reappears in getCartQuotes() and can be protected again |
| Retry after a failed write | Reconciles against the actual protection quantity without duplicating protection |
| Disable the cart add-protection feature in the back office | getCartQuotes() returns { enabled: false, quotes: [] } |
| Symptom | Cause and fix |
|---|---|
| No cart line displays the button | The feature is disabled (enabled: false), no items in the cart have eligible plans (quotes: []), or every main line is already protected (offers are filtered against the confirmed cart) |
| The button remains visible in the drawer after adding protection | The drawer doesn’t subscribe to the same shared cart state, or didn’t re-run getCartQuotes() after onCartChange |
| A newly added cart line doesn’t show the button | getCartQuotes() was called before syncCart finished |
| Reading quotes rejects | Handle the specific error first: unbound cart, not synchronized, sync in progress, stale snapshot, or failed sync |
| A protection line exists, but you need to check whether it matches the current quote | getRelations() confirms the actual relation, not quote freshness. Protected main lines are excluded from cart offers; the same Plan ID does not imply the same protection price. |
getRelations() returns only the main and protection line IDs, without a price or a quote-expiry flag. The SDK identifies protection lines by the configured PP product, then matches _PlanID, Plan ID (or legacy Cov), and Reference. It does not compare the protection variant against the current quote. Read-only queries do not reprice lines; syncCart still reconciles the cart using the changes returned by V4.
To replace a plan after confirming that a replacement is needed, call removeProtection, await a successful syncCart, then read the current main line ID from the latest Cart and load new candidates with getCartQuotes(). Add the plan the customer confirms. Do not add a duplicate or reuse the previous line ID or candidates.
Common errors:
| Code | Meaning |
|---|---|
CART_NOT_BOUND | No cart is bound yet. Get the cart and call syncCart(cartId) first |
PP_CART_QUOTES_NOT_READY | Not synchronized, sync in progress, a stale snapshot, or the cart changed while the add-protection modal was open. Wait or call syncCart again |
PP_CART_QUOTES_SYNC_FAILED | The current cart generation failed to sync, so old quotes are unusable. Handle cause, then resync |
PP_NO_OPTIONS | The add-protection feature is disabled, or the current quote has no options for that main line. No modal opens |
CART_LINE_NOT_FOUND | The target main line does not exist, or a protection line was passed. Refresh the cart and its line IDs |
PP_ALREADY_PROTECTED | The main line already has a real protection line. Refresh the cart, hide the offer, and do not add another. To replace the plan, remove protection first and use a current plan confirmed by the customer. |
INVALID_ARGUMENT | selection is missing, or the plan does not belong to that main line. Use the original option |
CART_SNAPSHOT_INCONSISTENT | The relation is ambiguous or the protection quantity does not match the shared group. Run syncCart and inspect |
Note: plan names such as 1 Year are not unique across products. Never use the plan name to infer which main product line a protection line belongs to. Use the line IDs returned by getRelations() instead.