captain-shipping-protection-react
v0.1.1
Published
React components and hooks for adding Captain Shipping Protection to a Shopify headless storefront.
Downloads
316
Readme
Captain Shipping Protection React
React components and hooks for adding Captain Shipping Protection to a Shopify headless storefront.
The package is designed for cart pages built with Hydrogen, Next.js, Remix, or a client-side React application. It calculates the correct protection variant for the current cart, renders the merchant-configured widget, and exposes the state needed to add protection immediately before checkout.
What this package does
- Loads the Captain Shipping Protection configuration for a Shopify store.
- Calculates the current protection price and Shopify variant.
- Applies the merchant's text, icon, colors, layout, and default opt-in setting.
- Hides the widget when the cart is empty or excluded.
- Detects and helps remove a protection line that is already in the cart.
- Keeps the shopper's opt-in intent without mutating the cart on every toggle.
- Recalculates the latest protection variant immediately before checkout.
- Includes TypeScript declarations.
Your storefront remains responsible for reading and updating the Shopify cart. This package does not call Shopify's Storefront Cart API on your behalf.
Requirements
- React 18 or later.
- A Captain Shipping Protection configuration for the target Shopify store.
- A browser with
fetchand Web Crypto support. - HTTPS in production. Web Crypto is also available on
localhostduring local development. - Cart prices passed to the SDK must be integer minor units. For USD,
1299means$12.99.
The package is ESM-only.
Installation
Install both the React package and its SDK peer dependency:
npm install captain-shipping-protection-react captain-shipping-protection-sdkWith pnpm:
pnpm add captain-shipping-protection-react captain-shipping-protection-sdkWith Yarn:
yarn add captain-shipping-protection-react captain-shipping-protection-sdkRecommended integration
Use ShippingProtectionWidget for the UI and
useShippingProtectionController for state and SDK communication.
The intended cart flow is:
- Convert the current Shopify cart to
SdkCartData. - Render the widget with
useShippingProtectionController. - Let the widget toggle update the shopper's intent only.
- If an old protection line is detected, remove it through the
removeProtectioncallback and refresh the host cart. - When the shopper clicks checkout, call
prepareCheckout(). - If it returns protection info, add that variant to Shopify.
- Redirect immediately to the checkout URL returned by the updated cart.
Why protection is added at checkout
The protection variant depends on the latest eligible cart total. Adding it only at checkout avoids repeatedly adding, removing, or replacing a Shopify cart line while the shopper edits quantities or products.
The widget toggle therefore does not add or remove a cart line. It records whether the shopper wants protection.
Step 1: Convert your Shopify cart
The controller accepts SdkCartData. Shopify's Storefront API normally returns
GraphQL GIDs and decimal money strings, so convert IDs to numbers and prices to
minor units.
import type {SdkCartData} from "captain-shipping-protection-react";
type StorefrontCart = {
id: string;
totalQuantity: number;
cost: {
subtotalAmount: {
amount: string;
currencyCode: string;
};
};
lines: {
nodes: Array<{
id: string;
quantity: number;
cost: {
totalAmount: {
amount: string;
};
};
merchandise: {
id: string;
sku?: string | null;
title: string;
product: {
id: string;
title: string;
};
};
}>;
};
};
function numericShopifyId(gid: string): number {
const value = Number(gid.split("/").pop());
if (!Number.isFinite(value)) {
throw new Error(`Invalid Shopify GID: ${gid}`);
}
return value;
}
function toMinorUnits(amount: string): number {
return Math.round(Number(amount) * 100);
}
export function toSdkCartData(cart: StorefrontCart): SdkCartData {
return {
token: cart.id,
currency: cart.cost.subtotalAmount.currencyCode,
total_price: toMinorUnits(cart.cost.subtotalAmount.amount),
item_count: cart.totalQuantity,
items: cart.lines.nodes.map((line) => ({
id: numericShopifyId(line.merchandise.id),
key: line.id,
quantity: line.quantity,
variant_id: numericShopifyId(line.merchandise.id),
product_id: numericShopifyId(line.merchandise.product.id),
final_line_price: toMinorUnits(line.cost.totalAmount.amount),
title: line.merchandise.title,
product_title: line.merchandise.product.title,
sku: line.merchandise.sku ?? "",
})),
};
}Use the cart subtotal before shipping and tax. Include discounts in
final_line_price and total_price when those discounts affect the amount the
shopper will pay.
Create a new SdkCartData object whenever the host cart changes. The controller
uses cart changes to recalculate the current protection information.
Step 2: Render the widget
The following example keeps Shopify mutations behind host-provided adapter functions. Replace those adapters with your Storefront API, Hydrogen cart handler, or commerce backend implementation.
import {
ShippingProtectionWidget,
useShippingProtectionController,
type SdkCartData,
} from "captain-shipping-protection-react";
interface CartProtectionProps {
cart: SdkCartData;
currency: string;
findProtectionLineIds: (productId: string) => string[];
removeCartLines: (lineIds: string[]) => Promise<void>;
refreshCart: () => Promise<void>;
addProtectionAndGetCheckoutUrl: (
variantGid: string,
) => Promise<string>;
getCheckoutUrl: () => Promise<string>;
}
function toVariantGid(variantId: string): string {
return variantId.startsWith("gid://shopify/ProductVariant/")
? variantId
: `gid://shopify/ProductVariant/${variantId}`;
}
export function CartProtection({
cart,
currency,
findProtectionLineIds,
removeCartLines,
refreshCart,
addProtectionAndGetCheckoutUrl,
getCheckoutUrl,
}: CartProtectionProps) {
const protection = useShippingProtectionController({
shop: "example.myshopify.com",
country: "US",
locale: "en",
currency,
cart,
// Called when the cart already contains Captain's protection product.
removeProtection: async (info) => {
const lineIds = findProtectionLineIds(info.productId);
if (lineIds.length > 0) {
await removeCartLines(lineIds);
await refreshCart();
}
},
// Optional analytics or application-state notification.
onToggle: async (checked, info) => {
console.log("Protection intent changed", {
checked,
variantId: info.variantsId,
});
},
// Optional notification when price, variant, or eligibility changes.
onInfoChange: (info) => {
console.log("Latest protection info", info);
},
});
async function handleCheckout() {
const latestInfo = await protection.prepareCheckout();
const checkoutUrl = latestInfo
? await addProtectionAndGetCheckoutUrl(
toVariantGid(latestInfo.variantsId),
)
: await getCheckoutUrl();
window.location.assign(checkoutUrl);
}
return (
<>
{protection.visible && protection.info ? (
<ShippingProtectionWidget
checked={protection.checked}
currency={currency}
disabled={
protection.togglePending ||
protection.removePending
}
info={protection.info}
onToggle={protection.toggle}
setting={protection.setting}
/>
) : null}
{protection.error ? (
<p role="alert">
Shipping Protection is temporarily unavailable.
</p>
) : null}
{protection.removeError ? (
<div role="alert">
<p>Could not remove the existing protection line.</p>
<button type="button" onClick={() => void protection.refresh()}>
Retry
</button>
</div>
) : null}
<button
type="button"
disabled={
protection.initPending ||
protection.infoPending ||
protection.removePending
}
onClick={() => void handleCheckout()}
>
Checkout
</button>
</>
);
}Important checkout behavior
After adding the variant returned by prepareCheckout(), redirect to checkout
using the URL returned by that same cart mutation.
Do not intentionally publish the newly added protection line back to a cart page and remain there. The controller removes existing protection lines from cart pages so that the next checkout attempt can recalculate the correct variant.
If prepareCheckout() returns null, continue checkout without adding a
protection line. This is expected when:
- The shopper turned protection off.
- The cart is empty.
- The cart contains only the protection product.
- The cart or shopper is excluded by the merchant's rules.
- An existing protection line could not be safely cleaned up.
Step 3: Implement Shopify cart adapters
Your host application must provide three operations:
- Remove existing protection: Find lines whose Shopify product ID matches
info.productId, remove those line IDs, and refresh the cart passed to the controller. - Add protection for checkout: Add one unit of
info.variantsIdand obtain the resulting cart's checkout URL. - Continue without protection: Obtain the current cart's checkout URL without adding a line.
When using Shopify Storefront GraphQL:
- Use
cartLinesRemoveforremoveProtection. - Use
cartLinesAddfor the result ofprepareCheckout(). - Request
cart.checkoutUrlfrom the mutation response. - Use a quantity of
1for the protection variant. - Match existing protection by product ID, not only by variant ID. The correct variant may change when the cart total changes.
Example add mutation:
mutation AddShippingProtection(
$cartId: ID!
$lines: [CartLineInput!]!
) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
checkoutUrl
}
userErrors {
field
message
}
}
}Example variables:
{
"cartId": "gid://shopify/Cart/your-cart-id",
"lines": [
{
"merchandiseId": "gid://shopify/ProductVariant/123456789",
"quantity": 1
}
]
}Always inspect Shopify userErrors before redirecting.
SSR frameworks
The controller starts SDK requests from React effects, so it does not fetch Captain settings during the server render. The initial server output contains no widget, and the widget appears after the client initializes the controller.
Next.js App Router
Place the integration in a Client Component:
"use client";
import {
ShippingProtectionWidget,
useShippingProtectionController,
} from "captain-shipping-protection-react";
export function ClientCartProtection(props: CartProtectionProps) {
// Use the same controller and widget flow shown above.
}Pass serializable cart data from the Server Component to the Client Component.
Do not call prepareCheckout() from a Server Component.
Hydrogen, Remix, and React Router
The package can be imported normally in a component that hydrates in the
browser. Build SdkCartData from loader data, then perform Storefront cart
mutations through your action, fetcher, or cart handler.
No dynamic import is required by the package itself.
Client-side React applications
Vite, Create React App, and other browser-rendered React applications can import the package directly:
import {
ShippingProtectionWidget,
useShippingProtectionController,
} from "captain-shipping-protection-react";Content Security Policy
The SDK sends configuration and eligibility requests to:
https://insurance.captaintop.comIf your storefront has a Content Security Policy, add that origin to
connect-src:
connect-src 'self' https://insurance.captaintop.com;The merchant may configure an external protection icon. Add the actual icon
origin to img-src if your CSP restricts images.
The React package does not require an external script, iframe, or font origin.
Do not add broad CSP allowances such as * or 'unsafe-inline' solely for this
package.
API reference
useShippingProtectionController(params)
This is the recommended high-level integration hook.
Required parameters:
shop: Shopify domain, for exampleexample.myshopify.com.country: shopper country code used for eligibility, for exampleUS.locale: storefront locale, for exampleen.currency: active ISO currency code, for exampleUSD.cart: current cart inSdkCartDataformat.
Optional callbacks:
removeProtection(info): removes existing protection lines from the host cart. The controller calls it whenincludedProtectionistrue.onToggle(checked, info): runs after the shopper changes the widget intent. It may be asynchronous. If it rejects, the controller restores the previous checked state and leaves the stored preference unchanged.onInfoChange(info): runs when the resolved product, variant, price, inclusion state, or exclusion state changes.
Checked-state persistence:
- The controller stores the shopper's last successful choice in
localStorage, using a key isolated byshop. - A cart that already contains protection takes priority, is treated as checked, and stores that checked preference before the old line is removed.
- For an eligible cart without protection, the stored preference takes
priority over
tm_default_display_status. - Excluded or empty carts hide and temporarily uncheck the widget without deleting the stored preference. The preference is restored when the cart becomes eligible again.
- If browser storage is unavailable, the controller safely falls back to
tm_default_display_status.
Returned state:
checked: the shopper's current protection intent.visible: whether the standard widget should be rendered.info: latestShippingProtectionInfo, ornullbefore resolution.setting: merchant widget configuration, ornullbefore initialization.excludedReason: reason the cart is not eligible.error: SDK initialization or information request error.initPending: configuration initialization is running.infoPending: cart information is being resolved.togglePending: an asynchronousonTogglecallback is running.removePending:removeProtectionis running.removeError: latestremoveProtectionerror.
Returned actions:
toggle(checked, info?): updates the shopper's intent. Pass this directly toShippingProtectionWidget.refresh(): resolves protection information again for the current cart. Use it to retry after a cleanup error.prepareCheckout(): resolves the latest cart price and variant. It returnsShippingProtectionInfowhen a protection line should be added, otherwisenull.
ShippingProtectionWidget
Pure presentation component. It does not initialize the SDK and does not mutate the Shopify cart.
Props:
checked: current opt-in state.currency: ISO currency code used to format the displayed price.info: latestShippingProtectionInfo.setting: merchant configuration returned by the controller.onToggle(checked, info): called when the shopper uses the switch or checkbox.disabled: disables interaction while the host is busy.className: optional class added to the widget root.
The component injects scoped styles using the csp- class namespace and applies
a local element reset to reduce interference from storefront CSS.
ShippingProtection
Convenience component that combines the controller and standard widget:
import {ShippingProtection} from "captain-shipping-protection-react";
<ShippingProtection
shop="example.myshopify.com"
country="US"
locale="en"
currency="USD"
cart={sdkCart}
removeProtection={removeProtectionLine}
onInfoChange={(info) => console.log(info)}
onToggle={(checked) => console.log(checked)}
/>;Use this component when you only need the standard UI and callbacks. For a
complete headless checkout integration, prefer
useShippingProtectionController plus ShippingProtectionWidget, because the
host must call prepareCheckout() immediately before checkout.
useShippingProtection
Low-level hook that exposes init() and getInfo(cart). Most storefronts
should use useShippingProtectionController instead, because the controller
manages initialization, checked intent, pending state, stale requests, cleanup,
and checkout preparation.
Data types
ShippingProtectionInfo
interface ShippingProtectionInfo {
productId: string;
variantsId: string;
price: string;
includedProtection: boolean;
isExcluded: boolean;
excludedReason?: string;
}productId: Captain protection Shopify product ID.variantsId: protection variant selected for the current eligible total.price: protection price in the currency's major unit.includedProtection: whether the input cart already contains the Captain protection product.isExcluded: whether protection must not be offered for this cart.excludedReason: machine-readable exclusion reason.
Common exclusion reasons
empty_cart: the cart contains no lines.invalid_cart: at least one line does not have a valid product ID.only_shipping_protection: no non-protection product remains.excluded_variant: the cart contains a merchant-configured excluded variant.check_display_hidden: Captain's eligibility response hides the widget.
Treat exclusion reason values as extensible. Do not assume the list above is exhaustive.
Error handling
Recommended behavior:
- Hide or replace the widget when
erroris present. - Disable checkout while
removePendingistrue. - Show a retry action when
removeErroris present. - Let checkout continue without protection when the SDK is unavailable, unless your business requirements explicitly say otherwise.
- Log Shopify
userErrorsseparately from SDK errors.
onInfoChange consumer errors are isolated from SDK state. An onToggle
rejection rolls the checked state back.
Styling
Merchant-controlled styles and copy come from Captain's configuration. Use the
widget's className for host-page layout such as width or margin:
<ShippingProtectionWidget
{...widgetProps}
className="cart-shipping-protection"
/>.cart-shipping-protection {
margin-block: 16px;
width: 100%;
}Avoid targeting internal csp- classes unless you intentionally want to
override merchant configuration.
Integration checklist
Before deploying:
- Install both npm packages and confirm React 18 or later.
- Confirm
shopuses theexample.myshopify.comformat. - Pass the active shopper country, locale, and currency.
- Convert Shopify GIDs to numeric IDs in
SdkCartData. - Pass all prices as integer minor units.
- Update the controller cart whenever products, quantities, or discounts change.
- Implement
removeProtectionand refresh the host cart after removal. - Disable checkout while protection cleanup is pending.
- Call
prepareCheckout()immediately before adding protection. - Add exactly one unit of the returned variant.
- Redirect using the checkout URL from the updated cart.
- Add
https://insurance.captaintop.comto CSPconnect-srcwhen CSP is enabled. - Test checked, unchecked, excluded, empty-cart, cleanup-failure, and API-failure flows.
Troubleshooting
The widget does not appear
Check error, info, visible, and excludedReason. The widget is expected to
stay hidden for an empty or excluded cart. Also confirm that the store has an
active Captain Shipping Protection configuration.
Toggling the widget does not add a Shopify cart line
This is intentional. The toggle stores shopper intent only. Add the variant
returned by prepareCheckout() when checkout begins.
The protection line is removed after being added
The controller removes protection found on a cart page so it can calculate a fresh variant. Add protection and redirect immediately to the checkout URL. Avoid updating the visible cart page with the added line before redirecting.
The displayed price does not update
Pass a new SdkCartData object after every host cart change. Verify that
total_price and final_line_price use minor units.
Cleanup repeats or checkout creates duplicate protection
After removeProtection succeeds, refresh the host cart and pass the updated
cart without the protection product back to the controller. Match lines by
info.productId, not only by variant ID.
Requests are blocked by CSP
Allow https://insurance.captaintop.com in connect-src. If the configured icon
is blocked, allow its exact origin in img-src.
Updating
Check the installed version:
npm list captain-shipping-protection-reactUpdate both packages together:
npm install \
captain-shipping-protection-react@latest \
captain-shipping-protection-sdk@latest