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:
1 parent
a348b759a1
commit
d7296336e3
4 files changed
+209
-43
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user