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:
1 parent
496acfd87a
commit
4322f37b67
4 files changed
+109
-134
No files matched your search
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user