Launch Handy Editor and save results
To illustrate how Handy Editor fits into an application, build a small Vanilla TypeScript app that opens Handy Editor for a configured Customer's Canvas product and submits the customization result for rendering. The result is a sandbox project for experiments with Customer's Canvas Hub APIs and SDKs.
Explore the https://github.com/aurigma/CCHubSample_WorkflowElements GitHub repo for a larger sample application.
Prerequisites
- A Customer's Canvas Hub tenant prepared for frontend integration
- The collected
tenantId,storefrontId, OAuth2 client credentials, and a testproductId - Node.js 18 or later
Set up the project
-
Create a project folder.
mkdir handy-editor-quick-startcd handy-editor-quick-start -
Create a Vite app.
npm create vite@latest . -- --template vanilla-ts -
Install the backend dependencies.
npm install @aurigma/axios-storefront-api-client axios dotenv expressnpm install -D @types/express concurrently tsx -
Create
vite.config.tsin the project root.import { defineConfig } from "vite";export default defineConfig({server: {host: "127.0.0.1",proxy: {"/api": "http://127.0.0.1:3001",},},});Vite runs on
127.0.0.1and forwards browser requests from/api/*to the local backend. -
Update the
devscript inpackage.json.{"scripts": {"dev": "concurrently vite \"tsx watch server/server.ts\""}}The
devscript starts Vite and the Express backend together. Leave the other Vite scripts unchanged.If you run
npm run devnow, it fails because the backend files don't exist yet.
Configure Customer's Canvas connection
-
Create an
.envfile in the project root. -
Add your Customer's Canvas values.
CCHUB_BASE_URL=https://customerscanvashub.comCCHUB_API_GATEWAY_URL=https://api.customerscanvashub.comCCHUB_ENVIRONMENT=usCCHUB_TENANT_ID=<tenant_id>CCHUB_STOREFRONT_ID=<storefront_id>CCHUB_CLIENT_ID=<client_id>CCHUB_CLIENT_SECRET=<client_secret>Use the EU or AU URLs and environment code when your tenant runs in another region. Keep
CCHUB_CLIENT_SECRETonly on the backend. Do not expose it inVITE_*variables or browser code.
Add the backend
-
Create a
serverfolder in the project root. -
Create
server/cchub-auth.ts. -
Add the Customer's Canvas connection and OAuth2 helper.
import axios from "axios";import dotenv from "dotenv";dotenv.config();type AuthResponse = {access_token: string;expires_in: number;token_type: "Bearer";scope: string;};export const cchubEnv = {baseUrl: requireEnv("CCHUB_BASE_URL"),apiGatewayUrl: requireEnv("CCHUB_API_GATEWAY_URL"),environment: requireEnv("CCHUB_ENVIRONMENT"),tenantId: Number(requireEnv("CCHUB_TENANT_ID")),storefrontId: Number(requireEnv("CCHUB_STOREFRONT_ID")),clientId: requireEnv("CCHUB_CLIENT_ID"),clientSecret: requireEnv("CCHUB_CLIENT_SECRET"),};let cachedAccessToken: { value: string; expiresAt: number } | null = null;export async function getAccessToken(): Promise<string> {if (cachedAccessToken !== null && cachedAccessToken.expiresAt > Date.now()) {return cachedAccessToken.value;}const body = new URLSearchParams({client_id: cchubEnv.clientId,client_secret: cchubEnv.clientSecret,grant_type: "client_credentials",});const { data } = await axios.post<AuthResponse>(`${cchubEnv.baseUrl}/connect/token`,body,{headers: {"Content-Type": "application/x-www-form-urlencoded",},});cachedAccessToken = {value: data.access_token,expiresAt: Date.now() + (data.expires_in - 60) * 1000,};return data.access_token;}function requireEnv(name: string): string {const value = process.env[name];if (value === undefined || value === "") {throw new Error(`Missing ${name} environment variable.`);}return value;}This module keeps OAuth2 client credentials on the backend, requests an access token through the Client Credentials flow, and caches the token until shortly before expiration.
-
Create
server/cchub-api.ts. -
Add Storefront API operations.
import {ApiClientConfiguration,ProjectsApiClient,ProductsApiClient,ProjectItemProductType,StorefrontUsersApiClient,} from "@aurigma/axios-storefront-api-client";import type {CreateSingleItemProjectDto,CreateStorefrontUserDto,} from "@aurigma/axios-storefront-api-client";import { cchubEnv, getAccessToken } from "./cchub-auth";export type LineItem = {productId: number;productVariantId?: number;productVersionId?: number;sku?: string;quantity: number;properties: {_stateId: string[];_userId: string;};};export type WorkflowElementConfig = {component?: string;tagName?: string;configVersion?: number;resources?: Record<string, any>;settings?: Record<string, any>;localization?: Record<string, any>;};export async function getStorefrontUserToken(userId: string): Promise<string> {const accessToken = await getAccessToken();const usersApi = new StorefrontUsersApiClient(createApiConfig(accessToken));try {const user: CreateStorefrontUserDto = {storefrontUserId: userId,isAnonymous: true,};await usersApi.create(cchubEnv.storefrontId, undefined, user);} catch (error) {if (getErrorStatus(error) !== 409) {throw new Error(`Cannot create storefront user. User ID = ${userId}.`, {cause: error,});}}try {return await usersApi.getToken(userId,cchubEnv.storefrontId);} catch (error) {throw new Error(`Cannot get storefront user token. User ID = ${userId}.`, {cause: error,});}}export async function getHandyEditorWorkflowConfig(productId: number): Promise<WorkflowElementConfig> {const accessToken = await getAccessToken();const productsApi = new ProductsApiClient(createApiConfig(accessToken));let content: any = "";try {const summary = await productsApi.getProductSummary(productId,undefined,cchubEnv.tenantId);content = summary.workflowContent;} catch (error) {throw new Error(`Cannot get product summary. Product ID = ${productId}.`, {cause: error,});}if (content == null || content.length == 0) {throw new Error("The product personalization workflow is empty.");}return findHandyEditorConfig(JSON.parse(content));}export async function createProject(lineItem: LineItem) {const accessToken = await getAccessToken();const projectsApi = new ProjectsApiClient(createApiConfig(accessToken));const body: CreateSingleItemProjectDto = {ownerId: lineItem.properties._userId,item: {productSpecifier: {type: ProjectItemProductType.Product,id: lineItem.productId,versionId: lineItem.productVersionId,variantId: lineItem.productVariantId,},designIds: lineItem.properties._stateId,sku: lineItem.sku,quantity: lineItem.quantity,},};try {return await projectsApi.createWithSingleItem(cchubEnv.storefrontId,undefined,body);} catch (error) {throw new Error("Cannot create a project from the Handy Editor result.", {cause: error,});}}function findHandyEditorConfig(content: any): WorkflowElementConfig {const configs = Array.isArray(content) ? content : [content];const config = configs.find(x => x.component === "handy-editor");if (!config) {throw new Error("The product workflow does not contain Handy Editor.");}return config;}export function getErrorStatus(error: any): number | undefined {return error?.response?.status?? error?.status?? (error instanceof Error ? getErrorStatus(error.cause) : undefined);}function createApiConfig(accessToken: string): ApiClientConfiguration {const config = new ApiClientConfiguration();config.apiUrl = cchubEnv.apiGatewayUrl;config.setAuthorizationToken(accessToken);return config;}This module registers a storefront user if needed, reads the compiled workflow content from the product summary, extracts the Handy Editor configuration, and creates a project from the line item returned by Handy Editor.
-
Create
server/server.ts. -
Add the Express routes.
import express from "express";import {createProject,getErrorStatus,getHandyEditorWorkflowConfig,getStorefrontUserToken,type LineItem,} from "./cchub-api";import { cchubEnv } from "./cchub-auth";const app = express();app.use(express.json());app.get("/", (request, response) => {response.send("Handy Editor quick start backend is running.");});app.get("/api/editor-session", async (request, response) => {const userId = String(request.query.userId ?? "quick-start-user");const productId = Number(request.query.productId);if (!Number.isInteger(productId) || productId <= 0) {response.status(400).json({ message: "The productId query parameter is required." });return;}try {const userToken = await getStorefrontUserToken(userId);const workflowConfig = await getHandyEditorWorkflowConfig(productId);response.json({scriptUrl: `https://staticjs-aurigma.azureedge.net/libs/${cchubEnv.environment}/workflow-elements/handy-editor/index.js`,styleUrl: `https://staticjs-aurigma.azureedge.net/libs/${cchubEnv.environment}/workflow-elements/handy-editor/styles.css`,config: {...workflowConfig,configVersion: 2,input: {productId,},integration: {tenantId: cchubEnv.tenantId,storefrontId: cchubEnv.storefrontId,cchubUrl: cchubEnv.baseUrl,cchubApiGatewayUrl: cchubEnv.apiGatewayUrl,user: {id: userId,token: userToken,},},resources: workflowConfig.resources ?? {},settings: workflowConfig.settings ?? {},localization: workflowConfig.localization ?? {language: "en",},},});} catch (error) {sendError(response, error);}});app.post("/api/projects", async (request, response) => {try {const lineItem = request.body as LineItem;const project = await createProject(lineItem);response.json(project);} catch (error) {sendError(response, error);}});app.listen(3001, () => {console.log("Backend is running at http://localhost:3001");});function sendError(response: express.Response, error: any): void {const status = getErrorStatus(error) ?? 500;const cause =error instanceof Error && error.cause != null? ` ${String(error.cause)}`: "";const message =error instanceof Error ? `${error.message}${cause}` : "Unexpected error";response.status(status).json({ message });}It implements HTTP endpoints which work with Customer's Canvas API to prepare editor configuration and send the user's customization to the server for rendering.
-
Run the dev script.
npm run devThe terminal shows a Vite local URL and the backend message:
Backend is running at http://localhost:3001Open
http://localhost:3001. The backend returns this text:Handy Editor quick start backend is running.Stop the command after both processes start.
Add the page
-
Replace the content of
index.html.<!doctype html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Handy Editor quick start</title><link rel="stylesheet" href="/src/style.css" /></head><body><main id="status" class="message">Opening Handy Editor...</main><au-handy-editor></au-handy-editor><script type="module" src="/src/main.ts"></script></body></html> -
Replace the content of
src/main.ts. Replace7263with your product ID.const productId = 7263; // Replace with your product ID.try {const session = await getEditorSession("quick-start-user", productId);if (session != null) {await loadStyles(session.styleUrl);await loadScript(session.scriptUrl);await customElements.whenDefined("au-handy-editor");const editor = document.querySelector("au-handy-editor") as HTMLElement & {init(config: any): Promise<void>;};if (editor == null) {showMessage("Cannot open Handy Editor","The au-handy-editor element is missing from the page.");} else {editor.addEventListener("addtocart", async (event: any) => {try {const lineItem = event.detail;const project = await createProject(lineItem);const cchubUrl = session.config.integration.cchubUrl.replace(/\/$/, "");if (project !== null) {console.log("Created project", project);showMessage("Project created",`Project ${project.id} created.`,{href: `${cchubUrl}/app/projects/${project.id}`,text: "Open in Customer's Canvas Hub",});}} catch (error) {showMessage("Cannot create project", getErrorMessageText(error));}});await editor.init(session.config);document.getElementById("status")?.remove();}}} catch (error) {showMessage("Cannot open Handy Editor", getErrorMessageText(error));}async function getEditorSession(userId: string,productId: number) {const params = new URLSearchParams({userId,productId: String(productId),});const response = await fetch(`/api/editor-session?${params.toString()}`);return readJson(response, "Cannot open Handy Editor");}async function createProject(lineItem: any) {const response = await fetch("/api/projects", {method: "POST",headers: {"Content-Type": "application/json",},body: JSON.stringify(lineItem),});return readJson(response, "Cannot create project");}async function readJson(response: Response, titleText: string) {if (!response.ok) {showMessage(titleText, await getResponseErrorMessage(response));return null;}return response.json();}async function getResponseErrorMessage(response: Response): Promise<string> {try {const body = await response.json();return body.message ?? response.statusText;} catch {return response.statusText;}}function showMessage(titleText: string,message: string,link?: { href: string; text: string }): void {const container = document.createElement("main");const title = document.createElement("h1");const details = document.createElement("p");container.className = "message";title.textContent = titleText;details.textContent = message;if (link != null) {const anchor = document.createElement("a");anchor.href = link.href;anchor.target = "_blank";anchor.rel = "noopener noreferrer";anchor.textContent = link.text;details.append(" ", anchor);}container.append(title, details);document.body.replaceChildren(container);}function getErrorMessageText(error: any): string {return error instanceof Error ? error.message : String(error);}async function loadStyles(url: string): Promise<void> {const link = document.createElement("link");link.href = url;link.rel = "stylesheet";document.head.appendChild(link);}async function loadScript(url: string): Promise<void> {await new Promise<void>((resolve, reject) => {const script = document.createElement("script");script.src = url;script.onload = () => resolve();script.onerror = () => reject(new Error(`Failed to load ${url}`));document.head.appendChild(script);});}The browser receives only the data needed to initialize Handy Editor. The OAuth2 client secret and access token stay on the backend. In a real storefront, set
productIdfrom the current product page, route, or catalog state. -
Replace the content of
src/style.css.html,body {height: 100%;margin: 0;}au-handy-editor {display: block;height: 100%;}.message {box-sizing: border-box;max-width: 720px;padding: 32px;font-family: system-ui, sans-serif;}.message h1 {margin: 0 0 12px;font-size: 24px;}Handy Editor fills the browser viewport. If initialization or project creation fails, the page shows the error message returned by the backend.
Run the app
-
Start the frontend and backend.
npm run dev -
Open the local URL printed by Vite.
Handy Editor loads the product from the
productIdvalue insrc/main.ts. -
Finish the customization flow in Handy Editor.
The page shows the created project ID and the Customer's Canvas Hub project URL.
Validate the result
Check that the page shows a success message like this. The link uses the Customer's Canvas Hub URL from CCHUB_BASE_URL.
Project 906 created. <a href="https://customerscanvashub.com/app/projects/906" target="_blank" rel="noopener noreferrer">Open in Customer's Canvas Hub</a>
The browser console also prints the created project object.
Check the project status and download rendered result by opening the project link or visiting Projects in Customer's Canvas BackOffice.