mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
3b55b3f1d9
commit
a45cbe6822
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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">
|
||||||
|
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 />
|
<CustomFieldsPanel />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
29
src/components/settings/fields-and-tags-panel.tsx
Normal file
29
src/components/settings/fields-and-tags-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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">
|
|
||||||
People with access to this account. Roles control what each
|
|
||||||
teammate can do.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<RequireRole min="admin">
|
<RequireRole min="admin">
|
||||||
<Button
|
<Button onClick={() => setInviteOpen(true)}>
|
||||||
onClick={() => setInviteOpen(true)}
|
|
||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Invite member
|
Invite member
|
||||||
</Button>
|
</Button>
|
||||||
</RequireRole>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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,6 +332,9 @@ export function ProfileForm() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={saving || !dirty || !profile}>
|
<Button type="submit" disabled={saving || !dirty || !profile}>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
@ -353,7 +348,6 @@ export function ProfileForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</section>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/components/settings/role-meta.ts
Normal file
51
src/components/settings/role-meta.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
24
src/components/settings/security-panel.tsx
Normal file
24
src/components/settings/security-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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" />
|
||||||
|
|||||||
64
src/components/settings/settings-chip.tsx
Normal file
64
src/components/settings/settings-chip.tsx
Normal 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,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
289
src/components/settings/settings-overview.tsx
Normal file
289
src/components/settings/settings-overview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/settings/settings-panel-head.tsx
Normal file
41
src/components/settings/settings-panel-head.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/components/settings/settings-rail.tsx
Normal file
109
src/components/settings/settings-rail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/components/settings/settings-sections.ts
Normal file
78
src/components/settings/settings-sections.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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,9 +96,9 @@ 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(),
|
||||||
@ -98,11 +107,10 @@ export function TagManager() {
|
|||||||
|
|
||||||
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,44 +148,25 @@ export function TagManager() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<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" />
|
<Loader2 className="size-6 animate-spin text-primary" />
|
||||||
</div>
|
</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">
|
{tags.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
@ -195,130 +184,93 @@ export function TagManager() {
|
|||||||
/>
|
/>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => confirmDelete(tag)}
|
onClick={() => confirmDelete(tag)}
|
||||||
className="ml-0.5 rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted"
|
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" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
) : (
|
||||||
</Card>
|
<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>
|
|
||||||
|
|
||||||
{/* 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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setDialogOpen(false)}
|
size="sm"
|
||||||
className="border-border text-muted-foreground hover:bg-muted"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={saving}
|
disabled={saving || !newTagName.trim()}
|
||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Creating...
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
'Create Tag'
|
<Plus className="size-4" />
|
||||||
)}
|
)}
|
||||||
|
Add tag
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</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 "{tagToDelete?.name}"? This will remove
|
Delete the tag "{tagToDelete?.name}"? 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
}
|
||||||
"Sync from Meta" to pull templates approved elsewhere.
|
action={
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleSyncFromMeta}
|
onClick={handleSyncFromMeta}
|
||||||
disabled={syncing}
|
disabled={syncing}
|
||||||
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
|
|
||||||
title="Pull approved templates from your Meta WhatsApp Business Account"
|
title="Pull approved templates from your Meta WhatsApp Business Account"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||||
{syncing ? 'Syncing…' : 'Sync from Meta'}
|
{syncing ? 'Syncing…' : 'Sync from Meta'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={openCreate}>
|
||||||
onClick={openCreate}
|
|
||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New Template
|
New Template
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
<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">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="size-6 animate-spin text-primary" />
|
<Loader2 className="size-6 animate-spin text-primary" />
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user