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";
|
} from "antd";
|
||||||
import Announcements from "./components/common/Announcements";
|
import Announcements from "./components/common/Announcements";
|
||||||
import SiteAdminView from "./views/SiteAdminView";
|
import SiteAdminView from "./views/SiteAdminView";
|
||||||
|
import StatisticsView from "./views/StatisticsView";
|
||||||
import TopNav from "./components/common/TopNav";
|
import TopNav from "./components/common/TopNav";
|
||||||
import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal";
|
import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal";
|
||||||
import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing";
|
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) {
|
if (isLocked) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -140,6 +141,10 @@ function RouteOutlet({ isLocked }: { isLocked: boolean }) {
|
|||||||
return <SiteAdminView section={route.section} />;
|
return <SiteAdminView section={route.section} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.type === "stats") {
|
||||||
|
return <StatisticsView section={route.section} isAdmin={isAdmin} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Result
|
<Result
|
||||||
status="404"
|
status="404"
|
||||||
@@ -216,7 +221,7 @@ function AppContent({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<MemoAnnouncements announcements={announcements} />
|
<MemoAnnouncements announcements={announcements} />
|
||||||
<RouteOutlet isLocked={isLocked} key={userId} />
|
<RouteOutlet isLocked={isLocked} isAdmin={isAdmin} key={userId} />
|
||||||
</Space>
|
</Space>
|
||||||
</Content>
|
</Content>
|
||||||
<Footer
|
<Footer
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
|
BarChartOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
@@ -134,6 +135,16 @@ export default function AccountSettingsPopoverContent({
|
|||||||
|
|
||||||
<Divider style={{ margin: "8px 0" }} />
|
<Divider style={{ margin: "8px 0" }} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
block
|
||||||
|
icon={<BarChartOutlined />}
|
||||||
|
style={menuEntryStyle}
|
||||||
|
onClick={() => onNavigate("/stats/orderee")}
|
||||||
|
>
|
||||||
|
Statistics
|
||||||
|
</Button>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -3,9 +3,21 @@ export type AppRoute =
|
|||||||
| { type: "create" }
|
| { type: "create" }
|
||||||
| { type: "order"; orderId: string }
|
| { type: "order"; orderId: string }
|
||||||
| { type: "admin"; 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 {
|
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") {
|
if (pathname === "/admin/users") {
|
||||||
return { type: "siteAdmin", section: "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