Skip to main content

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 test productId
  • Node.js 18 or later

Set up the project

  1. Create a project folder.

    mkdir handy-editor-quick-start
    cd handy-editor-quick-start
  2. Create a Vite app.

    npm create vite@latest . -- --template vanilla-ts
  3. Install the backend dependencies.

    npm install @aurigma/axios-storefront-api-client axios dotenv express
    npm install -D @types/express concurrently tsx
  4. Create vite.config.ts in 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.1 and forwards browser requests from /api/* to the local backend.

  5. Update the dev script in package.json.

    {
    "scripts": {
    "dev": "concurrently vite \"tsx watch server/server.ts\""
    }
    }

    The dev script starts Vite and the Express backend together. Leave the other Vite scripts unchanged.

    If you run npm run dev now, it fails because the backend files don't exist yet.

Configure Customer's Canvas connection

  1. Create an .env file in the project root.

  2. Add your Customer's Canvas values.

    CCHUB_BASE_URL=https://customerscanvashub.com
    CCHUB_API_GATEWAY_URL=https://api.customerscanvashub.com
    CCHUB_ENVIRONMENT=us
    CCHUB_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_SECRET only on the backend. Do not expose it in VITE_* variables or browser code.

Add the backend

  1. Create a server folder in the project root.

  2. Create server/cchub-auth.ts.

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

  4. Create server/cchub-api.ts.

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

  6. Create server/server.ts.

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

  8. Run the dev script.

    npm run dev

    The terminal shows a Vite local URL and the backend message:

    Backend is running at http://localhost:3001

    Open 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

  1. 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>
  2. Replace the content of src/main.ts. Replace 7263 with 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 productId from the current product page, route, or catalog state.

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

  1. Start the frontend and backend.

    npm run dev
  2. Open the local URL printed by Vite.

    Handy Editor loads the product from the productId value in src/main.ts.

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

Was this page helpful?