Skip to content

Material Purchase SDK Integration Guide

@atomm-developer/generator-material-purchase · current version 0.2.0

A 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> or import.


Table of contents


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):

ShapeHow it's triggeredWhat it looks likeGood for
Side drawer draweropen() without an entry configuredSlides in from the right, 320px on desktop, full screen on mobile, with a scrimYou already have your own "Buy materials" button
Card + persistent entry cardinit({ entry }), then the user clicks the SDK-rendered entry buttonA red "Get the Materials" pill stays on the page; clicking it grows a 330px card anchored next to itA zero-maintenance persistent purchase entry
Centered modal modalopen({ variant: 'modal' })480×480 centered, "download success" header + credits banner + compact product rowsRecommending materials right after an export / download

Two data sources (decided at init() time):

modeWhere products come fromWhat 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 packJust generatorId
materialA product id list you specifygeneratorId + materialIds

Which one should I use?

Your situationRecommendation
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 pagedefault mode + drawer, see Minimal integration
You want a persistent entry in a page cornerdefault mode + entry card, see Scenario A
You own the export flow and want to recommend after successopen({ variant: 'modal' }), see Scenario B
The product list is decided by your business logic, not generator configmode: 'material', see Scenario C

Installation

Via CDN (UMD)

html
<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)

bash
# Internal registry: configure .npmrc → http://repository.makeblock.com/repository/npm-group/ first
pnpm add @atomm-developer/generator-material-purchase
ts
import 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 materialPurchaseScriptUrl at your own UMD URL
  • The shell's atommProEnv is mapped to the SDK env automatically; atommProLocale: 'zh' maps to zh, everything else falls back to en

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

ts
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

ts
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 it

The 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)

ts
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)

ts
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

The shape is decided at open():

ts
open()                       // entry configured → card, otherwise drawer
open({ variant: 'modal' })   // centered modal, independent of entry
open({ variant: 'drawer' })  // force the drawer even with entry configured

All three shapes share the same data source, variant switching, notify-me and checkout logic; only the layout differs:

Drawer drawerCard cardModal modal
Size320px wide, full height on desktop; full screen ≤ 767px330px wide, height follows content, capped at viewport − 32px480 × 480 fixed, radius 12
ScrimYes, click closesNoneYes, click does not close (there's an explicit "Back to edit")
Store / country switcherShownShownHidden (a one-off recommendation shouldn't change shipping destination)
Quantity controlShown when purchasableShown when purchasableShown only when purchasable and checked
FooterTotal + BuyTotal + BuyTwo states: an outlined "Back to edit" only when nothing is checked; total + Buy once something is
HeaderTitle"Materials List" + subtitleGreen check + "download success" + credits banner (Learn more → creator program)
Materials Lab linkAt the bottom of the listNot shownAt the bottom of the list, pinned to the bottom when content is short
Ways to closeScrim / × / close()Click outside / collapse icon / click the entry again / close()"Back to edit" / × / close()
MobileFull-screen adaptationFixed 330pxFixed 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:

  1. Packs configured for the generator: the material packs bound to this generatorId on the developer platform, all concatenated
  2. 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
  3. 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 accessoryId of the accessory-pack endpoint. 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.

OperationCacheEntry widgetPopup (if open)
Calling init() againInvalidated only when generatorId / mode / materialIds changeUnmounted 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 changedRefreshes silently in place when the data source changed, keeping the old list until new data arrives
refresh()Unconditionally invalidated and refetchedUntouchedRefreshes in place (brief loading)
preload()Fetched when empty or staleUntouchedn/a
User switches storeRefetched and overwrittenUntouchedRefreshes
Sign-in state changesInvalidated on next use (guests and signed-in users take different tiers)Untouchedn/a
destroy()ClearedUnmountedUnmounted

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).

FeatureSigned outSigned in
Browse, switch variants, checkoutAvailable (Shopify guest cart)Available
Machine-matched packs in default mode (tier 2)SkippedIncluded
Back-in-stock notificationNo 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

ts
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
}
MethodDescription
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

FieldTypeDefaultDescription
generatorIdstringrequiredGenerator code, e.g. 'light_sign'. default mode uses it to look up pack configuration; all modes use it for checkout attribution and notify-me
localestring'en'UI language. en / zh are bundled; other languages are fetched from the i18n platform CDN, missing strings fall back to English. See i18n
zIndexnumber9999Stacking level for the popup and entry button; raise it if the host has higher layers (global toasts etc.)

Data source

FieldTypeDefaultDescription
mode'default' | 'material''default'default matches by generator via the three-tier fallback; material uses materialIds. See Data source
materialIdsArray<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

FieldTypeDefaultDescription
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
apiBaseUrlstringby envExplicit override for the community domain (packs / products / subscriptions / distribution), takes precedence over env
platformBaseUrlstringby envExplicit 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
materialsLabUrlstringcontent domain by envTarget of the "Can't find what you need?" link at the bottom of the list

Shape and entry

FieldTypeDefaultDescription
entryEntryOptionsno entryWhen 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
outsideClickIgnoreSelectorsstring[][]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

FieldTypeDefaultDescription
enableReplenishNotifybooleantrueWhether out-of-stock rows show a "Notify Me" button. Visibility is also gated by the backend's variant-level showNotifyMe
messagesPartial<Record<Locale, Record<string, string>>>Override or add strings, deep-merged. Keys may omit the sdk. prefix; the SDK adds it

Callbacks

FieldSignatureDescription
onCheckoutSuccess(checkoutUrl: string) => voidCheckout link created. Defaults to window.open(url, '_blank'); to redirect the current tab, do location.href = url here
onClose() => voidPopup closed (any source)
onRequireLogin() => voidA signed-out user clicked "Notify Me"; launch your sign-in flow. Without it the SDK toasts "Please sign in first"
onError(err: unknown) => voidProduct load / checkout / store switch / subscription errors, with the raw exception. The SDK already toasts; no extra UI feedback needed
onActionEvent(event: ActionEvent) => voidUnified 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)

FieldTypeDefaultDescription
pageSourcestring'generator'Call-source identifier
relatedObjectIdnumber | string1Related object id
relatedObjectTitlestringsame as generatorIdRelated object title

open() options

OpenModalOptions; all optional and scoped to this open call; omitting them next time restores defaults.

FieldTypeDescription
variant'drawer' | 'modal'Shape for this call. Omit to infer from entry (present → card, absent → drawer). See Popup shape
selectIdsArray<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)
hideIdsArray<string | number>supplySkuIds filtered out of the list entirely: not shown, not checked, not in checkout. Wins over selectIds / quantities on conflict
quantitiesRecord<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
ts
// 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

ts
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.

ts
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)

FieldDescription
timestampDate.now()
sdkVersionCurrent SDK version
generatorIdThe value passed to init
storeCurrent Shopify store name; empty string before initialization

Event catalogue (20 types)

actionWhenPayload
initinit() validated and ready
preloadpreload() finished (success or failure)
refreshrefresh() finished
updateupdate(patch) finishedchangedKeys: string[], the keys in the patch
destroydestroy() started (emitted before cleanup)
openPopup mountedsource: 'api' | 'entry', selectIds?, hideIds?
closePopup closedsource: 'api' | 'overlay' | 'header_close' | 'outside_click' | 'entry_toggle'
entry_clickEntry button clicked; fires before entry.onClick, even if the hook blocks opening
product_list_loadedProduct list loaded and renderedcount: number
product_list_load_failedProduct list failed to loadmessage: string
shop_menu_toggleStore dropdown opened / closedopen: boolean
shop_changeUser switched storefrom: string, to: string
variant_changeVariant dropdown selectionproductId, optionKey, value
quantity_changeQuantity ± buttonproductId, from, to, delta: 1 | -1
item_checkRow checked / unchecked (real user action, not the initial default selection)productId, checked: boolean
notify_click"Notify Me" buttonproductId, variantId?, state: 'require_login' | 'already_subscribed' | 'subscribing' | 'success' | 'failed'
buy_click"Buy now" clicked, before stock filtering and the checkout request; captures intentlist: ActionBuyItem[] (id / name / num / variantId / price)
checkout_successShopify checkout URL createdcheckoutUrl: string, trackId?: string
checkout_failedCheckout failedreason: 'no_valid' | 'all_out_of_stock' | 'network' | 'unknown', message?
errorAnalytics-only error event, fired alongside onErrorscope: '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
  • onError carries the raw exception (with stack) for debugging; the error event carries only a redacted message for analytics. Both can be used together
  • The five close.source values: api host called close(); overlay drawer scrim clicked; header_close the × icon; outside_click clicked outside the card; entry_toggle clicked the entry again in card shape

Framework examples

Vanilla JS

html
<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

vue
<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

tsx
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

vue
<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

  1. Bundled: complete en / zh sets, used as the first-frame fallback
  2. i18n platform CDN: on init() and update({ 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
  3. 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

ts
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.

AreaKeys
Drawer title / closeshopify_supplies_kit, close
Card shapematerials_list, matched_for_this_design, entry_get_materials (entry button), collapse
Modal shapedownload_success_title, credits_tip, learn_more, select_materials_tip, back_to_edit
Store barcurrent_country_notice (with {country}), store_usstore_jp, store_test, country_usacountry_japan
Listloading, no_items_in_cart, sold_out, cant_find_material (Materials Lab link)
Notify Menotify_me, notify_subscribed, notify_subscribe_success, notify_subscribe_fail, notify_login_required
Footer / checkouttotal, items_selected (with {count}), buy_now
Error toastsfailed_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:

HeaderSource
uTokenutoken in document.cookie, else localStorage.utoken
langlocalStorage.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:

envCommunity domain (packs / products / subscriptions / distribution)Platform domain (generator config / fallback pack)Content domain (Materials Lab)Shopify stores
devxcs-api-dev.makeblock.comapi-dev.makeblock.comwww-dev.atomm.comtestxtool only
testxcs-api-test.makeblock.comapi-test.makeblock.comxtool-community-test.makeblock.comtestxtool only
test_usxcs-api-test.xtool.comapi-test.xtool.comwww-test.atomm.comtestxtool only
prod (default)xcs-api.xtool.comapi.xtool.comwww.atomm.com8 production stores
prod_cnxcs-api.makextool.comapi.makextool.comwww.atomm.com.cn8 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 to document.body; open() appends a popup host, removed on close() / 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 via init({ zIndex }); entry.style.zIndex overrides 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-motion and prefers-reduced-transparency
  • Globals: under UMD only window.GeneratorMaterialPurchase is added; body styles 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

ChangeDetails
init({ accessoryType }) removedA 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 semanticsselectIds / 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 selectIds is 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 backend discount; 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.displayPrice changed 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 new priceText / compareText
  • Removed configVariantId (unified to defaultVariant) and displayCompareAtPrice (renamed compareText)
  • Added promotionPrice / discount / showNotifyMe / sort
  • variants[].option arrives positionally (option1/2/3); the SDK translates it into option-name keys following options[], 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 with materialPackIds; community domain /community/v1/web/material-package/<id> has items. If all three tiers are unconfigured the list is legitimately empty (not an error), see the next item
  • material: community domain /community/v1/web/product/list?ids=…&store=… has list
  • 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

ts
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-pack to the material-pack endpoints; default mode 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 from my/info machineItems[].code, first two, skipped for guests) → global fallback pack
  • Removed init({ accessoryType }); selectIds / hideIds / quantities now match on supplySkuId instead of accessoryId; added init({ platformBaseUrl })
  • Prices / discounts now use backend displayPrice / discount; Notify Me visibility follows the variant-level showNotifyMe
  • ProductVariantType.displayPrice changed meaning; added priceText / compareText / promotionPrice / discount / showNotifyMe / sort; removed configVariantId / 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() gains variant?: '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-workbench ships the post-export flow, on by default; see Generator Workbench

Behavior

  • Default selection changed from "all purchasable" to "first 3 purchasable"; unaffected when selectIds is 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; removed discount_notice / download_success_tip

0.1.18

Bug fix

  • Products with availableForSale: false + outOfStock: false rendered as a "dead row": purchasability is isVariantPurchasable() (availableForSale === true && outOfStock !== true), but the explanatory UI (disabled state, sold-out badge, notify button) only looked at outOfStock. With that combination the row had no greyed checkbox, no badge and no notify button, while clicks were silently swallowed by if (!isPurchasable) return. The renderer now uses a single isUnavailable = !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 to isUnavailable would show a phantom check on unavailable-but-in-stock rows that aren't in checkedUids. 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 on init(), 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 load product_list_loaded / product_list_load_failed; store shop_menu_toggle / shop_change; interactions item_check / variant_change / quantity_change; notify_click (5 states); checkout buy_click / checkout_success / checkout_failed; error
  • Callback exceptions don't affect the SDK; buy_click fires immediately on click; entry_click fires before entry.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 to generatorId; supports update(patch)

0.1.9

  • New options pageSource (default 'generator') and relatedObjectId (default 1), forwarded to the distribution trackId endpoint, replacing hard-coded values; support update(patch)

0.1.8

  • Entry widget mode: init() accepts entry (fixed-positioning style + optional onClick). Renders a red "Get the Materials" pill and switches open() 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 so open() is instant; best-effort
  • New API update(patch): partial updates; entry not re-mounted unless entry.style changed; data-source changes invalidate the cache and refresh in place; env / apiBaseUrl changes reset the ShopifyClient
  • New API refresh(): force cache invalidation and refetch
  • Fix: dropdown menus were clipped by the list's overflow: auto and the card's transform trapped 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() accepts mode ('default' | 'material') and materialIds: material mode calls GET /community/v1/web/product/list?ids=..&store=..
  • init() accepts env (dev / test / test_us / prod / prod_cn), precedence apiBaseUrl > env > default prod; non-prod / prod_cn switches to the single testxtool store
  • 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() accepts hideIds (filter items, highest precedence) and quantities (preset quantities; non-integers round up, invalid values fall back to 1); both scoped to the current open()

0.1.5

  • Default variant binding: first render / variant fallback / default check all honor the API default variant id
  • Default variant id field defaultVariantconfigVariantId, read with fallback (0.2.0 unified it back to defaultVariant)

0.1.4

  • Back-in-stock notification (Notify Me): on by default, enableReplenishNotify: false disables; out-of-stock rows show "Notify Me", switching to "Subscribed" with a toast on success
  • New onRequireLogin callback; 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-init with a different generatorId not refreshing
  • Mobile: full screen ≤ 767px, iOS momentum scrolling, larger close / quantity hit areas; :active feedback on all buttons

0.1.0 (initial release)

  • Exposes init / open / close / destroy, attaches global GeneratorMaterialPurchase
  • 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.

MIT Licensed