Skip to content

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: false means your account manager (AM) disabled the cart add-protection feature.quotes is always [] and the query does not read the cart just to answer this.
  • enabled: true with quotes: [] means the feature is on but the current cart genuinely has no eligible main line. Do not conflate the two.
  • Every option is a complete selection you can pass to addProtection.quantity is the current quantity of that main line.
  • plan.properties carries the Plan ID and Reference used to link the lines, while mainLineAttributes contains only the _PlanID written 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.
  • PP is enabled for the store, and the active cart has been synchronized via init({ cartId }) or syncCart(cartId).
  • onCartChange updates your shared storefront cart state. See Keep the cart in sync.
  • Your storefront provides showProtectionError(error) to surface errors if adding protection fails.
  1. Load cart quotes: call getCartQuotes() when the cart component mounts, cart lines change, and SDK synchronization completes. Also call getRelations() if you offer a “Remove protection” action.
  2. Match by line ID: match each cart line using quote.mainLineId === line.id and render the button only when quote.options.length > 0. Never match by variant ID. The same variant can exist as multiple separate lines in the cart.
  3. 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 reuses addProtection after confirmation. Cancelling resolves { status: 'cancelled' } without writing. A line that already has real protection rejects with PP_ALREADY_PROTECTED and opens no modal.
    • Storefront-rendered list: call addProtection({ mainLineId: line.id, selection: quote.options[index] }). selection is required. Omitting it rejects with INVALID_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.
  4. 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, await syncCart and read the current main line ID from the latest Cart. Then getCartQuotes() 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 next syncCart recovers.
// 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 picked
const { 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 confirmed
if (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.

  1. Keep all cart views synchronized: subscribe both views to onXcottonCartChange from the shared module and unsubscribe on unmount. Storefront queries may preserve the same lines array identity, so shared cart state alone cannot notify components that quotes changed. When addProtection finishes writing the protection line, the SDK fires onCartChange, 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.

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()
// 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,
)
}
ActionExpected result
Add the same variant as two separate cart linesEach line displays its own button independently by CartLine.id
Click “Add protection” on the cart pageThe 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 planofferProtection still opens the modal and writes only after the customer confirms
Cancel the cart add-protection modalResolves { status: 'cancelled' }. No cart write and no PDP selection change
The main line already has real protectionofferProtection rejects PP_ALREADY_PROTECTED and opens no modal
Call addProtection without selectionRejects with INVALID_ARGUMENT. No cart write and no modal
Click “Remove protection” on a protected lineOnly 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 quotesThe main line reappears in getCartQuotes() and can be protected again
Retry after a failed writeReconciles against the actual protection quantity without duplicating protection
Disable the cart add-protection feature in the back officegetCartQuotes() returns { enabled: false, quotes: [] }
SymptomCause and fix
No cart line displays the buttonThe 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 protectionThe 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 buttongetCartQuotes() was called before syncCart finished
Reading quotes rejectsHandle 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 quotegetRelations() 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:

CodeMeaning
CART_NOT_BOUNDNo cart is bound yet. Get the cart and call syncCart(cartId) first
PP_CART_QUOTES_NOT_READYNot 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_FAILEDThe current cart generation failed to sync, so old quotes are unusable. Handle cause, then resync
PP_NO_OPTIONSThe add-protection feature is disabled, or the current quote has no options for that main line. No modal opens
CART_LINE_NOT_FOUNDThe target main line does not exist, or a protection line was passed. Refresh the cart and its line IDs
PP_ALREADY_PROTECTEDThe 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_ARGUMENTselection is missing, or the plan does not belong to that main line. Use the original option
CART_SNAPSHOT_INCONSISTENTThe 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.