From a45cbe68221e13b92c91bfcb8e4f617a1883b980 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Thu, 18 Jun 2026 15:08:33 +0300 Subject: [PATCH] feat(settings): redesign Settings into a grouped rail + Overview landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat 8-tab Settings page with a cleaner information architecture: a grouped left rail (Account / Workspace) plus a new Overview landing with live status tiles. Related concerns are merged — "Login & security" (password + sessions) and "Fields & tags" (tags + custom fields). - New shell: settings-sections (section model + legacy ?tab= mapping, tags/custom-fields -> fields), settings-rail, settings-overview (live counts + identity), security-panel, fields-and-tags-panel, settings-panel-head, settings-chip, role-meta (shared role chip metadata, de-duped from members-tab). - Restyle the light panels to the mockup; reframe the heavy panels (WhatsApp, Templates) with the shared panel header, internals intact. - Tokens: add --card-2 (light+dark) and @theme mappings for primary-hover/soft/soft-2 so the redesign stays fully tokenized. - ?tab= remains the URL source of truth, keeping existing sidebar/header deep-links working. Overview decouples the slow WhatsApp Meta health-ping from the cheap count queries so a flaky ping never blanks the whole landing; template counts use head-only count queries. Co-Authored-By: Claude Opus 4.8 --- src/app/(dashboard)/settings/page.tsx | 209 ++++--------- src/app/globals.css | 11 + src/components/settings/appearance-panel.tsx | 37 ++- .../settings/custom-fields-settings.tsx | 52 ++-- src/components/settings/deals-settings.tsx | 9 +- .../settings/fields-and-tags-panel.tsx | 29 ++ src/components/settings/members-tab.tsx | 89 ++---- src/components/settings/password-form.tsx | 4 +- src/components/settings/profile-form.tsx | 64 ++-- src/components/settings/role-meta.ts | 51 ++++ src/components/settings/security-panel.tsx | 24 ++ src/components/settings/sessions-card.tsx | 2 +- src/components/settings/settings-chip.tsx | 64 ++++ src/components/settings/settings-overview.tsx | 289 ++++++++++++++++++ .../settings/settings-panel-head.tsx | 41 +++ src/components/settings/settings-rail.tsx | 109 +++++++ src/components/settings/settings-sections.ts | 78 +++++ src/components/settings/tag-manager.tsx | 280 +++++++---------- src/components/settings/template-manager.tsx | 63 ++-- src/components/settings/whatsapp-config.tsx | 27 +- 20 files changed, 1030 insertions(+), 502 deletions(-) create mode 100644 src/components/settings/fields-and-tags-panel.tsx create mode 100644 src/components/settings/role-meta.ts create mode 100644 src/components/settings/security-panel.tsx create mode 100644 src/components/settings/settings-chip.tsx create mode 100644 src/components/settings/settings-overview.tsx create mode 100644 src/components/settings/settings-panel-head.tsx create mode 100644 src/components/settings/settings-rail.tsx create mode 100644 src/components/settings/settings-sections.ts diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 88d971c..ff4f6f2 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -1,179 +1,82 @@ 'use client'; +import { useMemo, type ReactNode } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { - Settings, - MessageSquare, - Tag, - User, - Palette, - UsersRound, - Coins, - SlidersHorizontal, -} from 'lucide-react'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { useCan } from '@/hooks/use-can'; + +import { useAuth } from '@/hooks/use-auth'; +import { useTheme } from '@/hooks/use-theme'; +import { SettingsRail } from '@/components/settings/settings-rail'; +import { SettingsOverview } from '@/components/settings/settings-overview'; +import { ProfileForm } from '@/components/settings/profile-form'; +import { SecurityPanel } from '@/components/settings/security-panel'; +import { AppearancePanel } from '@/components/settings/appearance-panel'; import { WhatsAppConfig } from '@/components/settings/whatsapp-config'; import { TemplateManager } from '@/components/settings/template-manager'; -import { TagManager } from '@/components/settings/tag-manager'; -import { ProfileForm } from '@/components/settings/profile-form'; -import { PasswordForm } from '@/components/settings/password-form'; -import { SessionsCard } from '@/components/settings/sessions-card'; -import { AppearancePanel } from '@/components/settings/appearance-panel'; -import { MembersTab } from '@/components/settings/members-tab'; +import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel'; import { DealsSettings } from '@/components/settings/deals-settings'; -import { CustomFieldsSettings } from '@/components/settings/custom-fields-settings'; - -const TAB_VALUES = [ - 'profile', - 'whatsapp', - 'templates', - 'tags', - 'custom-fields', - 'deals', - 'appearance', - 'members', -] as const; -type TabValue = (typeof TAB_VALUES)[number]; - -function isTabValue(v: string | null): v is TabValue { - return !!v && (TAB_VALUES as readonly string[]).includes(v); -} +import { MembersTab } from '@/components/settings/members-tab'; +import { + resolveSection, + type SettingsSection, +} from '@/components/settings/settings-sections'; export default function SettingsPage() { const router = useRouter(); const searchParams = useSearchParams(); + const { defaultCurrency } = useAuth(); + const { mode } = useTheme(); - // Custom-field definitions are account-wide config, so editing them is - // admin+ only — mirror the gate on the Contacts page. The `custom_fields` - // RLS rejects non-admin writes regardless. - const canEditSettings = useCan('edit-settings'); + // The URL (`?tab=`) is the single source of truth for the active + // section — deep-linkable, and it keeps the existing links in the + // app sidebar/header working. Legacy tab values (tags, custom-fields) + // resolve onto their new home; unknown/empty → the Overview landing. + const section = resolveSection(searchParams.get('tab')); - // The URL is the single source of truth for the active tab — no - // local state, no sync effect. A previous revision duplicated this - // into `useState` + a sync effect, which tripped React 19's - // set-state-in-effect rule and was also redundant. - const queryTab = searchParams.get('tab'); - // Deep-linking to the admin-only tab as a non-admin falls back to profile - // rather than landing on a tab with no trigger or content. - const resolved: TabValue = isTabValue(queryTab) ? queryTab : 'profile'; - const tab: TabValue = - resolved === 'custom-fields' && !canEditSettings ? 'profile' : resolved; - - const onChange = (next: TabValue) => { + const go = (next: SettingsSection) => { const params = new URLSearchParams(searchParams.toString()); params.set('tab', next); router.replace(`/settings?${params.toString()}`, { scroll: false }); }; + // Cheap, fetch-free rail hints. The Overview landing carries the + // full live status/counts; the rail just surfaces the two that are + // already in context. + const hints: Partial> = useMemo( + () => ({ + appearance: mode.charAt(0).toUpperCase() + mode.slice(1), + deals: defaultCurrency, + }), + [mode, defaultCurrency], + ); + + const panel: Record = { + overview: , + profile: , + security: , + appearance: , + whatsapp: , + templates: , + fields: , + deals: , + members: , + }; + return ( -
+
-

Settings

+

+ Settings +

- Manage your profile, WhatsApp® integration, message templates, and - tags. + Everything in one place — your account and your workspace. Pick a + section to manage it.

- onChange(v as TabValue)}> - - - - Profile - - - - WhatsApp Config - - - - Templates - - - - Tags - - {canEditSettings && ( - - - Custom Fields - - )} - - - Deals - - - - Appearance - - - - Members - - - - - - - - - - - - - - - - - - - - - - {canEditSettings && ( - - - - )} - - - - - - - - - - - - - +
+ +
{panel[section]}
+
); } diff --git a/src/app/globals.css b/src/app/globals.css index 95f033f..ee8f58d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -39,6 +39,15 @@ --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); + /* Secondary card surface (tile / hover backgrounds) and the accent + * extras the Settings redesign leans on. The raw --primary-* vars + * already exist per-accent below; these mappings expose them (and + * --card-2) as Tailwind utilities: bg-card-2, bg-primary-soft, + * bg-primary-soft-2, hover:bg-primary-hover. */ + --color-card-2: var(--card-2); + --color-primary-hover: var(--primary-hover); + --color-primary-soft: var(--primary-soft); + --color-primary-soft-2: var(--primary-soft-2); --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); @@ -82,6 +91,7 @@ html[data-mode="dark"] { --background: oklch(0.13 0.01 260); --foreground: oklch(0.985 0 0); --card: oklch(0.18 0.01 260); + --card-2: oklch(0.205 0.01 260); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.18 0.01 260); --popover-foreground: oklch(0.985 0 0); @@ -110,6 +120,7 @@ html[data-mode="light"] { --background: oklch(0.99 0.002 260); --foreground: oklch(0.21 0.01 260); --card: oklch(1 0 0); + --card-2: oklch(0.985 0.002 260); --card-foreground: oklch(0.21 0.01 260); --popover: oklch(1 0 0); --popover-foreground: oklch(0.21 0.01 260); diff --git a/src/components/settings/appearance-panel.tsx b/src/components/settings/appearance-panel.tsx index 0100686..693608d 100644 --- a/src/components/settings/appearance-panel.tsx +++ b/src/components/settings/appearance-panel.tsx @@ -1,10 +1,11 @@ "use client"; -import { Check, Moon, Sun } from "lucide-react"; +import { Check, Moon, Palette, SunMoon, Sun } from "lucide-react"; import { useTheme } from "@/hooks/use-theme"; import { MODES, THEMES, type Mode, type ThemeId } from "@/lib/themes"; import { cn } from "@/lib/utils"; +import { SettingsPanelHead } from "./settings-panel-head"; /** * Appearance panel — light/dark mode + accent-color picker. @@ -21,16 +22,17 @@ import { cn } from "@/lib/utils"; export function AppearancePanel() { const { theme, setTheme, mode, setMode } = useTheme(); return ( -
+
+ +
-
-

Mode

-

- Choose light or dark. Light is easier on the eyes in bright - rooms; dark is the original look. Works with any accent - color below. Saved to this device. -

-
+

+ + Mode +

-
-
-

- Accent color -

-

- Pick the accent color used across the app — buttons, active - nav, and badges. Saved to this device. -

-
+
+

+ + Accent color +

{THEMES.map((t) => ( diff --git a/src/components/settings/custom-fields-settings.tsx b/src/components/settings/custom-fields-settings.tsx index dfee78f..48232f5 100644 --- a/src/components/settings/custom-fields-settings.tsx +++ b/src/components/settings/custom-fields-settings.tsx @@ -1,29 +1,43 @@ 'use client'; -import { Card, CardContent } from '@/components/ui/card'; +import { Shield, SlidersHorizontal } from 'lucide-react'; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager'; +import { SettingsChip } from './settings-chip'; /** - * Settings → Custom Fields. Manage the account-wide custom contact field - * catalogue (the same panel the Contacts page exposes via a dialog). Writes - * are admin-gated by the caller and enforced by `custom_fields` RLS. + * Settings → Custom Fields card. Manages the account-wide custom + * contact field catalogue (the same panel the Contacts page exposes + * via a dialog). Writes are admin-gated by the caller and enforced by + * `custom_fields` RLS. */ export function CustomFieldsSettings() { return ( -
-
-

Custom fields

-

- Define extra contact fields (e.g. ZIP code, lead source). They appear - on every contact and in the “Update Contact Field” automation action. -

-
- - - - - - -
+ + + + + Custom fields + + + Admin + + + + Extra contact fields (e.g. ZIP code, lead source). They appear on + every contact and in the “Update Contact Field” automation action. + + + + + + ); } diff --git a/src/components/settings/deals-settings.tsx b/src/components/settings/deals-settings.tsx index ad3460c..0ae6842 100644 --- a/src/components/settings/deals-settings.tsx +++ b/src/components/settings/deals-settings.tsx @@ -16,6 +16,7 @@ import { CardTitle, CardDescription, } from "@/components/ui/card"; +import { SettingsPanelHead } from "./settings-panel-head"; /** * Deals settings — account-wide default currency. @@ -67,8 +68,12 @@ export function DealsSettings() { } return ( -
- +
+ + diff --git a/src/components/settings/fields-and-tags-panel.tsx b/src/components/settings/fields-and-tags-panel.tsx new file mode 100644 index 0000000..a4d92f7 --- /dev/null +++ b/src/components/settings/fields-and-tags-panel.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useCan } from '@/hooks/use-can'; + +import { CustomFieldsSettings } from './custom-fields-settings'; +import { SettingsPanelHead } from './settings-panel-head'; +import { TagManager } from './tag-manager'; + +/** + * "Fields & tags" section — merges the former Tags and Custom Fields + * tabs. Tags are visible to everyone; the custom-fields catalogue is + * account-wide config, so the card is admin-gated (mirroring the old + * hidden-tab behaviour). `custom_fields` RLS rejects non-admin writes + * regardless. + */ +export function FieldsAndTagsPanel() { + const canEditSettings = useCan('edit-settings'); + + return ( +
+ + + {canEditSettings ? : null} +
+ ); +} diff --git a/src/components/settings/members-tab.tsx b/src/components/settings/members-tab.tsx index aa30393..16cfa02 100644 --- a/src/components/settings/members-tab.tsx +++ b/src/components/settings/members-tab.tsx @@ -25,15 +25,11 @@ import { useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { AlertTriangle, - Crown, Loader2, Mail, MailX, Plus, - Shield, Trash2, - UserCog, - UserIcon, UsersRound, } from 'lucide-react'; @@ -60,6 +56,8 @@ import { RequireRole } from '@/components/auth/require-role'; import { useAuth } from '@/hooks/use-auth'; import type { AccountRole } from '@/lib/auth/roles'; import { InviteMemberDialog } from './invite-member-dialog'; +import { SettingsPanelHead } from './settings-panel-head'; +import { ROLE_META } from './role-meta'; interface Member { user_id: string; @@ -86,40 +84,10 @@ const EDITABLE_ROLES: { value: AccountRole; label: string; hint: string }[] = [ { value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' }, ]; -// Per-role chip metadata. The colour scale runs amber (owner — -// scarce, immutable) → primary (admin — significant) → slate -// (agent — operational default) → muted slate (viewer — read- -// only). Mirrors the sidebar's ROLE_CHIP so the two surfaces -// don't drift; once the surface stabilises this should hoist -// into a shared module. -const ROLE_CHIP: Record< - AccountRole, - { icon: typeof Crown; label: string; className: string } -> = { - owner: { - icon: Crown, - label: 'Owner', - className: - 'border-amber-500/40 bg-amber-500/10 text-amber-300', - }, - admin: { - icon: Shield, - label: 'Admin', - className: 'border-primary/40 bg-primary/10 text-primary', - }, - agent: { - icon: UserCog, - label: 'Agent', - className: 'border-border bg-muted text-muted-foreground', - }, - viewer: { - icon: UserIcon, - label: 'Viewer', - // Outline-only so it stays quieter than the filled Agent chip in - // both modes — bg-card would blend into a card surface in light mode. - className: 'border-border bg-transparent text-muted-foreground', - }, -}; +// Per-role chip metadata (icon / label / colour) lives in the shared +// ROLE_META module so this roster and the Overview identity chip can't +// drift. The colour scale runs amber (owner — scarce, immutable) → +// primary (admin) → muted (agent / viewer). function fmtDate(iso: string): string { // Match the rest of the dashboard's locale-light formatting. @@ -294,33 +262,26 @@ export function MembersTab() { } return ( -
- {/* Header + invite button */} -
-
-

Account members

-

- People with access to this account. Roles control what each - teammate can do. -

-
- - - -
+
+ + + + } + /> {/* Roster */} - +
    {members.map((member) => { - const roleMeta = ROLE_CHIP[member.role]; + const roleMeta = ROLE_META[member.role]; const RoleIcon = roleMeta.icon; const isSelf = member.user_id === user?.id; const isOwnerRow = member.role === 'owner'; @@ -470,7 +431,7 @@ export function MembersTab() { ) : null} {invitations.length === 0 ? ( - +

    @@ -483,11 +444,11 @@ export function MembersTab() { ) : ( - +

      {invitations.map((inv) => { - const inviteRoleMeta = ROLE_CHIP[inv.role]; + const inviteRoleMeta = ROLE_META[inv.role]; const InviteRoleIcon = inviteRoleMeta.icon; return (
    • -
+
); } diff --git a/src/components/settings/password-form.tsx b/src/components/settings/password-form.tsx index 1e66064..6f6513b 100644 --- a/src/components/settings/password-form.tsx +++ b/src/components/settings/password-form.tsx @@ -81,7 +81,7 @@ export function PasswordForm() { }; return ( - + @@ -144,7 +144,7 @@ export function PasswordForm() {
{confirmError && ( -

+

{confirmError}

)} diff --git a/src/components/settings/profile-form.tsx b/src/components/settings/profile-form.tsx index 7a0d794..20593f8 100644 --- a/src/components/settings/profile-form.tsx +++ b/src/components/settings/profile-form.tsx @@ -14,13 +14,8 @@ import { AvatarFallback, AvatarImage, } from '@/components/ui/avatar'; -import { - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, -} from '@/components/ui/card'; +import { Card, CardContent } from '@/components/ui/card'; +import { SettingsPanelHead } from './settings-panel-head'; const MAX_AVATAR_BYTES = 2 * 1024 * 1024; const ALLOWED_MIME = new Set([ @@ -210,17 +205,14 @@ export function ProfileForm() { : '—'; return ( - - - Profile - - How you show up across the app. Your avatar and name appear in the - header, sidebar, and anywhere your teammates see you. - - - - -
+
+ + + + {/* Avatar row */}
@@ -297,7 +289,7 @@ export function ProfileForm() { required /> {emailChangePending && ( -

+

Check the inbox for {profile?.email} and{' '} @@ -309,7 +301,7 @@ export function ProfileForm() {

{/* Read-only block */} -
+

Account details

@@ -340,20 +332,22 @@ export function ProfileForm() {

)} -
- -
- - - + + + +
+ +
+ +
); } diff --git a/src/components/settings/role-meta.ts b/src/components/settings/role-meta.ts new file mode 100644 index 0000000..04a3fa5 --- /dev/null +++ b/src/components/settings/role-meta.ts @@ -0,0 +1,51 @@ +import { + Crown, + Shield, + UserCog, + UserIcon, + type LucideIcon, +} from 'lucide-react'; + +import type { AccountRole } from '@/lib/auth/roles'; +import type { ChipVariant } from './settings-chip'; + +/** + * Single source of truth for per-role chip metadata across settings + * surfaces (the Overview identity chip and the Members roster/invite + * chips). Previously duplicated in both files; hoisted here so a label, + * icon, or colour change lands once. + * + * `variant` drives the token-based ; `className` is the + * inline Tailwind string the Members tab applies to its own spans. + */ +export const ROLE_META: Record< + AccountRole, + { icon: LucideIcon; label: string; variant: ChipVariant; className: string } +> = { + owner: { + icon: Crown, + label: 'Owner', + variant: 'owner', + className: 'border-amber-500/40 bg-amber-500/10 text-amber-300', + }, + admin: { + icon: Shield, + label: 'Admin', + variant: 'admin', + className: 'border-primary/40 bg-primary/10 text-primary', + }, + agent: { + icon: UserCog, + label: 'Agent', + variant: 'muted', + className: 'border-border bg-muted text-muted-foreground', + }, + viewer: { + icon: UserIcon, + label: 'Viewer', + variant: 'muted', + // Outline-only so it stays quieter than the filled Agent chip in + // both modes — bg-card would blend into a card surface in light mode. + className: 'border-border bg-transparent text-muted-foreground', + }, +}; diff --git a/src/components/settings/security-panel.tsx b/src/components/settings/security-panel.tsx new file mode 100644 index 0000000..736a149 --- /dev/null +++ b/src/components/settings/security-panel.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { PasswordForm } from './password-form'; +import { SessionsCard } from './sessions-card'; +import { SettingsPanelHead } from './settings-panel-head'; + +/** + * "Login & security" section — groups the former Profile-tab password + * and active-sessions cards into their own dedicated home. + */ +export function SecurityPanel() { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/src/components/settings/sessions-card.tsx b/src/components/settings/sessions-card.tsx index d8ccded..6d63898 100644 --- a/src/components/settings/sessions-card.tsx +++ b/src/components/settings/sessions-card.tsx @@ -49,7 +49,7 @@ export function SessionsCard() { return ( <> - + diff --git a/src/components/settings/settings-chip.tsx b/src/components/settings/settings-chip.tsx new file mode 100644 index 0000000..7827698 --- /dev/null +++ b/src/components/settings/settings-chip.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +/** + * Small status / role pill used across the settings redesign + * (Overview tiles, WhatsApp banner, the "Active" appearance markers). + * + * Status colours (emerald = good, amber = attention) follow the same + * Tailwind palette the members tab already uses for role chips — they + * are semantic accents, not neutrals, so they're intentionally not + * tokenized. Neutrals stay on design tokens. + */ +export type ChipVariant = 'owner' | 'admin' | 'ok' | 'warn' | 'muted'; + +const VARIANTS: Record = { + owner: 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-300', + admin: 'border-primary-soft-2 bg-primary-soft text-primary', + ok: 'border-emerald-500/35 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300', + warn: 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-300', + muted: 'border-border bg-muted text-muted-foreground', +}; + +export function SettingsChip({ + variant = 'muted', + className, + children, +}: { + variant?: ChipVariant; + className?: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** A small live status dot (e.g. WhatsApp connected indicator). */ +export function StatusDot({ + tone = 'ok', + className, +}: { + tone?: 'ok' | 'muted'; + className?: string; +}) { + return ( + + ); +} diff --git a/src/components/settings/settings-overview.tsx b/src/components/settings/settings-overview.tsx new file mode 100644 index 0000000..73b5f5d --- /dev/null +++ b/src/components/settings/settings-overview.tsx @@ -0,0 +1,289 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; +import { ChevronRight, Loader2 } from 'lucide-react'; + +import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; +import { useTheme } from '@/hooks/use-theme'; +import { THEMES } from '@/lib/themes'; +import { CURRENCIES } from '@/lib/currency'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Card } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; + +import { SECTION_META, type SettingsSection } from './settings-sections'; +import { SettingsChip, StatusDot } from './settings-chip'; +import { ROLE_META } from './role-meta'; + +interface OverviewCounts { + members: number | null; + pendingInvites: number | null; + templates: number | null; + templatesPending: number | null; + tags: number | null; + customFields: number | null; +} + +interface WhatsAppStatus { + configured: boolean; + connected: boolean; +} + +export function SettingsOverview({ + onSelect, +}: { + onSelect: (section: SettingsSection) => void; +}) { + const { user, profile, accountId, accountRole, defaultCurrency, canManageMembers } = + useAuth(); + const { mode, theme } = useTheme(); + + const [counts, setCounts] = useState(null); + const [countsLoading, setCountsLoading] = useState(true); + // WhatsApp status is tracked separately: its health check decrypts the + // token and pings Meta, which is far slower than the cheap count + // queries. Gating it independently keeps a slow/flaky Meta round-trip + // from blanking the rest of the landing. + const [whatsapp, setWhatsapp] = useState(null); + const [whatsappLoading, setWhatsappLoading] = useState(true); + + useEffect(() => { + if (!user || !accountId) return; + let cancelled = false; + const supabase = createClient(); + const userId = user.id; + const acctId = accountId; + + // Cheap counts — resolve fast, render immediately. + (async () => { + setCountsLoading(true); + const [membersRes, invitesRes, templatesTotal, templatesPending, tagsRes, fieldsRes] = + await Promise.allSettled([ + fetch('/api/account/members', { cache: 'no-store' }).then((r) => r.json()), + canManageMembers + ? fetch('/api/account/invitations', { cache: 'no-store' }).then((r) => + r.json(), + ) + : Promise.resolve(null), + supabase + .from('message_templates') + .select('id', { count: 'exact', head: true }) + .eq('user_id', userId), + supabase + .from('message_templates') + .select('id', { count: 'exact', head: true }) + .eq('user_id', userId) + .eq('status', 'PENDING'), + supabase + .from('tags') + .select('id', { count: 'exact', head: true }) + .eq('user_id', userId), + supabase.from('custom_fields').select('id', { count: 'exact', head: true }), + ]); + + if (cancelled) return; + + const members = + membersRes.status === 'fulfilled' && Array.isArray(membersRes.value?.members) + ? membersRes.value.members.length + : null; + const pendingInvites = + invitesRes.status === 'fulfilled' && + invitesRes.value && + Array.isArray(invitesRes.value.invitations) + ? invitesRes.value.invitations.length + : null; + + setCounts({ + members, + pendingInvites, + templates: + templatesTotal.status === 'fulfilled' + ? templatesTotal.value.count ?? null + : null, + templatesPending: + templatesPending.status === 'fulfilled' + ? templatesPending.value.count ?? null + : null, + tags: tagsRes.status === 'fulfilled' ? tagsRes.value.count ?? null : null, + customFields: + fieldsRes.status === 'fulfilled' ? fieldsRes.value.count ?? null : null, + }); + setCountsLoading(false); + })(); + + // WhatsApp connection status — slower, independent. + (async () => { + setWhatsappLoading(true); + const [row, health] = await Promise.allSettled([ + supabase + .from('whatsapp_config') + .select('phone_number_id') + .eq('account_id', acctId) + .maybeSingle(), + fetch('/api/whatsapp/config', { cache: 'no-store' }).then((r) => r.json()), + ]); + if (cancelled) return; + setWhatsapp({ + configured: row.status === 'fulfilled' && !!row.value.data?.phone_number_id, + connected: health.status === 'fulfilled' && !!health.value?.connected, + }); + setWhatsappLoading(false); + })(); + + return () => { + cancelled = true; + }; + }, [user, accountId, canManageMembers]); + + const displayName = profile?.full_name || profile?.email || 'Your account'; + const initial = (profile?.full_name || profile?.email || 'U').charAt(0).toUpperCase(); + const roleMeta = accountRole ? ROLE_META[accountRole] : null; + const RoleIcon = roleMeta?.icon; + + const currencyLabel = + CURRENCIES.find((c) => c.code === defaultCurrency)?.label ?? defaultCurrency; + const themeName = THEMES.find((t) => t.id === theme)?.name ?? theme; + const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + + // Per-tile loading + subtitle. `null` counts render as a graceful + // fallback so a single failed query never blanks a tile. + const tiles: { + section: SettingsSection; + loading: boolean; + subtitle: ReactNode; + }[] = [ + { + section: 'whatsapp', + loading: whatsappLoading, + subtitle: !whatsapp?.configured ? ( + 'Not set up yet' + ) : whatsapp.connected ? ( + <> + Connected + + ) : ( + <> + Needs reconnecting + + ), + }, + { + section: 'members', + loading: countsLoading, + subtitle: + counts?.members == null + ? 'View team members' + : `${counts.members} member${counts.members === 1 ? '' : 's'}${ + counts.pendingInvites + ? ` · ${counts.pendingInvites} pending invite${ + counts.pendingInvites === 1 ? '' : 's' + }` + : '' + }`, + }, + { + section: 'templates', + loading: countsLoading, + subtitle: + counts?.templates == null + ? 'Manage message templates' + : `${counts.templates} template${counts.templates === 1 ? '' : 's'}${ + counts.templatesPending + ? ` · ${counts.templatesPending} pending review` + : '' + }`, + }, + { + section: 'deals', + loading: false, + subtitle: `${defaultCurrency} — ${currencyLabel}`, + }, + { + section: 'fields', + loading: countsLoading, + subtitle: + counts?.tags == null && counts?.customFields == null + ? 'Tags and custom fields' + : `${counts?.tags ?? 0} tag${counts?.tags === 1 ? '' : 's'} · ${ + counts?.customFields ?? 0 + } custom field${counts?.customFields === 1 ? '' : 's'}`, + }, + { + section: 'appearance', + loading: false, + subtitle: `${cap(mode)} mode · ${themeName} accent`, + }, + ]; + + return ( +
+ {/* Identity */} + + + {profile?.avatar_url ? ( + + ) : null} + + {initial} + + +
+
+ {displayName} +
+ {profile?.email ? ( +
+ {profile.email} +
+ ) : null} +
+ {roleMeta && RoleIcon ? ( + + + {roleMeta.label} + + ) : null} +
+ + {/* Status tiles */} +
+ {tiles.map(({ section, loading, subtitle }) => { + const meta = SECTION_META[section]; + const Icon = meta.icon; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/settings/settings-panel-head.tsx b/src/components/settings/settings-panel-head.tsx new file mode 100644 index 0000000..3c3deb9 --- /dev/null +++ b/src/components/settings/settings-panel-head.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +/** + * Section header shown at the top of every settings panel — a title, + * a one-line description, and an optional right-aligned action (e.g. + * "New template", "Invite member"). Mirrors the mockup's `.panel-head`. + */ +export function SettingsPanelHead({ + title, + description, + action, + className, +}: { + title: string; + description?: ReactNode; + action?: ReactNode; + className?: string; +}) { + return ( +
+
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {action ?
{action}
: null} +
+ ); +} diff --git a/src/components/settings/settings-rail.tsx b/src/components/settings/settings-rail.tsx new file mode 100644 index 0000000..384ff46 --- /dev/null +++ b/src/components/settings/settings-rail.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useEffect, useRef, type ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; +import { + RAIL_GROUPS, + SECTION_META, + SETTINGS_SECTIONS, + type SettingsSection, +} from './settings-sections'; + +// Width at/above which the rail is a vertical column (already in view, so +// no auto-scroll needed). Mirrors the Tailwind `lg:` breakpoint that +// drives the row→column switch in the markup below — keep the two in sync. +const RAIL_DESKTOP_MIN_PX = 1024; + +/** + * The settings left rail — grouped, vertical on desktop and a + * horizontal scroller on narrow screens (mirrors the mockup's ≤920px + * behaviour). The active item auto-scrolls into view when the rail is + * horizontal so a deep-linked section is never off-screen. + */ +export function SettingsRail({ + active, + onSelect, + hints, +}: { + active: SettingsSection; + onSelect: (section: SettingsSection) => void; + hints?: Partial>; +}) { + const activeRef = useRef(null); + + // When horizontal (mobile), keep the active chip in view. On desktop + // the rail is a static column, so skip. + useEffect(() => { + if (typeof window === 'undefined') return; + if (window.matchMedia(`(min-width: ${RAIL_DESKTOP_MIN_PX}px)`).matches) return; + activeRef.current?.scrollIntoView({ + inline: 'center', + block: 'nearest', + behavior: 'smooth', + }); + }, [active]); + + return ( + + ); +} diff --git a/src/components/settings/settings-sections.ts b/src/components/settings/settings-sections.ts new file mode 100644 index 0000000..6aca9ca --- /dev/null +++ b/src/components/settings/settings-sections.ts @@ -0,0 +1,78 @@ +import { + Coins, + FileText, + LayoutGrid, + Palette, + PlugZap, + Shield, + Tags, + User, + UsersRound, + type LucideIcon, +} from 'lucide-react'; + +/** + * Settings information architecture for the redesigned page. + * + * The flat tab strip became a grouped left rail with a new Overview + * landing. The URL query param stays `?tab=` (deep-linkable, and it + * keeps the existing links in sidebar.tsx / header.tsx working) — we + * just map the old values onto the new sections. + */ +export const SETTINGS_SECTIONS = [ + 'overview', + 'profile', + 'security', + 'appearance', + 'whatsapp', + 'templates', + 'fields', + 'deals', + 'members', +] as const; + +export type SettingsSection = (typeof SETTINGS_SECTIONS)[number]; + +export const DEFAULT_SECTION: SettingsSection = 'overview'; + +/** Rail grouping. `adminOnly` items are hidden for non-admins. */ +export interface SectionMeta { + id: SettingsSection; + label: string; + icon: LucideIcon; + group: 'top' | 'account' | 'workspace'; +} + +export const SECTION_META: Record = { + overview: { id: 'overview', label: 'Overview', icon: LayoutGrid, group: 'top' }, + profile: { id: 'profile', label: 'Your profile', icon: User, group: 'account' }, + security: { id: 'security', label: 'Login & security', icon: Shield, group: 'account' }, + appearance: { id: 'appearance', label: 'Appearance', icon: Palette, group: 'account' }, + whatsapp: { id: 'whatsapp', label: 'WhatsApp', icon: PlugZap, group: 'workspace' }, + templates: { id: 'templates', label: 'Templates', icon: FileText, group: 'workspace' }, + fields: { id: 'fields', label: 'Fields & tags', icon: Tags, group: 'workspace' }, + deals: { id: 'deals', label: 'Deals & currency', icon: Coins, group: 'workspace' }, + members: { id: 'members', label: 'Team members', icon: UsersRound, group: 'workspace' }, +}; + +export const RAIL_GROUPS: { label: string | null; group: SectionMeta['group'] }[] = [ + { label: null, group: 'top' }, + { label: 'Account', group: 'account' }, + { label: 'Workspace', group: 'workspace' }, +]; + +function isSection(value: string | null): value is SettingsSection { + return !!value && (SETTINGS_SECTIONS as readonly string[]).includes(value); +} + +/** + * Resolve a raw `?tab=` value to a section. Legacy tabs from the old + * flat layout collapse onto their new home (Tags + Custom fields → the + * merged "Fields & tags" section). Anything unknown falls back to the + * Overview landing. + */ +export function resolveSection(raw: string | null): SettingsSection { + if (raw === 'tags' || raw === 'custom-fields') return 'fields'; + if (isSection(raw)) return raw; + return DEFAULT_SECTION; +} diff --git a/src/components/settings/tag-manager.tsx b/src/components/settings/tag-manager.tsx index b23fdcf..26e883f 100644 --- a/src/components/settings/tag-manager.tsx +++ b/src/components/settings/tag-manager.tsx @@ -2,13 +2,18 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { Plus, X, Loader2 } from 'lucide-react'; +import { Loader2, Plus, Tag as TagIcon, X } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent } from '@/components/ui/card'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { Dialog, DialogContent, @@ -17,6 +22,7 @@ import { DialogDescription, DialogFooter, } from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; import type { Tag } from '@/types'; const PRESET_COLORS = [ @@ -30,13 +36,17 @@ const PRESET_COLORS = [ { name: 'Pink', value: '#ec4899' }, ]; +/** + * Tags card — colour-coded contact labels. Creation is an inline row + * (name + colour swatch + Add); deletion goes through a confirmation + * dialog since it detaches the tag from every contact. + */ export function TagManager() { const supabase = createClient(); const { user, accountId, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [tags, setTags] = useState([]); - const [dialogOpen, setDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tagToDelete, setTagToDelete] = useState(null); const [saving, setSaving] = useState(false); @@ -57,7 +67,6 @@ export function TagManager() { async function fetchTags(userId: string) { try { setLoading(true); - const { data, error } = await supabase .from('tags') .select('*') @@ -87,22 +96,21 @@ export function TagManager() { return; } - const { error } = await supabase - .from('tags') - .insert({ - user_id: user.id, - account_id: accountId, - name: newTagName.trim(), - color: selectedColor, - }); + // account_id is mandatory on every account-scoped insert (NOT + // NULL + RLS, no DB default). + const { error } = await supabase.from('tags').insert({ + user_id: user.id, + account_id: accountId, + name: newTagName.trim(), + color: selectedColor, + }); if (error) throw error; - toast.success('Tag created successfully'); - setDialogOpen(false); + toast.success('Tag created'); setNewTagName(''); setSelectedColor(PRESET_COLORS[3].value); - if (user) await fetchTags(user.id); + await fetchTags(user.id); } catch (err) { console.error('Create error:', err); toast.error('Failed to create tag'); @@ -140,185 +148,129 @@ export function TagManager() { } } - if (loading) { - return ( -
- -
- ); - } - return ( -
-
-
-

Tags

-

Organize your contacts with color-coded tags.

-
- -
- - {tags.length === 0 ? ( - - -

No tags yet.

-

Create tags to categorize your contacts.

-
-
- ) : ( - - -
- {tags.map((tag) => ( - + + + + + Tags + + + Colour-coded labels for grouping and filtering contacts. + + + + {loading ? ( +
+ +
+ ) : ( + <> + {tags.length > 0 ? ( +
+ {tags.map((tag) => ( - {tag.name} - - - ))} -
-
-
- )} + + {tag.name} + + + ))} +
+ ) : ( +

+ No tags yet — create your first one below. +

+ )} - {/* New Tag Dialog */} - - - - New Tag - - Create a new tag with a name and color. - - - -
-
- + {/* Inline create row */} +
setNewTagName(e.target.value)} - className="bg-muted border-border text-foreground placeholder:text-muted-foreground" onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }} + disabled={saving} + maxLength={40} + className="min-w-[180px] flex-1" /> -
- -
- -
+
{PRESET_COLORS.map((color) => (
-
- - {/* Preview */} -
- -
- - - {newTagName || 'Tag Name'} - -
-
-
- - - - - - -
+ ) : ( + + )} + Add tag + +
+ + )} +
- {/* Delete Confirmation Dialog */} + {/* Delete confirmation */} - + - Delete Tag - - Are you sure you want to delete the tag "{tagToDelete?.name}"? This will remove - it from all contacts. This action cannot be undone. + Delete tag + + Delete the tag "{tagToDelete?.name}"? This removes it + from all contacts and cannot be undone. - + -
+ ); } diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx index b145b04..f36bb37 100644 --- a/src/components/settings/template-manager.tsx +++ b/src/components/settings/template-manager.tsx @@ -25,6 +25,7 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; +import { SettingsPanelHead } from './settings-panel-head'; import { Dialog, DialogContent, @@ -479,38 +480,33 @@ export function TemplateManager() { } return ( -
-
-
-

Message Templates

-

- Create message templates and submit them to Meta for approval. Use - "Sync from Meta" to pull templates approved elsewhere. -

-
-
- - -
-
+
+ + + +
+ } + /> {templates.length === 0 ? ( - +

No templates yet.

@@ -524,10 +520,7 @@ export function TemplateManager() { const statusKey = template.status || 'DRAFT'; const status = templateStatusConfig[statusKey]; return ( - +

@@ -1136,6 +1129,6 @@ export function TemplateManager() { -
+
); } diff --git a/src/components/settings/whatsapp-config.tsx b/src/components/settings/whatsapp-config.tsx index 2ae7cee..e6e4023 100644 --- a/src/components/settings/whatsapp-config.tsx +++ b/src/components/settings/whatsapp-config.tsx @@ -21,6 +21,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { SettingsPanelHead } from './settings-panel-head'; import { Accordion, AccordionItem, @@ -360,16 +361,27 @@ export function WhatsAppConfig() { if (loading) { return ( -
- -
+
+ +
+ +
+
); } const showResetBanner = resetReason === 'token_corrupted'; return ( -
+
+ +
{/* Main config form */}
{/* Corrupted-token reset banner */} @@ -536,7 +548,7 @@ export function WhatsAppConfig() { )} {/* API Credentials */} - + API Credentials @@ -648,7 +660,7 @@ export function WhatsAppConfig() { {/* Webhook URL */} - + Webhook Configuration @@ -736,7 +748,7 @@ export function WhatsAppConfig() { {/* Setup Instructions Sidebar */}
- + Setup Instructions @@ -829,5 +841,6 @@ export function WhatsAppConfig() {
+
); }