From 4322f37b67e60aa0788225045fa8ebbb7c7c56ae Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 6 Nov 2026 14:01:26 +0100 Subject: [PATCH] Live picks: expandable banner on desktop, bolt button with count and popup on phones Co-Authored-By: Claude Sonnet 5.5 --- src/e2e/tests/orders.ui.spec.ts | 46 ++++---- .../src/components/views/LivePicks.tsx | 105 ++++++++---------- src/frontend/src/styles.css | 74 ++++++------ src/frontend/src/views/ParticipantView.tsx | 18 +-- 4 files changed, 109 insertions(+), 134 deletions(-) diff --git a/src/e2e/tests/orders.ui.spec.ts b/src/e2e/tests/orders.ui.spec.ts index 502aa1f..c222e07 100644 --- a/src/e2e/tests/orders.ui.spec.ts +++ b/src/e2e/tests/orders.ui.spec.ts @@ -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(); }); diff --git a/src/frontend/src/components/views/LivePicks.tsx b/src/frontend/src/components/views/LivePicks.tsx index 2554f43..0e4bdd4 100644 --- a/src/frontend/src/components/views/LivePicks.tsx +++ b/src/frontend/src/components/views/LivePicks.tsx @@ -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 ( - - ); - } - return ( - - - {closed ? "Final picks" : "Live picks"} - - } - extra={ - - } - > - - - ); +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 ? : ); - if (hidden) { - return ( - - ); - } +/** 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 ( -
+
+ {open && ( - + )} -
-
+ ); +} + +/** 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 ( +
+ {open && ( + } onClick={() => setOpen(false)} />} + > + + + )} + {!open && ( + - -
+ )}
); } diff --git a/src/frontend/src/styles.css b/src/frontend/src/styles.css index 089131a..0ae5edb 100644 --- a/src/frontend/src/styles.css +++ b/src/frontend/src/styles.css @@ -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; } } diff --git a/src/frontend/src/views/ParticipantView.tsx b/src/frontend/src/views/ParticipantView.tsx index 64b2c95..3d67ac8 100644 --- a/src/frontend/src/views/ParticipantView.tsx +++ b/src/frontend/src/views/ParticipantView.tsx @@ -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} > -
+
{orderAccess?.is_owner && ( -
+ {showBanner && } @@ -625,13 +625,7 @@ export default function ParticipantView({ orderId }: { orderId: string }) { )} - {showPanel && ( - - )} -
- {showBanner && } + {showFab && }