Creating a Variant
This guide walks through adding a new product card variant as an example. The same pattern applies to any component slot.
Step 1: Create the Component
Create a new file in the slot directory:
src/templates/components/product-display/card/CardHighlight.tsximport type { Product } from '../../../../types';
interface Props {
product: Product;
}
export default function CardHighlight({ product }: Props) {
const discount = product.specialPrice
? Math.round((1 - product.specialPrice / product.price) * 100)
: 0;
return (
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-lg hover:shadow-xl transition-shadow">
<figure class="relative">
<img
src={product.smallImageUrl}
alt={product.name}
class="w-full aspect-square object-contain"
loading="lazy"
/>
{discount > 0 && (
<span class="badge badge-error absolute top-2 right-2">
-{discount}%
</span>
)}
</figure>
<div class="card-body p-4">
<h3 class="card-title text-sm line-clamp-2">{product.name}</h3>
<div class="flex items-baseline gap-2">
{product.specialPrice ? (
<>
<span class="text-error font-bold">${product.specialPrice.toFixed(2)}</span>
<span class="text-text-muted line-through text-xs">${product.price.toFixed(2)}</span>
</>
) : (
<span class="font-bold">${product.price.toFixed(2)}</span>
)}
</div>
</div>
</div>
);
}Step 2: Register in Manifest
Add the variant to _manifest.json:
{
"slot": "card",
"domain": "product-display",
"description": "Product card shown in category grids and search results",
"variants": {
"standard": { "label": "Standard Card", "file": "CardStandard.tsx" },
"minimal": { "label": "Minimal Card", "file": "CardMinimal.tsx" },
"highlight": {
"label": "Highlight Card",
"description": "Gradient background card with prominent discount badge",
"file": "CardHighlight.tsx"
}
},
"default": "standard"
}Step 3: Add to Index
Update the slot's index.tsx to import and map the new variant:
import { getVariant } from '../../../page-config';
import CardStandard from './CardStandard';
import CardMinimal from './CardMinimal';
import CardHighlight from './CardHighlight';
const variants = {
standard: CardStandard,
minimal: CardMinimal,
highlight: CardHighlight,
};
export default function ProductCard(props) {
const variant = getVariant('product', 'card', 'standard');
const Component = variants[variant] || CardStandard;
return <Component {...props} />;
}Step 4: Use in Page Config
Enable the variant for a store by updating its page.json:
{
"pages": {
"product": {
"components": {
"card": "highlight"
}
},
"category": {
"components": {
"card": "highlight"
}
}
}
}Step 5: Regenerate Manifest
bun run manifestStep 6: Build and Test
bun run build
bun run devVariant Checklist
When creating a variant, ensure:
- [ ] Component accepts the same props as sibling variants
- [ ] Uses DaisyUI classes and UnoCSS utilities (not custom CSS where possible)
- [ ] Respects theme tokens (CSS custom properties, not hardcoded colors)
- [ ] Images use
loading="lazy"for off-screen content - [ ] Interactive elements have appropriate
data-controllerattributes if needed - [ ] Added to
_manifest.jsonwith label and description - [ ] Added to
index.tsxvariant map - [ ] Works at all breakpoints (mobile, tablet, desktop)
Source: src/templates/components/, scripts/generate-manifest.js
Adding a New Product Type
Product-type option UI (configurable, grouped, bundle, downloadable, giftcard, custom-options) is not a slot variant — it lives in the shared product-options/ module. Adding a new product type is a four-file change.
Step 1: Add the type-specific fields to types.ts
Extend the Product interface with any fields the API returns for the new type. Example for a hypothetical subscription type:
// src/types.ts
subscriptionInterval?: 'weekly' | 'monthly' | 'quarterly' | null;
subscriptionMinCommitment?: number | null;Step 2: Create the option component
src/templates/components/product-options/SubscriptionOptions.tsxRender form controls using DaisyUI classes (matches every other option component) and wire the JS contract with data-<type>-field="..." attributes:
import { jsx } from 'hono/jsx';
import type { FC } from 'hono/jsx';
import type { OptionsProps } from './types';
export const SubscriptionOptions: FC<OptionsProps> = ({ product }) => {
return (
<div class="flex flex-col gap-3">
<label class="text-sm font-medium mb-1 block">
Delivery frequency <span class="text-error ml-0.5">*</span>
</label>
<select
class="select select-sm w-full"
required
data-subscription-field="interval"
>
<option value="">Choose…</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
</select>
</div>
);
};Step 3: Register with the dispatcher
Add one line to product-options/index.tsx:
import { SubscriptionOptions } from './SubscriptionOptions';
// inside <ProductOptions>:
{product.type === 'subscription' && <SubscriptionOptions {...props} />}Every layout (Product.tsx, LayoutMasonry.tsx, InfoPanelCompact.tsx) already renders <ProductOptions> — no per-layout changes needed.
Step 4: Extract the payload in the controller
Add a per-type builder to product-controller.js and register it in the builders table inside add():
_buildSubscriptionBody(body) {
body.sku = this.skuValue;
const interval = this.element.querySelector('[data-subscription-field="interval"]')?.value;
if (!interval) throw new Error('Please select a delivery frequency');
body.subscriptionInterval = interval; // camelCase — see product controller docs
}
// then in add():
const builders = {
// ...
subscription: () => this._buildSubscriptionBody(body),
};The camelCase field name has to match the backend API-Platform DTO field (Mage_Checkout_Api_CartProcessor::addItemToCart). Snake_case fields are silently dropped by the DTO validator.
Step 5: Add a listing-stub refetch
If the new type has child arrays (like groupedProducts, bundleOptions) that the category-listing endpoint drops, extend the isListingStub guard in src/routes/url-resolver.tsx so the URL resolver refetches full detail:
|| (kvProduct.type === 'subscription' && !kvProduct.subscriptionInterval)Skip this step for types whose full data already comes back in the listing response (e.g. simple products with just custom options).
Product-type checklist
- [ ]
types.tsextended with the new type's fields - [ ]
<Type>Options.tsxcreated with DaisyUI classes - [ ] Case added to
product-options/index.tsx - [ ]
_build<Type>Bodymethod +builderstable entry inproduct-controller.js - [ ]
isListingStubextended inurl-resolver.tsx(if the type has child arrays) - [ ] Backend DTO (
Cart.phpmutation args +CartProcessor.phpforwarding) also updated in the mahocommerce/maho repo - [ ] Tested add-to-cart on
demo.mageaustralia.com.aufor the new type
Source: src/templates/components/product-options/, src/js/controllers/product-controller.js, src/routes/url-resolver.tsx