bundle-offers-app/app/lib/storefront.server.ts
metatroncubeswdev d4d42b917b Proxy pages carry their own CSS/JS; align festival calendar; App Store screenshots
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>
2026-10-14 12:26:04 -04:00

248 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\{/g, "&#123;")
.replace(/\}/g, "&#125;")
.replace(/%/g, "&#37;");
}
/** 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>`;
}