Build a post-purchase upsell backend with Gadget
Topics covered: HTTP routes
Time to build: ~1 hour
Post-purchase upsell applications are a great way for merchants to increase sales by presenting shoppers with a one-click option to add additional items to their purchases. There are countless different strategies to pick between when determining what products a merchant wants to push for an upsell opportunity: it might be products that are related or frequently bought together with the products that the customer has already bought, it could be an item that the merchant is particularly interested in selling, or it could just be an item that is on sale.
Thankfully, Shopify is aware of the usefulness of a post-purchase upsell built into the checkout experience! They have a post-purchase upsell UI extension that will be used for this tutorial. Before tackling this tutorial, it might be helpful to go through Shopify's docs on the post-purchase checkout extension to understand how the extension works.

The logic required to determine what products are presented to users is the interesting part of a post-purchase upsell application. But to get to the point where you are writing this business logic code, you need to go through the process of setting up a server and ensuring that it will be able to scale to handle potentially high traffic.

Using Gadget, this tutorial shows you how to skip the boilerplate server work and brings you directly to writing your post-purchase upsell logic.
Before starting this tutorial you need the following:
You can fork this Gadget project and try it out yourself.
You still need to set up the Shopify Post-purchase Extension, add environment variables, and complete the application frontend.
Create a new Shopify Connection
You will want the development store's Product Variant data in your Gadget app, so you need to set up a new Shopify Connection. You will need a Shopify CLI 3.X app to generate the post-purchase upsell extension, so we will connect our Gadget app to Shopify through a new CLI app.
Connect to an embedded Shopify CLI app
The Shopify CLI requires a Shopify Partners account. It also helps to have a development store on which you can install your custom application.
The first step when using the Shopify CLI (v3.0) to set up a new Gadget app is to follow Steps 1 and 2 from the instructions on Shopify's Create an app page. This will create an application for you and help set up a custom app on the Partners Dashboard.
Do not move to Step 3 yet!
You should also stop your app that is running locally, we need to make some changes to set up the connection to a Gadget backend.
Now we can set up the Shopify connection in Gadget.
- Go to the Connections page in your Gadget app

- Select CLI for your app Connection type

Click on Overiew on the side nav bar for your custom app in the Partners dashboard
Copy the Client ID and Client secret from your newly created Shopify app and paste the values into the Gadget connections page
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.
You require the Product Read and Write scopes, and want to import the Product, Product Image, and Product Variant models.

We have successfully created our connection!
- Navigate to your app's App setup page in the Partners Dashboard
- Copy and paste the App URL from the Gadget Connections page into the App URL for your custom app in the Partners Dashboard
- Copy and paste the Allowed redirection URL from the Gadget Connections page into the Allowed redirection URL(s) for your custom app in the Partners Dashboard.


The App URL for your connection is by default set to https://localhost
(for local development). If you already have a deployed frontend, you want to set this App URL to the URL of your deployed app, for example https://my-app.vercel.app.

Now that we have a connection set up, we can finish setting up our Shopify CLI app.
- Update the
dev
script inpackage.json
at the top level to always run with the--no-update
flag
1{2 // ...3 "scripts": {4 "shopify": "shopify",5 "build": "shopify app build",6 "dev": "shopify app dev --no-update",7 "info": "shopify app info",8 "scaffold": "shopify app scaffold",9 "deploy": "shopify app deploy"10 }11}
This prevents the npm run dev
script from replacing your app's App URL and Allowed Redirect URLs in the Partners dashboard with unneeded ngrok tunnel URLs. With Gadget, your backend is already hosted at a real URL, and no tunnel is required.
- Update
web/index.js
to not implement Shopify OAuth, as Gadget handles OAuth and syncing data from the Shopify API. Instead,web/index.js
just needs to serve the frontend application with the correct security headers for Shopify.
Replace the contents of web/index.js
with the following:
web/index.jsJavaScript1// @ts-check2import { join } from "path";3import * as fs from "fs";4import express from "express";5import serveStatic from "serve-static";67const __dirname = new URL(".", import.meta.url).pathname;89const PORT = parseInt(process.env["BACKEND_PORT"] || process.env["PORT"], 10);10const STATIC_PATH =11 process.env["NODE_ENV"] === "production"12 ? `${__dirname}/frontend/dist`13 : `${__dirname}/frontend/`;1415const app = express();1617// return Shopify's required iframe embedding headers for all requests18app.use((req, res, next) => {19 const shop = req.query.shop;20 if (shop) {21 res.setHeader(22 "Content-Security-Policy",23 `frame-ancestors https://${shop} https://admin.shopify.com;`24 );25 }26 next();27});2829// serve any static assets built by vite in the frontend folder30app.use(serveStatic(STATIC_PATH, { index: false }));3132// serve the client side app for all routes, allowing it to pick which page to render33app.use("/*", async (_req, res, _next) => {34 return res35 .status(200)36 .set("Content-Type", "text/html")37 .send(fs.readFileSync(join(STATIC_PATH, "index.html")));38});3940app.listen(PORT);
- You can then delete the other example code that
@shopify/cli
created in theweb/
directory when it created your app if you like by running the following command in your app's root directory
Shellrm -f web/shopify.js web/product-creator.js web/gdpr.js
Finally, we can set up our Gadget Client and use the Provider to handle OAuth for our embedded app.
You need to install your Gadget dependencies in the web/frontend
directory of your Shopify CLI application! Change into this directory before running the following commands:
Shellcd web/frontend
- Install
local-ssl-proxy
in theweb/frontend
directory
npm install local-ssl-proxy
yarn add local-ssl-proxy
- Update the
dev
script inweb/frontend/package.json
tovite & local-ssl-proxy --source 443 --target 3005
.
1{2 // ...3 "scripts": {4 "build": "vite build",5 "dev": "vite & local-ssl-proxy --source 443 --target 3005",6 "coverage": "vitest run --coverage"7 }8}
If you are working with Windows, the dev
command above will not work. You will need to split it up into two separate commands and run
them separately. For example, "dev": "vite"
and "dev-proxy": "local-ssl-proxy --source 443 --target 3005"
.
This allows us to use our local front-end when doing development inside Shopify's admin, which uses HTTPS.
- Replace your
web/frontend/vite.config
file with the following code:
web/frontend/vite.config.jsJavaScript1import { defineConfig } from "vite";2import { dirname } from "path";3import { fileURLToPath } from "url";4import react from "@vitejs/plugin-react";56if (7 process.env["npm_lifecycle_event"] === "build" &&8 !process.env["CI"] &&9 !process.env["SHOPIFY_API_KEY"]10) {11 console.warn(12 "\nBuilding the frontend app without an API key. The frontend build will not run without an API key. Set the SHOPIFY_API_KEY environment variable when running the build command.\n"13 );14}1516const host = "localhost";17const port = 3005;1819export default defineConfig({20 root: dirname(fileURLToPath(import.meta.url)),21 plugins: [react()],22 define: {23 "process.env": JSON.stringify({24 SHOPIFY_API_KEY: process.env["SHOPIFY_API_KEY"],25 }),26 },27 resolve: {28 preserveSymlinks: true,29 },30 server: {31 host: host,32 port: port,33 hmr: {34 protocol: "ws",35 host: host,36 port: port,37 clientPort: port,38 },39 },40});
If you wish to change the port for your local server, make sure to modify both the port variable in web/frontend/vite.config
and the
target at the end of the dev
script in the web/frontend/package.json
. Note that the ports must be the same for the proxy to function
correctly.
Shopify CLI apps using Gadget don't need to use ngrok and instead run at https://localhost
. This vite config keeps vite's hot module reloading functionality working quickly without using ngrok which is faster and more reliable.
- You need to register the Gadget NPM registry for the
@gadget-client
package scope:
web/frontendShellnpm config set @gadget-client:registry https://registry.gadget.dev/npm
- The following npm modules are required when creating an app that will be embedded in the Shopify Admin:
npm install @gadgetinc/react @gadgetinc/react-shopify-app-bridge @gadget-client/example-app
yarn add @gadgetinc/react @gadgetinc/react-shopify-app-bridge @gadget-client/example-app
Make sure to replace `example-app` with your app's package name!
- To deploy your frontend using hosting platforms such as Vercel, Heroku or Netlify, you will need to add a new file
web/frontend/.npmrc
to help point to the Gadget registry.
@gadget-client:registry=https://registry.gadget.dev/npm
- The next step is to set up your Gadget client in the application. You can use this client to make requests to your Gadget application. You can create a new file in your project, and add the following code:
import { Client } from "@gadget-client/example-app";export const api = new Client();
- Now you need to set up the Provider in
web/frontend/App.jsx
. We can also use theuseGadget
hook to ensure we are authenticated before we make requests using the API. Here is a small snippet as an example:
web/frontend/App.jsxJavaScript1import {2 AppType,3 Provider as GadgetProvider,4 useGadget,5} from "@gadgetinc/react-shopify-app-bridge";6import { api } from "./api";78import { PolarisProvider } from "./components";910/**11 Gadget's Provider takes care of App Bridge authentication, you do not need Shopify's default AppBridgeProvider.12*/13export default function App() {14 return (15 <GadgetProvider16 type={AppType.Embedded}17 shopifyApiKey={process.env["SHOPIFY_API_KEY"]}18 api={api}19 >20 <PolarisProvider>21 <EmbeddedApp />22 </PolarisProvider>23 </GadgetProvider>24 );25}2627// This is where we make sure we have auth'd with AppBridge28// Once we have authenticated, we can render our app!29// Feel free to use the default page navigation that Shopify's CLI sets up for you30// example here - https://github.com/gadget-inc/examples/blob/main/packages/shopify-cli-embedded/web/frontend/App.jsx31function EmbeddedApp() {32 // we use `isAuthenticated` to render pages once the OAuth flow is complete!33 const { isAuthenticated } = useGadget();34 return isAuthenticated ? (35 <span>Hello, world!</span>36 ) : (37 <span>Authenticating...</span>38 );39}
If you are looking for examples of how to use our API client, visit our examples repository.
To use App Bridge components from Shopify you will need the useGadget
React hook. More information about the hook can be found in the
useGadget React hook docs.
You can now install your app on a development store from the Partners dashboard. Shopify will redirect you to the embedded app once the installation is complete. You now have an embedded app set up using a Shopify CLI application for your front-end and Gadget as your back-end.
Note: If you are using an Event subscriptions version later than 2022-07, you will need to request access permissions in the Protected customer data access section on the App Setup page of your Shopify app. Clicking on Request access will bring you to the access request page. Once there, add requests to any data under Protected customer data.


After completing the required sections in Protected customer access data, return to your Gadget app and navigate to your Shopify connection on the Connections tab. Click on Shop Installs under the Shopify Apps section and register the webhooks.

To do this:
- Start your local app from the app's root directory
npm run dev
yarn dev
- Go to your app's Overview page in the Partners dashboard
- Click Select store in the Test your app card and follow the installation instructions
We use the local-ssl-proxy
package to create an https proxy so that your local app can be embedded in the Shopify admin. This package
uses a self-signed certificate that Chrome and other browsers may block.
If you're having an issue viewing your embedded app locally, try logging in at https://localhost
. If you see a
NET::ERR_CERT_AUTHORITY_INVALID
message for localhost you will need to click Advanced and then Proceed to localhost.

If you change the credentials of your Shopify application in Gadget, you may encounter an error while trying to install it on your dev store.

To avoid this problem, always make sure your local Shopify CLI app is configured to use the same Shopify app credentials.
To change the app credentials of a local Shopify CLI app, run npm run dev -- --reset
in the root of your project. When prompted to create a new app or connect to an existing app, choose No, connect it to an existing app and select the app with the same credentials set in your Gadget application.
Your local Shopify CLI app credentials now match the expected ones in Gadget, and you can install your app to your development store.
Check out some of our example apps on GitHub, including:
Once you have your app installed on a store, you can click on Shop Installs on the Shopify connections page in Gadget and click on the Sync button for the store. The sync will run and your imported Shopify Product models will have some data that can be used to offer products during the post-purchase upsell.
Set up Shopify App Bridge Extension
Good news! Shopify already has a tutorial that covers setting up a new frontend application using the Shopify App Bridge Extension.
You should go through the entire first page of their tutorial. It will help you set up a front-end application on a development store and allows you to inject a post-purchase upsell page into your checkout workflow. Instead of creating a new CLI app, you can use the existing app that you created to make your Gadget connection.
Fixes to the Shopify tutorial
When running your app locally, you need to start your app with a Cloudflare tunnel like so: yarn dev --tunnel-url https://my-tunnel-url:port
.
You must include a port, and the default Cloudflare tunnel port is 8080.
The index.jsx
code file that Shopify creates may require an additional import statement. If you see any React errors in your web
console, adding import React from "react";
at the top of your code file should be the fix!
Once your Shopify CLI app is up and running, Shopify gives you a button in your store Admin to add test products to your store automatically. Add some products!

You should also make sure your products are enabled and visible in your store, and that some products are being offered at a discounted price. To offer some products at a discount, fill in the Compare at price and Price fields on a product. You can also add some test products manually.
Do not proceed until you have the frontend of Shopify's post-purchase extension demo working!
Once you have completed the first portion of the Shopify tutorial, move on to the Upsell example. Your Gadget app will replace the Node server that you set up in Part 2: Build an app server.
This Gadget application will sync product data with your development store. This product data will be used along with custom routing endpoints to determine what product will be offered to a shopper.
Set up Gadget routing
Shopify's upsell tutorial has you set up a local Node server to test out the post-purchase upsell frontend. You will replace this server with a Gadget app!
There are 3 things you need to add to your Gadget app to get it up and running:
- a custom route to handle the
/offer
request - a custom route to handle the
/sign-changeset
request - custom configuration to handle CORS
Custom route for /offer
The first thing you want to do is set up the /offer
route. This route will accept a payload from the frontend app, determine what product will be presented as part of the post-purchase upsell, and then return the product variant information required to render on the frontend.
Go to the Gadget file explorer and add a new file in the routes
folder.

You can then rename the auto-generated file to POST-offer.js
. Custom routes in Gadget need to be prepended with one of the accepted Fastify request types. The remainder of the file name is the path of the route! You can read more information about creating custom routes in the Gadget documentation.
You are going to pass information from the post-purchase Shopify App Bridge Extension to our Gadget app. This information includes the line items the customer is purchasing, as well as the total price of the order. You can use this information to determine what kind of offer you want to present to the customer. For this tutorial, you will select a random product variant that is not included as part of the original purchase and offer it to the customer at a discount.
Paste the following code snippet into your POST-offer.js
file.
JavaScript1/**2 * Route handler for GET https://testing-out-post-purchase-stuff.gadget.app/offer3 *4 * @param { import("gadget-server").Request } request - incoming request data5 * @param { import("gadget-server").Reply } reply - reply for customizing and sending a response6 *7 * @see {@link https://www.fastify.io/docs/latest/Reference/Request}8 * @see {@link https://www.fastify.io/docs/latest/Reference/Reply}9 */1011module.exports = async (request, reply) => {12 const { body } = request;13 if (body) {14 // get the purchase information from the Shopify App Bridge Extension15 const { inputData } = body;1617 // get ids of variants that are in the purchase18 const purchaseIds = inputData.initialPurchase.lineItems.map(19 (lineItem) => lineItem.product.variant.id20 );2122 // use Gadget's findMany api and filter for variants that are not included in the shoppers original purchase23 let variants = await request.api.shopifyProductVariant.findMany({24 filter: {25 id: {26 notIn: purchaseIds,27 },28 },29 select: {30 id: true,31 price: true,32 compareAtPrice: true,33 product: {34 id: true,35 title: true,36 body: true,37 },38 },39 });4041 // only present an offer if there are variants that meet the criteria (cheaper than initial purchase and on sale)42 if (variants.length > 0) {43 // pick a random variant from the list44 const randomVariant = Math.floor(Math.random() * (variants.length - 1));45 const variant = variants[randomVariant];46 const { product } = variant;4748 // get single image for product to be presented on upsell page49 const productImages = await request.api.shopifyProductImage.findMany({50 select: {51 source: true,52 },53 filter: {54 product: {55 equals: product?.id,56 },57 },58 });5960 // grab first image for this product61 let productImage = productImages[0];6263 // discount original price by 15% if there isn't a compareAtPrice available64 const discountedPrice = variant.compareAtPrice65 ? variant.price66 : variant.price67 ? (parseFloat(variant.price) * 0.85).toFixed(2)68 : "No discount available";6970 // format data to be consumed by Shopify demo frontend application71 const initialData = {72 variantId: parseInt(variant.id),73 productTitle: product?.title,74 productImageURL: productImage?.source || "https://picsum.photos/200", // random image if product does not have one75 productDescription:76 product?.body?.split(/<br.*?>/) || "No product description",77 originalPrice: variant.compareAtPrice || variant.price,78 discountedPrice,79 };8081 // send product variant as a response to be offered to shopper82 reply.send(initialData);83 } else {84 defaultResponse(reply);85 }86 } else {87 defaultResponse(reply);88 }89};9091function defaultResponse(reply) {92 // empty response as a default - post-purchase upsell page will be empty93 reply.send({});94}
This file uses the Gadget API to call shopifyProductVariant.findMany()
and then applies a filter condition to get product variants that are not included in the original purchase. An explicit select
is also made so we can take advantage of the ShopifyProductVariants's relationship to the ShopifyProduct model.
JavaScript1let variants = await request.api.shopifyProductVariant.findMany({2 filter: {3 id: {4 notIn: purchaseIds,5 },6 },7 select: {8 id: true,9 price: true,10 compareAtPrice: true,11 product: {12 id: true,13 title: true,14 body: true,15 },16 },17});
Notice that the Gadget API is available through the request parameter.
You then select a random variant and return it to the frontend. This is the business logic code. You can replace this code when writing a custom post-purchase upsell application.
Custom route for /sign-changeset
If the customer chooses to purchase the product presented to them in the post-purchase upsell window, you need to modify the original order. Another route is needed to authenticate this transaction - Shopify requires a signed JWT to proceed. It is best to handle this with another custom route.
Create another new file in our routes
folder and call it POST-sign-changeset.js
.
Paste the following code snippet to respond to requests to this endpoint with a signed JWT. This code is almost identical to the server example provided in the Shopify tutorial.
1/**2 * Route handler for GET https://testing-out-post-purchase-stuff.gadget.app/sign-changeset3 *4 * @param { import("gadget-server").Request } request - incoming request data5 * @param { import("gadget-server").Reply } reply - reply for customizing and sending a response6 *7 * @see {@link https://www.fastify.io/docs/latest/Reference/Request}8 * @see {@link https://www.fastify.io/docs/latest/Reference/Reply}9 */1011const jwt = require("jsonwebtoken");12const { v4: uuidv4 } = require("uuid");1314module.exports = async (request, reply) => {15 const decodedToken = jwt.verify(16 request.body.token,17 process.env["SHOPIFY_API_SECRET"]18 );19 const decodedReferenceId = decodedToken.input_data.initialPurchase.referenceId;2021 if (decodedReferenceId !== request.body.referenceId) {22 reply.status(400);23 }2425 const payload = {26 iss: process.env["SHOPIFY_API_KEY"],27 jti: uuidv4(),28 iat: Date.now(),29 sub: request.body.referenceId,30 changes: request.body.changes,31 };3233 const token = jwt.sign(payload, process.env["SHOPIFY_API_SECRET"]);34 reply.send({ token });35};
This will allow the original order to be modified successfully. But first, you need to import the jsonwebtoken
and uuid
modules and provide the SHOPIFY_API_KEY
and SHOPIFY_API_SECRET
environment variables required by our snippet.
Load npm modules
To be able to run this snippet you need to import some modules in the package.json
file: jsonwebtoken
and uuid
. To load these modules in your Gadget application, open the package.json
file in the file explorer and add them as dependencies:
1{2 "dependencies": {3 "jsonwebtoken": "^8.5.1",4 "uuid": "^8.3.2"5 }6}
Click the Run yarn button in the top right of package.json
window to import the packages.

The status page that opens will let you know when the yarn install process is complete.
Add environment variables
To add environment variables in Gadget, go to Environment Variables variables in the navigation bar.

The SHOPIFY_API_KEY
and SHOPIFY_API_SECRET
can be found in your Shopify Partners Dashboard in the Overview tab marked as Client ID and Client secret.

Our POST-sign-changeset.js
snippet is already set up to read environment variables. You can access them in Gadget the same way you would access them in any other Node project: process.env.<ENVIRONMENT_VARIABLE_NAME>
.
Those are the only two routes required for a post-purchase upsell app! Now you just need to enable cross-origin requests so that our tunneled local frontend can retrieve information from our Gadget app.
Handle CORS
Gadget's custom routes are built on top of Fastify. This means you can use Fastify plugins such as fastify/cors
to customize your routing. You can read more about extending route functionality in Gadget in our documentation.
To add the fastify-cors
module to our Gadget application, open the package.json
file and add it as a dependency: "fastify-cors": "^6.0.3"
.
Click the Run yarn button in the top right of package.json
window to import the package.
Now you need to add a file that handles the injection of Fastify plugins into our router. Create a new file in the routes
folder and call it +scope.js
. Then paste the following snippet in the file:
1const FastifyCors = require("fastify-cors");23module.exports = async (server) => {4 await server.register(FastifyCors, {5 // allow CORS requests from any origin6 // you should configure this to be domain specific for production applications7 origin: true,8 // only allow POST requests9 methods: ["POST"],10 });11};
This allows for cross-origin requests from Shopify, our backend Gadget app should now be reachable!
Need a different flavour of CORS handling for your custom app? Check out our docs for more details on how you can handle CORS settings.
Your Gadget file explorer should now look like this:

Complete application frontend
Our Gadget app is finished! Now you can continue with the Shopify tutorial starting back at Step 3: Update the extension code or copy and paste the snippet below to finish setting up our application extension frontend.
1import React, { useEffect, useState } from "react";2import {3 extend,4 render,5 useExtensionInput,6 BlockStack,7 Button,8 CalloutBanner,9 Heading,10 Image,11 Text,12 TextContainer,13 Separator,14 Tiles,15 TextBlock,16 Layout,17} from "@shopify/post-purchase-ui-extensions-react";1819extend("Checkout::PostPurchase::ShouldRender", async ({ inputData, storage }) => {20 const postPurchaseOffer = await fetch(21 "https://<gadget-app-name>.gadget.app/offer",22 {23 method: "POST",24 headers: {25 "Content-Type": "application/json",26 },27 body: JSON.stringify({ inputData }),28 }29 ).then((res) => res.json());3031 await storage.update(postPurchaseOffer);3233 return { render: true };34});3536render("Checkout::PostPurchase::Render", () => <App />);3738export function App() {39 const { storage, inputData, calculateChangeset, applyChangeset, done } =40 useExtensionInput();41 const [loading, setLoading] = useState(true);42 const [calculatedPurchase, setCalculatedPurchase] = useState();4344 useEffect(() => {45 async function calculatePurchase() {46 // Request Shopify to calculate shipping costs and taxes for the upsell47 const result = await calculateChangeset({ changes });4849 setCalculatedPurchase(result.calculatedPurchase);50 setLoading(false);51 }5253 calculatePurchase();54 }, []);5556 const {57 variantId,58 productTitle,59 productImageURL,60 productDescription,61 originalPrice,62 discountedPrice,63 } = storage.initialData;6465 const changes = [{ type: "add_variant", variantId, quantity: 1 }];6667 // Extract values from the calculated purchase68 const shipping =69 calculatedPurchase?.addedShippingLines[0]?.priceSet?.presentmentMoney?.amount;70 const taxes =71 calculatedPurchase?.addedTaxLines[0]?.priceSet?.presentmentMoney?.amount;72 const total = calculatedPurchase?.totalOutstandingSet.presentmentMoney.amount;73 // const discountedPrice =74 // calculatedPurchase?.updatedLineItems[0].totalPriceSet.presentmentMoney75 // .amount;76 // const originalPrice =77 // calculatedPurchase?.updatedLineItems[0].priceSet.presentmentMoney.amount;7879 async function acceptOffer() {80 setLoading(true);8182 // Make a request to your app server to sign the changeset83 const token = await fetch(84 "https://<gadget-app-name>.gadget.app/sign-changeset",85 {86 method: "POST",87 headers: { "Content-Type": "application/json" },88 body: JSON.stringify({89 referenceId: inputData.initialPurchase.referenceId,90 changes: changes,91 token: inputData.token,92 }),93 }94 )95 .then((response) => response.json())96 .then((response) => response.token);9798 // Make a request to Shopify servers to apply the changeset99 await applyChangeset(token);100101 // Redirect to the thank-you page102 done();103 }104105 function declineOffer() {106 setLoading(true);107 done();108 }109110 return (111 <BlockStack spacing="loose">112 <CalloutBanner>113 <BlockStack spacing="tight">114 <TextContainer>115 <Text size="medium" emphasized>116 It's not too late to add this to your order117 </Text>118 </TextContainer>119 <TextContainer>120 <Text size="medium">Add the {productTitle} to your order and </Text>121 <Text size="medium" emphasized>122 save 15%.123 </Text>124 </TextContainer>125 </BlockStack>126 </CalloutBanner>127 <Layout128 media={[129 { viewportSize: "small", sizes: [1, 0, 1], maxInlineSize: 0.9 },130 { viewportSize: "medium", sizes: [532, 0, 1], maxInlineSize: 420 },131 { viewportSize: "large", sizes: [560, 38, 340] },132 ]}133 >134 <Image description="product photo" source={productImageURL} />135 <BlockStack />136 <BlockStack>137 <Heading>{productTitle}</Heading>138 <PriceHeader139 discountedPrice={discountedPrice}140 originalPrice={originalPrice}141 loading={!calculatedPurchase}142 />143 <ProductDescription textLines={productDescription} />144 <BlockStack spacing="tight">145 <Separator />146 <MoneyLine147 label="Subtotal"148 amount={discountedPrice}149 loading={!calculatedPurchase}150 />151 <MoneyLine152 label="Shipping"153 amount={shipping}154 loading={!calculatedPurchase}155 />156 <MoneyLine label="Taxes" amount={taxes} loading={!calculatedPurchase} />157 <Separator />158 <MoneySummary159 label="Total"160 amount={total}161 loading={!calculatedPurchase}162 />163 </BlockStack>164 <BlockStack>165 <Button onPress={acceptOffer} submit loading={loading}>166 Pay now · {formatCurrency(total)}167 </Button>168 <Button onPress={declineOffer} subdued loading={loading}>169 Decline this offer170 </Button>171 </BlockStack>172 </BlockStack>173 </Layout>174 </BlockStack>175 );176}177178function PriceHeader({ discountedPrice, originalPrice, loading }) {179 return (180 <TextContainer alignment="leading" spacing="loose">181 <Text role="deletion" size="large">182 {!loading && formatCurrency(originalPrice)}183 </Text>184 <Text emphasized size="large" appearance="critical">185 {" "}186 {!loading && formatCurrency(discountedPrice)}187 </Text>188 </TextContainer>189 );190}191192function ProductDescription({ textLines }) {193 return (194 <BlockStack spacing="xtight">195 {textLines.map((text, index) => (196 <TextBlock key={index} subdued>197 {text}198 </TextBlock>199 ))}200 </BlockStack>201 );202}203204function MoneyLine({ label, amount, loading = false }) {205 return (206 <Tiles>207 <TextBlock size="small">{label}</TextBlock>208 <TextContainer alignment="trailing">209 <TextBlock emphasized size="small">210 {loading ? "-" : formatCurrency(amount)}211 </TextBlock>212 </TextContainer>213 </Tiles>214 );215}216217function MoneySummary({ label, amount }) {218 return (219 <Tiles>220 <TextBlock size="medium" emphasized>221 {label}222 </TextBlock>223 <TextContainer alignment="trailing">224 <TextBlock emphasized size="medium">225 {formatCurrency(amount)}226 </TextBlock>227 </TextContainer>228 </Tiles>229 );230}231232function formatCurrency(amount) {233 if (!amount || parseInt(amount, 10) === 0) {234 return "Free";235 }236 return `$${amount}`;237}
If you don't copy and paste the above snippet, there are tweaks you need to make to the provided Shopify frontend code:
- replace the
postPurchaseOffer
fetch request with the following snippet to change the/offers
request to a POST, allowing you to send inputData to your Gadget app in the request body (make sure to replace the placeholder URL with the URL of your Gadget app!):
1extend("Checkout::PostPurchase::ShouldRender", async ({ inputData, storage }) => {2 const postPurchaseOffer = await fetch(3 "https://<gadget-app-name>.gadget.app/offer",4 {5 method: "POST",6 headers: {7 "Content-Type": "application/json",8 },9 body: JSON.stringify({ inputData }),10 }11 ).then((res) => res.json());1213 await storage.update(postPurchaseOffer);1415 return { render: true };16});
replace the default URL for the
/sign-changeset
request with the URL for your Gadget app (for example:https://post-purchase-demo.gadget.app/sign-changeset
)the current Shopify demo is not making use of the returned originalPrice and purchasePrice; you can comment out the below snippets and instead include these fields as part of the
storage.inputData
destructuring.
1const {2 variantId,3 productTitle,4 productImageURL,5 productDescription,6 originalPrice, // <-- add this field7 discountedPrice, // <-- add this field8} = storage.initialData;910const changes = [{ type: "add_variant", variantId, quantity: 1 }];1112// Extract values from the calculated purchase13const shipping =14 calculatedPurchase?.addedShippingLines[0]?.priceSet?.presentmentMoney?.amount;15const taxes =16 calculatedPurchase?.addedTaxLines[0]?.priceSet?.presentmentMoney?.amount;17const total = calculatedPurchase?.totalOutstandingSet.presentmentMoney.amount;18// const discountedPrice =19// calculatedPurchase?.updatedLineItems[0].totalPriceSet.presentmentMoney <-- comment out these fields20// .amount;21// const originalPrice =22// calculatedPurchase?.updatedLineItems[0].priceSet.presentmentMoney.amount;
Try it out!
And you're done, congrats!
If you simulate a fake purchase in our Shopify store, you should now be redirected to the post-purchase upsell screen before our purchase summary. And choosing to purchase the offered product will add it to the order.
You can use this as a template for writing your own post-purchase upsell application. All you need to do is replace the business logic in the POST-offer.js
file with your custom code!