Appearance
Forms, reCAPTCHA and Analytics
This page covers two things a module's storefront components often need: protecting a form with Google reCAPTCHA, and reporting what shoppers do to analytics. It ends with a complete example of adding a tracking provider alongside the built-in Google Analytics 4 component.
Google reCAPTCHA
reCAPTCHA in Lynx uses Magento's own configuration and validation. The merchant chooses a reCAPTCHA type per form in Stores › Configuration › Security › Google reCAPTCHA Storefront, and Magento's Magento_ReCaptcha* modules validate the token on the server. OOCS_LynxReCaptcha only handles the storefront side: it passes the configuration to the page, draws the widget and makes sure the token goes out with the request.
Nothing is loaded from Google until a shopper starts filling in a protected form.
Protecting a form
Add data-recaptcha="<key>" to the <form>. The key is one of the form keys of Google reCAPTCHA Storefront, the children of the config path recaptcha_frontend/type_for. Lynx's own forms use these:
| Key | Lynx form |
|---|---|
customer_login | Sign in |
customer_create | Create an account |
customer_forgot_password | Forgot password |
customer_edit | Account information |
contact | Contact us |
newsletter | Newsletter subscription |
product_review | Product review |
coupon_code | Discount code, on the cart and in the checkout |
place_order | Place order, in the checkout |
Any other key defined under recaptcha_frontend/type_for, by Magento (such as sendfriend or wishlist) or by another module, works the same on your own form. A form whose key has no type selected, or no site key and secret, is left alone.
vue
<form :action="urls.post" method="post" data-recaptcha="contact">
<!-- fields -->
<div data-recaptcha-slot></div>
<button type="submit" class="l-btn l-btn-primary">{{ labels.submit }}</button>
</form>When the shopper first focuses a field in the form, the widget is drawn inside the element marked data-recaptcha-slot, or just above the block holding the submit button when there is none. On submit:
- With a token, the submit goes ahead and the widget is reset for the next attempt.
- Without one, the submit is stopped. A checkbox widget is shown with Magento's configured failure message; an invisible widget or reCAPTCHA v3 gets its token and submits the form again.
After changing reCAPTCHA keys or types in the admin, refresh the configuration cache: the reCAPTCHA settings are cached with the rest of the storefront configuration.
Forms the browser submits
If the browser submits the form itself (a normal POST to the form's action), the attribute is all you need. The widget keeps its token in a <textarea name="g-recaptcha-response"> inside the form, so it is posted with the other fields and Magento's observer for that route validates it.
Forms you submit yourself
If your submit handler builds the request (fetch, an Inertia router.post), read g-recaptcha-response from the form synchronously in the handler and send it along, because the widget resets right after the submit event. Send it as the g-recaptcha-response parameter for a controller, or in the X-ReCaptcha header for a REST endpoint.
vue
<script setup lang="ts">
async function onSubmit(event: SubmitEvent) {
event.preventDefault()
const form = event.currentTarget as HTMLFormElement
// Read now: the widget resets once the submit event is over
const captcha = (form.elements.namedItem('g-recaptcha-response') as HTMLTextAreaElement | null)?.value
await fetch(form.action, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({
email: form.email.value,
...(captcha ? { 'g-recaptcha-response': captcha } : {}),
}),
})
}
</script>
<template>
<form :action="urls.post" data-recaptcha="vendor_foo_form" @submit="onSubmit">
<!-- fields -->
</form>
</template>The reCAPTCHA component stops the submit in the capture phase when there is no token yet, so your handler only runs once a token exists.
reCAPTCHA without a form
For an action that is not a form submit, such as a button that calls a REST endpoint, use the reCAPTCHA store from OOCS_LynxReCaptcha/stores/recaptchaStore:
ts
import { useRecaptchaStore } from 'OOCS_LynxReCaptcha/stores/recaptchaStore'
const recaptcha = useRecaptchaStore()
// When the action becomes visible: draw a checkbox widget at the anchor ahead of time
recaptcha.prepare('vendor_foo_action', anchorEl)
// When the shopper acts
const token = await recaptcha.resolve('vendor_foo_action', anchorEl)
if (token === null) return // checkbox not ticked yet; the widget is shown at the anchor
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { 'X-ReCaptcha': token } : {}) },
body: JSON.stringify(payload),
})resolve() returns '' when the key is not protected (or the reCAPTCHA module is disabled), null when a checkbox still has to be ticked, and the token otherwise.
A new key for your module's form
A form posting to your own controller needs its own key, which you add the Magento way: a field under recaptcha_frontend/type_for/<key> in system.xml, and an observer on your route's controller_action_predispatch_* event that calls Magento's Magento\ReCaptchaUi\Model\RequestHandlerInterface. Once the merchant selects a type for it, data-recaptcha="<key>" works on the storefront with no other change.
Checkout
The Payment step asks for a token under place_order before posting payment-information, and sends it in the X-ReCaptcha header. A payment method validated under a different key declares it with the recaptcha item of its paymentMethodRenderers entry. Use none when Magento exempts the method. A checkbox widget is drawn just above Place Order, once the chosen method's renderer reports loaded.
Analytics events
Lynx has one analytics bus, the analytics store in OOCS_LynxTheme/stores/global/analyticsStore. Components that own the data track events on it; trackers (the Google Analytics 4 component, or yours) subscribe and forward events to their service. Producers don't know which trackers are on the page, and trackers don't need to read page data.
The store API
| Member | Meaning |
|---|---|
track(event, params = {}) | Sends an event to every registered tracker and keeps it in a buffer of the current page's events (the last 20; a page_view starts a new page) |
trackOnce(key, event, params = {}) | Like track(), but at most once per browser session for key (kept in sessionStorage) |
addTracker(tracker) | Registers tracker(event, params) and replays the current page's buffered events to it |
removeTracker(tracker) | Unregisters a tracker |
Buffered events
The events of the current page, such as its page_view and a product page's view_item, are replayed to a tracker that registers later, so a tracker whose script loads after the GA4 node still sees the whole page. Events of earlier pages are not replayed.
A tracker that throws is logged to the console and skipped: the other trackers still get the event, and the step that reported it (adding to cart, placing an order) carries on.
Use it from components:
ts
import { useAnalyticsStore } from 'OOCS_LynxTheme/stores/global/analyticsStore'
const analytics = useAnalyticsStore()
analytics.track('vendor_foo_event', { value: 1 })Events Lynx tracks
Event names and parameters follow the GA4 ecommerce format. currency is the store's currency code. An item is { item_id, item_name, price, quantity }, where item_id is the SKU.
| Event | Tracked by | When | Parameters |
|---|---|---|---|
page_view | The theme's entry point | Every page load and every page change | page_location, page_title |
view_item_list | Product Listing | The list is shown, and again when it changes (pagination, filters, search) | item_list_name (the list title), items with index and without quantity |
view_item | Product page (simple, configurable, bundle, grouped, downloadable) | The product page is shown | currency, value (price), items (one item, without quantity) |
add_to_cart | Product page, Product Listing quick add, Product Carousel, Checkout Cart | The server accepted the add; on the cart, a quantity increase | currency, value, items. On the product page the price includes the chosen options; grouped products send one item per product with a quantity |
remove_from_cart | Checkout Cart | A quantity decrease or a removed item | currency, value, items (quantity is the amount removed) |
view_cart | Checkout Cart | The cart page is shown with items | currency, value (sum of row totals), items |
begin_checkout | Checkout | The checkout is shown with items | currency, value (subtotal), coupon (when applied), items |
add_shipping_info | Checkout Shipping Method step | Continue on the step | shipping_tier (<carrier>_<method>) |
add_payment_info | Checkout Payment step | Place Order, after validation | payment_type (the payment method code) |
purchase | Checkout Success page | The success page is shown; once per order (trackOnce) | transaction_id (order number), value (grand total), tax, shipping, currency, coupon (when used), items |
The purchase parameters are built on the server from the order (OOCS\LynxThemeComponent\Provider\Checkout\Success), the same values Magento's own Google Tag module reports on the native success page. They are passed to the page as the purchase prop of the Checkout Success component, so a tracker gets them from the bus without reading the order itself.
The Google Analytics 4 component forwards nine of these events, each behind its own toggle in the component's settings. remove_from_cart, and any event a module adds, is on the bus for other trackers only.
Tracking your own events
The component that owns the data tracks the event, usually in a lifecycle hook or after a successful request. Get the data from your component's provider through props, not by reading other components' props.
vue
<script setup lang="ts">
import { useAnalyticsStore } from 'OOCS_LynxTheme/stores/global/analyticsStore'
import { config } from 'OOCS_LynxTheme/utils/storeConfig'
const props = defineProps<{ sku: string; name: string; price: number }>()
const analytics = useAnalyticsStore()
async function addToWishlist() {
const response = await fetch(/* ... */)
if (response.ok) {
analytics.track('add_to_wishlist', {
currency: config<string>('currencyCode', ''),
value: props.price,
items: [{ item_id: props.sku, item_name: props.name, price: props.price, quantity: 1 }],
})
}
}
</script>Use trackOnce('<unique key>', event, params) for an event that must not repeat when the shopper reloads the page, as the success page does with purchase_<order number>.
Adding an analytics provider
A tracking provider (an advertising pixel, another tag manager) plugs into the bus the same way the built-in Google Analytics 4 component does: a headless PageBuilder component that the merchant places in the Backstage zone. Backstage is on every page, the checkout included, and renders nothing visible. The component registers a tracker when it mounts and forwards the events it supports.
Building it as a component, rather than a script added to the page layout, gives the merchant the usual PageBuilder control: they add it to the designs that should track, configure it per design, and remove it without a deployment.
The steps:
- Register a component with
zonesset tobackstage, settings for per-event toggles and load options, and a provider that passes the account id. - In the component, register a tracker with
addTracker()on mount, map Lynx events to the vendor's names, and remove it on unmount. - Load the vendor script lazily, and only once the consent cookies the merchant listed exist.
- Whitelist the vendor's domains in
etc/csp_whitelist.xml. - Do nothing in the PageBuilder preview.
Example: Vendor_FooPixel
app/code/Vendor/FooPixel/
├── etc/
│ ├── adminhtml/system.xml ← "Foo Pixel ID" field (standard Magento, not shown)
│ ├── components/FooPixel.graphqls
│ ├── csp_whitelist.xml
│ └── module.xml
├── Provider/FooPixel.php
└── view/frontend/lynx/
└── vue/src/Components/FooPixel.vueRegister the component
In etc/components/FooPixel.graphqls. Settings named event_<name> follow the GA4 component's convention. See Component Settings for every setting type.
graphql
"Sends the events switched on below to Foo. Renders nothing; place it once in Backstage."
type FooPixel @component(
code: "vendor_foo_pixel"
template: "Vendor_FooPixel::Components/FooPixel"
provider: "Vendor\\FooPixel\\Provider\\FooPixel"
label: "Foo Pixel"
group: "utility"
icon: "ph-chart-bar"
zones: ["backstage"]
) {
"One cookie name per line. The Foo script loads only while every one of them exists with a value, e.g. cookie_consent from the Cookie Consent component. Empty: no condition."
consent_cookies: Textarea! @setting(label: "Require cookies", default: "")
event_page_view: Boolean! @setting(label: "Send page views", default: true)
event_add_to_cart: Boolean! @setting(label: "Send add to cart", default: true)
event_purchase: Boolean! @setting(label: "Send purchases", default: true)
"The account id, empty when tracking is off"
pixel_id: String!
}The component types its props as Lynx.FooPixel. Every setting reaches it with its stored value or its default, so the component sets no defaults of its own.
The provider
The provider passes the account id from store configuration, or an empty string when tracking is off, so the component does nothing. See Providers.
php
<?php
declare(strict_types=1);
namespace Vendor\FooPixel\Provider;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
use OOCS\LynxComponent\Model\AbstractComponentProvider;
class FooPixel extends AbstractComponentProvider
{
public function __construct(
private readonly ScopeConfigInterface $scopeConfig,
) {
}
public function resolve(array $props): array
{
$props['pixel_id'] = $this->scopeConfig->isSetFlag('vendor_foo_pixel/general/active', ScopeInterface::SCOPE_STORE)
? (string) $this->scopeConfig->getValue('vendor_foo_pixel/general/pixel_id', ScopeInterface::SCOPE_STORE)
: '';
return $props;
}
}Content Security Policy
Allow the vendor's script and the hosts it sends data to, as for any Magento module. The Lynx checkout enforces its policy, so a missing entry stops tracking exactly where purchase matters.
xml
<?xml version="1.0"?>
<csp_whitelist xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Csp:etc/csp_whitelist.xsd">
<policies>
<policy id="script-src">
<values>
<value id="foo_pixel_script" type="host">https://cdn.foo.example</value>
</values>
</policy>
<policy id="connect-src">
<values>
<value id="foo_pixel_collect" type="host">https://collect.foo.example</value>
</values>
</policy>
<policy id="img-src">
<values>
<value id="foo_pixel_img" type="host">https://collect.foo.example</value>
</values>
</policy>
</policies>
</csp_whitelist>If the store runs bin/magento lynx:compile, run it again after installing the module so the compiled configuration includes the new whitelist.
The component
The component keeps one runtime per page even if a design holds the node twice, and builds the vendor's queue function before the script loads so no event is lost while waiting. The script itself loads on the shopper's first interaction, and only once the consent cookies exist; each interaction checks again until it has loaded.
vue
<script setup lang="ts">
import { computed, onMounted, onUnmounted, useAttrs } from 'vue'
import { useAnalyticsStore } from 'OOCS_LynxTheme/stores/global/analyticsStore'
// Node attributes go on the preview placeholder, the only element this component renders
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
const props = defineProps<Lynx.FooPixel>()
declare global {
interface Window {
foo?: ((...args: unknown[]) => void) & { queue?: unknown[] }
}
}
const INTERACTIONS = ['pointerdown', 'keydown', 'touchstart', 'scroll'] as const
// PageBuilder previews the storefront in an iframe
const inPreview = () => window.location.pathname.includes('/lynx_pagebuilder/') || window.parent !== window
// Lynx event name → the vendor's name and parameters
const MAP: Record<string, (p: Record<string, any>) => [string, Record<string, unknown>]> = {
page_view: () => ['PageView', {}],
add_to_cart: p => ['AddToCart', { value: p.value, currency: p.currency, contents: p.items }],
purchase: p => ['Purchase', { order_id: p.transaction_id, value: p.value, currency: p.currency, contents: p.items }],
}
const analytics = useAnalyticsStore()
let instances = 0
let stop: (() => void) | null = null
let requested = false
const cookieNames = computed(() =>
props.consent_cookies.split(/\r?\n/).map(name => name.trim()).filter(Boolean)
)
function consentGiven(): boolean {
const present = new Set(
document.cookie
.split(';')
.map(part => part.trim())
.filter(part => part.includes('=') && !part.endsWith('='))
.map(part => part.slice(0, part.indexOf('=')))
)
return cookieNames.value.every(name => present.has(name))
}
function loadScript() {
if (requested || !consentGiven()) return
requested = true
const script = document.createElement('script')
script.src = 'https://cdn.foo.example/pixel.js'
script.async = true
document.head.appendChild(script)
for (const name of INTERACTIONS) document.removeEventListener(name, loadScript)
}
function send(event: string, params: Record<string, unknown>) {
if (!MAP[event] || !props[`event_${event}` as keyof typeof props]) return
window.foo!('track', ...MAP[event](params))
}
onMounted(() => {
if (inPreview() || !props.pixel_id || instances++ > 0) return
// The vendor's stub: calls queue up until the script replaces it
window.foo ||= Object.assign((...args: unknown[]) => window.foo!.queue!.push(args), { queue: [] })
window.foo('init', props.pixel_id)
analytics.addTracker(send)
for (const name of INTERACTIONS) document.addEventListener(name, loadScript, { passive: true })
stop = () => {
analytics.removeTracker(send)
for (const name of INTERACTIONS) document.removeEventListener(name, loadScript)
}
})
onUnmounted(() => {
if (inPreview() || !props.pixel_id || --instances > 0) return
stop?.()
stop = null
})
</script>
<template>
<div
v-if="inPreview()"
:data-lynx-id="attrs['data-lynx-id']"
:data-lynx-label="attrs['data-lynx-label']"
class="rounded-box border-(length:--border) border-dashed border-base-300 px-3 py-2 text-sm text-base-content/70"
>
Foo Pixel · {{ pixel_id }}
</div>
</template>Points to keep from this example:
- Load lazily. The interaction trigger keeps the vendor script out of the first page load. Add a timed fallback, as the GA4 component's Delay setting does, if visitors who never interact must be counted too.
- Consent. The
consent_cookiessetting lets the merchant tie loading to the Cookie Consent component (cookiecookie_consent) or a consent tool's own cookie. Events keep queuing in the stub while consent is pending and are sent once the script loads. - One runtime per page. Count instances instead of using a flag, so a node unmounting while another mounts (entering the checkout, for example) does not remove the tracker.
- Skip the preview. PageBuilder shows the storefront in an iframe; tracking there would send the merchant's editing as visits. Show a small placeholder carrying the node's
data-lynx-idanddata-lynx-labelattributes so the merchant can select the node in the preview. - Text. The placeholder is shown to the merchant in the editor only. Anything shown to shoppers comes from server-provided labels.
After installing, the merchant opens PageBuilder, adds Foo Pixel to the Backstage zone of each design that should track, and saves.