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>
154 lines
7.1 KiB
TypeScript
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);
|