Add statistics placeholder pages with sidebar navigation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b0dd62bc9b
commit
70bd7642d4
4 files changed
+179
-3
No files matched your search
@@ -18,6 +18,7 @@ import {
|
||||
} from "antd";
|
||||
import Announcements from "./components/common/Announcements";
|
||||
import SiteAdminView from "./views/SiteAdminView";
|
||||
import StatisticsView from "./views/StatisticsView";
|
||||
import TopNav from "./components/common/TopNav";
|
||||
import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal";
|
||||
import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing";
|
||||
@@ -108,7 +109,7 @@ const warmComponentTokens = {
|
||||
},
|
||||
};
|
||||
|
||||
function RouteOutlet({ isLocked }: { isLocked: boolean }) {
|
||||
function RouteOutlet({ isLocked, isAdmin }: { isLocked: boolean; isAdmin: boolean }) {
|
||||
if (isLocked) {
|
||||
return null;
|
||||
}
|
||||
@@ -140,6 +141,10 @@ function RouteOutlet({ isLocked }: { isLocked: boolean }) {
|
||||
return <SiteAdminView section={route.section} />;
|
||||
}
|
||||
|
||||
if (route.type === "stats") {
|
||||
return <StatisticsView section={route.section} isAdmin={isAdmin} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
@@ -216,7 +221,7 @@ function AppContent({
|
||||
/>
|
||||
|
||||
<MemoAnnouncements announcements={announcements} />
|
||||
<RouteOutlet isLocked={isLocked} key={userId} />
|
||||
<RouteOutlet isLocked={isLocked} isAdmin={isAdmin} key={userId} />
|
||||
</Space>
|
||||
</Content>
|
||||
<Footer
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
BarChartOutlined,
|
||||
CheckOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
@@ -134,6 +135,16 @@ export default function AccountSettingsPopoverContent({
|
||||
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
block
|
||||
icon={<BarChartOutlined />}
|
||||
style={menuEntryStyle}
|
||||
onClick={() => onNavigate("/stats/orderee")}
|
||||
>
|
||||
Statistics
|
||||
</Button>
|
||||
|
||||
{isAdmin && (
|
||||
<Button
|
||||
type="text"
|
||||
|
||||
@@ -3,9 +3,21 @@ export type AppRoute =
|
||||
| { type: "create" }
|
||||
| { type: "order"; orderId: string }
|
||||
| { type: "admin"; orderId: string }
|
||||
| { type: "siteAdmin"; section: "users" | "orders" };
|
||||
| { type: "siteAdmin"; section: "users" | "orders" }
|
||||
| { type: "stats"; section: StatsSection };
|
||||
|
||||
export type StatsSection = "orderee" | "manager" | "admin";
|
||||
|
||||
export function parseRoute(pathname: string): AppRoute {
|
||||
const statsMatch = pathname.match(/^\/stats\/(orderee|manager|admin)$/);
|
||||
if (statsMatch) {
|
||||
return { type: "stats", section: statsMatch[1] as StatsSection };
|
||||
}
|
||||
|
||||
if (pathname === "/stats") {
|
||||
return { type: "stats", section: "orderee" };
|
||||
}
|
||||
|
||||
if (pathname === "/admin/users") {
|
||||
return { type: "siteAdmin", section: "users" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
BarChartOutlined,
|
||||
CrownOutlined,
|
||||
ShoppingCartOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Card, Empty, List, Menu, Typography } from "antd";
|
||||
import { navigateTo, StatsSection } from "../lib/routing";
|
||||
import { apiService } from "../lib/services";
|
||||
|
||||
type SectionDef = {
|
||||
key: StatsSection;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
audience: string;
|
||||
/** Shown only to administrators. */
|
||||
adminOnly?: boolean;
|
||||
/** Shown only to users who own at least one order. */
|
||||
ownersOnly?: boolean;
|
||||
/** Planned statistics, shown on the placeholder page until they are implemented. */
|
||||
planned: string[];
|
||||
};
|
||||
|
||||
const SECTIONS: SectionDef[] = [
|
||||
{
|
||||
key: "orderee",
|
||||
label: "My Orders",
|
||||
icon: <ShoppingCartOutlined />,
|
||||
audience: "For everyone who submits to group orders.",
|
||||
planned: [
|
||||
"Participation over time: orders joined per month, current streak",
|
||||
"Favourite items and categories, plus items you always repeat",
|
||||
"Total and average spend per order (where prices are configured)",
|
||||
"Your usual order day and how early you submit before the deadline",
|
||||
"Most frequent order groups and organizers",
|
||||
"Open payments: what you still owe, and payment history",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "manager",
|
||||
label: "Order Management",
|
||||
icon: <UserOutlined />,
|
||||
audience: "For users who create and run group orders.",
|
||||
ownersOnly: true,
|
||||
planned: [
|
||||
"Orders created over time, and how many reached the ordering stage",
|
||||
"Participants per order: average, trend, repeat vs. new participants",
|
||||
"Submission timeline: when people respond relative to the deadline",
|
||||
"Most popular items and categories across your orders",
|
||||
"Order volume: total items and value per order",
|
||||
"Payment status: paid vs. open, average time to payment",
|
||||
"Participants who are invited but rarely submit",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: "Site Overview",
|
||||
icon: <CrownOutlined />,
|
||||
adminOnly: true,
|
||||
audience: "For site administrators.",
|
||||
planned: [
|
||||
"Active users (daily/weekly/monthly) and new registrations",
|
||||
"Unconfirmed accounts and pending email-change/migration requests",
|
||||
"Orders per week, and the share with at least one submission",
|
||||
"Top order managers and most active participants",
|
||||
"Most ordered items across the site",
|
||||
"Storage usage: uploaded images, payment files, orphaned data",
|
||||
"Email volume and failures",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Placeholder statistics pages; each section lists the statistics planned for it. */
|
||||
export default function StatisticsView({
|
||||
section,
|
||||
isAdmin,
|
||||
}: {
|
||||
section: StatsSection;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const [ownsOrders, setOwnsOrders] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
apiService.orders
|
||||
.getMine({ role: "owner", limit: 1 })
|
||||
.then((res) => mounted && setOwnsOrders(res.pagination.total > 0))
|
||||
.catch(() => {}); // Treat a failed lookup as "no own orders".
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visible = SECTIONS.filter(
|
||||
(s) => (!s.adminOnly || isAdmin) && (!s.ownersOnly || ownsOrders),
|
||||
);
|
||||
// Fall back to the first visible page when the requested one is hidden for this user.
|
||||
const current = visible.find((s) => s.key === section) ?? visible[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 16,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{visible.length > 1 && (
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[`/stats/${current.key}`]}
|
||||
items={visible.map(({ key, label, icon }) => ({
|
||||
key: `/stats/${key}`,
|
||||
label,
|
||||
icon,
|
||||
}))}
|
||||
onClick={({ key }) => navigateTo(key)}
|
||||
style={{ flex: "0 0 220px", borderRadius: 12 }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: "1 1 400px", minWidth: 0 }}>
|
||||
<Card
|
||||
title={
|
||||
<>
|
||||
<BarChartOutlined /> {current.label}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{current.audience}
|
||||
</Typography.Paragraph>
|
||||
<Empty
|
||||
description="Statistics are coming soon."
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
<Typography.Text strong>Planned</Typography.Text>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={current.planned}
|
||||
renderItem={(item) => <List.Item>{item}</List.Item>}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user