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(); const [selected, setSelected] = useState([]); 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 ( Create offer Festival templates {publishError ? ( {publishError} ) : null}
setParam("filter", e.currentTarget.value)}> {FILTERS.map((f) => {f.label})} setParam("type", e.currentTarget.value)}> All types Fixed bundle Mix & match Buy X get Y
{selected.length ? ( {selected.length} selected bulk("publish")} disabled={busy}>Publish bulk("unpublish")} disabled={busy}>Unpublish bulk("duplicate")} disabled={busy}>Duplicate {filter === "archived" ? ( <> bulk("unarchive")} disabled={busy}>Restore bulk("delete")} disabled={busy}>Delete ) : ( bulk("archive")} disabled={busy}>Archive )} ) : null} {offers.length === 0 ? ( Create your first bundle offer 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. Create offer Start from a festival template ) : ( toggleAll(e.currentTarget.checked)} /> Priority Offer Type Dates Orders Status Published {offers.map((o, i) => ( { const on = e.currentTarget.checked; setSelected((s) => (on ? [...s, o.id] : s.filter((x) => x !== o.id))); }} />
{o.name} {o.summary} {o.badge ? {o.badge} : null} {TYPE_LABEL[o.type]} {o.starts ? `From ${o.starts}` : "No start date"} {o.ends ? `Ends ${o.ends}` : "No end date"} {o.countries ? {o.countries} : null} {o.orders} {o.active ? STATUS_LABEL[o.status] : "Archived"} {o.limitedByPlan ? Over plan limit : null}
{o.published ? "Unpublish" : "Publish"}
))}
)}
); } export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);