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>
73 lines
3.3 KiB
TypeScript
73 lines
3.3 KiB
TypeScript
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
|
|
import { useLoaderData } from "react-router";
|
|
import { boundary } from "@shopify/shopify-app-react-router/server";
|
|
import { authenticate } from "../shopify.server";
|
|
import { ensureShop } from "../lib/shop.server";
|
|
import { planOf } from "../lib/plans";
|
|
import { festivalByName, TEMPLATES, upcomingFestivals } from "../lib/templates";
|
|
import { moneyFormatter, offerSummary, TYPE_LABEL } from "../lib/offer-text";
|
|
|
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
const { admin, session } = await authenticate.admin(request);
|
|
const shop = await ensureShop(admin, session.shop);
|
|
const plan = planOf(shop.planCode);
|
|
const money = moneyFormatter(shop.currency);
|
|
const now = new Date();
|
|
return {
|
|
allowed: plan.templates,
|
|
calendar: upcomingFestivals(now).map((f) => ({
|
|
...f,
|
|
weeksAway: Math.round((new Date(`${f.date}T00:00:00Z`).getTime() - now.getTime()) / (7 * 86400000)),
|
|
})),
|
|
templates: TEMPLATES.map((t) => ({
|
|
...t,
|
|
summary: offerSummary({ ...t, mixQty: t.mixQty ?? 3, buyQty: t.buyQty ?? 2, getQty: t.getQty ?? 1, getPercent: t.getPercent ?? 100, active: true, published: true, startsAt: null, endsAt: null }, money),
|
|
next: festivalByName(t.festival)?.date || "",
|
|
})),
|
|
};
|
|
};
|
|
|
|
export default function Templates() {
|
|
const { templates, calendar, allowed } = useLoaderData<typeof loader>();
|
|
return (
|
|
<s-page heading="Festival templates" inlineSize="large">
|
|
{!allowed ? (
|
|
<s-banner tone="info" heading="Templates and the festival calendar are on the Growth plan">
|
|
<s-button slot="secondary-actions" href="/app/plans">See plans</s-button>
|
|
</s-banner>
|
|
) : null}
|
|
|
|
<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-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-text color="subdued">Plan your offers about 6 weeks ahead. Lunar dates are approximate: check them locally.</s-text>
|
|
</s-stack>
|
|
</s-section>
|
|
|
|
<s-section heading="Templates">
|
|
<s-grid gridTemplateColumns="repeat(auto-fill, minmax(260px, 1fr))" gap="base">
|
|
{templates.map((t) => (
|
|
<s-box key={t.key} padding="base" borderWidth="base" borderRadius="base">
|
|
<s-stack gap="small-200">
|
|
<s-stack direction="inline" gap="small-200"><s-badge>{t.badge}</s-badge><s-badge tone="info">{TYPE_LABEL[t.type]}</s-badge></s-stack>
|
|
<s-heading>{t.name}</s-heading>
|
|
<s-text>{t.summary}</s-text>
|
|
<s-text color="subdued">{t.festival}{t.next ? ` · ${t.next}` : ""}</s-text>
|
|
<s-button href={`/app/offers/new?template=${t.key}`} disabled={!allowed}>Use template</s-button>
|
|
</s-stack>
|
|
</s-box>
|
|
))}
|
|
</s-grid>
|
|
</s-section>
|
|
</s-page>
|
|
);
|
|
}
|
|
|
|
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);
|