Build a pre-purchase Shopify checkout UI extension
Expected time: 25 minutes
Checkout UI extensions are an important part of the Shopify developer ecosystem. Since Shopify announced the deprecation of checkout.liquid
, it has become more important than ever for developers to be able to swiftly migrate existing Plus-merchant checkout functionality over to checkout UI extensions.
In this tutorial, you will build a pre-purchase upsell extension that allows merchants to select multiple products in an embedded admin app that can be offered to buyers during checkout. If more than one product is selected, the OpenAI connection and vector embeddings will be used to examine the cart contents and recommend a product to the buyer.
Prerequisites
Before starting this tutorial, you will need:
- A Shopify Partners account
- A development store with the checkout extensibility developer preview enabled
- The Shopify CLI, installed locally
- ggt, the Gadget CLI, installed locally
Step 1: Create a Gadget app and connect to Shopify
Your first step will be to set up a Gadget project and connect to a Shopify store via the Shopify connection. Create a new Gadget application at gadget.new and select the Shopify app template.
Because you are adding an embedded frontend, you are going to connect to Shopify using the Partners connection.
Now we will set up a custom Shopify application in the Partners dashboard.
- Go to the Shopify Partner dashboard
- Click on the link to the Apps page
Both the Shopify store Admin and the Shopify Partner Dashboard have an Apps section. Ensure that you are on the Shopify Partner Dashboard before continuing.
- Click the Create App button
- Click the Create app manually button and enter a name for your Shopify app
- Click on Settings in the side nav bar
- Click on Plugins in the modal that opens
- Select Shopify from the list of plugins and connections
- Copy the Client ID and Client secret from your newly created Shopify app and paste the values into the Gadget Connections page
- Click Connect on the Gadget Connections page to move to scope and model selection
Now we get to select what Shopify scopes we give our application access to, while also picking what Shopify data models we want to import into our Gadget app.
- Select the scopes and models listed below and click Confirm to connect to the custom Shopify app
- Enable the read scope for the Shopify Products API, and select the underlying Product, Product variant, and Product media models that we want to import into Gadget
Now we want to connect our Gadget app to our custom app in the Partner dashboard.
- In your Shopify app in the Partner dashboard, click on Configuration in the side nav bar so you can edit the App URL and Allowed redirection URL(s) fields
- Copy the App URL and Allowed redirection URL from the Gadget Connections page and paste them into your custom Shopify App
Now we need to install our Shopify app on a store.
- Go back to the Shopify Partner dashboard
- Click on Apps to go to the Apps page again
- Click on your custom app
- Click on Select store
- Click on the store we want to use to develop our app
- You may be prompted about Store transfer being disabled. This is okay, click Install anyway
- Click Install app to install your Gadget app on your Shopify store
If you are getting a permissions denied error when installing your app, try logging in to the Shopify store Admin!
You will be redirected to an embedded admin app that has been generated for you. The code for this app template can be found in web/routes/index.jsx
.
Do not sync your data models yet! You will want to run product descriptions through OpenAI's embeddings API so they can be offered to buyers in the checkout.
Now that your app is connected to Shopify, the next step will be to start modifying your app backend and database to support vector embeddings.
Step 2: Add custom fields to the shopifyProduct
model
You will use vector embeddings to recommend a product to buyers based on the contents of their cart. To do this, you need to add a new field to the shopifyProduct
model that will store the vector embeddings for each product.
You can also add a field to keep track of the number of times a product was offered to buyers in the checkout.
- Navigate to
api/models/shopifyProduct/schema
- Add the following fields to the model:
Field name | Field type | Default value | Reason for adding the field to the product model | |
---|---|---|---|---|
embedding | vector | Stores vector embeddings for product descriptions. | ||
offerCount | number | 0 | Count the number of times a product has been offered. |
Step 3: Add the OpenAI connection
You need to set up the OpenAI connection in Gadget to generate vector embeddings. The OpenAI connection gives you an authenticated API client used to call the OpenAI API.
- Navigate to Settings → Plugins → OpenAI
- Click Add connection with Gadget development keys selected
Gadget gives you OpenAI credits to help you start building without requiring you to enter an OpenAI API key. If you have used your credits, you will need to enter your OpenAI API key.
Step 4: Add action to generate embeddings
Now that you have finished data modeling and setting up the OpenAI connection, you can start customizing your backend.
First, we need an action we can call to generate the vector embedding for offered products.
- Add a new
generateEmbedding.js
action file atapi/models/shopifyProduct/actions
- Paste the following code into the file:
1import { applyParams, save, ActionOptions } from "gadget-server";2import { preventCrossShopDataAccess } from "gadget-server/shopify";34export const run: ActionRun = async ({5 params,6 record,7 logger,8 api,9 connections,10}) => {11 applyParams(params, record);12 await preventCrossShopDataAccess(params, record);1314 try {15 // get an embedding for the product title + description using the OpenAI connection16 const response = await connections.openai.embeddings.create({17 input: `${record.title}: ${record.body}`,18 model: "text-embedding-3-small",19 });20 const embedding = response.data[0].embedding;2122 // write to the Gadget Logs23 logger.info({ id: record.id }, "generated product embedding");2425 // use the internal API to store vector embedding in Gadget database26 // on shopifyProduct model27 await api.internal.shopifyProduct.update(record.id, { embedding });28 } catch (error) {29 logger.error({ error }, "error creating embedding");30 }3132 await save(record);33};3435export const onSuccess: ActionOnSuccess = async ({36 params,37 record,38 logger,39 api,40 connections,41}) => {};4243export const options: ActionOptions = {44 actionType: "custom",45 triggers: {46 api: true,47 },48};
1import { applyParams, save, ActionOptions } from "gadget-server";2import { preventCrossShopDataAccess } from "gadget-server/shopify";34export const run: ActionRun = async ({5 params,6 record,7 logger,8 api,9 connections,10}) => {11 applyParams(params, record);12 await preventCrossShopDataAccess(params, record);1314 try {15 // get an embedding for the product title + description using the OpenAI connection16 const response = await connections.openai.embeddings.create({17 input: `${record.title}: ${record.body}`,18 model: "text-embedding-3-small",19 });20 const embedding = response.data[0].embedding;2122 // write to the Gadget Logs23 logger.info({ id: record.id }, "generated product embedding");2425 // use the internal API to store vector embedding in Gadget database26 // on shopifyProduct model27 await api.internal.shopifyProduct.update(record.id, { embedding });28 } catch (error) {29 logger.error({ error }, "error creating embedding");30 }3132 await save(record);33};3435export const onSuccess: ActionOnSuccess = async ({36 params,37 record,38 logger,39 api,40 connections,41}) => {};4243export const options: ActionOptions = {44 actionType: "custom",45 triggers: {46 api: true,47 },48};
This action:
- uses
connections.openai
to get an OpenAI client - calls the
embeddings.create
API to generate a vector embedding for the product title and description - uses the internal API to save the embedding with
api.internal.shopifyProduct.update()
Now you need to call this action when a product's create
(or update) action is run.
Calling createEmbedding
when a product record is created
You want to reliably generate embeddings for each product so they can all be offered as part of the pre-purchase upsell. Because you want this reliability, you can use Gadget's built-in background actions system to enqueue calls to generateEmbedding
actions.
Background actions have built-in concurrency control and retry handling, making it easier to ensure that each product gets a generated embedding.
- Paste the following code snippet in
api/models/shopifyProduct/actions/create.js
:
1import { applyParams, save, ActionOptions } from "gadget-server";2import { preventCrossShopDataAccess } from "gadget-server/shopify";34export const run: ActionRun = async ({ params, record }) => {5 applyParams(params, record);6 await preventCrossShopDataAccess(params, record);7 await save(record);8};910export const onSuccess: ActionOnSuccess = async ({ record, api }) => {11 // check to see if the product title or description has changed12 if (record.changes("title") || record.changes("body")) {13 // enqueue generateEmbedding action to background action queue14 await api.enqueue(api.shopifyProduct.generateEmbedding, { id: record.id });15 }16};1718export const options: ActionOptions = { actionType: "create" };
1import { applyParams, save, ActionOptions } from "gadget-server";2import { preventCrossShopDataAccess } from "gadget-server/shopify";34export const run: ActionRun = async ({ params, record }) => {5 applyParams(params, record);6 await preventCrossShopDataAccess(params, record);7 await save(record);8};910export const onSuccess: ActionOnSuccess = async ({ record, api }) => {11 // check to see if the product title or description has changed12 if (record.changes("title") || record.changes("body")) {13 // enqueue generateEmbedding action to background action queue14 await api.enqueue(api.shopifyProduct.generateEmbedding, { id: record.id });15 }16};1718export const options: ActionOptions = { actionType: "create" };
You can also use the code in the onSuccess
function in api/model/shopifyShop/update.js
so embeddings are
re-generated when the product description changes.
You can create a new file, export a function from that file, and import that shared function in both your create
and update
actions.
Step 5: Sync your Shopify data
Now that embeddings will be generated when your shopifyProduct.create
action is called, you can sync data from Shopify. This data sync will call the create
action for each product, which enqueues the generateEmbedding
action.
A data sync will sync all existing products for your installed Shopify store. If you have a lot of products, this may take a while and use your OpenAI credits. This will work for large collections of products, but you may want to test with less products for this tutorial.
- Navigate to Settings → Plugins → Shopify → Installs
- Click Sync to start a data sync
You will see your Queues count change in the Gadget editor as generateEmbedding
actions are enqueued and then executed.
If you navigate to api/models/shopifyProduct/data
, you should see that the embedding
field has data.
Step 6: Add an action to fetch offers
The last step before building the extension is creating an action that can be called that will recommend a product to buyers based on the contents of their cart.
This action will be called from the checkout extension.
- Add a global action to your app at
api/actions/recommendProduct.js
and add the following code:
1export const run: ActionRun = async ({ params, api, connections }) => {2 // get passed in product ids3 const { productIds } = params;45 if (!productIds) {6 throw new Error("productIds is required");7 }89 const pIds = productIds.map((productId) => productId.split("/").pop()!);1011 // generate an embedding for the contents of the current cart12 let cartContents =13 "This is a list of product titles and descriptions in a shopping cart. I want to find a product that can be recommended based on the similarity to these products: ";14 for (let id of pIds) {15 const product = await api.shopifyProduct.findOne(id, {16 select: { body: true, title: true },17 });18 // get product title and description for each product in cart and add to embedding input19 cartContents += product.body20 ? `${product.title}: ${product.body},`21 : `${product.title},`;22 }2324 // use OpenAI to generate embedding for cart25 const response = await connections.openai.embeddings.create({26 input: cartContents,27 model: "text-embedding-3-small",28 });29 const embedding = response.data[0].embedding;3031 // find the most closely-related product32 const productToRecommend = await api.shopifyProduct.findFirst({33 select: {34 id: true,35 title: true,36 // get variants from related productVariant model37 variants: {38 edges: {39 node: {40 id: true,41 price: true,42 },43 },44 },45 // get image from featured productMedia record46 featuredMedia: {47 file: {48 image: true,49 },50 },51 // get the currency for the shop52 shop: {53 currency: true,54 },55 },56 // use cosine similarity sort using the cart content embedding to find closest match57 sort: {58 embedding: {59 cosineSimilarityTo: embedding,60 },61 },62 // filter out products that are already in the cart63 filter: {64 id: {65 notIn: pIds,66 },67 },68 });6970 // prep payload for response71 const { id, title, featuredMedia, variants, shop } = productToRecommend;72 const currency = shop?.currency;7374 // increment offerCount atomically so count is always accurate in db75 await api.internal.shopifyProduct.update(id, {76 _atomics: {77 offerCount: { increment: 1 },78 },79 });8081 // return the recommendation to the extension82 return {83 id,84 title,85 imgSrc: (featuredMedia?.file?.image as Record<string, any> | undefined)86 ?.originalSrc,87 variant: variants.edges[0].node,88 currency,89 };90};9192// define custom param: an array of product ids93export const params = {94 productIds: {95 type: "array",96 items: {97 type: "string",98 },99 },100};
1export const run: ActionRun = async ({ params, api, connections }) => {2 // get passed in product ids3 const { productIds } = params;45 if (!productIds) {6 throw new Error("productIds is required");7 }89 const pIds = productIds.map((productId) => productId.split("/").pop()!);1011 // generate an embedding for the contents of the current cart12 let cartContents =13 "This is a list of product titles and descriptions in a shopping cart. I want to find a product that can be recommended based on the similarity to these products: ";14 for (let id of pIds) {15 const product = await api.shopifyProduct.findOne(id, {16 select: { body: true, title: true },17 });18 // get product title and description for each product in cart and add to embedding input19 cartContents += product.body20 ? `${product.title}: ${product.body},`21 : `${product.title},`;22 }2324 // use OpenAI to generate embedding for cart25 const response = await connections.openai.embeddings.create({26 input: cartContents,27 model: "text-embedding-3-small",28 });29 const embedding = response.data[0].embedding;3031 // find the most closely-related product32 const productToRecommend = await api.shopifyProduct.findFirst({33 select: {34 id: true,35 title: true,36 // get variants from related productVariant model37 variants: {38 edges: {39 node: {40 id: true,41 price: true,42 },43 },44 },45 // get image from featured productMedia record46 featuredMedia: {47 file: {48 image: true,49 },50 },51 // get the currency for the shop52 shop: {53 currency: true,54 },55 },56 // use cosine similarity sort using the cart content embedding to find closest match57 sort: {58 embedding: {59 cosineSimilarityTo: embedding,60 },61 },62 // filter out products that are already in the cart63 filter: {64 id: {65 notIn: pIds,66 },67 },68 });6970 // prep payload for response71 const { id, title, featuredMedia, variants, shop } = productToRecommend;72 const currency = shop?.currency;7374 // increment offerCount atomically so count is always accurate in db75 await api.internal.shopifyProduct.update(id, {76 _atomics: {77 offerCount: { increment: 1 },78 },79 });8081 // return the recommendation to the extension82 return {83 id,84 title,85 imgSrc: (featuredMedia?.file?.image as Record<string, any> | undefined)86 ?.originalSrc,87 variant: variants.edges[0].node,88 currency,89 };90};9192// define custom param: an array of product ids93export const params = {94 productIds: {95 type: "array",96 items: {97 type: "string",98 },99 },100};
This action:
- generates an embedding based on the titles and descriptions of the products in a buyer's cart
- finds a recommended product using
api.shopifyProduct.findFirst
, which uses cosine similarity sorting with the generated cart embedding to find the product that most closely matches the current products in the cart
Step 7: Update action permissions
Authorization is built into Gadget, which means that you need to give buyers access to your app's API.
Roles are already created for both merchants and buyers. Buyers in the checkout will be granted the unauthenticated
role.
- Navigate to
accessControl/permissions
in the Gadget editor - Grant the
unauthenticated
role access to therecommendProduct
action
Now that buyers in the checkout have permission to call your action, you can build your extension.
Step 8: Add embedded frontend to track what was offered
You can also modify the admin-embedded frontend to display the count of what products are offered to buyers.
- Paste the following code in
web/routes/index.jsx
:
1import { AutoTable } from "@gadgetinc/react/auto/polaris";2import { Card, Layout, Page } from "@shopify/polaris";3import { api } from "../api";45export default function () {6 return (7 <Page title="Products offered in checkout">8 <Layout>9 <Layout.Section>10 <Card>11 <AutoTable12 model={api.shopifyProduct}13 columns={["title", "offerCount"]}14 initialSort={{ offerCount: "Descending" }}15 actions={[]}16 live17 />18 </Card>19 </Layout.Section>20 </Layout>21 </Page>22 );23}
1import { AutoTable } from "@gadgetinc/react/auto/polaris";2import { Card, Layout, Page } from "@shopify/polaris";3import { api } from "../api";45export default function () {6 return (7 <Page title="Products offered in checkout">8 <Layout>9 <Layout.Section>10 <Card>11 <AutoTable12 model={api.shopifyProduct}13 columns={["title", "offerCount"]}14 initialSort={{ offerCount: "Descending" }}15 actions={[]}16 live17 />18 </Card>19 </Layout.Section>20 </Layout>21 </Page>22 );23}
This code makes use of Gadget's AutoTable autocomponent, and will render a Polaris table without any additional configuration.
The live
property on AutoTable
means that the table will get realtime updates as the value in the database changes.
Step 9: Build a checkout UI extension
Now you can build a checkout UI extension to offer the product to buyers during checkout.
Shopify hosts all extensions on their infrastructure, so you only need to write the extension code and deploy it using the Shopify CLI.
To manage Shopify extensions in your Gadget project, you need to use ggt
to pull down your project to your local machine.
- In your local terminal, run the
ggt dev
command replacing<YOUR APP SLUG>
to pull down your app to your local machine:
terminalggt dev ~/gadget/<YOUR APP SLUG> --app=<YOUR APP SLUG> --env=development
cd
into your project and open it in an editor- Add the following
workspaces
andtrustedDependencies
to yourpackage.json
:
package.jsonjson{"workspaces": ["extensions/*"],"trustedDependencies": ["@shopify/plugin-cloudflare"]}
- Add a
.ignore
file to the root of your project - Add the following to
.ignore
:
add to .ignore and .gitignoreextensions/*/distextensions/*/node_modules
- If it does not already exist, add an empty
shopify.app.toml
file to your project root. This is required to run the Shopify CLI commands. - Use the Shopify CLI in your local terminal to generate your checkout UI extension:
terminalshopify app generate extension --template checkout_ui --name=ai-pre-purchase-ext
- Select the same Partner app and development store you used to connect to Shopify when prompted by Shopify's CLI
- Select JavaScript React when prompted for the extension language
This command will generate an extensions
folder at your project root, and your extension will be generated by Shopify.
Install your Gadget API client and React hooks
You can install your Gadget app's API client directly inside the extension. You can also install the @gadgetinc/shopify-extensions
package that contains tooling to make it easier to work with your client inside extensions.
cd
into theextensions/ai-pre-purchase-ext
folder of your app- Run the following in your terminal:
yarn add @gadget-client/example-app @gadgetinc/shopify-extensions
You may need to set up the Gadget NPM registry on your machine to be able to install your API client.
set up Gadget NPM registrynpm config set @gadget-client:registry https://registry.gadget.dev/npm
cd
back to your project root
Modify the extension toml
configuration file
The first thing you need to do is modify your extension's shopify.extension.toml
file. You need to define your metafield as input and allow access to the Storefront API.
- Turn on network access in your
extensions/ai-pre-purchase-ext/shopify.extension.toml
:
extensions/ai-pre-purchase-ext/shopify.extension.tomltoml# Gives your extension access to make external network calls, using the# JavaScript `fetch()` API. Learn more:# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#network-accessnetwork_access = true
This will grant your extension network access so it can make requests to your Gadget backend.
You must request access to use the network_access
capability before publishing your extension. Read more in Shopify's
documentation.
Write checkout UI extension code
Now for the extension itself. The entire src/Checkout.jsx
code file is provided, with additional details provided below the snippet.
- Paste the following into your extension's
extensions/ai-pre-purchase-ext/src/Checkout.jsx
file:
Make sure to replace <YOUR-APP-SLUG>
when importing the API Client
below!
1import { Client } from "@gadget-client/<YOUR-APP-SLUG>";2import { useGlobalAction } from "@gadgetinc/react";3import { Provider, useGadget } from "@gadgetinc/shopify-extensions/react";4import {5 Banner,6 BlockStack,7 Button,8 Divider,9 Heading,10 Image,11 InlineLayout,12 reactExtension,13 SkeletonImage,14 SkeletonText,15 Text,16 useApi,17 useApplyCartLinesChange,18 useCartLines,19} from "@shopify/ui-extensions-react/checkout";20import { useEffect, useState } from "react";2122// initialize a new Client for your Gadget API23const client = new Client();2425export default reactExtension("purchase.checkout.block.render", () => <GadgetExtension />);2627// component to set up the Provider with the sessionToken from Shopify28function GadgetExtension() {29 const { sessionToken } = useApi();3031 return (32 <Provider api={client} sessionToken={sessionToken}>33 <Extension />34 </Provider>35 );36}3738function Extension() {39 const [fetched, setFetched] = useState(false);40 const [adding, setAdding] = useState(false);41 const [showError, setShowError] = useState(false);4243 // Use `i18n` to format currencies, numbers, and translate strings44 const { i18n } = useApi();45 // Get the current state of the cart46 const cartLines = useCartLines();47 // Get a reference to the function that will apply changes to the cart lines from the imported hook48 const applyCartLinesChange = useApplyCartLinesChange();4950 // get a 'ready' boolean from the useGadget hook51 const { ready, api } = useGadget<typeof client>();5253 const [{ data: offer, fetching, error: getOfferError }, getOffer] = useGlobalAction(api.recommendProduct);5455 // fetch the product to offer with Gadget action call56 useEffect(() => {57 const fetchOffer = async () => {58 const productIds = cartLines.map((cartLine) => cartLine.merchandise.product.id);59 // call getOffer callback provided by hook60 await getOffer({ productIds });61 };6263 if (cartLines && !fetched) {64 setFetched(true);65 fetchOffer()66 // make sure to catch any error67 .catch(console.error);68 }69 }, [cartLines, fetched]);7071 // If an offer is added and an error occurs, then show some error feedback using a banner72 useEffect(() => {73 if (showError) {74 const timer = setTimeout(() => setShowError(false), 3000);75 return () => clearTimeout(timer);76 }77 }, [showError]);7879 // loading state while fetching offer80 if ((!offer && !getOfferError) || fetching) {81 return (82 <BlockStack spacing="loose">83 <Divider />84 <Heading level={2}>You might also like</Heading>85 <BlockStack spacing="loose">86 <InlineLayout spacing="base" columns={[64, "fill", "auto"]} blockAlignment="center">87 <SkeletonImage aspectRatio={1} />88 <BlockStack spacing="none">89 <SkeletonText inlineSize="large" />90 <SkeletonText inlineSize="small" />91 </BlockStack>92 <Button kind="secondary" disabled={true}>93 Add94 </Button>95 </InlineLayout>96 </BlockStack>97 </BlockStack>98 );99 }100101 // Get the IDs of all product variants in the cart102 const cartLineProductVariantIds = cartLines.map((item) => item.merchandise.id);103104 // check to see if the product is already in the cart105 const productInCart = cartLineProductVariantIds.includes(`gid://shopify/ProductVariant/${offer.variant.id}`);106107 // If the product is in the cart, or there is an error fetching the offer108 // then don't show the offer109 if (productInCart || getOfferError) {110 return null;111 }112113 // Choose the first available product variant on offer114 const { imgSrc, title, variant, currency } = offer;115116 // Localize the currency for international merchants and customers117 const renderPrice = i18n.formatCurrency(variant.price, { currency });118119 // Use the first product image or a placeholder if the product has no images120 const imageUrl =121 imgSrc ?? "https://cdn.shopify.com/s/files/1/0533/2089/files/placeholder-images-image_medium.png?format=webp&v=1530129081";122123 return (124 <BlockStack spacing="loose">125 <Divider />126 <Heading level={2}>You might also like</Heading>127 <BlockStack spacing="loose">128 <InlineLayout129 spacing="base"130 // Use the `columns` property to set the width of the columns131 // Image: column should be 64px wide132 // BlockStack: column, which contains the title and price, should "fill" all available space133 // Button: column should "auto" size based on the intrinsic width of the elements134 columns={[64, "fill", "auto"]}135 blockAlignment="center"136 >137 <Image border="base" borderWidth="base" borderRadius="loose" source={imageUrl} accessibilityDescription={title} aspectRatio={1} />138 <BlockStack spacing="none">139 <Text size="medium" emphasis="bold">140 {title}141 </Text>142 <Text appearance="subdued">{renderPrice}</Text>143 </BlockStack>144 <Button145 kind="secondary"146 loading={adding}147 accessibilityLabel={`Add ${title} to cart`}148 onPress={async () => {149 setAdding(true);150 // Apply the cart lines change151 const result = await applyCartLinesChange({152 type: "addCartLine",153 merchandiseId: `gid://shopify/ProductVariant/${variant.id}`,154 quantity: 1,155 });156 setAdding(false);157 if (result.type === "error") {158 // An error occurred adding the cart line159 // Verify that you're using a valid product variant ID160 // For example, 'gid://shopify/ProductVariant/123'161 setShowError(true);162 console.error(result.message);163 }164 }}165 >166 Add167 </Button>168 </InlineLayout>169 </BlockStack>170 {showError && <Banner status="critical">There was an issue adding this product. Please try again.</Banner>}171 </BlockStack>172 );173}
1import { Client } from "@gadget-client/<YOUR-APP-SLUG>";2import { useGlobalAction } from "@gadgetinc/react";3import { Provider, useGadget } from "@gadgetinc/shopify-extensions/react";4import {5 Banner,6 BlockStack,7 Button,8 Divider,9 Heading,10 Image,11 InlineLayout,12 reactExtension,13 SkeletonImage,14 SkeletonText,15 Text,16 useApi,17 useApplyCartLinesChange,18 useCartLines,19} from "@shopify/ui-extensions-react/checkout";20import { useEffect, useState } from "react";2122// initialize a new Client for your Gadget API23const client = new Client();2425export default reactExtension("purchase.checkout.block.render", () => <GadgetExtension />);2627// component to set up the Provider with the sessionToken from Shopify28function GadgetExtension() {29 const { sessionToken } = useApi();3031 return (32 <Provider api={client} sessionToken={sessionToken}>33 <Extension />34 </Provider>35 );36}3738function Extension() {39 const [fetched, setFetched] = useState(false);40 const [adding, setAdding] = useState(false);41 const [showError, setShowError] = useState(false);4243 // Use `i18n` to format currencies, numbers, and translate strings44 const { i18n } = useApi();45 // Get the current state of the cart46 const cartLines = useCartLines();47 // Get a reference to the function that will apply changes to the cart lines from the imported hook48 const applyCartLinesChange = useApplyCartLinesChange();4950 // get a 'ready' boolean from the useGadget hook51 const { ready, api } = useGadget<typeof client>();5253 const [{ data: offer, fetching, error: getOfferError }, getOffer] = useGlobalAction(api.recommendProduct);5455 // fetch the product to offer with Gadget action call56 useEffect(() => {57 const fetchOffer = async () => {58 const productIds = cartLines.map((cartLine) => cartLine.merchandise.product.id);59 // call getOffer callback provided by hook60 await getOffer({ productIds });61 };6263 if (cartLines && !fetched) {64 setFetched(true);65 fetchOffer()66 // make sure to catch any error67 .catch(console.error);68 }69 }, [cartLines, fetched]);7071 // If an offer is added and an error occurs, then show some error feedback using a banner72 useEffect(() => {73 if (showError) {74 const timer = setTimeout(() => setShowError(false), 3000);75 return () => clearTimeout(timer);76 }77 }, [showError]);7879 // loading state while fetching offer80 if ((!offer && !getOfferError) || fetching) {81 return (82 <BlockStack spacing="loose">83 <Divider />84 <Heading level={2}>You might also like</Heading>85 <BlockStack spacing="loose">86 <InlineLayout spacing="base" columns={[64, "fill", "auto"]} blockAlignment="center">87 <SkeletonImage aspectRatio={1} />88 <BlockStack spacing="none">89 <SkeletonText inlineSize="large" />90 <SkeletonText inlineSize="small" />91 </BlockStack>92 <Button kind="secondary" disabled={true}>93 Add94 </Button>95 </InlineLayout>96 </BlockStack>97 </BlockStack>98 );99 }100101 // Get the IDs of all product variants in the cart102 const cartLineProductVariantIds = cartLines.map((item) => item.merchandise.id);103104 // check to see if the product is already in the cart105 const productInCart = cartLineProductVariantIds.includes(`gid://shopify/ProductVariant/${offer.variant.id}`);106107 // If the product is in the cart, or there is an error fetching the offer108 // then don't show the offer109 if (productInCart || getOfferError) {110 return null;111 }112113 // Choose the first available product variant on offer114 const { imgSrc, title, variant, currency } = offer;115116 // Localize the currency for international merchants and customers117 const renderPrice = i18n.formatCurrency(variant.price, { currency });118119 // Use the first product image or a placeholder if the product has no images120 const imageUrl =121 imgSrc ?? "https://cdn.shopify.com/s/files/1/0533/2089/files/placeholder-images-image_medium.png?format=webp&v=1530129081";122123 return (124 <BlockStack spacing="loose">125 <Divider />126 <Heading level={2}>You might also like</Heading>127 <BlockStack spacing="loose">128 <InlineLayout129 spacing="base"130 // Use the `columns` property to set the width of the columns131 // Image: column should be 64px wide132 // BlockStack: column, which contains the title and price, should "fill" all available space133 // Button: column should "auto" size based on the intrinsic width of the elements134 columns={[64, "fill", "auto"]}135 blockAlignment="center"136 >137 <Image border="base" borderWidth="base" borderRadius="loose" source={imageUrl} accessibilityDescription={title} aspectRatio={1} />138 <BlockStack spacing="none">139 <Text size="medium" emphasis="bold">140 {title}141 </Text>142 <Text appearance="subdued">{renderPrice}</Text>143 </BlockStack>144 <Button145 kind="secondary"146 loading={adding}147 accessibilityLabel={`Add ${title} to cart`}148 onPress={async () => {149 setAdding(true);150 // Apply the cart lines change151 const result = await applyCartLinesChange({152 type: "addCartLine",153 merchandiseId: `gid://shopify/ProductVariant/${variant.id}`,154 quantity: 1,155 });156 setAdding(false);157 if (result.type === "error") {158 // An error occurred adding the cart line159 // Verify that you're using a valid product variant ID160 // For example, 'gid://shopify/ProductVariant/123'161 setShowError(true);162 console.error(result.message);163 }164 }}165 >166 Add167 </Button>168 </InlineLayout>169 </BlockStack>170 {showError && <Banner status="critical">There was an issue adding this product. Please try again.</Banner>}171 </BlockStack>172 );173}
This extension file:
- initializes the Gadget
Client
using aProvider
andsessionToken
from Shopify - calls the
api.recommendProduct
action using auseGlobalAction
React hook - displays a loading widget until the offer is fetched from Gadget
- checks to see if the offer is already in the cart or there is an error with the fetch
- displays the offer to buyers
- adds the offered product to the cart at the press of a button
Test your extension
Now that you've built your extension, you need to test it out in a checkout.
- Start the extension dev server by running
shopify app dev
from your app root - When prompted, make sure you select the same development store you used to connect your Gadget app to Shopify
- Open the Preview URL to access the Shopify Developer Console and open the provided Checkout UI extension URL
You should be able to see the extension in the checkout UI.
See the offer in your admin embedded UI
You can go back to your Shopify store admin and see that the count in your table has increased. If you refresh your Shopify checkout tab, the table will update in real time.
Congrats! You have successfully built a custom pre-purchase checkout extension with Gadget!
Next steps
Have questions about the tutorial? Join Gadget's developer Discord to ask Gadget employees and join the Gadget developer community!
Extend this tutorial
If you want to extend this tutorial, you could:
- build an embedded frontend for merchants to select what products can be offered in the checkout
- build an embedded frontend that serves as an analytics dashboard and lets merchants know what products have been added to a buyer's cart and purchased
Other
Want to learn more about data modeling and writing custom code effects in Gadget? Try out the product tagger tutorial: