Bundle pages stay styled and functional when the app embed is off (e.g. on a draft theme). Listing screenshots captured from the dev store and rendered at 1600x900. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
14 KiB
TypeScript
248 lines
14 KiB
TypeScript
// Liquid for the app proxy pages (/apps/bundles and /apps/bundles/{handle}), brief §6.1–6.2, F12–F14, F19.
|
||
// Rendered by Shopify inside the theme layout, for guests too. Product data (titles, prices in the
|
||
// customer's currency, availability, images) comes from Liquid's all_products / collections objects,
|
||
// so it is always live and public. Styles and the add-to-cart script come from the app embed.
|
||
import { num, offerSummary, moneyFormatter, pct } from "./offer-text";
|
||
import type { OfferWithRelations } from "./offers.server";
|
||
import { formatShopDate } from "./tz";
|
||
// The storefront CSS/JS live in the theme app extension (loaded by the app embed). Proxy pages also carry
|
||
// them inline, so they still look right and "Add to cart" still works when the embed is switched off.
|
||
// bundle-offers.js guards against running twice; the CSS is idempotent.
|
||
import storefrontCss from "../../extensions/bundle-offers-theme/assets/bundle-offers.css?raw";
|
||
import storefrontJs from "../../extensions/bundle-offers-theme/assets/bundle-offers.js?raw";
|
||
|
||
/** Inline assets for proxy pages (no Liquid delimiters in them: checked by engine/tests/storefront.test.js). */
|
||
export function selfContained(html: string) {
|
||
return `${html}
|
||
<style>${storefrontCss}</style>
|
||
<script>${storefrontJs}</script>`;
|
||
}
|
||
|
||
/** HTML-escape merchant text and neutralise Liquid delimiters so it can't run as Liquid. */
|
||
export function esc(s: string | null | undefined) {
|
||
return String(s ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'")
|
||
.replace(/\{/g, "{")
|
||
.replace(/\}/g, "}")
|
||
.replace(/%/g, "%");
|
||
}
|
||
|
||
/** A string literal for a Liquid expression (handles are [a-z0-9-] but be strict anyway). */
|
||
const lit = (s: string | null | undefined) => `'${String(s ?? "").replace(/[^a-zA-Z0-9_-]/g, "")}'`;
|
||
|
||
const MAX_CHOICES = 60;
|
||
|
||
interface ShopCtx { currency: string; timezone: string; branding: boolean }
|
||
|
||
function localized(o: OfferWithRelations, locale: string | null) {
|
||
const i18n = (o.i18n || {}) as Record<string, { name?: string; badge?: string; description?: string }>;
|
||
const t = locale ? i18n[locale] || i18n[locale.split("-")[0]] : undefined;
|
||
return { name: t?.name || o.name, badge: t?.badge || o.badge || "", description: t?.description || o.description || "" };
|
||
}
|
||
|
||
/** Summary with $-amounts marked for currency conversion by bundle-offers.js. */
|
||
function summaryHtml(o: OfferWithRelations, currency: string) {
|
||
const money = moneyFormatter(currency);
|
||
const marker = "\u0000";
|
||
const text = offerSummary(o, () => marker);
|
||
const v = num(o.discountValue);
|
||
return esc(text).replace(marker, `<span data-bo-money="${v}">${esc(money(v))}</span>`);
|
||
}
|
||
|
||
function thumbs(o: OfferWithRelations) {
|
||
return [...o.lines.map((l) => l.imageUrl), ...o.scopes.map((s) => s.imageUrl)].filter(Boolean).slice(0, 4) as string[];
|
||
}
|
||
|
||
function media(o: OfferWithRelations) {
|
||
if (o.imageUrl) return `<img src="${esc(o.imageUrl)}" alt="" width="800" height="600">`;
|
||
const t = thumbs(o);
|
||
if (!t.length) return "";
|
||
return `<span class="bo-card__thumbs bo-card__thumbs--${t.length}">${t.map((src) => `<img src="${esc(src)}" alt="" loading="lazy" width="200" height="200">`).join("")}</span>`;
|
||
}
|
||
|
||
/** Liquid that sums the live regular price (in cents) of a fixed bundle into `bo_regular`. */
|
||
function fixedRegularLiquid(o: OfferWithRelations) {
|
||
const parts = ["{%- assign bo_regular = 0 -%}"];
|
||
for (const l of o.lines) {
|
||
parts.push(`{%- assign bo_p = all_products[${lit(l.handle)}] -%}{%- assign bo_price = bo_p.price -%}`);
|
||
if (l.variantId) {
|
||
parts.push(`{%- for bo_v in bo_p.variants -%}{%- assign bo_vid = bo_v.id | append: '' -%}{%- if bo_vid == ${lit(l.variantId)} -%}{%- assign bo_price = bo_v.price -%}{%- endif -%}{%- endfor -%}`);
|
||
}
|
||
parts.push(`{%- assign bo_line = bo_price | times: ${l.quantity} -%}{%- assign bo_regular = bo_regular | plus: bo_line -%}`);
|
||
}
|
||
const dvCents = Math.round(num(o.discountValue) * 100);
|
||
if (o.discountType === "percent") parts.push(`{%- assign bo_saving = bo_regular | times: ${num(o.discountValue)} | divided_by: 100.0 | round -%}`);
|
||
else if (o.discountType === "amount") parts.push(`{%- assign bo_saving = ${dvCents} | at_most: bo_regular -%}`);
|
||
else parts.push(`{%- assign bo_saving = bo_regular | minus: ${dvCents} | at_least: 0 -%}`);
|
||
parts.push("{%- assign bo_bundle = bo_regular | minus: bo_saving -%}");
|
||
return parts.join("");
|
||
}
|
||
|
||
function card(o: OfferWithRelations, shop: ShopCtx, locale: string | null, base: string) {
|
||
const t = localized(o, locale);
|
||
const url = `${base}/${o.handle}`;
|
||
const items = o.lines.reduce((s, l) => s + l.quantity, 0);
|
||
const prices = o.type === "fixed" && o.lines.length
|
||
? `${fixedRegularLiquid(o)}{%- if bo_regular > 0 -%}<p class="bo-card__prices" data-bo-fixed data-regular="{{ bo_regular }}" data-dt="${o.discountType}" data-dv="${num(o.discountValue)}">
|
||
<span class="bo-card__now" data-bo-bundle>{{ bo_bundle | money }}</span>{%- if bo_saving > 0 -%} <s class="bo-card__was">{{ bo_regular | money }}</s>{%- endif -%}
|
||
<span class="bo-card__count">${items} ${items === 1 ? "item" : "items"}</span></p>{%- endif -%}`
|
||
: "";
|
||
return `<article class="bo-card">
|
||
<a class="bo-card__media" href="${url}" tabindex="-1" aria-hidden="true">${media(o)}</a>
|
||
${t.badge ? `<p class="bo-stub"><span>${esc(t.badge)}</span></p>` : ""}
|
||
<div class="bo-card__body">
|
||
<h2 class="bo-card__title"><a href="${url}">${esc(t.name)}</a></h2>
|
||
<p class="bo-card__deal">${summaryHtml(o, shop.currency)}</p>
|
||
${prices}
|
||
${o.endsAt ? `<p class="bo-card__ends">Ends ${esc(formatShopDate(o.endsAt, shop.timezone, false))}</p>` : ""}
|
||
<a class="bo-button" href="${url}">Shop the bundle</a>
|
||
</div>
|
||
</article>`;
|
||
}
|
||
|
||
/** Proxy path: merchants can rename it, so use Shopify's path_prefix query parameter (default /apps/bundles). */
|
||
export function proxyBase(pathPrefix: string | null | undefined) {
|
||
const p = (pathPrefix || "/apps/bundles").replace(/[^a-zA-Z0-9/_-]/g, "").replace(/\/+$/, "");
|
||
return p.startsWith("/") ? p : `/${p}`;
|
||
}
|
||
|
||
export function bundlesListLiquid(offers: OfferWithRelations[], shop: ShopCtx, locale: string | null, base = "/apps/bundles") {
|
||
const body = offers.length
|
||
? `<div class="bo-grid">${offers.map((o) => card(o, shop, locale, base)).join("\n")}</div>`
|
||
: `<div class="bo-empty"><p>No bundle offers right now. Check back for our festival specials!</p>
|
||
<a class="bo-button" href="{{ routes.all_products_collection_url }}">Continue shopping</a></div>`;
|
||
return `<section class="bo-section">
|
||
<h1 class="bo-section__title">Bundle offers</h1>
|
||
${body}
|
||
${shop.branding ? `<p class="bo-powered">Bundles by <a href="https://apps.shopify.com/offerlyst" rel="nofollow">Offerlyst</a></p>` : ""}
|
||
</section>`;
|
||
}
|
||
|
||
// ---------- bundle page ----------
|
||
|
||
/** One selectable row. `pvar` is a Liquid product variable; `vexpr` the variant to add. */
|
||
function choiceRow(pvar: string, vexpr: string, label: string, seenVar: string, alsoSkip?: string) {
|
||
return `{%- if ${pvar}.id and ${pvar}.available -%}
|
||
{%- assign bo_key = ',' | append: ${vexpr}.id | append: ',' -%}
|
||
{%- unless ${seenVar} contains bo_key${alsoSkip ? ` or ${alsoSkip} contains bo_key` : ""} -%}{%- if bo_n < ${MAX_CHOICES} -%}{%- if ${vexpr}.available -%}
|
||
{%- assign ${seenVar} = ${seenVar} | append: bo_key -%}{%- assign bo_n = bo_n | plus: 1 -%}
|
||
<div class="bo-choice">
|
||
{%- if ${pvar}.featured_image -%}{{ ${pvar}.featured_image | image_url: width: 128 | image_tag: alt: '', loading: 'lazy', width: 64, height: 64 }}{%- else -%}<span class="bo-choice__ph"></span>{%- endif -%}
|
||
<div><a class="bo-choice__name" href="{{ ${pvar}.url }}">{{ ${pvar}.title }}${label}</a><span class="bo-choice__price">{{ ${vexpr}.price | money }}</span></div>
|
||
<span class="bo-qty">
|
||
<button type="button" data-step="-1" aria-label="Remove one">−</button>
|
||
<input type="number" inputmode="numeric" min="0" max="99" value="0" data-variant="{{ ${vexpr}.id }}" aria-label="Quantity of {{ ${pvar}.title | escape }}">
|
||
<button type="button" data-step="1" aria-label="Add one">+</button>
|
||
</span>
|
||
</div>
|
||
{%- endif -%}{%- endif -%}{%- endunless -%}
|
||
{%- endif -%}`;
|
||
}
|
||
|
||
function scopeChoices(o: OfferWithRelations, role: string, seenVar: string, alsoSkip?: string) {
|
||
const out: string[] = [];
|
||
for (const s of o.scopes.filter((x) => x.role === role)) {
|
||
if (s.kind === "collection") {
|
||
out.push(`{%- for bo_cp in collections[${lit(s.handle)}].products limit: ${MAX_CHOICES} -%}${choiceRow("bo_cp", "bo_cp.selected_or_first_available_variant", "", seenVar, alsoSkip)}{%- endfor -%}`);
|
||
} else if (s.kind === "product") {
|
||
out.push(`{%- assign bo_pp = all_products[${lit(s.handle)}] -%}${choiceRow("bo_pp", "bo_pp.selected_or_first_available_variant", "", seenVar, alsoSkip)}`);
|
||
} else {
|
||
out.push(`{%- assign bo_pp = all_products[${lit(s.handle)}] -%}{%- for bo_vv in bo_pp.variants -%}{%- assign bo_vid = bo_vv.id | append: '' -%}{%- if bo_vid == ${lit(s.refId)} -%}${choiceRow("bo_pp", "bo_vv", " – {{ bo_vv.title }}", seenVar, alsoSkip)}{%- endif -%}{%- endfor -%}`);
|
||
}
|
||
}
|
||
return out.join("\n");
|
||
}
|
||
|
||
function fixedRows(o: OfferWithRelations) {
|
||
const rows: string[] = ["{%- assign bo_unavailable = false -%}"];
|
||
for (const l of o.lines) {
|
||
rows.push(`{%- assign bo_p = all_products[${lit(l.handle)}] -%}{%- assign bo_v = bo_p.selected_or_first_available_variant -%}`);
|
||
if (l.variantId) rows.push(`{%- for bo_x in bo_p.variants -%}{%- assign bo_xid = bo_x.id | append: '' -%}{%- if bo_xid == ${lit(l.variantId)} -%}{%- assign bo_v = bo_x -%}{%- endif -%}{%- endfor -%}`);
|
||
rows.push(`{%- if bo_p.id == nil or bo_v.available != true -%}{%- assign bo_unavailable = true -%}{%- endif -%}
|
||
<div class="bo-choice{% if bo_v.available != true %} bo-choice--off{% endif %}">
|
||
{%- if bo_p.featured_image -%}{{ bo_p.featured_image | image_url: width: 128 | image_tag: alt: '', loading: 'lazy', width: 64, height: 64 }}{%- else -%}<span class="bo-choice__ph"></span>{%- endif -%}
|
||
<div><a class="bo-choice__name" href="{{ bo_p.url }}">{{ bo_p.title | default: '${esc(l.title)}' }}${l.variantId ? "{% unless bo_v.title == 'Default Title' %} – {{ bo_v.title }}{% endunless %}" : ""}</a></div>
|
||
<span class="bo-choice__qty">${l.quantity} × {{ bo_v.price | money }}</span>
|
||
<input type="hidden" data-variant="{{ bo_v.id }}" value="${l.quantity}">
|
||
</div>`);
|
||
}
|
||
return rows.join("\n");
|
||
}
|
||
|
||
export function bundlePageLiquid(o: OfferWithRelations, shop: ShopCtx, locale: string | null) {
|
||
const t = localized(o, locale);
|
||
const summary = summaryHtml(o, shop.currency);
|
||
const hero = media(o);
|
||
const ends = o.endsAt ? `<p class="bo-page__ends">Offer ends ${esc(formatShopDate(o.endsAt, shop.timezone))}</p>` : "";
|
||
const needMore = `Please choose at least ${o.mixQty} items for this offer.`;
|
||
const nothing = "Please choose at least one item.";
|
||
|
||
let right = "";
|
||
if (o.type === "fixed") {
|
||
right = `<form class="bo-form" data-bo-bundle-form data-type="fixed" data-nothing="${esc(nothing)}">
|
||
<h2 class="bo-page__h">What's in the bundle</h2>
|
||
${fixedRows(o)}
|
||
${fixedRegularLiquid(o)}
|
||
<dl class="bo-totals" data-bo-fixed data-regular="{{ bo_regular }}" data-dt="${o.discountType}" data-dv="${num(o.discountValue)}">
|
||
<dt>Bought separately</dt><dd>{{ bo_regular | money }}</dd>
|
||
<dt>Bundle price</dt><dd class="bo-totals__now" data-bo-bundle>{{ bo_bundle | money }}</dd>
|
||
{%- if bo_saving > 0 -%}<dt>You save</dt><dd class="bo-totals__save" data-bo-save>{{ bo_saving | money }}</dd>{%- endif -%}
|
||
</dl>
|
||
{%- if bo_unavailable -%}<p class="bo-error">Some items in this bundle are out of stock.</p>{%- endif -%}
|
||
<p class="bo-error" data-bo-error hidden></p>
|
||
<button type="submit" class="bo-button bo-submit"{% if bo_unavailable %} disabled{% endif %}>Add bundle to cart</button>
|
||
</form>`;
|
||
} else if (o.type === "mix") {
|
||
right = `<form class="bo-form" data-bo-bundle-form data-type="mix" data-need="${o.mixQty}" data-need-more="${esc(needMore)}" data-nothing="${esc(nothing)}">
|
||
<h2 class="bo-page__h">Choose any ${o.mixQty}</h2>
|
||
{%- assign bo_seen = ',' -%}{%- assign bo_n = 0 -%}
|
||
${scopeChoices(o, "mix", "bo_seen")}
|
||
<p class="bo-counter" data-bo-counter data-template="{k} of {n} chosen" aria-live="polite">0 of ${o.mixQty} chosen</p>
|
||
<p class="bo-error" data-bo-error hidden></p>
|
||
<button type="submit" class="bo-button bo-submit" disabled>Add to cart</button>
|
||
</form>`;
|
||
} else {
|
||
const gp = num(o.getPercent);
|
||
right = `<form class="bo-form" data-bo-bundle-form data-type="bxgy" data-nothing="${esc(nothing)}">
|
||
<div class="bo-page__group">
|
||
<h2 class="bo-page__h">Buy ${o.buyQty} of these</h2>
|
||
{%- assign bo_buy = ',' -%}{%- assign bo_n = 0 -%}
|
||
${scopeChoices(o, "buy", "bo_buy")}
|
||
</div>
|
||
<div class="bo-page__group">
|
||
<h2 class="bo-page__h">${gp >= 100 ? `Get ${o.getQty} free` : `Get ${o.getQty} at ${pct(gp)}% off`}</h2>
|
||
{%- assign bo_get = ',' -%}{%- assign bo_before = bo_n -%}
|
||
${scopeChoices(o, "get", "bo_get", "bo_buy")}
|
||
{%- if bo_n == bo_before -%}<p class="bo-page__note">From the products above.</p>{%- endif -%}
|
||
</div>
|
||
<p class="bo-counter" data-bo-counter data-template="{k} chosen" aria-live="polite">0 chosen</p>
|
||
<p class="bo-error" data-bo-error hidden></p>
|
||
<button type="submit" class="bo-button bo-submit" disabled>Add to cart</button>
|
||
</form>`;
|
||
}
|
||
|
||
return `<div class="bo-page">
|
||
<div>
|
||
<div class="bo-page__hero">${hero}${t.badge ? `<p class="bo-stub"><span>${esc(t.badge)}</span></p>` : ""}</div>
|
||
<h1 class="bo-page__title">${esc(t.name)}</h1>
|
||
<p class="bo-page__deal">${summary}</p>
|
||
${t.description ? `<div class="bo-page__desc">${esc(t.description).replace(/\n/g, "<br>")}</div>` : ""}
|
||
${ends}
|
||
<p class="bo-page__note">The discount is applied automatically in your cart.</p>
|
||
</div>
|
||
<div>${right}</div>
|
||
</div>`;
|
||
}
|
||
|
||
export function notFoundLiquid(base = "/apps/bundles") {
|
||
return `<section class="bo-section bo-empty">
|
||
<h1 class="bo-section__title">This offer isn't available</h1>
|
||
<p>It may have ended. See the bundle offers running now.</p>
|
||
<a class="bo-button" href="${base}">See all bundles</a>
|
||
</section>`;
|
||
}
|