bundle-offers-app/app/routes/app.offers._index.tsx
metatroncubeswdev 365e45406a Bundle Offers Shopify app: discount function, admin, storefront, deploy stack
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>
2026-10-13 21:35:58 -04:00

267 lines
13 KiB
TypeScript

import { useState } from "react";
import type { ActionFunctionArgs, HeadersFunction, LoaderFunctionArgs } from "react-router";
import { Form, useLoaderData, useNavigation, useSearchParams, useSubmit } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server";
import type { Prisma } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { ensureShop } from "../lib/shop.server";
import { bulkAction, moveOffer } from "../lib/offers.server";
import { publishShop } from "../lib/publish.server";
import { moneyFormatter, offerStatus, offerSummary, STATUS_LABEL, STATUS_TONE, TYPE_LABEL } from "../lib/offer-text";
import { formatShopDate } from "../lib/tz";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const shop = await ensureShop(admin, session.shop);
const url = new URL(request.url);
const filter = url.searchParams.get("filter") || "current";
const type = url.searchParams.get("type") || "";
const q = (url.searchParams.get("q") || "").trim();
const now = new Date();
const where: Prisma.OfferWhereInput = { shopId: shop.id };
if (filter === "archived") where.active = false;
else {
where.active = true;
if (filter === "published") where.published = true;
if (filter === "current") where.OR = [{ endsAt: null }, { endsAt: { gte: now } }];
}
if (type) where.type = type;
if (q) {
where.AND = [{
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ lines: { some: { title: { contains: q, mode: "insensitive" } } } },
{ scopes: { some: { title: { contains: q, mode: "insensitive" } } } },
],
}];
}
const offers = await db.offer.findMany({
where,
orderBy: [{ sequence: "asc" }, { startsAt: "desc" }, { createdAt: "desc" }],
include: { lines: true, scopes: true, _count: { select: { usages: true } } },
});
const money = moneyFormatter(shop.currency);
return {
filter, type, q,
offers: offers.map((o) => {
const status = offerStatus(o, now);
return {
id: o.id, name: o.name, badge: o.badge, type: o.type, status,
summary: offerSummary(o, money),
image: o.imageUrl || o.lines.find((l) => l.imageUrl)?.imageUrl || o.scopes.find((s) => s.imageUrl)?.imageUrl || "",
starts: formatShopDate(o.startsAt, shop.timezone),
ends: formatShopDate(o.endsAt, shop.timezone),
countries: o.countries.join(", "),
orders: o._count.usages,
published: o.published,
active: o.active,
limitedByPlan: o.limitedByPlan,
};
}),
publishError: shop.publishError,
};
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const shop = await ensureShop(admin, session.shop);
const form = await request.formData();
const intent = String(form.get("intent") || "");
const ids = form.getAll("ids").map(String);
const staff = session.onlineAccessInfo?.associated_user?.email;
if (intent === "move") await moveOffer(shop.id, String(form.get("id")), form.get("direction") === "up" ? "up" : "down");
else if (intent === "toggle") await bulkAction(shop.id, [String(form.get("id"))], form.get("published") === "1" ? "publish" : "unpublish", staff);
else await bulkAction(shop.id, ids, intent, staff);
const outcome = await publishShop(admin, session.shop);
return { ok: outcome.ok, error: outcome.error };
};
const FILTERS = [
{ value: "current", label: "Current & upcoming" },
{ value: "published", label: "Published" },
{ value: "all", label: "All (not archived)" },
{ value: "archived", label: "Archived" },
];
export default function OffersIndex() {
const { offers, filter, type, q, publishError } = useLoaderData<typeof loader>();
const [selected, setSelected] = useState<string[]>([]);
const [params, setParams] = useSearchParams();
const submit = useSubmit();
const nav = useNavigation();
const busy = nav.state !== "idle";
const setParam = (k: string, v: string) => {
const next = new URLSearchParams(params);
if (v) next.set(k, v);
else next.delete(k);
setParams(next);
setSelected([]);
};
const bulk = (intent: string) => {
const fd = new FormData();
fd.set("intent", intent);
selected.forEach((id) => fd.append("ids", id));
submit(fd, { method: "post" });
setSelected([]);
};
const toggleAll = (on: boolean) => setSelected(on ? offers.map((o) => o.id) : []);
return (
<s-page heading="Bundle offers" inlineSize="large">
<s-button slot="primary-action" variant="primary" href="/app/offers/new">Create offer</s-button>
<s-button slot="secondary-actions" href="/app/templates">Festival templates</s-button>
{publishError ? (
<s-banner tone="critical" heading="Offers could not be published to your store">
<s-paragraph>{publishError}</s-paragraph>
</s-banner>
) : null}
<s-section padding="base">
<s-stack gap="base">
<s-grid gridTemplateColumns="2fr 1fr 1fr" gap="base">
<Form method="get">
<input type="hidden" name="filter" value={filter} />
<input type="hidden" name="type" value={type} />
<s-search-field label="Search" labelAccessibilityVisibility="exclusive" name="q" defaultValue={q} placeholder="Search by offer or product name" />
</Form>
<s-select label="Show" labelAccessibilityVisibility="exclusive" value={filter} onChange={(e) => setParam("filter", e.currentTarget.value)}>
{FILTERS.map((f) => <s-option key={f.value} value={f.value}>{f.label}</s-option>)}
</s-select>
<s-select label="Type" labelAccessibilityVisibility="exclusive" value={type} onChange={(e) => setParam("type", e.currentTarget.value)}>
<s-option value="">All types</s-option>
<s-option value="fixed">Fixed bundle</s-option>
<s-option value="mix">Mix & match</s-option>
<s-option value="bxgy">Buy X get Y</s-option>
</s-select>
</s-grid>
{selected.length ? (
<s-stack direction="inline" gap="small-200" alignItems="center">
<s-text>{selected.length} selected</s-text>
<s-button onClick={() => bulk("publish")} disabled={busy}>Publish</s-button>
<s-button onClick={() => bulk("unpublish")} disabled={busy}>Unpublish</s-button>
<s-button onClick={() => bulk("duplicate")} disabled={busy}>Duplicate</s-button>
{filter === "archived" ? (
<>
<s-button onClick={() => bulk("unarchive")} disabled={busy}>Restore</s-button>
<s-button tone="critical" onClick={() => bulk("delete")} disabled={busy}>Delete</s-button>
</>
) : (
<s-button tone="critical" onClick={() => bulk("archive")} disabled={busy}>Archive</s-button>
)}
</s-stack>
) : null}
{offers.length === 0 ? (
<s-box padding="large-300">
<s-stack gap="base" alignItems="center">
<s-heading>Create your first bundle offer</s-heading>
<s-paragraph>
Pick products from the catalogue, choose % off, $ off or a bundle price, and set festival dates.
Running offers show on the homepage, on product pages and as hints in the cart.
</s-paragraph>
<s-stack direction="inline" gap="base">
<s-button variant="primary" href="/app/offers/new">Create offer</s-button>
<s-button href="/app/templates">Start from a festival template</s-button>
</s-stack>
</s-stack>
</s-box>
) : (
<s-table>
<s-table-header-row>
<s-table-header>
<s-checkbox
accessibilityLabel="Select all offers"
checked={selected.length === offers.length}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
</s-table-header>
<s-table-header>Priority</s-table-header>
<s-table-header listSlot="primary">Offer</s-table-header>
<s-table-header>Type</s-table-header>
<s-table-header>Dates</s-table-header>
<s-table-header format="numeric">Orders</s-table-header>
<s-table-header listSlot="secondary">Status</s-table-header>
<s-table-header>Published</s-table-header>
</s-table-header-row>
<s-table-body>
{offers.map((o, i) => (
<s-table-row key={o.id}>
<s-table-cell>
<s-checkbox
accessibilityLabel={`Select ${o.name}`}
checked={selected.includes(o.id)}
onChange={(e) => {
const on = e.currentTarget.checked;
setSelected((s) => (on ? [...s, o.id] : s.filter((x) => x !== o.id)));
}}
/>
</s-table-cell>
<s-table-cell>
<s-stack direction="inline" gap="small-400">
<Form method="post">
<input type="hidden" name="intent" value="move" />
<input type="hidden" name="id" value={o.id} />
<input type="hidden" name="direction" value="up" />
<s-button type="submit" variant="tertiary" icon="arrow-up" accessibilityLabel={`Move ${o.name} up`} disabled={i === 0 || busy} />
</Form>
<Form method="post">
<input type="hidden" name="intent" value="move" />
<input type="hidden" name="id" value={o.id} />
<input type="hidden" name="direction" value="down" />
<s-button type="submit" variant="tertiary" icon="arrow-down" accessibilityLabel={`Move ${o.name} down`} disabled={i === offers.length - 1 || busy} />
</Form>
</s-stack>
</s-table-cell>
<s-table-cell>
<s-stack direction="inline" gap="base" alignItems="center">
<s-thumbnail src={o.image || undefined} alt="" size="small" />
<s-stack gap="small-500">
<s-link href={`/app/offers/${o.id}`}>{o.name}</s-link>
<s-text color="subdued">{o.summary}</s-text>
{o.badge ? <s-badge>{o.badge}</s-badge> : null}
</s-stack>
</s-stack>
</s-table-cell>
<s-table-cell>{TYPE_LABEL[o.type]}</s-table-cell>
<s-table-cell>
<s-stack gap="small-500">
<s-text>{o.starts ? `From ${o.starts}` : "No start date"}</s-text>
<s-text color="subdued">{o.ends ? `Ends ${o.ends}` : "No end date"}</s-text>
{o.countries ? <s-text color="subdued">{o.countries}</s-text> : null}
</s-stack>
</s-table-cell>
<s-table-cell>{o.orders}</s-table-cell>
<s-table-cell>
<s-stack gap="small-500">
<s-badge tone={STATUS_TONE[o.status]}>{o.active ? STATUS_LABEL[o.status] : "Archived"}</s-badge>
{o.limitedByPlan ? <s-badge tone="caution">Over plan limit</s-badge> : null}
</s-stack>
</s-table-cell>
<s-table-cell>
<Form method="post">
<input type="hidden" name="intent" value="toggle" />
<input type="hidden" name="id" value={o.id} />
<input type="hidden" name="published" value={o.published ? "0" : "1"} />
<s-button type="submit" variant="secondary" disabled={!o.active || busy}>
{o.published ? "Unpublish" : "Publish"}
</s-button>
</Form>
</s-table-cell>
</s-table-row>
))}
</s-table-body>
</s-table>
)}
</s-stack>
</s-section>
</s-page>
);
}
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);