Material Purchase SDK Integration Guide
@atomm-developer/generator-material-purchase· current version 0.2.0A framework-agnostic material purchase popup: it fetches the material list matched to the current generator, lets the user pick items and variants, and hands back a Shopify checkout link in one click. Works in Vue / React / vanilla JS via
<script src>orimport.
Table of contents
- Overview
- Installation
- Quick start
- Core concepts
- API reference
- Action events: onActionEvent
- Framework examples
- Internationalization (i18n)
- Auth and environments
- Styling and DOM
- Upgrading from 0.1.x to 0.2.0
- FAQ
- Changelog
Overview
What it does: call init() once and open() when needed, and the SDK shows a material list on your page. The list is matched by the backend for the generator (or supplied by you). The user checks items, switches variants, adjusts quantities, clicks "Buy now", and the SDK creates a Shopify cart and hands you the checkout URL (opened in a new tab by default). The storefront (US / EU / JP …) is picked by IP, out-of-stock items offer a back-in-stock notification, and every interaction emits an analytics event.
Three popup shapes (decided at open() time; one instance can switch per call):
| Shape | How it's triggered | What it looks like | Good for |
|---|---|---|---|
Side drawer drawer | open() without an entry configured | Slides in from the right, 320px on desktop, full screen on mobile, with a scrim | You already have your own "Buy materials" button |
Card + persistent entry card | init({ entry }), then the user clicks the SDK-rendered entry button | A red "Get the Materials" pill stays on the page; clicking it grows a 330px card anchored next to it | A zero-maintenance persistent purchase entry |
Centered modal modal | open({ variant: 'modal' }) | 480×480 centered, "download success" header + credits banner + compact product rows | Recommending materials right after an export / download |
Two data sources (decided at init() time):
mode | Where products come from | What you provide |
|---|---|---|
default (default) | Matched by the backend for the generator: CMS-configured material packs → packs for the user's machines → an operations-configured global fallback pack | Just generatorId |
material | A product id list you specify | generatorId + materialIds |
Which one should I use?
| Your situation | Recommendation |
|---|---|
The generator runs inside the generator-workbench shell and you only want "recommend after export" | Nothing to integrate: the shell ships it, on by default. See Running inside generator-workbench |
| You have your own buy button on the page | default mode + drawer, see Minimal integration |
| You want a persistent entry in a page corner | default mode + entry card, see Scenario A |
| You own the export flow and want to recommend after success | open({ variant: 'modal' }), see Scenario B |
| The product list is decided by your business logic, not generator config | mode: 'material', see Scenario C |
Installation
Via CDN (UMD)
<script src="https://static-res.atomm.com/scripts/js/generator-sdk/generator-material-purchase/index.umd.js"></script>After loading, window.GeneratorMaterialPurchase is the SDK object. Suited to non-Vue/React projects or pages that don't want a build dependency.
Via npm (ESM)
# Internal registry: configure .npmrc → http://repository.makeblock.com/repository/npm-group/ first
pnpm add @atomm-developer/generator-material-purchaseimport GeneratorMaterialPurchase from '@atomm-developer/generator-material-purchase'
// or named
import { GeneratorMaterialPurchase } from '@atomm-developer/generator-material-purchase'The package ships ESM (index.es.js), UMD (index.umd.js), and a bundled type declaration index.d.ts.
Running inside generator-workbench (no integration needed)
If the generator runs inside the generator-workbench app shell, the "recommend materials after export" flow is built in and on by default: after a successful SVG download or Open in Studio, the shell fetches materials by sdk.getAppKey() and opens the modal shape, at most once per shell instance per session; the instruction PDF does not trigger it.
- To turn it off:
materialPurchaseEnabled: false - To test an unreleased SDK build: point
materialPurchaseScriptUrlat your own UMD URL - The shell's
atommProEnvis mapped to the SDKenvautomatically;atommProLocale: 'zh'maps tozh, everything else falls back toen
Full details in Generator Workbench · Material purchase modal after export. In that case you don't need the rest of this page, unless you also want a persistent entry outside the shell.
Quick start
Minimal integration
import GeneratorMaterialPurchase from '@atomm-developer/generator-material-purchase'
GeneratorMaterialPurchase.init({ generatorId: 'light_sign' })
GeneratorMaterialPurchase.preload() // optional: warm the cache so open() has no loading state
document.querySelector('#buyBtn')!.addEventListener('click', () => {
GeneratorMaterialPurchase.open()
})Three steps: init with the generator code, preload to warm up, open to show. env defaults to prod, locale to en, and the product list is matched by the backend for the generator.
Scenario A: persistent entry + card
GeneratorMaterialPurchase.init({
generatorId: 'light_sign',
entry: {
style: { top: '16px', right: '16px' }, // positioning properties only
onClick: () => {
// optional: return false to block opening, e.g. require sign-in first
},
},
})
GeneratorMaterialPurchase.preload()
// No open() call needed afterwards: clicking the entry opens the card, clicking again collapses itThe entry button is rendered by the SDK and stays on the page; the card grows from the button's position with a morph transition, and the two are never visible in the same frame. Clicking outside the card collapses it; for page elements that should not trigger a collapse (e.g. a template switcher), declare them via outsideClickIgnoreSelectors.
Scenario B: recommend after export (modal)
GeneratorMaterialPurchase.init({ generatorId: 'light_sign', locale: 'en' })
GeneratorMaterialPurchase.preload() // warm up before export so the popup shows instantly
async function onExportSuccess() {
await GeneratorMaterialPurchase.open({ variant: 'modal' })
}variant is an open() option, so even an instance with entry configured can open this shape on export success; both share the same product cache.
Scenario C: custom material list (material mode)
GeneratorMaterialPurchase.init({
generatorId: 'light_sign', // still used for checkout attribution and notify-me, required
mode: 'material',
materialIds: [889, 890, 891], // supplySkuId of the unified product card
})
GeneratorMaterialPurchase.open({
selectIds: [889], // pre-checked and pinned to top
quantities: { 889: 2 },
})The list is entirely defined by materialIds; the generator's pack configuration is not consulted. Switch lists at runtime with update({ materialIds }): the entry is not re-mounted and an open popup refreshes in place.
Core concepts
Popup shape: variant
The shape is decided at open():
open() // entry configured → card, otherwise drawer
open({ variant: 'modal' }) // centered modal, independent of entry
open({ variant: 'drawer' }) // force the drawer even with entry configuredAll three shapes share the same data source, variant switching, notify-me and checkout logic; only the layout differs:
Drawer drawer | Card card | Modal modal | |
|---|---|---|---|
| Size | 320px wide, full height on desktop; full screen ≤ 767px | 330px wide, height follows content, capped at viewport − 32px | 480 × 480 fixed, radius 12 |
| Scrim | Yes, click closes | None | Yes, click does not close (there's an explicit "Back to edit") |
| Store / country switcher | Shown | Shown | Hidden (a one-off recommendation shouldn't change shipping destination) |
| Quantity control | Shown when purchasable | Shown when purchasable | Shown only when purchasable and checked |
| Footer | Total + Buy | Total + Buy | Two states: an outlined "Back to edit" only when nothing is checked; total + Buy once something is |
| Header | Title | "Materials List" + subtitle | Green check + "download success" + credits banner (Learn more → creator program) |
| Materials Lab link | At the bottom of the list | Not shown | At the bottom of the list, pinned to the bottom when content is short |
| Ways to close | Scrim / × / close() | Click outside / collapse icon / click the entry again / close() | "Back to edit" / × / close() |
| Mobile | Full-screen adaptation | Fixed 330px | Fixed 480px, no small-screen adaptation |
Only the card shape depends on entry: open() without variant while an entry exists yields the card. The variant type only accepts 'drawer' | 'modal'; the card cannot be requested explicitly.
Data source: mode and the three-tier fallback
default mode: the backend matches by generator, falling through tiers until products are found:
- Packs configured for the generator: the material packs bound to this
generatorIdon the developer platform, all concatenated - Machine-matched packs: when none are configured and the user is signed in, packs matched to the user's frequently used machines (up to the first two), all items shown. Skipped for guests
- Global fallback pack: the fallback pack configured by operations on the efficacy platform; if that's unset too, the list is empty
So a generator with no materials configured can still show fallback materials; conversely, an empty list only means all three tiers came up empty, not an integration error.
Multi-pack merge rules: concatenate in configuration order, keep only the first occurrence of a product across packs, sink out-of-stock items to the bottom; a single unavailable pack (deleted / unpublished) simply contributes nothing and does not affect the others.
material mode: the list is entirely defined by materialIds via GET /community/v1/web/product/list?ids=…&store=…, with no dedupe or sinking. generatorId is still required for checkout attribution and notify-me.
Both modes return the same "unified product card", so field semantics are identical.
Material id semantics
selectIds / hideIds / quantities in open() and materialIds in init() all match on the unified product card's supplySkuId (the product's auto-increment id), in both modes.
Before 0.2.0 this was the
accessoryIdof theaccessory-packendpoint. After upgrading, the values change while the parameter shapes stay the same. Old values don't throw; they just fail to match silently. See Upgrading.
When rendering yourself or reading ProductDataType, item.accessoryId still exists and is already mapped to supplySkuId by the SDK; use it for matching directly.
Cache and lifecycle
The SDK is a singleton with one internal product cache; on a hit, open() renders immediately with no loading state.
| Operation | Cache | Entry widget | Popup (if open) |
|---|---|---|---|
Calling init() again | Invalidated only when generatorId / mode / materialIds change | Unmounted and re-mounted (flickers) | Unaffected |
update(patch) | Invalidated when the three fields above change; also when env / apiBaseUrl / platformBaseUrl / materialsLabUrl change (store is reset too) | Re-mounted only if entry.style actually changed | Refreshes silently in place when the data source changed, keeping the old list until new data arrives |
refresh() | Unconditionally invalidated and refetched | Untouched | Refreshes in place (brief loading) |
preload() | Fetched when empty or stale | Untouched | n/a |
| User switches store | Refetched and overwritten | Untouched | Refreshes |
| Sign-in state changes | Invalidated on next use (guests and signed-in users take different tiers) | Untouched | n/a |
destroy() | Cleared | Unmounted | Unmounted |
Recommended rhythm: call preload() right after init() (no need to await; failures only go to onError), then open() on user action for an instant show. Use update() for runtime list / environment changes; use refresh() only when config is unchanged but you want fresh stock / prices.
open() can be called repeatedly; each call resets UI state (checks, variants, scroll) but reuses init config and the product cache. selectIds / hideIds / quantities apply to that call only. Only one popup exists at a time.
What sign-in affects
The SDK doesn't manage sign-in; it only forwards the host page's existing uToken in request headers (see Auth and environments).
| Feature | Signed out | Signed in |
|---|---|---|
| Browse, switch variants, checkout | Available (Shopify guest cart) | Available |
Machine-matched packs in default mode (tier 2) | Skipped | Included |
| Back-in-stock notification | No request sent; onRequireLogin fires (toast if not configured) | Available |
Store resolution
The SDK ships 8 production Shopify storefronts (US / CA / EU / UK / FR / DE / JP / AU). On first open, the store is resolved in this order: localStorage.xtool_current_shop_name → IP geolocation endpoint → fallback US. Users can switch manually in the drawer / card header; switching refetches products (prices, stock and availability are per store).
When env is dev / test / test_us, the SDK switches to the single testxtool test storefront and never touches production stores.
API reference
Methods
interface GeneratorMaterialPurchaseApi {
init(options: PurchaseModalInitOptions): void
update(patch: Partial<PurchaseModalInitOptions>): void
refresh(): Promise<void>
preload(): Promise<void>
open(options?: OpenModalOptions): Promise<void>
close(): void
destroy(): void
}| Method | Description |
|---|---|
init(options) | Must be called first. Throws if generatorId is missing, or if mode: 'material' has no non-empty materialIds. Can be called again; the semantics are "replace the whole config", and entry is re-mounted. The first call logs the SDK version to the console once |
update(patch) | Merge a partial config. Doesn't re-mount the entry unless entry.style changed; invalidates the cache when data-source fields change and refreshes an open popup in place; resets the store when environment fields change. Function fields (onClick / callbacks) are not compared; re-init to replace them |
refresh() | Unconditionally invalidate the cache and refetch with the current config. Refreshes in place if open; otherwise only clears the cache for the next open() |
preload() | Warm-up: resolve the store + fetch products into the cache. Best-effort: network failures only go to onError without rejecting; calling before init throws |
open(options?) | Open the popup. Shows a loading state when not preloaded. The returned Promise resolves after the list has loaded; on failure it toasts and calls onError, and still resolves |
close() | Play the exit animation and unmount; in card shape the entry reappears after the popup has fully collapsed |
destroy() | Unmount popup and entry, clear cache and config. Requires init again before reuse |
init() options
PurchaseModalInitOptions; only generatorId is required. Grouped by purpose:
Basics
| Field | Type | Default | Description |
|---|---|---|---|
generatorId | string | required | Generator code, e.g. 'light_sign'. default mode uses it to look up pack configuration; all modes use it for checkout attribution and notify-me |
locale | string | 'en' | UI language. en / zh are bundled; other languages are fetched from the i18n platform CDN, missing strings fall back to English. See i18n |
zIndex | number | 9999 | Stacking level for the popup and entry button; raise it if the host has higher layers (global toasts etc.) |
Data source
| Field | Type | Default | Description |
|---|---|---|---|
mode | 'default' | 'material' | 'default' | default matches by generator via the three-tier fallback; material uses materialIds. See Data source |
materialIds | Array<number | string> | Required in mode: 'material': supplySkuId array of the unified product card. Numbers or numeric strings; an empty array counts as missing and throws |
Environment and domains
| Field | Type | Default | Description |
|---|---|---|---|
env | 'dev' | 'test' | 'test_us' | 'prod' | 'prod_cn' | 'prod' | Switches all three backend domains + the Shopify store config in one step. Mapping in Auth and environments |
apiBaseUrl | string | by env | Explicit override for the community domain (packs / products / subscriptions / distribution), takes precedence over env |
platformBaseUrl | string | by env | Explicit override for the platform domain (the generator's bound pack ids, the global fallback pack). Only needed when you also pass a custom apiBaseUrl; the two domains are independent and the SDK won't derive one from the other |
materialsLabUrl | string | content domain by env | Target of the "Can't find what you need?" link at the bottom of the list |
Shape and entry
| Field | Type | Default | Description |
|---|---|---|---|
entry | EntryOptions | no entry | When set, a persistent entry button is rendered and open() defaults to the card shape. entry.style accepts only top / right / bottom / left / zIndex (at least one side); other looks are controlled by the SDK. entry.onClick fires before opening; return false to block |
outsideClickIgnoreSelectors | string[] | [] | Exemptions for the card's "click outside to collapse". If the mousedown target or any ancestor (including the shadow DOM composedPath) matches a selector, the card stays open. For hosts that switch templates / materials while the card is open. Adjustable via update(); irrelevant for drawer / modal |
Behavior switches
| Field | Type | Default | Description |
|---|---|---|---|
enableReplenishNotify | boolean | true | Whether out-of-stock rows show a "Notify Me" button. Visibility is also gated by the backend's variant-level showNotifyMe |
messages | Partial<Record<Locale, Record<string, string>>> | Override or add strings, deep-merged. Keys may omit the sdk. prefix; the SDK adds it |
Callbacks
| Field | Signature | Description |
|---|---|---|
onCheckoutSuccess | (checkoutUrl: string) => void | Checkout link created. Defaults to window.open(url, '_blank'); to redirect the current tab, do location.href = url here |
onClose | () => void | Popup closed (any source) |
onRequireLogin | () => void | A signed-out user clicked "Notify Me"; launch your sign-in flow. Without it the SDK toasts "Please sign in first" |
onError | (err: unknown) => void | Product load / checkout / store switch / subscription errors, with the raw exception. The SDK already toasts; no extra UI feedback needed |
onActionEvent | (event: ActionEvent) => void | Unified interaction events, 20 types. See Action events |
Distribution tracking (a distribution trackId is requested before checkout; these fields are forwarded to it; all support update() hot-updates, effective on the next checkout)
| Field | Type | Default | Description |
|---|---|---|---|
pageSource | string | 'generator' | Call-source identifier |
relatedObjectId | number | string | 1 | Related object id |
relatedObjectTitle | string | same as generatorId | Related object title |
open() options
OpenModalOptions; all optional and scoped to this open call; omitting them next time restores defaults.
| Field | Type | Description |
|---|---|---|
variant | 'drawer' | 'modal' | Shape for this call. Omit to infer from entry (present → card, absent → drawer). See Popup shape |
selectIds | Array<string | number> | supplySkuIds to pre-check. When given: matched items are pinned to the top as a block (keeping their relative order), all matched purchasable items are checked, with no 3-item cap. When omitted or empty: only the first 3 purchasable items are checked (unpurchasable ones are skipped while counting) |
hideIds | Array<string | number> | supplySkuIds filtered out of the list entirely: not shown, not checked, not in checkout. Wins over selectIds / quantities on conflict |
quantities | Record<string | number, number | string> | Initial quantity per supplySkuId; unspecified items default to 1. Accepts numbers or numeric strings, non-integers round up, NaN / non-numeric / ≤ 0 fall back to 1. Independent of selectIds; unchecked items can have a preset quantity too |
// Check and pin 1001 and 1002, quantity 3 for 1001, hide 2001
GeneratorMaterialPurchase.open({
selectIds: [1001, 1002],
quantities: { 1001: 3 },
hideIds: [2001],
})Matching converts every id to a string, so number or string both work; an out-of-stock item is never checked even if it matches selectIds, so checkout isn't blocked.
Exported types and version
import GeneratorMaterialPurchase, {
SDK_VERSION,
type PurchaseModalInitOptions,
type OpenModalOptions,
type ActionEvent,
type ProductDataType,
} from '@atomm-developer/generator-material-purchase'
console.log(SDK_VERSION) // '0.2.0'Every type in src/types.ts is exported (export * from './types'). Under UMD the version is at window.GeneratorMaterialPurchase.SDK_VERSION; the first init() also logs [GeneratorMaterialPurchase] SDK version: x.y.z to the console (kept in production builds) so you can confirm which build the page loaded.
Action events: onActionEvent
Pass onActionEvent to init() and the SDK calls back with a structured object on every key interaction: ideal for analytics, data warehousing, or business hooks. One unified entry point scales better than adding a callback per action.
GeneratorMaterialPurchase.init({
generatorId: 'light_sign',
onActionEvent: (event) => {
// event is a discriminated union; switching on action narrows the type
switch (event.action) {
case 'open':
console.log('opened from', event.source)
break
case 'buy_click':
analytics.track('material_buy_click', { items: event.list })
break
case 'checkout_success':
analytics.track('material_checkout', { url: event.checkoutUrl, trackId: event.trackId })
break
}
},
})Common context (attached to every event automatically)
| Field | Description |
|---|---|
timestamp | Date.now() |
sdkVersion | Current SDK version |
generatorId | The value passed to init |
store | Current Shopify store name; empty string before initialization |
Event catalogue (20 types)
| action | When | Payload |
|---|---|---|
init | init() validated and ready | |
preload | preload() finished (success or failure) | |
refresh | refresh() finished | |
update | update(patch) finished | changedKeys: string[], the keys in the patch |
destroy | destroy() started (emitted before cleanup) | |
open | Popup mounted | source: 'api' | 'entry', selectIds?, hideIds? |
close | Popup closed | source: 'api' | 'overlay' | 'header_close' | 'outside_click' | 'entry_toggle' |
entry_click | Entry button clicked; fires before entry.onClick, even if the hook blocks opening | |
product_list_loaded | Product list loaded and rendered | count: number |
product_list_load_failed | Product list failed to load | message: string |
shop_menu_toggle | Store dropdown opened / closed | open: boolean |
shop_change | User switched store | from: string, to: string |
variant_change | Variant dropdown selection | productId, optionKey, value |
quantity_change | Quantity ± button | productId, from, to, delta: 1 | -1 |
item_check | Row checked / unchecked (real user action, not the initial default selection) | productId, checked: boolean |
notify_click | "Notify Me" button | productId, variantId?, state: 'require_login' | 'already_subscribed' | 'subscribing' | 'success' | 'failed' |
buy_click | "Buy now" clicked, before stock filtering and the checkout request; captures intent | list: ActionBuyItem[] (id / name / num / variantId / price) |
checkout_success | Shopify checkout URL created | checkoutUrl: string, trackId?: string |
checkout_failed | Checkout failed | reason: 'no_valid' | 'all_out_of_stock' | 'network' | 'unknown', message? |
error | Analytics-only error event, fired alongside onError | scope: 'open' | 'checkout' | 'shop_change' | 'notify' | 'preload' | 'refresh', message: string (redacted) |
productId in payloads is the material's supplySkuId (same semantics as the open() options).
Notes
- Throwing from the callback doesn't affect the SDK; it's wrapped in try/catch with a
console.warn onErrorcarries the raw exception (with stack) for debugging; theerrorevent carries only a redactedmessagefor analytics. Both can be used together- The five
close.sourcevalues:apihost calledclose();overlaydrawer scrim clicked;header_closethe × icon;outside_clickclicked outside the card;entry_toggleclicked the entry again in card shape
Framework examples
Vanilla JS
<script src="https://static-res.atomm.com/scripts/js/generator-sdk/generator-material-purchase/index.umd.js"></script>
<script>
GeneratorMaterialPurchase.init({
generatorId: 'light_sign',
locale: 'en',
onCheckoutSuccess: (url) => (location.href = url), // redirect instead of a new tab
})
GeneratorMaterialPurchase.preload()
document.querySelector('#buyBtn').onclick = () => GeneratorMaterialPurchase.open()
</script>Vue 3
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
import GeneratorMaterialPurchase from '@atomm-developer/generator-material-purchase'
onMounted(() => {
GeneratorMaterialPurchase.init({ generatorId: 'light_sign' })
GeneratorMaterialPurchase.preload()
})
onBeforeUnmount(() => GeneratorMaterialPurchase.destroy())
</script>
<template>
<button @click="GeneratorMaterialPurchase.open()">Buy materials</button>
</template>React
import { useEffect } from 'react'
import GeneratorMaterialPurchase from '@atomm-developer/generator-material-purchase'
export function BuyButton() {
useEffect(() => {
GeneratorMaterialPurchase.init({ generatorId: 'light_sign' })
GeneratorMaterialPurchase.preload()
return () => GeneratorMaterialPurchase.destroy()
}, [])
return <button onClick={() => GeneratorMaterialPurchase.open()}>Buy materials</button>
}Vue 2
<script>
import GeneratorMaterialPurchase from '@atomm-developer/generator-material-purchase'
export default {
mounted() {
GeneratorMaterialPurchase.init({ generatorId: 'light_sign' })
GeneratorMaterialPurchase.preload()
},
beforeDestroy() {
GeneratorMaterialPurchase.destroy()
},
methods: {
openModal() {
GeneratorMaterialPurchase.open()
},
},
}
</script>
<template>
<button @click="openModal">Buy materials</button>
</template>The SDK is a global singleton shared by all components; init / destroy once at the app level rather than per component.
Internationalization (i18n)
Sources and precedence
- Bundled: complete
en/zhsets, used as the first-frame fallback - i18n platform CDN: on
init()andupdate({ locale })the SDK fetches that language's live strings and overrides the bundled ones. So edit copy on the i18n platform; edits to the package JSON are overridden at runtime messages: host overrides, highest precedence
locale accepts en / zh / zh-CN / zh-TW / zh-HK / de / es / fr / it / ja / ko / ru / uk / sl / vi / id and more; anything else falls back to en. Languages other than en / zh have no bundled strings and show English until the CDN responds.
Overriding strings
GeneratorMaterialPurchase.init({
generatorId: 'light_sign',
locale: 'en',
messages: {
en: { buy_now: 'Checkout securely' }, // sdk. prefix optional
ja: { 'sdk.buy_now': '今すぐ購入' }, // prefixed also fine
},
})Switch language at runtime with update({ locale: 'zh' }); an open popup re-renders immediately.
String keys
All keys carry the sdk. prefix in practice; omitted below.
| Area | Keys |
|---|---|
| Drawer title / close | shopify_supplies_kit, close |
| Card shape | materials_list, matched_for_this_design, entry_get_materials (entry button), collapse |
| Modal shape | download_success_title, credits_tip, learn_more, select_materials_tip, back_to_edit |
| Store bar | current_country_notice (with {country}), store_us … store_jp, store_test, country_usa … country_japan |
| List | loading, no_items_in_cart, sold_out, cant_find_material (Materials Lab link) |
| Notify Me | notify_me, notify_subscribed, notify_subscribe_success, notify_subscribe_fail, notify_login_required |
| Footer / checkout | total, items_selected (with {count}), buy_now |
| Error toasts | failed_load_product_list, checkout_failed, selected_items_out_of_stock, no_valid_products_selected |
Auth and environments
Request headers
Every request to the atomm backend carries two headers, both read from storage the host page already has:
| Header | Source |
|---|---|
uToken | utoken in document.cookie, else localStorage.utoken |
lang | localStorage.LANG_KEY, else 'en' |
For cross-origin deployments make sure: the host has written utoken to a cookie or localStorage; the backend CORS allowlist includes your origin and permits the custom uToken / lang headers. On 401 / 403, check whether the token exists, has expired, or is stripped cross-origin.
env mapping
env selects three domains plus the Shopify store config at once:
env | Community domain (packs / products / subscriptions / distribution) | Platform domain (generator config / fallback pack) | Content domain (Materials Lab) | Shopify stores |
|---|---|---|---|---|
dev | xcs-api-dev.makeblock.com | api-dev.makeblock.com | www-dev.atomm.com | testxtool only |
test | xcs-api-test.makeblock.com | api-test.makeblock.com | xtool-community-test.makeblock.com | testxtool only |
test_us | xcs-api-test.xtool.com | api-test.xtool.com | www-test.atomm.com | testxtool only |
prod (default) | xcs-api.xtool.com | api.xtool.com | www.atomm.com | 8 production stores |
prod_cn | xcs-api.makextool.com | api.makextool.com | www.atomm.com.cn | 8 production stores |
apiBaseUrl / platformBaseUrl / materialsLabUrl explicitly override the respective domain and take precedence over env. The three are independent: overriding the community domain does not derive the platform domain, so a custom apiBaseUrl usually needs a matching platformBaseUrl, otherwise you get "packs fetched from the new environment, but which packs are configured looked up in the old one".
Styling and DOM
- Mounting:
init({ entry })appends an entry host todocument.body;open()appends a popup host, removed onclose()/destroy(). Both use Shadow DOM, so host CSS resets / Tailwind / global styles don't leak in and popup styles don't leak out - Stacking: popup and entry default to
z-index: 9999, adjustable viainit({ zIndex });entry.style.zIndexoverrides the entry alone - Sizes: drawer 320px × 100vh, full screen ≤ 767px; card 330px wide, height follows content up to viewport − 32px; modal 480 × 480 centered. Only the drawer adapts to mobile
- Motion: drawer slides in, card grows from the entry, modal scales and fades; all respect
prefers-reduced-motionandprefers-reduced-transparency - Globals: under UMD only
window.GeneratorMaterialPurchaseis added;bodystyles are untouched - Not customizable: no theme / style injection API; visuals are owned by design. Copy can be changed via i18n
Upgrading from 0.1.x to 0.2.0
The data source moved from generator-accessory-pack to the material-pack endpoints, and field semantics were unified to the "unified product card".
Must change
| Change | Details |
|---|---|
init({ accessoryType }) removed | A required option of the old endpoint; not needed by the new data source. TS callers get a type error, JS callers are silently ignored |
open() id options changed semantics | selectIds / hideIds / quantities values move from the old accessoryId to supplySkuId. Same shape, different values; old values don't throw, they just don't match |
New init({ platformBaseUrl }) | Only callers that pass a custom apiBaseUrl need to pass this too; env-driven callers are unaffected |
Behavior changes (no code change, but visible)
- List source is now a three-tier fallback: a generator with no materials configured may show machine-matched or global fallback packs. Tier 2 (machines) was a placeholder that always returned empty; it's now wired to the real endpoint
- Multi-pack merge: concatenate in config order, dedupe across packs, sink out-of-stock items; one broken pack no longer takes down the list
- Default selection: from "all purchasable items" to "the first 3 purchasable items". Unaffected when
selectIdsis passed - Prices use backend semantics: current price is
displayPrice(backend already prefers the promotion price, falling back to the regular price), the discount badge is the backenddiscount; the frontend no longer computes it. Promoted items show the promotion price - Notify Me visibility now follows the variant-level
showNotifyMe(backend combines "out of stock + listable"), so the button updates when switching variants - Card product row styling changed along with the drawer (no row divider, inset thumbnail, discount badge after the name)
- New Materials Lab link at the bottom of the list (drawer and modal, not card); the Notify Me icon changed from a bell to
+/✓
Internal fields (only if you render yourself or read ProductDataType)
ProductVariantType.displayPricechanged meaning: in 0.1.x it was frontend-formatted text; from 0.2.0 it's the backend numeric string. Formatted text moved to the newpriceText/compareText- Removed
configVariantId(unified todefaultVariant) anddisplayCompareAtPrice(renamedcompareText) - Added
promotionPrice/discount/showNotifyMe/sort variants[].optionarrives positionally (option1/2/3); the SDK translates it into option-name keys followingoptions[], so custom renderers see the translated form
Strings
Added items_selected / cant_find_material / credits_tip / learn_more / download_success_title / select_materials_tip / back_to_edit; removed discount_notice / download_success_tip.
Unchanged
mode: 'material' + materialIds, update / refresh / preload, entry and the card shape, onActionEvent, all callback signatures.
FAQ
"call init() before open()"
init() wasn't called first. Call it once during app mount (Vue onMounted / React useEffect); the same applies to preload / update / refresh.
I passed variant: 'modal' but got the drawer
The page loaded a pre-0.2.0 CDN build, which ignores variant. Check the [GeneratorMaterialPurchase] SDK version: console log; hard-refresh or wait for the CDN cache to expire.
The popup opens but the list is empty / keeps spinning
Check Network by mode:
default: platform domain/developer-platform/apps/<generatorId>returns 200 withmaterialPackIds; community domain/community/v1/web/material-package/<id>hasitems. If all three tiers are unconfigured the list is legitimately empty (not an error), see the next itemmaterial: community domain/community/v1/web/product/list?ids=…&store=…haslist- 401 / 403: check
uToken; CORS errors: ask the backend to allowlist your origin - An endless spinner usually means the endpoint is down; the SDK toasts "Failed to load product list" and calls
onError
The generator has no materials configured, why are products showing?
The three-tier fallback of default mode: with no generator packs configured it falls to the user's machine-matched packs or the operations-configured global fallback pack. This is expected; use mode: 'material' for precise control.
selectIds / hideIds / quantities have no effect
Check that you pass the unified product card's supplySkuId. 0.1.x accessoryId values don't match in 0.2.0 and don't throw. Log the productId of item_check events from onActionEvent after product_list_loaded; that's the correct id.
Why are only 3 items checked by default?
Since 0.2.0 only the first 3 purchasable items are pre-checked, so the total doesn't start out huge. To select all or specific ones, pass selectIds (every match is checked, no cap).
The Buy button is disabled
All checked items are unpurchasable (availableForSale === false or outOfStock === true). Switch variant or store and retry.
Redirect the current tab instead of opening a new one
GeneratorMaterialPurchase.init({
generatorId: '...',
onCheckoutSuccess: (url) => (location.href = url),
})Language switched but the popup didn't update
Use update({ locale: 'zh' }); an open popup re-renders immediately. Re-init works too but re-mounts the entry.
An item is out of stock but there's no "Notify Me" button
Both conditions must hold: init didn't pass enableReplenishNotify: false, and the backend variant has showNotifyMe === true (out of stock and listable). Unlisted, unsellable items only show "Sold out".
Clicking "Notify Me" does nothing / shows a sign-in toast
The subscription endpoint requires sign-in. When signed out the SDK doesn't send a request: it calls onRequireLogin if configured, otherwise toasts "Please sign in first". The button is disabled in "Subscribed" / "Subscribing" states, which is normal de-duplication.
After a refresh "Subscribed" reverts to "Notify Me"
Expected. The "Subscribed" state only prevents duplicate clicks within the session; the backend record is unaffected, and re-subscribing to the same variant doesn't send duplicate emails.
In card shape, clicking my own page elements collapses the card
The card collapses on outside clicks by default. Pass selectors for elements that shouldn't trigger a collapse via outsideClickIgnoreSelectors, e.g. ['.template-card', '[data-role="material-switch"]']; adjustable via update().
The price differs from the store product page
The SDK shows the backend displayPrice (the promotion price when there is one), the strikethrough is compareAtPrice shown only when higher than the current price, and the badge is the backend discount. Semantics are owned by the backend; the frontend doesn't compute them.
Can two popups be open at once?
No. The SDK is a singleton with one popup at a time; calling open() again rebuilds the content.
Debugging inside Shadow DOM is awkward
Chrome DevTools → Settings → Preferences → Elements → enable "Show user agent shadow DOM". Or call methods on window.GeneratorMaterialPurchase from the console.
Changelog
0.2.0
Breaking (see Upgrading)
- Data source moved from
generator-accessory-packto the material-pack endpoints;defaultmode is now a three-tier fallback: generator-configured packs → the user's machine-matched packs (GET /community/v1/web/material-package/list?store=&deviceCodes=, machines frommy/infomachineItems[].code, first two, skipped for guests) → global fallback pack - Removed
init({ accessoryType });selectIds/hideIds/quantitiesnow match onsupplySkuIdinstead ofaccessoryId; addedinit({ platformBaseUrl }) - Prices / discounts now use backend
displayPrice/discount; Notify Me visibility follows the variant-levelshowNotifyMe ProductVariantType.displayPricechanged meaning; addedpriceText/compareText/promotionPrice/discount/showNotifyMe/sort; removedconfigVariantId/displayCompareAtPrice
Added
- Third shape
open({ variant: 'modal' }): a centered 480×480 popup for post-export recommendation, with a green check + "download success" + credits banner (Learn more → creator program), compact product rows and a two-state footer (only "Back to edit" while nothing is checked). Scrim click doesn't close, no store switcher, quantity control only when purchasable and checked open()gainsvariant?: 'drawer' | 'modal'; omitting it keeps the previous behavior- New Materials Lab link at the bottom of the list (drawer, modal); override the URL with
init({ materialsLabUrl }) generator-workbenchships the post-export flow, on by default; see Generator Workbench
Behavior
- Default selection changed from "all purchasable" to "first 3 purchasable"; unaffected when
selectIdsis passed - Multi-pack merge: config order, cross-pack dedupe, out-of-stock sinks; one broken pack doesn't affect the rest
- A sign-in state change automatically invalidates the product cache (guests and signed-in users take different tiers)
- Variant dropdown width now follows content, at least the trigger width, capped at 200px, right-aligned near the viewport edge
- Notify Me icon changed from a bell to
+/✓; product row styling shared across shapes - Strings: added
items_selected/cant_find_material/credits_tip/learn_more/download_success_title/select_materials_tip/back_to_edit; removeddiscount_notice/download_success_tip
0.1.18
Bug fix
- Products with
availableForSale: false+outOfStock: falserendered as a "dead row": purchasability isisVariantPurchasable()(availableForSale === true && outOfStock !== true), but the explanatory UI (disabled state, sold-out badge, notify button) only looked atoutOfStock. With that combination the row had no greyed checkbox, no badge and no notify button, while clicks were silently swallowed byif (!isPurchasable) return. The renderer now uses a singleisUnavailable = !isVariantPurchasable(variant)predicate. Genuinely sold-out rows (outOfStock: true) behave exactly as before - Deliberately unchanged: "sold-out rows render as checked" still keys off
outOfStock; switching toisUnavailablewould show a phantom check on unavailable-but-in-stock rows that aren't incheckedUids. Needs a separate evaluation
Changelog entries for 0.1.14 – 0.1.17 are missing (shipped in code, never documented).
0.1.13
- New option
onActionEvent: a unified action-event callback oninit(), covering 20 interaction types with a TypeScript discriminated union (ActionEvent) - Common context auto-filled: every event carries
timestamp/sdkVersion/generatorId/store - Catalogue: lifecycle
init/preload/refresh/update/destroy/open/close/entry_click; product loadproduct_list_loaded/product_list_load_failed; storeshop_menu_toggle/shop_change; interactionsitem_check/variant_change/quantity_change;notify_click(5 states); checkoutbuy_click/checkout_success/checkout_failed;error - Callback exceptions don't affect the SDK;
buy_clickfires immediately on click;entry_clickfires beforeentry.onClick - Backward compatible: no side effects when omitted; coexists with
onError/onClose/onCheckoutSuccess
0.1.12
- New option
relatedObjectTitle, forwarded to the distribution trackId endpoint, falling back togeneratorId; supportsupdate(patch)
0.1.9
- New options
pageSource(default'generator') andrelatedObjectId(default1), forwarded to the distribution trackId endpoint, replacing hard-coded values; supportupdate(patch)
0.1.8
- Entry widget mode:
init()acceptsentry(fixed-positioningstyle+ optionalonClick). Renders a red "Get the Materials" pill and switchesopen()to a 330px card anchored to it. Card title "Materials List" + subtitle "Matched for this design", collapse icon - Morph transition: entry and card are never visible in the same frame
- New API
preload(): pre-resolve the store + fetch products into the cache soopen()is instant; best-effort - New API
update(patch): partial updates; entry not re-mounted unlessentry.stylechanged; data-source changes invalidate the cache and refresh in place;env/apiBaseUrlchanges reset the ShopifyClient - New API
refresh(): force cache invalidation and refetch - Fix: dropdown menus were clipped by the list's
overflow: autoand the card'stransformtrapped fixed positioning; menus now portal to a floating layer in the shadow root; list scroll closes open dropdowns - New strings
materials_list/matched_for_this_design/entry_get_materials/collapse
0.1.7
init()acceptsmode('default' | 'material') andmaterialIds:materialmode callsGET /community/v1/web/product/list?ids=..&store=..init()acceptsenv(dev/test/test_us/prod/prod_cn), precedenceapiBaseUrl>env> defaultprod; non-prod/prod_cnswitches to the singletestxtoolstore- UX: Apple-style easing,
prefers-reduced-motion/prefers-reduced-transparency, spinner on Buy Now loading, Apple-like scrollbars, dropdown option spacing - Fixes: checkbox pop animation re-firing on re-render; dropdown trigger press feedback being cut short
- API layer expands GET array params with repeated keys
0.1.6
open()acceptshideIds(filter items, highest precedence) andquantities(preset quantities; non-integers round up, invalid values fall back to 1); both scoped to the currentopen()
0.1.5
- Default variant binding: first render / variant fallback / default check all honor the API default variant id
- Default variant id field
defaultVariant→configVariantId, read with fallback (0.2.0 unified it back todefaultVariant)
0.1.4
- Back-in-stock notification (Notify Me): on by default,
enableReplenishNotify: falsedisables; out-of-stock rows show "Notify Me", switching to "Subscribed" with a toast on success - New
onRequireLogincallback; out-of-stock rows no longer show a disabled quantity control - New strings
notify_me/notify_subscribed/notify_subscribe_success/notify_subscribe_fail/notify_login_required
0.1.3
- Fixes: list scroll reset on variant change; re-
initwith a differentgeneratorIdnot refreshing - Mobile: full screen ≤ 767px, iOS momentum scrolling, larger close / quantity hit areas;
:activefeedback on all buttons
0.1.0 (initial release)
- Exposes
init / open / close / destroy, attaches globalGeneratorMaterialPurchase - Ships 8 production Shopify storefronts; right-side 320px drawer / full height / scrim
- Bundled en + zh strings; auto-injects
uToken+lang; Shadow DOM style isolation
Feedback
For bugs or feature requests, contact 邓时佳 (Deng Shijia) on Feishu.