bundle-offers-app/app/routes/app._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

163 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 db from "../db.server";
import { ensureShop, gql } from "../lib/shop.server";
import { currentMonthOrders, publishShop } from "../lib/publish.server";
import { planOf } from "../lib/plans";
import { moneyFormatter, offerStatus, offerSummary } from "../lib/offer-text";
import { formatShopDate } from "../lib/tz";
import { CONFIG_MAX_BYTES } from "../lib/compile";
import { upcomingFestivals } from "../lib/templates";
function settingsMenuLinked(settings: unknown) {
return !!(settings && typeof settings === "object" && (settings as { menuLinked?: boolean }).menuLinked);
}
const THEME_QUERY = `#graphql
query BundleOffersTheme {
themes(first: 1, roles: [MAIN]) {
nodes { id name files(filenames: ["config/settings_data.json"]) { nodes { body { ... on OnlineStoreThemeFileBodyText { content } } } } }
}
}`;
async function setupState(admin: Parameters<typeof gql>[0]) {
let embedOn = false;
let themeId = "";
try {
const t = await gql(admin, THEME_QUERY);
const theme = t.themes.nodes[0];
themeId = theme?.id?.split("/").pop() || "";
const content: string = theme?.files?.nodes?.[0]?.body?.content || "";
// app embeds are stored under current.blocks with type "shopify://apps/<app>/blocks/<handle>/<uuid>"
const json = JSON.parse(content.replace(/\/\*[\s\S]*?\*\//g, "") || "{}");
const blocks = json?.current?.blocks || {};
embedOn = Object.values(blocks).some((b) => {
const block = b as { type?: string; disabled?: boolean };
return typeof block.type === "string" && block.type.includes("/blocks/bundle-offers-embed/") && !block.disabled;
});
} catch {
/* read_themes missing or theme unreadable: show the step as to-do */
}
return { embedOn, themeId };
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const shop = await ensureShop(admin, session.shop, true);
if (!shop.lastPublishedAt) await publishShop(admin, session.shop);
const plan = planOf(shop.planCode);
const now = new Date();
const offers = await db.offer.findMany({ where: { shopId: shop.id, active: true }, orderBy: [{ sequence: "asc" }] });
const running = offers.filter((o) => offerStatus(o, now) === "running");
const since = new Date(now.getTime() - 30 * 86400000);
const usage = await db.orderUsage.aggregate({ where: { shopId: shop.id, createdAt: { gte: since } }, _sum: { discount: true, bundleRevenue: true } });
const orders = await db.orderUsage.groupBy({ by: ["orderId"], where: { shopId: shop.id, createdAt: { gte: since } } });
const setup = await setupState(admin);
const money = moneyFormatter(shop.currency);
const apiKey = process.env.SHOPIFY_API_KEY || "";
return {
shop: { domain: shop.domain, publishError: shop.publishError, configBytes: shop.configBytes, monthOrders: currentMonthOrders(shop) },
plan,
running: running.map((o) => ({ id: o.id, name: o.name, summary: offerSummary(o, money), ends: formatShopDate(o.endsAt, shop.timezone) })),
totalOffers: offers.length,
stats: {
orders: orders.length,
revenue: money(Number(usage._sum.bundleRevenue || 0)),
discount: money(Number(usage._sum.discount || 0)),
},
setup,
menuLinked: settingsMenuLinked(shop.settings),
embedLink: `https://${shop.domain}/admin/themes/${setup.themeId || "current"}/editor?context=apps&activateAppId=${apiKey}/bundle-offers-embed`,
festivals: upcomingFestivals(now, 120).slice(0, 4),
configMax: CONFIG_MAX_BYTES,
};
};
export default function Home() {
const d = useLoaderData<typeof loader>();
const steps = [
{ done: d.setup.embedOn, label: "Turn on the Bundle Offers app embed", help: "Loads the storefront script and styles (cart hints, add bundle to cart).", action: <s-button href={d.embedLink} target="_blank">Open theme editor</s-button> },
{ done: d.menuLinked, label: "Link the bundles page in your menu", help: "Your bundles page is at /apps/bundles. Add it to the main menu (Online Store › Navigation), or place the “Bundles grid” block on any page.", action: <s-button href={`https://${d.shop.domain}/admin/menus`} target="_blank">Open navigation</s-button> },
{ done: d.totalOffers > 0, label: "Create your first offer", help: "Start from scratch or from a festival template.", action: <s-button href="/app/offers/new">Create offer</s-button> },
{ done: d.running.length > 0, label: "Publish an offer and preview it", help: "Published offers within their dates run automatically in cart and checkout.", action: <s-button href={`https://${d.shop.domain}/apps/bundles`} target="_blank">View bundles page</s-button> },
];
const doneCount = steps.filter((s) => s.done).length;
return (
<s-page heading="Bundle Offers">
<s-button slot="primary-action" variant="primary" href="/app/offers/new">Create offer</s-button>
{d.shop.publishError ? (
<s-banner tone="critical" heading="Your offers could not be published"><s-paragraph>{d.shop.publishError}</s-paragraph></s-banner>
) : null}
{d.plan.monthlyOrders !== Infinity && d.shop.monthOrders >= d.plan.monthlyOrders ? (
<s-banner tone="warning" heading={`You've reached ${d.plan.monthlyOrders} bundle orders this month`}>
<s-paragraph>Bundle discounts are paused until next month. Upgrade to keep them running.</s-paragraph>
<s-button slot="secondary-actions" href="/app/plans">See plans</s-button>
</s-banner>
) : null}
{doneCount < steps.length ? (
<s-section heading={`Setup: ${doneCount} of ${steps.length} done`}>
<s-stack gap="base">
{steps.map((s) => (
<s-stack key={s.label} direction="inline" gap="base" alignItems="center" justifyContent="space-between">
<s-stack direction="inline" gap="small-200" alignItems="center">
<s-icon type={s.done ? "check-circle" : "circle"} tone={s.done ? "success" : "neutral"} />
<s-stack gap="small-500">
<s-text type="strong">{s.label}</s-text>
<s-text color="subdued">{s.help}</s-text>
</s-stack>
</s-stack>
{s.done ? null : s.action}
</s-stack>
))}
</s-stack>
</s-section>
) : null}
<s-section heading="Last 30 days">
<s-grid gridTemplateColumns="1fr 1fr 1fr" gap="base">
<s-box padding="base" background="subdued" borderRadius="base"><s-stack gap="small-500"><s-text color="subdued">Bundle orders</s-text><s-heading>{String(d.stats.orders)}</s-heading></s-stack></s-box>
<s-box padding="base" background="subdued" borderRadius="base"><s-stack gap="small-500"><s-text color="subdued">Bundle revenue</s-text><s-heading>{d.stats.revenue}</s-heading></s-stack></s-box>
<s-box padding="base" background="subdued" borderRadius="base"><s-stack gap="small-500"><s-text color="subdued">Discounts given</s-text><s-heading>{d.stats.discount}</s-heading></s-stack></s-box>
</s-grid>
</s-section>
<s-section heading="Running now">
{d.running.length ? (
<s-stack gap="small-200">
{d.running.map((o) => (
<s-stack key={o.id} direction="inline" justifyContent="space-between">
<s-link href={`/app/offers/${o.id}`}>{o.name}</s-link>
<s-text color="subdued">{o.summary}{o.ends ? ` · ends ${o.ends}` : ""}</s-text>
</s-stack>
))}
</s-stack>
) : <s-paragraph>No offer is running. Published offers within their dates run automatically.</s-paragraph>}
</s-section>
<s-section slot="aside" heading="Plan">
<s-stack gap="small-200">
<s-text type="strong">{d.plan.name}</s-text>
<s-text>Running offers: {d.running.length} of {d.plan.runningOffers === Infinity ? "unlimited" : d.plan.runningOffers}</s-text>
{d.plan.monthlyOrders !== Infinity ? <s-text>Bundle orders this month: {d.shop.monthOrders} of {d.plan.monthlyOrders}</s-text> : null}
<s-text color="subdued">Discount configuration: {Math.round((d.shop.configBytes / d.configMax) * 100)}% of the limit</s-text>
<s-button href="/app/plans">Plans</s-button>
</s-stack>
</s-section>
<s-section slot="aside" heading="Coming up">
<s-stack gap="small-200">
{d.festivals.map((f) => <s-text key={f.key}>{f.name}: {f.date}{f.approximate ? " (approx.)" : ""}</s-text>)}
<s-button href="/app/templates">Festival templates</s-button>
</s-stack>
</s-section>
</s-page>
);
}
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);