fix(layout): mobile UX cleanup + interest-stage legend popover
Mobile UX: - Hide ColumnPicker on `< sm` viewports (cards, no columns to toggle). - Hide kanban toggle in interest list on mobile and snap viewMode back to 'table' if the persisted choice was 'board'. - Drop dead "Inbox" link from the More-sheet (email/IMAP feature is deferred per sidebar.tsx note). - Repoint Notifications nav from `/notifications` (no page.tsx — 404) to `/notifications/preferences` and re-label as "Notification preferences" (the bell stays the surface for actual notifications). - Hide Website Analytics on both desktop sidebar and mobile More-sheet when Umami isn't configured for the port (`useUmamiActive()`). Interests: - New `<StageLegend>` popover button in the filter row decodes the card stripe colours to pipeline stage names, kept in sync with `STAGE_DOT` automatically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import {
|
||||
Plus,
|
||||
@@ -35,6 +35,7 @@ import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
import { InterestCard } from '@/components/interests/interest-card';
|
||||
import { StageLegend } from '@/components/interests/stage-legend';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -63,6 +64,13 @@ export function InterestList() {
|
||||
const queryClient = useQueryClient();
|
||||
const { viewMode, setViewMode } = usePipelineStore();
|
||||
|
||||
// Force the list view at mobile widths even when the user previously
|
||||
// toggled the kanban from desktop — the board is desktop-only.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (viewMode === 'board' && window.innerWidth < 640) setViewMode('table');
|
||||
}, [viewMode, setViewMode]);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editInterest, setEditInterest] = useState<InterestRow | null>(null);
|
||||
const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null);
|
||||
@@ -157,7 +165,10 @@ export function InterestList() {
|
||||
variant="gradient"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center border rounded-md overflow-hidden">
|
||||
{/* Kanban view is desktop-only — mobile drops the toggle and
|
||||
falls back to the list/cards view (the board's column
|
||||
horizontal-scroll model is unusable at phone widths). */}
|
||||
<div className="hidden sm:flex items-center border rounded-md overflow-hidden">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewMode === 'table' ? 'default' : 'ghost'}
|
||||
@@ -234,6 +245,7 @@ export function InterestList() {
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<StageLegend />
|
||||
</div>
|
||||
|
||||
<SaveViewDialog
|
||||
|
||||
52
src/components/interests/stage-legend.tsx
Normal file
52
src/components/interests/stage-legend.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
PIPELINE_STAGES,
|
||||
STAGE_LABELS,
|
||||
stageDotClass,
|
||||
type PipelineStage,
|
||||
} from '@/lib/constants';
|
||||
|
||||
/**
|
||||
* Small popover that decodes the colored stripe on each interest card to
|
||||
* the pipeline stage it represents. The stripe colors come from
|
||||
* `STAGE_DOT` in `lib/constants.ts`; if that map changes, this legend
|
||||
* stays in sync because both read from the same source.
|
||||
*/
|
||||
export function StageLegend() {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-muted-foreground"
|
||||
aria-label="What do the colors mean?"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Legend</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Pipeline stage colors
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{PIPELINE_STAGES.map((stage: PipelineStage) => (
|
||||
<li key={stage} className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${stageDotClass(stage)}`}
|
||||
/>
|
||||
<span>{STAGE_LABELS[stage]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Building2,
|
||||
Globe,
|
||||
Home,
|
||||
Mail,
|
||||
Receipt,
|
||||
Settings,
|
||||
Shield,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerClose,
|
||||
} from '@/components/shared/drawer';
|
||||
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
|
||||
|
||||
type MoreItem = {
|
||||
label: string;
|
||||
@@ -38,14 +38,21 @@ type MoreItem = {
|
||||
// reps reach the active deals via the Interests tab on a client detail
|
||||
// (or via the new bottom-sheet drawer). Yachts is asset-record traffic
|
||||
// best reached contextually from inside an interest or client.
|
||||
//
|
||||
// Inbox is intentionally absent — the email/threading inbox feature was
|
||||
// deferred (see sidebar.tsx). Re-add this entry once IMAP/SMTP wiring
|
||||
// + Google OAuth review are done. Website analytics is filtered below
|
||||
// when Umami isn't configured for this port.
|
||||
const MORE_ITEMS: MoreItem[] = [
|
||||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
||||
{ label: 'Inbox', icon: Mail, segment: 'email' },
|
||||
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
|
||||
{ label: 'Notifications', icon: BellRing, segment: 'notifications' },
|
||||
// Notifications themselves live on the topbar bell — this entry deep-links
|
||||
// to the per-channel preferences page. Pointing at the bare `/notifications`
|
||||
// segment 404s today (no page.tsx, only `/preferences`).
|
||||
{ label: 'Notification preferences', icon: BellRing, segment: 'notifications/preferences' },
|
||||
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
||||
{ label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
|
||||
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
|
||||
@@ -65,6 +72,14 @@ export function MoreSheet({
|
||||
const pathname = usePathname();
|
||||
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
||||
|
||||
// Hide "Website analytics" if Umami isn't wired up for this port — the
|
||||
// dedicated tile on the dashboard already does the same.
|
||||
const umami = useUmamiActive('today');
|
||||
const umamiConfigured = umami.data?.error !== 'umami_not_configured';
|
||||
const items = MORE_ITEMS.filter(
|
||||
(item) => item.segment !== 'website-analytics' || umamiConfigured,
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent>
|
||||
@@ -72,7 +87,7 @@ export function MoreSheet({
|
||||
<DrawerTitle>More</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<ul className="grid grid-cols-3 gap-2 px-3 pb-4">
|
||||
{MORE_ITEMS.map((item) => {
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<li key={item.segment}>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { UserMenu } from '@/components/layout/user-menu';
|
||||
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
|
||||
import type { UserPortRole } from '@/lib/db/schema/users';
|
||||
import type { Role } from '@/lib/db/schema/users';
|
||||
import type { Port } from '@/lib/db/schema/ports';
|
||||
@@ -76,6 +77,8 @@ interface NavSection {
|
||||
marinaRequired?: boolean;
|
||||
/** When true, only render if the user has residential-side access. */
|
||||
residentialRequired?: boolean;
|
||||
/** When true, only render if Umami analytics is wired up for the port. */
|
||||
umamiRequired?: boolean;
|
||||
}
|
||||
|
||||
function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
@@ -140,10 +143,12 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
{
|
||||
title: 'Insights',
|
||||
marinaRequired: true,
|
||||
umamiRequired: true,
|
||||
items: [
|
||||
// Marketing / Umami integration. Distinct from the main dashboard
|
||||
// (which is sales-focused) so the audience and the metrics don't
|
||||
// compete for visual real estate.
|
||||
// compete for visual real estate. Whole section is hidden when
|
||||
// Umami isn't wired up — see SidebarContent.
|
||||
{
|
||||
href: `${base}/website-analytics`,
|
||||
label: 'Website analytics',
|
||||
@@ -250,6 +255,8 @@ function SidebarContent({
|
||||
const pathname = usePathname();
|
||||
const [adminExpanded, setAdminExpanded] = useState(true);
|
||||
const sections = buildNavSections(portSlug);
|
||||
const umami = useUmamiActive('today');
|
||||
const umamiConfigured = umami.data?.error !== 'umami_not_configured';
|
||||
|
||||
// Small label under the user identity when the user has access to more
|
||||
// than one port — disambiguates which port is currently active without
|
||||
@@ -324,6 +331,7 @@ function SidebarContent({
|
||||
if (section.adminRequired && !hasAdminAccess) return null;
|
||||
if (section.marinaRequired && !hasMarinaAccess) return null;
|
||||
if (section.residentialRequired && !hasResidentialAccess) return null;
|
||||
if (section.umamiRequired && !umamiConfigured) return null;
|
||||
|
||||
return (
|
||||
<div key={section.title}>
|
||||
|
||||
@@ -68,9 +68,12 @@ export function ColumnPicker({
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5 h-8">
|
||||
{/* Hide entirely on mobile — small viewports render the list as
|
||||
cards (no columns to pick). The desktop table view shows
|
||||
this trigger from the `sm:` breakpoint up. */}
|
||||
<Button variant="outline" size="sm" className="hidden sm:inline-flex gap-1.5 h-8">
|
||||
<Columns3 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Columns</span>
|
||||
<span>Columns</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
|
||||
Reference in New Issue
Block a user