From 139ea259f4a6229fa03fad04e1293b66bee68c61 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 2 Nov 2026 07:13:51 +0200 Subject: [PATCH] feat(sidebar): What's New button and a Dark Mode section The sidebar's bottom block gets two new controls: - A "What's New" button that opens the release-note popup, with a badge until the operator has read the latest release. - A Dark Mode section that replaces the old one-click toggle with an explicit Light/Dark switch, so the current mode is always visible instead of being inferred from the button's label. The theme colour picker stays in Settings -> Appearance; the sidebar carries the mode switch only, so the nav rail is not a strip of 16 coloured squares. --- src/components/sidebar.test.tsx | 29 +++++ src/components/sidebar.tsx | 105 +++++++++++++--- src/components/whats-new-dialog.test.tsx | 45 +++++++ src/components/whats-new-dialog.tsx | 150 +++++++++++++++++++++++ src/lib/release-notes.ts | 82 +++++++++++++ 5 files changed, 393 insertions(+), 18 deletions(-) create mode 100644 src/components/whats-new-dialog.test.tsx create mode 100644 src/components/whats-new-dialog.tsx create mode 100644 src/lib/release-notes.ts diff --git a/src/components/sidebar.test.tsx b/src/components/sidebar.test.tsx index 686ee75..c8d17ef 100644 --- a/src/components/sidebar.test.tsx +++ b/src/components/sidebar.test.tsx @@ -122,4 +122,33 @@ describe("Sidebar", () => { expect(link).not.toBeNull(); expect(link).toHaveAttribute("href", "/search"); }); + + it("puts What's New and a Dark Mode section in the bottom block", () => { + render(); + const desktop = screen.getAllByRole("complementary")[0]; + + // The release-notes popup button. + expect( + within(desktop).getByRole("button", { name: "What's New" }), + ).toBeInTheDocument(); + + // The Dark Mode section: a collapsible heading and the light/dark + // switch. The theme colour swatches were pulled out of the sidebar + // (a strip of coloured squares reads as noise in a nav rail) — the + // full picker lives in Settings → Appearance. + expect( + within(desktop).getByRole("button", { name: /dark mode/i }), + ).toBeInTheDocument(); + expect(within(desktop).getByRole("button", { name: "Light" })).toBeInTheDocument(); + expect(within(desktop).getByRole("button", { name: "Dark" })).toBeInTheDocument(); + + // Regression guard: no base theme may leak back into the sidebar as + // a swatch button. + for (const label of ["WFDASH", "Neon", "Cosmic", "MiniMax", "Crisp"]) { + expect( + within(desktop).queryByRole("button", { name: label }), + `sidebar should not render a theme swatch for "${label}"`, + ).not.toBeInTheDocument(); + } + }); }); diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 27c7ecf..0bba727 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -39,6 +39,7 @@ import { BrandStripe } from "@/components/brand-stripe"; import { Separator } from "@/components/ui/separator"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet"; +import { WhatsNewButton } from "@/components/whats-new-dialog"; import { useEffect, useState } from "react"; import { cn } from "@/lib/utils"; @@ -273,13 +274,92 @@ function NavLink({ ); } +/* ============================================================================ + * DarkModeSection — the light/dark switch + * + * Lives in the sidebar's bottom block. Only the mode switch lives here: + * the row of theme colour swatches that used to sit under it was pulled + * out — a strip of 16 coloured squares reads as noise in a nav rail, and + * the sidebar is for navigation, not for picking a palette. + * + * Settings → Appearance still carries the full theme picker. + * ========================================================================== */ +function DarkModeSection() { + const { theme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + const [open, setOpen] = useState(true); + + useEffect(() => setMounted(true), []); // eslint-disable-line react-hooks/set-state-in-effect + + // `theme` is undefined on the server and undefined for the client's + // first (hydration) render — next-themes reads localStorage after + // mount. Rendering a highlight off it before mount is what produces + // the "wrong button is lit for a frame" flicker, and on a dark-mode + // browser an outright hydration mismatch. So we highlight nothing + // until we know, then paint the right one. + const isDark = mounted && theme === "dark"; + + return ( +
+ + + {open && ( +
+
+ + +
+
+ )} +
+ ); +} + /* ============================================================================ * NavContent — the actual sidebar body * ========================================================================== */ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) { const pathname = usePathname(); const { user, logout } = useAuth(); - const { theme, setTheme } = useTheme(); return (
@@ -391,6 +471,8 @@ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) { {/* Bottom block */}
+ + void }) { Settings - + {/* Dark mode — the light/dark switch. This replaces the old + one-click toggle that sat here: two controls for one setting + in the same strip reads as a bug, not a feature. */} + diff --git a/src/components/whats-new-dialog.test.tsx b/src/components/whats-new-dialog.test.tsx new file mode 100644 index 0000000..d71e6ea --- /dev/null +++ b/src/components/whats-new-dialog.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { WhatsNewButton } from "./whats-new-dialog"; +import { LATEST_RELEASE } from "@/lib/release-notes"; + +const SEEN_KEY = "wfdash-whats-new-seen"; + +describe("WhatsNewButton", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("flags the latest release as new until it has been read", () => { + render(); + + expect(screen.getByTestId("whats-new-badge")).toBeInTheDocument(); + }); + + it("marks the release as seen when the popup is opened", () => { + render(); + + // The dialog is opened by us, not by base-ui, so the "seen" stamp + // has to be written here. A version that only fired from + // onOpenChange would leave the badge lit forever. + fireEvent.click(screen.getByRole("button", { name: "What's New" })); + + expect(window.localStorage.getItem(SEEN_KEY)).toBe(LATEST_RELEASE.version); + }); + + it("does not flag a release the operator has already read", () => { + window.localStorage.setItem(SEEN_KEY, LATEST_RELEASE.version); + render(); + + expect(screen.queryByTestId("whats-new-badge")).not.toBeInTheDocument(); + }); + + it("shows the release notes in the popup", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "What's New" })); + + expect(await screen.findByText(LATEST_RELEASE.title)).toBeInTheDocument(); + }); +}); diff --git a/src/components/whats-new-dialog.tsx b/src/components/whats-new-dialog.tsx new file mode 100644 index 0000000..2c224fc --- /dev/null +++ b/src/components/whats-new-dialog.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Megaphone } from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { LATEST_RELEASE, RELEASE_NOTES } from "@/lib/release-notes"; +import { cn } from "@/lib/utils"; + +/** + * What's New — a button in the sidebar that opens a popup of the + * releases that have landed since the operator last looked. + * + * "Seen" is tracked in localStorage against the latest version string. + * Bump the version in src/lib/release-notes.ts and the button lights up + * again for everyone — no server round-trip, no account state. + */ + +const SEEN_KEY = "wfdash-whats-new-seen"; + +function formatDate(iso: string): string { + const d = new Date(`${iso}T00:00:00`); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString("en-ZA", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +export function WhatsNewButton({ className }: { className?: string }) { + const [open, setOpen] = useState(false); + // `mounted` guards the "New" badge: the server has no idea what this + // operator has already read, so the badge stays off until we know. + // Reading localStorage during render would mismatch the server HTML. + const [state, setState] = useState<{ mounted: boolean; seen: string | null }>({ + mounted: false, + seen: null, + }); + + useEffect(() => { + let stored: string | null = null; + try { + stored = window.localStorage.getItem(SEEN_KEY); + } catch { + /* localStorage blocked — treat as unseen */ + } + // eslint-disable-next-line react-hooks/set-state-in-effect + setState({ mounted: true, seen: stored }); + }, []); + + const markSeen = useCallback(() => { + try { + window.localStorage.setItem(SEEN_KEY, LATEST_RELEASE.version); + } catch { + /* ignore */ + } + setState((s) => ({ ...s, seen: LATEST_RELEASE.version })); + }, []); + + const isNew = state.mounted && state.seen !== LATEST_RELEASE.version; + + return ( + <> + + + + + + + + What's new + + + What has landed in WFDASH lately. The most recent release is at the top. + + + +
+
    + {RELEASE_NOTES.map((release, i) => ( +
  1. +
    + + {release.version} + + + {formatDate(release.date)} + +
    +

    {release.title}

    +
      + {release.items.map((item) => ( +
    • + + {item} +
    • + ))} +
    +
  2. + ))} +
+
+
+
+ + ); +} diff --git a/src/lib/release-notes.ts b/src/lib/release-notes.ts new file mode 100644 index 0000000..c0939be --- /dev/null +++ b/src/lib/release-notes.ts @@ -0,0 +1,82 @@ +/** + * What's New — the release-note registry behind the Settings page popup. + * + * A plain data file, newest first. There's no changelog table and no + * fetch: the notes are written by hand when a release goes out, and the + * popup renders whatever is in here. That keeps the whole feature + * client-side (it opens instantly, works offline) and means the copy + * stays deliberate rather than being generated from commit messages. + * + * To publish a release: add an entry at the top of the array. The + * version string is the "seen" marker — bumping it is what makes the + * What's New button light up for every operator again. + */ + +export interface ReleaseNote { + /** Version marker. Bump this to re-notify operators. */ + version: string; + /** ISO date (YYYY-MM-DD) of the release. */ + date: string; + /** One-line headline for the release. */ + title: string; + /** The changes, one sentence each. */ + items: string[]; +} + +export const RELEASE_NOTES: ReleaseNote[] = [ + { + version: "2026.11.01", + date: "2026-11-01", + title: "Delivery Pool, letterhead lock and a dark mode section", + items: [ + "Delivery Pool: invoiced lines waiting to go out, one card per customer invoice. Assign a driver, upload the signed copy, and the delivery closes.", + "Quote letterheads are locked to the customer's company — one letterhead per company, so quotes always go out on the right paper.", + "The counter never sells at R0. Items without a price stay off the till, and customer lines go to the price list instead.", + "The sidebar now has a Dark Mode section with a light/dark switch. Theme colours live in Settings → Appearance.", + "This What's New popup — press it in the sidebar to see what changed.", + ], + }, + { + version: "2026.10.30", + date: "2026-10-30", + title: "Collection Confirm gets a second pair of eyes", + items: [ + "Collection Confirm: a Notes button on each PO card, so the driver photos can be checked against the PO before you confirm.", + "The AI never changes a price. A different cost on the invoice is flagged as a red Price tick for a person to settle.", + "Collection Pool: stock lines can go back to pending and be marked ready again.", + "Quotes now record who they were sent to.", + ], + }, + { + version: "2026.10.28", + date: "2026-10-28", + title: "Auto-push and a shortcut to AI settings", + items: [ + "Collection Confirm can auto-push purple cards straight on to invoicing, with a two-day log of everything it moved.", + "The Settings page links straight to AI settings.", + ], + }, + { + version: "2026.10.26", + date: "2026-10-26", + title: "A phone board for the managers", + items: [ + "/manager is a mobile board for the managers: every order and where it is, on a phone.", + "The preview popup is the same one the desk uses on /search.", + ], + }, + { + version: "2026.10.24", + date: "2026-10-24", + title: "One POS matcher, AI-only quote matching", + items: [ + "One size-aware matcher now drives the quote assistant, the order fill and the blue price check — so they can't disagree about an item.", + "The quote assistant prices only an exact match and shows the closest one instead when it isn't sure.", + "Quote matching is AI-only. The old rule-based scorer is retired; the AI links an order when it's sure and suggests otherwise.", + "The blue price check badge appears on both Process pages.", + ], + }, +]; + +/** The newest entry — the one the "New" dot on the button points at. */ +export const LATEST_RELEASE: ReleaseNote = RELEASE_NOTES[0];