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.
This commit is contained in:
parent
61fb2669e0
commit
139ea259f4
5 changed files with 393 additions and 18 deletions
|
|
@ -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(<Sidebar />);
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-1.5 px-2.5 py-1 text-[10.5px] font-semibold uppercase tracking-widest text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Dark mode
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"ml-auto h-3 w-3 transition-transform",
|
||||
open ? "" : "-rotate-90"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-2 px-2.5">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-sidebar-accent/50 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme("light")}
|
||||
aria-pressed={mounted && !isDark}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors",
|
||||
mounted && !isDark
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-sidebar-foreground"
|
||||
)}
|
||||
>
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme("dark")}
|
||||
aria-pressed={isDark}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors",
|
||||
isDark
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-sidebar-foreground"
|
||||
)}
|
||||
>
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* NavContent — the actual sidebar body
|
||||
* ========================================================================== */
|
||||
function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuth();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
||||
|
|
@ -391,6 +471,8 @@ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
|
|||
|
||||
{/* Bottom block */}
|
||||
<div className="space-y-1 p-3">
|
||||
<WhatsNewButton />
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={onLinkClick}
|
||||
|
|
@ -402,23 +484,10 @@ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
|
|||
Settings
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<>
|
||||
<Sun className="h-[17px] w-[17px]" />
|
||||
Light mode
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Moon className="h-[17px] w-[17px]" />
|
||||
Dark mode
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{/* 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. */}
|
||||
<DarkModeSection />
|
||||
|
||||
<Separator className="my-1" />
|
||||
|
||||
|
|
|
|||
45
src/components/whats-new-dialog.test.tsx
Normal file
45
src/components/whats-new-dialog.test.tsx
Normal file
|
|
@ -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(<WhatsNewButton />);
|
||||
|
||||
expect(screen.getByTestId("whats-new-badge")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the release as seen when the popup is opened", () => {
|
||||
render(<WhatsNewButton />);
|
||||
|
||||
// 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(<WhatsNewButton />);
|
||||
|
||||
expect(screen.queryByTestId("whats-new-badge")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the release notes in the popup", async () => {
|
||||
render(<WhatsNewButton />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "What's New" }));
|
||||
|
||||
expect(await screen.findByText(LATEST_RELEASE.title)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
150
src/components/whats-new-dialog.tsx
Normal file
150
src/components/whats-new-dialog.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
// Opening is what counts as reading it. This has to happen
|
||||
// here rather than in onOpenChange: the dialog is opened by
|
||||
// us, so base-ui never routes the open through its own
|
||||
// change handler — only the close does.
|
||||
markSeen();
|
||||
}}
|
||||
aria-label="What's New"
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Megaphone className="h-[17px] w-[17px]" />
|
||||
What's New
|
||||
{isNew && (
|
||||
<span
|
||||
data-testid="whats-new-badge"
|
||||
className="ml-auto rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary"
|
||||
>
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[85vh] grid-rows-[auto_1fr] gap-0 p-0 overflow-hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Megaphone className="h-4 w-4 text-primary" />
|
||||
What's new
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
What has landed in WFDASH lately. The most recent release is at the top.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto px-6 py-4">
|
||||
<ol className="space-y-5">
|
||||
{RELEASE_NOTES.map((release, i) => (
|
||||
<li key={release.version} className="space-y-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide",
|
||||
i === 0
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{release.version}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(release.date)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{release.title}</p>
|
||||
<ul className="space-y-1.5">
|
||||
{release.items.map((item) => (
|
||||
<li key={item} className="flex gap-2 text-sm text-muted-foreground">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-[7px] h-1 w-1 shrink-0 rounded-full bg-primary/70"
|
||||
/>
|
||||
<span className="leading-relaxed">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
src/lib/release-notes.ts
Normal file
82
src/lib/release-notes.ts
Normal file
|
|
@ -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];
|
||||
Loading…
Reference in a new issue