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>
This commit is contained in:
metatroncubeswdev 2026-10-14 12:26:04 -04:00
parent 731e0ba06a
commit d4d42b917b
18 changed files with 35 additions and 6 deletions

View File

@ -5,6 +5,18 @@
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) {

View File

@ -40,11 +40,11 @@ export default function Templates() {
<s-section heading="Festival calendar">
<s-stack gap="small-200">
{calendar.map((f) => (
<s-stack key={f.key} direction="inline" gap="base" justifyContent="space-between">
<s-grid key={f.key} gridTemplateColumns="1fr 1fr auto" gap="base" alignItems="center">
<s-text type="strong">{f.name}</s-text>
<s-text>{f.date}{f.approximate ? " (approx.)" : ""}</s-text>
<s-badge tone={f.weeksAway <= 6 ? "caution" : "neutral"}>{f.weeksAway <= 0 ? "This week" : `In ${f.weeksAway} weeks`}</s-badge>
</s-stack>
</s-grid>
))}
<s-text color="subdued">Plan your offers about 6 weeks ahead. Lunar dates are approximate: check them locally.</s-text>
</s-stack>

View File

@ -5,7 +5,7 @@ import { redirect } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { PROXY_CACHE, runningOffersForShop } from "../lib/proxy.server";
import { bundlePageLiquid, notFoundLiquid, proxyBase } from "../lib/storefront.server";
import { bundlePageLiquid, notFoundLiquid, proxyBase, selfContained } from "../lib/storefront.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { liquid } = await authenticate.public.appProxy(request);
@ -18,7 +18,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
if (!data) return liquid(notFoundLiquid(base), { status: 404 });
const offer = data.running.find((o) => o.handle === handle);
if (offer) return liquid(bundlePageLiquid(offer, data.ctx, locale), { headers: { "Cache-Control": PROXY_CACHE } });
if (offer) return liquid(selfContained(bundlePageLiquid(offer, data.ctx, locale)), { headers: { "Cache-Control": PROXY_CACHE } });
const renamed = await db.offer.findFirst({ where: { shopId: data.shop.id, oldHandles: { has: handle } }, select: { id: true, handle: true } });
if (renamed && data.running.some((o) => o.id === renamed.id)) {

View File

@ -2,7 +2,7 @@
import type { LoaderFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import { PROXY_CACHE, runningOffersForShop } from "../lib/proxy.server";
import { bundlesListLiquid, proxyBase } from "../lib/storefront.server";
import { bundlesListLiquid, proxyBase, selfContained } from "../lib/storefront.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { liquid } = await authenticate.public.appProxy(request);
@ -12,5 +12,5 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const locale = request.headers.get("accept-language")?.split(",")[0]?.split("-")[0] || null;
const data = await runningOffersForShop(shop, request.headers.get("x-shopify-country") || null);
if (!data) return liquid(bundlesListLiquid([], { currency: "USD", timezone: "UTC", branding: false }, null, base));
return liquid(bundlesListLiquid(data.running, data.ctx, locale, base), { headers: { "Cache-Control": PROXY_CACHE } });
return liquid(selfContained(bundlesListLiquid(data.running, data.ctx, locale, base)), { headers: { "Cache-Control": PROXY_CACHE } });
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 241 KiB

View File

@ -1 +1,6 @@
1-offers.png: Offerlyst offers list showing running bundle offers with status badges and priority order
2-editor.png: Offer editor with bundle type, discount, products and a live example cart preview
3-bundle-page.png: Storefront bundle page with product list, bundle price and Add bundle to cart button
4-checkout.png: Shopify cart and checkout showing the bundle discount applied automatically
5-templates.png: Festival templates and calendar with ready-made bundle offers
6-home.png: Offerlyst home page with setup checklist and recent bundle results

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -34,3 +34,15 @@ test('bundle pages render for every type without merchant-controlled Liquid', ()
expect(list).toContain('data-bo-money="20"');
if (out) writeFileSync(join(out, 'snippets', 'list.liquid'), list);
});
test('inlined storefront CSS/JS contain no Liquid delimiters (proxy pages render them as Liquid)', async () => {
// vitest stubs CSS imports, so check the source files; the production build inlines them via ?raw
const { readFileSync } = await import('node:fs');
for (const f of ['bundle-offers.css', 'bundle-offers.js']) {
const src = readFileSync(new URL(`../../extensions/bundle-offers-theme/assets/${f}`, import.meta.url), 'utf8');
expect(src.length).toBeGreaterThan(1000);
expect(src).not.toMatch(/\{\{|\{%/);
}
const { selfContained } = await import('../../app/lib/storefront.server.ts');
expect(selfContained('<p>x</p>')).toContain('window.BundleOffers');
});