Live picks: expandable banner on desktop, bolt button with count and popup 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 14:01:26 +01:00
1 parent 496acfd87a
commit 4322f37b67
4 files changed
+109 -134

No files matched your search

+21 -25
View File
@@ -45,33 +45,33 @@ test("first visit without an account shows onboarding, not a crash", async ({ pa
expect(errors).toEqual([]);
});
test("live picks: counts, podium stamp, hide/show, frozen after close", async ({ page, user }) => {
test("live picks: counts, podium stamp, expand/collapse, frozen after close", async ({ page, user }) => {
const order = await createOrder(user.userId, { title: "Live picks" });
const first = await signIn();
await submit(first.userId, order.id, { burger: "Veggie" });
await loginAs(page, first);
await page.goto(`/order/${order.id}`);
const card = page.locator(".ant-card", { hasText: /(Live|Final) picks/ }).filter({ hasText: "ordered" });
await expect(card.getByText("1 person has ordered")).toBeVisible();
await expect(card.getByText("Veggie")).toBeVisible();
const banner = page.getByRole("button", { name: /ordered/ });
await expect(banner).toContainText("1 ordered · Veggie leads (1)");
await expect(page.getByText("You were 1st to order!")).toBeVisible();
await expect(page.getByText("1 person has ordered")).toBeHidden();
// polling picks up another participant without a reload
await submit((await signIn()).userId, order.id, { burger: "Classic" });
await expect(card.getByText("2 people have ordered")).toBeVisible({ timeout: 10_000 });
await expect(banner).toContainText("2 ordered", { timeout: 10_000 });
await card.getByRole("button", { name: "Hide" }).click();
await expect(card.getByText("2 people have ordered")).toBeHidden();
await page.getByRole("button", { name: "Show live picks" }).click();
await expect(card.getByText("2 people have ordered")).toBeVisible();
await banner.click();
await expect(page.getByText("2 people have ordered")).toBeVisible();
await banner.click();
await expect(page.getByText("2 people have ordered")).toBeHidden();
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();
await expect(banner).toContainText("Final: 2 ordered");
});
test("live picks on phones: banner opens a sheet, dismiss leaves a bolt", async ({ browser, user }) => {
test("live picks on phones: bolt button with count opens a popup and minimizes again", async ({ browser, user }) => {
const order = await createOrder(user.userId, { title: "Phone picks" });
const first = await signIn();
await submit(first.userId, order.id, { burger: "Veggie" });
@@ -79,17 +79,19 @@ test("live picks on phones: banner opens a sheet, dismiss leaves a bolt", async
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();
const fab = page.getByRole("button", { name: "Show live picks, 1 ordered" });
await expect(fab).toBeVisible();
await fab.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.reload();
await expect(page.getByText("1 person has ordered")).toBeVisible(); // expanded state is remembered
await page.getByRole("button", { name: "Minimize live picks" }).click();
await expect(page.getByText("1 person has ordered")).toBeHidden();
await expect(fab).toBeVisible();
await page.close();
});
test("live picks on phones: closed order gets the final banner, expanded state survives reload", async ({ browser, user }) => {
test("live picks on phones: closed order gets the grey final button", async ({ browser, user }) => {
const order = await createOrder(user.userId, { title: "Closed picks" });
const first = await signIn();
await submit(first.userId, order.id, { burger: "Veggie" });
@@ -97,13 +99,7 @@ test("live picks on phones: closed order gets the final banner, expanded state s
const page = await browser.newPage({ viewport: { width: 390, height: 800 } });
await loginAs(page, first);
await page.goto(`/order/${order.id}`);
const banner = page.getByRole("status");
await expect(banner).toContainText("Final: 1 ordered · Veggie led (1)");
await expect(banner).toHaveClass(/closed/);
await page.getByRole("button", { name: /Final:/ }).click();
await page.reload();
await expect(page.getByText("1 person has ordered")).toBeVisible();
await expect(page.getByRole("button", { name: "Show live picks, 1 ordered" })).toHaveClass(/closed/);
await page.close();
});
+49 -56
View File
@@ -1,11 +1,10 @@
import React, { useState } from "react";
import { CloseOutlined, LockOutlined, ThunderboltFilled } from "@ant-design/icons";
import { Button, Card, Flex, Progress, Typography } from "antd";
import { CloseOutlined, DownOutlined, LockOutlined, ThunderboltFilled } from "@ant-design/icons";
import { Badge, 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";
const EXPANDED_KEY = "lunchtime_live_picks_expanded";
@@ -49,68 +48,62 @@ function Bars({ stats }: { stats: OrderStats }) {
);
}
/** Desktop: quiet side panel that can be tucked away. */
export function LivePicksPanel({ stats, closed }: { stats: OrderStats; closed: boolean }) {
const [hidden, setHidden] = useStoredFlag(HIDDEN_KEY);
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={closed ? "live-dot closed" : "live-dot"} aria-hidden />
{closed ? "Final picks" : "Live picks"}
</Flex>
}
extra={
<Button type="text" size="small" onClick={() => setHidden(true)}>
Hide
</Button>
}
>
<Bars stats={stats} />
</Card>
);
function summary(stats: OrderStats, closed: boolean) {
const top = stats.categories[0]?.items[0];
return `${closed ? "Final: " : ""}${stats.participant_count} ordered${top ? ` · ${top.name} ${closed ? "led" : "leads"} (${top.count})` : ""}`;
}
/** Phones: floating energy banner that opens a sheet with the bars; dismiss leaves a small bolt to bring it back. */
export function LivePicksBanner({ stats, closed }: { stats: OrderStats; closed: boolean }) {
const [hidden, setHidden] = useStoredFlag(HIDDEN_KEY);
const [open, setOpen] = useStoredFlag(EXPANDED_KEY);
const top = stats.categories[0]?.items[0];
const icon = (closed: boolean) => (closed ? <LockOutlined /> : <ThunderboltFilled />);
if (hidden) {
return (
<button className={closed ? "live-fab closed" : "live-fab"} aria-label="Show live picks" onClick={() => setHidden(false)}>
{closed ? <LockOutlined /> : <ThunderboltFilled />}
</button>
);
}
/** Desktop: expandable banner above the order card; the bars unfold below it. */
export function LivePicksBanner({ stats, closed }: { stats: OrderStats; closed: boolean }) {
const [open, setOpen] = useStoredFlag(EXPANDED_KEY);
return (
<div className="live-banner-wrap">
<div>
<button
className={closed ? "live-banner closed" : "live-banner"}
aria-expanded={open}
onClick={() => setOpen(!open)}
>
{icon(closed)}
<span className="live-banner-text">{summary(stats, closed)}</span>
<DownOutlined className={open ? "live-chevron open" : "live-chevron"} />
</button>
{open && (
<Card size="small" className="live-sheet">
<Card size="small" style={{ marginTop: 8 }} styles={{ body: { padding: "12px 16px" } }}>
<Bars stats={stats} />
</Card>
)}
<div className={closed ? "live-banner closed" : "live-banner"} role="status">
<button className="live-banner-main" aria-expanded={open} onClick={() => setOpen(!open)}>
{closed ? <LockOutlined /> : <ThunderboltFilled />}
<span>
{closed ? "Final: " : ""}
{stats.participant_count} ordered{top ? ` · ${top.name} ${closed ? "led" : "leads"} (${top.count})` : ""}
</span>
</div>
);
}
/** Phones: a bolt button with the order count; tap for the popup, tap again (or the X) to minimize. */
export function LivePicksFab({ stats, closed }: { stats: OrderStats; closed: boolean }) {
const [open, setOpen] = useStoredFlag(EXPANDED_KEY);
return (
<div className="live-fab-wrap">
{open && (
<Card
size="small"
className="live-sheet"
title={closed ? "Final picks" : "Live picks"}
extra={<Button type="text" size="small" aria-label="Minimize live picks" icon={<CloseOutlined />} onClick={() => setOpen(false)} />}
>
<Bars stats={stats} />
</Card>
)}
{!open && (
<button
className={closed ? "live-fab closed" : "live-fab"}
aria-label={`Show live picks, ${stats.participant_count} ordered`}
onClick={() => setOpen(true)}
>
<Badge count={stats.participant_count} size="small" offset={[4, -4]} color="#fff" styles={{ indicator: { color: "#c2410c", fontWeight: 700 } }}>
<span className="live-fab-icon">{icon(closed)}</span>
</Badge>
</button>
<button className="live-banner-close" aria-label="Hide live picks" onClick={() => setHidden(true)}>
<CloseOutlined />
</button>
</div>
)}
</div>
);
}
+33 -41
View File
@@ -173,66 +173,58 @@
}
/* 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 {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: 0;
border-radius: 999px;
color: #fff;
font: inherit;
font-weight: 600;
text-align: left;
cursor: pointer;
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);
box-shadow: 0 4px 14px rgba(194, 65, 12, 0.35);
}
.live-banner-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.live-banner > .anticon:first-child { animation: live-zap 1.6s ease-in-out infinite; }
.live-chevron { transition: transform 0.2s; }
.live-chevron.open { transform: rotate(180deg); }
@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 {
.live-banner.closed { background: #6b6560; animation: none; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
.live-banner.closed > .anticon:first-child { animation: none; }
.live-fab-wrap {
position: fixed;
right: 12px;
bottom: calc(12px + env(safe-area-inset-bottom));
left: 12px;
z-index: 50;
width: 44px;
height: 44px;
display: flex;
flex-direction: column;
align-items: flex-end;
pointer-events: none;
}
.live-fab-wrap > * { pointer-events: auto; }
.live-sheet { width: 100%; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); }
.live-fab {
width: 48px;
height: 48px;
border: 0;
border-radius: 50%;
color: #fff;
background: #ea580c;
font-size: 18px;
cursor: pointer;
background: linear-gradient(135deg, #ea580c, #f59e0b);
box-shadow: 0 6px 18px rgba(194, 65, 12, 0.45);
}
.live-dot.closed { background: #8c8c8c; animation: none; }
.live-banner.closed { background: #6b6560; animation: none; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); }
.live-banner.closed .anticon { animation: none; }
.live-fab:not(.closed) .live-fab-icon { display: inline-block; animation: live-zap 1.6s ease-in-out infinite; }
.live-fab.closed { background: #6b6560; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); }
@media (prefers-reduced-motion: reduce) {
.live-dot, .live-banner, .live-banner-main .anticon { animation: none; }
.live-banner, .live-banner > .anticon:first-child, .live-fab-icon { animation: none !important; }
}
+6 -12
View File
@@ -15,7 +15,7 @@ import {
import AsyncContent from "../components/utils/AsyncContent";
import { useApiRequest } from "../hooks/useApiRequest";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { LivePicksBanner, LivePicksPanel } from "../components/views/LivePicks";
import { LivePicksBanner, LivePicksFab } from "../components/views/LivePicks";
import { isNotFoundError } from "../lib/api";
import { navigateTo } from "../lib/routing";
import { apiService } from "../lib/services";
@@ -363,8 +363,8 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
const wide = useMediaQuery("(min-width: 992px)");
const hasStats = !!stats && stats.participant_count > 0;
const showPanel = hasStats && wide;
const showBanner = hasStats && !wide;
const showBanner = hasStats && wide;
const showFab = hasStats && !wide;
const onFinish = async (values: FormModel) => {
if (isClosed) {
@@ -485,7 +485,7 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
loadingRows={5}
loadingSections={2}
>
<div style={showBanner ? { paddingBottom: 72 } : undefined}>
<div style={showFab ? { paddingBottom: 72 } : undefined}>
<Space direction="vertical" style={{ width: "100%" }} size={16}>
{orderAccess?.is_owner && (
<Alert
@@ -526,7 +526,7 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
onTabChange={setOverviewTab}
/>
<div className={`live-picks-layout${showPanel ? " has-panel" : ""}`}>
{showBanner && <LivePicksBanner stats={stats} closed={isClosed} />}
<Card
title="Your order"
>
@@ -625,13 +625,7 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
)}
</Space>
</Card>
{showPanel && (
<aside className="live-picks-aside">
<LivePicksPanel stats={stats} closed={isClosed} />
</aside>
)}
</div>
{showBanner && <LivePicksBanner stats={stats} closed={isClosed} />}
{showFab && <LivePicksFab stats={stats} closed={isClosed} />}
</Space>
</div>