// 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} `; } /** 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, "%"); } /** 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; 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, `${esc(money(v))}`); } 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 ``; const t = thumbs(o); if (!t.length) return ""; return `${t.map((src) => ``).join("")}`; } /** 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 -%}

{{ bo_bundle | money }}{%- if bo_saving > 0 -%} {{ bo_regular | money }}{%- endif -%} ${items} ${items === 1 ? "item" : "items"}

{%- endif -%}` : ""; return `
${t.badge ? `

${esc(t.badge)}

` : ""}

${esc(t.name)}

${summaryHtml(o, shop.currency)}

${prices} ${o.endsAt ? `

Ends ${esc(formatShopDate(o.endsAt, shop.timezone, false))}

` : ""} Shop the bundle
`; } /** 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 ? `
${offers.map((o) => card(o, shop, locale, base)).join("\n")}
` : `

No bundle offers right now. Check back for our festival specials!

Continue shopping
`; return `

Bundle offers

${body} ${shop.branding ? `

Bundles by Offerlyst

` : ""}
`; } // ---------- 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 -%}
{%- if ${pvar}.featured_image -%}{{ ${pvar}.featured_image | image_url: width: 128 | image_tag: alt: '', loading: 'lazy', width: 64, height: 64 }}{%- else -%}{%- endif -%}
{{ ${pvar}.title }}${label}{{ ${vexpr}.price | money }}
{%- 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 -%}
{%- if bo_p.featured_image -%}{{ bo_p.featured_image | image_url: width: 128 | image_tag: alt: '', loading: 'lazy', width: 64, height: 64 }}{%- else -%}{%- endif -%}
{{ bo_p.title | default: '${esc(l.title)}' }}${l.variantId ? "{% unless bo_v.title == 'Default Title' %} – {{ bo_v.title }}{% endunless %}" : ""}
${l.quantity} × {{ bo_v.price | money }}
`); } 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 ? `

Offer ends ${esc(formatShopDate(o.endsAt, shop.timezone))}

` : ""; 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 = `

What's in the bundle

${fixedRows(o)} ${fixedRegularLiquid(o)}
Bought separately
{{ bo_regular | money }}
Bundle price
{{ bo_bundle | money }}
{%- if bo_saving > 0 -%}
You save
{{ bo_saving | money }}
{%- endif -%}
{%- if bo_unavailable -%}

Some items in this bundle are out of stock.

{%- endif -%}
`; } else if (o.type === "mix") { right = `

Choose any ${o.mixQty}

{%- assign bo_seen = ',' -%}{%- assign bo_n = 0 -%} ${scopeChoices(o, "mix", "bo_seen")}

0 of ${o.mixQty} chosen

`; } else { const gp = num(o.getPercent); right = `

Buy ${o.buyQty} of these

{%- assign bo_buy = ',' -%}{%- assign bo_n = 0 -%} ${scopeChoices(o, "buy", "bo_buy")}

${gp >= 100 ? `Get ${o.getQty} free` : `Get ${o.getQty} at ${pct(gp)}% off`}

{%- assign bo_get = ',' -%}{%- assign bo_before = bo_n -%} ${scopeChoices(o, "get", "bo_get", "bo_buy")} {%- if bo_n == bo_before -%}

From the products above.

{%- endif -%}

0 chosen

`; } return `
${hero}${t.badge ? `

${esc(t.badge)}

` : ""}

${esc(t.name)}

${summary}

${t.description ? `
${esc(t.description).replace(/\n/g, "
")}
` : ""} ${ends}

The discount is applied automatically in your cart.

${right}
`; } export function notFoundLiquid(base = "/apps/bundles") { return `

This offer isn't available

It may have ended. See the bundle offers running now.

See all bundles
`; }