Merge pull request #265 from ArnasDon/feat/settings-redesign

Settings redesign: grouped rail + Overview landing
This commit is contained in:
Arnas Donauskas 2026-06-18 15:11:41 +03:00 committed by GitHub
commit 7e531e6fbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1030 additions and 502 deletions

View File

@ -1,179 +1,82 @@
'use client'; 'use client';
import { useMemo, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import {
Settings, import { useAuth } from '@/hooks/use-auth';
MessageSquare, import { useTheme } from '@/hooks/use-theme';
Tag, import { SettingsRail } from '@/components/settings/settings-rail';
User, import { SettingsOverview } from '@/components/settings/settings-overview';
Palette, import { ProfileForm } from '@/components/settings/profile-form';
UsersRound, import { SecurityPanel } from '@/components/settings/security-panel';
Coins, import { AppearancePanel } from '@/components/settings/appearance-panel';
SlidersHorizontal,
} from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useCan } from '@/hooks/use-can';
import { WhatsAppConfig } from '@/components/settings/whatsapp-config'; import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
import { TemplateManager } from '@/components/settings/template-manager'; import { TemplateManager } from '@/components/settings/template-manager';
import { TagManager } from '@/components/settings/tag-manager'; import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel';
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 { DealsSettings } from '@/components/settings/deals-settings'; import { DealsSettings } from '@/components/settings/deals-settings';
import { CustomFieldsSettings } from '@/components/settings/custom-fields-settings'; import { MembersTab } from '@/components/settings/members-tab';
import {
const TAB_VALUES = [ resolveSection,
'profile', type SettingsSection,
'whatsapp', } from '@/components/settings/settings-sections';
'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);
}
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { defaultCurrency } = useAuth();
const { mode } = useTheme();
// Custom-field definitions are account-wide config, so editing them is // The URL (`?tab=`) is the single source of truth for the active
// admin+ only — mirror the gate on the Contacts page. The `custom_fields` // section — deep-linkable, and it keeps the existing links in the
// RLS rejects non-admin writes regardless. // app sidebar/header working. Legacy tab values (tags, custom-fields)
const canEditSettings = useCan('edit-settings'); // 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 const go = (next: SettingsSection) => {
// 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 params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set('tab', next); params.set('tab', next);
router.replace(`/settings?${params.toString()}`, { scroll: false }); 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 ( return (
<div className="space-y-6"> <div className="mx-auto max-w-[1040px]">
<div> <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"> <p className="mt-1 text-sm text-muted-foreground">
Manage your profile, WhatsApp® integration, message templates, and Everything in one place — your account and your workspace. Pick a
tags. section to manage it.
</p> </p>
</div> </div>
<Tabs value={tab} onValueChange={(v) => onChange(v as TabValue)}> <div className="mt-6 grid gap-6 lg:grid-cols-[236px_1fr] lg:items-start">
<TabsList className="border border-border bg-card"> <SettingsRail active={section} onSelect={go} hints={hints} />
<TabsTrigger <div className="min-w-0">{panel[section]}</div>
value="profile" </div>
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> </div>
); );
} }

View File

@ -39,6 +39,15 @@
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --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-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8); --radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@ -82,6 +91,7 @@ html[data-mode="dark"] {
--background: oklch(0.13 0.01 260); --background: oklch(0.13 0.01 260);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.01 260); --card: oklch(0.18 0.01 260);
--card-2: oklch(0.205 0.01 260);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0.01 260); --popover: oklch(0.18 0.01 260);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
@ -110,6 +120,7 @@ html[data-mode="light"] {
--background: oklch(0.99 0.002 260); --background: oklch(0.99 0.002 260);
--foreground: oklch(0.21 0.01 260); --foreground: oklch(0.21 0.01 260);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-2: oklch(0.985 0.002 260);
--card-foreground: oklch(0.21 0.01 260); --card-foreground: oklch(0.21 0.01 260);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.21 0.01 260); --popover-foreground: oklch(0.21 0.01 260);

View File

@ -1,10 +1,11 @@
"use client"; "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 { useTheme } from "@/hooks/use-theme";
import { MODES, THEMES, type Mode, type ThemeId } from "@/lib/themes"; import { MODES, THEMES, type Mode, type ThemeId } from "@/lib/themes";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SettingsPanelHead } from "./settings-panel-head";
/** /**
* Appearance panel — light/dark mode + accent-color picker. * Appearance panel — light/dark mode + accent-color picker.
@ -21,16 +22,17 @@ import { cn } from "@/lib/utils";
export function AppearancePanel() { export function AppearancePanel() {
const { theme, setTheme, mode, setMode } = useTheme(); const { theme, setTheme, mode, setMode } = useTheme();
return ( 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 className="space-y-4">
<div> <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<h2 className="text-lg font-semibold text-foreground">Mode</h2> <SunMoon className="size-4 text-muted-foreground" />
<p className="mt-1 text-sm text-muted-foreground"> Mode
Choose light or dark. Light is easier on the eyes in bright </h3>
rooms; dark is the original look. Works with any accent
color below. Saved to this device.
</p>
</div>
<div <div
role="radiogroup" role="radiogroup"
@ -48,16 +50,11 @@ export function AppearancePanel() {
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="mt-8 space-y-4">
<div> <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<h2 className="text-lg font-semibold text-foreground"> <Palette className="size-4 text-muted-foreground" />
Accent color Accent color
</h2> </h3>
<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="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{THEMES.map((t) => ( {THEMES.map((t) => (

View File

@ -1,29 +1,43 @@
'use client'; '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 { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager';
import { SettingsChip } from './settings-chip';
/** /**
* Settings → Custom Fields. Manage the account-wide custom contact field * Settings → Custom Fields card. Manages the account-wide custom
* catalogue (the same panel the Contacts page exposes via a dialog). Writes * contact field catalogue (the same panel the Contacts page exposes
* are admin-gated by the caller and enforced by `custom_fields` RLS. * via a dialog). Writes are admin-gated by the caller and enforced by
* `custom_fields` RLS.
*/ */
export function CustomFieldsSettings() { export function CustomFieldsSettings() {
return ( return (
<div className="mt-4 space-y-4"> <Card>
<div> <CardHeader>
<h2 className="text-lg font-semibold text-foreground">Custom fields</h2> <CardTitle className="flex items-center gap-2 text-foreground">
<p className="text-sm text-muted-foreground"> <SlidersHorizontal className="size-4 text-primary" />
Define extra contact fields (e.g. ZIP code, lead source). They appear Custom fields
on every contact and in the “Update Contact Field” automation action. <SettingsChip variant="admin" className="font-medium">
</p> <Shield />
</div> Admin
</SettingsChip>
<Card className="border-border bg-card ring-0 ring-transparent"> </CardTitle>
<CardContent className="pt-4"> <CardDescription className="text-muted-foreground">
<CustomFieldsPanel /> Extra contact fields (e.g. ZIP code, lead source). They appear on
</CardContent> every contact and in the “Update Contact Field” automation action.
</Card> </CardDescription>
</div> </CardHeader>
<CardContent>
<CustomFieldsPanel />
</CardContent>
</Card>
); );
} }

View File

@ -16,6 +16,7 @@ import {
CardTitle, CardTitle,
CardDescription, CardDescription,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { SettingsPanelHead } from "./settings-panel-head";
/** /**
* Deals settings — account-wide default currency. * Deals settings — account-wide default currency.
@ -67,8 +68,12 @@ export function DealsSettings() {
} }
return ( return (
<section className="mt-4 space-y-4"> <section className="animate-in fade-in-50 duration-200">
<Card className="bg-card border-border ring-0 ring-transparent"> <SettingsPanelHead
title="Deals & currency"
description="The currency used for new deals and for pipeline and dashboard totals."
/>
<Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<Coins className="size-4 text-primary" /> <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 { toast } from 'sonner';
import { import {
AlertTriangle, AlertTriangle,
Crown,
Loader2, Loader2,
Mail, Mail,
MailX, MailX,
Plus, Plus,
Shield,
Trash2, Trash2,
UserCog,
UserIcon,
UsersRound, UsersRound,
} from 'lucide-react'; } from 'lucide-react';
@ -60,6 +56,8 @@ import { RequireRole } from '@/components/auth/require-role';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import type { AccountRole } from '@/lib/auth/roles'; import type { AccountRole } from '@/lib/auth/roles';
import { InviteMemberDialog } from './invite-member-dialog'; import { InviteMemberDialog } from './invite-member-dialog';
import { SettingsPanelHead } from './settings-panel-head';
import { ROLE_META } from './role-meta';
interface Member { interface Member {
user_id: string; 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' }, { value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' },
]; ];
// Per-role chip metadata. The colour scale runs amber (owner — // Per-role chip metadata (icon / label / colour) lives in the shared
// scarce, immutable) → primary (admin — significant) → slate // ROLE_META module so this roster and the Overview identity chip can't
// (agent — operational default) → muted slate (viewer — read- // drift. The colour scale runs amber (owner — scarce, immutable) →
// only). Mirrors the sidebar's ROLE_CHIP so the two surfaces // primary (admin) → muted (agent / viewer).
// 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',
},
};
function fmtDate(iso: string): string { function fmtDate(iso: string): string {
// Match the rest of the dashboard's locale-light formatting. // Match the rest of the dashboard's locale-light formatting.
@ -294,33 +262,26 @@ export function MembersTab() {
} }
return ( return (
<div className="space-y-6 mt-4"> <section className="animate-in fade-in-50 space-y-6 duration-200">
{/* Header + invite button */} <SettingsPanelHead
<div className="flex items-center justify-between"> title="Team members"
<div> description="People with access to this account. Roles control what each teammate can do."
<h2 className="text-lg font-semibold text-foreground">Account members</h2> action={
<p className="text-sm text-muted-foreground"> <RequireRole min="admin">
People with access to this account. Roles control what each <Button onClick={() => setInviteOpen(true)}>
teammate can do. <Plus className="size-4" />
</p> Invite member
</div> </Button>
<RequireRole min="admin"> </RequireRole>
<Button }
onClick={() => setInviteOpen(true)} />
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Plus className="size-4" />
Invite member
</Button>
</RequireRole>
</div>
{/* Roster */} {/* Roster */}
<Card className="bg-card border-border ring-0 ring-transparent"> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
<ul className="divide-y divide-border"> <ul className="divide-y divide-border">
{members.map((member) => { {members.map((member) => {
const roleMeta = ROLE_CHIP[member.role]; const roleMeta = ROLE_META[member.role];
const RoleIcon = roleMeta.icon; const RoleIcon = roleMeta.icon;
const isSelf = member.user_id === user?.id; const isSelf = member.user_id === user?.id;
const isOwnerRow = member.role === 'owner'; const isOwnerRow = member.role === 'owner';
@ -470,7 +431,7 @@ export function MembersTab() {
) : null} ) : null}
{invitations.length === 0 ? ( {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"> <CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Mail className="size-6 text-muted-foreground" /> <Mail className="size-6 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
@ -483,11 +444,11 @@ export function MembersTab() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<Card className="bg-card border-border ring-0 ring-transparent"> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
<ul className="divide-y divide-border"> <ul className="divide-y divide-border">
{invitations.map((inv) => { {invitations.map((inv) => {
const inviteRoleMeta = ROLE_CHIP[inv.role]; const inviteRoleMeta = ROLE_META[inv.role];
const InviteRoleIcon = inviteRoleMeta.icon; const InviteRoleIcon = inviteRoleMeta.icon;
return ( return (
<li <li
@ -587,6 +548,6 @@ export function MembersTab() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </section>
); );
} }

View File

@ -81,7 +81,7 @@ export function PasswordForm() {
}; };
return ( return (
<Card className="bg-card/40 border-border"> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<KeyRound className="size-4 text-primary" /> <KeyRound className="size-4 text-primary" />
@ -144,7 +144,7 @@ export function PasswordForm() {
</div> </div>
{confirmError && ( {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} {confirmError}
</p> </p>
)} )}

View File

@ -14,13 +14,8 @@ import {
AvatarFallback, AvatarFallback,
AvatarImage, AvatarImage,
} from '@/components/ui/avatar'; } from '@/components/ui/avatar';
import { import { Card, CardContent } from '@/components/ui/card';
Card, import { SettingsPanelHead } from './settings-panel-head';
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
const MAX_AVATAR_BYTES = 2 * 1024 * 1024; const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
const ALLOWED_MIME = new Set([ const ALLOWED_MIME = new Set([
@ -210,17 +205,14 @@ export function ProfileForm() {
: '—'; : '—';
return ( return (
<Card className="bg-card/40 border-border"> <section className="animate-in fade-in-50 duration-200">
<CardHeader> <SettingsPanelHead
<CardTitle className="text-foreground">Profile</CardTitle> title="Your profile"
<CardDescription className="text-muted-foreground"> description="How you show up across the app. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you."
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">
</CardDescription> <Card>
</CardHeader> <CardContent className="space-y-6">
<CardContent>
<form onSubmit={onSubmit} className="space-y-6">
{/* Avatar row */} {/* Avatar row */}
<div className="flex flex-wrap items-center gap-5"> <div className="flex flex-wrap items-center gap-5">
<Avatar size="lg" className="size-16"> <Avatar size="lg" className="size-16">
@ -297,7 +289,7 @@ export function ProfileForm() {
required required
/> />
{emailChangePending && ( {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" /> <Mail className="mt-0.5 size-3.5 shrink-0" />
<span> <span>
Check the inbox for <strong>{profile?.email}</strong> and{' '} Check the inbox for <strong>{profile?.email}</strong> and{' '}
@ -309,7 +301,7 @@ export function ProfileForm() {
</div> </div>
{/* Read-only block */} {/* 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"> <p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Account details Account details
</p> </p>
@ -340,20 +332,22 @@ export function ProfileForm() {
</p> </p>
)} )}
<div className="flex justify-end"> </CardContent>
<Button type="submit" disabled={saving || !dirty || !profile}> </Card>
{saving ? (
<> <div className="flex justify-end">
<Loader2 className="size-4 animate-spin" /> <Button type="submit" disabled={saving || !dirty || !profile}>
Saving… {saving ? (
</> <>
) : ( <Loader2 className="size-4 animate-spin" />
'Save changes' Saving…
)} </>
</Button> ) : (
</div> 'Save changes'
</form> )}
</CardContent> </Button>
</Card> </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 ( return (
<> <>
<Card className="bg-card/40 border-border"> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<LogOut className="size-4 text-primary" /> <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 { useEffect, useState } from 'react';
import { toast } from 'sonner'; 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 { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import {
import { Card, CardContent } from '@/components/ui/card'; Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -17,6 +22,7 @@ import {
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import type { Tag } from '@/types'; import type { Tag } from '@/types';
const PRESET_COLORS = [ const PRESET_COLORS = [
@ -30,13 +36,17 @@ const PRESET_COLORS = [
{ name: 'Pink', value: '#ec4899' }, { 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() { export function TagManager() {
const supabase = createClient(); const supabase = createClient();
const { user, accountId, loading: authLoading } = useAuth(); const { user, accountId, loading: authLoading } = useAuth();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null); const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -57,7 +67,6 @@ export function TagManager() {
async function fetchTags(userId: string) { async function fetchTags(userId: string) {
try { try {
setLoading(true); setLoading(true);
const { data, error } = await supabase const { data, error } = await supabase
.from('tags') .from('tags')
.select('*') .select('*')
@ -87,22 +96,21 @@ export function TagManager() {
return; return;
} }
const { error } = await supabase // account_id is mandatory on every account-scoped insert (NOT
.from('tags') // NULL + RLS, no DB default).
.insert({ const { error } = await supabase.from('tags').insert({
user_id: user.id, user_id: user.id,
account_id: accountId, account_id: accountId,
name: newTagName.trim(), name: newTagName.trim(),
color: selectedColor, color: selectedColor,
}); });
if (error) throw error; if (error) throw error;
toast.success('Tag created successfully'); toast.success('Tag created');
setDialogOpen(false);
setNewTagName(''); setNewTagName('');
setSelectedColor(PRESET_COLORS[3].value); setSelectedColor(PRESET_COLORS[3].value);
if (user) await fetchTags(user.id); await fetchTags(user.id);
} catch (err) { } catch (err) {
console.error('Create error:', err); console.error('Create error:', err);
toast.error('Failed to create tag'); 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 ( return (
<div className="space-y-4 mt-4"> <Card>
<div className="flex items-center justify-between"> <CardHeader>
<div> <CardTitle className="flex items-center gap-2 text-foreground">
<h2 className="text-lg font-semibold text-foreground">Tags</h2> <TagIcon className="size-4 text-primary" />
<p className="text-sm text-muted-foreground">Organize your contacts with color-coded tags.</p> Tags
</div> </CardTitle>
<Button <CardDescription className="text-muted-foreground">
onClick={() => { Colour-coded labels for grouping and filtering contacts.
setNewTagName(''); </CardDescription>
setSelectedColor(PRESET_COLORS[3].value); </CardHeader>
setDialogOpen(true); <CardContent className="space-y-4">
}} {loading ? (
className="bg-primary hover:bg-primary/90 text-primary-foreground" <div className="flex items-center justify-center py-8">
> <Loader2 className="size-6 animate-spin text-primary" />
<Plus className="size-4" /> </div>
New Tag ) : (
</Button> <>
</div> {tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{tags.length === 0 ? ( {tags.map((tag) => (
<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`,
}}
>
<span <span
className="size-2 rounded-full" key={tag.id}
style={{ backgroundColor: tag.color }} className="group inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
/> style={{
{tag.name} backgroundColor: `${tag.color}20`,
<button color: tag.color,
onClick={() => confirmDelete(tag)} border: `1px solid ${tag.color}40`,
className="ml-0.5 rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted" }}
> >
<X className="size-3" /> <span
</button> className="size-2 rounded-full"
</span> style={{ backgroundColor: tag.color }}
))} />
</div> {tag.name}
</CardContent> <button
</Card> 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 */} {/* Inline create row */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <div className="flex flex-wrap items-center gap-2.5">
<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>
<Input <Input
placeholder="e.g. VIP Customer" placeholder="e.g. Newsletter"
value={newTagName} value={newTagName}
onChange={(e) => setNewTagName(e.target.value)} onChange={(e) => setNewTagName(e.target.value)}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate(); if (e.key === 'Enter') handleCreate();
}} }}
disabled={saving}
maxLength={40}
className="min-w-[180px] flex-1"
/> />
</div> <div className="flex gap-1.5">
<div className="space-y-2">
<Label className="text-muted-foreground">Color</Label>
<div className="flex gap-2 flex-wrap">
{PRESET_COLORS.map((color) => ( {PRESET_COLORS.map((color) => (
<button <button
key={color.value} key={color.value}
type="button"
onClick={() => setSelectedColor(color.value)} 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" aria-label={`Use ${color.name}`}
style={{ aria-pressed={selectedColor === color.value}
backgroundColor: color.value, className={cn(
boxShadow: selectedColor === color.value ? `0 0 0 2px var(--popover), 0 0 0 4px ${color.value}` : 'none', '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} title={color.name}
/> />
))} ))}
</div> </div>
</div> <Button
variant="outline"
{/* Preview */} size="sm"
<div className="space-y-2"> onClick={handleCreate}
<Label className="text-muted-foreground">Preview</Label> disabled={saving || !newTagName.trim()}
<div> >
<span {saving ? (
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 ? (
<>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Creating... ) : (
</> <Plus className="size-4" />
) : ( )}
'Create Tag' Add tag
)} </Button>
</Button> </div>
</DialogFooter> </>
</DialogContent> )}
</Dialog> </CardContent>
{/* Delete Confirmation Dialog */} {/* Delete confirmation */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-popover border-border sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">Delete Tag</DialogTitle> <DialogTitle>Delete tag</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription>
Are you sure you want to delete the tag &quot;{tagToDelete?.name}&quot;? This will remove Delete the tag &quot;{tagToDelete?.name}&quot;? This removes it
it from all contacts. This action cannot be undone. from all contacts and cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="bg-popover border-border"> <DialogFooter>
<Button <Button
variant="outline" variant="ghost"
onClick={() => setDeleteDialogOpen(false)} onClick={() => setDeleteDialogOpen(false)}
className="border-border text-muted-foreground hover:bg-muted" disabled={deleting}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive"
onClick={handleDelete} onClick={handleDelete}
disabled={deleting} disabled={deleting}
className="bg-red-600 hover:bg-red-700 text-white"
> >
{deleting ? ( {deleting ? (
<> <>
@ -326,12 +278,12 @@ export function TagManager() {
Deleting... Deleting...
</> </>
) : ( ) : (
'Delete Tag' 'Delete tag'
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </Card>
); );
} }

View File

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

View File

@ -21,6 +21,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { SettingsPanelHead } from './settings-panel-head';
import { import {
Accordion, Accordion,
AccordionItem, AccordionItem,
@ -360,16 +361,27 @@ export function WhatsAppConfig() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <section className="animate-in fade-in-50 duration-200">
<Loader2 className="size-6 animate-spin text-primary" /> <SettingsPanelHead
</div> 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'; const showResetBanner = resetReason === 'token_corrupted';
return ( 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 */} {/* Main config form */}
<div className="space-y-6"> <div className="space-y-6">
{/* Corrupted-token reset banner */} {/* Corrupted-token reset banner */}
@ -536,7 +548,7 @@ export function WhatsAppConfig() {
)} )}
{/* API Credentials */} {/* API Credentials */}
<Card className="bg-card border-border ring-0 ring-transparent"> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">API Credentials</CardTitle> <CardTitle className="text-foreground">API Credentials</CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
@ -648,7 +660,7 @@ export function WhatsAppConfig() {
</Card> </Card>
{/* Webhook URL */} {/* Webhook URL */}
<Card className="bg-card border-border ring-0 ring-transparent"> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Webhook Configuration</CardTitle> <CardTitle className="text-foreground">Webhook Configuration</CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
@ -736,7 +748,7 @@ export function WhatsAppConfig() {
{/* Setup Instructions Sidebar */} {/* Setup Instructions Sidebar */}
<div> <div>
<Card className="bg-card border-border ring-0 ring-transparent"> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-foreground text-base">Setup Instructions</CardTitle> <CardTitle className="text-foreground text-base">Setup Instructions</CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
@ -829,5 +841,6 @@ export function WhatsAppConfig() {
</Card> </Card>
</div> </div>
</div> </div>
</section>
); );
} }