logo

Build a Plugin

A reference architecture for shipping an OV25 plugin on any e-commerce platform.

This guide is for engineers who need to wrap OV25 in a first-class plugin / app for an e-commerce platform we don't already ship for. It's a checklist plus the cross-cutting patterns we already shipped in the open-source WooCommerce extension.

One mental model, every platform. Every plugin is the same five layers wired into different platform APIs. Read the section that matches the layer you're working on, not the platform.


🧱 1. Plugin Anatomy

A complete OV25 plugin is five layers stacked on top of ov25-ui:

#LayerJobReference implementation
1Settings UIAdmin enters API key, selectors, branding, behaviour flagsOV25_Admin_Page (Woo) / Remix admin (Shopify)
2Product linkingMap a platform product → OV25 product link ('217', 'range/126', 'snap2/4', 'bed-configurator/2', 'dining-configurator/3')OV25_Product_Field metabox / product metafield
3Storefront bundleLoad on PDP/cart, call injectConfigurator(...), hide native ATCsrc/frontend/index.ts / extensions/ov25-configurator/assets/ov25-configurator.js
4Cart bridgeConvert OnChangePayload → cart-line metadata, override the line price, promote everything to the orderOV25_Ajax_Cart + cart-item-config.php / Shopify create-invoice webhook
5Checkout bridgeForce OV25-priced lines through a checkout that respects them and reject anything that bypasses itWoo cart_item_price filter / Shopify requestOV25Checkout + extensions/ov25-checkout-guard function

💡 You almost always want a sixth piece - a swatches path - but it's optional and reuses the cart bridge.


⚙️ 2. Settings UI - use ov25-setup, don't roll your own

Do not build the settings form by hand.

Every option injectConfigurator accepts (selectors, display modes, branding, flags, multi-line layout shells like snap2 / bed / dining) is already exposed by the ov25-setup React package - the same UI that powers the visual builder at app.orbital.vision/configurator-setup.

It emits the exact JSON shape that injectConfigurator consumes, with a live preview, so plugin authors get the entire settings surface for free and stay in sync as we add new options.

🌍 Two scopes, same component

All oif our plugins reference it twice:

ScopeWhere it livesSaved to
Global - defaults for every linked productA "Configurator Setup" page in the plugin's admin sectionwp_options['ov25_settings'].configuratorConfig (Woo) / shop.metafields.ov25.defaultConfiguratorSettings (Shopify)
Per-product override - optional, opt-inInside the product editor, behind an "Override global configurator settings" toggleProduct meta _ov25_configurator_config (Woo) / product.metafields.ov25.configuratorSettings (Shopify)

Both wire it up the same way:

import { ConfiguratorSetup, type ConfiguratorSetupPayload } from 'ov25-setup';
import 'ov25-setup/index.css';
 
<ConfiguratorSetup
  onSave={(payload: ConfiguratorSetupPayload) => savePluginSetting(payload)}
  className="h-full min-h-0 w-full flex"
/>

The payload is the same JSON injectConfigurator(...) already accepts - store it verbatim and pass it straight into the storefront bundle at runtime. The Woo extension's per-product editor (src/admin/components/ProductField.tsx) and global page (src/admin/pages/ConfiguratorSetup.tsx) are both ~50 lines of wrapper around <ConfiguratorSetup> plus the platform's save mechanism.

2.1 Merging global + per-product

When both exist, deep-merge the per-product payload over the global one before injecting:

Per-product wins. Arrays replace, objects merge.

The Woo plugin does this in resolveConfiguratorConfig + deepMerge (src/frontend/index.ts); the Shopify equivalent is in assets/ov25-configurator.js (window.ov25DefaultConfiguratorSettings overlaid by window.ov25ProductConfiguratorSettings).

Configurator layouts are bucketed by shape (single-line vs multi-line - currently standard vs snap2) so a product flipped to a multi-line layout picks up the right defaults.

2.2 What the plugin still has to persist itself

ov25-setup does not know about the things that aren't part of injectConfigurator's contract. Your settings UI still needs fields for:

  • apiKey - productConfiguratorAccess key, generated at app.orbital.vision/auth/api-keys. Used by the storefront bundle.
  • privateApiKey - a separate private productConfiguratorAccess key used only by the admin / settings UI to call the products-list endpoint and the swatches admin endpoints. Never expose it to the storefront.
  • swatchProductId - platform product/variant id used to charge for sample packs.
  • Anything platform-specific - Woo cart flags like useNativeCartSubmit / disableCartFormHiding, Shopify metafield names, etc.

Push the merged result into a window.ov25Settings-style global from the server template so the storefront bundle picks it up synchronously - see ov25_configurator.liquid (Shopify) and OV25_Admin_Page::enqueue_settings (Woo).

Don't fetch settings asynchronously from the storefront bundle. injectConfigurator should be callable on the first paint of the PDP, otherwise the gallery will flash unstyled.


🔗 3. Product Linking

Each platform product needs one piece of data: the OV25 productLink string. The current valid shapes - every one of them is rendered straight from the products-list endpoint:

ShapeUse it forCart shape
"217"A standard single-product configuratorsingle line
"range/126"A range selector (lets the user pick a sub-product before configuring)single line
"snap2/4"Snap2 modular scene attached to a rangemultiple lines
"bed-configurator/2"A bed configurator (base + headboard + mattress + accessories)multiple lines
"dining-configurator/3"A dining configurator (table + chairs + benches)multiple lines

🧭 Branch on shape, not prefix. Anything that writes through mode: 'multi' is a multi-line layout. Future configurator types will surface as new prefixes on this same field - your plugin only needs to branch on payload.skus.mode === 'single' | 'multi', never on the prefix string. Treat the productLink as an opaque string the merchant pastes in from the picker.

3.1 The products-list endpoint

To build a product picker (so the merchant doesn't have to type the link by hand), call:

GET https://app.ov25.ai/api/public/products-list?apiKey={privateApiKey}

{privateApiKey} is the private productConfiguratorAccess key from your settings UI - call this server-side (Woo: OV25_Admin_API) or from your platform's admin extension context (Shopify: extensions/ov25-product-admin/src/utils.js → fetchOV25Products); never from the storefront.

The response has three top-level lists. ranges carries products and snap2; bedConfigurators and diningConfigurators are separate top-level entries because they aren't tied to a product range:

{
  ranges: [
    {
      id: number;
      name: string;
      manufacturerName: string;
      description: string | null;
      hasSnap2: boolean;
      snap2Active: boolean;
      products: [
        {
          id: number;
          name: string;
          thumbnail: string | null;
          category: string | null;
          hasConfigurator: boolean;
          status: boolean;
        },
      ];
    },
  ],
  bedConfigurators: [
    {
      id: number;
      name: string;
      manufacturerId: number;
      sizes: string[] | null;          // bed sizes the configurator supports
    },
  ],
  diningConfigurators: [
    {
      id: number;
      name: string;
      manufacturerId: number;
    },
  ],
}
Source fieldproductLink to writeShow as
ranges[i].products[j].id"{id}" (just the number)a single product
ranges[i].id (always)"range/{rangeId}"a range selector
ranges[i] where hasSnap2 && snap2Active"snap2/{rangeId}"a snap2 modular scene
bedConfigurators[i].id"bed-configurator/{id}"a bed configurator (use sizes for the subtitle)
diningConfigurators[i].id"dining-configurator/{id}"a dining configurator

The Shopify picker (extensions/ov25-product-admin/src/utils.js → fetchOV25Products) is the canonical implementation - it iterates all five buckets in this order and pushes one item per row, with kind, linkValue, name, and subtitle so the search UI can group/filter.

🧱 Mirror that structure rather than rolling your own - if Orbital Vision adds a new top-level configurator family (e.g. kitchenConfigurators), your picker only needs to add one more loop.

Picker UX gotchas

  • Bed and dining are MANUFACTURER-key only. Retailer keys can return non-empty bedConfigurators / diningConfigurators lists too, but only for entries explicitly granted to that retailer in OV25 - empty arrays are normal for retailer keys, don't surface that as an error.
  • Render the bed sizes array (e.g. "Single, Double, King") as a subtitle so merchants who carry the same bed in multiple sizes can pick the right configurator instance.
  • hasSnap2 without snap2Active means the range has a snap2 configurator that's currently disabled in OV25 - don't expose "snap2/{id}" as a valid choice for that row.

Always provide a manual-entry fallback (text input) for when the API is unreachable or the merchant uses a retailer key that returns an empty list.

PlatformStorage location
WooCommercePost meta - _ov25_product_id on the product
ShopifyMetafield - product.metafields.ov25.configuratorID
Anything elseWhatever your platform uses for "private product attributes" / "metafields" / "custom fields"

The key thing is that it has to be readable from the storefront context (Liquid, PHP template, theme JSON, etc.) so the bundle can pick it up at first paint.

If your plugin supports per-product overrides for the inject config (see section 2.1), store that as a second JSON field next to the product link.


🖼️ 4. The Storefront Bundle

This is where ov25-ui does almost all of the work. The bundle's only job is to:

  1. Build the inject config from the global settings + per-product overrides.
  2. Call OV25.injectConfigurator(...) once.
  3. Wire addToBasket, buyNow, buySwatches callbacks to your platform's cart APIs.
  4. Hide the native add-to-cart UI on linked PDPs.

A minimal cross-platform skeleton:

import * as OV25 from 'ov25-ui';
import type { OnChangePayload } from 'ov25-ui';
 
const s = window.ov25Settings;
const link = window.ov25Settings.productLink;
if (!link) return;
 
OV25.injectConfigurator({
  apiKey: s.apiKey,
  productLink: link,
  images: s.images || [],
  selectors: {
    gallery:        { selector: s.gallerySelector,        replace: true },
    variants:       { selector: s.variantsSelector },
    price:          { selector: s.priceSelector,          replace: true },
    name:           { selector: s.nameSelector,           replace: true },
    swatches:       { selector: s.swatchesSelector },
    configureButton:{ selector: s.configureButtonSelector,replace: true },
  },
  branding: { logoURL: s.logoURL, cssString: s.customCSS },
  callbacks: {
    addToBasket:  (payload) => addToCart(payload, false),
    buyNow:       (payload) => addToCart(payload, true),
    buySwatches:  (swatches, rules) => createSwatchOnlyCart(swatches, rules),
  },
});

addToCart is your platform-specific function - it takes the normalized OnChangePayload produced by ov25-ui and POSTs it to whatever cart endpoint the platform exposes. See section 6 for what to do with the payload.

📚 For the full surface area of injectConfigurator (every selector, flag, branding option, and multi-line layout setting) read UI Package Integration. For the raw iframe message types under the hood—including SELECT_SELECTION by UUID triple or by a single fuzzy-matched display-name pair—read Code Integration.

4.1 Hiding the native add-to-cart UI

Once OV25 is in charge of pricing, you cannot let the platform's native form post - it would add the item at the platform's static price.

Both reference plugins remove the native form on linked PDPs:

  • WooCommerce (src/frontend/index.ts → ensureOv25NativeAtcHideStyles + scheduleOv25NativeProductFormRemoval) ships a CSS rule keyed by body.postid-{id} that hides every flavour of form.cart submit, plus a MutationObserver that yanks any form WooCommerce re-renders later (variations form, blocks, etc.).
  • Shopify does the same with a Liquid guard ({% if product.metafields.ov25.configuratorID %}) plus a JS hijack on [name="checkout"] and .shopify-payment-button.

🧯 Provide a way to opt out (the Woo plugin uses disableCartFormHiding / useNativeCartSubmit) for themes that need to keep the native form for non-OV25 reasons.

You should also remove the Add to Cart button from category / collection / related-product loops on linked products (those bypass the PDP and would post at the catalogue price). The Woo plugin does this in OV25_Loop_Button_Hook via the woocommerce_loop_add_to_cart_link filter; on Shopify the equivalent is wrapping the loop button in a {% if product.metafields.ov25.configuratorID %} guard.

The replacement should send the merchant to the PDP (or open the configurator from the loop), never let them buy without configuring.

4.2 Server-rendered DOM placeholders beat CSS selectors

Wherever you can, drop server-rendered placeholder elements on the PDP and let injectConfigurator mount into them, instead of relying on CSS selectors that vary per theme:

PlaceholderWhere the bundle reads itBoth plugins emit it from
data-ov25-iframe="{apiKey}/{productLink}"Source of truth for apiKey + link at inject timeWoo OV25_Gallery_Hooks::inject_attribute_into_gallery_html / Shopify ov25_configurator.liquid
data-ov25-variantsMount point for the options/variants treeWoo OV25_Variant_Hook::render_placeholder_*
data-ov25-configure-buttonMount point for the "Configure" CTA on simple themesSame hook, gated by useSimpleConfigureButton

The CSS selectors in window.ov25Settings (gallerySelector, variantsSelector, …) stay as the escape hatch for themes that won't tolerate template overrides. Document both paths in your plugin's setup wizard.

4.3 Thumbnails

OV25 exposes window.ov25GenerateThumbnail() after injectConfigurator finishes - call it inside addToBasket / buyNow and ship the resulting URL on the cart line so every layer of the platform shows the configured product, not the catalogue photo.

You will need to override the thumbnail in every of these contexts:

  • 🛒 Mini-cart / side-cart drawer
  • 📄 Cart page
  • 💳 Checkout summary
  • ✅ Order confirmation page
  • ✉️ Transactional emails
  • 🛠 Order detail in the admin / merchant dashboard
  • 🌐 Any REST / GraphQL / Store API response the theme uses for cart rendering (woocommerce_store_api_cart_item_images on Woo, line-item image field on Shopify)

The Woo plugin hooks four separate filters (woocommerce_cart_item_thumbnail, woocommerce_checkout_item_thumbnail, woocommerce_order_item_thumbnail, woocommerce_store_api_cart_item_images); plan an equivalent matrix for your platform up-front, otherwise customers see two different products in cart vs checkout.


📦 5. The OnChangePayload Contract

The single piece of API surface you need to understand to wire any platform's cart is what ov25-ui hands you. Every commerce callback (addToBasket, buyNow, onChange) receives a normalized payload with this shape:

type OnChangePayload = {
  price: UnifiedPricePayload | null;
  skus:  UnifiedSkuPayload   | null;
};
 
interface UnifiedPricePayload {
  mode: 'single' | 'multi';
  totalPrice: number;          // integer minor units (pence/cents)
  subtotal: number;
  formattedPrice: string;
  formattedSubtotal: string;
  discount: { amount: number; formattedAmount: string; percentage: number };
  lines: CommerceLineItemPrice[];
  // Raw passthrough (single-line priceBreakdown, multi-line productBreakdowns) when present
  priceBreakdown?: unknown[];
  productBreakdowns?: ProductPriceBreakdown[];
}
 
type UnifiedSkuPayload =
  | { mode: 'single'; skuString: string; skuMap?: Record<string,string>; lines: SkuLine[] }
  | { mode: 'multi';  lines: SkuLine[] };
 
interface SkuLine { id: string; skuString: string; skuMap?: Record<string,string>; quantity: number; }

🎯 Two decisions follow from mode

modeMeaningWhat you do at the cart
'single'Standard or range product, one configured itemAdd one cart line. Use price.totalPrice as the unit price, skus.skuString as the SKU.
'multi'A multi-line layout (snap2 today; dining, bed, and any future multi-line configurator come through the same mode) with N itemsAdd N cart lines (one per skus.lines[i]). Use each line's quantity and the matching productBreakdowns[i].price as the line total.

Money is integer minor units. totalPrice and subtotal are always integers (e.g. 12950 = £129.50). Don't reparse formattedPrice; just format totalPrice / 100 with the user's locale when displaying.

If you need the raw iframe payloads (e.g. you want to render a per-selection breakdown in the cart), pull them from price.priceBreakdown or price.productBreakdowns.


🛒 6. The Cart Bridge

The cart bridge is a server endpoint that takes the normalized payload and creates one or more cart lines with the correct dynamic price. Three problems to solve.

6.1 Force the configured price onto the line

Every e-commerce platform calculates line prices from the catalogue - you have to override that. Three patterns cover essentially every platform:

Pattern A - Cart-line price filter

Best for: WooCommerce, Magento, anything with a per-line price hook.

Store cfg_price as cart-item meta and re-apply it on every cart total recalculation, not just at add-to-cart. WooCommerce does this by cloning WC_Product per cart row inside woocommerce_before_calculate_totals (priority 1000) and calling set_price(), set_regular_price(), set_sale_price(''). Same goes for the price HTML filter (woocommerce_cart_item_price) so cart/checkout templates show the OV25 number.

Pattern B - Custom-price cart attribute

Best for: Magento custom_price, BigCommerce list_price, Adobe Commerce original_custom_price.

Set it directly when adding the line. Easiest path when available.

Pattern C - Draft order / invoice redirect

Best for: Shopify, any platform with a locked storefront cart.

You can't override the line price, so the native cart stays at the catalogue price as a placeholder; the checkout button is hijacked (requestOV25Checkout) and POSTs the cart to a server webhook that creates an Admin-API draft order with custom line prices and returns an invoiceUrl to redirect to.

Cross-cutting requirements

Whichever strategy you pick:

  • Re-apply on every recalc. Carts can recalculate on coupon application, address changes, currency switches, and quantity edits. The override has to be idempotent and run every time.
  • Respect tax-display mode. Read the platform's "prices include tax" setting and honour it when displaying - Woo uses wc_get_price_including_tax / wc_get_price_excluding_tax against the cloned product. Don't hard-code an inclusive or exclusive number.
  • Respect the storefront's currency. Multi-currency / multi-region storefronts (Shopify Markets, Woo Multi-Currency, etc.) will format totalPrice for display but you still send the OV25 number unchanged - currency conversion is the platform's job. The Shopify plugin reads localization.country.currency from Liquid and forwards shopifyShopCurrencyCode so the bundle renders the symbol correctly.
  • Validate the price server-side. Never trust cfg_price from the client without re-running it past your OV25 backend.

6.2 Persist the configuration on the line

Stash everything you'll need later (cart UI, email receipts, order admin, factory export) on the cart line as private metadata. The keys both reference plugins use:

KeyValue
_ov25_product_idOV25 product link, e.g. "snap2/4"
_ov25_product_skuFinal SKU string for this line
_ov25_product_base_priceCatalogue base price (minor units)
_ov25_original_priceSubtotal before discount (minor units)
_ov25_final_priceFinal unit price (minor units)
_ov25_organization_idOV25 org id (parsed from API key prefix)
_ov25_discount_percentagediscount.percentage from the payload
_ov25_ssJSON of the per-selection breakdown
_ov25_cart_image_urlResult of window.ov25GenerateThumbnail()

🔒 Underscore-prefixed properties are hidden from the customer in both Shopify and WooCommerce - use whatever the equivalent "private/hidden line property" convention is on your platform.

The visible "cart properties" the customer sees on the cart line should be a small, human-readable subset:

Fabric:   Olive Velvet
Legs:     Walnut
Frame:    Solid Oak

Both reference plugins build this list by decoding cfg_skumap (the JSON SKU map) and emitting one row per non-structural key - see Woo's woocommerce_get_item_data filter in cart-item-config.php.

Never rely on selectedSelections from a global; the cart line has to be self-contained because it gets serialized into sessions, drafts, and order rows that outlive the configurator instance.

6.3 Handle multi-line add-to-cart

When payload.skus.mode === 'multi', you must split the request into one cart line per skus.lines[i]. Every line has its own skuString, quantity, and matching entry in price.productBreakdowns[i] (per-line subtotal, price, selections, image).

This is the path snap2 uses today; dining, bed and any future multi-line configurator type produce the same payload shape, so a mode === 'multi' branch covers all of them - don't gate the cart-bridge logic on the snap2/ prefix.

The Woo extension does this in OV25_Ajax_Cart::finish_multi_line_add_to_cart:

  1. Allocate per-line totals from cfg_payload.price.lines (preferred) or split cfg_price evenly as a fallback (allocate_line_prices_minor).
  2. Reconcile rounding drift so the per-line sum matches cfg_price (reconcile_line_pence_to_cfg_total).
  3. Tag every resulting cart row with the same ov25_multi_line_group UUID so the storefront / order admin can show them as one bundle.
  4. Roll back all already-added rows if any single line fails validation.

The Shopify equivalent (buildSnap2CreateInvoiceItems) builds one item per productBreakdowns row and ships them to the create-invoice webhook for resolution to Shopify product/variant ids on the server.

6.4 Promote line meta to the order

The cart bridge is only half the story - the same metadata has to land on the order so production / fulfilment / customer service can read it after checkout.

Hook the platform's "create order line item from cart item" event:

PlatformHook
WooCommercewoocommerce_checkout_create_order_line_item
ShopifyCopy via the draft-order create payload

…and copy:

  • SKU - the final cfg_sku for the line
  • PART - multi-line carts only - the per-line product label decoded from skuMap.Product/Products; lets factory/admin tell which item in the multi-line set the row belongs to
  • One order-meta entry per non-structural skuMap key (Fabric: …, Legs: …, Frame: …)
  • The thumbnail URL (so admin order screens and emails render the configured product, not the catalogue photo)
  • The _ov25_* private bag from section 6.2 verbatim, in case support needs to re-issue or audit a configuration

If your platform splits cart-item meta and order-item meta into different storage (Woo does), you have to write to both. Don't assume cart meta automatically promotes.


💳 7. The Checkout Bridge

Whatever you do at the cart, the checkout has to honour the OV25 price too.

Two patterns:

Pattern A - Cart-line price filter

WooCommerce, Magento, anything with a checkout-line filter.

Hook the platform's per-line price calculator and reapply cfg_price. This means default checkout, Shop Pay equivalents, finance widgets, and analytics all see the OV25 price natively. Make sure the filter runs on every OV25 line on every request - not just at add-to-cart.

Pattern B - Custom checkout

Shopify.

Shopify's checkout cannot accept arbitrary line-item prices, so the plugin POSTs the cart to a webhook that creates an Admin-API draft order with custom line prices and returns an invoiceUrl. The plugin replaces the storefront checkout button (requestOV25Checkout) with one that calls the webhook then window.location = data.invoiceUrl.

Subtleties shared by both patterns

1. Hide accelerated checkouts when an OV25 product is in the cart.

Apple Pay / Google Pay / Shop Pay / PayPal Express all skip your custom price logic - they post directly from the cart to the wallet at the catalogue total.

Detect "any cart line has an OV25 product link" and CSS-hide every express button you can find. The reference Shopify integration keys this on cart.items[].properties._ov25_product_id; the Woo equivalent sits in class-ov25-ajax-cart.php. See the theme docs in Shopify.

2. Provide a programmatic checkout entry point (window.requestOV25Checkout()) so themes that ship custom checkout buttons can opt in instead of being hijacked.

3. Set a cart-level marker when redirecting through your custom checkout. If your platform supports cart-level attributes (Shopify's cart.attributes, Woo's session data), set something like ov25_draft_order=true whenever you build the OV25 invoice/draft order. This is what your server-side guard checks (next bullet).

4. Ship a server-side guard.

Theme code, browser extensions, deep links, and copy-pasted checkout URLs can all bypass the JS hijack. Without a server-side guard, you will eventually ship configured products at the wrong price.

Add a server-side guard at the checkout step that fails if a cart line has an OV25 product link and the cart-level "this went through OV25" marker is missing.

PlatformGuard implementation
ShopifyCart Validations Function (extensions/ov25-checkout-guard) returning "Custom pricing not applied. Please return to cart" when ov25_draft_order != 'true' and an OV25 product is present
WooCommerceThrow WC_Error from woocommerce_check_cart_items

🎨 8. Swatches

Sample-pack ordering uses the same payload contract but goes through a separate "swatches-only cart" path.

The user clicks Order Samples in the configurator UI → ov25-ui calls your buySwatches(swatches, rules) callback → you redirect them to a checkout for the chosen swatch SKUs.

Both reference plugins implement it as a single REST endpoint:

PlatformEndpoint
WooCommercePOST /wp-json/ov25/v1/create-swatch-cart (OV25_Swatch_API) - builds a Woo cart of swatch products (priced from rules.pricePerSwatch) and returns the checkout URL
ShopifyPOST /webhooks/shopify/swatches/create-cart - builds a Shopify cart with swatch variants and returns the cart URL

You also need a swatch book page - a page template that renders the swatches block from ov25-ui against your whole product range. See the WooCommerce OV25_Swatch_Page and the Shopify ov25_swatches.liquid block for examples.


🎛️ 9. Programmatic Controls

Once injected, OV25 exposes a small global API the host theme can call from custom buttons:

window.ov25OpenConfigurator(optionId?: string);  // optional optionId opens directly to that option
window.ov25CloseConfigurator();
window.ov25OpenSwatchBook();
window.ov25CloseSwatchBook();
window.ov25GenerateThumbnail(): Promise<string>;
window.requestOV25Checkout();

Expose at least ov25OpenConfigurator and requestOV25Checkout in your plugin's documented API - every storefront integrator will need them at some point.


🚀 10. Update & Distribution

A real plugin needs to ship updates without breaking stores in production:

  • 🏷️ Versioning - tag every release in semver. The Woo plugin uses Plugin Update Checker pointed at GitHub releases (includes/plugin-update-checker/).
  • 🛑 Kill switch - keep the ability to remotely disable the plugin if a release goes wrong. The Woo extension polls a webhook (/api/woo-commerce/kill-switch) at boot, returns early if it gets disabled, and caches the result in a 5-minute transient.
  • 🪵 Logging prefix - every console message should be prefixed ([ov25-woo], [ov25-shopify]) so support can grep merchant consoles quickly.
  • 🧬 Migration safety - when you rename a settings field or cart-item key, write a one-shot migration that copies the old value to the new one.

Stable cart-item keys. OV25 cart-item keys are stable - never rename cfg_price, cfg_sku, cfg_payload, cfg_commerce_mode or any _ov25_* line property.


📚 11. Reference Implementations

Both repositories are MIT-licensed; copy and adapt liberally.

WooCommerce extension

orbitalvision/ov25-woo-extension

FileWhat it covers
src/frontend/index.tsFull storefront bundle (inject + cart + thumbnail + multi-line splitting)
includes/class-ov25-ajax-cart.phpServer cart endpoint with per-line price reconciliation
includes/class-price-hook.phpReplaces woocommerce_get_price_html with an OV25 price skeleton
includes/class-product-field.phpProduct metabox + per-product config override

Shopify app

orbitalvision/ov25-shopify-app (private; ask Orbital Vision for access)

FileWhat it covers
extensions/ov25-configurator/blocks/ov25_configurator.liquidLiquid → window globals
extensions/ov25-configurator/assets/ov25-configurator.jsStorefront bundle
extensions/ov25-checkout-guard/Function extension that prevents non-OV25 checkout for OV25 lines
app/routes/...Remix admin (settings + product linking)

Read this first. If you only read one file before starting, read src/frontend/index.ts - every cross-cutting concern (payload normalization, multi-line splits, native form removal, cart-meta keys, thumbnail capture) shows up there in under 1000 lines.