Skip to main content

Build a single-product personalization theme

Many stores sell one personalizable product at a time, such as a mug with a name on it. Spiff Commerce supports this with an activation in single-product mode. In this tutorial you will build a theme that:

  1. reads the product the customer is configuring from the theme context,
  2. chooses the product's workflow,
  3. creates a bundle in the activation and adds a workflow experience to it,
  4. lets the customer type text on a text step and pick an image on a picture step, and
  5. finishes the bundle and hands the design to the e-commerce platform.

This tutorial builds on the project from Build and deploy your first theme. Complete that first, or have a theme project that already contains the main.tsx loader setup from that tutorial.

Before you begin​

In addition to the requirements of the first tutorial, you need:

  • An activation with single-product mode enabled, linked to a product collection that contains your product.
  • A product, in that collection, whose workflow contains at least one text step and one picture step. Note the product's integration product ID.

How single-product mode works​

When an activation is in single-product mode, the theme context's bundleOptions for a new bundle include two extra fields:

FieldDescription
singleProductModetrue. The customer configures exactly one product from the activation's collection.
collectionProductA CollectionProduct for the product being configured. Use it to find the product's workflows and its integration.

The collectionProduct is only present when singleProductMode is true. You can read the workflows straight from it, so you do not need to fetch the integration product separately.

note

If your installed version of @spiffcommerce/theme-bridge does not declare these two fields on ThemeContextBundleOptionsNew, update to the latest version.

1. Provide a collection product in development​

On the platform, the context is supplied for you. Locally, you need to build the same context yourself. Fetch the activation, asking for your product to be located within its product collections, then wrap the match in a CollectionProduct.

Add the product settings to .env.local, keeping the values from the first tutorial:

VITE_APPLICATION_KEY=<your-application-key>
VITE_ACTIVATION_ID=<your-single-product-activation-id>
VITE_INTEGRATION_PRODUCT_ID=<your-integration-product-id>

You no longer need VITE_PRODUCT_COLLECTION_ID, because the collection comes from the activation.

Replace the development branch of src/main.tsx:

/src/main.tsx
import { createRoot } from "react-dom/client";
import type { SpiffThemeLoader } from "@spiffcommerce/theme-bridge";
import type { ActivationProductCollection } from "@spiffcommerce/core";
import { App } from "./App";

let spiffThemeLoader: SpiffThemeLoader;

export function getSpiffThemeLoader() {
return spiffThemeLoader;
}

function start(loader: SpiffThemeLoader) {
spiffThemeLoader = loader;
loader.themeLoaded();
loader.getContext().then((context) => {
createRoot(context.container).render(<App context={context} />);
});
}

if (import.meta.env.DEV) {
Promise.all([import("@spiffcommerce/theme-bridge"), import("@spiffcommerce/core")]).then(
async ([themeBridge, core]) => {
const integrationProductId = import.meta.env.VITE_INTEGRATION_PRODUCT_ID || "";

// Activations locate products by their product ID, not their integration product ID.
const [integrationProduct] = await core.getIntegrationProducts([integrationProductId]);
const productId = integrationProduct?.product?.id;
const activation = await core.getActivation(
import.meta.env.VITE_ACTIVATION_ID || "",
productId ? [productId] : undefined,
);

// Find the collection and the collection product that contain the product.
type CollectionProducts = NonNullable<
ActivationProductCollection["productCollection"]["productCollectionProducts"]
>;
// The API returns a list here, although the type declares a single resource.
const productsIn = (apc: ActivationProductCollection) =>
([] as CollectionProducts[]).concat(apc.productCollection.productCollectionProducts ?? []);
const collection = activation?.activationProductCollections?.find((apc) =>
productsIn(apc).some((p) => p.productId === productId),
);
const match = collection && productsIn(collection).find((p) => p.productId === productId);
if (!collection || !match) {
throw new Error("The product was not found in the activation's product collections.");
}

themeBridge.setSpiffDevOptions("root", {
type: "bundle",
applicationKey: import.meta.env.VITE_APPLICATION_KEY || "",
configuration: themeBridge.exampleThemeInstallConfig(),
activation,
bundleOptions: {
type: "new",
productCollectionId: collection.productCollection.id,
integrationProductId,
singleProductMode: true,
collectionProduct: new core.CollectionProduct(match),
},
});
start((window as any).SpiffThemeLoader);
},
);
} else {
start((window as any).SpiffThemeLoader);
}

2. Create the bundle and choose the workflow​

A product can have more than one workflow. collectionProduct.getAllWorkflows() returns them in the order configured for the collection. If there is exactly one, use it straight away. Otherwise, let the customer choose.

To create the workflow experience, getWorkflowExperiences needs the integration product that the workflow belongs to. collectionProduct.getCurrentIntegration() returns the product's link to the integration your theme is running in, and its id is the integration product ID. It throws if the product is not linked to the current integration.

Replace src/App.tsx with the loader below. The Customizer component is added in the next steps.

/src/App.tsx
import { useEffect, useState } from "react";
import type { ThemeContext } from "@spiffcommerce/theme-bridge";
import { SpiffCommerceClient } from "@spiffcommerce/core";
import type { Bundle, CollectionProduct, ProductWorkflow, WorkflowExperience } from "@spiffcommerce/core";
import { getSpiffThemeLoader } from "./main";

export const App: React.FC<{ context: ThemeContext }> = ({ context }) => {
const [client, setClient] = useState<SpiffCommerceClient>();
const [bundle, setBundle] = useState<Bundle>();
const [product, setProduct] = useState<CollectionProduct>();
const [workflows, setWorkflows] = useState<ProductWorkflow[]>([]);
const [experience, setExperience] = useState<WorkflowExperience>();
const [error, setError] = useState<string>();

useEffect(() => {
if (context.type !== "bundle") {
setError("This theme only supports bundle contexts.");
return;
}
const newClient = new SpiffCommerceClient({ applicationKey: context.applicationKey });
getSpiffThemeLoader().configureClient(newClient);
setClient(newClient);

const load = async () => {
const { bundleOptions, activation } = context;

if (bundleOptions.type === "existing") {
// The customer is returning to a bundle they already started.
const existing = await newClient.getExistingBundle(bundleOptions.bundleId);
setBundle(existing);
setExperience(existing.getWorkflowExperiences()[0]);
return;
}

const { collectionProduct } = bundleOptions;
if (!bundleOptions.singleProductMode || !collectionProduct) {
throw new Error("This theme must be launched from an activation in single-product mode.");
}

// Create the bundle within the activation.
const newBundle = await newClient.getNewBundle(
bundleOptions.productCollectionId,
undefined,
{},
activation?.id,
);
setBundle(newBundle);
setProduct(collectionProduct);

const productWorkflows = collectionProduct.getAllWorkflows();
setWorkflows(productWorkflows);
if (productWorkflows.length === 1) {
setExperience(await addExperience(newClient, newBundle, collectionProduct, productWorkflows[0]));
}
};
load().catch((e) => setError(e instanceof Error ? e.message : String(e)));
}, [context]);

if (error) {
return <p>{error}</p>;
}
if (!client || !bundle) {
return <p>Loading...</p>;
}
if (!experience) {
// More than one workflow: let the customer choose.
return (
<div>
<h1>Choose a style</h1>
{workflows.map((workflow) => (
<button
key={workflow.getId()}
onClick={async () => setExperience(await addExperience(client, bundle, product!, workflow))}
>
{workflow.getName()}
</button>
))}
</div>
);
}
return <Customizer bundle={bundle} experience={experience} />;
};

async function addExperience(
client: SpiffCommerceClient,
bundle: Bundle,
product: CollectionProduct,
workflow: ProductWorkflow,
): Promise<WorkflowExperience> {
const [experience] = await client.getWorkflowExperiences([
{
type: "integration",
integrationProductId: product.getCurrentIntegration().id,
workflowId: workflow.getId(),
},
]);
await bundle.addWorkflowExperiences([experience]);
return experience;
}

As elsewhere, use the list-based getWorkflowExperiences and addWorkflowExperiences even for a single design, and await both.

3. Render the active steps​

Which steps are visible changes as the customer makes selections, because of conditions. Attach a renderable scene listener and render only the steps it reports. Each step handle reports its type with getType(), which you use to decide which component to show.

Add the Customizer to src/App.tsx, along with the extra imports:

/src/App.tsx
import { StepType } from "@spiffcommerce/core";
import type { PictureStepHandle, StepHandle, TextStepHandle } from "@spiffcommerce/core";

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();
getSpiffThemeLoader().addToEcommerceCart(message);
} finally {
setBusy(false);
}
};

return (
<div>
<h1>{experience.getProduct()?.name}</h1>
{steps.map((step) => {
switch (step.getType()) {
case StepType.Text:
return <TextStep key={step.getId()} step={step as TextStepHandle} />;
case StepType.Picture:
return <PictureStep key={step.getId()} step={step as PictureStepHandle} />;
default:
// Other step types are covered in the step handle guides.
return null;
}
})}
<button disabled={busy} onClick={finish}>
Add to cart
</button>
<button onClick={() => getSpiffThemeLoader().close()}>Cancel</button>
</div>
);
};

4. Handle a text step​

A TextStepHandle takes the customer's text with setText. The call is synchronous and returns a result describing what happened:

  • input is the text that was set.
  • helperData.charactersRemaining is how many characters are left, if the step has a limit.
  • errorData, if present, explains why the text was not accepted. For example, blockedProfanity, doesNotFit, hitCharacterLimit or illegalMultipleLines.

getCharacterLimit() returns the maximum length, or undefined if there is none, and getText() returns the last valid text.

/src/App.tsx
import type { TextChangeResult } from "@spiffcommerce/core";

const TextStep: React.FC<{ step: TextStepHandle }> = ({ step }) => {
const [value, setValue] = useState(step.getText());
const [result, setResult] = useState<TextChangeResult>();

const onChange = (input: string) => {
setValue(input);
setResult(step.setText(input));
};

const errors = result?.errorData;
return (
<section>
<h2>{step.getName()}</h2>
<input value={value} maxLength={step.getCharacterLimit()} onChange={(e) => onChange(e.target.value)} />
{result?.helperData.charactersRemaining !== undefined && (
<small>{result.helperData.charactersRemaining} characters remaining</small>
)}
{errors?.blockedProfanity && <p>Please choose different text.</p>}
{errors?.doesNotFit && <p>That text is too long to fit.</p>}
{errors?.illegalMultipleLines && <p>This text must fit on one line.</p>}
</section>
);
};

A text step can also offer fonts and colors. See Text step for selectVariant, getAvailableFillColors and setFillColor.

5. Handle a picture step​

A picture step offers a predetermined list of images that were uploaded in the Hub. List them with getAvailableVariants(), show each with variant.getThumbnail(), and select one with selectVariant(). Selecting is asynchronous because the image may need processing, so wait for it to finish before allowing another selection.

/src/App.tsx
import type { Variant } from "@spiffcommerce/core";

const PictureStep: React.FC<{ step: PictureStepHandle }> = ({ step }) => {
const [variants, setVariants] = useState<Variant[]>([]);
const [selecting, setSelecting] = useState(false);
const [selectedId, setSelectedId] = useState(step.getCurrentVariant()?.getId());

useEffect(() => {
step.getAvailableVariants().then(setVariants);
}, [step]);

const select = async (variant: Variant) => {
setSelecting(true);
try {
await step.selectVariant(variant);
setSelectedId(variant.getId());
} finally {
setSelecting(false);
}
};

return (
<section>
<h2>{step.getName()}</h2>
{variants.map((variant) => (
<button
key={variant.getId()}
disabled={selecting}
aria-pressed={variant.getId() === selectedId}
onClick={() => select(variant)}
>
<img src={variant.getThumbnail()} alt={variant.getName()} width={64} height={64} />
</button>
))}
</section>
);
};

6. Run it​

yarn dev

You should see the product name, a text box and a row of image buttons, matching the steps in your workflow. If the workflow has several steps of other types, they are skipped until you add components for them. Click "Add to cart" to finish the bundle. The development loader logs the message that would be sent to the e-commerce platform. It contains the bundle ID and one item for the design, with its transaction ID.

7. Finish and deploy​

bundle.finish() creates the design and resolves with a BundleDesignCreationMessage. Pass it to SpiffThemeLoader.addToEcommerceCart() and the platform adds the design to the customer's cart and ends the session. If you are not using an official e-commerce plugin, you can also place the order yourself with bundle.placeOrder().

Build and upload the theme as described in Build and deploy your first theme, then select your theme configuration on the single-product activation.

Next steps​