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>
476 lines
30 KiB
TypeScript
476 lines
30 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import type { ActionFunctionArgs, HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||
import { redirect, useActionData, useFetcher, 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 { duplicateOffer, offerInclude, saveOffer, type OfferForm, type OfferFormLine, type OfferFormScope } from "../lib/offers.server";
|
||
import { publishShop } from "../lib/publish.server";
|
||
import { moneyFormatter, offerStatus, offerSummary, STATUS_LABEL, STATUS_TONE } from "../lib/offer-text";
|
||
import { validateOffer, type Errors } from "../lib/validation";
|
||
import { planOf } from "../lib/plans";
|
||
import { festivalByName, festivalWindow, TEMPLATES } from "../lib/templates";
|
||
import { formatShopDate, shopLocalToUtc, utcToShopLocal } from "../lib/tz";
|
||
import { allocateByLine, applyOffers, toUnits } from "../../engine/engine.js";
|
||
|
||
type FormState = Omit<OfferForm, "startsAt" | "endsAt" | "discountValue" | "getPercent"> & {
|
||
startsAt: string; // shop-local datetime-local value
|
||
endsAt: string;
|
||
discountValue: string;
|
||
getPercent: string;
|
||
i18n: Record<string, { name?: string; badge?: string; description?: string }>;
|
||
};
|
||
|
||
const blank: FormState = {
|
||
name: "", badge: "", description: "", type: "fixed", discountType: "percent", discountValue: "10",
|
||
mixQty: 3, buyQty: 2, getQty: 1, getPercent: "100", startsAt: "", endsAt: "", maxPerOrder: 0, sequence: 10,
|
||
published: false, countries: [], showHome: true, showProduct: true, showCartHints: true, imageUrl: "",
|
||
lines: [], scopes: [], i18n: {},
|
||
};
|
||
|
||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||
const { admin, session } = await authenticate.admin(request);
|
||
const shop = await ensureShop(admin, session.shop);
|
||
const isNew = params.id === "new";
|
||
let form: FormState = { ...blank };
|
||
let meta: { status: string; orders: number; handle: string; active: boolean; createdAt: string | null } = {
|
||
status: "draft", orders: 0, handle: "", active: true, createdAt: null,
|
||
};
|
||
let history: { field: string; oldValue: string | null; newValue: string | null; staffUser: string | null; at: string }[] = [];
|
||
let templateHint = "";
|
||
|
||
if (!isNew) {
|
||
const o = await db.offer.findFirst({ where: { id: params.id, shopId: shop.id }, include: offerInclude });
|
||
if (!o) throw new Response("Offer not found", { status: 404 });
|
||
form = {
|
||
name: o.name, badge: o.badge || "", description: o.description || "", type: o.type, discountType: o.discountType,
|
||
discountValue: String(Number(o.discountValue)), mixQty: o.mixQty, buyQty: o.buyQty, getQty: o.getQty,
|
||
getPercent: String(Number(o.getPercent)), startsAt: utcToShopLocal(o.startsAt, shop.timezone), endsAt: utcToShopLocal(o.endsAt, shop.timezone),
|
||
maxPerOrder: o.maxPerOrder, sequence: o.sequence, published: o.published, active: o.active, countries: o.countries,
|
||
showHome: o.showHome, showProduct: o.showProduct, showCartHints: o.showCartHints, imageUrl: o.imageUrl || "",
|
||
lines: o.lines.map((l) => ({ productId: l.productId, variantId: l.variantId, quantity: l.quantity, title: l.title, handle: l.handle, imageUrl: l.imageUrl })),
|
||
scopes: o.scopes.map((s) => ({ role: s.role, kind: s.kind, refId: s.refId, parentId: s.parentId, title: s.title, handle: s.handle, imageUrl: s.imageUrl })),
|
||
i18n: (o.i18n || {}) as FormState["i18n"],
|
||
};
|
||
meta = {
|
||
status: o.active ? offerStatus(o) : "archived",
|
||
orders: await db.orderUsage.count({ where: { offerId: o.id } }),
|
||
handle: o.handle, active: o.active, createdAt: o.createdAt.toISOString(),
|
||
};
|
||
history = (await db.auditLog.findMany({ where: { offerId: o.id }, orderBy: { at: "desc" }, take: 25 })).map((h) => ({
|
||
field: h.field, oldValue: h.oldValue, newValue: h.newValue, staffUser: h.staffUser, at: formatShopDate(h.at, shop.timezone),
|
||
}));
|
||
} else {
|
||
const key = new URL(request.url).searchParams.get("template");
|
||
const t = TEMPLATES.find((x) => x.key === key);
|
||
if (t) {
|
||
const fest = festivalByName(t.festival);
|
||
const win = fest ? festivalWindow(fest) : null;
|
||
form = {
|
||
...blank, name: t.name, badge: t.badge, description: t.description, type: t.type, discountType: t.discountType,
|
||
discountValue: String(t.discountValue || 10), mixQty: t.mixQty ?? 3, buyQty: t.buyQty ?? 2, getQty: t.getQty ?? 1,
|
||
getPercent: String(t.getPercent ?? 100), startsAt: win?.start || "", endsAt: win?.end || "",
|
||
};
|
||
templateHint = `${t.hint}${fest?.approximate ? ` ${fest.name} dates are approximate: check them against your local calendar.` : ""}`;
|
||
}
|
||
}
|
||
return {
|
||
isNew, form, meta, history, templateHint,
|
||
shop: { domain: shop.domain, currency: shop.currency, timezone: shop.timezone },
|
||
plan: planOf(shop.planCode),
|
||
};
|
||
};
|
||
|
||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||
const { admin, session } = await authenticate.admin(request);
|
||
const shop = await ensureShop(admin, session.shop);
|
||
const fd = await request.formData();
|
||
const intent = String(fd.get("intent") || "save");
|
||
const staff = session.onlineAccessInfo?.associated_user?.email;
|
||
const id = params.id === "new" ? null : params.id!;
|
||
|
||
if (intent === "duplicate" && id) {
|
||
const copy = await duplicateOffer(shop.id, id, staff);
|
||
await publishShop(admin, session.shop);
|
||
return redirect(`/app/offers/${copy!.id}?saved=duplicated`);
|
||
}
|
||
if ((intent === "archive" || intent === "restore") && id) {
|
||
await db.offer.update({ where: { id }, data: intent === "archive" ? { active: false, published: false } : { active: true } });
|
||
await db.auditLog.create({ data: { shopId: shop.id, offerId: id, staffUser: staff, field: intent, newValue: "" } });
|
||
await publishShop(admin, session.shop);
|
||
return redirect(`/app/offers/${id}?saved=${intent}`);
|
||
}
|
||
|
||
const raw = JSON.parse(String(fd.get("payload"))) as FormState;
|
||
const form: OfferForm = {
|
||
...raw,
|
||
startsAt: shopLocalToUtc(raw.startsAt, shop.timezone),
|
||
endsAt: shopLocalToUtc(raw.endsAt, shop.timezone),
|
||
};
|
||
const plan = planOf(shop.planCode);
|
||
const errors: Errors = {};
|
||
if (form.published && !plan.types.includes(form.type)) errors.type = `${form.type === "mix" ? "Mix & match" : "Buy X get Y"} bundles need a paid plan. Save as a draft or upgrade.`;
|
||
if (form.published && !plan.scheduling && (form.startsAt || form.endsAt)) errors.startsAt = "Start and end dates need a paid plan.";
|
||
if (form.countries.length && !plan.markets) errors.countries = "Market targeting needs the Growth plan.";
|
||
if (Object.keys(errors).length) return { errors, publishError: null };
|
||
|
||
const saved = await saveOffer(shop.id, id, form, staff);
|
||
if (saved.errors) return { errors: saved.errors, publishError: null };
|
||
const outcome = await publishShop(admin, session.shop);
|
||
if (!outcome.ok) return { errors: {}, publishError: outcome.error, savedId: saved.offer!.id };
|
||
const reason = outcome.result.excluded[saved.offer!.id];
|
||
return redirect(`/app/offers/${saved.offer!.id}?saved=1${reason ? `&limited=${encodeURIComponent(reason)}` : ""}`);
|
||
};
|
||
|
||
// ---------- UI ----------
|
||
|
||
type Picked = { id: string; title?: string; handle?: string; images?: { originalSrc?: string; url?: string }[]; image?: { originalSrc?: string; url?: string } | null; totalVariants?: number; variants?: { id: string; title?: string; price?: string }[] };
|
||
const nid = (gid: string) => gid.split("/").pop() || gid;
|
||
const img = (p: Picked) => p.images?.[0]?.originalSrc || p.images?.[0]?.url || p.image?.originalSrc || p.image?.url || null;
|
||
|
||
export default function OfferEditor() {
|
||
const data = useLoaderData<typeof loader>();
|
||
const actionData = useActionData<typeof action>();
|
||
const shopify = useAppBridge();
|
||
const submit = useSubmit();
|
||
const nav = useNavigation();
|
||
const upload = useFetcher<{ url?: string; error?: string }>();
|
||
const [f, setF] = useState<FormState>(data.form);
|
||
const [showErrors, setShowErrors] = useState(false);
|
||
const [locale, setLocale] = useState("fr");
|
||
const [prices, setPrices] = useState<Record<string, string>>({});
|
||
const saving = nav.state === "submitting";
|
||
const money = useMemo(() => moneyFormatter(data.shop.currency), [data.shop.currency]);
|
||
|
||
useEffect(() => setF(data.form), [data.form]);
|
||
useEffect(() => {
|
||
const p = new URLSearchParams(window.location.search);
|
||
if (p.get("saved")) shopify.toast.show(p.get("saved") === "duplicated" ? "Offer duplicated" : "Offer saved");
|
||
if (p.get("limited")) shopify.toast.show(`Saved, but not running: ${p.get("limited")}`, { isError: true, duration: 8000 });
|
||
}, [data, shopify]);
|
||
useEffect(() => {
|
||
if (upload.data?.url) setF((s) => ({ ...s, imageUrl: upload.data!.url! }));
|
||
if (upload.data?.error) shopify.toast.show(upload.data.error, { isError: true });
|
||
}, [upload.data, shopify]);
|
||
|
||
const set = <K extends keyof FormState>(k: K, v: FormState[K]) => setF((s) => ({ ...s, [k]: v }));
|
||
const clientErrors = validateOffer({ ...f, startsAt: f.startsAt || null, endsAt: f.endsAt || null });
|
||
const serverErrors = (actionData && "errors" in actionData ? actionData.errors : {}) || {};
|
||
const errors: Errors = { ...(showErrors ? clientErrors : {}), ...serverErrors };
|
||
const err = (k: string) => errors[k] || undefined;
|
||
|
||
const summary = offerSummary({ ...f, active: true, startsAt: null, endsAt: null }, money);
|
||
|
||
const save = (publish?: boolean) => {
|
||
setShowErrors(true);
|
||
const next = publish === undefined ? f : { ...f, published: publish };
|
||
if (Object.keys(validateOffer({ ...next, startsAt: next.startsAt || null, endsAt: next.endsAt || null })).length) {
|
||
shopify.toast.show("Please fix the highlighted fields", { isError: true });
|
||
return;
|
||
}
|
||
const fd = new FormData();
|
||
fd.set("intent", "save");
|
||
fd.set("payload", JSON.stringify(next));
|
||
submit(fd, { method: "post" });
|
||
};
|
||
const simple = (intent: string) => {
|
||
const fd = new FormData();
|
||
fd.set("intent", intent);
|
||
submit(fd, { method: "post" });
|
||
};
|
||
|
||
// ---- pickers ----
|
||
async function pickLines() {
|
||
const selected = (await shopify.resourcePicker({ type: "product", multiple: true, filter: { variants: true } })) as Picked[] | undefined;
|
||
if (!selected) return;
|
||
const add: OfferFormLine[] = [];
|
||
for (const p of selected) {
|
||
const variants = p.variants || [];
|
||
const whole = !variants.length || (p.totalVariants ?? variants.length) <= variants.length;
|
||
if (whole) add.push({ productId: nid(p.id), variantId: null, quantity: 1, title: p.title, handle: p.handle, imageUrl: img(p) });
|
||
else for (const v of variants) add.push({ productId: nid(p.id), variantId: nid(v.id), quantity: 1, title: `${p.title} – ${v.title}`, handle: p.handle, imageUrl: img(p) });
|
||
const price = variants[0]?.price;
|
||
if (price) setPrices((s) => ({ ...s, [nid(p.id)]: price }));
|
||
}
|
||
setF((s) => ({ ...s, lines: [...s.lines, ...add.filter((a) => !s.lines.some((l) => l.productId === a.productId && (l.variantId || null) === (a.variantId || null)))] }));
|
||
}
|
||
|
||
async function pickScope(role: string, kind: "product" | "collection") {
|
||
const selected = (await shopify.resourcePicker({ type: kind, multiple: true, ...(kind === "product" ? { filter: { variants: true } } : {}) })) as Picked[] | undefined;
|
||
if (!selected) return;
|
||
const add: OfferFormScope[] = [];
|
||
for (const p of selected) {
|
||
if (kind === "collection") { add.push({ role, kind: "collection", refId: nid(p.id), title: p.title, handle: p.handle, imageUrl: img(p) }); continue; }
|
||
const variants = p.variants || [];
|
||
const whole = !variants.length || (p.totalVariants ?? variants.length) <= variants.length;
|
||
if (whole) add.push({ role, kind: "product", refId: nid(p.id), title: p.title, handle: p.handle, imageUrl: img(p) });
|
||
else for (const v of variants) add.push({ role, kind: "variant", refId: nid(v.id), parentId: nid(p.id), title: `${p.title} – ${v.title}`, handle: p.handle, imageUrl: img(p) });
|
||
const price = variants[0]?.price;
|
||
if (price) setPrices((s) => ({ ...s, [nid(p.id)]: price }));
|
||
}
|
||
setF((s) => ({ ...s, scopes: [...s.scopes, ...add.filter((a) => !s.scopes.some((x) => x.role === a.role && x.kind === a.kind && x.refId === a.refId))] }));
|
||
}
|
||
|
||
const removeScope = (i: number) => setF((s) => ({ ...s, scopes: s.scopes.filter((_, k) => k !== i) }));
|
||
|
||
const ScopeList = ({ role, title, help, error }: { role: string; title: string; help: string; error?: string }) => (
|
||
<s-stack gap="small-200">
|
||
<s-heading>{title}</s-heading>
|
||
<s-text color="subdued">{help}</s-text>
|
||
{error ? <s-text tone="critical">{error}</s-text> : null}
|
||
{f.scopes.map((s, i) => (s.role !== role ? null : (
|
||
<s-stack key={`${s.kind}${s.refId}`} direction="inline" gap="base" alignItems="center">
|
||
<s-thumbnail src={s.imageUrl || undefined} alt="" size="small-200" />
|
||
<s-text>{s.title || s.refId}</s-text>
|
||
<s-badge>{s.kind === "collection" ? "Collection" : s.kind === "variant" ? "Variant" : "Product"}</s-badge>
|
||
<s-button variant="tertiary" icon="delete" accessibilityLabel={`Remove ${s.title}`} onClick={() => removeScope(i)} />
|
||
</s-stack>
|
||
)))}
|
||
<s-stack direction="inline" gap="small-200">
|
||
<s-button onClick={() => pickScope(role, "product")}>Add products</s-button>
|
||
<s-button onClick={() => pickScope(role, "collection")}>Add collections</s-button>
|
||
</s-stack>
|
||
</s-stack>
|
||
);
|
||
|
||
// ---- example cart preview: the real engine on sample quantities ----
|
||
const preview = useMemo(() => {
|
||
const items = new Map<string, { id: string; label: string; variantId: string; productId: string; collectionIds: string[]; qty: number }>();
|
||
if (f.type === "fixed") for (const l of f.lines) items.set(`p${l.productId}v${l.variantId || ""}`, { id: `p${l.productId}v${l.variantId || ""}`, label: l.title || l.productId, variantId: l.variantId || `any-${l.productId}`, productId: l.productId, collectionIds: [], qty: l.quantity });
|
||
else for (const s of f.scopes) {
|
||
const key = `${s.kind}${s.refId}`;
|
||
if (items.has(key)) continue;
|
||
const qty = f.type === "mix" ? 1 : s.role === "buy" ? f.buyQty : f.getQty;
|
||
items.set(key, {
|
||
id: key, label: s.title || s.refId, qty,
|
||
variantId: s.kind === "variant" ? s.refId : `v-${key}`, productId: s.kind === "product" ? s.refId : s.parentId || `p-${key}`,
|
||
collectionIds: s.kind === "collection" ? [s.refId] : [],
|
||
});
|
||
}
|
||
const rows = [...items.values()];
|
||
if (f.type === "mix" && rows.length) rows[0].qty = Math.max(rows[0].qty, f.mixQty - rows.length + 1);
|
||
const price = (r: (typeof rows)[number]) => Number(prices[r.productId] || prices[r.id] || 10) || 10;
|
||
const units = toUnits(rows.map((r) => ({ id: r.id, variantId: r.variantId, productId: r.productId, collectionIds: r.collectionIds, unitPrice: price(r), quantity: r.qty })));
|
||
const scope = (role: string) => {
|
||
const sc = f.scopes.filter((s) => s.role === role);
|
||
return {
|
||
variantIds: new Set(sc.filter((s) => s.kind === "variant").map((s) => s.refId)),
|
||
productIds: new Set(sc.filter((s) => s.kind === "product").map((s) => s.refId)),
|
||
collectionIds: new Set(sc.filter((s) => s.kind === "collection").map((s) => s.refId)),
|
||
};
|
||
};
|
||
const offer = {
|
||
id: "x", type: f.type, maxUses: f.maxPerOrder, discountType: f.discountType, value: Number(f.discountValue) || 0,
|
||
lines: f.lines.map((l) => (l.variantId ? { variantId: l.variantId, quantity: l.quantity } : { productId: l.productId, quantity: l.quantity })),
|
||
qty: f.mixQty, scope: scope("mix"), buyQty: f.buyQty, getQty: f.getQty, getPercent: Number(f.getPercent) || 0,
|
||
buyScope: scope("buy"), getScope: scope("get"),
|
||
};
|
||
const apps = applyOffers(units, [offer]);
|
||
const totals = allocateByLine(apps);
|
||
return {
|
||
rows: rows.map((r) => ({ ...r, price: price(r), discount: totals.get(`x\u0000${r.id}`) || 0 })),
|
||
total: apps.reduce((s, a) => s + a.discount, 0),
|
||
};
|
||
}, [f, prices]);
|
||
|
||
const statusKey = data.meta.status as keyof typeof STATUS_LABEL;
|
||
const previewUrl = data.meta.handle ? `https://${data.shop.domain}/apps/bundles/${data.meta.handle}` : "";
|
||
|
||
return (
|
||
<s-page heading={data.isNew ? "New bundle offer" : f.name || "Bundle offer"} inlineSize="base">
|
||
<s-link slot="breadcrumb-actions" href="/app/offers">Offers</s-link>
|
||
<s-button slot="primary-action" variant="primary" onClick={() => save()} {...(saving ? { loading: true } : {})}>Save</s-button>
|
||
{!data.isNew ? (
|
||
<>
|
||
<s-button slot="secondary-actions" onClick={() => save(!f.published)}>{f.published ? "Unpublish" : "Publish"}</s-button>
|
||
{previewUrl ? <s-button slot="secondary-actions" href={previewUrl} target="_blank">Preview</s-button> : null}
|
||
<s-button slot="secondary-actions" onClick={() => simple("duplicate")}>Duplicate</s-button>
|
||
<s-button slot="secondary-actions" onClick={() => simple(data.meta.active ? "archive" : "restore")}>{data.meta.active ? "Archive" : "Restore"}</s-button>
|
||
</>
|
||
) : null}
|
||
|
||
{actionData && "publishError" in actionData && actionData.publishError ? (
|
||
<s-banner tone="critical" heading="Saved, but the store could not be updated"><s-paragraph>{actionData.publishError}</s-paragraph></s-banner>
|
||
) : null}
|
||
{data.templateHint ? <s-banner tone="info" heading="Template"><s-paragraph>{data.templateHint}</s-paragraph></s-banner> : null}
|
||
|
||
{/* status bar */}
|
||
<s-section padding="base">
|
||
<s-stack direction="inline" gap="base" alignItems="center" justifyContent="space-between">
|
||
<s-stack direction="inline" gap="small-200" alignItems="center">
|
||
<s-badge tone={statusKey in STATUS_TONE ? STATUS_TONE[statusKey] : "neutral"}>{statusKey in STATUS_LABEL ? STATUS_LABEL[statusKey] : "Archived"}</s-badge>
|
||
<s-switch label="Published" checked={f.published} onChange={(e) => set("published", e.currentTarget.checked)} />
|
||
</s-stack>
|
||
{!data.isNew ? <s-button variant="tertiary" href={`/app/analytics?offer=${encodeURIComponent(data.form.name)}`}>{data.meta.orders} orders</s-button> : null}
|
||
</s-stack>
|
||
</s-section>
|
||
|
||
<s-section heading="Offer">
|
||
<s-stack gap="base">
|
||
<s-text-field label="Name" value={f.name} maxLength={80} error={err("name")} details="Shown to customers and as the discount name at checkout." onInput={(e) => set("name", e.currentTarget.value)} />
|
||
<s-box padding="small-200" background="subdued" borderRadius="base"><s-text>Deal: <s-text type="strong">{summary}</s-text></s-text></s-box>
|
||
<s-choice-list label="Bundle type" values={[f.type]} error={err("type")} onChange={(e) => set("type", e.currentTarget.values[0] || "fixed")}>
|
||
<s-choice value="fixed">Fixed bundle — a set list of products and quantities</s-choice>
|
||
<s-choice value="mix" disabled={!data.plan.types.includes("mix") && f.type !== "mix"}>Mix & match — any N items from chosen products or collections{data.plan.types.includes("mix") ? "" : " (paid plans)"}</s-choice>
|
||
<s-choice value="bxgy" disabled={!data.plan.types.includes("bxgy") && f.type !== "bxgy"}>Buy X get Y{data.plan.types.includes("bxgy") ? "" : " (paid plans)"}</s-choice>
|
||
</s-choice-list>
|
||
{f.type !== "bxgy" ? (
|
||
<s-grid gridTemplateColumns="1fr 1fr" gap="base">
|
||
<s-select label="Discount type" value={f.discountType} error={err("discountType")} onChange={(e) => set("discountType", e.currentTarget.value)}>
|
||
<s-option value="percent">% off</s-option>
|
||
<s-option value="amount">{data.shop.currency} off</s-option>
|
||
<s-option value="fixed_price">Bundle price</s-option>
|
||
</s-select>
|
||
<s-number-field label={f.discountType === "percent" ? "Percentage" : f.discountType === "amount" ? "Amount off" : "Bundle price"} value={f.discountValue}
|
||
min={0} step={0.01} suffix={f.discountType === "percent" ? "%" : data.shop.currency} error={err("discountValue")}
|
||
details="Prices as shown in your store (tax-inclusive stores: including tax)." onInput={(e) => set("discountValue", e.currentTarget.value)} />
|
||
</s-grid>
|
||
) : null}
|
||
{f.type === "mix" ? (
|
||
<s-number-field label="Number of items" value={String(f.mixQty)} min={1} step={1} error={err("mixQty")} onInput={(e) => set("mixQty", Number(e.currentTarget.value))} />
|
||
) : null}
|
||
{f.type === "bxgy" ? (
|
||
<s-grid gridTemplateColumns="1fr 1fr 1fr" gap="base">
|
||
<s-number-field label="Buy quantity" value={String(f.buyQty)} min={1} step={1} error={err("buyQty")} onInput={(e) => set("buyQty", Number(e.currentTarget.value))} />
|
||
<s-number-field label="Get quantity" value={String(f.getQty)} min={1} step={1} error={err("getQty")} onInput={(e) => set("getQty", Number(e.currentTarget.value))} />
|
||
<s-number-field label="Discount on get items" value={f.getPercent} min={0} max={100} step={1} suffix="%" details="100 = free" error={err("getPercent")} onInput={(e) => set("getPercent", e.currentTarget.value)} />
|
||
</s-grid>
|
||
) : null}
|
||
<s-number-field label="Max uses per order" value={String(f.maxPerOrder)} min={0} step={1} details="0 = no limit. The offer applies as many times as the cart allows." error={err("maxPerOrder")} onInput={(e) => set("maxPerOrder", Number(e.currentTarget.value))} />
|
||
</s-stack>
|
||
</s-section>
|
||
|
||
<s-section heading="Products">
|
||
{f.type === "fixed" ? (
|
||
<s-stack gap="small-200">
|
||
<s-text color="subdued">The customer gets the discount when all of these are in the cart, in these quantities. Prices are as shown in your store.</s-text>
|
||
{err("lines") ? <s-text tone="critical">{err("lines")}</s-text> : null}
|
||
{f.lines.map((l, i) => (
|
||
<s-grid key={`${l.productId}${l.variantId}`} gridTemplateColumns="auto 1fr 120px auto" gap="base" alignItems="center">
|
||
<s-thumbnail src={l.imageUrl || undefined} alt="" size="small-200" />
|
||
<s-stack gap="small-500">
|
||
<s-text>{l.title || l.productId}</s-text>
|
||
<s-text color="subdued">{l.variantId ? "This variant" : "Any variant"}</s-text>
|
||
</s-stack>
|
||
<s-number-field label="Quantity" labelAccessibilityVisibility="exclusive" value={String(l.quantity)} min={1} max={99} step={1}
|
||
onInput={(e) => { const q = Number(e.currentTarget.value); setF((s) => ({ ...s, lines: s.lines.map((x, k) => (k === i ? { ...x, quantity: q } : x)) })); }} />
|
||
<s-button variant="tertiary" icon="delete" accessibilityLabel={`Remove ${l.title}`} onClick={() => setF((s) => ({ ...s, lines: s.lines.filter((_, k) => k !== i) }))} />
|
||
</s-grid>
|
||
))}
|
||
<s-button onClick={pickLines}>Add products</s-button>
|
||
</s-stack>
|
||
) : f.type === "mix" ? (
|
||
<ScopeList role="mix" title="Eligible items" error={err("scopes_mix")}
|
||
help="Any items from these products or collections count. The cheapest matching items in the cart are used. Collections don't include sub-collections: add every collection that should count." />
|
||
) : (
|
||
<s-stack gap="large">
|
||
<s-text color="subdued">The most expensive matching items count as the "buy" part; the cheapest remaining "get" items get the discount.</s-text>
|
||
<ScopeList role="buy" title="Customer buys" error={err("scopes_buy")} help="Products or collections the customer must buy." />
|
||
<ScopeList role="get" title="Customer gets" error={err("scopes_get")} help="Products or collections that get the discount (can be the same as above)." />
|
||
</s-stack>
|
||
)}
|
||
</s-section>
|
||
|
||
<s-section heading="Example cart">
|
||
<s-stack gap="small-200">
|
||
<s-text color="subdued">A live check of the discount on sample items (the same engine as checkout). Edit a price to try other carts.</s-text>
|
||
{preview.rows.length ? (
|
||
<s-table>
|
||
<s-table-header-row>
|
||
<s-table-header listSlot="primary">Item</s-table-header>
|
||
<s-table-header format="numeric">Qty</s-table-header>
|
||
<s-table-header>Unit price</s-table-header>
|
||
<s-table-header format="numeric">Discount</s-table-header>
|
||
</s-table-header-row>
|
||
<s-table-body>
|
||
{preview.rows.map((r) => (
|
||
<s-table-row key={r.id}>
|
||
<s-table-cell>{r.label}</s-table-cell>
|
||
<s-table-cell>{r.qty}</s-table-cell>
|
||
<s-table-cell>
|
||
<s-number-field label="Unit price" labelAccessibilityVisibility="exclusive" value={String(r.price)} min={0} step={0.01}
|
||
onInput={(e) => { const v = e.currentTarget.value; setPrices((s) => ({ ...s, [r.productId]: v, [r.id]: v })); }} />
|
||
</s-table-cell>
|
||
<s-table-cell>{money(r.discount)}</s-table-cell>
|
||
</s-table-row>
|
||
))}
|
||
</s-table-body>
|
||
</s-table>
|
||
) : <s-text color="subdued">Add products to see an example.</s-text>}
|
||
<s-text type="strong">Total discount: {money(preview.total)}</s-text>
|
||
</s-stack>
|
||
</s-section>
|
||
|
||
<s-section heading="When & where">
|
||
<s-stack gap="base">
|
||
<s-grid gridTemplateColumns="1fr 1fr" gap="base">
|
||
<label>
|
||
<s-text>Starts ({data.shop.timezone})</s-text>
|
||
<input type="datetime-local" value={f.startsAt} disabled={!data.plan.scheduling} onChange={(e) => set("startsAt", e.target.value)} style={{ width: "100%", fontSize: 16, padding: 6 }} />
|
||
{err("startsAt") ? <s-text tone="critical">{err("startsAt")}</s-text> : null}
|
||
</label>
|
||
<label>
|
||
<s-text>Ends ({data.shop.timezone})</s-text>
|
||
<input type="datetime-local" value={f.endsAt} disabled={!data.plan.scheduling} onChange={(e) => set("endsAt", e.target.value)} style={{ width: "100%", fontSize: 16, padding: 6 }} />
|
||
{err("endsAt") ? <s-text tone="critical">{err("endsAt")}</s-text> : null}
|
||
</label>
|
||
</s-grid>
|
||
{!data.plan.scheduling ? <s-text color="subdued">Scheduling is available on paid plans.</s-text> : null}
|
||
<s-text-field label="Badge" value={f.badge || ""} maxLength={30} placeholder="Diwali Special" onInput={(e) => set("badge", e.currentTarget.value)} />
|
||
<s-text-field label="Countries (optional)" value={f.countries.join(", ")} placeholder="CA, US" disabled={!data.plan.markets}
|
||
details={data.plan.markets ? "ISO country codes. Leave empty for all markets." : "Market targeting is on the Growth plan."} error={err("countries")}
|
||
onInput={(e) => set("countries", e.currentTarget.value.split(/[\s,]+/).filter(Boolean))} />
|
||
<s-number-field label="Priority (sequence)" value={String(f.sequence)} step={1} details="Lower runs first. An item counts towards one offer only; earlier offers win." onInput={(e) => set("sequence", Number(e.currentTarget.value))} />
|
||
<s-checkbox label="Show on the homepage strip" checked={f.showHome} disabled={!data.plan.homepage} onChange={(e) => set("showHome", e.currentTarget.checked)} />
|
||
<s-checkbox label="Show on product pages" checked={f.showProduct} onChange={(e) => set("showProduct", e.currentTarget.checked)} />
|
||
<s-checkbox label="Show as a cart hint" checked={f.showCartHints} disabled={!data.plan.cartHints} onChange={(e) => set("showCartHints", e.currentTarget.checked)} />
|
||
</s-stack>
|
||
</s-section>
|
||
|
||
<s-section heading="Image & description">
|
||
<s-stack gap="base">
|
||
{f.imageUrl ? <s-image src={f.imageUrl} alt="Offer image" aspectRatio="16/9" objectFit="cover" inlineSize="auto" /> : <s-text color="subdued">No image: the card shows up to 4 product pictures.</s-text>}
|
||
<upload.Form method="post" action="/app/upload" encType="multipart/form-data">
|
||
<s-stack direction="inline" gap="small-200" alignItems="center">
|
||
<input type="file" name="file" accept="image/*" aria-label="Offer image" onChange={(e) => e.currentTarget.form?.requestSubmit()} />
|
||
{upload.state !== "idle" ? <s-spinner accessibilityLabel="Uploading" size="base" /> : null}
|
||
{f.imageUrl ? <s-button variant="tertiary" onClick={() => set("imageUrl", "")}>Remove image</s-button> : null}
|
||
</s-stack>
|
||
</upload.Form>
|
||
<s-text-area label="Description" value={f.description || ""} rows={4} onInput={(e) => set("description", e.currentTarget.value)} />
|
||
</s-stack>
|
||
</s-section>
|
||
|
||
<s-section heading="Translations">
|
||
{data.plan.translations ? (
|
||
<s-stack gap="base">
|
||
<s-select label="Language" value={locale} onChange={(e) => setLocale(e.currentTarget.value)}>
|
||
{["fr", "es", "de", "hi", "pa", "ta", "zh-CN", "ar"].map((l) => <s-option key={l} value={l}>{l}</s-option>)}
|
||
</s-select>
|
||
{(["name", "badge", "description"] as const).map((k) => (
|
||
<s-text-field key={k} label={k === "name" ? "Name" : k === "badge" ? "Badge" : "Description"} value={f.i18n[locale]?.[k] || ""}
|
||
onInput={(e) => { const v = e.currentTarget.value; setF((s) => ({ ...s, i18n: { ...s.i18n, [locale]: { ...s.i18n[locale], [k]: v } } })); }} />
|
||
))}
|
||
</s-stack>
|
||
) : <s-text color="subdued">Translations are on the Growth plan.</s-text>}
|
||
</s-section>
|
||
|
||
{!data.isNew ? (
|
||
<s-section heading="History">
|
||
{data.history.length ? (
|
||
<s-unordered-list>
|
||
{data.history.map((h, i) => (
|
||
<s-list-item key={i}>{h.at} · {h.staffUser || "Staff"} · {h.field}{h.oldValue !== null ? `: ${h.oldValue || "—"} → ${h.newValue || "—"}` : ""}</s-list-item>
|
||
))}
|
||
</s-unordered-list>
|
||
) : <s-text color="subdued">No changes yet.</s-text>}
|
||
</s-section>
|
||
) : null}
|
||
</s-page>
|
||
);
|
||
}
|
||
|
||
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);
|