Appearance
Product Types
Every product type has its own product page in Lynx, with its own layout and its own Product Detail component. This page explains how that works, what a store sees for a product type Lynx does not know (a gift card or subscription type from an extension, or your own Magento\Catalog\Model\Product\Type\AbstractType), and how to add support for one.
Claude Code
The Lynx Claude Code skills can add support for a new product type for you, following this page. See Working with Claude Code.
One page per product type
On a layout-driven page, the content zone is read from the design under the request's full action name (Pages and Controllers). For a product page that would be catalog_product_view. OOCS_LynxCatalog adds the type of the product being viewed to that path, the same way Magento adds the catalog_product_view_type_<type> layout handle:
catalog_product_view → catalog_product_view_type_<type_id>The plugin that does this, OOCS\LynxCatalog\Plugin\ProductTypeLayoutPath, is a before plugin on LayoutRepositoryInterface::getLayout() in etc/frontend/di.xml (sort order 10). It rewrites the path only when the registry holds a current_product. The bare catalog_product_view path is never used for a product page.
Each type gets its own path because each type needs a different Product Detail component: configurable products pick a variant, grouped products list their items with quantities, bundles are built from options. The layout around it (tabs, related products, banners) can then also differ per type.
| Product type | Layout path | Product Detail entry | Component | Page in the editor |
|---|---|---|---|---|
| Simple | catalog_product_view_type_simple | default_product_detail_simple | OOCS_LynxCatalog::Components/Product/Simple | Products › Simple product page |
| Virtual | catalog_product_view_type_virtual | default_product_detail_simple | OOCS_LynxCatalog::Components/Product/Simple | Products › Virtual product page |
| Configurable | catalog_product_view_type_configurable | default_product_detail_configurable | …/Product/Configurable | Products › Configurable product page |
| Bundle | catalog_product_view_type_bundle | default_product_detail_bundle | …/Product/Bundle | Products › Bundle product page |
| Grouped | catalog_product_view_type_grouped | default_product_detail_grouped | …/Product/Grouped | Products › Grouped product page |
| Downloadable | catalog_product_view_type_downloadable | default_product_detail_downloadable | …/Product/Downloadable | Products › Downloadable product page |
Virtual products have no component of their own: the simple entry lists both paths in its availableOn, and the default layout of the virtual page uses it.
Each Product Detail entry is limited to its type's path with availableOn, so the editor offers only the right one on each page. Product (default_product, the frame that holds the images beside the detail) and Product Media (default_product_media, the gallery) also list the six type paths one by one, so a type you add must be added to them too. The other product page components use catalog_product_view*, which matches every type's path by prefix, including a type you add: Product Description, Product Specifications, Product Reviews, Product Custom Options, Product Attribute, Product Image Attribute, Price Alert, Stock Alert, Related Products and Up-sell Products.
The default design holds a layout for each built-in type, installed by OOCS\LynxCatalog\Setup\Patch\Data\InstallCatalogLayouts: a Group holding the Product component, with Product Media in its Images slot and the Product Detail in its Details slot (Product Custom Options in the detail's Product Options slot), then a Tabs group (Description, Details, Reviews), and Related Products.
A product type Lynx does not know
Nothing is shown for a new type until you add it
Lynx has a page, a component and a default layout only for the six types above. For any other type code, out of the box:
- The product page is empty. The path becomes
catalog_product_view_type_<code>, no design has a layout for it, so the page renders with the header and footer and an empty content area. There is no fallback to the simple product's layout. The page still has its title, meta, Open Graph tags and full page cache tags, which come from layout XML. - The editor has no page for it. The page switcher lists only the pages declared in
PreviewPagebuilder, so merchants cannot build the missing layout until a module declares the page. - No Product Detail component fits. Even with a page, the built-in Product Detail entries, Product and Product Media are limited to the built-in types' paths and are not offered on the new page.
- Cards follow Magento's rule. Product Listing, Search Results, Product Carousel, Related, Up-sell and Cross-sell cards send a product to its page when Magento says it cannot be bought from a list (
isPossibleBuyFromList()of its type, which by default means it has required options), and always for configurable and grouped products. Any other product is posted tocheckout/cart/addwithproductandqty=1. So a type that needs input works on cards once its type class reports required options; see Cards in product lists.
What does work for any type without changes, because it goes through Magento's own type API:
- Cart page and mini cart. Line item options come from Magento's
Magento\Catalog\Helper\Product\ConfigurationPool(cart page) and thecartcustomer data section (mini cart). A type that registers its configuration helper and itsMagento\Checkout\CustomerData\ItemPoolitem, as it would for Luma, shows its options there. - My Wish List. An item goes to its product page instead of the cart when its type instance answers
canConfigure()orhasRequiredOptions(). - Full page cache. The product page is tagged
cat_pandcat_p_<id>by a block inlynx_catalog_product_view.xml, whatever the type.
Adding support for a new type
The example adds a hypothetical type vendor_giftcard, defined by an extension Vendor_GiftCard. Shoppers pick an amount and enter a recipient. The Lynx part goes into a compatibility module Vendor_GiftCardLynx, laid out as described in Making a Luma Extension Compatible. The same steps apply to a type you define yourself.
etc/module.xml:
xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Vendor_GiftCardLynx">
<sequence>
<module name="Vendor_GiftCard"/>
<module name="OOCS_LynxComponent"/>
<module name="OOCS_LynxPageBuilder"/>
<!-- Declares the Product Detail entries and the default product layouts -->
<module name="OOCS_LynxCatalog"/>
</sequence>
</module>
</config>In composer.json, require oocscommerce/module-lynx-component, oocscommerce/module-lynx-page-builder and oocscommerce/module-lynx-catalog.
1. Declare the page in the editor
Add the type's page to the pages argument of PreviewPagebuilder in the module's global etc/di.xml, next to the built-in product pages:
xml
<type name="OOCS\LynxPageBuilder\Model\PreviewPagebuilder">
<arguments>
<argument name="pages" xsi:type="array">
<item name="product_vendor_giftcard" xsi:type="array">
<item name="sort_order" xsi:type="number">20</item>
<item name="label" xsi:type="string">Gift card product page</item>
<item name="path" xsi:type="string">catalog_product_view_type_vendor_giftcard</item>
<item name="route" xsi:type="string">catalog/product/view</item>
<item name="sample" xsi:type="string">product</item>
<item name="type" xsi:type="string">vendor_giftcard</item>
<item name="group" xsi:type="string">Products</item>
</item>
</argument>
</arguments>
</type>path must be catalog_product_view_type_ followed by the type code exactly as getTypeId() returns it. With sample = product and type, the preview opens the first enabled product of that type that is visible in the catalog or search of the previewed store view. When the store view has no such product, the preview opens catalog/product/view without a product and shows the store's not-found page. The other keys are described in Make the page editable.
2. Give the page a Product Detail component
Components are declared in etc/components/*.graphqls files (see Declare the component). Whatever the option below, first let the Product frame and the gallery onto the page. Their declarations (Product in module-lynx-catalog/etc/components/Page.graphqls, ProductMedia in Media.graphqls) list exact type paths, so add yours with extend type in a .graphqls file of your module, for example etc/components/Product.graphqls. An argument of @component in an extend type replaces the old value, so repeat the current list and append your path:
graphql
extend type Product @component(availableOn: ["catalog_product_view_type_simple", "catalog_product_view_type_virtual", "catalog_product_view_type_configurable", "catalog_product_view_type_grouped", "catalog_product_view_type_bundle", "catalog_product_view_type_downloadable", "catalog_product_view_type_vendor_giftcard"])
extend type ProductMedia @component(availableOn: ["catalog_product_view_type_simple", "catalog_product_view_type_virtual", "catalog_product_view_type_configurable", "catalog_product_view_type_grouped", "catalog_product_view_type_bundle", "catalog_product_view_type_downloadable", "catalog_product_view_type_vendor_giftcard"])Your module must load after OOCS_LynxCatalog (<sequence> in etc/module.xml), and after any other installed module that extends the same list, since the module later in the sequence wins.
Then pick the lightest option that fits the type.
a. The type is bought like a simple product
If checkout/cart/add with product and qty (plus custom options) is all the type needs, reuse the simple Product Detail as virtual products do. Add your path to the availableOn of its declaration, ProductDetailSimple in module-lynx-catalog/etc/components/Simple.graphqls, the same way:
graphql
extend type ProductDetailSimple @component(availableOn: ["catalog_product_view_type_simple", "catalog_product_view_type_virtual", "catalog_product_view_type_vendor_giftcard"])The provider of that entry, Provider\Product\Detail\Simple, sends what AbstractDetail reads from any product (final price, rating summary, salability, URLs, labels, and the wish list and compare URLs when their settings are on), plus the regular price through addRegularPrice() and the tier prices and quantity labels through addQuantityProps(). The gallery comes from Product Media (Provider\Product\Media) and the breadcrumbs from Product (Provider\Product\Page). Slot changes other modules make to default_product_detail_simple apply to your page too.
b. The type needs a few extra fields
The simple Product Detail posts more than its own fields. Every component in its Product Options slot can contribute to the request through the product store of OOCS_LynxCatalog (useProductStore from OOCS_LynxCatalog/stores/productStore), which is how Product Custom Options works. Each contribution has:
| Key | Meaning |
|---|---|
fields | Extra checkout/cart/add parameters; a list value is posted once per entry |
files | Uploads by field name; any file switches the request to multipart |
price | Added to the price the Product Detail shows, and to the analytics value |
missing | Titles of required fields still empty. When it is not empty, Add to Cart does not post and calls requestValidation(product_id), which bumps validationTicks[product_id] so the block can mark its empty fields and move focus to the first one. |
base | Optional. Prices that replace the product's own (price, original_price, is_on_sale), as the picked child of a configurable product does |
images | Optional. Images Product Media shows before the product's own, as the picked child of a configurable product does |
So a gift card can keep the simple Product Detail from option a, and add a Gift Card Options component in the product_option group, the only group the Product Options slot accepts.
Declare it in etc/components/GiftCardOptions.graphqls, with every key its provider returns:
graphql
"Amount, recipient and message of a gift card, posted with Add to Cart"
type VendorGiftCardOptions @component(
code: "vendor_giftcard_options"
template: "Vendor_GiftCardLynx::Components/GiftCardOptions"
provider: "Vendor\\GiftCardLynx\\Provider\\GiftCardOptions"
label: "Gift Card Options"
group: "product_option"
icon: "ph-gift"
zones: ["content"]
availableOn: ["catalog_product_view_type_vendor_giftcard"]
) {
product_id: Int! @optional
amounts: [VendorGiftCardAmount!]! @optional
labels: StringMap! @optional
}
type VendorGiftCardAmount {
"Posted as the type reads it"
value: Float!
"Shown and added to the displayed price, in the current currency"
price: Float!
}The fields are @optional because the provider returns them only on a gift card page. The server drops any key the .graphqls does not declare, and the components take their props type, Lynx.VendorGiftCardOptions, from it.
The provider reads the viewed product. getAmounts() stands for whatever your type offers:
php
<?php
declare(strict_types=1);
namespace Vendor\GiftCardLynx\Provider;
use Magento\Catalog\Model\Product;
use Magento\Framework\Pricing\PriceCurrencyInterface;
use Magento\Framework\Registry;
use OOCS\LynxComponent\Model\AbstractComponentProvider;
use Vendor\GiftCard\Model\Product\Type\GiftCard;
class GiftCardOptions extends AbstractComponentProvider
{
public function __construct(
private readonly Registry $registry,
private readonly PriceCurrencyInterface $priceCurrency,
) {
}
public function resolve(array $props): array
{
/** @var Product|null $product */
$product = $this->registry->registry('current_product');
if (!$product || $product->getTypeId() !== GiftCard::TYPE_CODE) {
return $props;
}
/** @var GiftCard $type */
$type = $product->getTypeInstance();
// product_id keys this block's share of the request in the product store
$props['product_id'] = (int) $product->getId();
$props['amounts'] = array_map(
fn (float $amount): array => [
// Posted as the type reads it
'value' => $amount,
// Shown and added to the displayed price, in the current currency
'price' => (float) $this->priceCurrency->convert($amount),
],
$type->getAmounts($product)
);
$props['labels'] = [
'amount' => (string) __('Amount'),
'recipientName' => (string) __('Recipient Name'),
'recipientEmail' => (string) __('Recipient Email'),
'message' => (string) __('Message'),
];
return $props;
}
}The component reports its fields to the store whenever they change, and withdraws them when it goes away. The parameter names (giftcard_amount…) are the buy request keys your type reads in prepareForCartAdvanced():
vue
<!-- GiftCardOptions.vue -->
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useProductStore } from 'OOCS_LynxCatalog/stores/productStore'
import { formatPrice } from 'OOCS_LynxTheme/utils/price'
const props = withDefaults(defineProps<Lynx.VendorGiftCardOptions>(), {
product_id: 0,
amounts: () => [],
labels: () => ({}),
})
// This block's key among the contributions to the product's request
const CONTRIBUTOR = 'vendor_giftcard'
const optionsStore = useProductStore()
const amount = ref<Lynx.VendorGiftCardAmount | null>(null)
const name = ref('')
const email = ref('')
const message = ref('')
const section = ref<HTMLElement | null>(null)
const missing = computed(() =>
[!amount.value && props.labels.amount, !name.value.trim() && props.labels.recipientName,
!email.value.trim() && props.labels.recipientEmail].filter((title): title is string => !!title)
)
watch(
[amount, name, email, message, missing],
() =>
optionsStore.contribute(props.product_id, CONTRIBUTOR, {
fields: {
giftcard_amount: amount.value ? String(amount.value.value) : '',
giftcard_recipient_name: name.value,
giftcard_recipient_email: email.value,
giftcard_message: message.value,
},
files: {},
price: amount.value?.price ?? 0,
missing: missing.value,
}),
{ immediate: true }
)
// Add to Cart was pressed with a field empty
watch(
() => optionsStore.validationTicks[props.product_id],
tick => tick && section.value?.querySelector<HTMLElement>('input')?.focus()
)
// An Inertia visit to another product can keep the block mounted
watch(
() => props.product_id,
(id, previous) => previous && previous !== id && optionsStore.forget(previous, CONTRIBUTOR)
)
onUnmounted(() => optionsStore.forget(props.product_id, CONTRIBUTOR))
</script>
<template>
<section ref="section" class="border-0 border-b border-solid border-base-300 py-4 grid gap-4">
<fieldset class="grid gap-2">
<legend class="mb-2 font-semibold text-base-content">{{ labels.amount }}</legend>
<div class="flex flex-wrap gap-2">
<button
v-for="option in amounts"
:key="option.value"
type="button"
class="l-btn"
:class="amount?.value === option.value ? 'l-btn-primary' : 'l-btn-outline'"
:aria-pressed="amount?.value === option.value"
@click="amount = option"
>
{{ formatPrice(option.price) }}
</button>
</div>
</fieldset>
<label class="grid gap-1">
<span class="text-sm">{{ labels.recipientName }}</span>
<input v-model="name" type="text" required class="l-input w-full" />
</label>
<label class="grid gap-1">
<span class="text-sm">{{ labels.recipientEmail }}</span>
<input v-model="email" type="email" required class="l-input w-full" />
</label>
<label class="grid gap-1">
<span class="text-sm">{{ labels.message }}</span>
<textarea v-model="message" rows="3" class="l-textarea w-full" />
</label>
</section>
</template>The Product Detail does the rest: holding back Add to Cart while a required field is empty, the POST with X-Requested-With: XMLHttpRequest, the messages that come back with it, and the reload of the customer data sections that checkout/cart/add invalidates when the product went in (see The add to cart response). Add Amount, Recipient Name and the other new phrases to the module's i18n/en_US.csv.
c. The type needs its own Product Detail
When the type changes the product area itself (no quantity, a price range instead of a price, a preview of the card), write a Product Detail of your own: a provider that extends AbstractDetail and a component that starts as a copy of the simple one.
The provider adds the type's props in addTypeProps(). resolve() already returns the input unchanged when there is no current_product, and tags the page with the product's cache tags:
php
<?php
declare(strict_types=1);
namespace Vendor\GiftCardLynx\Provider\Product\Detail;
use Magento\Catalog\Model\Product;
use OOCS\LynxCatalog\Provider\Product\AbstractDetail;
class GiftCard extends AbstractDetail
{
protected function addTypeProps(Product $product, array $props): array
{
/** @var \Vendor\GiftCard\Model\Product\Type\GiftCard $type */
$type = $product->getTypeInstance();
$props['amounts'] = array_map(static fn (float $amount): array => ['value' => $amount], $type->getAmounts($product));
// Added to the labels AbstractDetail translated
$props['labels'] += [
'amount' => (string) __('Amount'),
'recipientName' => (string) __('Recipient Name'),
'recipientEmail' => (string) __('Recipient Email'),
];
return $props;
}
}AbstractDetail takes nine constructor dependencies. A subclass that needs another one repeats them and calls parent::__construct(), as Provider\Product\Detail\Downloadable does. Extend AbstractDetail rather than Detail\Simple: Magento applies plugins declared on a class to its subclasses, so plugins on the simple provider would run for your type too.
Declare the entry in etc/components/GiftCard.graphqls. @inherit copies the fields of the simple Product Detail, ProductDetailSimple in module-lynx-catalog/etc/components/Simple.graphqls, so merchants find the same areas on every product page: the Show Wishlist and Show Compare settings that AbstractDetail reads, the slots Below Price, Product Options (stored as product.info.additional, product_option only, one block) and Below Add to Cart with their full lists of allowed groups, and the data fields AbstractDetail sends. except drops what this provider never sends (it calls neither addQuantityProps() nor addRegularPrice()), and the fields declared here add what addTypeProps() returns:
graphql
"Product details for a gift card: the amount, the recipient and a message"
type VendorProductDetailGiftCard @component(
code: "vendor_product_detail_giftcard"
template: "Vendor_GiftCardLynx::Components/Product/GiftCard"
provider: "Vendor\\GiftCardLynx\\Provider\\Product\\Detail\\GiftCard"
label: "Product Detail (Gift Card)"
group: "product_page"
icon: "ph-tag"
zones: ["content"]
availableOn: ["catalog_product_view_type_vendor_giftcard"]
) @inherit(from: "ProductDetailSimple", except: ["original_price", "is_on_sale", "tier_prices"]) {
amounts: [VendorGiftCardDetailAmount!]! @optional
}
type VendorGiftCardDetailAmount {
value: Float!
}The gallery and its Below Gallery slot belong to Product Media, the breadcrumbs to Product: both already list your path from the start of this step.
For the component, copy Components/Product/Simple.vue from module-lynx-catalog/view/frontend/lynx/vue/src/ into Vendor/GiftCardLynx/view/frontend/lynx/vue/src/Components/Product/GiftCard.vue. It holds the price, tier prices, rating, the wish list and compare buttons and the three slots; keep those, render your fields where the quantity stepper is, and add the type's parameters to the request. The parts that change:
vue
<!-- GiftCard.vue -->
<script setup lang="ts">
// Imports and the wish list, compare and analytics code of Simple.vue stay as they are
const props = withDefaults(defineProps<Lynx.VendorProductDetailGiftCard>(), {
/* …defaults of Simple.vue, plus: */ amounts: () => [],
})
const amount = ref<number | null>(null)
const recipient = ref({ name: '', email: '' })
// Set by an add to cart with a gift card field empty; the template marks the empty fields while it is on
const fieldsAsked = ref(false)
async function addToCart() {
if (adding.value) return
const { name, email } = recipient.value
const fieldsMissing = amount.value === null || !name.trim() || !email.trim()
const optionsMissing = chosenOptions.value.missing.length > 0
if (fieldsMissing || optionsMissing) {
// Custom options mark their own, and take the shopper there unless a gift card field above comes first
if (optionsMissing) optionsStore.requestValidation(props.product_id, !fieldsMissing)
fieldsAsked.value = fieldsMissing
return
}
const body = new URLSearchParams({
product: String(props.product_id),
qty: '1',
form_key: formKeyStore.ensure(),
giftcard_amount: String(amount.value),
giftcard_recipient_name: name,
giftcard_recipient_email: email,
})
// From here on as in Simple.vue: custom option fields and uploads, fetch(props.urls.add_to_cart, …),
// the JSON result: messages, then analytics + customerDataStore.reload(props.sections.add_to_cart) on success, backUrl
}
</script>Validate required fields before posting, so the shopper sees the empty field marked instead of a refusal from the server. The built-in components post checkout/cart/add the same way for every type; only the parameters differ (super_attribute[<id>] for configurable, bundle_option[<id>][] for bundle, super_group[<id>] for grouped, links[] for downloadable).
The add to cart response
On a Lynx store, an AJAX request to checkout/cart/add (header X-Requested-With: XMLHttpRequest) answers with Magento's own JSON plus two keys, added by OOCS\LynxCatalog\Plugin\CartAddJson:
json
{
"success": true,
"messages": [{ "type": "success", "text": "You added Gift Card to your <a href=\"…\">shopping cart</a>." }],
"backUrl": "https://example.com/checkout/cart/"
}| Key | Meaning |
|---|---|
success | true when the product went in the cart: Magento dispatched checkout_cart_add_product_complete. A refused add (missing option, out of stock, quantity not available) is false |
messages | Magento's messages of the request, rendered as the customer-data messages section renders them (types success, error, warning, notice). They are taken out of the session, so they do not show a second time |
backUrl | Magento's own key, present when it wants the shopper elsewhere: the cart when After Adding a Product Redirect to Shopping Cart is on, or the product page when options are missing. Follow it with router.visit() |
The built-in components show the messages, track add_to_cart and reload the cart sections only when success is true, and follow backUrl when it is there. Do the same in your own Product Detail or card.
3. Ship a starting layout
Without a layout the new page stays empty until a merchant builds it. A data patch can install one into the default design, the same way InstallCatalogLayouts does for the built-in types (the row format is described in Ship a starting layout). The default design is read-only; merchant designs are copies of it and inherit every path they have not saved themselves, so the layout reaches existing designs too.
The simplest start is the simple product's default layout (a Group with Product, which holds Product Media and the Product Detail with Product Custom Options; the Tabs group; Related Products), with the Gift Card Options of option b added to the Product Options slot:
php
<?php
declare(strict_types=1);
namespace Vendor\GiftCardLynx\Setup\Patch\Data;
use Magento\Framework\Serialize\Serializer\Json;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use OOCS\LynxCatalog\Setup\Patch\Data\InstallCatalogLayouts;
use OOCS\LynxPageBuilder\Model\Design;
class InstallGiftCardLayout implements DataPatchInterface
{
private const TABLE = 'lynx_pagebuilder_layout';
public function __construct(
private readonly ModuleDataSetupInterface $moduleDataSetup,
private readonly Json $json,
) {
}
public static function getDependencies(): array
{
return [InstallCatalogLayouts::class];
}
public function getAliases(): array
{
return [];
}
public function apply(): self
{
$connection = $this->moduleDataSetup->getConnection();
$table = $this->moduleDataSetup->getTable(self::TABLE);
$value = (string) $connection->fetchOne(
$connection->select()
->from($table, 'value')
->where('design_id = ?', Design::DEFAULT_ID)
->where('path = ?', 'catalog_product_view_type_simple')
);
if ($value === '') {
return $this;
}
// First node: the Group; in it Product, whose Details slot holds the Product Detail. Its Product Options
// slot already holds Product Custom Options
$nodes = $this->json->unserialize($value);
$detail = &$nodes[0]['slots']['default'][0]['slots']['detail'][0];
$detail['slots']['product.info.additional'][] = [
'name' => 'vendor_giftcard_options',
'component' => 'Vendor_GiftCardLynx/Components/GiftCardOptions',
'label' => 'Gift Card Options',
'props' => [],
'visible' => true,
];
unset($detail);
$connection->insertOnDuplicate($table, [[
'design_id' => Design::DEFAULT_ID,
'path' => 'catalog_product_view_type_vendor_giftcard',
'value' => $this->json->serialize($nodes),
]], ['value']);
return $this;
}
}In a layout, component is written Module_Name/Path, with a slash, while the template of a .graphqls declaration uses Module_Name::Path. With a Product Detail of your own (option c), set $detail['name'] to vendor_product_detail_giftcard and $detail['component'] to Vendor_GiftCardLynx/Components/Product/GiftCard instead of adding the options node. The path $nodes[0]['slots']['default'][0]['slots']['detail'][0] is the shape the default design ships; a layout a merchant has changed may differ, so check the node's name before writing to it if you read from another design.
Layouts are cached in the lynx_component cache type, and product pages in the full page cache. After the patch has run, clean both. Without a patch, tell merchants to open Products › Gift card product page in the editor and build the page; the default design cannot be edited, so they do it in their own design.
4. Components around the Product Detail
The built-in product page components use catalog_product_view* and are offered on the new page without changes: Product Description, Specifications, Reviews, Custom Options, Product Attribute, Product Image Attribute, Price Alert, Stock Alert, Related and Up-sell Products. Product and Product Media list exact type paths; add yours as at the start of step 2. Custom Options post through the same product store, so they work in the Product Options slot of options a, b and c.
Components from other modules that list exact type paths (catalog_product_view_type_simple) are not offered on your page. Add your path to their availableOn with extend type, the same way as in option a. If a component of yours should go into a slot of your Product Detail, add its group to that slot's allow list; see Slots of built-in components.
5. Cards in product lists
Each card carries a needs_options flag, set on the server by OOCS\LynxCatalog\Helper\BuyFromList. With it set, the card's button opens the product page; without it, the card posts the product to the cart with qty=1. Product Listing, Search Results, Advanced Search Results, Product Carousel and the Related, Up-sell and Cross-sell rows all use it.
The flag follows Magento's own rule for list pages: $product->getTypeInstance()->isPossibleBuyFromList($product), which by default is false when the product has required options (the required_options column, kept by the type when the product is saved). Configurable products always get the flag, since the card can only add them once a swatch is picked, and so do grouped products, which need a quantity per child.
So for a type whose products need input before they go in the cart (an amount, a recipient), make its type class say so, the way Magento's own types do:
php
<?php
declare(strict_types=1);
namespace Vendor\GiftCard\Model\Product\Type;
use Magento\Catalog\Model\Product\Type\AbstractType;
class GiftCard extends AbstractType
{
/**
* An amount and a recipient are always needed
*
* @param \Magento\Catalog\Model\Product $product
* @return bool
*/
public function hasRequiredOptions($product)
{
return true;
}
// …
}If the type belongs to another vendor, a plugin on its type class (afterHasRequiredOptions or afterIsPossibleBuyFromList) does the same from your compatibility module. No theme override is needed.
The salable state on cards needs nothing: a composite type (isComposite()) is read from the stock index in bulk, any other type from isSalable().
Changing one existing type
Merchants open that type's page in the editor (Products › Configurable product page, for example) and change its layout. The other types' pages are not affected. Simple and virtual are two pages that share one Product Detail component, each with its own layout.
Developers have two options that touch one type only:
Override the type's component in the theme, for example
OOCS_LynxCatalog/lynx/vue/src/Components/Product/Configurable.vue; see Example: change the product page. OverridingProduct/Simplechanges simple and virtual products, and any type whose page you pointed at it.Plug into the type's provider to send more data. A plugin on
Provider\Product\Detail\Configurableruns only for configurable products:xml<type name="OOCS\LynxCatalog\Provider\Product\Detail\Configurable"> <plugin name="vendor_module_size_guide" type="Vendor\Module\Plugin\AddSizeGuide"/> </type>php<?php declare(strict_types=1); namespace Vendor\Module\Plugin; use OOCS\LynxCatalog\Provider\Product\Detail\Configurable; class AddSizeGuide { public function afterResolve(Configurable $subject, array $result, array $props): array { // Not on a product page: the provider returned the node's settings if (!isset($result['product_id'])) { return $result; } $result['size_guide_url'] = '/size-guide'; return $result; } }Declare the new prop in a
.graphqlsfile of your module, or the server drops it, and read it in a theme override of the component asprops.size_guide_url:graphqlextend type ProductDetailConfigurable { size_guide_url: String @optional }A plugin on
AbstractDetailitself would run for every type.
Checklist
- A real product of the type renders its page with the layout you shipped or built, on a store view with a Lynx theme.
- The editor lists the page under Products and the preview opens a product of that type. Product, Product Media, the Product Detail entry and your options component are offered there, and the Product Detail entry and options component only there.
- Add to Cart with every field filled adds the product with the right options; the cart page and mini cart show them.
- Add to Cart with a required field empty posts nothing, marks the field and moves focus to it. An add Magento refuses (out of stock, quantity) shows Magento's message.
- The mini cart count and the messages update without a page reload, and so does the wish list after Add to Wish List.
- Cards of the type in Product Listing, Product Carousel and the Related rows open the product page or add correctly.
- Saving the product in the admin refreshes its cached page (
cat_p_<id>). Your providers tag any other entity they show (Full page cache). - With SSR on, the page renders on the server: no
windowordocumentduring setup or render. - After adding the files, run
bin/magento lynx:theme:setupfor the store's themes, thenbin/magento lynx:static:deploy(Build and see it). - After changing a
.graphqlsfile, clean thelynx_componentcache (bin/magento cache:clean lynx_component) and runbin/magento lynx:static:deployagain, which also writes theLynx.*props types the components use.