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 & { startsAt: string; // shop-local datetime-local value endsAt: string; discountValue: string; getPercent: string; i18n: Record; }; 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(); const actionData = useActionData(); const shopify = useAppBridge(); const submit = useSubmit(); const nav = useNavigation(); const upload = useFetcher<{ url?: string; error?: string }>(); const [f, setF] = useState(data.form); const [showErrors, setShowErrors] = useState(false); const [locale, setLocale] = useState("fr"); const [prices, setPrices] = useState>({}); 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: 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 }) => ( {title} {help} {error ? {error} : null} {f.scopes.map((s, i) => (s.role !== role ? null : ( {s.title || s.refId} {s.kind === "collection" ? "Collection" : s.kind === "variant" ? "Variant" : "Product"} removeScope(i)} /> )))} pickScope(role, "product")}>Add products pickScope(role, "collection")}>Add collections ); // ---- example cart preview: the real engine on sample quantities ---- const preview = useMemo(() => { const items = new Map(); 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 ( Offers save()} {...(saving ? { loading: true } : {})}>Save {!data.isNew ? ( <> save(!f.published)}>{f.published ? "Unpublish" : "Publish"} {previewUrl ? Preview : null} simple("duplicate")}>Duplicate simple(data.meta.active ? "archive" : "restore")}>{data.meta.active ? "Archive" : "Restore"} ) : null} {actionData && "publishError" in actionData && actionData.publishError ? ( {actionData.publishError} ) : null} {data.templateHint ? {data.templateHint} : null} {/* status bar */} {statusKey in STATUS_LABEL ? STATUS_LABEL[statusKey] : "Archived"} set("published", e.currentTarget.checked)} /> {!data.isNew ? {data.meta.orders} orders : null} set("name", e.currentTarget.value)} /> Deal: {summary} set("type", e.currentTarget.values[0] || "fixed")}> Fixed bundle — a set list of products and quantities Mix & match — any N items from chosen products or collections{data.plan.types.includes("mix") ? "" : " (paid plans)"} Buy X get Y{data.plan.types.includes("bxgy") ? "" : " (paid plans)"} {f.type !== "bxgy" ? ( set("discountType", e.currentTarget.value)}> % off {data.shop.currency} off Bundle price set("discountValue", e.currentTarget.value)} /> ) : null} {f.type === "mix" ? ( set("mixQty", Number(e.currentTarget.value))} /> ) : null} {f.type === "bxgy" ? ( set("buyQty", Number(e.currentTarget.value))} /> set("getQty", Number(e.currentTarget.value))} /> set("getPercent", e.currentTarget.value)} /> ) : null} set("maxPerOrder", Number(e.currentTarget.value))} /> {f.type === "fixed" ? ( The customer gets the discount when all of these are in the cart, in these quantities. Prices are as shown in your store. {err("lines") ? {err("lines")} : null} {f.lines.map((l, i) => ( {l.title || l.productId} {l.variantId ? "This variant" : "Any variant"} { const q = Number(e.currentTarget.value); setF((s) => ({ ...s, lines: s.lines.map((x, k) => (k === i ? { ...x, quantity: q } : x)) })); }} /> setF((s) => ({ ...s, lines: s.lines.filter((_, k) => k !== i) }))} /> ))} Add products ) : f.type === "mix" ? ( ) : ( The most expensive matching items count as the "buy" part; the cheapest remaining "get" items get the discount. )} A live check of the discount on sample items (the same engine as checkout). Edit a price to try other carts. {preview.rows.length ? ( Item Qty Unit price Discount {preview.rows.map((r) => ( {r.label} {r.qty} { const v = e.currentTarget.value; setPrices((s) => ({ ...s, [r.productId]: v, [r.id]: v })); }} /> {money(r.discount)} ))} ) : Add products to see an example.} Total discount: {money(preview.total)} {!data.plan.scheduling ? Scheduling is available on paid plans. : null} set("badge", e.currentTarget.value)} /> set("countries", e.currentTarget.value.split(/[\s,]+/).filter(Boolean))} /> set("sequence", Number(e.currentTarget.value))} /> set("showHome", e.currentTarget.checked)} /> set("showProduct", e.currentTarget.checked)} /> set("showCartHints", e.currentTarget.checked)} /> {f.imageUrl ? : No image: the card shows up to 4 product pictures.} e.currentTarget.form?.requestSubmit()} /> {upload.state !== "idle" ? : null} {f.imageUrl ? set("imageUrl", "")}>Remove image : null} set("description", e.currentTarget.value)} /> {data.plan.translations ? ( setLocale(e.currentTarget.value)}> {["fr", "es", "de", "hi", "pa", "ta", "zh-CN", "ar"].map((l) => {l})} {(["name", "badge", "description"] as const).map((k) => ( { const v = e.currentTarget.value; setF((s) => ({ ...s, i18n: { ...s.i18n, [locale]: { ...s.i18n[locale], [k]: v } } })); }} /> ))} ) : Translations are on the Growth plan.} {!data.isNew ? ( {data.history.length ? ( {data.history.map((h, i) => ( {h.at} · {h.staffUser || "Staff"} · {h.field}{h.oldValue !== null ? `: ${h.oldValue || "—"} → ${h.newValue || "—"}` : ""} ))} ) : No changes yet.} ) : null} ); } export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);