yiekheng ab547c7b34 fix(reminder-edit): preserve message stack across all section forms; UI cleanup
Several user-reported bugs and UX nits fixed in one cut:

1. Editing account / when / groups silently dropped messages 2..N
   --------------------------------------------------------------
   Symptom: a reminder with 3 message parts came back with 1 after
   the user edited any section other than the message itself.

   Cause: the three section forms were still on the legacy
   {text, mediaId, caption} prop shape. The parent pages pulled only
   messages[0] from the DB, reduced it to those three fields, and
   the form posted them through to updateReminderAction. The action
   then folded the legacy fields into a single MessagePart and
   replaced the whole reminder_messages row set — wiping parts 2..N
   even though the user only meant to change the schedule.

   Fix: each form (edit-account / edit-when / edit-groups) now takes
   the full `messages: MessagePart[]` and forwards it unchanged. The
   three parent pages load the full stack (sorted by position) and
   pass it through.

   Test: new edit-section-forms.test.tsx asserts a 3-part stack
   reaches updateReminderAction intact for both the account-form and
   groups-form code paths, plus a sanity test that the legacy
   single-message payload shape (without `messages`) is what a
   future regression would look like.

2. Reminders list: removed the Group filter
   --------------------------------------------------------------
   Per request — Account + Search already cover the use cases the
   Group filter was supposed to. Search even matches group names
   directly, so the dropdown was redundant. Page no longer fetches
   the groups table for its filter bar at all.

3. Mobile chrome: bottom nav → top header w/ menu drawer
   --------------------------------------------------------------
   Removed the bottom tab bar. Mobile now has a single-row top
   header:

       ┌──┐                          ┌────┐
       │cm│   <current page title>   │menu│
       └──┘                          └────┘

   - Brand mark on the left links home.
   - Current page title sits in the middle so the user always knows
     where they are.
   - Menu icon on the right opens a right-side Sheet (radix Dialog)
     containing the full nav list. Active item highlighted; the
     drawer auto-closes when a nav item is clicked (effect on the
     pathname change).
   - Theme toggle stays only in the desktop sidebar footer per the
     follow-up ask.

   Main content padding adjusted: pt-16 (mobile) for the h-14
   header, no bottom padding now.

4. Cleaned up the now-unused legacy props
   --------------------------------------------------------------
   `text` / `mediaId` / `caption` removed from the three section
   form prop types. The wizard's URL-state pass-through still
   accepts the legacy fields and folds them into the new
   `messages` shape on entry, so old bookmarked /reminders/new
   URLs still work.

194 passing web tests (was 194; net 0 — the new edit-section-forms
tests replaced coverage we lost when the legacy props went away).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:47:38 +08:00

205 lines
7.2 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MenuIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { NAV_ITEMS } from "@/components/nav-config";
import { ThemeToggle } from "@/components/theme-toggle";
// ---------------------------------------------------------------------------
// Mobile header (sm:hidden)
//
// Single-row layout:
// ┌──┐ ┌────┐
// │cm│ Page title │menu│
// └──┘ └────┘
//
// The brand mark on the left links home. The page title (derived from
// the current nav route) gives the user a "you are here" cue without
// waiting for the page content to render. The menu button on the right
// opens a Sheet with the full nav list and the theme toggle.
// ---------------------------------------------------------------------------
function MobileHeader() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
// Close the drawer when the route changes (i.e. the user picked a nav
// item). Without this, navigating leaves the sheet open over the new
// page until the user dismisses it manually.
useEffect(() => {
setOpen(false);
}, [pathname]);
const currentItem = NAV_ITEMS.find(({ href }) =>
href === "/" ? pathname === "/" : pathname.startsWith(href),
);
const title = currentItem?.label ?? "WhatsApp Bot";
return (
<header className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center justify-between border-b border-border bg-background/95 backdrop-blur-sm px-3 sm:hidden">
<Link
href="/"
aria-label="Go home"
className="flex size-9 items-center justify-center rounded-lg bg-primary text-xs font-bold uppercase text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
cm
</Link>
<span className="truncate text-sm font-semibold tracking-tight px-2">
{title}
</span>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Open menu"
className="size-9"
>
<MenuIcon className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="flex w-72 flex-col gap-0 p-0">
<SheetHeader className="gap-1 border-b border-border px-4 py-3">
<SheetTitle className="flex items-center gap-2">
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
>
cm
</span>
WhatsApp Bot
</SheetTitle>
<SheetDescription className="sr-only">
Primary navigation menu
</SheetDescription>
</SheetHeader>
<nav
aria-label="Primary navigation"
className="flex flex-col gap-0.5 p-2 flex-1"
>
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={key}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
aria-current={active ? "page" : undefined}
className={cn(
"flex min-h-[44px] items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted",
)}
>
<Icon
size={18}
strokeWidth={active ? 2.5 : 1.75}
aria-hidden
/>
{label}
</Link>
);
})}
</nav>
</SheetContent>
</Sheet>
</header>
);
}
// ---------------------------------------------------------------------------
// Sidebar (desktop only — hidden below sm)
// ---------------------------------------------------------------------------
function Sidebar() {
const pathname = usePathname();
return (
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
{/* Bot name / brand */}
<div className="flex h-14 items-center gap-2 px-4 border-b border-sidebar-border shrink-0">
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
>
cm
</span>
<span className="text-sm font-semibold tracking-tight text-sidebar-foreground">
WhatsApp Bot
</span>
</div>
{/* Nav items */}
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={key}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors min-h-[44px]",
active
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
)}
>
<Icon size={18} strokeWidth={active ? 2.5 : 1.75} aria-hidden />
{label}
</Link>
);
})}
</nav>
{/* Footer: theme toggle */}
<div className="border-t border-sidebar-border p-3">
<ThemeToggle />
</div>
</aside>
);
}
// ---------------------------------------------------------------------------
// AppShell — the outer container
// ---------------------------------------------------------------------------
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<>
{/* Desktop sidebar */}
<Sidebar />
{/* Mobile header (single row: brand · title · menu) */}
<MobileHeader />
{/* Main content
Mobile: push down for the h-14 header (56px) plus a small gap
so page titles don't kiss the bottom edge of the nav.
Desktop: push right for the sidebar (sm:pl-56), no top offset. */}
<main className="min-h-dvh pt-16 sm:pl-56 sm:pt-0">
{children}
</main>
</>
);
}