# Use BigCommerce App Extensions to build a size chart app for Catalyst storefronts  Expected time: 30 minutes This tutorial will show you how to build a size chart app for BigCommerce Catalyst storefronts, using App Extensions. By the end of this tutorial, you will have: * Set up a BigCommerce connection * Stored BigCommerce data in a Gadget database * Subscribed to BigCommerce webhooks and synced historical data * Built a [BigCommerce App Extension](https://developer.bigcommerce.com/docs/integrations/apps/app-extensions) * Set up a Catalyst storefront that reads size chart info from your Gadget app ## Prerequisites  Before starting, you will need: * A [BigCommerce partner account](https://partners.bigcommerce.com/) * A [BigCommerce sandbox store](https://developer.bigcommerce.com/docs/start/about/sandboxes) * A [BigCommerce Catalyst project](https://www.catalyst.dev/docs/installation) set up locally ## Step 1: Create a new Gadget app and connect to BigCommerce  Start by creating a new Gadget app and connecting to BigCommerce. 1. Follow the [BigCommerce quickstart](https://docs.gadget-canary.xyz/guides/getting-started/quickstarts/bigcommerce-quickstart) guide to set up a BigCommerce connection and install your app on a sandbox store. Select the **Products Read-only** and **App Extensions Manage** OAuth scopes when setting up the connection. You now have a full-stack, single-click BigCommerce app. OAuth and frontend sessions are handled, and you can subscribe to BigCommerce webhooks. ## Step 2: Add a product data model  You need to store both product data and size charts created by merchants in your Gadget database. You can create data models in Gadget to store this data. 1. Right-click on the `api/models/bigcommerce` directory and select **Add model**. 2. Name the model `product` and add the following fields and validations: | Field name | Field type | Validations | | --- | --- | --- | | `name` | string | Required | | `sizeChart` | json | | | `store` | belongs to | Required | | `bigcommerceId` | number | Uniqueness (scoped by `store`), Required | When a model is created and edited Gadget will automatically run the required mutations on the underlying database. A CRUD API will also be automatically generated for your model. [Read more about data models in Gadget](https://docs.gadget-canary.xyz/guides/models). 3. For the `store` field, select the `bigcommerce/store` model as the parent model, so that `bigcommerce/store` has many `bigcommerce/product`. For multi-tenant apps, you may have multiple stores whose resources have the same `bigcommerceId`. To avoid conflicts, you can scope the Uniqueness validation on `bigcommerceId` by the `store` relationship. This ensures that `bigcommerceId` is unique per store. [Read more about multi-tenancy in BigCommerce apps](https://docs.gadget-canary.xyz/guides/plugins/bigcommerce/data#data-security-and-multi-tenancy). ## Step 3: Add `appExtensionId` field to `bigcommerce/store`  You also want to store the App Extension ID on your `bigcommerce/store` model so the extension can be referenced from your app. Add the following field at `api/models/bigcommerce/store/schema`: | Field name | Field type | | --- | --- | | `appExtensionId` | string | ## Step 4: Subscribe to `store/product` webhooks  You can use webhooks to keep your product data in Gadget up to date with data in a store. 1. Click the **+** button next to `api/actions` and enter `bigcommerce/handleProductWebhooks.js`. This creates a `bigcommerce` namespace folder and the new action. 2. Click the **+** button in the action's Triggers card and select **BigCommerce**. 3. Select the `store/product/created`, `store/product/updated`, and `store/product/deleted` webhook scopes. 4. (Optional) Remove the **Generated API endpoint** trigger from the action. Now this action will run anytime a product is created, updated, or deleted in BigCommerce. When a product webhook is fired, you want to call the `bigcommerce/product` model's actions to create, update, or delete records in the Gadget database. Notice that the [`upsert` meta API](https://docs.gadget-canary.xyz/guides/actions/code#upsert-meta-action) is used to handle `store/product/updated` webhooks. This is because the product may not yet exist in your database. 5. Paste the following code in `api/actions/bigcommerce/handleProductWebhooks.js`: ```typescript import { ActionOptions } from "gadget-server"; export const run: ActionRun = async ({ params, api, connections, trigger }) => { // get the BigCommerce API client for the current store const bigcommerce = connections.bigcommerce.current!; if (trigger.type !== "bigcommerce_webhook") { throw new Error("This action can only be triggered by a BigCommerce webhook"); } // handle store/product/deleted webhooks if (trigger.scope === "store/product/deleted") { const productRecordToDelete = await api.bigcommerce.product.maybeFindFirst({ filter: { bigcommerceId: { equals: params.id } }, select: { id: true }, }); if (productRecordToDelete) { // if it exists, delete it await api.bigcommerce.product.delete(productRecordToDelete.id); } return; } // handle store/product/created and store/product/updated webhooks // fetch the product data const product = await bigcommerce.v3.get("/catalog/products/{product_id}", { path: { product_id: params.id, }, }); if (!product) { throw new Error("Product not found"); } // upsert product data into the model await api.bigcommerce.product.upsert({ bigcommerceId: product.id, store: { // get the bigcommerce/store id for the record stored in Gadget _link: connections.bigcommerce.currentStoreId, }, name: product.name, on: ["bigcommerceId", "store"], }); }; export const options: ActionOptions = { triggers: { api: false, bigcommerce: { webhooks: [ "store/product/created", "store/product/updated", "store/product/deleted", ], }, }, }; ``` Products in your Gadget database now stay in sync with BigCommerce stores that have installed your app. ## Step 5: Sync product data and set up a BigCommerce App Extension  You still need a way to sync existing product data into your data models, and set up a BigCommerce App Extension. You can do both of these things together in the `api/models/bigcommerce/store/actions/install.js` action. This action is run immediately after your app is installed on store. Start by creating another global action to handle the data sync using Gadget's built-in background actions. 1. Create a new file `syncProducts.js` in `api/actions/bigcommerce` and paste the following code: ```typescript import { ActionOptions } from "gadget-server"; export const run: ActionRun = async ({ params, api, connections }) => { // set the batch size to 50 for bulk upsert const BATCH_SIZE = 50; if (!params.storeHash) { throw new Error("Store hash is required"); } const bigcommerce = await connections.bigcommerce.forStoreHash(params.storeHash); // use the API client to fetch all products, and return const products = await bigcommerce.v3.list(`/catalog/products`); // get the id of the store record in Gadget db const store = await api.bigcommerce.store.findFirst({ filter: { storeHash: { equals: params.storeHash, }, }, select: { id: true, }, }); const productPayload = []; // use a for await loop to iterate over the AsyncIterables, add to an array for await (const product of products) { productPayload.push({ // use bigcommerceId and store to identify unique records for upsert on: ["bigcommerceId", "store"], // store the BigCommerce ID bigcommerceId: product.id, // associate the product with the current store store: { _link: store.id, }, name: product.name, }); // enqueue 50 actions at a time if (productPayload.length >= BATCH_SIZE) { const section = productPayload.splice(0, BATCH_SIZE); // bulk enqueue create action await api.enqueue(api.bigcommerce.product.bulkUpsert, section, { queue: { name: "product-sync" }, }); // delay for a second, don't exceed rate limits! await new Promise((r) => setTimeout(r, 1000)); // ONLY SYNC 50 PRODUCTS FOR THE TUTORIAL break; } } // enqueue any remaining products await api.enqueue(api.bigcommerce.product.bulkUpsert, productPayload, { queue: { name: "product-sync" }, }); }; export const options: ActionOptions = { // 15 minute timeout for the sync timeoutMS: 900000, }; // accept store hash as action param export const params = { storeHash: { type: "string" }, }; ``` This fetches all product data and enqueues upserts using [background actions](https://docs.gadget-canary.xyz/guides/actions/background). Background actions are used so you get the built-in retry handling to ensure data is reliably added to your database in bulk. This sample code will `break` the product iteration loop after 50 products this is to limit resources used while building this tutorial. Remove the `break` in the snippet to sync all product data for production apps. Call your sync action and set up a BigCommerce App Extension in the `install` action. The App Extension URL `"/products/${id}/size-chart"` will connect to a frontend route. 2. Paste the following in `api/models/bigcommerce/store/actions/install.js`: ```typescript import { applyParams, save, ActionOptions } from "gadget-server"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ record, logger, api, params, }) => { if (!params?.store?.storeHash) { throw new Error("No store hash found, cannot install app"); } // use internal API to read access token for app extension registration const store = await api.internal.bigcommerce.store.findFirst({ filter: { storeHash: { equals: params.store.storeHash }, }, select: { accessToken: true, }, }); // use fetch for GraphQL request (GraphQL not supported by built-in client) const response = await fetch( `https://api.bigcommerce.com/stores/${record.storeHash}/graphql`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", "X-Auth-Token": store.accessToken, }, body: JSON.stringify({ query: `mutation AppExtension($input: CreateAppExtensionInput!) { appExtension { createAppExtension(input: $input) { appExtension { id context model url label { defaultValue locales { value localeCode } } } } } }`, variables: { // edit input to match your desired App Extension input: { context: "PANEL", model: "PRODUCTS", url: "/products/${id}/size-chart", label: { defaultValue: "Size chart", locales: [ { value: "Size chart", localeCode: "en-US", }, ], }, }, }, }), } ); const jsonResponse = await response.json(); if (jsonResponse.errors) { logger.error({ errors: jsonResponse.errors }, "Error creating app extension"); } // save the App Extension id to your bigcommerce/store model await api.internal.bigcommerce.store.update(record.id, { appExtensionId: jsonResponse.data.appExtension.createAppExtension.appExtension.id, }); // sync existing product data from BigCommerce store to Gadget db // use background action so install is not blocked await api.enqueue(api.bigcommerce.syncProducts, { storeHash: record.storeHash, }); }; export const options: ActionOptions = { actionType: "create", }; ``` This action sets up an App Extension via GraphQL, stores the App Extension's ID on the `bigcommerce/store` model, and kicks off a data sync. For more on App Extensions, [read our docs](https://docs.gadget-canary.xyz/guides/plugins/bigcommerce/app-extensions). ### Running the install action  You have already installed your app on a store. This means that to run this action you need to: 1. Uninstall the app from your sandbox store 2. Delete the store record at `api/models/bigcommerce/store/data` 3. Reinstall the app on your sandbox store For production apps, you may want to run this same code in both the `bigcommerce/store/install` and `bigcommerce/store/reinstall` actions. ## Step 6: Access control  All Gadget apps have authorization built in. A role-based access control system allows us to restrict API and data access to merchants and shoppers. The `bigcommerce-app-users` role is automatically assigned to merchants who install your app in BigCommerce. Storefront shoppers will be granted the `unauthenticated` role. You can read more about roles [in our docs](https://docs.gadget-canary.xyz/guides/plugins/bigcommerce/frontends#frontend-data-security-and-access-control). You need to grant both merchants and shoppers access to the actions that power size chart frontends: 1. Navigate to the `accessControl/permissions` page. 2. Grant the `bigcommerce-app-users` role access to the `bigcommerce/product/` model's `read` and `update` actions. This will allow merchants to create and save size charts for products. 3. Grant the `unauthenticated` role access to the `bigcommerce/product` model's `read` API. This allows shoppers to read the size charts in a storefront. [Read about data security and multi-tenancy](https://docs.gadget-canary.xyz/guides/plugins/bigcommerce/data#data-security-and-multi-tenancy) for apps installed on multiple stores. ## Step 7: App Extension Size Chart code  Now you are ready to build your App Extension frontend. Gadget frontends are built with React in the `web` folder. [BigDesign](https://developer.bigcommerce.com/big-design/) is pre-installed for you. An API client in `web/api.js` stays up to date as you add models and actions. Use this client with React hooks from `@gadgetinc/react` to call actions and save size charts. Start by adding a new route component to your frontend. The next step will be defining this route on the frontend router. 1. Create a new file `size-chart.jsx` in `web/routes` and paste the following code: ```tsx import { Box, Button, Flex, FlexItem, Form, H2, Input, Message, Panel, StatefulTable, Text } from "@bigcommerce/big-design"; import { AddIcon, RemoveIcon } from "@bigcommerce/big-design-icons"; import { useAction, useFindFirst } from "@gadgetinc/react"; import { ChangeEvent, FormEvent, memo, useCallback, useEffect, useMemo, useState } from "react"; import { useParams } from "react-router"; import { api } from "../api"; // sample data used when no size chart is present const SAMPLE_COLUMNS = [{ hash: "column1" }, { hash: "column2" }, { hash: "column3" }, { hash: "column4" }]; const SAMPLE_ITEMS = [ { id: "header", column1: "Size", column2: "S", column3: "M", column4: "L", }, { id: "row1", column1: "Chest", column2: '34-36"', column3: '37-38"', column4: '39-40"', }, { id: "row2", column1: "Waist", column2: '30-32"', column3: '33-34"', column4: '35-36"', }, ]; // buttons for adding or removing columns and rows to table const TableButtons = ({ name, onAdd, onRemove, disableRemove, }: { name: string; onAdd: () => void; onRemove: () => void; disableRemove: boolean; }) => ( {name} ); }; export default function () { const params = useParams(); const productId = params.productId!; const [isErrorVisible, setIsErrorVisible] = useState(false); const [{ data: product, fetching, error }] = useFindFirst(api.bigcommerce.product, { select: { id: true, name: true, sizeChart: true }, filter: { bigcommerceId: { equals: parseInt(productId) } }, }); if (!product) { return Product not found; } return ( {error && ( setIsErrorVisible(false)} /> )} {fetching ? ( Loading... ) : ( )} ); } ``` This frontend route: * Gets the `productId` from the params. * Loads existing product data, including a size chart if it already exists. * Renders a dynamic table component that can be customized. * Cells are memoized to prevent redraws on `Input` updates. * State is managed with `useState` hooks. * Saves a size chart as json by calling the `api.bigcommerce.product.update` action. * Cells are memoized to prevent redraws on `Input` updates. * State is managed with `useState` hooks. The [`@gadgetinc/react` library](https://docs.gadget-canary.xyz/reference/react) provides hooks for fetching data (`useFindMany`), calling actions (`useAction`), and managing form state (`useActionForm`). 2. Now add the route definition to the frontend router in `web/components/App.jsx`: ```tsx // .. additional imports import SizeChart from "../routes/size-chart"; // add the new route in the App component function App() { const router = createBrowserRouter( createRoutesFromElements( }> } /> } /> {/** add the size chart route */} } /> ) ); return ; } ``` ### Testing your App Extension  Now you can test your App Extension. Open up the **Size chart** extension for a product in your store. **Make sure you select one of the 50 products that was synced to your Gadget database.** You can see what products were synced at `api/models/bigcommerce/product/data`. You can modify the number of rows and columns in the chart, and edit the chart contents/measurements. Once you are done, click **Save chart** and the chart config will be saved to Gadget as JSON. ## Step 8: Draw size chart in Catalyst product page  The last step is rendering the custom size charts on the product pages of a Catalyst storefront. You need to install a copy of your API client into your Catalyst project to read size chart data from your Gadget models. Your API client package will have the format `@gadget-client/`. 1. [Set up a Catalyst project and start your dev server](https://www.catalyst.dev/docs/installation). This can be an existing storefront or a new project. 2. `cd` into your Catalyst project's `core` folder. 3. Install your [Gadget API client](https://docs.gadget-canary.xyz/api/example-app/development/external-api-calls/installing#node-module): ```shell pnpm install @gadget-client/example-app ``` You may also need to register the Gadget NPM repository. ```shell npm config set @gadget-client:registry https://registry.gadget.dev/npm ``` 4. Create a new file `gadget.ts` in `core/`, then initialize and export your client: ```typescript import { ExampleAppClient } from "@gadget-client/example-app"; export const api = new ExampleAppClient(); ``` 5. Create a `size-chart.jsx` file in `core/app/[locale]/(default)/product/[slug]/_components` and paste the following code: ```tsx import React from "react"; import { api } from "../../../../../../gadget"; // Define types for the size chart data interface SizeChartItem { id: string; [key: string]: string; } interface SizeChartData { items: SizeChartItem[]; columns: { hash: string }[]; } // SizeChart component export const SizeChart: React.FC<{ productId: number }> = async ({ productId }) => { // fetch size chart for product from Gadget API const response = await api.bigcommerce.product.findFirst({ filter: { bigcommerceId: { equals: productId, // select the size chart for this product }, store: { storeHash: { equals: process.env.BIGCOMMERCE_STORE_HASH, // for multi-tenant apps, select the size chart for this store }, }, }, select: { sizeChart: true, // only return the size chart field }, }); const { sizeChart } = response.toJSON(); // no size chart available, return nothing! if (!sizeChart) { return null; } /// map json response to SizeChartData type const data: SizeChartData = { items: (sizeChart as any).items as SizeChartItem[], columns: (sizeChart as any).columns as { hash: string }[], }; return ( <>

Size chart

{data.columns.map((column) => ( ))} {data.items.slice(1).map((item) => ( {Object.entries(item) .filter(([key, _value]) => key !== "id") .map(([key, value], index) => ( ))} ))}
{data.items[0] && data.items[0][column.hash as keyof SizeChartItem]}
{value}
); }; ``` This component: * Uses your `api` client to read product size chart data. * Renders a size chart, if data is returned from Gadget. 6. Import the `SizeChart` component into your product page at `core/app/[locale]/(default)/product/[slug]/page.tsx` and render the chart: ```tsx // .. other imports import { SizeChart } from "./_components/size-chart"; // .. interface, type, and function definitions export default async function Product({ params: { locale, slug }, searchParams }: Props) { return ( <> {/* other components such as Breadcrumbs */}
{/* other components such as Gallery and Details */}
{/* other components such as Reviews */}
{/* rest of the page */} ); } ``` For more on Catalyst storefronts, [read our docs](https://docs.gadget-canary.xyz/guides/plugins/bigcommerce/catalyst). ### Test it out  You have just built a full stack size chart app, congrats! You can preview your custom size charts on the storefront by navigating to a product page for which you have built and saved a size chart. ## Next steps  * Join Gadget's developer community on [Discord](https://ggt.link/discord) * Import existing size charts as CSV using an action. * Display size charts in a modal on the storefront.