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>
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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 } });
|
||||
};
|
||||
|
||||
BIN
brand/screenshots/listing/1-offers.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
brand/screenshots/listing/2-editor.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
brand/screenshots/listing/3-bundle-page.png
Normal file
|
After Width: | Height: | Size: 552 KiB |
BIN
brand/screenshots/listing/4-checkout.png
Normal file
|
After Width: | Height: | Size: 363 KiB |
BIN
brand/screenshots/listing/5-templates.png
Normal file
|
After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 241 KiB |
@ -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
|
||||
|
||||
BIN
brand/screenshots/raw/1-offers.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
brand/screenshots/raw/2-editor.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
brand/screenshots/raw/3-bundle-page.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
brand/screenshots/raw/4-checkout.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
brand/screenshots/raw/5-templates.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 98 KiB |
@ -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');
|
||||
});
|
||||
|
||||