Use Case: Product prices are conditional based on a customer's declaration/input that's made during the checkout.
[EG. embroidery/engraving is chosen for a product, eat-in markup on prices for restaurants, tax exclusion for non-residents, etc.]
Approach: When the checkout meets the appropriate conditions, we need to surface a UI Extension featuring the relevant fields for the customer to engage with. Modifying the product prices must be done via a Cart Transform Function, as the displayed prices can't be a strikethrough discount - the actual line item price has to change.
Problem: We must pass a cart attribute variable from the UI Extension to the Function input in order for the scenario to actually work. A UI Extension on it's own can't modify pricing, and a Function on it's own can't access states within the section-rendered custom UI Extension.
Let's take the really typical use case of providing engraving as our baseline here, while keeping in mind how many other applications this solution has available to explore.
I've written before about storing an attribute at Checkout for a Function to read in it's input query and execute it's logic, but in this case I'm going to look specifically at customer-supplied logic within the Checkout to modify pricing.
The workflow example above is based off a real-life scenario with some confusing requirements that I had to design around, which involved taking into account the conditions of a customer's checkout to decide when to show or not show a specific UI element. In this case, I've standardised it to a different use case, but the essential logic flow remains the same;
condition > check > condition > check > selected state > output result
Keeping a simple visualisation of this logic in my back pocket at all times is mostly to make my own life easier, but it can also help others grasp the desired outcome of the solution before you set off on a build.
So let's take a look at the final working version of this logic before I break down how I've implemented it:
-
IF the Beanie product is in the cart, a custom checkbox UI is rendered to show that it can be embroidered.
-
Then, IF the customer selects to add the custom embroidery, two things happen;
-
A new UI is surfaced with a custom text entry field.
-
and the line item price is increased by %10
Our first task should be to build the Extension UI in order to properly set some cart line item attributes that we'll use later in our Function code.
[Editor's Note: This step was much harder than I expected, because I kept finding new UX things to consider - I'll skip over explaining things like character count, surfacing the textfield based on checkbox state, and the structural components just for time's sake. whew -- but it looks nice!]
I wanted to make sure that the UI only displayed under the line items in the cart, and not just anywhere on the page. That's simply handled by the Extension Target of:
purchase.checkout.cart-line-item.render-after
Once we've told the UI to only render after each line item, we gotta limit it even more to only render for specific products, in this case with the type Accessories
. Luckily, we can do this right in the Checkout.jsx
file just through the CartLineItemAPI object available to extensions that are using the cart-line-item target we set earlier.
if (cartLine.merchandise.product.productType !== 'Accessories') {
return null; // Do not render the UI if the product type is not 'Accessories'
}
The next big piece of the puzzle is to be able to write our attribute changes when the checkbox and textfield are interacted with. That's managed by a useApplyCartLinesChange
function available to the UI Extension. Big callout here; there is also an option to set the attribute directly on the cart and not the cart lines -- but it'll entirely break our expected outcome, because any Cart Transform function would then modify the prices of every single cart line.
This also means that the resulting order in the admin outputs the attributes directly under each line item nicely, for any downstream systems or customer service agents to use when processing or fulfilling the items.
const updateCartLineAttributes = async (isChecked, customText) => {
// Create a CartLineUpdateChange object
const change = {
type: "updateCartLine",
id: cartLine.id,
attributes: [
{
key: "added_customisation",
value: isChecked ? "true" : "false",
},
{
key: "custom_text",
value: customText,
},
],
};
// Call the API to modify checkout
const result = await applyCartLinesChange(change);
console.log("applyCartLinesChange result", result);
};
As I was building this out, I also wanted a few quality of life processes built in that would account for edge cases. While the base version was okay, taking the extra few hours to make it good was worthwhile. Some scenarios the code is accounting for:
-
IF the customer has input a value in the textfield, but they uncheck the checkbox - the textfield is wiped and the line item attribute it's setting is erased.
-
IF the customer adds more than 25 characters, it serves an error but it also refuses to add it's line item attribute.
-
IF you've set your custom text and it's created a line item attribute, but then you erase your text, it also clears the set line item attribute.
One improvement I think would be neat is implementing the textfield rendering within a Disclosure controlled by the checkbox - but that's for another day.
[Note: I'll share the full code for the checkout.jsx at the bottom of this post]
Okay, now that we've got our Checkout UI Extension setting an attribute, we need to use it within a Function to modify our line item prices conditionally.
I've made this really easy to debug, because I'm outputting the added_customisation
line item attribute and it's true
or false
value directly to the Checkout. You could choose to make it a hidden property simply by appending it with an underscore so it's _added_customisation
.
For our function, we want to fetch that attribute with our input query:
query RunInput {
cart {
lines {
id
quantity
cost {
amountPerQuantity {
amount
currencyCode
}
}
attribute(key: "added_customisation") {
value
}
}
}
}
Then essentially, we want to use that attribute value to decide wether or not we modify any line items that have the value set as true
, which again is directly controlled by our checkbox we've built within our Extension UI.
const isCustomised = cartLine.attribute?.value === 'true';
Pretty simple! If the cart line's attribute(s) have the right value, then we return the Cart Transform operation to that line. I've hardcoded my code to apply a %10 markup (base price x 1.10) when a customisation is applied, but you could also take a merchant-configured metafield value supplied by an embedded UI as an input and use that to determine what value of markup to output in the Function. That's a whole other ballgame - review this Discounts UI tutorial for an example of how that might work.
Note: Hosting an app with a front end UI to take a merchant-defined metafield value turns this from an extension-only app (hosted & scaled by Shopify) to a full custom app deployment (your own hosting & scaling considerations for the frontend).
Some caveats do exist here that I (regrettably) have to mention in this otherwise perfect (thanks very much) approach:
-
I am, quite obviously, not accounting for multiples. If I had 3x beanies in my cart, I'm getting 3x customised beanies, baby -- if I wanted them or not. If you need to split similar line items between customised and non-customised, consider a different variant for each, and modify the cart accordingly (with
useApplyCartLinesChange
and it's remove or update actions). Another option is to limit any customisable items to a quantity of one. -
Setting attributes may not be available in certain scenarios, such as draft orders or accelerated wallet checkouts. Make sure you're accounting for those journeys.
-
This can't be combined with other active Cart Transform functions, otherwise they'll compete on pricing. If you have a custom bundle app or similar solution, make sure that customisation pricing logic is embedded into that bundling code, and not running alongside it.
-
None of this has any validation behind it, and that'd be a good idea. A Cart & Checkout Validation function could double check that a line item attribute isn't beyond a certain length, or somehow a product got a price it shouldn't have, etc.
Here's the various files and their full codes involved in this solution. Hope it helps, especially as a building block towards other use cases!
âď¸
Function: run.js
// @ts-check
/**
* @typedef {import("../generated/api").RunInput} RunInput
* @typedef {import("../generated/api").FunctionRunResult} FunctionRunResult
* @typedef {import("../generated/api").CartOperation} CartOperation
*/
/**
* @type {FunctionRunResult}
*/
const NO_CHANGES = {
operations: [],
};
/**
* @param {RunInput} input
* @returns {FunctionRunResult}
*/
export function run(input) {
const operations = input.cart.lines.reduce(
/** @param {CartOperation[]} acc */
(acc, cartLine) => {
// Check if the cart line attribute 'added_customisation' is set to 'true'
const isCustomised = cartLine.attribute?.value === 'true';
if (isCustomised) {
const updateOperation = buildUpdateOperation(cartLine);
if (updateOperation) {
acc.push({ update: updateOperation });
}
}
return acc;
},
/** @type {CartOperation[]} */ ([]),
);
return operations.length > 0 ? { operations } : NO_CHANGES;
}
/**
* @param {RunInput['cart']['lines'][number]} cartLine
* @returns {CartOperation['update'] | null}
*/
function buildUpdateOperation(cartLine) {
const { id: cartLineId, cost: { amountPerQuantity } } = cartLine;
if (amountPerQuantity) {
const originalPrice = Number(amountPerQuantity.amount);
const markupPrice = originalPrice * 1.10; // Apply 10% markup
return {
cartLineId,
price: {
adjustment: {
fixedPricePerUnit: {
amount: markupPrice.toFixed(2) // Ensure the price is formatted to 2 decimal places
}
}
}
};
}
return null;
}
Checkout UI Extension: checkout.jsx
import {
reactExtension,
Banner,
BlockStack,
View,
Checkbox,
Text,
TextBlock,
TextField,
BlockSpacer,
useApi,
useApplyCartLinesChange,
useInstructions,
useTranslate,
useCartLineTarget,
} from "@shopify/ui-extensions-react/checkout";
import React, { useState } from 'react';
// 1. Choose an extension target
export default reactExtension("purchase.checkout.cart-line-item.render-after", () => (
<Extension />
));
function Extension() {
const translate = useTranslate();
const { extension } = useApi();
const instructions = useInstructions();
const applyCartLinesChange = useApplyCartLinesChange();
const cartLine = useCartLineTarget();
// 2. Check instructions for feature availability, see https://shopify.dev/docs/api/checkout-ui-extensions/apis/cart-instructions for details
if (!instructions.attributes.canUpdateAttributes) {
// For checkouts such as draft order invoices, cart attributes may not be allowed
// Consider rendering a fallback UI or nothing at all, if the feature is unavailable
return (
<Banner title="product-customiser-ui" status="warning">
{translate("attributeChangesAreNotSupported")}
</Banner>
);
}
// 3. Check if the cart line is an accessory
if (cartLine.merchandise.product.productType !== 'Accessories') {
return null; // Do not render the UI if the product type is not 'Accessories'
}
// 4. Manage Checkbox and TextField State
const [isChecked, setIsChecked] = useState(false);
const [customText, setCustomText] = useState("");
const [error, setError] = useState("");
// 5. Handle Checkbox Change
const onCheckboxChange = (checked) => {
setIsChecked(checked);
const newCustomText = checked ? customText : "";
setCustomText(newCustomText);
updateCartLineAttributes(checked, newCustomText);
};
// 6. Handle TextField Change
const onTextFieldChange = (value) => {
if (value.length > 25) {
setError("Max 25 characters, please");
} else {
setError("");
setCustomText(value);
updateCartLineAttributes(isChecked, value);
}
};
// 7. Update Cart Line Attributes
const updateCartLineAttributes = async (isChecked, customText) => {
// Create a CartLineUpdateChange object
const change = {
type: "updateCartLine",
id: cartLine.id,
attributes: [
{
key: "added_customisation",
value: isChecked ? "true" : "false",
},
{
key: "custom_text",
value: customText,
},
],
};
// Call the API to modify checkout
const result = await applyCartLinesChange(change);
console.log("applyCartLinesChange result", result);
};
// 8. Render a UI
return (
<BlockStack border={"dotted"} padding={"tight"}>
<View border="base" padding="base">
<Checkbox onChange={onCheckboxChange} checked={isChecked}>
<Text>Yes, add custom embroidery for small fee.</Text>
</Checkbox>
</View>
{isChecked && (
<View border="base" padding="base">
<TextField
label="Your Custom Text"
value={customText}
onChange={onTextFieldChange}
required={isChecked}
error={error}
/>
<BlockSpacer spacing="loose" />
<TextBlock size="small" emphasis="italic" appearance="subdued" inlineAlignment="end">{customText.length}/25</TextBlock> {/* Display character count */}
</View>
)}
</BlockStack>
);
}