Rust discount function (one automatic discount evaluating all offers in priority order, parity with the Odoo engine), React Router admin with Polaris, theme app extension and app proxy pages, webhooks incl. GDPR, managed-pricing limits, and a Docker Compose stack (Postgres + Caddy HTTPS) for a single VPS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
13 KiB
TypeScript
236 lines
13 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";
|
||
|
||
/** 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/bundle-offers" rel="nofollow">Bundle Offers</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>`;
|
||
}
|