bundle-offers-app/app/routes/app.analytics.tsx
metatroncubeswdev 38c03cd1ae Encrypt order data at rest; add terms of service with data processing terms
Protected customer data (Level 1): order ids are stored as keyed HMACs, order numbers
and ids for admin links as AES-256-GCM ciphertext (DATA_ENCRYPTION_KEY). New public
/terms page with processor terms; privacy policy links to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:29:48 -04:00

154 lines
7.1 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";
import { decrypt } from "../lib/crypto.server";
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: decrypt(u.orderRef) || "", orderName: decrypt(u.orderName) || "Order", 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>{r.orderId ? <s-link href={`shopify://admin/orders/${r.orderId}`} target="_top">{r.orderName}</s-link> : r.orderName}</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);