162 lines
8.8 KiB
TypeScript
162 lines
8.8 KiB
TypeScript
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 { ensureShop, gql } 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<typeof gql>[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/<app>/blocks/<handle>/<uuid>"
|
||
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 ensureShop(admin, session.shop, true);
|
||
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<typeof loader>();
|
||
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: <s-button href={d.embedLink} target="_blank">Open theme editor</s-button> },
|
||
{ 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: <s-button href={`https://${d.shop.domain}/admin/menus`} target="_blank">Open navigation</s-button> },
|
||
{ done: d.totalOffers > 0, label: "Create your first offer", help: "Start from scratch or from a festival template.", action: <s-button href="/app/offers/new">Create offer</s-button> },
|
||
{ 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: <s-button href={`https://${d.shop.domain}/apps/bundles`} target="_blank">View bundles page</s-button> },
|
||
];
|
||
const doneCount = steps.filter((s) => s.done).length;
|
||
|
||
return (
|
||
<s-page heading="Offerlyst">
|
||
<s-button slot="primary-action" variant="primary" href="/app/offers/new">Create offer</s-button>
|
||
|
||
{d.shop.publishError ? (
|
||
<s-banner tone="critical" heading="Your offers could not be published"><s-paragraph>{d.shop.publishError}</s-paragraph></s-banner>
|
||
) : null}
|
||
{d.plan.monthlyOrders !== Infinity && d.shop.monthOrders >= d.plan.monthlyOrders ? (
|
||
<s-banner tone="warning" heading={`You've reached ${d.plan.monthlyOrders} bundle orders this month`}>
|
||
<s-paragraph>Bundle discounts are paused until next month. Upgrade to keep them running.</s-paragraph>
|
||
<s-button slot="secondary-actions" href="/app/plans">See plans</s-button>
|
||
</s-banner>
|
||
) : null}
|
||
|
||
{doneCount < steps.length ? (
|
||
<s-section heading={`Setup: ${doneCount} of ${steps.length} done`}>
|
||
<s-stack gap="base">
|
||
{steps.map((s) => (
|
||
/* icon | text | action in fixed columns, so long help text wraps inside its column */
|
||
<s-grid key={s.label} gridTemplateColumns="auto 1fr auto" gap="base" alignItems="center">
|
||
<s-icon type={s.done ? "check-circle" : "circle"} tone={s.done ? "success" : "neutral"} />
|
||
<s-stack gap="small-500">
|
||
<s-text type="strong">{s.label}</s-text>
|
||
<s-text color="subdued">{s.help}</s-text>
|
||
</s-stack>
|
||
{s.done ? <span /> : s.action}
|
||
</s-grid>
|
||
))}
|
||
</s-stack>
|
||
</s-section>
|
||
) : null}
|
||
|
||
<s-section heading="Last 30 days">
|
||
<s-grid gridTemplateColumns="1fr 1fr 1fr" gap="base">
|
||
<s-box padding="base" background="subdued" borderRadius="base"><s-stack gap="small-500"><s-text color="subdued">Bundle orders</s-text><s-heading>{String(d.stats.orders)}</s-heading></s-stack></s-box>
|
||
<s-box padding="base" background="subdued" borderRadius="base"><s-stack gap="small-500"><s-text color="subdued">Bundle revenue</s-text><s-heading>{d.stats.revenue}</s-heading></s-stack></s-box>
|
||
<s-box padding="base" background="subdued" borderRadius="base"><s-stack gap="small-500"><s-text color="subdued">Discounts given</s-text><s-heading>{d.stats.discount}</s-heading></s-stack></s-box>
|
||
</s-grid>
|
||
</s-section>
|
||
|
||
<s-section heading="Running now">
|
||
{d.running.length ? (
|
||
<s-stack gap="small-200">
|
||
{d.running.map((o) => (
|
||
<s-stack key={o.id} direction="inline" justifyContent="space-between">
|
||
<s-link href={`/app/offers/${o.id}`}>{o.name}</s-link>
|
||
<s-text color="subdued">{o.summary}{o.ends ? ` · ends ${o.ends}` : ""}</s-text>
|
||
</s-stack>
|
||
))}
|
||
</s-stack>
|
||
) : <s-paragraph>No offer is running. Published offers within their dates run automatically.</s-paragraph>}
|
||
</s-section>
|
||
|
||
<s-section slot="aside" heading="Plan">
|
||
<s-stack gap="small-200">
|
||
<s-text type="strong">{d.plan.name}</s-text>
|
||
<s-text>Running offers: {d.running.length} of {d.plan.runningOffers === Infinity ? "unlimited" : d.plan.runningOffers}</s-text>
|
||
{d.plan.monthlyOrders !== Infinity ? <s-text>Bundle orders this month: {d.shop.monthOrders} of {d.plan.monthlyOrders}</s-text> : null}
|
||
<s-text color="subdued">Discount configuration: {Math.round((d.shop.configBytes / d.configMax) * 100)}% of the limit</s-text>
|
||
<s-button href="/app/plans">Plans</s-button>
|
||
</s-stack>
|
||
</s-section>
|
||
|
||
<s-section slot="aside" heading="Coming up">
|
||
<s-stack gap="small-200">
|
||
{d.festivals.map((f) => <s-text key={f.key}>{f.name}: {f.date}{f.approximate ? " (approx.)" : ""}</s-text>)}
|
||
<s-button href="/app/templates">Festival templates</s-button>
|
||
</s-stack>
|
||
</s-section>
|
||
</s-page>
|
||
);
|
||
}
|
||
|
||
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);
|