Headless configurator SDK
Use a published configurator with your own controls, price and cart, and mount the iJewel 3D view in your page.
Use a configurator that you published in iJewel Drive with your own controls, your own price and your own cart. iJewel keeps the rules of the configurator (which options go together, which ring can be made), and its 3D view stays available: you mount it in an element of your page, or you bind an iJewel viewer that is in an iframe.
"Headless" means that the SDK draws nothing by itself. You read the choices from a session, draw them as you want (selects, swatches, cards), send each pick to the session, and add the session's configuration to your cart. The SDK runs in the browser, and the same calls run on your server to check a cart line: see Cart and Server.
SDK version
This page describes the configurator SDK 0.1.0-dev.2, a preview whose API can still change. The newest version is in latest-version.txt. Pin the version in your page.
Before you start
You need three things from the Drive:
- A configurator folder that is published. Each publish makes a new revision; the SDK always opens the newest one.
- The folder's link access set to Anyone with the link. The SDK reads the folder without a sign-in, so a private folder gives
E_NOT_FOUND. - The Drive name and the folder id. Both are in the Link of the Embed dialog:
https://ijewel3d.com/drive/files/<folder id>/view. The Drive name isdrivefor the shared Drive (ijewel3d.com/drive/…), or the name of your business workspace (ijewel3d.com/<name>/…).
All 3 steps are done.
For prices from iJewel's price service, your shop domain must also be on the configurator's list of allowed origins (Allowed origins for prices). Your own price function does not need this.
Install
The SDK is one file with no dependencies, released at https://releases.ijewel3d.com/libs/configurator-sdk/<version>/. The newest version number is in https://releases.ijewel3d.com/libs/configurator-sdk/latest-version.txt. The examples below use 0.1.0-dev.2.
Script tag. The file configurator-sdk.iife.js sets the global window.IjewelConfigurator.
<script src="https://releases.ijewel3d.com/libs/configurator-sdk/0.1.0-dev.2/configurator-sdk.iife.js"></script>
<script>
const { open, mountViewer } = window.IjewelConfigurator
</script>ES module. The file configurator-sdk.mjs is an ES module. Import it from the release URL, or copy it into your build.
import { open, mountViewer } from 'https://releases.ijewel3d.com/libs/configurator-sdk/0.1.0-dev.2/configurator-sdk.mjs'npm. The release also has a package tarball, with TypeScript types:
npm install https://releases.ijewel3d.com/libs/web/ijewel3d-configurator-sdk-0.1.0-dev.2.tgzimport { open, mountViewer } from '@ijewel3d/configurator-sdk'The types are also at https://releases.ijewel3d.com/libs/configurator-sdk/<version>/types/api-runtime.d.ts.
Quickstart
This page opens a configurator, builds one select per choice, shows the price, mounts the 3D view and logs the cart line. Replace drive and <folder id> with your values.
<div id="ring-3d" style="width: 100%; max-width: 560px; aspect-ratio: 1"></div>
<div id="choices"></div>
<p id="price"></p>
<button id="add" type="button">Add to cart</button>
<script src="https://releases.ijewel3d.com/libs/configurator-sdk/0.1.0-dev.2/configurator-sdk.iife.js"></script>
<script>
(async () => {
const { open, mountViewer } = window.IjewelConfigurator
// 1. Open the published configurator: a session with the newest revision.
const session = await open({ drive: 'drive', id: '<folder id>' })
// 2. Build your controls from the session's axes (one axis = one choice, such as Shape or Metal).
function render() {
const box = document.getElementById('choices')
box.replaceChildren()
for (const axis of session.axes()) {
if (!axis.active) continue // not used for this ring
const select = document.createElement('select')
for (const option of axis.options) {
if (option.state === 'hidden') continue // not offered with this ring
const item = new Option(option.label, option.key, false, option.state === 'selected')
item.disabled = option.state === 'blocked' || option.state === 'no-file'
select.append(item)
}
// 3. Send each pick to the session.
select.onchange = async () => {
const result = await session.select(axis.key, select.value)
if (!result.applied) render() // refused: show the selection as it is
}
const label = document.createElement('label')
label.append(axis.label + ' ', select)
box.append(label)
}
}
// 4. Show the price of the configuration. `total` is in minor units (cents).
const money = answer => new Intl.NumberFormat(undefined, {
style: 'currency', currency: answer.currency, minimumFractionDigits: answer.minorUnits, maximumFractionDigits: answer.minorUnits,
}).format(answer.total / 10 ** answer.minorUnits)
async function showPrice() {
const line = document.getElementById('price')
if (!session.info().capabilities.includes('price')) return
const state = session.stateString()
try {
const answer = await session.price()
if (state !== session.stateString()) return // the shopper picked again meanwhile
line.textContent = 'total' in answer ? money(answer) : answer.onRequest ? 'Price on request' : ''
} catch (error) {
if (state === session.stateString()) line.textContent = `No price (${error.code})`
}
}
// A pick on your page, or in the 3D view, changes the selection.
session.on('selection:changed', () => { render(); showPrice() })
render()
showPrice()
// 5. Mount iJewel's 3D view in your element. It follows the session both ways.
mountViewer(document.getElementById('ring-3d'), session, { camera: 'fit' })
.catch(error => console.warn('3D view:', error.code, error.message))
// 6. Add to cart: the configuration is the cart line.
document.getElementById('add').onclick = async () => {
await session.price().catch(() => null) // the line carries the price of this configuration
const line = session.configuration()
console.log('Add to cart', line) // send it to your cart
}
})().catch(error => console.error('Configurator:', error.code, error.message))
</script>Serve the page over http:// or https://, not from a file:// path. On localhost the price stays empty with E_PRICE, because iJewel's price service answers only allowed origins (Allowed origins for prices). Use your own price function while you develop (Your own price).
The session
open() returns a session. The session holds one configuration and changes it one pick at a time, in call order.
Axes and options. session.axes() returns the choices in the author's order. Each axis has key, label, kind (geometry, material, …), ordered (its options go from small to large, like carat), active and options. An axis with active: false is not used for the current ring (for example, a side-stone metal when the ring has no side stones); its reason is the author's text, when there is one. Each option has key, label, state and, when the author set them, description, icon (a URL), swatch (a CSS color), tags and reason.
state | What it means | What to show |
|---|---|---|
selected | The option of the current configuration. | Selected. |
available | A pick changes only this axis. | A normal option. |
adjusts | A pick is possible, but other axes move so the ring can be made. | A normal option, with a hint such as "changes other choices". |
blocked | A rule of the author forbids it with the current configuration. | Disabled. reason says why, when the author wrote it. |
no-file | The author has no 3D model for it with the current configuration. | Disabled. |
hidden | The author hides it with the current configuration. | Do not show it. |
session.options(axisKey) returns the options of one axis, and session.selection() returns { axisKey: optionKey } for every visible axis.
Picks. session.select(axisKey, optionKey) returns a promise of the result:
const result = await session.select('carat', '2-00-ct')
// { applied: true, moved: [{ axis: 'head', from: 'halo', to: '4-prong' }], deactivated: [] }applied: falsewithrefused('blocked','hidden','no-file'): nothing changed.moved: the other axes that changed so that the ring can be made. Tell the shopper, for example "Head changed to 4-prong".deactivated: the axes that are now not used.select(axis, option, { mode: 'strict' })refuses anadjustsoption instead of moving other axes.
session.selectMany({ shape: 'oval', carat: '1-00-ct' }) applies several picks as one change. session.reset() goes back to the author's default configuration. An unknown axis or option rejects with E_UNKNOWN_AXIS or E_UNKNOWN_OPTION.
State string. session.stateString() returns a short text, such as v2.r1.shape-round.carat-2-00-ct.…, that opens the same configuration again: open({ …, state }) or session.applyState(state). Store it in a link, a saved design or an order.
Price. session.price() returns a promise of the price of the current configuration:
| Answer | Meaning |
|---|---|
{ total, currency, minorUnits, sku?, lines?, taxIncluded? } | total is an integer in minor units: { total: 349900, currency: 'USD', minorUnits: 2 } is 3,499.00 USD. |
{ onRequest: true, currency, minorUnits, sku? } | The author sells this configuration on request. |
{ sku } | The configurator has SKUs but no prices. |
Answers are kept for the configuration, so a second price() with no change asks nothing. A failed price from iJewel rejects with E_PRICE; an error of your own price function rejects as you threw it. A session with no price rejects with E_NO_PROVIDER; check session.info().capabilities.includes('price') first.
Recipes
Your own price
Pass a price function to open(). It gets the configuration and returns a price answer (above). The second argument has app, the iJewel price of the folder, so you can build on it; app is null when the folder has no price.
// Your own price, from your server.
const session = await open({
drive: 'drive',
id: '<folder id>',
price: async query => {
const res = await fetch('/api/ring-price', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: query.state, selection: query.selection }),
})
if (!res.ok) throw new Error(`price ${res.status}`)
const { cents } = await res.json()
return { total: cents, currency: 'EUR', minorUnits: 2 }
},
})// iJewel's price plus 10 %.
const session = await open({
drive: 'drive',
id: '<folder id>',
price: async (query, { app }) => {
if (!app) return { onRequest: true, currency: 'EUR', minorUnits: 2 } // the folder has no price
const answer = await app(query)
if (!Number.isSafeInteger(answer.total)) return answer // on request, or a SKU only
const { lines, ...rest } = answer // the lines no longer add up
return { ...rest, total: Math.round(answer.total * 1.1) }
},
})query has configurator, revision, state, selection (the visible axes), derived (axes the author derives), tags and specs. The session calls your function again after each pick. An answer that is not one of the three forms rejects with E_PRICE.
No price
const session = await open({ drive: 'drive', id: '<folder id>', price: false })
session.info().capabilities.includes('price') // false
session.configuration().price // undefinedA folder with no price set up in the Drive also opens with no price.
A transparent 3D background over your page
background: 'transparent' shows the ring with no ground plane, on what is behind the element. A CSS color, such as '#f6f3ee', shows the ring on that color.
<div class="hero" style="background: linear-gradient(#fff, #efe7da)">
<div id="ring-3d" style="width: 100%; height: 480px"></div>
</div>
<script>
IjewelConfigurator.mountViewer(document.getElementById('ring-3d'), session, {
background: 'transparent',
camera: 'fit',
autoRotate: true,
buttons: false,
})
</script>Give the element a size: the view fills it. Change the options later with view.update({ … }).
The 3D view in an iframe: attachViewer
When your page already embeds the iJewel viewer of the same folder (the iframe of the Drive's Embed dialog), bind it to your session. A pick on your page shows in the iframe, and a pick in the iframe comes back to your session.
<iframe id="viewer" src="<the iframe src from the Embed dialog>" width="100%" height="480" allow="fullscreen"></iframe>
<script>
const iframe = document.getElementById('viewer')
const detach = IjewelConfigurator.attachViewer(session, iframe, {
origin: new URL(iframe.src).origin, // messages go to this origin only
onError: error => console.warn('viewer:', error.code, error.message),
})
// detach() stops both directions.
</script>origin is the exact origin of the iframe's page, never '*'. The binding works only while the iframe shows the same configurator and revision as the session. Another folder gives onError with E_PARAMS. Another revision (the author published again after your page opened the session) gives E_REVISION: open the session again.
Listen to events
session.on(event, handler) returns a function that removes the handler.
| Event | Data | When |
|---|---|---|
selection:changed | { selection, moved, deactivated } | The configuration changed: a pick on your page, in the 3D view, reset() or applyState(). |
price:changed | the price answer | A new price is there for the current configuration. While a handler listens, the session asks for the price after each change. |
error | { code, message } | A price request failed, or a handler threw. |
validation:warning | { field, provided, corrected } | applyState() corrected a state of this revision. |
state:migrated | { from, to, changes } | applyState() moved a state of an older revision to the current one. |
cart:requested | the configuration | session.requestCart() priced the configuration and returns it. |
ready | session.info() | At once: the session is open. |
const off = session.on('price:changed', answer => console.log('price', answer))
session.on('error', ({ code, message }) => console.warn(code, message))
session.on('selection:changed', ({ moved }) => {
for (const m of moved) console.log(`${m.axis} changed from ${m.from} to ${m.to}`)
})
off() // stop listening to the priceRestore a saved state from a URL
Write the state string into the address after each change, and apply it when the page opens. Listen before you apply it, so you hear what was corrected.
const session = await open({ drive: 'drive', id: '<folder id>' })
session.on('state:migrated', ({ changes }) => console.log('the design moved to the newest revision', changes))
session.on('validation:warning', warning => console.log('corrected', warning))
const saved = new URL(location.href).searchParams.get('ring')
if (saved) {
try {
const result = await session.applyState(saved)
if (!result.applied) console.log('The saved design was corrected (the events above say how)')
} catch (error) {
console.warn('not a state of this configurator:', error.code) // E_STATE: keep the default ring
}
}
session.on('selection:changed', () => {
const url = new URL(location.href)
url.searchParams.set('ring', session.stateString())
history.replaceState(null, '', url)
})A state of an older revision opens in the current revision: a retired option goes to its replacement or to the default, and a ring that can no longer be made goes to the nearest one that can. open({ …, state: saved }) does the same in one call, but you cannot hear the events of that first apply.
The 3D view: mountViewer
mountViewer(container, session, options) loads iJewel's viewer into container and returns a promise of the mounted view. The viewer shows the folder's scene (environment, camera, materials) and follows the session both ways. Its own configurator panel stays off: your page draws the controls.
| Option | Default | What it does |
|---|---|---|
background | 'scene' | 'scene': the folder's background and ground. 'transparent', or a CSS color such as '#f6f3ee': the ring with no ground plane. |
camera | 'scene' | 'scene': the folder's camera. 'fit': the ring fills the view. |
autoRotate | false | true, or a speed (2 is about one turn in 30 s). |
controls | true | The shopper turns and zooms the ring. |
zoom | {} | { min, max } camera distances, or false: no zoom. |
buttons | true | The buttons over the ring: full screen, reset, rotate, Try-On. |
tryOn | true | The Try-On button. The folder's Try-On setup and your domain's licence still apply. |
loadingText | 'Loading the 3D view…' | The text under the loader; '' shows none. |
viewer, webgi | the versions this SDK release was tested with | A viewer or WebGI release, or the https URL of its bundle. A page that has its own keeps it. One version of each per page. |
viewerOptions | {} | Other options of the iJewel viewer, passed as they are, such as { disableInteractionPrompt: true }. |
onError | none | Hears viewer errors: E_SCENE (a file did not load), E_PARAMS or E_REVISION (the viewer shows another configurator or revision). |
The mounted view has:
update(options): changes the options above on the running view, exceptviewer,webgiandproject.attach(session): binds another session of the same configurator, for example afteropen()with another price.detach(): stops the binding; the view stays.destroy(): stops the binding and removes the view from the page.controller: the viewer's configurator, withon('load:complete', …)andgetImage()for a picture of the ring.viewer: the 3D viewer itself, for what the options do not cover.
const view = await mountViewer(document.getElementById('ring-3d'), session, { background: '#f6f3ee', camera: 'fit', tryOn: false })
view.update({ autoRotate: true })
const picture = await view.controller.getImage() // a Blob, for the cart or an emailThe view is optional. Without it, the session still gives the choices, the price and the cart line.
Error codes
Every error that the SDK gives is an Error with a code.
| Code | Where | What it means |
|---|---|---|
E_PARAMS | open, mountViewer, attachViewer, update | An argument is wrong; the message says which. From a viewer: it shows another configurator. |
E_NOT_FOUND | open, loadPublished | The Drive shows no public folder with that id: a wrong id or Drive name, or the folder is not shared with "Anyone with the link". |
E_NOT_PUBLISHED | open, loadPublished | The folder has no published configurator. Publish it in the Drive. |
E_UPSTREAM | open, loadPublished, mountViewer | The Drive did not answer within 10 s or answered with an error, a viewer script did not load, or the viewer did not start within 60 s. |
E_SCHEMA, E_READER_TOO_OLD | open, loadPublished | The configurator is in a format that this SDK version does not read. Use a newer SDK version. |
E_INTEGRITY | open, loadPublished | The published files do not match each other. Publish again. |
E_UNKNOWN_AXIS, E_UNKNOWN_OPTION | select, selectMany, options | No visible axis or option with that key. |
E_STATE | applyState, open({ state }) | The text is not a state string of this configurator. |
E_REVISION | viewer onError | The viewer shows another revision of the configurator. Open the session again. |
E_PRICE | price, error event | The price request failed: no answer in 10 s on each of two tries, an origin that is not allowed, a configuration that the price service cannot price, or an answer of the wrong form. |
E_NO_PROVIDER | price | The session has no price (price: false, or the folder has none). |
E_SEARCH_LIMIT | select, selectMany | The search for a ring that can be made took too long. Rare; pick again. |
E_SCENE | viewer onError | A 3D file did not load. |
try {
const session = await open({ drive: 'drive', id: '<folder id>' })
} catch (error) {
if (error.code === 'E_NOT_FOUND') showMessage('This configurator is not available.')
else throw error
}Limits
- The SDK file is about 17 KB with gzip (the release limit is 24 KB). It holds no price model and no private data of the author.
open()waits at most 10 s for the Drive, and a request to iJewel's price service at most 10 s. A price request with no answer in 10 s goes once more beforeprice()rejects.- The 3D view must start within 60 s. One viewer version and one WebGI version per page.
- A price answer has at most 64 lines. A SKU has at most 64 characters.
- iJewel's price service keeps the newest 20 revisions of a configurator. A page that holds an older revision gets
E_PRICE: open the session again. - iJewel's price service has a daily request limit. Above it,
price()rejects withE_PRICE. Browsers keep each price answer for 5 minutes, and the session asks again only when a choice that changes the price changes. - A state string is at most 2,048 characters for iJewel's price service.
Versioning
- Each release has its own folder,
libs/configurator-sdk/<version>/, and never changes. Pin a version in your page, and update it on purpose. - A version with
-dev.Nis a preview. Its API can change before the stable release. - The changes of each release are in
https://releases.ijewel3d.com/libs/configurator-sdk/CHANGELOG.md. - Each SDK release names the viewer and WebGI versions that it was tested with (
VIEWER_VERSION,WEBGI_VERSION).mountViewerloads those unless you setviewerandwebgi. - The cart line has
schema: "ijewel.configuration/2.0". A later2.xadds fields and keeps the meaning of the fields above. - State strings stay valid when the author publishes again: option keys never change their meaning, and an older state moves to the current revision (Restore a saved state from a URL).
API summary
| Call | Returns |
|---|---|
open({ drive, id, price, locale, state, fetch }) | A promise of the session. price: left out (iJewel's price), a function, or false. |
session.axes(), session.options(axis), session.selection() | The choices, their states, the current picks. |
session.select(axis, option, { mode }), selectMany(picks), reset() | A promise of the pick result. |
session.price() | A promise of the price answer. |
session.configuration() | The cart line. |
session.requestCart() | A promise of the cart line, priced; fires cart:requested. |
session.stateString(), session.applyState(state) | The state text; a promise of the pick result. |
session.info() | { configuratorId, revision, capabilities, … }. |
session.on(event, handler) | The function that removes the handler. |
mountViewer(container, session, options) | A promise of the mounted view. |
attachViewer(session, iframe, { origin, onError }) | detach(). |
loadPublished({ drive, id }) | A promise of the published folder, for verifyConfiguration and createSession. |
verifyConfiguration(folder, line) | A promise of { ok: true, configuration, migratedFrom? } or { ok: false, problems }. |