{% sw_extends '@Storefront/storefront/base.html.twig' %} {% block base_body_inner %} Special Offer – {{ headline|default('Wait! Don\'t Miss Out')|sw_sanitize }} {# ── Header — centered logo ── #}
{# ── AfterSell-style header (Ridge pattern) ── Top: order number + "You've paid for your order." with check icon + view-confirmation link. Below: centered "Order Confirmed" headline. Below: light-lavender countdown banner. The legacy sp-confirm-bar / sp-bubble that follow are hidden by `.sp-asl-active` CSS so template logic below (variant swatches, JS hooks) keeps working untouched. #}
{% if orderId %} {# Short order tag — last 6 chars of UUID for human readability #}
Order #{{ orderId|slice(-6)|upper }}
{% endif %}
{{ confirmationTitle|sw_sanitize }}
{{ confirmationLinkText|sw_sanitize }} ›

Order Confirmed

{% if countdownSeconds > 0 and expiresAt %} {# Banner text honours the admin-configured `bubbleTextTemplate` if it contains a {countdown} token; otherwise falls back to the AfterSell default copy. The {countdown} token is replaced by a live-ticking span so the existing countdown JS still updates the timer in place. #} {% set bannerTpl = bubbleTextTemplate|default('') %} {% if bannerTpl is empty or '{countdown}' not in bannerTpl %} {% set bannerTpl = 'You can still add to your order – limited time: {countdown}' %} {% endif %} {# Strip {name} / {discount} tokens if they were left in the template — they don't belong in the slim countdown banner (kept for legacy bubble). #} {% if customerFirstName %} {% set bannerTpl = bannerTpl|replace({'{name}': customerFirstName|sw_sanitize}) %} {% else %} {% set bannerTpl = bannerTpl|replace({'{name},': '', '{name}': ''}) %} {% endif %} {% if discountPercent > 0 %} {% set bannerTpl = bannerTpl|replace({'{discount}': discountPercent|round(0) ~ '%'}) %} {% else %} {% set bannerTpl = bannerTpl|replace({'{discount}% OFF': '', '{discount}%': '', '{discount}': ''}) %} {% endif %} {% set parts = bannerTpl|split('{countdown}') %}
{{ parts[0]|raw }}{% set m = (countdownSeconds / 60)|round(0, 'floor') %}{% set s = (countdownSeconds % 60) %}{{ m }}:{{ '%02d'|format(s) }}{{ parts[1]|default('')|raw }}
{% endif %} {# ── Order confirmation bar ── #}
{{ confirmationTitle|sw_sanitize }}
{{ confirmationLinkText|sw_sanitize }} ›
{# ── Offer bubble ── #}
{% if countdownSeconds > 0 and expiresAt %} {# Left timer — desktop only #}
{% set m = (countdownSeconds / 60)|round(0, 'floor') %}{% set s = (countdownSeconds % 60) %}{{ m }}:{{ '%02d'|format(s) }}
{% endif %}

{# Process the merchant-configured bubble text template. Tokens: {name}, {discount}, {countdown} {name} → customer first name (with trailing comma) or empty string {discount} → "13%" wrapped in bold, or stripped if no discount {countdown}→ live-ticking span, or stripped if no countdown #} {% set bubbleTpl = bubbleTextTemplate %} {# Replace {name} #} {% if customerFirstName %} {% set bubbleTpl = bubbleTpl|replace({'{name}': '' ~ customerFirstName|sw_sanitize ~ ','}) %} {% else %} {% set bubbleTpl = bubbleTpl|replace({'{name},': ''})|replace({'{name}': ''}) %} {% endif %} {# Replace {discount} #} {% if discountPercent > 0 %} {% set bubbleTpl = bubbleTpl|replace({'{discount}%': '' ~ discountPercent|round(0) ~ '% OFF'}) %} {% set bubbleTpl = bubbleTpl|replace({'{discount}': discountPercent|round(0) ~ '%'}) %} {% else %} {% set bubbleTpl = bubbleTpl|replace({'{discount}% OFF': ''})|replace({'{discount}%': ''})|replace({'{discount}': ''}) %} {% endif %} {# Split on {countdown} and insert live ticker, or strip the token #} {% if countdownSeconds > 0 and expiresAt and '{countdown}' in bubbleTpl %} {% set parts = bubbleTpl|split('{countdown}') %} {{ parts[0]|raw }} {% set m = (countdownSeconds / 60)|round(0, 'floor') %}{% set s = (countdownSeconds % 60) %}{{ m }}:{{ '%02d'|format(s) }} {{ parts[1]|raw }} {% else %} {% set bubbleTpl = bubbleTpl|replace({'{countdown}': ''}) %} {{ bubbleTpl|raw }} {% endif %}

{# ── Global continue button (appears after claiming when 2+ offers) ── #} {# ── Product cards — AfterSell pattern: first product = large hero card, image-left/content-right rest = "OR" divider + 2-up grid, collapses to 1-up on mobile The slider wrapper + track + inner divs are kept (with the same IDs) so the legacy slider JS in this file doesn't error out — `.sp-asl-active` in the CSS hides any visible slider chrome. ── #}
{% for product in upsellProducts %} {# ── Layout wrappers: hero (first), OR + grid (rest) ── #} {% if loop.first %}
{% elseif loop.index % 2 == 0 %}
OR
{% endif %} {% if not loop.first %}
{% endif %} {% set pid = product.id %} {% set isConfig = pid in configurableProductIds %} {% set variants = isConfig ? (variantsByProductId[pid] ?? []) : [] %} {% set unitPrice = product.calculatedPrice is not null ? product.calculatedPrice.unitPrice : 0 %} {# Effective discount = per-product override (only for offer_type='manual' rules that defined one) → otherwise rule default. `productDiscounts` is keyed by lowercase, hyphen-stripped UUIDs to match UpsellRuleEngine::decodeProductDiscounts. #} {% set pidKey = (pid|default(''))|lower|replace({'-': ''}) %} {% set effectivePct = (productDiscounts[pidKey] is defined and productDiscounts[pidKey] is not null) ? productDiscounts[pidKey] : discountPercent %} {% set dPrice = effectivePct > 0 ? (unitPrice * (1 - effectivePct / 100))|round(2) : unitPrice %} {% set dAmount = effectivePct > 0 ? (unitPrice - dPrice)|round(2) : 0 %} {% set cover = product.cover is not null and product.cover.media is not null ? product.cover.media.url : '' %} {% set imgs = [] %} {% if product.media is not empty %} {% for m in product.media %} {% if m.media is not null and m.media.url is not empty %} {% set imgs = imgs|merge([m.media.url]) %} {% endif %} {% endfor %} {% endif %} {% if imgs is empty and cover %}{% set imgs = [cover] %}{% endif %}
{# Image panel — dots on mobile, arrows on desktop #}
{% for img in imgs %} {{ product.translated.name|default('')|sw_sanitize }} {% endfor %} {% if imgs|length > 1 %} {# Arrows — shown on desktop via CSS #} {# Dots — shown on mobile via CSS #}
{% for i in 1..imgs|length %} {% endfor %}
{% endif %}
{# Details panel #}
{# AfterSell-style pill badge — text comes from admin config `badgeText`, colours from admin config `badgeBgColor` / `badgeBorderColor` / `badgeTextColor` via the CSS vars set on `.sp-page` (see `--sp-badge-*`). #} {{ (badgeText ?: 'New')|sw_sanitize }} {# Legacy badge kept for non-AfterSell flows; hidden by .sp-asl-active CSS #}
{{ badgeText|sw_sanitize }}
{{ product.translated.name|default('')|sw_sanitize }}
{% if effectivePct > 0 %} {{ unitPrice|currency }} {% endif %} {{ dPrice|currency }} {% if effectivePct > 0 %} (Save {{ effectivePct|round(0) }}%) {% endif %}
{# ── Product description (hero only, max 3 lines via CSS line-clamp). Strip HTML tags from the rich-text editor output before display so we don't render unstyled

/
from the editor. Falls back to metaDescription, then short keyword line if description is empty. ── #} {% set spDescRaw = product.translated.description ?? product.translated.metaDescription ?? '' %} {% set spDesc = spDescRaw|striptags|trim %} {% if spDesc %}

{{ spDesc }}
{% endif %} {# Variant swatches #} {% if isConfig and variants is not empty %} {% set varSimple = [] %} {% for v in variants %} {% set varSimple = varSimple|merge([{id: v.id, label: v.label, groups: v.groups ?? []}]) %} {% endfor %}
👆 Please select all options to continue.
{% endif %} {# Success #}
✓ Added to your order!
Ships with your current order.
{# Error #}
{# CTA #} {% if hasOneClick %} {% else %}
{% if effectivePct > 0 %} {% endif %}
{% endif %} {# Decline — per-card (hidden by .sp-asl-active CSS; we render one global decline below) #}
{# /sp-card #} {# ── Close the layout wrappers we opened at the top of this iteration ── #} {% if loop.first %}
{# /sp-asl-hero #} {% else %}
{# /sp-asl-alt #} {% if loop.index % 2 == 1 or loop.last %}
{# /sp-asl-alt-grid #} {% endif %} {% endif %} {% endfor %}
{# /sp-slider-inner #}
{# /sp-slider-track #}
{# /sp-slider-wrap #} {# ── Global decline button, centred under all offers (AfterSell pattern). Visual is fully driven by admin config via the .sp-asl-decline-row CSS: - border colour (--sp-decline-border = declineButtonBorderColor) - text colour (--sp-decline-text = declineButtonTextColor) - corner radius (--sp-decline-radius = declineButtonRadius) No inline `style` here on purpose so the configured radius and colours actually win. ── #}
{% endblock %}