---
title: "Launch Handy Editor and save results | Customer&#x27;s Canvas Hub"
canonical: "https://customerscanvas.com/docs/hub/dev-center/frontend-integration/workflow-elements/handy-editor/quick-start/"
---

# 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](https://github.com/aurigma/CCHubSample_WorkflowElements) GitHub repo for a larger sample application.

## Prerequisites

- A Customer's Canvas Hub tenant [prepared for frontend integration](https://customerscanvas.com/docs/hub/dev-center/frontend-integration/prepare-for-frontend-integration.md)
- 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.

   ```bash
   mkdir handy-editor-quick-start
   cd handy-editor-quick-start
   ```

2. Create a Vite app.

   ```bash
   npm create vite@latest . -- --template vanilla-ts
   ```

3. Install the backend dependencies.

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

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

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

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

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

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

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

   ```bash
   npm run dev
   ```

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

   ```bash
   Backend is running at http://localhost:3001
   ```

   Open `http://localhost:3001`. The backend returns this text:

   ```bash
   Handy Editor quick start backend is running.
   ```

   Stop the command after both processes start.

## Add the page

1. Replace the content of `index.html`.

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

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

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

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

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