Shopify App Pricing keeps a paid yearly plan active until the billing period ends and reports the new plan as pendingUpdate. The app keeps the paid features until then and now says when the change applies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
9.1 KiB
TypeScript
165 lines
9.1 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 { 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";
|
||
import { scheduledChange } from "./app.plans";
|
||
|
||
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 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,
|
||
scheduled: scheduledChange(shop),
|
||
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>
|
||
{d.scheduled ? <s-text color="subdued">Changes to {d.scheduled.name}{d.scheduled.date ? ` on ${d.scheduled.date}` : " at the end of this billing period"}</s-text> : null}
|
||
<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);
|