Product variants
If you want to sell t-shirts, for example, you probably want to sell the shirts in a specific size. This cookbook explains how to sell variations of a product.
To manage product stock or to define individual prices for each product variant, you need to create an own page for each product variant.
content/
t-shirt/
product.txt
1_medium/
product-variant.txt
2_large/
product-variant.txt
Product template
On the product page, the customer should be able to select the desired variant. Instead of the hidden input field from the Adding products there will be a select input field with all available variants. The value of each option is the uuid of the product variant page.
<?php
/** @var Wagnerwagner\Merx\ProductPage $page */
$variants = $page->children()->template('product-variant')->listed();
?>
<form id="product-form" action="<?= url('api/shop/cart') ?>" method="post">
<select name="page">
<?php foreach ($variants as $variant): ?>
<option value="<?= $variant->uuid() ?>"><?= $variant->title() ?></option>
<?php endforeach ?>
</select>
</form>
Technically the product-variant page is a product page. To get all the methods from Merx’ ProductPage class, create a model for product-variant and extend Wagnerwagner\Merx\ProductPage.
<?php
class ProductVariantPage extends \Wagnerwagner\Merx\ProductPage
{}
Handling the price
Different t-shirt sizes normally have all the same price. Instead of adding the same price to each product variant, you can overwrite the price method of the product-variant page model and use the price of the parent product page.
<?php
class ProductVariantPage extends \Wagnerwagner\Merx\ProductPage
{
public function price(null|string|\Wagnerwagner\Merx\PricingRule $pricingRule = null): ?\Wagnerwagner\Merx\Price
{
return $this->parent()->price($pricingRule);
}
}
You could also use the price of the parent product page only, when the price field of the product variant page is empty.
return parent::price() === null ? $this->parent()->price($pricingRule) : parent::price();
Extending product variant model
You can further extend the product variant page model, for example you can create a full title from the parent product page and the variant page.
<?php
class ProductVariantPage extends \Wagnerwagner\Merx\ProductPage
{
// …
public function title(): Field
{
$fullTitle = $this->parent()->title() . ' – ' . $this->content()->title();
return parent::title()->value($fullTitle);
}
}
Multiple variations
When a product has multiple variations (e.g. color and size), things will get a bit more complicated on the front-end side.
The structure of the content folder might look like this. Each variant has an own product-variant page.
content/
t-shirt/
product.txt
1_red-medium/
product-variant.txt
2_red-large/
product-variant.txt
3_red-medium/
product-variant.txt
4_red-large/
product-variant.txt
Each variant should have fields for each attribute. In this example color and size.
Color: Red
----
Size: Large
----
Uuid: 96satwnkbxtyk4w9
HTML
The customer will expect not one but two select fields. One for the color and one for the size.
First create an array with all colors and all sizes using $pages->pluck() and use these arrays to create two select fields. Also add a hidden page input field. The value of this field will late be set using JavaScript.
<?php
/** @var Wagnerwagner\Merx\ProductPage $page */
$variants = $page->children()->template('product-variant')->listed();
$sizes = array_unique($variants->pluck('size'));
$colors = array_unique($variants->pluck('color'));
?>
<form id="product-form" action="<?= url('api/shop/cart') ?>" method="post">
<select name="color">
<?php foreach ($colors as $color): ?>
<option value="<?= $color ?>"><?= $color ?></option>
<?php endforeach ?>
</select>
<select name="size">
<?php foreach ($sizes as $size): ?>
<option value="<?= $size ?>"><?= $size ?></option>
<?php endforeach ?>
</select>
<input type="hidden" name="page" value="">
</form>
To set the value of the hidden page field, you need a list of all page uuids and the respective attributes (color and size). Create this list and store it as JSON in a <script> element.
<?php
/** @var \Wagnerwagner\Merx\ProductPage $page */
$variants = $page->children()->template('product-variant');
$variantsArray = $variants->toArray(fn (ProductVariantPage $productVariantPage) => [
'uuid' => (string)$productVariantPage->uuid(),
'size' => (string)$productVariantPage->size(),
'color' => (string)$productVariantPage->color(),
]);
$sizes = array_unique($variants->pluck('size'));
$colors = array_unique($variants->pluck('color'));
?>
<form id="product-form" action="<?= url('api/shop/cart') ?>" method="post">
<script id="variants">
<?= json_encode(array_values($variantsArray), JSON_UNESCAPED_SLASHES) ?>
</script>
JavaScript
Now you have all the things you need to update the value of the hidden page input field.
- Parse the JSON from the script element with the id
variants - Create an event listener for each select field.
- Find the item with the selected attributes
- Set the value of the hidden
pagefield with the uuid of the product variant.
// [1]
const variants = JSON.parse(document.getElementById('variants').innerText);;
function updatePageField() {
// [3]
const { color, size } = productForm.elements;
const variant = variants.find((item) => item.color === color.value && item.size === size.value);
// [4]
productForm.elements.page.value = variant?.uuid ?? null;
}
updatePageField();
productForm.querySelectorAll('select').forEach((selectElement) => {
// [2]
selectElement.addEventListener('change', updatePageField);
})
You can use the same mechanism to update product images, showing the available stock or the specific price, when you have different prices per variant.
Add a variant to the cart
The form posts the selected variant’s UUID as page, so the API route puts the right variant into the cart without any further work. To add a variant from PHP, pass the variant page as page and give it a key of your own.
$cart->add([
'page' => 'page://96satwnkbxtyk4w9',
'key' => 't-shirt-red-large',
'data' => [
'color' => 'Red',
'size' => 'Large',
],
]);
The key identifies the item inside the cart, so every variant needs its own. Adding the same key twice adds up the quantities instead of creating a second line. Title, price and tax are read from the variant page, data is yours to fill.
$cart->add() accepts the parameters of new ListItem() and nothing else. A key like color or size at the top level fails with Unknown named parameter — your own attributes belong in data.
A one-off price
Most variants should get their price from the page, as described in Handling the price. If you do need to set a price while adding, pass a Price object.
use Wagnerwagner\Merx\Price;
$cart->add([
'page' => 'page://96satwnkbxtyk4w9',
'key' => 't-shirt-gold-large',
'price' => new Price(price: 249.99, currency: $cart->currency()),
]);
Pass the cart’s currency() along with the price. The cart compares the currency of every incoming item against its own and silently drops a price that does not match, which would put the variant into the cart for free. A plain 'price' => 249.99 is exactly that case.