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

120 lines
6.8 KiB
TypeScript

import { useEffect, useState } from "react";
import type { ActionFunctionArgs, HeadersFunction, LoaderFunctionArgs } from "react-router";
import { useActionData, useLoaderData, useNavigation, useSubmit } from "react-router";
import { useAppBridge } from "@shopify/app-bridge-react";
import { boundary } from "@shopify/shopify-app-react-router/server";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { ensureShop } from "../lib/shop.server";
import { updateCombinations } from "../lib/publish.server";
import { CONFIG_MAX_BYTES, settingsOf, type ShopSettings } from "../lib/compile";
type Settings = ReturnType<typeof settingsOf> & { menuLinked?: boolean };
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const shop = await ensureShop(admin, session.shop);
const raw = (shop.settings || {}) as ShopSettings & { menuLinked?: boolean };
return {
settings: { ...settingsOf(raw), menuLinked: !!raw.menuLinked } as Settings,
configBytes: shop.configBytes,
storefrontBytes: shop.storefrontBytes,
max: CONFIG_MAX_BYTES,
};
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const shop = await ensureShop(admin, session.shop);
const s = JSON.parse(String((await request.formData()).get("payload"))) as Settings;
const clean: Settings = {
excludeTag: (s.excludeTag || "").trim().slice(0, 40),
excludeSubscriptions: !!s.excludeSubscriptions,
excludeGiftCards: !!s.excludeGiftCards,
bxgyAllocation: s.bxgyAllocation === "get_units_only" ? "get_units_only" : "all_units",
combineProduct: !!s.combineProduct,
combineOrder: !!s.combineOrder,
combineShipping: !!s.combineShipping,
afterAdd: s.afterAdd === "drawer" ? "drawer" : "cart",
accent: /^#[0-9a-fA-F]{6}$/.test(s.accent || "") ? s.accent : "",
radius: Math.min(Math.max(Math.round(Number(s.radius) || 0), 0), 24),
menuLinked: !!s.menuLinked,
};
await db.shop.update({ where: { id: shop.id }, data: { settings: clean } });
const outcome = await updateCombinations(admin, session.shop);
return { ok: outcome.ok, error: outcome.error };
};
export default function SettingsPage() {
const { settings, configBytes, storefrontBytes, max } = useLoaderData<typeof loader>();
const result = useActionData<typeof action>();
const [s, setS] = useState<Settings>(settings);
const submit = useSubmit();
const nav = useNavigation();
const shopify = useAppBridge();
useEffect(() => {
if (result?.ok) shopify.toast.show("Settings saved");
if (result?.error) shopify.toast.show(result.error, { isError: true });
}, [result, shopify]);
const set = <K extends keyof Settings>(k: K, v: Settings[K]) => setS((x) => ({ ...x, [k]: v }));
const save = () => {
const fd = new FormData();
fd.set("payload", JSON.stringify(s));
submit(fd, { method: "post" });
};
const pct = Math.round((configBytes / max) * 100);
return (
<s-page heading="Settings">
<s-button slot="primary-action" variant="primary" onClick={save} {...(nav.state !== "idle" ? { loading: true } : {})}>Save</s-button>
<s-section heading="Exclusions">
<s-stack gap="base">
<s-text-field label="Exclusion tag" value={s.excludeTag} details="Products with this tag never count towards a bundle (e.g. marketplace sellers' products)." onInput={(e) => set("excludeTag", e.currentTarget.value)} />
<s-checkbox label="Exclude subscription items (bought with a selling plan)" checked={s.excludeSubscriptions} onChange={(e) => set("excludeSubscriptions", e.currentTarget.checked)} />
<s-checkbox label="Exclude gift cards" checked={s.excludeGiftCards} onChange={(e) => set("excludeGiftCards", e.currentTarget.checked)} />
</s-stack>
</s-section>
<s-section heading="Combining with other discounts">
<s-stack gap="base">
<s-checkbox label="Combine with other product discounts" checked={s.combineProduct} onChange={(e) => set("combineProduct", e.currentTarget.checked)} />
<s-checkbox label="Combine with order discounts" checked={s.combineOrder} onChange={(e) => set("combineOrder", e.currentTarget.checked)} />
<s-checkbox label="Combine with shipping discounts" checked={s.combineShipping} onChange={(e) => set("combineShipping", e.currentTarget.checked)} />
</s-stack>
</s-section>
<s-section heading="Buy X get Y">
<s-choice-list label="How the discount shows on the items" values={[s.bxgyAllocation]} onChange={(e) => set("bxgyAllocation", (e.currentTarget.values[0] as Settings["bxgyAllocation"]) || "all_units")}>
<s-choice value="all_units">Spread over all items of the deal (same total, matches the original module)</s-choice>
<s-choice value="get_units_only">Only on the "get" items (the free item shows as free)</s-choice>
</s-choice-list>
</s-section>
<s-section heading="Storefront">
<s-stack gap="base">
<s-choice-list label="After “Add bundle to cart”" values={[s.afterAdd]} onChange={(e) => set("afterAdd", (e.currentTarget.values[0] as Settings["afterAdd"]) || "cart")}>
<s-choice value="cart">Go to the cart page</s-choice>
<s-choice value="drawer">Open the theme's cart drawer (if the theme has one)</s-choice>
</s-choice-list>
<s-text-field label="Accent colour" value={s.accent} placeholder="#b5462e" details="Leave empty to use your theme's colours." onInput={(e) => set("accent", e.currentTarget.value)} />
<s-number-field label="Card corner radius" value={String(s.radius)} min={0} max={24} step={1} suffix="px" onInput={(e) => set("radius", Number(e.currentTarget.value))} />
<s-checkbox label="I've added /apps/bundles to my store menu" checked={!!s.menuLinked} onChange={(e) => set("menuLinked", e.currentTarget.checked)} />
</s-stack>
</s-section>
<s-section heading="Discount configuration size">
<s-stack gap="small-200">
<s-text>{configBytes.toLocaleString()} of {max.toLocaleString()} bytes ({pct}%)</s-text>
<div role="meter" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label="Configuration size" style={{ height: 8, borderRadius: 4, background: "#e3e3e3" }}>
<div style={{ width: `${Math.min(pct, 100)}%`, height: 8, borderRadius: 4, background: pct > 85 ? "#c70a24" : "#29845a" }} />
</div>
<s-text color="subdued">All running offers share one Shopify discount so each item counts once. Storefront data: {storefrontBytes.toLocaleString()} bytes.</s-text>
</s-stack>
</s-section>
</s-page>
);
}
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);