Live picks: side panel on desktop, floating energy banner on phones

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
simandClaude Sonnet 5.5 committed 2026-11-06 13:52:12 +01:00
1 parent a348b759a1
commit d7296336e3
4 files changed
+209 -43

No files matched your search

+19 -1
View File
@@ -63,10 +63,28 @@ test("live picks: counts, podium stamp, hide/show, frozen after close", async ({
await card.getByRole("button", { name: "Hide" }).click();
await expect(card.getByText("2 people have ordered")).toBeHidden();
await card.getByRole("button", { name: "Show" }).click();
await page.getByRole("button", { name: "Show live picks" }).click();
await expect(card.getByText("2 people have ordered")).toBeVisible();
await api(`/api/orders/${order.id}/admin/status`, { userId: user.userId, method: "PUT", body: { closed: true } });
await expect(page.getByText(/This order is closed/)).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("2 people have ordered")).toBeVisible();
});
test("live picks on phones: banner opens a sheet, dismiss leaves a bolt", async ({ browser, user }) => {
const order = await createOrder(user.userId, { title: "Phone picks" });
const first = await signIn();
await submit(first.userId, order.id, { burger: "Veggie" });
const page = await browser.newPage({ viewport: { width: 390, height: 800 } });
await loginAs(page, first);
await page.goto(`/order/${order.id}`);
await expect(page.getByRole("status")).toContainText("1 ordered · Veggie leads (1)");
await page.getByRole("button", { name: /1 ordered/ }).click();
await expect(page.getByText("1 person has ordered")).toBeVisible();
await page.getByRole("button", { name: "Hide live picks" }).click();
await expect(page.getByRole("status")).toBeHidden();
await page.getByRole("button", { name: "Show live picks" }).click();
await expect(page.getByRole("status")).toBeVisible();
await page.close();
});
@@ -0,0 +1,113 @@
import React, { useState } from "react";
import { CloseOutlined, ThunderboltFilled } from "@ant-design/icons";
import { Button, Card, Flex, Progress, Typography } from "antd";
import type { OrderStats } from "../../lib/services/ordersService";
import { getStoredValue, setStoredValue } from "../../lib/storage";
const { Text } = Typography;
const HIDDEN_KEY = "lunchtime_live_picks_hidden";
function useHidden() {
const [hidden, setHidden] = useState(() => getStoredValue(HIDDEN_KEY) === "1");
return [
hidden,
(value: boolean) => {
setStoredValue(HIDDEN_KEY, value ? "1" : "0");
setHidden(value);
},
] as const;
}
function Bars({ stats }: { stats: OrderStats }) {
const n = stats.participant_count;
return (
<>
<Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
{n} {n === 1 ? "person has" : "people have"} ordered
</Text>
{stats.categories.map((category) => (
<div key={category.label} style={{ marginBottom: 10 }}>
<Text strong>{category.label}</Text>
{category.items.slice(0, 6).map((item) => (
<Flex key={item.name} gap={8} align="center">
<Text ellipsis style={{ flex: "0 0 38%" }}>
{item.name}
</Text>
<Progress
style={{ flex: 1, margin: 0 }}
percent={(item.count / n) * 100}
format={() => item.count}
size="small"
/>
</Flex>
))}
</div>
))}
</>
);
}
/** Desktop: quiet side panel that can be tucked away. */
export function LivePicksPanel({ stats }: { stats: OrderStats }) {
const [hidden, setHidden] = useHidden();
if (hidden) {
return (
<Button type="text" size="small" icon={<ThunderboltFilled />} onClick={() => setHidden(false)}>
Show live picks
</Button>
);
}
return (
<Card
size="small"
title={
<Flex align="center" gap={8}>
<span className="live-dot" aria-hidden />
Live picks
</Flex>
}
extra={
<Button type="text" size="small" onClick={() => setHidden(true)}>
Hide
</Button>
}
>
<Bars stats={stats} />
</Card>
);
}
/** Phones: floating energy banner that opens a sheet with the bars; dismiss leaves a small bolt to bring it back. */
export function LivePicksBanner({ stats }: { stats: OrderStats }) {
const [hidden, setHidden] = useHidden();
const [open, setOpen] = useState(false);
const top = stats.categories[0]?.items[0];
if (hidden) {
return (
<button className="live-fab" aria-label="Show live picks" onClick={() => setHidden(false)}>
<ThunderboltFilled />
</button>
);
}
return (
<div className="live-banner-wrap">
{open && (
<Card size="small" className="live-sheet">
<Bars stats={stats} />
</Card>
)}
<div className="live-banner" role="status">
<button className="live-banner-main" aria-expanded={open} onClick={() => setOpen(!open)}>
<ThunderboltFilled />
<span>
{stats.participant_count} ordered{top ? ` · ${top.name} leads (${top.count})` : ""}
</span>
</button>
<button className="live-banner-close" aria-label="Hide live picks" onClick={() => setHidden(true)}>
<CloseOutlined />
</button>
</div>
</div>
);
}
+61
View File
@@ -171,3 +171,64 @@
display: block;
}
}
/* Live picks */
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #52c41a;
animation: live-pulse 2s ease-out infinite;
}
@keyframes live-pulse {
0% { box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.5); }
100% { box-shadow: 0 0 0 8px rgba(82, 196, 26, 0); }
}
.live-picks-layout { display: grid; gap: 16px; }
@media (min-width: 992px) {
.live-picks-layout.has-panel { grid-template-columns: minmax(0, 1fr) 300px; align-items: start; }
.live-picks-aside { position: sticky; top: 16px; }
}
.live-banner-wrap {
position: fixed;
left: 12px;
right: 12px;
bottom: calc(12px + env(safe-area-inset-bottom));
z-index: 50;
display: flex;
flex-direction: column;
gap: 8px;
}
.live-sheet { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); }
.live-banner {
display: flex;
align-items: center;
border-radius: 999px;
color: #fff;
background: linear-gradient(110deg, #c2410c, #f59e0b, #ea580c, #c2410c);
background-size: 300% 100%;
animation: live-flow 6s linear infinite;
box-shadow: 0 6px 18px rgba(194, 65, 12, 0.45);
}
@keyframes live-flow { to { background-position: -300% 0; } }
.live-banner button, .live-fab { border: 0; background: none; color: inherit; font: inherit; cursor: pointer; }
.live-banner-main { flex: 1; min-width: 0; display: flex; gap: 8px; align-items: center; padding: 10px 6px 10px 16px; text-align: left; }
.live-banner-main span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.live-banner-main .anticon { animation: live-zap 1.6s ease-in-out infinite; }
@keyframes live-zap { 50% { transform: scale(1.25); filter: drop-shadow(0 0 4px #fff); } }
.live-banner-close { padding: 10px 14px; opacity: 0.85; }
.live-fab {
position: fixed;
right: 12px;
bottom: calc(12px + env(safe-area-inset-bottom));
z-index: 50;
width: 44px;
height: 44px;
border-radius: 50%;
color: #fff;
background: #ea580c;
box-shadow: 0 6px 18px rgba(194, 65, 12, 0.45);
}
@media (prefers-reduced-motion: reduce) {
.live-dot, .live-banner, .live-banner-main .anticon { animation: none; }
}
+16 -42
View File
@@ -7,7 +7,6 @@ import {
Flex,
Form,
Popconfirm,
Progress,
Row,
Space,
Typography,
@@ -15,9 +14,10 @@ import {
} from "antd";
import AsyncContent from "../components/utils/AsyncContent";
import { useApiRequest } from "../hooks/useApiRequest";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { LivePicksBanner, LivePicksPanel } from "../components/views/LivePicks";
import { isNotFoundError } from "../lib/api";
import { navigateTo } from "../lib/routing";
import { getStoredValue, setStoredValue } from "../lib/storage";
import { apiService } from "../lib/services";
import type { OrderStats } from "../lib/services/ordersService";
import LoadingSkeleton from "../components/utils/LoadingSkeleton";
@@ -45,7 +45,6 @@ import ParticipantDraftSummaryAlert from "../components/views/ParticipantDraftSu
const { Text, Link } = Typography;
const OVERVIEW_CARD_ID = "order-overview";
const STATS_OPEN_KEY = "lunchtime_live_picks_open";
type SubmissionValues = {
choices: NormalizedOrderSelections;
@@ -345,12 +344,6 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
}, [existingSubmission, form, orderId]);
// Live pick counts; polls only while open, keeps the last result (frozen) once closed.
const [statsOpen, setStatsOpen] = useState(() => getStoredValue(STATS_OPEN_KEY) !== "0");
const toggleStats = () =>
setStatsOpen((open) => {
setStoredValue(STATS_OPEN_KEY, open ? "0" : "1");
return !open;
});
const [stats, setStats] = useState<OrderStats | null>(null);
useEffect(() => {
if (isClosed) {
@@ -370,6 +363,11 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
};
}, [isClosed, orderId]);
const wide = useMediaQuery("(min-width: 992px)");
const hasStats = !!stats && stats.participant_count > 0;
const showPanel = hasStats && wide;
const showBanner = hasStats && !wide;
const onFinish = async (values: FormModel) => {
if (isClosed) {
message.warning("This order is closed.");
@@ -489,7 +487,7 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
loadingRows={5}
loadingSections={2}
>
<div>
<div style={showBanner ? { paddingBottom: 72 } : undefined}>
<Space direction="vertical" style={{ width: "100%" }} size={16}>
{orderAccess?.is_owner && (
<Alert
@@ -530,6 +528,7 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
onTabChange={setOverviewTab}
/>
<div className={`live-picks-layout${showPanel ? " has-panel" : ""}`}>
<Card
title="Your order"
>
@@ -628,38 +627,13 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
)}
</Space>
</Card>
{stats && stats.participant_count > 0 && (
<Card
title="Live picks"
extra={
<Button type="text" size="small" onClick={toggleStats} aria-expanded={statsOpen}>
{statsOpen ? "Hide" : "Show"}
</Button>
}
styles={statsOpen ? undefined : { body: { display: "none" } }}
>
<Text type="secondary" style={{ display: "block", marginBottom: 12 }}>
{stats.participant_count} {stats.participant_count === 1 ? "person has" : "people have"} ordered
</Text>
{stats.categories.map((category) => (
<div key={category.label} style={{ marginBottom: 12 }}>
<Text strong>{category.label}</Text>
{category.items.slice(0, 8).map((item) => (
<Flex key={item.name} gap={8} align="center">
<Text ellipsis style={{ flex: "0 0 30%" }}>{item.name}</Text>
<Progress
style={{ flex: 1, margin: 0 }}
percent={(item.count / stats.participant_count) * 100}
format={() => item.count}
size="small"
/>
</Flex>
))}
</div>
))}
</Card>
)}
{showPanel && (
<aside className="live-picks-aside">
<LivePicksPanel stats={stats} />
</aside>
)}
</div>
{showBanner && <LivePicksBanner stats={stats} />}
</Space>
</div>