Skip to content

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:

KeyLynx form
customer_loginSign in
customer_createCreate an account
customer_forgot_passwordForgot password
customer_editAccount information
contactContact us
newsletterNewsletter subscription
product_reviewProduct review
coupon_codeDiscount code, on the cart and in the checkout
place_orderPlace 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 ​

MemberMeaning
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.

EventTracked byWhenParameters
page_viewThe theme's entry pointEvery page load and every page changepage_location, page_title
view_item_listProduct ListingThe list is shown, and again when it changes (pagination, filters, search)item_list_name (the list title), items with index and without quantity
view_itemProduct page (simple, configurable, bundle, grouped, downloadable)The product page is showncurrency, value (price), items (one item, without quantity)
add_to_cartProduct page, Product Listing quick add, Product Carousel, Checkout CartThe server accepted the add; on the cart, a quantity increasecurrency, value, items. On the product page the price includes the chosen options; grouped products send one item per product with a quantity
remove_from_cartCheckout CartA quantity decrease or a removed itemcurrency, value, items (quantity is the amount removed)
view_cartCheckout CartThe cart page is shown with itemscurrency, value (sum of row totals), items
begin_checkoutCheckoutThe checkout is shown with itemscurrency, value (subtotal), coupon (when applied), items
add_shipping_infoCheckout Shipping Method stepContinue on the stepshipping_tier (<carrier>_<method>)
add_payment_infoCheckout Payment stepPlace Order, after validationpayment_type (the payment method code)
purchaseCheckout Success pageThe 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:

  1. Register a component with zones set to backstage, settings for per-event toggles and load options, and a provider that passes the account id.
  2. In the component, register a tracker with addTracker() on mount, map Lynx events to the vendor's names, and remove it on unmount.
  3. Load the vendor script lazily, and only once the consent cookies the merchant listed exist.
  4. Whitelist the vendor's domains in etc/csp_whitelist.xml.
  5. 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.vue

Register 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_cookies setting lets the merchant tie loading to the Cookie Consent component (cookie cookie_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-id and data-lynx-label attributes 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.