.label` / `.value` in the row. * All three adapters fill the same shape, so one spec-table component is identical on a commerce site and * an enquiry site. * * `specs` was added on 2026-08-22 and its origin is worth recording. Before that it was marked "not * implemented", so the only route for NexCart's attributes to reach a layout was the shadow meta * `_aumnexcart_attr_` — and the shadow is **only generated for attributes registered as "filterable" * in NexCart's settings**, while a kit could not write plugin settings at all (the importer's options section * knew only a fixed handful). * The consequence was the spec table completely blank after a buyer imported the kit, with no error, while * our own target site had been configured by hand once and showed nothing wrong — the same class of accident * as "nobody but us has that import button". * An attribute is the product's own data and should not depend on a filtering feature's switch. * * post.product.gallery additional images, a list of `{url, alt}` rows, also ready for a loop * * `gallery` works exactly like `specs` (`source:'field'`) and is likewise filled to one shape by all three. * The main image is not in it — that is WordPress's featured image (`post.thumbnail.url`) and is the * same across all three engines. `gallery` means "the ones besides the main image"; merging them would make a * detail page render the main image twice. * * # Two kinds of button, both needed * * 1. `action_url` + `action_label` — the button is drawn in the editor, its styling entirely the * designer's, and the component is 100% portable. The cost: Woo's add-to-cart is an ordinary link * navigation with no AJAX. * 2. The `[aumframe_product_action]` shortcode — the adapter emits its own native HTML (Woo's AJAX add-to-cart * button, NexCart's contact button group). Full functionality, with the appearance decided by the adapter. * * Commerce sites use 2 and everything else uses 1. A Shortcode node is all it takes; no new editor capability * is required. * * @package Aumframe */ /** * The contract an adapter implements. * * An abstract class rather than an interface, deliberately: most fields do not apply to any given engine, and * an interface would force every adapter to write a pile of `return null`. */ final class Catalog { /** * The neutral vocabulary. Changing this changes the contract — the component library binds against it. * * `type` is not decoration: the editor filters by it * (`fields.filter(f => prop.accepts.includes(f.type))`, see editor/src/DataPanel.jsx). **A field without a * type never appears in the interface at all** — the data is sent, the binding panel is empty, and nothing * is reported. * Legal values: `text` / `url` / `image` for bindings; `list` for a loop's data source * (`listFields()` in editor/src/bindings.js takes only `type === 'list'`). A missing type, or an * unrecognised one, gives the same result: the data is sent, nothing is selectable in the interface, and * nothing is reported. */ const FIELDS = [ // Prices. price_html is the version meant for people: each engine assembles the correct shape // itself (Woo's sale strikethrough, NexCart's "From $500" or "Contact for price"), so binding it is // always right. The ones below are the raw ingredients, for layouts that want to arrange their own. 'price_html' => ['label' => 'Price (formatted)', 'type' => 'text'], 'price' => ['label' => 'Price (text)', 'type' => 'text'], 'price_kind' => ['label' => 'Price kind', 'type' => 'text'], 'regular_price' => ['label' => 'Regular price', 'type' => 'text'], 'price_from' => ['label' => 'Price from', 'type' => 'text'], 'price_to' => ['label' => 'Price to', 'type' => 'text'], 'currency' => ['label' => 'Currency', 'type' => 'text'], 'has_price' => ['label' => 'Has a price', 'type' => 'text'], 'on_sale' => ['label' => 'On sale', 'type' => 'text'], 'sku' => ['label' => 'SKU', 'type' => 'text'], 'availability' => ['label' => 'Availability', 'type' => 'text'], 'in_stock' => ['label' => 'In stock', 'type' => 'text'], // Digital delivery (2026-09-14, template #10): '1' when paying delivers a file rather than a parcel — // an "instant download" line on a card should read this, not be typed in. Only Woo answers it; // the other engines return '' (they sell enquiries, appointments or catalogue rows, not files). 'downloadable' => ['label' => 'Downloadable', 'type' => 'text'], 'action_url' => ['label' => 'Action link', 'type' => 'url'], 'action_label' => ['label' => 'Action label', 'type' => 'text'], 'can_act' => ['label' => 'Action available', 'type' => 'text'], // `list` is a loop's data source rather than a bindable value. Once chosen, rows bind `.label` / // `.value` 'specs' => ['label' => 'Specifications', 'type' => 'list'], 'gallery' => ['label' => 'Gallery', 'type' => 'list'], ]; /** * `price_kind`'s values — a layout branches on it. * * This field was forced into existence by NexCart: its price can be a range, a sentence like "Contact * for price", or nothing at all. With only `price_html`, adding a small "From" label to a range is * impossible — the layout has no way of knowing which kind it is. * * fixed a single price range a range (empty price_to = "from …") * text a non-numeric phrase none no price shown */ const PRICE_KINDS = ['fixed', 'range', 'text', 'none']; /** @var Adapter[] post_type => adapter */ private static $by_type = []; private static $booted = false; public static function boot(): void { if (self::$booted) return; self::$booted = true; add_action('init', [self::class, 'register_adapters'], 0); add_action('init', static function () { if (!interface_exists('AumViso_Product_Source')) { return; } add_filter('aumviso_product_sources', ['\\Aumframe\\Catalog\\VisoSource', 'register'], 20); }, 5); add_filter('aumframe/post_row', [self::class, 'post_row'], 10, 2); add_filter('aumframe/site_data', [self::class, 'site_data']); add_filter('aumframe/raw_expressions', [self::class, 'raw_expressions']); add_filter('aumframe/shortcode_html', [self::class, 'shortcode_html'], 10, 3); } public static function register_adapters(): void { Adapters\Cpt::maybe_register(); Adapters\Woo::maybe_register(); Adapters\NexCart::maybe_register(); Adapters\Reserva::maybe_register(); do_action('aumframe/catalog/register'); } public static function register(Adapter $adapter): void { foreach ($adapter->post_types() as $pt) { $held = self::$by_type[$pt] ?? null; // A dedicated adapter beats the fallback, regardless of registration order if ($held && $adapter->is_fallback() && !$held->is_fallback()) { continue; } self::$by_type[$pt] = $adapter; } } /** Content types with a dedicated (non-fallback) adapter — the admin uses it to decide whether to show * "treat as a catalogue". */ public static function dedicated_types(): array { $out = []; foreach (self::$by_type as $pt => $a) { if (!$a->is_fallback()) $out[$pt] = $a->label(); } return $out; } public static function adapter_for(int $post_id): ?Adapter { $pt = get_post_type($post_id); return $pt && isset(self::$by_type[$pt]) ? self::$by_type[$pt] : null; } public static function adapters(): array { return self::$by_type; } /** Attach post.product.*. It has to be lazy — this filter runs for every row. */ public static function post_row($row, $post) { if (is_array($row) && $post instanceof \WP_Post && isset(self::$by_type[$post->post_type])) { $row['product'] = new Bag((int) $post->ID); } return $row; } /** Tell the editor these fields are bindable — otherwise the user can only type paths from memory. */ public static function site_data($data) { if (!is_array($data)) return $data; foreach ((array) ($data['postTypes'] ?? []) as $i => $entry) { $pt = $entry['name'] ?? ''; if ($pt === '' || !isset(self::$by_type[$pt])) continue; $fields = (array) ($data['postTypes'][$i]['fields'] ?? []); foreach (self::FIELDS as $key => $meta) { $fields[] = [ 'path' => 'post.product.' . $key, 'label' => $meta['label'], 'type' => $meta['type'], // Without it, the editor filters this entry out ]; } $data['postTypes'][$i]['fields'] = $fields; } return $data; } /** price_html is assembled HTML (the sale strikethrough), so it has to be allowed through. */ public static function raw_expressions($list) { $list[] = 'post.product.price_html'; return $list; } /** * `[aumframe_product_action buttons="1"]` — the variation choices as a row of buttons instead of dropdowns * (2026-09-27, template #24: choosing a size from a