Grid Inline Banners
Non-product content (promo banners, video tiles, editorial blocks) inserted at configurable positions within the product results grid. The widget supports three injection sources, all merged and interleaved at render time.
Architecture Overview
GridApp (Vue)
|
+--------------------+--------------------+
| | |
Admin-configured Metaobject promos Videowise video
(MarqoUIConfig) (Storefront API fetch) (Storefront API)
| | |
v v v
grid_injections __marqoCollectionPromos __marqoVideoCard
UIComponent (window global) (window global)
\ | /
+-------------------+-------------------+
|
injections (computed)
|
interleavedItems (computed)
|
Vue render function
All three sources are normalized into the same { id, position, span, html, css, enabled } shape and spliced into the product list by position.
Source 1: Metaobject Promo Cards (Storefront API)
Collection-level metafields that reference Shopify metaobjects. Fetched client-side via the Storefront API on collection page load.
Data Flow
enrichWithCollectionPromos(collectionHandle)is called on page load (marqo-search-app.ts)fetchCollectionPromotionalCards()issues a Storefront API GraphQL query against the collection- Parsed cards are cached on
window.__marqoCollectionPromos marqo:promos.readyevent fires, triggering GridApp re-evaluation
Metaobject Types
plp_inline_banner
Referenced via the collection metafield global.inline_banner. Supports full responsive configuration with separate desktop/mobile fields.
| Metaobject Field Key | Type | Required | Description |
|---|---|---|---|
title | Single-line text | No | Banner headline |
text | Rich text | No | Body text (parsed from Shopify rich text JSON) |
url | URL | No | Desktop link destination |
url_mobile | URL | No | Mobile-specific link (falls back to url) |
button_text | Single-line text | No | CTA button label |
button_style | Single-line text | No | Rendered as data-button-style attribute for CSS targeting |
background_image | File (image) | Yes | Desktop hero image (MediaImage reference) |
background_image_mobile | File (image) | No | Mobile hero image (falls back to background_image) |
title_color | Single-line text | No | Hex color for title |
text_color | Single-line text | No | Hex color for body text |
alignment | Single-line text | No | Layout alignment (left, center, right) |
alignment_mobile | Single-line text | No | Mobile-specific alignment |
location | Integer | Yes | Grid position (1-based: 1 = after 1st product) |
location_mobile | Integer | No | Mobile-specific grid position |
Required for rendering: background_image and location must both be set or the banner is skipped entirely.
Layout: Always rendered with stacked: true (image behind, text overlay at bottom), unlike the legacy promotional image slots.
Assigning to a collection: In Shopify Admin, go to the collection's metafields and set global.inline_banner to reference a plp_inline_banner metaobject entry.
promotional_image / promotional_image_2 (legacy promo slots)
Two fixed collection metafield namespaces providing up to 2 promo card slots per collection. These use individual metafields rather than a metaobject reference.
| Metafield Key | Type | Required | Description |
|---|---|---|---|
title | Rich text | No | Card headline (parsed from rich text JSON) |
subcopy | Rich text | No | Card body text |
image | File (image) | Yes | Card image (MediaImage reference) |
url | Single-line text | No | Link destination |
button_text | Single-line text | No | CTA button label |
location | Integer | Yes | Grid position (1-based) |
bg_color | Single-line text | No | Background color hex |
title_color | Single-line text | No | Title text color hex |
subcopy_color | Single-line text | No | Body text color hex |
button_style | Single-line text | No | CSS class for button styling |
alignment | Single-line text | No | Layout alignment |
switch_title_and_subtitle | Boolean string | No | Swap title/subcopy display order |
Key differences from plp_inline_banner:
- No mobile-specific fields (position, image, URL, alignment)
- No
stackedlayout (side-by-side image + text) - Two fixed slots per collection (not a single metaobject reference)
GraphQL Query
The full query issued by fetchCollectionPromotionalCards():
query CollectionPromos($handle: String!) {
collectionByHandle(handle: $handle) {
# Slot 1: promotional_image namespace
promoTitle: metafield(namespace: "promotional_image", key: "title") { value }
promoSubcopy: metafield(namespace: "promotional_image", key: "subcopy") { value }
promoImage: metafield(namespace: "promotional_image", key: "image") {
reference { ... on MediaImage { image { url } } }
}
promoUrl: metafield(namespace: "promotional_image", key: "url") { value }
promoButton: metafield(namespace: "promotional_image", key: "button_text") { value }
promoLocation: metafield(namespace: "promotional_image", key: "location") { value }
promoBgColor: metafield(namespace: "promotional_image", key: "bg_color") { value }
promoTitleColor: metafield(namespace: "promotional_image", key: "title_color") { value }
promoSubcopyColor: metafield(namespace: "promotional_image", key: "subcopy_color") { value }
promoButtonStyle: metafield(namespace: "promotional_image", key: "button_style") { value }
promoAlignment: metafield(namespace: "promotional_image", key: "alignment") { value }
promoSwitch: metafield(namespace: "promotional_image", key: "switch_title_and_subtitle") { value }
# Slot 2: promotional_image_2 namespace (same fields)
promo2Title: metafield(namespace: "promotional_image_2", key: "title") { value }
# ... (identical fields with promo2 prefix)
# Inline banner: global.inline_banner metaobject reference
inlineBanner: metafield(namespace: "global", key: "inline_banner") {
reference {
... on Metaobject {
bannerHandle: handle
bannerTitle: field(key: "title") { value }
bannerText: field(key: "text") { value }
bannerUrl: field(key: "url") { value }
bannerUrlMobile: field(key: "url_mobile") { value }
bannerButtonText: field(key: "button_text") { value }
bannerButtonStyle: field(key: "button_style") { value }
bannerBgImage: field(key: "background_image") {
reference { ... on MediaImage { image { url } } }
}
bannerBgImageMobile: field(key: "background_image_mobile") {
reference { ... on MediaImage { image { url } } }
}
bannerTitleColor: field(key: "title_color") { value }
bannerTextColor: field(key: "text_color") { value }
bannerAlignment: field(key: "alignment") { value }
bannerAlignmentMobile: field(key: "alignment_mobile") { value }
bannerLocation: field(key: "location") { value }
bannerLocationMobile: field(key: "location_mobile") { value }
}
}
}
}
}
Rendered HTML Structure
Metaobject promo cards are rendered by buildPromoCardHtml() in GridApp.ts:
<div class="marqo-grid-injection">
<a href="/collections/routines" class="marqo-promo-card-link">
<div class="marqo-promo-card marqo-promo-card--stacked"
data-promo-handle="spring-routine-banner"
style="--promo-title-color: #fff; --promo-alignment: center">
<picture>
<source media="(max-width: 768px)" srcset="https://cdn.shopify.com/.../mobile.jpg" />
<img class="marqo-promo-card-image"
src="https://cdn.shopify.com/.../desktop.jpg"
alt="Complete Your Routine" loading="lazy" />
</picture>
<div class="marqo-promo-card-overlay">
<div class="marqo-promo-card-title">Complete Your Routine</div>
<div class="marqo-promo-card-subcopy">Shop our curated sets</div>
<span class="marqo-promo-card-button">Shop Routines</span>
</div>
</div>
</a>
</div>
Responsive URLs: When url_mobile differs from url, two <a> wrappers are rendered with CSS classes marqo-promo-link-desktop / marqo-promo-link-mobile for show/hide at breakpoints.
CSS Custom Properties
Metaobject field values are mapped to CSS custom properties on the .marqo-promo-card element:
| Field | CSS Variable |
|---|---|
bg_color | --promo-bg-color |
title_color | --promo-title-color |
subcopy_color / text_color | --promo-subcopy-color |
alignment | --promo-alignment |
alignment_mobile | --promo-alignment-mobile |
Source 2: Admin-Configured Grid Injections
Static HTML/CSS blocks configured per-shop in the Marqo admin UI. Stored in the grid_injections UIComponent.
Configuration
Admin UI: Settings > Grid Injections > Add Content Block
| Setting | Type | Default | Description |
|---|---|---|---|
| Position | Integer | 0 | Where to insert (0 = before first product, 4 = after 4th product) |
| Column Span | Integer (1-4) | 1 | Grid columns to span. Clamped to current column count at each breakpoint via span min(N, var(--marqo-grid-columns)). |
| HTML | Code editor | — | Raw HTML content |
| CSS | Code editor | — | Scoped styles |
| Enabled | Toggle | true | Show/hide this injection |
Backend Model
{
"grid_injections": {
"name": "Grid Injections",
"enabled": true,
"value": {
"items": [
{
"id": "spring-promo",
"position": 4,
"span": 4,
"html": "<div class='promo'>Spring Sale</div>",
"css": ".promo { text-align: center; }",
"enabled": true
}
],
"videowise_enabled": false
}
}
}
HTML Sanitization
Admin-authored HTML goes through sanitizeInjectionHtml() which strips:
- Inline event handlers (
onclick,onerror, etc.) javascript:,data:,vbscript:URIs inhref/src/action/formaction/srcdoc<script>,<iframe>,<object>,<embed>,<form>tags
CSS Scoping
Injection CSS is injected into <style id="marqo-grid-injection-styles">. Scope selectors to avoid page-wide side effects:
/* Good */
.marqo-grid-injection .my-banner { padding: 24px; }
/* Bad */
.banner { padding: 24px; }
Source 3: Videowise Inline Video
Requires videowise_enabled: true in the grid_injections UIComponent. Fetches from the promotional_video collection metafield namespace.
| Metafield Key | Description |
|---|---|
main_video_code | Videowise embed HTML (script tags extracted and loaded separately) |
main_video_desktop_location | Desktop grid position |
main_video_mobile_location | Mobile grid position |
main_product_variant_select | Product variant ID for overlay pricing |
Grid Rendering Pipeline
Position Semantics
| Source | Position field | Indexing | Conversion to 0-based |
|---|---|---|---|
| Admin-configured | position | 0-based | Used as-is |
| Metaobject promo | location / location_mobile | 1-based | position - 1 |
| Videowise video | main_video_desktop_location | 1-based | position - 1 |
Splice Algorithm (GridApp.ts)
- Start with product cards as the base array
- Collect all enabled injections from all three sources
- Sort by position ascending
- Splice each injection into the array at its position — earlier insertions shift later indices, which provides the correct offset for subsequent splices
- Positions exceeding the product count are clamped to the end; negative positions are clamped to 0
Mobile Responsiveness
- Breakpoint:
768px(hardcodedwindow.matchMedia) - When the viewport crosses the breakpoint,
promoGenerationincrements, forcing theinjectionscomputed property to re-evaluate - Metaobject banners swap to their
*Mobilefield variants (position, image, URL, alignment) - Admin-configured injections have no per-breakpoint fields — they keep the same position but their
spanis clamped via CSSspan min(N, var(--marqo-grid-columns))
DOM Events
| Event | When | Payload |
|---|---|---|
marqo:promos.ready | After Storefront API fetch completes | — |
marqo:grid.injected | After render when injections are present | { injections: [...] } |
Third-party scripts (Videowise, Visually) can listen to marqo:grid.injected to hydrate containers after injection.
Key Source Files
| File | What |
|---|---|
storefront_search/src/storefront.ts:343-505 | CollectionPromoCard interface, parsers, GraphQL query |
storefront_search/src/collection-promos.ts | enrichWithCollectionPromos() orchestrator |
storefront_search/src/vue/GridApp.ts:43-97 | buildPromoCardHtml() — HTML construction |
storefront_search/src/vue/GridApp.ts:200-275 | Injection merging + interleave splice logic |
storefront_search/src/vue/GridApp.ts:441-473 | Vue render function |
storefront_admin/app/components/settings/grid-injections-section.tsx | Admin UI configuration |
storefront_admin/app/lib/types.ts:228-240 | GridInjectionItem / GridInjectionsSettings types |
admin_server/admin_server/models/ui_settings.py:497-504 | Backend UIComponent defaults |