@tiledev/sdk-redo
v0.2.0
Published
Redo coverage (free returns / package protection) for Tile apps on @tiledev/sdk-shopify: asks Redo for the cart's coverage, keeps exactly one Redo line when the shopper chooses cover, and keeps it right as the cart changes. Headless: no UI.
Readme
@tiledev/sdk-redo
Redo coverage (free returns / package protection) for Tile apps on
@tiledev/sdk-shopify. Redo sells its coverage as a product line the shopper adds
to the Shopify cart. This package asks Redo what that line costs for the cart, puts exactly one of it
in the cart when the shopper chooses cover, and keeps it right as the cart changes.
It is headless: no UI, no navigation, no shopper-facing words, no Live Layer. The app draws its own
tile from useRedoCoverage.
npm install @tiledev/sdk-redo@tiledev/sdk-shopify 0.15 or later and react are peers (the hook reads useCart and
useCustomer). react-native is an optional peer and never imported.
What it does
the cart's other lines change → ask Redo (POST …/coverage-products)
offer → status "offered": the app shows its tile with offer.price
not eligible → status "notEligible": a Redo line in the cart is taken out
failed → status "failed": the cart is left alone
shopper chooses cover → setCovered(true): one line of the offer's variant, attribute "true"
shopper chooses without → setCovered(false): no Redo line, attribute "false"The rules, each from Redo's own Hydrogen client (@redotech/redo-hydrogen 2.0.2) unless it says otherwise:
- Cover needs the shopper's consent. A cart starts uncovered, and the package never adds cover on
its own. Only
setCovered(true)does. - Ask again only when the shopper's lines change. The quote key (
quoteKeyFor) is each non-Redo line's id, variant, quantity, unit price and total, so adding or swapping the Redo line asks nothing new. An answer for a cart that has changed since is dropped. - Exactly one Redo line. Covering takes out every other Redo line (an older tier, a second line, a quantity above 1) and adds one of the offer's variant, quantity 1. Uncovering takes out every Redo line.
- The attribute says the choice.
cartAttributefrom Redo's answer (defaultredo_opted_in_from_cart) is set to"true"or"false", keeping every other cart attribute. - The cart follows Redo's answer. While covered, a new tier is swapped in (this package: Redo's
client only fixes the tier on its next
enable()). A cart that is no longer eligible, or that holds nothing but the Redo line, loses it (attribute"false"). - A failed answer touches nothing. A 400, a 500, no network, a timeout (10 s), or an answer we
can't read: eligibility is unknown, so the cart is left as it is and
onErroris told. - No store id, no Redo, unless the app gives a fixed offer. An empty
storeId(orenabled: false) turns the whole package off: no request, no line is Redo's, no write. WithfixedOffer(0.2.0), an app without a Team ID offers the same coverage for every cart instead (see below).
Setting it up in an app
1. The client, from the app's config
import { configureRedo, redoAwareGuard, redoCartExtension } from "@tiledev/sdk-redo";
export const redo = configureRedo({
storeId: appConfig.redo?.storeId ?? "", // the merchant's Redo Team ID; "" = off
enabled: appConfig.redo?.enabled ?? true,
productHandles: appConfig.redo?.productHandles ?? [], // the Redo product's handle(s): strongly recommended
onError: (error, { at }) => reportError(error, { feature: "redo", at }),
});Without a Team ID (0.2.0): Redo can't price each cart, but the app can offer one coverage for every
cart, read from the store. fixedOffer is asked when a cart first needs an offer and kept; null (no such
product yet) is notEligible and asked again next time; a throw is failed (onError at
quote.fixedOffer). It is ignored while storeId is set. K-Marie's, the Redo product's first tier, as its
earlier app charged:
export const redo = configureRedo({
storeId: "",
fixedOffer: async () => {
const page = await shopify.collections.products("redo-product-apptile", { first: 5 });
const product = page.nodes.find((each) => each.vendor === REDO_VENDOR);
const tier = product?.variants.find((variant) => variant.availableForSale);
if (!product || !tier) return null;
return { variantId: tier.id, productId: product.id, title: product.title, price: tier.price, imageUrl: null, cartAttributeKey: "" };
},
productHandles: ["free-unlimited-return-valid-in-us"],
});The trade-off is the price: one tier for every cart, where Redo's own quote charges more for a bigger order. Set the Team ID when the merchant has it, and Redo's quote takes over.
2. On ShopifyProvider
Build the extension list and the guard once (module constants, or one useMemo keyed on the config),
since the provider reads them on every cart change and write:
const CART_EXTENSIONS = [cartHoldExtension({ client: cartHold }), redoCartExtension(redo)];
const CART_GUARD = redoAwareGuard(cartHoldGuard, redo); // cartHoldGuard may be undefined
<ShopifyProvider cartExtensions={CART_EXTENSIONS} cartGuard={CART_GUARD} onEvent={onShopifyEvent}>redoCartExtension(redo): a Redo line is a service line on sdk-shopify's cart page (no quantity controls, no reservation clock).redoAwareGuard(guard, redo): the app's guard with the Redo line kept out of it. Without it, Cart Hold would claim stock for the Redo line and stamp it like any product. A Redo add or increase is approved as it is; the guard isn't told when a Redo line lands or leaves; everything else reaches the guard unchanged. Only the hooks the guard has are on the result, soredoAwareGuard(undefined, redo)is{}, the same as no guard.
3. Toasts and analytics for the Redo line
sdk-shopify announces every line write as an event, the Redo line's too: cart:add with the "Added to
cart" copy when cover goes on, cart:remove with "Removed from cart" when it comes off. The analytics
SDK turns them into add_to_cart / remove_from_cart. To keep them quiet:
function onShopifyEvent(event: ShopifyEvent) {
if (onlyRedoLinesChanged(event, redo)) return; // no toast (and decide whether analytics should hear it)
showToast(event);
}4. The cart screen's tile
const { status, offer, covered, busy, setCovered } = useRedoCoverage(redo);| Field | Meaning |
| --- | --- |
| status | off · checking · offered · notEligible · failed. Show the tile for offered, and keep it (with offer) while checking. |
| offer | { variantId, productId, title, price, imageUrl, cartAttributeKey }. While checking, the last offer (its price may be about to change). |
| covered | The cart has a Redo line. Read from the cart, never kept separately. |
| busy | A choice or a cart write is under way: disable the controls. |
| setCovered(next) | The shopper's choice. true when it landed. Covering waits for Redo's answer when one is on its way; a choice made meanwhile wins. |
Redo's guidance is a choice at checkout, not only a toggle: the shopper picks "checkout with
coverage" or "checkout without". The package supports both, since covered and setCovered are all
either needs:
// A choice at checkout (Redo's own buttons go to checkout whatever the answer)
<Button disabled={busy} onPress={async () => { await setCovered(true); openCheckout(); }}>…</Button>
<Button disabled={busy} onPress={async () => { await setCovered(false); openCheckout(); }}>…</Button>
// A toggle
{offer && (status === "offered" || status === "checking") && (
<CoverageTile price={offer.price} title={offer.title} on={covered} disabled={busy} onChange={setCovered} />
)}The hook can be mounted on more than one screen: every hook on one client shares one question per cart, its answer, and one queue of cart writes.
What the merchant needs
- A Redo Team ID (24 hex characters, e.g.
679d97672916716ed50b559f): thestoreId. Without one, only afixedOffer(one price for every cart). - The Redo product published to the app's sales channel. The Storefront API only sells what the
app's channel can see; an unpublished Redo variant makes the add fail (
onErroratsetCovered). - The Redo product's handle in the app's config (
productHandles), so a Redo line is known from the first render: as a service line on the cart page, and in a cart that holds nothing else. Without it, a Redo line is known only once a quote has named its product, which is not a cart change, so sdk-shopify's cart page may show it as a product until the cart next changes, and a cart left with only a Redo line from an earlier session keeps it.
What it does not do
- No UI, no copy, no Live Layer. The tile, its words and its placement are the app's.
- It doesn't hide the Redo product from catalogue feeds (collections, search, recommendations).
That needs an option in sdk-shopify; a follow-up. Meanwhile
REDO_SEARCH_EXCLUSION(-vendor:'re:do' -product_type:package_protection) can be added to a Storefront search query. - It doesn't ask again when the shopper signs in. The customer id goes with the next question (as in Redo's client, whose key is the lines alone).
- No web-only parts of Redo's client: its checkout-buttons HTML (
GET …/checkout-buttons-ui), its info modal, and theredo.conciergeAssistedattribute it copies from the browser's localStorage.
Where it differs from Redo's Hydrogen client, and why
| Redo's client | This package | Why |
| --- | --- | --- |
| Sends every line, its own included, and cart.cost.totalAmount | Leaves Redo lines out; priceTotal is the other lines' totals added exactly | The same key always sends the same cart; sdk-shopify's totalAmount may or may not count a gift card, and counts a Redo line already in the cart |
| Takes the Redo line out on the first answer after every page load | Keeps it | Cover chosen in an earlier session is still the shopper's choice; the toggle reads covered from the cart |
| Fixes the tier only on its next enable() (its checkout button) | Swaps it as soon as Redo answers | A toggle has no later button press to fix it on |
| A 200 that isn't JSON counts as not eligible (and takes the line out) | Failed: the cart is left alone | An answer we can't read says nothing about eligibility |
| An offer with no price above 0 hides its buttons (eligible is false) | Failed (quote.response): hidden, and the cart left alone | The same for the shopper; reported, since it means Redo's answer changed shape |
Surface
Everything ships from the package root (no subpath).
| Export | Notes |
| --- | --- |
| configureRedo(options) | The client: enabled, quote(cart, customer?), isRedoLine(line), isRedoVariant(variantId), reportError(error, at). |
| useRedoCoverage(redo) | { status, offer, covered, busy, setCovered }, inside ShopifyProvider. |
| redoCartExtension(redo) | For cartExtensions: { name: "redo", lineFacts }. |
| redoAwareGuard(guard, redo) | For cartGuard: the guard with Redo's line kept out. |
| onlyRedoLinesChanged(event, redo) | For onEvent: every line the event changed is Redo's. |
| quoteRequestFor(cart, redo, customer?) | Pure: the quote request body. |
| quoteKeyFor(cart, redo) | Pure: changes only when the non-Redo lines change; "" for none. |
| cartChangesFor(cart, redo, { covered, offer }) | Pure: { removeLineIds, addVariantId, attribute } for a choice. |
| coverageStateFor(…) · automaticCartChangesFor(…) | Pure: what the hook shows, and the writes it makes on its own. |
| REDO_VENDOR · REDO_SEARCH_EXCLUSION | "re:do" · "-vendor:'re:do' -product_type:package_protection". |
| REDO_DEFAULT_CART_ATTRIBUTE_KEY · REDO_DEFAULT_API_BASE_URL · REDO_DEFAULT_TIMEOUT_MS | "redo_opted_in_from_cart" · "https://api.getredo.com/v2.2" · 10000. |
| type RedoOptions, RedoClient, RedoOffer, RedoQuote, RedoCoverage, RedoCoverageStatus, RedoCartChanges, CartForRedo, … | |
interface RedoOptions {
storeId: string; // Redo Team ID; "" = off (unless fixedOffer)
fixedOffer?: () => Promise<RedoOffer | null>; // 0.2.0: without a storeId, one offer for every cart
enabled?: boolean; // default true; false = off whatever the storeId
apiBaseUrl?: string; // default "https://api.getredo.com/v2.2"
productHandles?: string[]; // the Redo product's handles, so its line is known before any quote
fetch?: typeof fetch; // for tests; called plainly, never as a method
timeoutMs?: number; // default 10000
onError?: (error: unknown, context: { at: string }) => void;
}The cart, extension and guard are typed by their shape, not by importing sdk-shopify's types, so
the package builds without forcing an sdk-shopify version; test/types.check.ts checks the shapes
against sdk-shopify's real Cart, CartExtension, CartLineGuard and ShopifyEvent.
Where onError is told (at)
| at | What failed |
| --- | --- |
| quote.fixedOffer | fixedOffer threw, or gave no variant, product or price above 0 (asked again next time). |
| quote.status | Redo answered an error status (the error carries status and body). |
| quote.network | Redo couldn't be reached (what fetch threw). |
| quote.timeout | No answer within timeoutMs; the request is aborted. |
| quote.response | A 200 we can't use: not JSON, no coverageProducts list, an offer with no variant, product or price above 0. |
| quote.request | A cart that couldn't be turned into a request. |
| setCovered | A cart write for the shopper's choice (Shopify refused it, the guard or the line limit refused the add, the attribute couldn't be written). |
| keepCoverageRight | A cart write the hook made on its own (a tier swap, a removal). |
Wire protocol
POST {apiBaseUrl}/stores/{storeId}/coverage-products, Content-Type: application/json, no auth.
{
"cart": {
"lineItems": [{
"id": "gid://shopify/CartLine/…", // Shopify gids, as Redo's client sends them
"originalPrice": { "amount": "29.99", "currency": "USD" }, // one unit
"priceTotal": { "amount": "59.98", "currency": "USD" }, // the line, after its own discounts
"product": { "id": "gid://shopify/Product/1" },
"variant": { "id": "gid://shopify/ProductVariant/1" },
"quantity": 2
}],
"priceTotal": { "amount": "59.98", "currency": "USD" } // the lines above, added
},
"customer": { "id": "", "country": "US" } // id "" when signed out
}A 200 answers { "coverageProducts": [{ "cartInfoToEnable": { "productId", "variantId", "cartAttribute",
"selectedVariant": { "price": { "amount", "currencyCode" }, "title", "image", "product" } } }] }.
Redo names the product and variant by number; the package turns them into gids
(gid://shopify/ProductVariant/<n>, gid://shopify/Product/<n>) and takes gids as they are. An empty
list means the cart is not eligible.
Tests
npm test builds, then runs plain node scripts (no network, no renderer):
test/cart.test.mjs(20): the request, the key, the cart changes for every choice, the event helper.test/client.test.mjs(26): off switches, the offer and its gids, the lines learned, not eligible, every failure (400, 500, 503, network, timeout, unreadable, unusable), an unboundfetch.test/provider-parts.test.mjs(11): the cart extension and the guard composition.test/coverage.test.mjs(12): the hook's decisions as plain functions.test/use-redo-coverage.test.mjs(17): the hook end to end through a small stand-in for React and for sdk-shopify'suseCart(asking, choosing, tier swaps, failures, out-of-order answers, quick choices, two screens).tsc -p test: the shapes against sdk-shopify's real types.
