Showcase
Each storefront below is built from the methods and events of Build your own designer, and from nothing else: the page draws its own form, options and buttons around the canvas, and looks as its shop does. Each is run as printed here, in a browser, against Pixonaut.
publishableKey and blankId are yours, and the template ids are your
templates’.
A product page with its own options
Section titled “A product page with its own options”A sign sold in two materials, single- or double-sided. The material decides the print size, and double-sided prints the back too, with a tab to design it. The text fields are the page’s own, drawn for the side shown:
<div id="canvas"></div><form id="options"> <fieldset> <legend>Material</legend> <label><input type="radio" name="material" value="0" checked> Poster paper, 24 × 30 cm</label> <label><input type="radio" name="material" value="1"> Foam board, 40 × 50 cm</label> </fieldset> <fieldset> <legend>Sides</legend> <label><input type="radio" name="sides" value="single" checked> Single-sided</label> <label><input type="radio" name="sides" value="double"> Double-sided</label> </fieldset> <div id="side-tabs" hidden> <button type="button" data-side="front">Front</button> <button type="button" data-side="back">Back</button> </div> <div id="fields"></div> <button id="approve" type="button">Approve</button> <p id="error" role="alert"></p></form><div id="proofs"></div>const designer = await Pixonaut.mount('#canvas', { publishableKey, blankId, templateId: 'sign-front@1', placementId: 'front', placements: [ { placementId: 'front' }, { placementId: 'back', templateId: 'sign-back@1', enabled: false }, ], // A size per material, in the order of the material options. printSizes: [ { widthMm: 240, heightMm: 300, label: '24 × 30 cm' }, { widthMm: 400, heightMm: 500, label: '40 × 50 cm' }, ], showForm: false, showViews: false,});const options = document.querySelector('#options');
// The material decides the print size.for (const material of options.elements.material) { material.addEventListener('change', () => designer.setPrintSize(Number(material.value)));}
// Double-sided prints the back too, and offers its tab.const tabs = document.querySelector('#side-tabs');for (const sides of options.elements.sides) { sides.addEventListener('change', async () => { const double = sides.value === 'double'; designer.setSideEnabled('back', double); tabs.hidden = !double; if (!double) await designer.setSide('front'); });}for (const tab of tabs.querySelectorAll('[data-side]')) { tab.addEventListener('click', () => designer.setSide(tab.dataset.side));}
// The text fields of the side shown, drawn again for the other side.const fields = document.querySelector('#fields');const drawFields = () => { const texts = designer .getFormSchema() .steps.flatMap((step) => step.fields) .filter((field) => field.type === 'text'); fields.replaceChildren( ...texts.map((field) => { const label = document.createElement('label'); const input = document.createElement('input'); input.name = field.layerId; input.value = designer.getValue(field.layerId, field.prop) ?? ''; input.addEventListener('input', () => designer.setValue(field.layerId, field.prop, input.value)); label.append(field.label, input); return label; }), );};designer.on('side', drawFields);drawFields();
// A proof of each side printed, or the side that cannot be.const error = document.querySelector('#error');document.querySelector('#approve').addEventListener('click', async () => { error.textContent = ''; try { const { sides } = await designer.approve(); document.querySelector('#proofs').replaceChildren( ...sides.map(({ placementId, proofUrl }) => { const proof = document.createElement('img'); proof.alt = `Proof of the ${placementId}`; proof.src = proofUrl; return proof; }), ); } catch (refusal) { error.textContent = `The ${refusal.placementId ?? 'design'} cannot be printed as it is.`; }});A storefront’s own look
Section titled “A storefront’s own look”The shop’s own colours, type and buttons, a box per text with what it says, its font, its colour and its size, and the canvas’s handles shown only to a customer who asks to adjust rotation and size:
<style> .boutique { font-family: Georgia, serif; background: #f6f1e9; color: #3d2b1f; padding: 24px; border-radius: 12px; } .boutique fieldset { border: 1px solid #c9b8a0; border-radius: 8px; margin: 12px 0; } .boutique button { background: #3d2b1f; color: #f6f1e9; border: 0; border-radius: 999px; padding: 8px 24px; }</style><div class="boutique"> <div id="canvas"></div> <label><input id="adjust" type="checkbox"> Adjust rotation and size</label> <div id="texts"></div> <button id="approve" type="button">Looks good</button> <p id="error" role="alert"></p> <img id="proof" alt="Your proof" hidden></div>const designer = await Pixonaut.mount('#canvas', { publishableKey, templateId: 'boutique-card@1', showForm: false, showViews: false,});const { steps } = designer.getFormSchema();
// Handles only behind the checkbox.const adjust = document.querySelector('#adjust');const layout = () => { for (const { layerId } of steps.flatMap((step) => step.transforms)) { designer.setInteractive(layerId, adjust.checked); }};adjust.addEventListener('change', layout);layout();
// One control per setting of a text: its words, font, colour or size.const control = (field) => { const set = (value) => designer.setValue(field.layerId, field.prop, value); if (field.prop === 'fontFamily') { const fonts = document.createElement('select'); fonts.append(...field.constraints.oneOf.map((font) => new Option(font, font))); fonts.value = field.default; fonts.addEventListener('change', () => set(fonts.value)); return fonts; } const input = document.createElement('input'); if (field.prop === 'fill') { input.type = 'color'; input.addEventListener('change', () => { const hex = input.value.slice(1); set({ rgb: [0, 2, 4].map((at) => Number.parseInt(hex.slice(at, at + 2), 16)) }); }); } else if (field.prop === 'fontSizeFrac') { const { min, max, step } = field.constraints; Object.assign(input, { type: 'range', min, max, step, value: field.default }); input.addEventListener('input', () => set(Number(input.value))); } else { input.value = field.default; input.addEventListener('input', () => set(input.value)); } return input;};
// A box per text, its settings inside.const fields = steps.flatMap((step) => step.fields);document.querySelector('#texts').replaceChildren( ...fields .filter((field) => field.prop === 'content') .map((text) => { const box = document.createElement('fieldset'); const legend = document.createElement('legend'); legend.textContent = text.label; box.append(legend); for (const field of fields.filter((f) => f.layerId === text.layerId)) { const label = document.createElement('label'); const input = control(field); input.name = `${field.layerId}.${field.prop}`; label.append(field.label, input); box.append(label); } return box; }),);
document.querySelector('#approve').addEventListener('click', async () => { const proof = document.querySelector('#proof'); const error = document.querySelector('#error'); error.textContent = ''; designer.setMode('preview'); try { proof.src = (await designer.approve()).proofUrl; proof.hidden = false; } catch { designer.setMode('edit'); error.textContent = 'This card cannot be printed as it is. Check its texts.'; }});A plain three-step page
Section titled “A plain three-step page”Nothing but the browser’s own controls, one step at a time: the customer’s photo with a progress bar and a word on its quality, a strip of templates drawn with that photo and the name typed, and a table of the texts, each with its colour, an outline and a shadow, a rotation and a zoom slider and a reset:
<div id="canvas"></div><section data-step="photo"> <h2>1. Your photo</h2> <input id="photo" type="file" accept="image/*"> <progress id="upload" max="1" value="0"></progress> <p id="quality"></p> <p id="error" role="alert"></p></section><section data-step="design" hidden> <h2>2. Choose a design</h2> <div id="templates"></div></section><section data-step="texts" hidden> <h2>3. Your texts</h2> <table> <thead><tr><th>Text</th><th>Colour</th><th>Outline</th><th>Shadow</th><th>Rotate</th><th>Zoom</th><th></th></tr></thead> <tbody id="texts"></tbody> </table> <button id="approve" type="button">Approve</button> <p id="refused" role="alert"></p> <img id="proof" alt="Your proof" hidden></section><button id="back" type="button">Back</button><button id="next" type="button">Next</button>const templates = [ { id: 'plain-portrait@1', label: 'Portrait' }, { id: 'plain-banner@1', label: 'Banner' },];const designer = await Pixonaut.mount('#canvas', { publishableKey, templateId: templates[0].id, printSizes: [{ widthMm: 400, heightMm: 500, label: '40 × 50 cm' }], showForm: false, showViews: false,});const fields = () => designer.getFormSchema().steps.flatMap((step) => step.fields);
// Step 1: the photo, its upload's progress, and whether it prints sharp.const bar = document.querySelector('#upload');const photo = document.querySelector('#photo');const quality = document.querySelector('#quality');const error = document.querySelector('#error');photo.addEventListener('change', async () => { const { layerId, prop } = fields().find((field) => field.type === 'image'); quality.textContent = ''; error.textContent = ''; bar.value = 0; try { await designer.uploadAsset(photo.files[0], layerId, prop); const resolution = designer.getResolution(layerId); quality.textContent = resolution?.low ? `Low quality: ${resolution.effectiveDpi} dpi at this size.` : 'Good quality.'; } catch { error.textContent = 'This photo could not be uploaded. Try another one.'; }});designer.on('progress', ({ sentBytes, totalBytes }) => { bar.value = sentBytes / totalBytes;});
// Step 2: each template drawn with what the customer gave so far.const strip = document.querySelector('#templates');const drawStrip = () => { const { templateId, values } = designer.getState(); strip.replaceChildren( ...templates.map(({ id, label }) => { const button = document.createElement('button'); const picture = document.createElement('img'); button.type = 'button'; picture.alt = ''; button.append(picture, label); button.setAttribute('aria-pressed', String(id === templateId)); button.addEventListener('click', async () => { await designer.setTemplate(id); drawStrip(); }); designer.renderTemplate(id, { maxEdgePx: 120, values }).then((png) => { picture.src = URL.createObjectURL(png); picture.addEventListener('load', () => URL.revokeObjectURL(picture.src), { once: true }); }); return button; }), );};
// Step 3: a row per text of the template chosen: its words, its colour, its// outline's colour and width, its shadow's colour and softness, each where// the template offers it. The colours given are kept by the page, to show// them again when the table is drawn.const table = document.querySelector('#texts');const colours = new Map();const drawTable = () => { const transforms = designer.getFormSchema().steps.flatMap((step) => step.transforms); const offered = (layerId, prop) => fields().find((f) => f.layerId === layerId && f.prop === prop); table.replaceChildren( ...fields() .filter((field) => field.prop === 'content') .map((text) => { const { layerId } = text; const axes = transforms.find((t) => t.layerId === layerId)?.transform ?? {}; const placed = designer.getState().values[`${layerId}.transform`] ?? {}; const cell = (...inputs) => { const td = document.createElement('td'); for (const input of inputs.filter(Boolean)) { input.name = `${layerId}.${input.name}`; td.append(input); } return td; }; const words = Object.assign(document.createElement('input'), { name: 'content', value: designer.getValue(layerId, 'content') ?? '', }); words.addEventListener('input', () => designer.setValue(layerId, 'content', words.value)); // A colour the template offers, sent as RGB; the designer stores the // CMYK it prints as. const colour = (prop) => { if (!offered(layerId, prop)) return null; const key = `${layerId}.${prop}`; const input = Object.assign(document.createElement('input'), { name: prop, type: 'color', value: colours.get(key) ?? '#000000', }); input.addEventListener('change', () => { colours.set(key, input.value); const hex = input.value.slice(1); designer.setValue(layerId, prop, { rgb: [0, 2, 4].map((at) => Number.parseInt(hex.slice(at, at + 2), 16)), }); }); return input; }; // A length the template offers, within the range it offers. const length = (prop) => { const field = offered(layerId, prop); if (!field) return null; const { min, max, step } = field.constraints; const input = Object.assign(document.createElement('input'), { name: prop, type: 'range', min, max, step: step ?? 'any', value: designer.getValue(layerId, prop), }); input.addEventListener('input', () => designer.setValue(layerId, prop, Number(input.value))); return input; }; const slider = (name, axis, value) => axis ? Object.assign(document.createElement('input'), { name, type: 'range', min: axis.min, max: axis.max, step: 'any', value, }) : null; const rotate = slider('rotate', axes.rotate, placed.rotationDeg ?? 0); rotate?.addEventListener('input', () => designer.setTransform(layerId, { rotationDeg: Number(rotate.value) })); const zoom = slider('zoom', axes.scale, placed.scale ?? 1); zoom?.addEventListener('input', () => designer.scaleLayer(layerId, Number(zoom.value))); const reset = Object.assign(document.createElement('button'), { name: 'reset', type: 'button', textContent: 'Reset', }); reset.addEventListener('click', () => { designer.resetLayer(layerId); for (const key of colours.keys()) { if (key.startsWith(`${layerId}.`)) colours.delete(key); } drawTable(); }); const row = document.createElement('tr'); row.append( cell(words), cell(colour('fill')), cell(colour('outlineColor'), length('outlineWidthEm')), cell(colour('shadowColor'), length('shadowBlurEm')), cell(rotate), cell(zoom), cell(reset), ); return row; }), );};
// One step shown at a time; each is drawn as it is entered.const steps = [...document.querySelectorAll('[data-step]')];const draw = { design: drawStrip, texts: drawTable };let shown = 0;const show = (by) => { shown = Math.min(Math.max(shown + by, 0), steps.length - 1); steps.forEach((step, at) => { step.hidden = at !== shown; }); draw[steps[shown].dataset.step]?.();};document.querySelector('#back').addEventListener('click', () => show(-1));document.querySelector('#next').addEventListener('click', () => show(1));
document.querySelector('#approve').addEventListener('click', async () => { const proof = document.querySelector('#proof'); const refused = document.querySelector('#refused'); refused.textContent = ''; try { proof.src = (await designer.approve()).proofUrl; proof.hidden = false; } catch { refused.textContent = 'This design cannot be printed as it is. Check your texts.'; }});Front and back
Section titled “Front and back”A tee printed on both sides, with a tab for each side, a strip of templates under the tabs drawn with what the customer typed, arrows that step through the product’s views, a double-sided option and an Add to cart button. The canvas and the generated form are the designer’s; everything around them is the page’s own:
<div role="tablist"> <button role="tab" data-side="front">Front</button> <button role="tab" data-side="back">Back</button></div><div id="gallery"></div><div id="canvas"></div><button id="previous">Previous view</button><button id="next">Next view</button><label><input id="double-sided" type="checkbox" checked> Print the back too</label><button id="add-to-cart">Add to cart</button>const templates = { front: [ { id: 'tee-front-name@1', label: 'Big name' }, { id: 'tee-front-badge@1', label: 'Badge' }, ], back: [ { id: 'tee-back-number@1', label: 'Number' }, { id: 'tee-back-team@1', label: 'Name and team' }, ],};
const designer = await Pixonaut.mount('#canvas', { publishableKey, blankId, templateId: templates.front[0].id, placementId: 'front', placements: [ { placementId: 'front' }, { placementId: 'back', templateId: templates.back[0].id }, ], showViews: false,});
// The templates of the side shown, each drawn with what the customer typed;// a click redraws the canvas in the one chosen, keeping it.const gallery = document.querySelector('#gallery');const drawGallery = () => { const { placementId, templateId, values } = designer.getState(); gallery.replaceChildren( ...templates[placementId].map(({ id, label }) => { const button = document.createElement('button'); const picture = document.createElement('img'); picture.alt = ''; button.append(picture, label); button.setAttribute('aria-pressed', String(id === templateId)); button.addEventListener('click', () => designer.setTemplate(id)); designer.renderTemplate(id, { maxEdgePx: 120, values }).then((png) => { picture.src = URL.createObjectURL(png); picture.addEventListener('load', () => URL.revokeObjectURL(picture.src), { once: true }); }); return button; }), );};
// A tab per side, marked when that side's design breaks a rule.const tabs = document.querySelectorAll('[data-side]');const markTabs = () => { const shown = designer.getState().placementId; for (const tab of tabs) { const side = designer.getSides().find((s) => s.placementId === tab.dataset.side); tab.setAttribute('aria-selected', String(tab.dataset.side === shown)); tab.classList.toggle('invalid', !side.valid); }};for (const tab of tabs) { tab.addEventListener('click', () => designer.setSide(tab.dataset.side));}
// The strip is drawn again once the customer's edits settle, not on every// keystroke.let redraw;designer.on('change', () => { markTabs(); clearTimeout(redraw); redraw = setTimeout(drawGallery, 400);});drawGallery();markTabs();
// The arrows step through every view; a view of the other side shows it.const step = (by) => { const views = designer.getViews().filter((view) => view.hasPhoto); const at = views.findIndex((view) => view.viewId === designer.getView()); designer.setView(views[(at + by + views.length) % views.length].viewId);};document.querySelector('#previous').addEventListener('click', () => step(-1));document.querySelector('#next').addEventListener('click', () => step(1));
document.querySelector('#double-sided').addEventListener('change', (event) => { designer.setSideEnabled('back', event.target.checked); markTabs();});
// One design per side, ordered together as one shirt.document.querySelector('#add-to-cart').addEventListener('click', async () => { const { sides } = await designer.commit(); await addToCart(sides.map(({ placementId, designId }) => ({ placementId, designId })));});addToCart is your storefront’s: your server makes a print of each design
and places one order holding them (POST /v1/orders with every print, the
first side’s first), as one item printed on two sides.
Questions? Write to [email protected].