feat(settings): redesign Settings into a grouped rail + Overview landing

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 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-18 15:08:33 +03:00
parent 3b55b3f1d9
commit a45cbe6822
No known key found for this signature in database
20 changed files with 1030 additions and 502 deletions

View File

@ -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<Record<SettingsSection, ReactNode>> = useMemo(
() => ({
appearance: mode.charAt(0).toUpperCase() + mode.slice(1),
deals: defaultCurrency,
}),
[mode, defaultCurrency],
);
const panel: Record<SettingsSection, ReactNode> = {
overview: <SettingsOverview onSelect={go} />,
profile: <ProfileForm />,
security: <SecurityPanel />,
appearance: <AppearancePanel />,
whatsapp: <WhatsAppConfig />,
templates: <TemplateManager />,
fields: <FieldsAndTagsPanel />,
deals: <DealsSettings />,
members: <MembersTab />,
};
return (
<div className="space-y-6">
<div className="mx-auto max-w-[1040px]">
<div>
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Settings
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Manage your profile, WhatsApp® integration, message templates, and
tags.
Everything in one place — your account and your workspace. Pick a
section to manage it.
</p>
</div>
<Tabs value={tab} onValueChange={(v) => onChange(v as TabValue)}>
<TabsList className="border border-border bg-card">
<TabsTrigger
value="profile"
className="data-active:text-primary text-muted-foreground data-active:bg-muted"
>
<User className="size-4" />
Profile
</TabsTrigger>
<TabsTrigger
value="whatsapp"
className="data-active:text-primary text-muted-foreground data-active:bg-muted"
>
<Settings className="size-4" />
WhatsApp Config
</TabsTrigger>
<TabsTrigger
value="templates"
className="data-active:text-primary text-muted-foreground data-active:bg-muted"
>
<MessageSquare className="size-4" />
Templates
</TabsTrigger>
<TabsTrigger
value="tags"
className="data-active:text-primary text-muted-foreground data-active:bg-muted"
>
<Tag className="size-4" />
Tags
</TabsTrigger>
{canEditSettings && (
<TabsTrigger
value="custom-fields"
className="data-active:text-primary text-muted-foreground data-active:bg-muted"
>
<SlidersHorizontal className="size-4" />
Custom Fields
</TabsTrigger>
)}
<TabsTrigger
value="deals"
className="data-active:text-primary text-muted-foreground data-active:bg-muted"
>
<Coins className="size-4" />
Deals
</TabsTrigger>
<TabsTrigger
value="appearance"
className="data-active:text-primary text-muted-foreground data-active:bg-muted"
>
<Palette className="size-4" />
Appearance
</TabsTrigger>
<TabsTrigger
value="members"
className="data-active:text-primary text-muted-foreground data-active:bg-muted"
>
<UsersRound className="size-4" />
Members
</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-6">
<ProfileForm />
<PasswordForm />
<SessionsCard />
</TabsContent>
<TabsContent value="whatsapp">
<WhatsAppConfig />
</TabsContent>
<TabsContent value="templates">
<TemplateManager />
</TabsContent>
<TabsContent value="tags">
<TagManager />
</TabsContent>
{canEditSettings && (
<TabsContent value="custom-fields">
<CustomFieldsSettings />
</TabsContent>
)}
<TabsContent value="deals">
<DealsSettings />
</TabsContent>
<TabsContent value="appearance">
<AppearancePanel />
</TabsContent>
<TabsContent value="members">
<MembersTab />
</TabsContent>
</Tabs>
<div className="mt-6 grid gap-6 lg:grid-cols-[236px_1fr] lg:items-start">
<SettingsRail active={section} onSelect={go} hints={hints} />
<div className="min-w-0">{panel[section]}</div>
</div>
</div>
);
}

View File

@ -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);

View File

@ -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 (
<section className="space-y-8">
<section className="animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Appearance"
description="Set the mode and accent colour used across the app. Saved to this device — try it, it changes live."
/>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-foreground">Mode</h2>
<p className="mt-1 text-sm text-muted-foreground">
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.
</p>
</div>
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<SunMoon className="size-4 text-muted-foreground" />
Mode
</h3>
<div
role="radiogroup"
@ -48,16 +50,11 @@ export function AppearancePanel() {
</div>
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-foreground">
Accent color
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Pick the accent color used across the app — buttons, active
nav, and badges. Saved to this device.
</p>
</div>
<div className="mt-8 space-y-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Palette className="size-4 text-muted-foreground" />
Accent color
</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{THEMES.map((t) => (

View File

@ -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 (
<div className="mt-4 space-y-4">
<div>
<h2 className="text-lg font-semibold text-foreground">Custom fields</h2>
<p className="text-sm text-muted-foreground">
Define extra contact fields (e.g. ZIP code, lead source). They appear
on every contact and in the “Update Contact Field” automation action.
</p>
</div>
<Card className="border-border bg-card ring-0 ring-transparent">
<CardContent className="pt-4">
<CustomFieldsPanel />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<SlidersHorizontal className="size-4 text-primary" />
Custom fields
<SettingsChip variant="admin" className="font-medium">
<Shield />
Admin
</SettingsChip>
</CardTitle>
<CardDescription className="text-muted-foreground">
Extra contact fields (e.g. ZIP code, lead source). They appear on
every contact and in the “Update Contact Field” automation action.
</CardDescription>
</CardHeader>
<CardContent>
<CustomFieldsPanel />
</CardContent>
</Card>
);
}

View File

@ -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 (
<section className="mt-4 space-y-4">
<Card className="bg-card border-border ring-0 ring-transparent">
<section className="animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Deals & currency"
description="The currency used for new deals and for pipeline and dashboard totals."
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<Coins className="size-4 text-primary" />

View File

@ -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 (
<section className="animate-in fade-in-50 space-y-4 duration-200">
<SettingsPanelHead
title="Fields & tags"
description="Two ways to organize contacts: colour-coded tags for quick grouping, and custom fields for structured data."
/>
<TagManager />
{canEditSettings ? <CustomFieldsSettings /> : null}
</section>
);
}

View File

@ -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 (
<div className="space-y-6 mt-4">
{/* Header + invite button */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Account members</h2>
<p className="text-sm text-muted-foreground">
People with access to this account. Roles control what each
teammate can do.
</p>
</div>
<RequireRole min="admin">
<Button
onClick={() => setInviteOpen(true)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Plus className="size-4" />
Invite member
</Button>
</RequireRole>
</div>
<section className="animate-in fade-in-50 space-y-6 duration-200">
<SettingsPanelHead
title="Team members"
description="People with access to this account. Roles control what each teammate can do."
action={
<RequireRole min="admin">
<Button onClick={() => setInviteOpen(true)}>
<Plus className="size-4" />
Invite member
</Button>
</RequireRole>
}
/>
{/* Roster */}
<Card className="bg-card border-border ring-0 ring-transparent">
<Card>
<CardContent className="p-0">
<ul className="divide-y divide-border">
{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 ? (
<Card className="bg-card border-border ring-0 ring-transparent">
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Mail className="size-6 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground">
@ -483,11 +444,11 @@ export function MembersTab() {
</CardContent>
</Card>
) : (
<Card className="bg-card border-border ring-0 ring-transparent">
<Card>
<CardContent className="p-0">
<ul className="divide-y divide-border">
{invitations.map((inv) => {
const inviteRoleMeta = ROLE_CHIP[inv.role];
const inviteRoleMeta = ROLE_META[inv.role];
const InviteRoleIcon = inviteRoleMeta.icon;
return (
<li
@ -587,6 +548,6 @@ export function MembersTab() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</section>
);
}

View File

@ -81,7 +81,7 @@ export function PasswordForm() {
};
return (
<Card className="bg-card/40 border-border">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<KeyRound className="size-4 text-primary" />
@ -144,7 +144,7 @@ export function PasswordForm() {
</div>
{confirmError && (
<p className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-300">
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{confirmError}
</p>
)}

View File

@ -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 (
<Card className="bg-card/40 border-border">
<CardHeader>
<CardTitle className="text-foreground">Profile</CardTitle>
<CardDescription className="text-muted-foreground">
How you show up across the app. Your avatar and name appear in the
header, sidebar, and anywhere your teammates see you.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-6">
<section className="animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Your profile"
description="How you show up across the app. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you."
/>
<form onSubmit={onSubmit} className="space-y-4">
<Card>
<CardContent className="space-y-6">
{/* Avatar row */}
<div className="flex flex-wrap items-center gap-5">
<Avatar size="lg" className="size-16">
@ -297,7 +289,7 @@ export function ProfileForm() {
required
/>
{emailChangePending && (
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-300">
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<Mail className="mt-0.5 size-3.5 shrink-0" />
<span>
Check the inbox for <strong>{profile?.email}</strong> and{' '}
@ -309,7 +301,7 @@ export function ProfileForm() {
</div>
{/* Read-only block */}
<div className="rounded-lg border border-border bg-card/60 p-4">
<div className="rounded-lg border border-border bg-muted p-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Account details
</p>
@ -340,20 +332,22 @@ export function ProfileForm() {
</p>
)}
<div className="flex justify-end">
<Button type="submit" disabled={saving || !dirty || !profile}>
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving…
</>
) : (
'Save changes'
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={saving || !dirty || !profile}>
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving…
</>
) : (
'Save changes'
)}
</Button>
</div>
</form>
</section>
);
}

View File

@ -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 <SettingsChip>; `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',
},
};

View File

@ -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 (
<section className="animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Login & security"
description="Change your password and sign out of your devices. These keep your account safe."
/>
<div className="space-y-4">
<PasswordForm />
<SessionsCard />
</div>
</section>
);
}

View File

@ -49,7 +49,7 @@ export function SessionsCard() {
return (
<>
<Card className="bg-card/40 border-border">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<LogOut className="size-4 text-primary" />

View File

@ -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<ChipVariant, string> = {
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 (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium whitespace-nowrap [&_svg]:size-3.5',
VARIANTS[variant],
className,
)}
>
{children}
</span>
);
}
/** A small live status dot (e.g. WhatsApp connected indicator). */
export function StatusDot({
tone = 'ok',
className,
}: {
tone?: 'ok' | 'muted';
className?: string;
}) {
return (
<span
aria-hidden
className={cn(
'inline-block size-1.5 shrink-0 rounded-full',
tone === 'ok' ? 'bg-emerald-500' : 'bg-muted-foreground',
className,
)}
/>
);
}

View File

@ -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<OverviewCounts | null>(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<WhatsAppStatus | null>(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 ? (
<>
<StatusDot tone="ok" /> Connected
</>
) : (
<>
<StatusDot tone="muted" /> 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 (
<section className="animate-in fade-in-50 duration-200">
{/* Identity */}
<Card className="flex-row items-center gap-4 px-5 py-5">
<Avatar size="lg" className="size-14">
{profile?.avatar_url ? (
<AvatarImage src={profile.avatar_url} alt={displayName} />
) : null}
<AvatarFallback className="bg-primary/10 text-xl text-primary">
{initial}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-base font-semibold text-foreground">
{displayName}
</div>
{profile?.email ? (
<div className="truncate text-sm text-muted-foreground">
{profile.email}
</div>
) : null}
</div>
{roleMeta && RoleIcon ? (
<SettingsChip variant={roleMeta.variant}>
<RoleIcon />
{roleMeta.label}
</SettingsChip>
) : null}
</Card>
{/* Status tiles */}
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{tiles.map(({ section, loading, subtitle }) => {
const meta = SECTION_META[section];
const Icon = meta.icon;
return (
<button
key={section}
type="button"
onClick={() => onSelect(section)}
className={cn(
'group flex items-start gap-3.5 rounded-xl border border-border bg-card p-4 text-left transition-colors',
'hover:border-primary-soft-2 hover:bg-card-2',
)}
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary-soft text-primary">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-foreground">
{meta.label}
</span>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{loading ? (
<>
<Loader2 className="size-3 animate-spin" /> Loading…
</>
) : (
subtitle
)}
</span>
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
);
})}
</div>
</section>
);
}

View File

@ -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 (
<div
className={cn(
'mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between',
className,
)}
>
<div className="min-w-0">
<h2 className="text-lg font-semibold tracking-tight text-foreground">
{title}
</h2>
{description ? (
<p className="mt-1 max-w-[62ch] text-sm text-muted-foreground">
{description}
</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}

View File

@ -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<Record<SettingsSection, ReactNode>>;
}) {
const activeRef = useRef<HTMLButtonElement>(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 (
<nav
aria-label="Settings sections"
className={cn(
'flex gap-1 overflow-x-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'border-b border-border',
'lg:sticky lg:top-0 lg:flex-col lg:overflow-visible lg:border-b-0 lg:pb-0',
)}
>
{RAIL_GROUPS.map(({ label, group }) => {
const items = SETTINGS_SECTIONS.filter(
(s) => SECTION_META[s].group === group,
);
return (
<div
key={group}
className="flex shrink-0 gap-1 lg:flex-col lg:gap-0.5"
>
{label ? (
<div className="hidden px-3 pt-3.5 pb-1.5 text-[11px] font-semibold tracking-[0.09em] text-muted-foreground uppercase lg:block">
{label}
</div>
) : null}
{items.map((s) => {
const meta = SECTION_META[s];
const Icon = meta.icon;
const isActive = s === active;
return (
<button
key={s}
ref={isActive ? activeRef : undefined}
type="button"
onClick={() => onSelect(s)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex shrink-0 items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium whitespace-nowrap transition-colors',
'lg:w-full',
isActive
? 'bg-primary-soft text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1">{meta.label}</span>
{hints?.[s] != null ? (
<span
className={cn(
'hidden items-center gap-1.5 text-xs lg:inline-flex',
isActive ? 'text-primary' : 'text-muted-foreground',
)}
>
{hints[s]}
</span>
) : null}
</button>
);
})}
</div>
);
})}
</nav>
);
}

View File

@ -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<SettingsSection, SectionMeta> = {
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;
}

View File

@ -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<Tag[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tagToDelete, setTagToDelete] = useState<Tag | null>(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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Tags</h2>
<p className="text-sm text-muted-foreground">Organize your contacts with color-coded tags.</p>
</div>
<Button
onClick={() => {
setNewTagName('');
setSelectedColor(PRESET_COLORS[3].value);
setDialogOpen(true);
}}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Plus className="size-4" />
New Tag
</Button>
</div>
{tags.length === 0 ? (
<Card className="bg-card border-border ring-0 ring-transparent">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground text-sm">No tags yet.</p>
<p className="text-muted-foreground text-xs mt-1">Create tags to categorize your contacts.</p>
</CardContent>
</Card>
) : (
<Card className="bg-card border-border ring-0 ring-transparent">
<CardContent className="pt-4">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag.id}
className="group inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
style={{
backgroundColor: `${tag.color}20`,
color: tag.color,
border: `1px solid ${tag.color}40`,
}}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<TagIcon className="size-4 text-primary" />
Tags
</CardTitle>
<CardDescription className="text-muted-foreground">
Colour-coded labels for grouping and filtering contacts.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : (
<>
{tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
className="size-2 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
<button
onClick={() => confirmDelete(tag)}
className="ml-0.5 rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted"
key={tag.id}
className="group inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
style={{
backgroundColor: `${tag.color}20`,
color: tag.color,
border: `1px solid ${tag.color}40`,
}}
>
<X className="size-3" />
</button>
</span>
))}
</div>
</CardContent>
</Card>
)}
<span
className="size-2 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
<button
type="button"
onClick={() => confirmDelete(tag)}
aria-label={`Delete ${tag.name}`}
className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
>
<X className="size-3" />
</button>
</span>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No tags yet — create your first one below.
</p>
)}
{/* New Tag Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-popover border-border sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-popover-foreground">New Tag</DialogTitle>
<DialogDescription className="text-muted-foreground">
Create a new tag with a name and color.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label className="text-muted-foreground">Tag Name</Label>
{/* Inline create row */}
<div className="flex flex-wrap items-center gap-2.5">
<Input
placeholder="e.g. VIP Customer"
placeholder="e.g. Newsletter"
value={newTagName}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Color</Label>
<div className="flex gap-2 flex-wrap">
<div className="flex gap-1.5">
{PRESET_COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setSelectedColor(color.value)}
className="relative size-8 rounded-full transition-transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-popover"
style={{
backgroundColor: color.value,
boxShadow: selectedColor === color.value ? `0 0 0 2px var(--popover), 0 0 0 4px ${color.value}` : 'none',
}}
aria-label={`Use ${color.name}`}
aria-pressed={selectedColor === color.value}
className={cn(
'size-6 rounded-md transition-transform hover:scale-110',
selectedColor === color.value &&
'outline outline-2 outline-offset-2 outline-primary',
)}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
</div>
{/* Preview */}
<div className="space-y-2">
<Label className="text-muted-foreground">Preview</Label>
<div>
<span
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium"
style={{
backgroundColor: `${selectedColor}20`,
color: selectedColor,
border: `1px solid ${selectedColor}40`,
}}
>
<span
className="size-2 rounded-full"
style={{ backgroundColor: selectedColor }}
/>
{newTagName || 'Tag Name'}
</span>
</div>
</div>
</div>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={saving}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{saving ? (
<>
<Button
variant="outline"
size="sm"
onClick={handleCreate}
disabled={saving || !newTagName.trim()}
>
{saving ? (
<Loader2 className="size-4 animate-spin" />
Creating...
</>
) : (
'Create Tag'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<Plus className="size-4" />
)}
Add tag
</Button>
</div>
</>
)}
</CardContent>
{/* Delete Confirmation Dialog */}
{/* Delete confirmation */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-popover border-border sm:max-w-sm">
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-popover-foreground">Delete Tag</DialogTitle>
<DialogDescription className="text-muted-foreground">
Are you sure you want to delete the tag &quot;{tagToDelete?.name}&quot;? This will remove
it from all contacts. This action cannot be undone.
<DialogTitle>Delete tag</DialogTitle>
<DialogDescription>
Delete the tag &quot;{tagToDelete?.name}&quot;? This removes it
from all contacts and cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-popover border-border">
<DialogFooter>
<Button
variant="outline"
variant="ghost"
onClick={() => setDeleteDialogOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleting}
className="bg-red-600 hover:bg-red-700 text-white"
>
{deleting ? (
<>
@ -326,12 +278,12 @@ export function TagManager() {
Deleting...
</>
) : (
'Delete Tag'
'Delete tag'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Card>
);
}

View File

@ -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 (
<div className="space-y-4 mt-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-lg font-semibold text-foreground">Message Templates</h2>
<p className="text-sm text-muted-foreground">
Create message templates and submit them to Meta for approval. Use
&quot;Sync from Meta&quot; to pull templates approved elsewhere.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleSyncFromMeta}
disabled={syncing}
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
title="Pull approved templates from your Meta WhatsApp Business Account"
>
<RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Sync from Meta'}
</Button>
<Button
onClick={openCreate}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Plus className="size-4" />
New Template
</Button>
</div>
</div>
<section className="animate-in fade-in-50 space-y-4 duration-200">
<SettingsPanelHead
title="Message templates"
description={
'Create templates and submit them to Meta for approval. Use "Sync from Meta" to pull templates approved elsewhere.'
}
action={
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleSyncFromMeta}
disabled={syncing}
title="Pull approved templates from your Meta WhatsApp Business Account"
>
<RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Sync from Meta'}
</Button>
<Button onClick={openCreate}>
<Plus className="size-4" />
New Template
</Button>
</div>
}
/>
{templates.length === 0 ? (
<Card className="bg-card border-border ring-0 ring-transparent">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground text-sm">No templates yet.</p>
<p className="text-muted-foreground text-xs mt-1">
@ -524,10 +520,7 @@ export function TemplateManager() {
const statusKey = template.status || 'DRAFT';
const status = templateStatusConfig[statusKey];
return (
<Card
key={template.id}
className="bg-card border-border ring-0 ring-transparent"
>
<Card key={template.id}>
<CardContent className="flex items-start justify-between pt-4">
<div className="space-y-2 min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
@ -1136,6 +1129,6 @@ export function TemplateManager() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</section>
);
}

View File

@ -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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
<section className="animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="WhatsApp connection"
description="Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here."
/>
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
</section>
);
}
const showResetBanner = resetReason === 'token_corrupted';
return (
<div className="grid gap-6 lg:grid-cols-[1fr_380px] mt-4">
<section className="animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="WhatsApp connection"
description="Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here."
/>
<div className="grid gap-6 lg:grid-cols-[1fr_380px]">
{/* Main config form */}
<div className="space-y-6">
{/* Corrupted-token reset banner */}
@ -536,7 +548,7 @@ export function WhatsAppConfig() {
)}
{/* API Credentials */}
<Card className="bg-card border-border ring-0 ring-transparent">
<Card>
<CardHeader>
<CardTitle className="text-foreground">API Credentials</CardTitle>
<CardDescription className="text-muted-foreground">
@ -648,7 +660,7 @@ export function WhatsAppConfig() {
</Card>
{/* Webhook URL */}
<Card className="bg-card border-border ring-0 ring-transparent">
<Card>
<CardHeader>
<CardTitle className="text-foreground">Webhook Configuration</CardTitle>
<CardDescription className="text-muted-foreground">
@ -736,7 +748,7 @@ export function WhatsAppConfig() {
{/* Setup Instructions Sidebar */}
<div>
<Card className="bg-card border-border ring-0 ring-transparent">
<Card>
<CardHeader>
<CardTitle className="text-foreground text-base">Setup Instructions</CardTitle>
<CardDescription className="text-muted-foreground">
@ -829,5 +841,6 @@ export function WhatsAppConfig() {
</Card>
</div>
</div>
</section>
);
}