Build and deploy your first theme
In this tutorial you will build a small but complete theme, test it locally, and deploy it to the Spiff Commerce Hub. By the end, your theme will:
- read the theme context that Spiff Commerce provides,
- create a bundle inside the activation it was launched from,
- add a workflow experience to the bundle and let the customer make a choice on each step, and
- finish the bundle and hand the result back to the e-commerce platform.
The theme is deliberately plain so the moving parts are easy to see. The full-featured reference implementations are apps/corporate-theme and apps/beeline-theme in the gaia repository.
Before you begin
You will need:
- Node.js and Yarn.
- A Spiff Commerce Hub account, with an integration. The integration provides your application key.
- A product with at least one workflow, linked to the integration as an integration product. Note its integration product ID.
- A product collection containing that product. Note its ID.
- An activation. Note its ID. You will link your theme to it at the end of this tutorial.
1. Create the project
Themes can be built with any framework that can be statically hosted. This tutorial uses React with TypeScript.
yarn create vite my-first-theme --template react-ts
cd my-first-theme
yarn
Install the Spiff Commerce packages. theme-bridge is only needed at build time and when developing locally, so it is a dev dependency. bestzip packages the build for upload.
yarn add @spiffcommerce/core
yarn add -D @spiffcommerce/theme-bridge bestzip
2. Add your local settings
Create .env.local in the project root. Vite loads it automatically, and it is only used during local development.
VITE_APPLICATION_KEY=<your-application-key>
VITE_ACTIVATION_ID=<your-activation-id>
VITE_PRODUCT_COLLECTION_ID=<your-product-collection-id>
VITE_INTEGRATION_PRODUCT_ID=<your-integration-product-id>
3. Connect your theme to Spiff Commerce
When your theme runs on the platform, Spiff Commerce places a SpiffThemeLoader object on window before your code runs. It gives you the context and lets you talk back to the platform. When you develop locally, setSpiffDevOptions from @spiffcommerce/theme-bridge creates a stand-in loader that behaves the same way.
Replace src/main.tsx with the following.
import { createRoot } from "react-dom/client";
import type { SpiffThemeLoader } from "@spiffcommerce/theme-bridge";
import { App } from "./App";
let spiffThemeLoader: SpiffThemeLoader;
// A helper so the rest of your code does not need to reach for `window`.
export function getSpiffThemeLoader() {
return spiffThemeLoader;
}
function start(loader: SpiffThemeLoader) {
spiffThemeLoader = loader;
// Hides the loading indicator that Spiff Commerce shows while your theme loads.
loader.themeLoaded();
loader.getContext().then((context) => {
// Render into the container provided by the platform.
createRoot(context.container).render(<App context={context} />);
});
}
if (import.meta.env.DEV) {
// Imported dynamically so that `theme-bridge`, a dev dependency, is not part of the production bundle.
Promise.all([import("@spiffcommerce/theme-bridge"), import("@spiffcommerce/core")]).then(
async ([themeBridge, core]) => {
// Fetch the activation, exactly as the platform would provide it.
const activation = await core.getActivation(import.meta.env.VITE_ACTIVATION_ID || "");
themeBridge.setSpiffDevOptions(
// The id of the element in `index.html` to render into.
"root",
// This object is passed to your theme as the context.
{
type: "bundle",
applicationKey: import.meta.env.VITE_APPLICATION_KEY || "",
configuration: themeBridge.exampleThemeInstallConfig(),
activation,
bundleOptions: {
type: "new",
productCollectionId: import.meta.env.VITE_PRODUCT_COLLECTION_ID || "",
integrationProductId: import.meta.env.VITE_INTEGRATION_PRODUCT_ID || "",
},
},
);
// Only exists once `setSpiffDevOptions` has been called.
start((window as any).SpiffThemeLoader);
},
);
} else {
// In production, the platform assigns the loader before your theme loads.
start((window as any).SpiffThemeLoader);
}
index.html created by Vite renders into <div id="root">. In development, the dev loader renders its own wrapper into that element and your theme renders inside the wrapper's container, #spiffThemeWrapper. On the platform, the container is always #spiffThemeWrapper.
4. Create the bundle and the workflow experience
Themes are always launched with a bundle context. Its bundleOptions either describe a new bundle to create, or an existing bundle to reopen. Both cases are handled below.
For a new bundle, the theme creates it within the activation by passing activation?.id as the last argument of getNewBundle. It then asks the integration product for its default workflow, creates a workflow experience for it, and adds the experience to the bundle.
Always use the list-based getWorkflowExperiences and addWorkflowExperiences, even for a single product.
Replace src/App.tsx:
import { useEffect, useState } from "react";
import type { ThemeContext } from "@spiffcommerce/theme-bridge";
import { SpiffCommerceClient } from "@spiffcommerce/core";
import type { Bundle, StepHandle, WorkflowExperience } from "@spiffcommerce/core";
import { getSpiffThemeLoader } from "./main";
export const App: React.FC<{ context: ThemeContext }> = ({ context }) => {
const [bundle, setBundle] = useState<Bundle>();
const [experience, setExperience] = useState<WorkflowExperience>();
const [error, setError] = useState<string>();
useEffect(() => {
if (context.type !== "bundle") {
setError("This theme only supports bundle contexts.");
return;
}
const client = new SpiffCommerceClient({ applicationKey: context.applicationKey });
// Points the client at the correct Spiff Commerce servers.
getSpiffThemeLoader().configureClient(client);
const load = async () => {
const { bundleOptions, activation } = context;
// Reopen the customer's bundle, or create a new one within the activation.
const loadedBundle =
bundleOptions.type === "existing"
? await client.getExistingBundle(bundleOptions.bundleId)
: await client.getNewBundle(bundleOptions.productCollectionId, undefined, {}, activation?.id);
if (bundleOptions.type === "new") {
if (!bundleOptions.integrationProductId) {
throw new Error("No product was provided. See the single-product tutorial for product selection.");
}
const integrationProduct = await client.getIntegrationProductById(bundleOptions.integrationProductId);
const [newExperience] = await client.getWorkflowExperiences([
{
type: "integration",
integrationProductId: bundleOptions.integrationProductId,
workflowId: integrationProduct.getDefaultWorkflow().getId(),
},
]);
await loadedBundle.addWorkflowExperiences([newExperience]);
}
setBundle(loadedBundle);
setExperience(loadedBundle.getWorkflowExperiences()[0]);
};
load().catch((e) => setError(e instanceof Error ? e.message : String(e)));
}, [context]);
if (error) {
return <p>{error}</p>;
}
if (!bundle || !experience) {
return <p>Loading...</p>;
}
return <Customizer bundle={bundle} experience={experience} />;
};
getWorkflowExperiences returns the experiences in the same order as the options you pass. Both calls are asynchronous, so be sure to await them before using the experience.
integrationProductId is only present when a product was preselected, for example by the product page that launched the theme. When the customer should choose a product, you work from the activation's product collection instead. The single-product tutorial shows how.
5. Render the steps
A workflow is made up of steps, and which steps are visible can change as the customer makes selections, because of conditions. Attach a listener to the experience to learn which steps are currently renderable, and render only those.
For each step this theme lists the available variants as buttons. Selecting a variant updates the design.
Add this to the bottom of src/App.tsx:
const Customizer: React.FC<{ bundle: Bundle; experience: WorkflowExperience }> = ({ bundle, experience }) => {
const [steps, setSteps] = useState<StepHandle<any>[]>([]);
const [busy, setBusy] = useState(false);
useEffect(() => {
// Called immediately, then again whenever the active steps change.
const onScenesChange = (scenes: { renderableSteps: string[] }[]) => {
const renderable = scenes.flatMap((scene) => scene.renderableSteps);
setSteps(experience.getSteps().filter((step) => renderable.includes(step.getId())));
};
experience.attachRenderableSceneListener(onScenesChange);
return () => experience.detachRenderableSceneListener(onScenesChange);
}, [experience]);
const finish = async () => {
setBusy(true);
try {
const message = await bundle.finish();
// Sends the finished designs to the e-commerce platform and closes the theme.
getSpiffThemeLoader().addToEcommerceCart(message);
} finally {
setBusy(false);
}
};
return (
<div>
<h1>{experience.getProduct()?.name}</h1>
{steps.map((step) => (
<StepPicker key={step.getId()} step={step} />
))}
<button disabled={busy} onClick={finish}>
Add to cart
</button>
<button onClick={() => getSpiffThemeLoader().close()}>Cancel</button>
</div>
);
};
const StepPicker: React.FC<{ step: StepHandle<any> }> = ({ step }) => {
const [variants, setVariants] = useState<Awaited<ReturnType<StepHandle<any>["getAvailableVariants"]>>>([]);
useEffect(() => {
step.getAvailableVariants().then(setVariants);
}, [step]);
// Steps without variants, such as text steps, need their own UI. See the single-product tutorial.
if (variants.length === 0) {
return null;
}
return (
<section>
<h2>{step.getName()}</h2>
{variants.map((variant) => (
<button key={variant.getId()} onClick={() => step.selectVariant(variant)}>
{variant.getName()}
</button>
))}
</section>
);
};
bundle.finish()creates the designs and resolves with aBundleDesignCreationMessagecontaining the bundle ID and one item per design, each with its transaction ID.addToEcommerceCart()sends that message to the e-commerce platform and ends the session. You do not need to place an order yourself when using an official plugin.close()exits without finishing, which the platform treats as the customer cancelling.
6. Run it locally
yarn dev
Open the URL Vite prints. You should see your product's name, a list of steps with a button for each variant, and the "Add to cart" and "Cancel" buttons. Click a variant to change the selection. When you click "Add to cart", the development loader logs the message it would have sent to the e-commerce platform.
7. Prepare the build for upload
The Hub needs a .zip file with a spiffTheme.json at its root. That file lists the scripts and stylesheets the platform must load for your theme. The spiffThemePlugin from @spiffcommerce/theme-bridge/plugin generates it for you during the build. It also sets Vite's base to an empty string, so assets are resolved relative to wherever your theme is hosted.
Update vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { spiffThemePlugin } from "@spiffcommerce/theme-bridge/plugin";
export default defineConfig({
plugins: [react(), spiffThemePlugin()],
build: {
emptyOutDir: true,
},
});
The plugin must be imported from the /plugin subpath, because it uses Node.js APIs.
Then update the build script in package.json to zip the output. It zips from inside dist so that spiffTheme.json ends up at the root of the archive.
{
"scripts": {
"build": "tsc -b && vite build && cd dist && bestzip dist.zip *"
}
}
Run it:
yarn build
This produces dist/dist.zip.
Reference images and other local assets with new URL("./img.png", import.meta.url).href, rather than a plain import, so they resolve correctly once the theme is hosted by Spiff Commerce.
8. Upload the theme to the Hub
- In the Hub, open Themes and click NEW THEME. Give it a name, save it, and reopen it.
- Under Theme Versions, click NEW VERSION, name the version, and upload
dist/dist.zip. Names must be unique within the theme. - Review the list of files shown and click SUBMIT. If you see an error, check that
spiffTheme.jsonis at the root of the zip and lists at least one target file. - When the version has finished processing, click PUBLISH.
9. Use your theme in an activation
- Open Theme Installs, click INSTALL THEME, and choose your theme.
- Create a configuration, or use the default one, and select the version you just published.
- Select that configuration on your activation, so the activation launches your theme. The activation provides the configuration to your theme through the context.
To try the theme on an integration without an activation, you can instead select the install and configuration in the Marketplace Themes section of the integration, as described in Deploying to the Spiff Commerce Hub.
You can now launch the activation from your site with the Hosted Experience API, or from an e-commerce plugin such as the Shopify Personalize Button.
Marketplace themes are only used by external integrations when an application key is present. Without one, the experience falls back to the built-in themes.
Next steps
- Make the product selectable, add a text step and a picture step, and finish a single-product experience in the single-product tutorial.
- Read the theme context reference for everything the platform provides your theme.
- Expose settings that merchants can customize, which the Hub shows when they configure your theme, as described in Deploying to the Spiff Commerce Hub.
- Learn more about bundles and workflow experiences.