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 { gql, syncShop } 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[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//blocks//" 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 syncShop(admin, session.shop, { fresh: true }); // plan straight from Shopify App Pricing 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(); const steps = [ { done: d.setup.embedOn, label: "Turn on the Offerlyst app embed", help: "Loads the storefront script and styles (cart hints, add bundle to cart).", action: Open theme editor }, { 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: Open navigation }, { done: d.totalOffers > 0, label: "Create your first offer", help: "Start from scratch or from a festival template.", action: Create offer }, { 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: View bundles page }, ]; const doneCount = steps.filter((s) => s.done).length; return ( Create offer {d.shop.publishError ? ( {d.shop.publishError} ) : null} {d.plan.monthlyOrders !== Infinity && d.shop.monthOrders >= d.plan.monthlyOrders ? ( Bundle discounts are paused until next month. Upgrade to keep them running. See plans ) : null} {doneCount < steps.length ? ( {steps.map((s) => ( /* icon | text | action in fixed columns, so long help text wraps inside its column */ {s.label} {s.help} {s.done ? : s.action} ))} ) : null} Bundle orders{String(d.stats.orders)} Bundle revenue{d.stats.revenue} Discounts given{d.stats.discount} {d.running.length ? ( {d.running.map((o) => ( {o.name} {o.summary}{o.ends ? ` · ends ${o.ends}` : ""} ))} ) : No offer is running. Published offers within their dates run automatically.} {d.plan.name} Running offers: {d.running.length} of {d.plan.runningOffers === Infinity ? "unlimited" : d.plan.runningOffers} {d.plan.monthlyOrders !== Infinity ? Bundle orders this month: {d.shop.monthOrders} of {d.plan.monthlyOrders} : null} Discount configuration: {Math.round((d.shop.configBytes / d.configMax) * 100)}% of the limit Plans {d.festivals.map((f) => {f.name}: {f.date}{f.approximate ? " (approx.)" : ""})} Festival templates ); } export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);