bundle-offers-app/app/routes/app.analytics.tsx
metatroncubeswdev 365e45406a Bundle Offers Shopify app: discount function, admin, storefront, deploy stack
Rust discount function (one automatic discount evaluating all offers in priority
order, parity with the Odoo engine), React Router admin with Polaris, theme app
extension and app proxy pages, webhooks incl. GDPR, managed-pricing limits, and
a Docker Compose stack (Postgres + Caddy HTTPS) for a single VPS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:35:58 -04:00

153 lines
7.0 KiB
TypeScript

import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
import { Form, 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 } from "../lib/shop.server";
import { planOf } from "../lib/plans";
import { moneyFormatter } from "../lib/offer-text";
import { formatShopDate } from "../lib/tz";
const RANGES: Record<string, number> = { "7": 7, "30": 30, "90": 90, "365": 365 };
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 days = RANGES[url.searchParams.get("days") || "30"] || 30;
const offerFilter = url.searchParams.get("offer") || "";
const since = new Date(Date.now() - days * 86400000);
const money = moneyFormatter(shop.currency);
const usages = await db.orderUsage.findMany({ where: { shopId: shop.id, createdAt: { gte: since } }, orderBy: { createdAt: "desc" } });
const stats = await db.orderStat.findMany({ where: { shopId: shop.id, createdAt: { gte: since } } });
const byOffer = new Map<string, { name: string; orders: Set<string>; revenue: number; discount: number }>();
for (const u of usages) {
const e = byOffer.get(u.offerName) || { name: u.offerName, orders: new Set<string>(), revenue: 0, discount: 0 };
e.orders.add(u.orderId);
e.revenue += Number(u.bundleRevenue);
e.discount += Number(u.discount);
byOffer.set(u.offerName, e);
}
const avg = (xs: number[]) => (xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : 0);
const withB = stats.filter((s) => s.hasBundle).map((s) => Number(s.total));
const without = stats.filter((s) => !s.hasBundle).map((s) => Number(s.total));
const offers = [...byOffer.values()].sort((a, b) => b.revenue - a.revenue);
const recent = usages.filter((u) => !offerFilter || u.offerName === offerFilter).slice(0, 50);
return {
allowed: planOf(shop.planCode).analytics,
days: String(days),
offerFilter,
totals: {
orders: new Set(usages.map((u) => u.orderId)).size,
revenue: money(offers.reduce((s, o) => s + o.revenue, 0)),
discount: money(offers.reduce((s, o) => s + o.discount, 0)),
aovWith: money(avg(withB)),
aovWithout: money(avg(without)),
lift: avg(without) ? `${Math.round(((avg(withB) - avg(without)) / avg(without)) * 100)}%` : "—",
},
offers: offers.map((o) => ({ name: o.name, orders: o.orders.size, revenue: money(o.revenue), discount: money(o.discount) })),
recent: recent.map((u) => ({
id: u.id, orderId: u.orderId, orderName: u.orderName || u.orderId, offer: u.offerName,
discount: money(Number(u.discount)), at: formatShopDate(u.createdAt, shop.timezone),
})),
shopDomain: shop.domain,
};
};
export default function Analytics() {
const d = useLoaderData<typeof loader>();
return (
<s-page heading="Analytics" inlineSize="large">
{!d.allowed ? (
<s-banner tone="info" heading="Full analytics are on the Growth plan">
<s-paragraph>You can still see orders per offer below.</s-paragraph>
<s-button slot="secondary-actions" href="/app/plans">See plans</s-button>
</s-banner>
) : null}
<s-section padding="base">
<Form method="get">
<s-stack direction="inline" gap="base" alignItems="end">
<s-select label="Date range" name="days" value={d.days}>
<s-option value="7">Last 7 days</s-option>
<s-option value="30">Last 30 days</s-option>
<s-option value="90">Last 90 days</s-option>
<s-option value="365">Last 12 months</s-option>
</s-select>
{d.offerFilter ? <input type="hidden" name="offer" value={d.offerFilter} /> : null}
<s-button type="submit">Apply</s-button>
</s-stack>
</Form>
</s-section>
{d.allowed ? (
<s-section heading="Overview">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(160px, 1fr))" gap="base">
{[
["Bundle orders", String(d.totals.orders)],
["Bundle revenue", d.totals.revenue],
["Discounts given", d.totals.discount],
["AOV with a bundle", d.totals.aovWith],
["AOV without", d.totals.aovWithout],
["AOV lift", d.totals.lift],
].map(([label, value]) => (
<s-box key={label} padding="base" background="subdued" borderRadius="base">
<s-stack gap="small-500"><s-text color="subdued">{label}</s-text><s-heading>{value}</s-heading></s-stack>
</s-box>
))}
</s-grid>
</s-section>
) : null}
<s-section heading="By offer">
{d.offers.length ? (
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Offer</s-table-header>
<s-table-header format="numeric">Orders</s-table-header>
{d.allowed ? <s-table-header format="numeric">Bundle revenue</s-table-header> : null}
{d.allowed ? <s-table-header format="numeric">Discount</s-table-header> : null}
</s-table-header-row>
<s-table-body>
{d.offers.map((o) => (
<s-table-row key={o.name}>
<s-table-cell><s-link href={`/app/analytics?days=${d.days}&offer=${encodeURIComponent(o.name)}`}>{o.name}</s-link></s-table-cell>
<s-table-cell>{o.orders}</s-table-cell>
{d.allowed ? <s-table-cell>{o.revenue}</s-table-cell> : null}
{d.allowed ? <s-table-cell>{o.discount}</s-table-cell> : null}
</s-table-row>
))}
</s-table-body>
</s-table>
) : <s-paragraph>No bundle orders in this period yet.</s-paragraph>}
</s-section>
<s-section heading={d.offerFilter ? `Orders with “${d.offerFilter}”` : "Recent bundle orders"}>
{d.recent.length ? (
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Order</s-table-header>
<s-table-header>Offer</s-table-header>
<s-table-header format="numeric">Discount</s-table-header>
<s-table-header>Date</s-table-header>
</s-table-header-row>
<s-table-body>
{d.recent.map((r) => (
<s-table-row key={r.id}>
<s-table-cell><s-link href={`shopify://admin/orders/${r.orderId}`} target="_top">{r.orderName}</s-link></s-table-cell>
<s-table-cell>{r.offer}</s-table-cell>
<s-table-cell>{r.discount}</s-table-cell>
<s-table-cell>{r.at}</s-table-cell>
</s-table-row>
))}
</s-table-body>
</s-table>
) : <s-paragraph>No orders.</s-paragraph>}
</s-section>
</s-page>
);
}
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);