Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,198 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { Bot, RotateCcw, Send, Loader2, UserCircle2, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface Turn {
role: 'user' | 'assistant';
content: string;
/** assistant-only: the agent signalled a human handoff on this turn. */
handoff?: boolean;
}
export function AiPlayground({ onGoToSetup }: { onGoToSetup?: () => void }) {
const [turns, setTurns] = useState<Turn[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [turns, sending]);
const send = async () => {
const text = input.trim();
if (!text || sending) return;
const next: Turn[] = [...turns, { role: 'user', content: text }];
setTurns(next);
setInput('');
setSending(true);
try {
const res = await fetch('/api/ai/playground', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Send only role+content — the server ignores anything else.
body: JSON.stringify({
messages: next.map((t) => ({ role: t.role, content: t.content })),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.code === 'ai_not_configured') {
toast.error('No agent configured yet — finish Setup first.');
} else {
toast.error(data.error ?? "Couldn't get a reply.");
}
// Roll the unsent user turn back so the transcript stays clean.
setTurns(turns);
setInput(text);
return;
}
setTurns([
...next,
{
role: 'assistant',
content:
typeof data.reply === 'string' && data.reply.trim()
? data.reply
: '',
handoff: Boolean(data.handoff),
},
]);
} catch {
toast.error("Couldn't reach the agent.");
setTurns(turns);
setInput(text);
} finally {
setSending(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send();
}
};
return (
<div className="flex h-[60vh] min-h-[420px] flex-col rounded-xl border border-border bg-card">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
<span className="text-sm font-medium text-foreground">Playground</span>
<span className="text-xs text-muted-foreground">
— test replies as if you were a customer
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setTurns([])}
disabled={turns.length === 0 || sending}
className="text-muted-foreground"
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> Reset
</Button>
</div>
{/* Transcript */}
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
{turns.length === 0 && (
<div className="flex h-full flex-col items-center justify-center text-center text-sm text-muted-foreground">
<Bot className="mb-2 h-8 w-8 text-muted-foreground/60" />
<p>Send a message to see how your agent would reply.</p>
<p className="mt-1 text-xs">
It uses your knowledge base and behaves exactly like the
auto-reply bot — including handoff.
</p>
{onGoToSetup && (
<Button
variant="link"
size="sm"
onClick={onGoToSetup}
className="mt-1 h-auto p-0 text-xs"
>
Not set up yet? Go to Setup <ArrowRight className="ml-1 h-3 w-3" />
</Button>
)}
</div>
)}
{turns.map((t, i) => (
<div
key={i}
className={cn(
'flex gap-2',
t.role === 'user' ? 'justify-end' : 'justify-start',
)}
>
{t.role === 'assistant' && (
<Bot className="mt-1 h-5 w-5 shrink-0 text-primary" />
)}
<div
className={cn(
'max-w-[80%] rounded-2xl px-3.5 py-2 text-sm',
t.role === 'user'
? 'rounded-br-sm bg-primary text-primary-foreground'
: 'rounded-bl-sm bg-muted text-foreground',
)}
>
{t.content && <p className="whitespace-pre-wrap">{t.content}</p>}
{t.role === 'assistant' && t.handoff && (
<p
className={cn(
'flex items-center gap-1 text-xs text-amber-500',
t.content && 'mt-1.5 border-t border-border/50 pt-1.5',
)}
>
<UserCircle2 className="h-3.5 w-3.5" />
Would hand off to a human here
</p>
)}
</div>
{t.role === 'user' && (
<UserCircle2 className="mt-1 h-5 w-5 shrink-0 text-muted-foreground" />
)}
</div>
))}
{sending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Bot className="h-5 w-5 text-primary" />
<Loader2 className="h-4 w-4 animate-spin" /> Thinking…
</div>
)}
</div>
{/* Composer */}
<div className="flex items-end gap-2 border-t border-border p-3">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a customer message…"
rows={1}
className="flex-1 resize-none rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
/>
<Button
size="sm"
onClick={send}
disabled={!input.trim() || sending}
className="h-9 w-9 shrink-0 p-0"
>
{sending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import type { ReactNode } from "react";
import { useAuth } from "@/hooks/use-auth";
import { hasMinRole, type AccountRole } from "@/lib/auth/roles";
interface RequireRoleProps {
/** Minimum role to render `children`. Uses the standard hierarchy
* owner > admin > agent > viewer. */
min: AccountRole;
/** What to render while the role is below `min` OR while we don't
* yet know the role (`profileLoading` is true). Defaults to
* `null` — most call sites just want the gated element to be
* absent until we're sure. Pass a placeholder if a layout slot
* would collapse and re-flow when the role resolves. */
fallback?: ReactNode;
children: ReactNode;
}
/**
* `<RequireRole min="admin">…</RequireRole>` — conditional render
* helper for UI gated by account role.
*
* Three states:
* 1. profileLoading → render `fallback` (we don't know the role
* yet; fail closed so we never flash the gated content to an
* under-privileged user).
* 2. role ≥ min → render `children`.
* 3. role < min → render `fallback`.
*
* Mirrors the server-side `requireRole(min)` from `@/lib/auth/account`
* so client and server gates stay aligned by construction.
*/
export function RequireRole({
min,
fallback = null,
children,
}: RequireRoleProps) {
const { profileLoading, accountRole } = useAuth();
if (profileLoading) return <>{fallback}</>;
if (!accountRole) return <>{fallback}</>;
if (!hasMinRole(accountRole, min)) return <>{fallback}</>;
return <>{children}</>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button';
import { Loader2, FileText, ArrowRight } from 'lucide-react';
const categoryColors: Record<string, string> = {
Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
Utility: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
Authentication: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
};
interface Step1Props {
selectedTemplate: MessageTemplate | null;
onSelect: (template: MessageTemplate) => void;
onNext: () => void;
onBack: () => void;
}
export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack }: Step1Props) {
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchTemplates() {
try {
const supabase = createClient();
// Only APPROVED templates can be sent via Meta — anything else
// would 400 at broadcast time. Hide them rather than letting
// the user pick a template that will fail.
const { data, error: fetchError } = await supabase
.from('message_templates')
.select('*')
.eq('status', 'APPROVED')
.order('created_at', { ascending: false });
if (fetchError) throw fetchError;
setTemplates(data ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load templates');
} finally {
setLoading(false);
}
}
fetchTemplates();
}, []);
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-foreground">Choose a Template</h2>
<p className="mt-1 text-sm text-muted-foreground">
Select an approved message template for your broadcast.
</p>
</div>
{templates.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-border bg-card/50">
<FileText className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No templates available.</p>
<p className="mt-1 text-xs text-muted-foreground">Create a template in Settings first.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((template) => {
const isSelected = selectedTemplate?.id === template.id;
const catColor = categoryColors[template.category] ?? categoryColors.Utility;
return (
<button
key={template.id}
onClick={() => onSelect(template)}
className={`flex flex-col gap-3 rounded-xl border p-4 text-left transition-all ${
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
: 'border-border bg-card/50 hover:border-border hover:bg-card'
}`}
>
<div className="flex items-start justify-between">
<h3 className="text-sm font-medium text-foreground">{template.name}</h3>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${catColor}`}
>
{template.category}
</span>
</div>
<p className="line-clamp-3 text-xs text-muted-foreground">{template.body_text}</p>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span>{template.language ?? 'en_US'}</span>
{/* Status is omitted on purpose — every template
shown here is already filtered to APPROVED,
so the chip carried no information. */}
</div>
</button>
);
})}
</div>
)}
<div className="flex items-center justify-between border-t border-border pt-4">
<Button variant="outline" onClick={onBack} className="border-border text-muted-foreground">
Back
</Button>
<Button
onClick={onNext}
disabled={!selectedTemplate}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Next
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,472 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { createClient } from '@/lib/supabase/client';
import { CustomField, Tag } from '@/types';
import { Button } from '@/components/ui/button';
import {
Users,
Tags,
Filter,
Upload,
Loader2,
ArrowRight,
ArrowLeft,
X,
} from 'lucide-react';
type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv';
type CustomFieldOperator = 'is' | 'is_not' | 'contains';
interface CustomFieldFilter {
fieldId: string;
operator: CustomFieldOperator;
value: string;
}
interface AudienceConfig {
type: AudienceType;
tagIds?: string[];
customField?: CustomFieldFilter;
csvContacts?: { phone: string; name?: string }[];
excludeTagIds?: string[];
}
interface Step2Props {
audience: AudienceConfig;
onUpdate: (audience: AudienceConfig) => void;
onNext: () => void;
onBack: () => void;
}
const audienceOptions: {
type: AudienceType;
label: string;
description: string;
icon: typeof Users;
}[] = [
{
type: 'all',
label: 'All Contacts',
description: 'Send to every contact in your database',
icon: Users,
},
{
type: 'tags',
label: 'Filter by Tags',
description: 'Target contacts with specific tags',
icon: Tags,
},
{
type: 'custom_field',
label: 'Custom Field',
description: 'Filter by a custom field value',
icon: Filter,
},
{
type: 'csv',
label: 'Upload CSV',
description: 'Upload a list of phone numbers',
icon: Upload,
},
];
const OPERATOR_OPTIONS: { value: CustomFieldOperator; label: string }[] = [
{ value: 'is', label: 'is' },
{ value: 'is_not', label: 'is not' },
{ value: 'contains', label: 'contains' },
];
export function Step2SelectAudience({
audience,
onUpdate,
onNext,
onBack,
}: Step2Props) {
const [tags, setTags] = useState<Tag[]>([]);
const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [loadingTags, setLoadingTags] = useState(false);
const [loadingFields, setLoadingFields] = useState(false);
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
const [loadingCount, setLoadingCount] = useState(false);
// Tags are used both by the primary "Filter by Tags" audience type
// AND by the exclude-list below — so always load once on mount.
useEffect(() => {
async function fetchTags() {
setLoadingTags(true);
try {
const supabase = createClient();
const { data } = await supabase.from('tags').select('*').order('name');
setTags(data ?? []);
} finally {
setLoadingTags(false);
}
}
fetchTags();
}, []);
// Lazy-load custom fields only when that audience type is active.
useEffect(() => {
if (audience.type !== 'custom_field') return;
async function fetchFields() {
setLoadingFields(true);
try {
const supabase = createClient();
const { data } = await supabase
.from('custom_fields')
.select('*')
.order('field_name');
setCustomFields(data ?? []);
} finally {
setLoadingFields(false);
}
}
fetchFields();
}, [audience.type]);
const fetchEstimatedCount = useCallback(async () => {
setLoadingCount(true);
try {
const supabase = createClient();
// Base query — produces the superset before exclude is applied.
let baseIds: Set<string> | null = null; // null means "all contacts"
if (audience.type === 'all') {
// Handled below — full-table count adjusted by excludes.
} else if (
audience.type === 'tags' &&
audience.tagIds &&
audience.tagIds.length > 0
) {
const { data } = await supabase
.from('contact_tags')
.select('contact_id')
.in('tag_id', audience.tagIds);
baseIds = new Set((data ?? []).map((r) => r.contact_id));
} else if (
audience.type === 'custom_field' &&
audience.customField?.fieldId &&
audience.customField.value
) {
const { fieldId, operator, value } = audience.customField;
let q = supabase
.from('contact_custom_values')
.select('contact_id')
.eq('custom_field_id', fieldId);
if (operator === 'is') q = q.eq('value', value);
else if (operator === 'is_not') q = q.neq('value', value);
else q = q.ilike('value', `%${value}%`);
const { data } = await q;
baseIds = new Set((data ?? []).map((r) => r.contact_id));
} else if (
audience.type === 'csv' &&
audience.csvContacts &&
audience.csvContacts.length > 0
) {
setEstimatedCount(audience.csvContacts.length);
return;
} else {
// Partially-configured audience — wait for the user to finish.
setEstimatedCount(null);
return;
}
// Apply exclude tags
let excludeSet: Set<string> | null = null;
if (audience.excludeTagIds && audience.excludeTagIds.length > 0) {
const { data: excludeRows } = await supabase
.from('contact_tags')
.select('contact_id')
.in('tag_id', audience.excludeTagIds);
excludeSet = new Set((excludeRows ?? []).map((r) => r.contact_id));
}
if (baseIds) {
const effective = [...baseIds].filter(
(id) => !excludeSet?.has(id),
);
setEstimatedCount(effective.length);
} else {
// "All" — fetch the total, then subtract exclude set if any.
const { count } = await supabase
.from('contacts')
.select('*', { count: 'exact', head: true });
const total = count ?? 0;
setEstimatedCount(excludeSet ? Math.max(0, total - excludeSet.size) : total);
}
} finally {
setLoadingCount(false);
}
}, [
audience.type,
audience.tagIds,
audience.customField,
audience.csvContacts,
audience.excludeTagIds,
]);
useEffect(() => {
fetchEstimatedCount();
}, [fetchEstimatedCount]);
function toggleTag(tagId: string) {
const current = audience.tagIds ?? [];
const updated = current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId];
onUpdate({ ...audience, tagIds: updated });
}
function toggleExcludeTag(tagId: string) {
const current = audience.excludeTagIds ?? [];
const updated = current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId];
onUpdate({ ...audience, excludeTagIds: updated });
}
function updateCustomField(patch: Partial<CustomFieldFilter>) {
const prev = audience.customField ?? {
fieldId: '',
operator: 'is' as CustomFieldOperator,
value: '',
};
onUpdate({ ...audience, customField: { ...prev, ...patch } });
}
const isValid =
audience.type === 'all' ||
(audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) ||
(audience.type === 'custom_field' &&
!!audience.customField?.fieldId &&
audience.customField.value.length > 0) ||
(audience.type === 'csv' &&
audience.csvContacts &&
audience.csvContacts.length > 0);
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-foreground">Select Audience</h2>
<p className="mt-1 text-sm text-muted-foreground">
Choose who will receive this broadcast.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{audienceOptions.map((option) => {
const isSelected = audience.type === option.type;
const Icon = option.icon;
return (
<button
key={option.type}
onClick={() =>
onUpdate({
...audience,
type: option.type,
// Wipe shape fields from other types to avoid stale
// config leaking across selections.
tagIds: option.type === 'tags' ? audience.tagIds : undefined,
customField:
option.type === 'custom_field'
? audience.customField
: undefined,
csvContacts:
option.type === 'csv' ? audience.csvContacts : undefined,
})
}
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
: 'border-border bg-card/50 hover:border-border'
}`}
>
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
isSelected
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-foreground">{option.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{option.description}
</p>
</div>
</button>
);
})}
</div>
{audience.type === 'tags' && (
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="mb-3 text-sm font-medium text-foreground">Select Tags</p>
{loadingTags ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : tags.length === 0 ? (
<p className="text-xs text-muted-foreground">
No tags found. Create tags in Settings.
</p>
) : (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const isSelected = audience.tagIds?.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => toggleTag(tag.id)}
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
isSelected
? 'border-primary/30 bg-primary/10 text-primary'
: 'border-border bg-muted text-muted-foreground hover:border-border'
}`}
>
<span
className="mr-1.5 h-2 w-2 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
</button>
);
})}
</div>
)}
</div>
)}
{audience.type === 'custom_field' && (
<div className="space-y-3 rounded-xl border border-border bg-card/50 p-4">
<p className="text-sm font-medium text-foreground">Custom Field Filter</p>
{loadingFields ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : customFields.length === 0 ? (
<p className="text-xs text-muted-foreground">
No custom fields defined. Create one in Settings → Custom Fields.
</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_140px_minmax(0,1fr)]">
<select
value={audience.customField?.fieldId ?? ''}
onChange={(e) => updateCustomField({ fieldId: e.target.value })}
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
>
<option value="">Select field…</option>
{customFields.map((f) => (
<option key={f.id} value={f.id}>
{f.field_name}
</option>
))}
</select>
<select
value={audience.customField?.operator ?? 'is'}
onChange={(e) =>
updateCustomField({
operator: e.target.value as CustomFieldOperator,
})
}
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</select>
<input
type="text"
value={audience.customField?.value ?? ''}
onChange={(e) => updateCustomField({ value: e.target.value })}
placeholder="Value"
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
)}
</div>
)}
{/* Exclude list — applies regardless of audience type */}
<div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2">
<X className="h-4 w-4 text-red-400" />
<p className="text-sm font-medium text-foreground">
Exclude contacts with these tags
</p>
<span className="text-xs text-muted-foreground">(optional)</span>
</div>
{tags.length === 0 ? (
<p className="text-xs text-muted-foreground">No tags available.</p>
) : (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const isExcluded = audience.excludeTagIds?.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => toggleExcludeTag(tag.id)}
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
isExcluded
? 'border-red-500/30 bg-red-500/10 text-red-300'
: 'border-border bg-muted text-muted-foreground hover:border-border'
}`}
>
<span
className="mr-1.5 h-2 w-2 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
</button>
);
})}
</div>
)}
</div>
{/* Audience Summary */}
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="mb-2 text-sm font-medium text-foreground">Audience Summary</p>
{loadingCount ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-xs text-muted-foreground">Calculating…</span>
</div>
) : estimatedCount !== null ? (
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-primary" />
<span className="text-sm text-foreground">
{estimatedCount.toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">estimated recipients</span>
</div>
) : (
<p className="text-xs text-muted-foreground">
Select an audience type to see the estimate.
</p>
)}
</div>
<div className="flex items-center justify-between border-t border-border pt-4">
<Button
variant="outline"
onClick={onBack}
className="border-border text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button
onClick={onNext}
disabled={!isValid}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Next
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,460 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Contact, CustomField, MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, ArrowRight, Eye, ImageIcon, Loader2 } from 'lucide-react';
type VariableType = 'static' | 'field' | 'custom_field';
interface VariableMapping {
type: VariableType;
value: string;
}
interface Step3Props {
template: MessageTemplate;
variables: Record<string, VariableMapping>;
onUpdate: (variables: Record<string, VariableMapping>) => void;
/** Media URL for an IMAGE/VIDEO/DOCUMENT header, when the template has one. */
headerMediaUrl: string;
onHeaderMediaUrlChange: (url: string) => void;
onNext: () => void;
onBack: () => void;
}
const MEDIA_HEADER_TYPES = ['image', 'video', 'document'] as const;
type MediaHeaderType = (typeof MEDIA_HEADER_TYPES)[number];
function isMediaHeaderType(value: unknown): value is MediaHeaderType {
return MEDIA_HEADER_TYPES.includes(value as MediaHeaderType);
}
function isValidHttpUrl(value: string): boolean {
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
const contactFields = [
{ value: 'name', label: 'Contact Name' },
{ value: 'phone', label: 'Phone Number' },
{ value: 'email', label: 'Email Address' },
{ value: 'company', label: 'Company' },
];
const SAMPLE_CONTACT: Contact = {
id: 'sample',
user_id: '',
account_id: '',
name: 'John Doe',
phone: '+1234567890',
email: 'john@example.com',
company: 'Acme Corp',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
export function Step3Personalize({
template,
variables,
onUpdate,
headerMediaUrl,
onHeaderMediaUrlChange,
onNext,
onBack,
}: Step3Props) {
const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [loadingFields, setLoadingFields] = useState(true);
const [firstContact, setFirstContact] = useState<Contact | null>(null);
const [firstContactCustomValues, setFirstContactCustomValues] = useState<
Map<string, string>
>(new Map());
const [loadingPreview, setLoadingPreview] = useState(true);
// Load user's custom fields + a representative contact for the
// live preview. Fall back to sample data if no contacts exist yet.
useEffect(() => {
let cancelled = false;
(async () => {
const supabase = createClient();
const [fieldsRes, contactRes] = await Promise.all([
supabase.from('custom_fields').select('*').order('field_name'),
supabase
.from('contacts')
.select('*')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle(),
]);
if (cancelled) return;
setCustomFields(fieldsRes.data ?? []);
setLoadingFields(false);
const contact = contactRes.data ?? null;
setFirstContact(contact);
if (contact) {
const { data: customVals } = await supabase
.from('contact_custom_values')
.select('custom_field_id, value')
.eq('contact_id', contact.id);
if (!cancelled) {
const map = new Map<string, string>();
for (const row of customVals ?? []) {
map.set(row.custom_field_id, row.value ?? '');
}
setFirstContactCustomValues(map);
}
}
setLoadingPreview(false);
})();
return () => {
cancelled = true;
};
}, []);
const placeholders = useMemo(() => {
const matches = template.body_text.match(/\{\{(\d+)\}\}/g);
if (!matches) return [];
return [...new Set(matches)].sort();
}, [template.body_text]);
// Templates with an IMAGE/VIDEO/DOCUMENT header need a media URL at
// send time — Meta requires the media component on every delivery and
// rejects the broadcast without it. The field is hidden for text-only
// headers.
const mediaHeaderType = isMediaHeaderType(template.header_type)
? template.header_type
: null;
// Seed the field with the template's stored sample URL the first time
// we land on a media-header template, so the common "reuse the
// approved media" case needs no typing. Only seeds when empty to avoid
// clobbering a URL the user already edited.
useEffect(() => {
if (mediaHeaderType && !headerMediaUrl && template.header_media_url) {
onHeaderMediaUrlChange(template.header_media_url);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mediaHeaderType, template.header_media_url]);
const headerMediaError = useMemo<'missing' | 'invalid' | null>(() => {
if (!mediaHeaderType) return null;
const value = headerMediaUrl.trim();
if (!value) return 'missing';
if (!isValidHttpUrl(value)) return 'invalid';
return null;
}, [mediaHeaderType, headerMediaUrl]);
/**
* A placeholder is "unmapped" if the user hasn't picked either a
* static value or a field/custom-field source. Blocks Next until
* every placeholder has something — otherwise the broadcast would
* ship with empty strings and confuse recipients.
*/
const unmappedKeys = useMemo(() => {
const missing: string[] = [];
for (const placeholder of placeholders) {
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
const mapping = variables[key];
if (!mapping || !mapping.value?.trim()) {
missing.push(placeholder);
}
}
return missing;
}, [placeholders, variables]);
function updateVariable(key: string, patch: Partial<VariableMapping>) {
const current = variables[key] ?? { type: 'static' as VariableType, value: '' };
onUpdate({
...variables,
[key]: { ...current, ...patch },
});
}
/**
* Substitute placeholders using the first real contact where
* possible. Placeholders keyed by "{{N}}" map to variable key "N".
*/
const previewText = useMemo(() => {
const contact = firstContact ?? SAMPLE_CONTACT;
const customValues = firstContact
? firstContactCustomValues
: new Map<string, string>();
let text = template.body_text;
for (const placeholder of placeholders) {
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
const mapping = variables[key];
let replacement = placeholder;
if (mapping) {
if (mapping.type === 'static' && mapping.value) {
replacement = mapping.value;
} else if (mapping.type === 'field' && mapping.value) {
const fieldMap: Record<string, string | undefined> = {
name: contact.name,
phone: contact.phone,
email: contact.email,
company: contact.company,
};
replacement = fieldMap[mapping.value] ?? placeholder;
} else if (mapping.type === 'custom_field' && mapping.value) {
replacement = customValues.get(mapping.value) || placeholder;
}
}
text = text.replaceAll(placeholder, replacement);
}
return text;
}, [
template.body_text,
variables,
placeholders,
firstContact,
firstContactCustomValues,
]);
const previewLabel = firstContact
? firstContact.name || firstContact.phone
: 'sample data';
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-foreground">Personalize Message</h2>
<p className="mt-1 text-sm text-muted-foreground">
Map template variables to contact fields, custom fields, or static
values.
</p>
</div>
{mediaHeaderType && (
<div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-primary" />
<p className="text-sm font-medium text-foreground">Header media</p>
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium uppercase text-primary">
{mediaHeaderType}
</span>
</div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Media URL
</label>
<Input
type="url"
value={headerMediaUrl}
onChange={(e) => onHeaderMediaUrlChange(e.target.value)}
placeholder={`https://example.com/header.${
mediaHeaderType === 'image'
? 'jpg'
: mediaHeaderType === 'video'
? 'mp4'
: 'pdf'
}`}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Public URL of the {mediaHeaderType} sent as the message header.
Used for every recipient in this broadcast.
</p>
{mediaHeaderType === 'image' &&
headerMediaError === null &&
headerMediaUrl.trim() && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={headerMediaUrl.trim()}
alt="Header preview"
className="mt-3 max-h-40 rounded-lg border border-border object-contain"
/>
)}
{headerMediaError && (
<p className="mt-1.5 text-xs text-amber-300">
{headerMediaError === 'missing'
? 'A media URL is required to send this template.'
: 'Enter a valid http(s) URL.'}
</p>
)}
</div>
)}
{placeholders.length === 0 && !mediaHeaderType ? (
<div className="rounded-xl border border-border bg-card/50 p-6 text-center">
<p className="text-sm text-muted-foreground">
This template has no variables to personalize.
</p>
</div>
) : placeholders.length === 0 ? null : (
<div className="space-y-4">
{placeholders.map((placeholder) => {
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
const mapping = variables[key] ?? { type: 'static', value: '' };
return (
<div
key={placeholder}
className="rounded-xl border border-border bg-card/50 p-4"
>
<div className="mb-3 flex items-center gap-2">
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-mono font-medium text-primary">
{placeholder}
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Mapping Type
</label>
<Select
value={mapping.type}
onValueChange={(val) =>
updateVariable(key, {
type: val as VariableType,
value: '',
})
}
>
<SelectTrigger className="w-full border-border bg-muted text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-border bg-popover">
<SelectItem value="static">Static Value</SelectItem>
<SelectItem value="field">Contact Field</SelectItem>
<SelectItem value="custom_field">
Custom Field
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
{mapping.type === 'static' ? 'Value' : 'Field'}
</label>
{mapping.type === 'static' ? (
<Input
value={mapping.value}
onChange={(e) =>
updateVariable(key, { value: e.target.value })
}
placeholder="Enter value..."
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
) : mapping.type === 'field' ? (
<Select
value={mapping.value || undefined}
onValueChange={(val) =>
updateVariable(key, { value: val || '' })
}
>
<SelectTrigger className="w-full border-border bg-muted text-foreground">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent className="border-border bg-popover">
{contactFields.map((field) => (
<SelectItem key={field.value} value={field.value}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select
value={mapping.value || undefined}
onValueChange={(val) =>
updateVariable(key, { value: val || '' })
}
>
<SelectTrigger className="w-full border-border bg-muted text-foreground">
<SelectValue
placeholder={
loadingFields
? 'Loading…'
: customFields.length === 0
? 'No custom fields'
: 'Select custom field…'
}
/>
</SelectTrigger>
<SelectContent className="border-border bg-popover">
{customFields.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.field_name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Live Preview — rendered as a WhatsApp-style bubble so the user
sees approximately what the recipient will see. */}
<div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2">
<Eye className="h-4 w-4 text-primary" />
<p className="text-sm font-medium text-foreground">Live Preview</p>
<span className="text-xs text-muted-foreground">({previewLabel})</span>
{loadingPreview && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
)}
</div>
<div className="rounded-lg bg-[#0e1a12] p-3">
<div className="ml-auto max-w-[85%] rounded-lg bg-primary/30 px-3 py-2 shadow-sm">
<p className="whitespace-pre-wrap text-sm text-primary">
{previewText}
</p>
</div>
</div>
</div>
{unmappedKeys.length > 0 && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
Map every placeholder before continuing — still missing{' '}
<span className="font-mono font-semibold">
{unmappedKeys.join(', ')}
</span>
. Otherwise those placeholders will ship to Meta as empty strings.
</div>
)}
<div className="flex items-center justify-between border-t border-border pt-4">
<Button
variant="outline"
onClick={onBack}
className="border-border text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button
onClick={onNext}
disabled={unmappedKeys.length > 0 || headerMediaError !== null}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Next
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { ArrowLeft, Send, Loader2, Users, Save } from 'lucide-react';
interface AudienceConfig {
type: string;
tagIds?: string[];
csvContacts?: { phone: string; name?: string }[];
}
interface Step4Props {
name: string;
onNameChange: (name: string) => void;
template: MessageTemplate;
audience: AudienceConfig;
onSend: () => void;
onSaveDraft?: () => void;
onBack: () => void;
isProcessing: boolean;
progress: number;
}
export function Step4ScheduleSend({
name,
onNameChange,
template,
audience,
onSend,
onSaveDraft,
onBack,
isProcessing,
progress,
}: Step4Props) {
const [showConfirm, setShowConfirm] = useState(false);
const [estimatedReach, setEstimatedReach] = useState<number>(0);
const [loadingReach, setLoadingReach] = useState(true);
useEffect(() => {
async function calculateReach() {
setLoadingReach(true);
try {
const supabase = createClient();
if (audience.type === 'all') {
const { count } = await supabase
.from('contacts')
.select('*', { count: 'exact', head: true });
setEstimatedReach(count ?? 0);
} else if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
const { data: contactTags } = await supabase
.from('contact_tags')
.select('contact_id')
.in('tag_id', audience.tagIds);
const uniqueIds = new Set((contactTags ?? []).map((ct) => ct.contact_id));
setEstimatedReach(uniqueIds.size);
} else if (audience.type === 'csv' && audience.csvContacts) {
setEstimatedReach(audience.csvContacts.length);
} else {
setEstimatedReach(0);
}
} finally {
setLoadingReach(false);
}
}
calculateReach();
}, [audience]);
const audienceLabel =
audience.type === 'all'
? 'All Contacts'
: audience.type === 'tags'
? `Tags (${audience.tagIds?.length ?? 0} selected)`
: audience.type === 'csv'
? 'CSV Upload'
: 'Custom';
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-foreground">Review & Send</h2>
<p className="mt-1 text-sm text-muted-foreground">
Name your broadcast, review the details, and send.
</p>
</div>
{/* Broadcast Name */}
<div>
<label className="mb-1.5 block text-sm font-medium text-foreground">Broadcast Name</label>
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g. Summer Sale Announcement"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
</div>
{/* Summary Card */}
<div className="rounded-xl border border-border bg-card/50 p-4 space-y-3">
<p className="text-sm font-medium text-foreground">Summary</p>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Template</p>
<p className="text-foreground">{template.name}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Audience</p>
<p className="text-foreground">{audienceLabel}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Estimated Reach</p>
<div className="flex items-center gap-1.5">
{loadingReach ? (
<Loader2 className="h-3 w-3 animate-spin text-primary" />
) : (
<>
<Users className="h-3.5 w-3.5 text-primary" />
<p className="font-medium text-foreground">{estimatedReach.toLocaleString()}</p>
</>
)}
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Language</p>
<p className="text-foreground">{template.language ?? 'en_US'}</p>
</div>
</div>
</div>
{/* Processing overlay */}
{isProcessing && (
<div className="rounded-xl border border-primary/20 bg-primary/5 p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<p className="text-sm font-medium text-foreground">Sending broadcast...</p>
</div>
<span className="text-xs font-medium text-primary">{progress}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-muted">
<div
className="h-1.5 rounded-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
<Button
variant="outline"
onClick={onBack}
disabled={isProcessing}
className="border-border text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center gap-2">
{onSaveDraft && (
<Button
variant="outline"
onClick={onSaveDraft}
disabled={!name.trim() || isProcessing}
className="border-border text-muted-foreground hover:bg-muted disabled:opacity-50"
>
<Save className="h-4 w-4" />
Save as Draft
</Button>
)}
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
<DialogTrigger
render={
<Button
disabled={!name.trim() || isProcessing}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
/>
}
>
<Send className="h-4 w-4" />
Send Broadcast
</DialogTrigger>
<DialogContent className="border-border bg-popover sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-popover-foreground">Confirm Broadcast</DialogTitle>
<DialogDescription className="text-muted-foreground">
You are about to send this broadcast to{' '}
<span className="font-medium text-popover-foreground">{estimatedReach.toLocaleString()}</span>{' '}
contacts using the{' '}
<span className="font-medium text-popover-foreground">{template.name}</span> template.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowConfirm(false)}
className="border-border text-muted-foreground"
>
Cancel
</Button>
<Button
onClick={() => {
setShowConfirm(false);
onSend();
}}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Send className="h-4 w-4" />
Confirm & Send
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,763 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { formatCurrency } from '@/lib/currency';
import { toast } from 'sonner';
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal, MessageTemplate } from '@/types';
import {
TemplatePicker,
type TemplateSendValues,
} from '@/components/inbox/template-picker';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Phone,
Mail,
Building2,
Copy,
Check,
Loader2,
Plus,
Trash2,
Save,
X,
DollarSign,
LayoutTemplate,
} from 'lucide-react';
interface ContactDetailViewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
contactId: string | null;
onUpdated: () => void;
}
export function ContactDetailView({
open,
onOpenChange,
contactId,
onUpdated,
}: ContactDetailViewProps) {
const supabase = createClient();
const { accountId, defaultCurrency } = useAuth();
const [contact, setContact] = useState<Contact | null>(null);
const [loading, setLoading] = useState(false);
const [copiedPhone, setCopiedPhone] = useState(false);
// Send template — lets the business initiate (or re-open) a conversation
// with this contact by sending an approved template. The send route
// find-or-creates the conversation, so no inbound message is required.
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const [sendingTemplate, setSendingTemplate] = useState(false);
// Details tab
const [editName, setEditName] = useState('');
const [editPhone, setEditPhone] = useState('');
const [editEmail, setEditEmail] = useState('');
const [editCompany, setEditCompany] = useState('');
const [savingDetails, setSavingDetails] = useState(false);
// Tags tab
const [allTags, setAllTags] = useState<Tag[]>([]);
const [contactTagIds, setContactTagIds] = useState<string[]>([]);
const [savingTags, setSavingTags] = useState(false);
// Notes tab
const [notes, setNotes] = useState<ContactNote[]>([]);
const [newNote, setNewNote] = useState('');
const [savingNote, setSavingNote] = useState(false);
const [loadingNotes, setLoadingNotes] = useState(false);
// Custom fields tab
const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [customValues, setCustomValues] = useState<Record<string, string>>({});
const [savingCustom, setSavingCustom] = useState(false);
const [loadingCustom, setLoadingCustom] = useState(false);
// Deals tab
const [deals, setDeals] = useState<Deal[]>([]);
const [loadingDeals, setLoadingDeals] = useState(false);
const fetchContact = useCallback(async () => {
if (!contactId) return;
setLoading(true);
const { data } = await supabase
.from('contacts')
.select('*')
.eq('id', contactId)
.single();
if (data) {
setContact(data);
setEditName(data.name ?? '');
setEditPhone(data.phone);
setEditEmail(data.email ?? '');
setEditCompany(data.company ?? '');
}
setLoading(false);
}, [contactId, supabase]);
const fetchTags = useCallback(async () => {
if (!contactId) return;
const [tagsRes, contactTagsRes] = await Promise.all([
supabase.from('tags').select('*').order('name'),
supabase.from('contact_tags').select('tag_id').eq('contact_id', contactId),
]);
if (tagsRes.data) setAllTags(tagsRes.data);
if (contactTagsRes.data) {
setContactTagIds(contactTagsRes.data.map((ct) => ct.tag_id));
}
}, [contactId, supabase]);
const fetchNotes = useCallback(async () => {
if (!contactId) return;
setLoadingNotes(true);
const { data } = await supabase
.from('contact_notes')
.select('*')
.eq('contact_id', contactId)
.order('created_at', { ascending: false });
if (data) setNotes(data);
setLoadingNotes(false);
}, [contactId, supabase]);
const fetchCustomFields = useCallback(async () => {
if (!contactId) return;
setLoadingCustom(true);
const [fieldsRes, valuesRes] = await Promise.all([
supabase.from('custom_fields').select('*').order('field_name'),
supabase
.from('contact_custom_values')
.select('*')
.eq('contact_id', contactId),
]);
if (fieldsRes.data) setCustomFields(fieldsRes.data);
if (valuesRes.data) {
const map: Record<string, string> = {};
valuesRes.data.forEach((v) => {
map[v.custom_field_id] = v.value ?? '';
});
setCustomValues(map);
}
setLoadingCustom(false);
}, [contactId, supabase]);
const fetchDeals = useCallback(async () => {
if (!contactId) return;
setLoadingDeals(true);
const { data } = await supabase
.from('deals')
.select('*, stage:pipeline_stages(*)')
.eq('contact_id', contactId)
.order('created_at', { ascending: false });
setDeals((data ?? []) as Deal[]);
setLoadingDeals(false);
}, [contactId, supabase]);
useEffect(() => {
if (open && contactId) {
fetchContact();
fetchTags();
fetchNotes();
fetchCustomFields();
fetchDeals();
}
}, [open, contactId, fetchContact, fetchTags, fetchNotes, fetchCustomFields, fetchDeals]);
async function copyPhone() {
if (!contact) return;
await navigator.clipboard.writeText(contact.phone);
setCopiedPhone(true);
setTimeout(() => setCopiedPhone(false), 2000);
}
async function saveDetails() {
if (!contactId || !editPhone.trim()) {
toast.error('Phone number is required');
return;
}
setSavingDetails(true);
const { error } = await supabase
.from('contacts')
.update({
name: editName.trim() || null,
phone: editPhone.trim(),
email: editEmail.trim() || null,
company: editCompany.trim() || null,
updated_at: new Date().toISOString(),
})
.eq('id', contactId);
if (error) {
toast.error('Failed to update contact');
} else {
toast.success('Contact updated');
fetchContact();
onUpdated();
}
setSavingDetails(false);
}
async function toggleTag(tagId: string) {
if (!contactId) return;
setSavingTags(true);
const isSelected = contactTagIds.includes(tagId);
if (isSelected) {
const { error } = await supabase
.from('contact_tags')
.delete()
.eq('contact_id', contactId)
.eq('tag_id', tagId);
if (!error) {
setContactTagIds((prev) => prev.filter((id) => id !== tagId));
onUpdated();
}
} else {
const { error } = await supabase
.from('contact_tags')
.insert({ contact_id: contactId, tag_id: tagId });
if (!error) {
setContactTagIds((prev) => [...prev, tagId]);
onUpdated();
}
}
setSavingTags(false);
}
async function addNote() {
if (!contactId || !newNote.trim()) return;
setSavingNote(true);
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user || !accountId) {
toast.error('Not authenticated');
setSavingNote(false);
return;
}
const { error } = await supabase.from('contact_notes').insert({
contact_id: contactId,
account_id: accountId,
user_id: user.id,
note_text: newNote.trim(),
});
if (error) {
toast.error('Failed to add note');
} else {
setNewNote('');
fetchNotes();
toast.success('Note added');
}
setSavingNote(false);
}
async function deleteNote(noteId: string) {
const { error } = await supabase
.from('contact_notes')
.delete()
.eq('id', noteId);
if (error) {
toast.error('Failed to delete note');
} else {
setNotes((prev) => prev.filter((n) => n.id !== noteId));
toast.success('Note deleted');
}
}
async function saveCustomFields() {
if (!contactId) return;
setSavingCustom(true);
try {
// Delete existing values and re-insert
await supabase
.from('contact_custom_values')
.delete()
.eq('contact_id', contactId);
const rows = Object.entries(customValues)
.filter(([, val]) => val.trim())
.map(([fieldId, val]) => ({
contact_id: contactId,
custom_field_id: fieldId,
value: val.trim(),
}));
if (rows.length > 0) {
const { error } = await supabase
.from('contact_custom_values')
.insert(rows);
if (error) throw error;
}
toast.success('Custom fields saved');
} catch {
toast.error('Failed to save custom fields');
}
setSavingCustom(false);
}
async function handleSendTemplate(
template: MessageTemplate,
values: TemplateSendValues,
) {
if (!contactId) return;
setSendingTemplate(true);
try {
const res = await fetch('/api/whatsapp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// No conversation_id — the route find-or-creates one for this
// contact, mirroring the inbox template-send payload otherwise.
contact_id: contactId,
message_type: 'template',
template_name: template.name,
template_language: template.language,
template_message_params: {
body: values.body,
headerText: values.headerText,
buttonParams: values.buttonParams,
},
template_params: values.body,
}),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const reason = payload?.error || `HTTP ${res.status}`;
toast.error(`Failed to send template: ${reason}`);
return;
}
toast.success(`Template "${template.name}" sent`);
} catch (err) {
const reason = err instanceof Error ? err.message : 'network error';
toast.error(`Failed to send template: ${reason}`);
} finally {
setSendingTemplate(false);
}
}
function getInitials(name?: string | null) {
if (!name) return '?';
return name
.split(' ')
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="bg-popover border-border text-popover-foreground sm:max-w-lg w-full p-0"
>
{loading || !contact ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : (
<div className="flex flex-col h-full">
{/* Header */}
<SheetHeader className="p-4 border-b border-border/50">
<div className="flex items-center gap-3">
<Avatar className="size-12 bg-muted border border-border">
<AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
{getInitials(contact.name)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<SheetTitle className="text-popover-foreground truncate">
{contact.name || 'Unknown'}
</SheetTitle>
<SheetDescription className="text-muted-foreground text-xs mt-0.5">
Contact details
</SheetDescription>
<div className="flex flex-wrap items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<button
onClick={copyPhone}
className="flex items-center gap-1 hover:text-primary transition-colors cursor-pointer"
>
<Phone className="size-3" />
{contact.phone}
{copiedPhone ? (
<Check className="size-3 text-primary" />
) : (
<Copy className="size-3" />
)}
</button>
{contact.email && (
<span className="flex items-center gap-1">
<Mail className="size-3" />
{contact.email}
</span>
)}
{contact.company && (
<span className="flex items-center gap-1">
<Building2 className="size-3" />
{contact.company}
</span>
)}
</div>
</div>
</div>
<div className="mt-3">
<Button
size="sm"
onClick={() => setTemplatePickerOpen(true)}
disabled={sendingTemplate}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{sendingTemplate ? (
<Loader2 className="size-4 animate-spin" />
) : (
<LayoutTemplate className="size-4" />
)}
Send template
</Button>
</div>
</SheetHeader>
{/* Tabs */}
<Tabs defaultValue="details" className="flex-1 flex flex-col min-h-0">
<TabsList className="bg-muted/50 border-b border-border mx-4 mt-3">
<TabsTrigger
value="details"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Details
</TabsTrigger>
<TabsTrigger
value="tags"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Tags
</TabsTrigger>
<TabsTrigger
value="notes"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Notes
</TabsTrigger>
<TabsTrigger
value="custom"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Custom Fields
</TabsTrigger>
<TabsTrigger
value="deals"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Deals
</TabsTrigger>
</TabsList>
{/* Details Tab */}
<TabsContent value="details" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Name</Label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">
Phone <span className="text-red-400">*</span>
</Label>
<Input
value={editPhone}
onChange={(e) => setEditPhone(e.target.value)}
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Email</Label>
<Input
value={editEmail}
onChange={(e) => setEditEmail(e.target.value)}
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Company</Label>
<Input
value={editCompany}
onChange={(e) => setEditCompany(e.target.value)}
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<Button
onClick={saveDetails}
disabled={savingDetails}
className="bg-primary hover:bg-primary/90 text-primary-foreground w-full"
size="sm"
>
{savingDetails ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
Save Changes
</Button>
</div>
</TabsContent>
{/* Tags Tab */}
<TabsContent value="tags" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Click a tag to add or remove it from this contact.
</p>
{allTags.length === 0 ? (
<p className="text-sm text-muted-foreground">
No tags available. Create tags in Settings.
</p>
) : (
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => {
const selected = contactTagIds.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => toggleTag(tag.id)}
disabled={savingTags}
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium transition-all cursor-pointer ${
selected
? 'ring-2 ring-primary ring-offset-1 ring-offset-border'
: 'opacity-50 hover:opacity-80'
}`}
style={{
backgroundColor: tag.color + '20',
color: tag.color,
}}
>
{selected && <Check className="size-3 mr-1" />}
{tag.name}
</button>
);
})}
</div>
)}
</div>
</TabsContent>
{/* Notes Tab */}
<TabsContent value="notes" className="flex-1 flex flex-col min-h-0 px-4 py-3">
<div className="space-y-2 mb-3">
<Textarea
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
placeholder="Write a note..."
className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[60px] text-sm resize-none"
/>
<Button
onClick={addNote}
disabled={!newNote.trim() || savingNote}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
size="sm"
>
{savingNote ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Plus className="size-3.5" />
)}
Add Note
</Button>
</div>
<div className="flex-1 overflow-y-auto space-y-2">
{loadingNotes ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : notes.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No notes yet.
</p>
) : (
notes.map((note) => (
<div
key={note.id}
className="rounded-lg bg-muted/50 border border-border/50 p-3 group"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-muted-foreground whitespace-pre-wrap flex-1">
{note.note_text}
</p>
<button
onClick={() => deleteNote(note.id)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-red-400 transition-all cursor-pointer shrink-0"
>
<Trash2 className="size-3.5" />
</button>
</div>
<p className="text-xs text-muted-foreground mt-1.5">
{new Date(note.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
))
)}
</div>
</TabsContent>
{/* Custom Fields Tab */}
<TabsContent value="custom" className="flex-1 overflow-y-auto px-4 py-3">
{loadingCustom ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : customFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No custom fields defined. Create them in Settings.
</p>
) : (
<div className="space-y-3">
{customFields.map((field) => (
<div key={field.id} className="space-y-1.5">
<Label className="text-muted-foreground text-xs capitalize">
{field.field_name}
</Label>
<Input
value={customValues[field.id] ?? ''}
onChange={(e) =>
setCustomValues((prev) => ({
...prev,
[field.id]: e.target.value,
}))
}
placeholder={`Enter ${field.field_name}...`}
className="bg-muted border-border text-foreground h-8 text-sm placeholder:text-muted-foreground"
/>
</div>
))}
<Button
onClick={saveCustomFields}
disabled={savingCustom}
className="bg-primary hover:bg-primary/90 text-primary-foreground w-full"
size="sm"
>
{savingCustom ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
Save Custom Fields
</Button>
</div>
)}
</TabsContent>
{/* Deals Tab */}
<TabsContent value="deals" className="flex-1 overflow-y-auto px-4 py-3">
{loadingDeals ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-primary" />
</div>
) : deals.length === 0 ? (
<p className="text-xs text-muted-foreground">No deals yet</p>
) : (
<div className="space-y-2">
{deals.map((deal) => (
<div
key={deal.id}
className="rounded-lg border border-border bg-muted/50 p-3"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-foreground">
{deal.title}
</p>
{deal.stage && (
<span
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: `${deal.stage.color}20`,
color: deal.stage.color,
}}
>
{deal.stage.name}
</span>
)}
</div>
<div className="mt-1.5 flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<DollarSign className="size-3" />
{formatCurrency(
deal.value ?? 0,
deal.currency || defaultCurrency,
)}
</span>
{deal.status && deal.status !== 'open' && (
<span
className={
deal.status === 'won'
? 'text-primary'
: 'text-red-400'
}
>
{deal.status}
</span>
)}
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
)}
</SheetContent>
</Sheet>
<TemplatePicker
open={templatePickerOpen}
onOpenChange={setTemplatePickerOpen}
onSelect={handleSendTemplate}
/>
</>
);
}

View File

@@ -0,0 +1,388 @@
'use client';
import { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { toast } from 'sonner';
import type { Contact, Tag, ContactTag } from '@/types';
import {
findExistingContact,
isExactMatch,
isUniqueViolation,
type ExistingContact,
} from '@/lib/contacts/dedupe';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Loader2, AlertTriangle } from 'lucide-react';
interface ContactFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
contact?: Contact | null;
contactTags?: ContactTag[];
onSaved: () => void;
/** Open an existing contact's detail view — used by the duplicate
* notice to jump to the contact that already owns this number. */
onViewExisting?: (contactId: string) => void;
}
export function ContactForm({
open,
onOpenChange,
contact,
contactTags = [],
onSaved,
onViewExisting,
}: ContactFormProps) {
const supabase = createClient();
const { accountId } = useAuth();
const isEdit = !!contact;
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [company, setCompany] = useState('');
const [saving, setSaving] = useState(false);
// Duplicate-phone detection for NEW contacts. `exact` (same digits)
// hard-blocks the save; a fuzzy trunk-variant match only warns. The
// DB unique index (migration 022) is the real backstop — this is the
// friendly heads-up before we get there.
const [dupMatch, setDupMatch] = useState<
{ contact: ExistingContact; exact: boolean } | null
>(null);
const [checkingDup, setCheckingDup] = useState(false);
const [tags, setTags] = useState<Tag[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [loadingTags, setLoadingTags] = useState(false);
useEffect(() => {
if (open) {
setName(contact?.name ?? '');
setPhone(contact?.phone ?? '');
setEmail(contact?.email ?? '');
setCompany(contact?.company ?? '');
setSelectedTagIds(contactTags.map((ct) => ct.tag_id));
setDupMatch(null);
fetchTags();
}
}, [open, contact]);
// Look up an existing contact with this number (new contacts only).
// Runs on blur so we don't query on every keystroke.
async function checkDuplicate() {
if (isEdit || !accountId) return;
const value = phone.trim();
if (!value) {
setDupMatch(null);
return;
}
setCheckingDup(true);
try {
const existing = await findExistingContact(supabase, accountId, value);
setDupMatch(
existing
? { contact: existing, exact: isExactMatch(existing, value) }
: null,
);
} finally {
setCheckingDup(false);
}
}
async function fetchTags() {
setLoadingTags(true);
const { data } = await supabase
.from('tags')
.select('*')
.order('name');
if (data) setTags(data);
setLoadingTags(false);
}
function toggleTag(tagId: string) {
setSelectedTagIds((prev) =>
prev.includes(tagId)
? prev.filter((id) => id !== tagId)
: [...prev, tagId]
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!phone.trim()) {
toast.error('Phone number is required');
return;
}
// Hard-block an exact duplicate on create (the DB unique index is
// the real backstop; this avoids a round-trip + a raw error toast).
if (!isEdit && dupMatch?.exact) {
toast.error('A contact with this phone number already exists');
return;
}
setSaving(true);
try {
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) throw new Error('Not authenticated');
if (!accountId) throw new Error('Your profile is not linked to an account.');
let contactId = contact?.id;
if (isEdit && contactId) {
const { error } = await supabase
.from('contacts')
.update({
name: name.trim() || null,
phone: phone.trim(),
email: email.trim() || null,
company: company.trim() || null,
updated_at: new Date().toISOString(),
})
.eq('id', contactId);
if (error) throw error;
} else {
const { data, error } = await supabase
.from('contacts')
.insert({
user_id: user.id,
account_id: accountId,
name: name.trim() || null,
phone: phone.trim(),
email: email.trim() || null,
company: company.trim() || null,
})
.select('id')
.single();
if (error) throw error;
contactId = data.id;
}
// Sync tags
if (contactId) {
await supabase
.from('contact_tags')
.delete()
.eq('contact_id', contactId);
if (selectedTagIds.length > 0) {
const tagRows = selectedTagIds.map((tag_id) => ({
contact_id: contactId!,
tag_id,
}));
const { error: tagError } = await supabase
.from('contact_tags')
.insert(tagRows);
if (tagError) throw tagError;
}
}
toast.success(isEdit ? 'Contact updated' : 'Contact created');
onOpenChange(false);
onSaved();
} catch (err: unknown) {
// The unique index (migration 022) rejects a duplicate phone that
// slipped past the on-blur check (race, or a format that
// normalizes equal). Surface it as the friendly duplicate notice
// and, for new contacts, point the user at the existing record.
if (isUniqueViolation(err)) {
toast.error('A contact with this phone number already exists');
if (!isEdit && accountId) {
const existing = await findExistingContact(
supabase,
accountId,
phone.trim(),
);
if (existing) setDupMatch({ contact: existing, exact: true });
}
return;
}
const message = err instanceof Error ? err.message : 'Failed to save contact';
toast.error(message);
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-popover-foreground">
{isEdit ? 'Edit Contact' : 'Add Contact'}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{isEdit
? 'Update the contact details below.'
: 'Fill in the details to create a new contact.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cf-name" className="text-muted-foreground">
Name
</Label>
<Input
id="cf-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cf-phone" className="text-muted-foreground">
Phone <span className="text-red-400">*</span>
</Label>
<Input
id="cf-phone"
value={phone}
onChange={(e) => {
setPhone(e.target.value);
if (dupMatch) setDupMatch(null);
}}
onBlur={checkDuplicate}
placeholder="+1 234 567 8900"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
{dupMatch ? (
<div
className={`flex items-start gap-2 rounded-md border px-2.5 py-2 text-xs ${
dupMatch.exact
? 'border-red-500/40 bg-red-500/10 text-red-300'
: 'border-amber-500/40 bg-amber-500/10 text-amber-300'
}`}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="space-y-1">
<p>
{dupMatch.exact
? 'A contact with this phone number already exists.'
: 'A contact with a very similar number already exists.'}
</p>
{onViewExisting && (
<button
type="button"
onClick={() => onViewExisting(dupMatch.contact.id)}
className="font-medium underline underline-offset-2 hover:no-underline"
>
View {dupMatch.contact.name || dupMatch.contact.phone}
</button>
)}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">
Include country code, e.g. +1 for US
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="cf-email" className="text-muted-foreground">
Email
</Label>
<Input
id="cf-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="john@example.com"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cf-company" className="text-muted-foreground">
Company
</Label>
<Input
id="cf-company"
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="Acme Inc."
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Tags</Label>
{loadingTags ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="size-3 animate-spin" />
Loading tags...
</div>
) : tags.length === 0 ? (
<p className="text-xs text-muted-foreground">
No tags available. Create tags in Settings.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => {
const selected = selectedTagIds.includes(tag.id);
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors cursor-pointer ${
selected
? 'ring-2 ring-primary ring-offset-1 ring-offset-border'
: 'opacity-60 hover:opacity-100'
}`}
style={{
backgroundColor: tag.color + '20',
color: tag.color,
borderColor: tag.color,
}}
>
{tag.name}
</button>
);
})}
</div>
)}
</div>
<DialogFooter className="bg-popover border-border">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
type="submit"
disabled={saving || checkingDup || (!isEdit && !!dupMatch?.exact)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{saving && <Loader2 className="size-4 animate-spin" />}
{isEdit ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,286 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { toast } from 'sonner';
import type { CustomField } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Loader2, Plus, Trash2 } from 'lucide-react';
interface CustomFieldsManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Dialog wrapper around {@link CustomFieldsPanel}, used on the Contacts page.
* The same panel is rendered inline under Settings → Custom Fields, so the
* editing UI lives in one place. Radix unmounts the dialog content on close,
* so the panel remounts (and refetches) on each open.
*/
export function CustomFieldsManager({
open,
onOpenChange,
}: CustomFieldsManagerProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="border-border bg-popover text-popover-foreground sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-popover-foreground">Custom fields</DialogTitle>
<DialogDescription className="text-muted-foreground">
Define extra contact fields (e.g. ZIP code, lead source). They
appear on every contact and in the “Update Contact Field” automation
action.
</DialogDescription>
</DialogHeader>
<CustomFieldsPanel />
</DialogContent>
</Dialog>
);
}
/**
* Create / rename / delete account-wide custom contact field definitions.
* Per-contact values are edited elsewhere (contact detail → Custom Fields);
* this only manages the field catalogue. Admin+ gated by the caller — the
* `custom_fields` RLS also rejects non-admin writes as defense in depth.
*/
export function CustomFieldsPanel() {
const supabase = createClient();
const { user, accountId } = useAuth();
const [fields, setFields] = useState<CustomField[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const fetchFields = useCallback(async () => {
if (!accountId) return;
setLoading(true);
const { data } = await supabase
.from('custom_fields')
.select('*')
.order('field_name');
setFields((data as CustomField[] | null) ?? []);
setLoading(false);
}, [supabase, accountId]);
// Load the field list on mount once the account is known. The setters
// inside fetchFields run after the Supabase await — not synchronously in
// the effect body — so the cascade the lint rule warns about doesn't apply.
useEffect(() => {
if (accountId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchFields();
}
}, [accountId, fetchFields]);
/** Case-insensitive name clash within the loaded list. */
function isDuplicate(name: string, exceptId?: string): boolean {
const lower = name.toLowerCase();
return fields.some(
(f) => f.id !== exceptId && f.field_name.toLowerCase() === lower
);
}
async function handleCreate() {
const name = newName.trim();
if (!name) return;
if (!accountId || !user) {
toast.error('Your profile is not linked to an account.');
return;
}
if (isDuplicate(name)) {
toast.error(`A field named "${name}" already exists.`);
return;
}
setCreating(true);
const { error } = await supabase.from('custom_fields').insert({
field_name: name,
field_type: 'text',
user_id: user.id,
account_id: accountId,
});
setCreating(false);
if (error) {
toast.error('Could not create field. You may not have permission.');
return;
}
toast.success(`Created "${name}".`);
setNewName('');
await fetchFields();
}
/** Returns true on success so the row can keep the new name, false so it
* reverts to the previous one. No-ops (blank / unchanged) count as success. */
async function handleRename(
field: CustomField,
nextName: string
): Promise<boolean> {
const name = nextName.trim();
if (!name || name === field.field_name) return true;
if (isDuplicate(name, field.id)) {
toast.error(`A field named "${name}" already exists.`);
return false;
}
setBusyId(field.id);
const { error } = await supabase
.from('custom_fields')
.update({ field_name: name })
.eq('id', field.id);
setBusyId(null);
if (error) {
toast.error('Could not rename field.');
return false;
}
await fetchFields();
return true;
}
async function handleDelete(field: CustomField) {
if (
!window.confirm(
`Delete "${field.field_name}"? This also removes its stored value on every contact. This cannot be undone.`
)
) {
return;
}
setBusyId(field.id);
const { error } = await supabase
.from('custom_fields')
.delete()
.eq('id', field.id);
setBusyId(null);
if (error) {
toast.error('Could not delete field.');
return;
}
toast.success(`Deleted "${field.field_name}".`);
await fetchFields();
}
return (
<div className="space-y-4">
{/* Create */}
<div className="flex items-center gap-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleCreate();
}
}}
placeholder="New field name…"
className="bg-muted text-foreground"
/>
<Button
onClick={handleCreate}
disabled={creating || !newName.trim()}
className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0"
>
{creating ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
Add
</Button>
</div>
{/* List */}
<div className="max-h-72 overflow-y-auto rounded-md border border-border">
{loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading…
</div>
) : fields.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No custom fields yet.
</p>
) : (
<ul className="divide-y divide-border">
{fields.map((field) => (
<FieldRow
key={field.id}
field={field}
busy={busyId === field.id}
onRename={handleRename}
onDelete={handleDelete}
/>
))}
</ul>
)}
</div>
</div>
);
}
/** A single editable row. Controlled local state lets us commit on blur /
* Enter and cleanly revert to the last saved name when a rename fails. */
function FieldRow({
field,
busy,
onRename,
onDelete,
}: {
field: CustomField;
busy: boolean;
onRename: (field: CustomField, name: string) => Promise<boolean>;
onDelete: (field: CustomField) => void;
}) {
const [name, setName] = useState(field.field_name);
async function commit() {
if (name.trim() === field.field_name) {
setName(field.field_name); // normalise any whitespace-only edit
return;
}
const ok = await onRename(field, name);
if (!ok) setName(field.field_name);
}
return (
<li className="flex items-center gap-2 px-3 py-2">
<Input
value={name}
disabled={busy}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
aria-label={`Rename ${field.field_name}`}
className="focus:border-primary h-8 border-transparent bg-transparent text-foreground hover:border-border"
/>
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
onClick={() => onDelete(field)}
title="Delete field"
className="shrink-0 text-muted-foreground hover:text-red-400"
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
</Button>
</li>
);
}

View File

@@ -0,0 +1,648 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import {
dedupeByPhone,
isUniqueViolation,
normalizeKey,
} from '@/lib/contacts/dedupe';
import {
parseContactCsv,
type ParsedContactRow,
} from '@/lib/contacts/parse-contact-csv';
import {
assignImportedContactTags,
resolveImportTagIds,
type ContactTagAssignment,
} from '@/lib/contacts/resolve-import-tags';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Upload,
FileText,
Loader2,
CheckCircle,
XCircle,
AlertTriangle,
Tag,
} from 'lucide-react';
const DEFAULT_TAG_COLOR = '#3b82f6';
const PREVIEW_LIMIT = 5;
function truncateFilename(name: string, max = 48): string {
if (name.length <= max) return name;
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
const base = name.slice(0, name.length - ext.length);
const keep = max - ext.length - 1;
return `${base.slice(0, Math.max(keep, 12))}…${ext}`;
}
function PreviewCell({
value,
mono,
maxWidth = 'max-w-[9rem]',
}: {
value: string;
mono?: boolean;
maxWidth?: string;
}) {
return (
<span
className={cn(
'block truncate',
maxWidth,
mono && 'font-mono text-[11px]'
)}
title={value}
>
{value}
</span>
);
}
function ImportPreviewTags({
tagNames,
tagColorByKey,
}: {
tagNames: string[];
tagColorByKey: Map<string, string>;
}) {
if (tagNames.length === 0) {
return <span className="text-muted-foreground">—</span>;
}
return (
<div className="flex min-w-[4.5rem] flex-wrap gap-1">
{tagNames.map((name) => {
const color =
tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR;
const isKnown = tagColorByKey.has(name.trim().toLowerCase());
return (
<span
key={name}
className="inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-[10px] leading-none font-medium"
style={{
backgroundColor: `${color}18`,
color,
border: `1px solid ${color}${isKnown ? '55' : '30'}`,
}}
title={isKnown ? name : `${name} (will be created on import)`}
>
<span
className="size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="truncate">{name}</span>
</span>
);
})}
</div>
);
}
interface ImportModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImported: () => void;
}
export function ImportModal({
open,
onOpenChange,
onImported,
}: ImportModalProps) {
const supabase = createClient();
const { accountId, canEditSettings } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [parsedRows, setParsedRows] = useState<ParsedContactRow[]>([]);
const [hasTagsColumn, setHasTagsColumn] = useState(false);
const [hasCompanyColumn, setHasCompanyColumn] = useState(false);
const [tagColorByKey, setTagColorByKey] = useState<Map<string, string>>(
new Map()
);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{
imported: number;
skipped: number;
failed: number;
tagsAssigned: number;
} | null>(null);
function reset() {
setFile(null);
setParsedRows([]);
setHasTagsColumn(false);
setHasCompanyColumn(false);
setTagColorByKey(new Map());
setResult(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}
function handleOpenChange(next: boolean) {
if (!next) reset();
onOpenChange(next);
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const selected = e.target.files?.[0];
if (!selected) return;
setFile(selected);
setResult(null);
const text = await selected.text();
const {
rows,
hasTagsColumn: csvHasTags,
hasCompanyColumn: csvHasCompany,
} = parseContactCsv(text);
if (rows.length === 0) {
toast.error(
'No valid rows found. Ensure CSV has a "phone" column header.'
);
setParsedRows([]);
setHasTagsColumn(false);
setHasCompanyColumn(false);
setTagColorByKey(new Map());
return;
}
setParsedRows(rows);
setHasTagsColumn(csvHasTags);
setHasCompanyColumn(csvHasCompany);
if (csvHasTags && accountId) {
const { data: tags } = await supabase
.from('tags')
.select('name, color')
.eq('account_id', accountId);
const colors = new Map<string, string>();
for (const tag of tags ?? []) {
const key = tag.name.trim().toLowerCase();
if (!colors.has(key)) colors.set(key, tag.color);
}
setTagColorByKey(colors);
} else {
setTagColorByKey(new Map());
}
}
async function handleImport() {
if (parsedRows.length === 0) return;
setImporting(true);
try {
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) throw new Error('Not authenticated');
if (!accountId)
throw new Error('Your profile is not linked to an account.');
let imported = 0;
let skipped = 0;
let failed = 0;
// 1) De-dupe within the file by normalized phone (keep first).
const { unique, duplicates: inFileDupes } = dedupeByPhone(parsedRows);
skipped += inFileDupes;
// 2) Skip numbers already in this account. One read of the
// generated `phone_normalized` column (migration 022) → Set.
const { data: existingRows } = await supabase
.from('contacts')
.select('phone_normalized')
.eq('account_id', accountId);
const existing = new Set(
(existingRows ?? [])
.map(
(r) => (r as { phone_normalized: string | null }).phone_normalized
)
.filter((p): p is string => !!p)
);
const toInsert = unique.filter((row) => {
if (existing.has(normalizeKey(row.phone))) {
skipped++;
return false;
}
return true;
});
// 3) Resolve tag names → ids (admin+ may auto-create missing tags).
// Skip the round-trip when the import carries no tag names.
const allTagNames = toInsert.flatMap((row) => row.tagNames);
let tagIdByKey = new Map<string, string>();
let skippedNames: string[] = [];
if (allTagNames.length > 0) {
({ tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, {
accountId,
userId: user.id,
tagNames: allTagNames,
canCreateTags: canEditSettings,
}));
}
const tagAssignments: ContactTagAssignment[] = [];
// 4) Batch insert the genuinely-new rows in chunks of 50. The DB
// unique index is the backstop: a 23505 (race, or a format
// that normalizes equal) counts as skipped, not failed.
const chunkSize = 50;
for (let i = 0; i < toInsert.length; i += chunkSize) {
const chunk = toInsert.slice(i, i + chunkSize);
const rows = chunk.map((row) => ({
user_id: user.id,
account_id: accountId,
phone: row.phone,
name: row.name || null,
email: row.email || null,
company: row.company || null,
}));
const { data, error } = await supabase
.from('contacts')
.insert(rows)
.select('id');
if (error) {
// Retry individually so one bad/duplicate row doesn't sink
// the whole chunk.
for (let j = 0; j < rows.length; j++) {
const row = rows[j];
const source = chunk[j];
const { data: singleData, error: singleErr } = await supabase
.from('contacts')
.insert(row)
.select('id')
.single();
if (!singleErr && singleData) {
imported++;
if (source.tagNames.length > 0) {
tagAssignments.push({
contactId: singleData.id,
tagNames: source.tagNames,
});
}
} else if (isUniqueViolation(singleErr)) {
skipped++;
} else {
failed++;
}
}
} else {
const inserted = data ?? [];
imported += inserted.length;
// inserted[j] ↔ chunk[j] only holds because a single INSERT
// preserves RETURNING order. If this path is ever split into
// parallel inserts, zip by phone or returned id instead.
for (let j = 0; j < inserted.length; j++) {
const source = chunk[j];
if (!source || source.tagNames.length === 0) continue;
tagAssignments.push({
contactId: inserted[j].id,
tagNames: source.tagNames,
});
}
}
}
// 5) Wire tags onto the contacts we just created. Failure here must
// not mask a successful contact import.
let tagsAssigned = 0;
try {
tagsAssigned = await assignImportedContactTags(
supabase,
tagAssignments,
tagIdByKey
);
} catch {
toast.warning('Contacts imported, but some tag assignments failed.');
}
setResult({ imported, skipped, failed, tagsAssigned });
if (imported > 0) {
toast.success(
`${imported} contact${imported !== 1 ? 's' : ''} imported`
);
onImported();
}
if (tagsAssigned > 0) {
toast.success(
`${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`
);
}
if (skippedNames.length > 0) {
const sample = skippedNames.slice(0, 3).join(', ');
const more =
skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : '';
toast.info(
`Unknown tags skipped (create them in Settings first): ${sample}${more}`
);
}
if (skipped > 0) {
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`);
}
if (failed > 0) {
toast.error(
`${failed} contact${failed !== 1 ? 's' : ''} failed to import`
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Import failed';
toast.error(message);
} finally {
setImporting(false);
}
}
const preview = parsedRows.slice(0, PREVIEW_LIMIT);
// Tags: OR — show when the CSV declares a column or preview rows carry
// values, so an all-empty tags column still renders for validation.
const previewHasTags =
hasTagsColumn || preview.some((row) => row.tagNames.length > 0);
// Company: AND — hide unless the CSV declares it and preview has data,
// avoiding an all-dash column that wastes horizontal space.
const previewHasCompany =
hasCompanyColumn && preview.some((row) => row.company?.trim());
const tagStats = useMemo(() => {
const names = new Set<string>();
let rowsWithTags = 0;
for (const row of parsedRows) {
if (row.tagNames.length === 0) continue;
rowsWithTags++;
for (const name of row.tagNames) names.add(name.trim().toLowerCase());
}
return { unique: names.size, rowsWithTags };
}, [parsedRows]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[min(90vh,720px)] flex-col gap-0 overflow-hidden border-border/80 bg-popover p-0 text-popover-foreground sm:max-w-2xl">
<div className="shrink-0 space-y-4 border-b border-border/80 px-6 pt-6 pb-5">
<DialogHeader className="gap-1.5">
<DialogTitle className="text-lg text-popover-foreground">
Import Contacts
</DialogTitle>
<DialogDescription className="leading-relaxed text-muted-foreground">
Upload a CSV with a required{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
phone
</code>{' '}
column. Optional:{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
name
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
email
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
company
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
tags
</code>{' '}
(comma-separated; quote multi-tag cells).
</DialogDescription>
</DialogHeader>
<div
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
fileInputRef.current?.click();
}}
className={cn(
'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all',
file
? 'border-primary/35 bg-primary/[0.04]'
: 'hover:border-primary/40 border-border/80 bg-background/40 hover:bg-background/70'
)}
>
{file ? (
<>
<div className="bg-primary/15 ring-primary/25 flex size-10 items-center justify-center rounded-lg ring-1">
<FileText className="text-primary size-5" />
</div>
<p
className="max-w-full truncate px-2 text-sm font-medium text-popover-foreground"
title={file.name}
>
{truncateFilename(file.name)}
</p>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '}
ready
</span>
</>
) : (
<>
<div className="flex size-10 items-center justify-center rounded-lg bg-muted/80 ring-1 ring-border/80 transition-colors group-hover:bg-muted">
<Upload className="size-5 text-muted-foreground group-hover:text-foreground" />
</div>
<p className="text-sm text-muted-foreground">
Click to choose a CSV file
</p>
<p className="text-[11px] text-muted-foreground">
.csv up to your browser limit
</p>
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
onChange={handleFileChange}
className="hidden"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{preview.length > 0 && !result && (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
Preview · first {preview.length}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{tagStats.rowsWithTags > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-muted/90 px-2 py-0.5 text-[11px] text-muted-foreground">
<Tag className="text-primary/80 size-3" />
{tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '}
{tagStats.rowsWithTags} contact
{tagStats.rowsWithTags !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
<div className="overflow-hidden rounded-xl border border-border ring-1 ring-border/50">
<div className="overflow-x-auto">
<table className="w-full min-w-[32rem] text-xs">
<thead>
<tr className="border-b border-border bg-background/60">
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Phone
</th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Name
</th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Email
</th>
{previewHasCompany && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Company
</th>
)}
{previewHasTags && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Tags
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{preview.map((row, i) => (
<tr
key={i}
className="bg-popover/40 transition-colors hover:bg-muted/30"
>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
<PreviewCell
value={row.phone}
mono
maxWidth="max-w-[7.5rem]"
/>
</td>
<td className="px-3 py-2 text-popover-foreground">
<PreviewCell
value={row.name || '—'}
maxWidth="max-w-[8.5rem]"
/>
</td>
<td className="px-3 py-2 text-muted-foreground">
<PreviewCell
value={row.email || '—'}
maxWidth="max-w-[10rem]"
/>
</td>
{previewHasCompany && (
<td className="px-3 py-2 text-muted-foreground">
<PreviewCell
value={row.company || '—'}
maxWidth="max-w-[7rem]"
/>
</td>
)}
{previewHasTags && (
<td className="px-3 py-2 align-top">
<ImportPreviewTags
tagNames={row.tagNames}
tagColorByKey={tagColorByKey}
/>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{parsedRows.length > PREVIEW_LIMIT && (
<p className="text-center text-[11px] text-muted-foreground">
+ {parsedRows.length - PREVIEW_LIMIT} more row
{parsedRows.length - PREVIEW_LIMIT !== 1 ? 's' : ''} not shown
</p>
)}
</div>
)}
{result && (
<div className="rounded-xl border border-border bg-background/50 p-4">
<p className="text-sm font-medium text-popover-foreground">Import complete</p>
<div className="mt-3 flex flex-wrap gap-3">
{result.imported > 0 && (
<div className="text-primary flex items-center gap-1.5 text-sm">
<CheckCircle className="size-4 shrink-0" />
{result.imported} imported
</div>
)}
{result.tagsAssigned > 0 && (
<div className="flex items-center gap-1.5 text-sm text-cyan-400">
<CheckCircle className="size-4 shrink-0" />
{result.tagsAssigned} tag
{result.tagsAssigned !== 1 ? 's' : ''} assigned
</div>
)}
{result.skipped > 0 && (
<div className="flex items-center gap-1.5 text-sm text-amber-400">
<AlertTriangle className="size-4 shrink-0" />
{result.skipped} skipped
</div>
)}
{result.failed > 0 && (
<div className="flex items-center gap-1.5 text-sm text-red-400">
<XCircle className="size-4 shrink-0" />
{result.failed} failed
</div>
)}
</div>
</div>
)}
</div>
<DialogFooter className="mt-0 shrink-0 gap-2 border-t border-border/80 bg-background/50 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
{result ? 'Close' : 'Cancel'}
</Button>
{!result && (
<Button
type="button"
disabled={parsedRows.length === 0 || importing}
onClick={handleImport}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{importing && <Loader2 className="size-4 animate-spin" />}
Import {parsedRows.length > 0 ? parsedRows.length : ''} contact
{parsedRows.length !== 1 ? 's' : ''}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,167 @@
"use client"
import Link from 'next/link'
import { useState } from 'react'
import {
MessageSquare,
UserPlus,
Briefcase,
Radio,
Zap,
Inbox,
} from 'lucide-react'
import type { ComponentType } from 'react'
import type { ActivityItem, ActivityKind } from '@/lib/dashboard/types'
import { cn } from '@/lib/utils'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
interface ActivityFeedProps {
items: ActivityItem[] | null
loading: boolean
}
const PAGE_SIZES = [5, 10, 20, 50] as const
type PageSize = (typeof PAGE_SIZES)[number]
interface KindTheme {
icon: ComponentType<{ className?: string }>
/** Tailwind classes for the round icon badge + label color. */
badge: string
}
const KIND_THEME: Record<ActivityKind, KindTheme> = {
message: { icon: MessageSquare, badge: 'bg-blue-500/10 text-blue-400' },
contact: { icon: UserPlus, badge: 'bg-primary/10 text-primary' },
deal: { icon: Briefcase, badge: 'bg-primary/10 text-primary' },
broadcast: { icon: Radio, badge: 'bg-amber-500/10 text-amber-400' },
automation: { icon: Zap, badge: 'bg-rose-500/10 text-rose-400' },
}
export function ActivityFeed({ items, loading }: ActivityFeedProps) {
// Start at 5 — a quick scan of the most recent events without
// dominating vertical real estate. User expands explicitly via the
// footer control when they want deeper history.
const [pageSize, setPageSize] = useState<PageSize>(5)
const totalLoaded = items?.length ?? 0
const visible = items?.slice(0, pageSize) ?? []
// A size option is "useful" if picking it would reveal rows the
// smaller option doesn't already show. With PAGE_SIZES=[5,10,20,50]:
// "10" is useful only once we've loaded ≥6 items, "20" once ≥11, etc.
// The smallest option is always enabled.
const isSizeUseful = (size: PageSize, i: number) =>
i === 0 || totalLoaded > PAGE_SIZES[i - 1]
return (
<section className="rounded-xl border border-border bg-card">
<header className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">Recent Activity</h2>
<Link
href="/inbox"
className="text-xs font-medium text-primary hover:text-primary/80"
>
View all →
</Link>
</header>
{loading || !items ? (
<div className="space-y-2 p-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : items.length === 0 ? (
<div className="p-5">
<EmptyState
icon={Inbox}
title="No activity yet"
hint="Activity from messages, deals, broadcasts, and automations will appear here."
/>
</div>
) : (
<>
<ul className="divide-y divide-border">
{visible.map((it, i) => {
const theme = KIND_THEME[it.kind]
const Icon = theme.icon
// Alternating row background for scanability. bg-muted/40
// keeps the stripe visible in both light and dark modes
// (bg-card/40 vanishes against a white card surface in light).
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-muted/40'
const row = (
<div className="flex items-center gap-3 px-5 py-2.5">
<span
className={cn(
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full',
theme.badge,
)}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{it.text}
</span>
<span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">
{relativeTime(it.at)}
</span>
</div>
)
return (
<li key={it.id} className={cn(stripe, 'transition-colors hover:bg-muted/40')}>
{it.href ? (
<Link href={it.href} className="block">
{row}
</Link>
) : (
row
)}
</li>
)
})}
</ul>
<footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs">
<span className="text-muted-foreground tabular-nums">
Showing {visible.length} of {totalLoaded}
{totalLoaded === 50 ? '+' : ''}
</span>
<div className="flex items-center gap-1">
<span className="mr-1 text-muted-foreground">Show</span>
{PAGE_SIZES.map((size, i) => {
const disabled = !isSizeUseful(size, i)
return (
<button
key={size}
type="button"
onClick={() => setPageSize(size)}
disabled={disabled}
className={cn(
'rounded-md px-2 py-1 font-medium tabular-nums transition-colors',
pageSize === size
? 'bg-secondary text-secondary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
)}
>
{size}
</button>
)
})}
</div>
</footer>
</>
)}
</section>
)
}
function relativeTime(iso: string): string {
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return ''
const diffSec = Math.round((Date.now() - then) / 1000)
if (diffSec < 60) return `${Math.max(1, diffSec)}s ago`
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
if (diffSec < 2_592_000) return `${Math.floor(diffSec / 86400)}d ago`
return new Date(iso).toLocaleDateString()
}

View File

@@ -0,0 +1,341 @@
"use client"
import { useEffect, useMemo, useRef, useState } from 'react'
import { MessageSquare } from 'lucide-react'
import type { ConversationsSeriesPoint } from '@/lib/dashboard/types'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
import { cn } from '@/lib/utils'
type RangeDays = 7 | 30 | 90
interface ConversationsChartProps {
/** Per-range data, so switching tabs never re-fetches. */
series: Record<RangeDays, ConversationsSeriesPoint[] | null>
loading: boolean
range: RangeDays
onRangeChange: (r: RangeDays) => void
}
// ------------------------------------------------------------
// Layout constants. The SVG renders into a fixed viewBox and scales
// via CSS (preserveAspectRatio default). Everything inside uses
// viewBox coordinates so the drawing math stays simple even as the
// container resizes.
// ------------------------------------------------------------
const VB_W = 760
const VB_H = 240
const PADDING = { top: 16, right: 16, bottom: 28, left: 40 }
export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) {
const data = series[range]
// Memoise the max so per-day hover math doesn't recompute it.
const { maxY, niceTicks } = useMemo(() => {
const arr = data ?? []
const max = arr.reduce(
(m, p) => Math.max(m, p.incoming, p.outgoing),
0,
)
const ceil = niceCeil(max)
const ticks = [0, ceil / 4, ceil / 2, (3 * ceil) / 4, ceil].map((v) =>
Math.round(v),
)
// De-dupe when the series is flat 0.
return { maxY: ceil, niceTicks: Array.from(new Set(ticks)) }
}, [data])
return (
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
<header className="flex items-center justify-between border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">Conversations Over Time</h2>
<p className="mt-0.5 text-xs text-muted-foreground">Daily message volume by direction</p>
</div>
<div className="flex items-center gap-1 rounded-lg bg-muted/60 p-1">
{[7, 30, 90].map((r) => (
<button
key={r}
type="button"
onClick={() => onRangeChange(r as RangeDays)}
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
range === r
? 'bg-secondary text-secondary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{r} days
</button>
))}
</div>
</header>
<div className="p-5">
{loading || !data ? (
<Skeleton className="h-[240px] w-full" />
) : data.every((p) => p.incoming === 0 && p.outgoing === 0) ? (
<EmptyState
icon={MessageSquare}
title="No message activity in this range"
hint="Send or receive messages to start populating this chart."
/>
) : (
<LineSvg data={data} maxY={maxY} ticks={niceTicks} />
)}
</div>
<footer className="flex items-center gap-4 border-t border-border px-5 py-3 text-xs text-muted-foreground">
<LegendDot color="#3b82f6" label="Incoming" />
<LegendDot color="#7c3aed" label="Outgoing" />
</footer>
</section>
)
}
// ------------------------------------------------------------
// The actual SVG. Two polylines + per-day hit targets for hover.
// ------------------------------------------------------------
function LineSvg({
data,
maxY,
ticks,
}: {
data: ConversationsSeriesPoint[]
maxY: number
ticks: number[]
}) {
// Hover state: both the snapped index AND the tooltip's pixel
// offset inside the wrapper div. They're stored together so the
// tooltip positions against the chart's actual rendered pixels,
// not against a raw viewBox percentage. See the precision note on
// the onMove handler below.
const [hover, setHover] = useState<{ idx: number; tooltipLeftPx: number } | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const chartW = VB_W - PADDING.left - PADDING.right
const chartH = VB_H - PADDING.top - PADDING.bottom
// x step can be fractional for 90-day views; points are positioned
// at the center of each "slot" so the first and last points don't
// sit right on the axis.
const stepX = data.length > 1 ? chartW / (data.length - 1) : 0
const yFor = (v: number) =>
maxY === 0 ? PADDING.top + chartH : PADDING.top + chartH - (v / maxY) * chartH
const xFor = (i: number) => PADDING.left + i * stepX
const incomingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.incoming)}`).join(' ')
const outgoingPath = data.map((p, i) => `${i === 0 ? 'M' : 'L'}${xFor(i)},${yFor(p.outgoing)}`).join(' ')
// Mouse-move: use the SVG's current screen-CTM to map clientX
// back to viewBox coordinates. The previous rect-based math
// assumed the viewBox filled the SVG DOM box linearly, but
// `preserveAspectRatio="xMidYMid meet"` (the SVG default)
// letterboxes the content horizontally when the container is
// wider than the viewBox aspect — so hover snapped hundreds of
// pixels off on wide layouts. CTM-inverse correctly accounts for
// letterboxing, scaling, and any future transform changes.
useEffect(() => {
const svg = svgRef.current
const wrap = wrapRef.current
if (!svg || !wrap) return
const onMove = (e: MouseEvent) => {
const ctm = svg.getScreenCTM()
if (!ctm) return
const pt = svg.createSVGPoint()
pt.x = e.clientX
pt.y = e.clientY
const local = pt.matrixTransform(ctm.inverse())
const xVb = local.x
if (xVb < PADDING.left - 8 || xVb > VB_W - PADDING.right + 8) {
setHover(null)
return
}
const relative = xVb - PADDING.left
const idx = Math.max(
0,
Math.min(data.length - 1, Math.round(stepX === 0 ? 0 : relative / stepX)),
)
// Map the snapped data-point's viewBox x back to screen, then
// subtract the wrapper's left edge — that pixel offset is what
// the absolutely-positioned tooltip div consumes. `xFor` is
// inlined here so the effect deps stay stable (it's a closure
// that'd otherwise be a new reference every render).
const dataPointVbX = PADDING.left + idx * stepX
const dataPointPt = svg.createSVGPoint()
dataPointPt.x = dataPointVbX
dataPointPt.y = 0
const screen = dataPointPt.matrixTransform(ctm)
const wrapRect = wrap.getBoundingClientRect()
setHover({ idx, tooltipLeftPx: screen.x - wrapRect.left })
}
const onLeave = () => setHover(null)
svg.addEventListener('mousemove', onMove)
svg.addEventListener('mouseleave', onLeave)
return () => {
svg.removeEventListener('mousemove', onMove)
svg.removeEventListener('mouseleave', onLeave)
}
// xFor + yFor close over stepX, so stepX covers them.
}, [data, stepX])
const hovered = hover !== null ? data[hover.idx] : null
const hoverX = hover !== null ? xFor(hover.idx) : 0
// X-axis label strategy: show ~6 evenly-spaced labels regardless
// of range so the axis never looks crowded.
const labelStride = Math.max(1, Math.ceil(data.length / 6))
return (
<div ref={wrapRef} className="relative w-full">
<svg
ref={svgRef}
viewBox={`0 0 ${VB_W} ${VB_H}`}
className="h-[240px] w-full"
role="img"
aria-label="Conversations per day"
>
{/* Y-axis gridlines + labels */}
{ticks.map((t) => {
const y = yFor(t)
return (
<g key={t}>
<line
x1={PADDING.left}
x2={VB_W - PADDING.right}
y1={y}
y2={y}
stroke="var(--border)"
strokeDasharray="3 3"
/>
<text
x={PADDING.left - 8}
y={y}
textAnchor="end"
dominantBaseline="middle"
className="fill-muted-foreground text-[10px]"
>
{t}
</text>
</g>
)
})}
{/* X-axis labels */}
{data.map((p, i) =>
i % labelStride === 0 ? (
<text
key={p.day}
x={xFor(i)}
y={VB_H - 8}
textAnchor="middle"
className="fill-muted-foreground text-[10px]"
>
{shortDayLabel(p.day)}
</text>
) : null,
)}
{/* Outgoing polyline (violet) */}
<path
d={outgoingPath}
fill="none"
stroke="#7c3aed"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Incoming polyline (blue) */}
<path
d={incomingPath}
fill="none"
stroke="#3b82f6"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Hover crosshair */}
{hover !== null && (
<g pointerEvents="none">
<line
x1={hoverX}
x2={hoverX}
y1={PADDING.top}
y2={PADDING.top + chartH}
stroke="var(--muted-foreground)"
strokeDasharray="3 3"
/>
<circle cx={hoverX} cy={yFor(data[hover.idx].incoming)} r={3.5} fill="#3b82f6" />
<circle cx={hoverX} cy={yFor(data[hover.idx].outgoing)} r={3.5} fill="#7c3aed" />
</g>
)}
</svg>
{/* Tooltip — absolute-positioned div so we get crisp text, not
SVG-rendered text. The left offset comes from the CTM-based
mapping so it lines up with the actual crosshair pixel, not a
letterboxed viewBox percentage. */}
{hovered && hover !== null && (
<div
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-border bg-popover px-2.5 py-1.5 text-[11px] shadow-lg"
style={{ left: `${hover.tooltipLeftPx}px` }}
>
<div className="font-medium text-popover-foreground">{longDayLabel(hovered.day)}</div>
<div className="mt-1 flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-blue-300">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500" />
{hovered.incoming} incoming
</span>
<span className="flex items-center gap-1.5 text-primary">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
{hovered.outgoing} outgoing
</span>
</div>
</div>
)}
</div>
)
}
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full" style={{ background: color }} />
{label}
</span>
)
}
function shortDayLabel(key: string): string {
// key is YYYY-MM-DD; return "Apr 17"-style. Using Date with an
// appended time avoids timezone-shift surprises across midnight.
const [y, m, d] = key.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function longDayLabel(key: string): string {
const [y, m, d] = key.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
/**
* Round `max` up to a "nice" number so Y-axis ticks feel natural
* (1, 2, 5, 10, 20, 50, …). Keeps the chart readable even when the
* series is small (max=3 becomes ceil=4, not 3).
*/
function niceCeil(max: number): number {
if (max <= 0) return 4
const pow = Math.pow(10, Math.floor(Math.log10(max)))
const normalised = max / pow
let nice: number
if (normalised <= 1) nice = 1
else if (normalised <= 2) nice = 2
else if (normalised <= 5) nice = 5
else nice = 10
return nice * pow
}

View File

@@ -0,0 +1,36 @@
import { BarChart3 } from 'lucide-react'
import type { ComponentType } from 'react'
import { cn } from '@/lib/utils'
/**
* Shared empty-state panel for charts that can't render meaningfully
* without a minimum amount of data. Kept minimal and uniform so the
* three empty states on the dashboard don't each feel like a
* different widget.
*/
export function EmptyState({
title = 'Not enough data yet',
hint,
icon: Icon = BarChart3,
className,
}: {
title?: string
hint?: string
icon?: ComponentType<{ className?: string }>
className?: string
}) {
return (
<div
className={cn(
'flex h-full min-h-40 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-card/40 px-4 py-6 text-center',
className,
)}
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
{hint && <p className="max-w-xs text-xs text-muted-foreground">{hint}</p>}
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { ArrowDown, ArrowUp, Minus } from 'lucide-react'
import type { ComponentType } from 'react'
import { cn } from '@/lib/utils'
interface MetricCardProps {
title: string
/** Pre-formatted value for display (e.g. "42" or "$1,250"). */
value: string
icon: ComponentType<{ className?: string }>
/**
* Delta-mode secondary row: arrow + delta text. Omit when the metric
* doesn't have a sensible comparison (e.g. total pipeline value).
*/
delta?: {
/** Positive / negative / zero drives arrow + color. */
sign: number
/** Pre-formatted delta, e.g. "+3 vs yesterday". */
label: string
}
/** Used instead of `delta` when the metric has a static subtitle. */
subtitle?: string
}
export function MetricCard({ title, value, icon: Icon, delta, subtitle }: MetricCardProps) {
return (
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="h-4 w-4" />
</div>
</div>
<p className="mt-3 text-[28px] leading-none font-bold tabular-nums text-foreground">
{value}
</p>
{delta ? <DeltaRow sign={delta.sign} label={delta.label} /> : subtitle ? (
<p className="mt-2 text-sm text-muted-foreground">{subtitle}</p>
) : null}
</div>
)
}
function DeltaRow({ sign, label }: { sign: number; label: string }) {
const tone =
sign > 0
? 'text-primary'
: sign < 0
? 'text-red-400'
: 'text-muted-foreground'
const Arrow = sign > 0 ? ArrowUp : sign < 0 ? ArrowDown : Minus
return (
<div className={cn('mt-2 flex items-center gap-1 text-sm', tone)}>
<Arrow className="h-4 w-4" aria-hidden />
<span className="tabular-nums">{label}</span>
</div>
)
}

View File

@@ -0,0 +1,141 @@
"use client"
import { GitBranch } from 'lucide-react'
import type { PipelineDonutData } from '@/lib/dashboard/types'
import { formatCurrencyShort } from '@/lib/currency'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
interface PipelineDonutProps {
data: PipelineDonutData | null
loading: boolean
/** Account default currency for the totals. */
currency: string
}
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
return (
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
<header className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">Pipeline Value</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Open deals by stage
</p>
</header>
<div className="flex flex-1 flex-col p-5">
{loading || !data ? (
<Skeleton className="h-56 w-full" />
) : data.stages.length === 0 ? (
<EmptyState
icon={GitBranch}
title="No open deals yet"
hint="Create deals in Pipelines to see stage breakdowns here."
/>
) : (
<>
<Donut data={data} currency={currency} />
<ul className="mt-5 space-y-2">
{data.stages.map((s) => (
<li key={s.id} className="flex items-center gap-3 text-xs">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ background: s.color }}
aria-hidden
/>
<span className="flex-1 truncate text-muted-foreground">{s.name}</span>
<span className="text-muted-foreground tabular-nums">
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
</span>
<span className="w-20 text-right text-muted-foreground tabular-nums">
{formatCurrencyShort(s.totalValue, currency)}
</span>
</li>
))}
</ul>
</>
)}
</div>
</section>
)
}
// ------------------------------------------------------------
// SVG ring. 200×200 viewBox, 12px ring width. We draw one <path>
// per stage using an SVG arc from startAngle → endAngle. Gaps
// between segments are implied by a thin slate-900 stroke between
// them for a cleaner look.
// ------------------------------------------------------------
function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) {
const size = 200
const r = 80
const ringWidth = 18
const cx = size / 2
const cy = size / 2
// Small slices would render as slivers that disappear into stroke
// rounding. We give each stage a floor share purely for rendering,
// but keep the labels/legend honest with the actual totals.
const totalRaw = data.totalValue || 1
const minFrac = 0.02
const rawShares = data.stages.map((s) => s.totalValue / totalRaw)
const floored = rawShares.map((x) => Math.max(x, minFrac))
const floorSum = floored.reduce((a, b) => a + b, 0)
const shares = floored.map((x) => x / floorSum)
// Build a cumulative-offset array, then map stages → arc paths. Using
// a pre-computed offsets array avoids the Next 16 React Compiler's
// "Cannot reassign variable after render completes" rule.
const offsets: number[] = [0]
for (let i = 0; i < shares.length; i++) offsets.push(offsets[i] + shares[i])
const segments = data.stages.map((s, i) => {
const start = offsets[i] * Math.PI * 2 - Math.PI / 2
const end = offsets[i + 1] * Math.PI * 2 - Math.PI / 2
return { path: arcPath(cx, cy, r, start, end), color: s.color, id: s.id }
})
return (
<div className="flex items-center justify-center">
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage">
{/* background ring */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} />
{segments.map((seg) => (
<path
key={seg.id}
d={seg.path}
fill="none"
stroke={seg.color}
strokeWidth={ringWidth}
strokeLinecap="butt"
/>
))}
{/* center label */}
<text
x={cx}
y={cy - 6}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
Total
</text>
<text
x={cx}
y={cy + 14}
textAnchor="middle"
className="fill-foreground text-[18px] font-semibold tabular-nums"
>
{formatCurrencyShort(data.totalValue, currency)}
</text>
</svg>
</div>
)
}
function arcPath(cx: number, cy: number, r: number, startRad: number, endRad: number): string {
const x1 = cx + r * Math.cos(startRad)
const y1 = cy + r * Math.sin(startRad)
const x2 = cx + r * Math.cos(endRad)
const y2 = cy + r * Math.sin(endRad)
const largeArc = endRad - startRad > Math.PI ? 1 : 0
return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`
}

View File

@@ -0,0 +1,45 @@
"use client"
import Link from 'next/link'
import { UserPlus, Briefcase, Radio, Zap } from 'lucide-react'
import type { ComponentType } from 'react'
// Quick-action shortcuts. Each navigates to the page that owns the
// relevant "create" flow. We deliberately don't try to auto-open any
// modal on the target page — that'd require touching those pages,
// which is out of scope here.
interface Action {
label: string
href: string
icon: ComponentType<{ className?: string }>
tint: string
}
const ACTIONS: Action[] = [
{ label: 'New Contact', href: '/contacts', icon: UserPlus, tint: 'text-primary' },
{ label: 'New Deal', href: '/pipelines', icon: Briefcase, tint: 'text-blue-400' },
{ label: 'New Broadcast', href: '/broadcasts/new', icon: Radio, tint: 'text-amber-400' },
{ label: 'New Automation', href: '/automations/new', icon: Zap, tint: 'text-primary' },
]
export function QuickActions() {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{ACTIONS.map((a) => {
const Icon = a.icon
return (
<Link
key={a.href}
href={a.href}
className="group flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 transition-colors hover:border-border hover:bg-muted/60"
>
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-muted ${a.tint}`}>
<Icon className="h-4 w-4" />
</div>
<span className="text-sm font-medium text-foreground">{a.label}</span>
</Link>
)
})}
</div>
)
}

View File

@@ -0,0 +1,116 @@
"use client"
import { Clock } from 'lucide-react'
import { DOW_SHORT_MON_FIRST } from '@/lib/dashboard/date-utils'
import type { ResponseTimeSummary } from '@/lib/dashboard/types'
import { BarChart } from '@/components/tremor/bar-chart'
import { EmptyState } from './empty-state'
import { Skeleton } from './skeleton'
interface ResponseTimeChartProps {
data: ResponseTimeSummary | null
loading: boolean
/** Minutes. Surfaced as a "target" pill in the header. The
* hand-rolled SVG version drew this as a horizontal dashed
* line on the chart; Tremor BarChart doesn't expose Recharts
* primitives, so we promote it to the header for now. A
* follow-up can introduce an overlay or extend the vendored
* BarChart with a `referenceLines` prop. */
thresholdMinutes?: number
}
// Single category, single colour — the data is "average minutes
// per weekday". Tremor expects categories as the second tuple in
// the row object, so we shape the buckets into
// `{ day: 'Mon', 'Avg minutes': 4.2 }` rows below.
const CATEGORY = 'Avg minutes'
export function ResponseTimeChart({
data,
loading,
thresholdMinutes = 5,
}: ResponseTimeChartProps) {
const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false
// Map buckets → Tremor rows. Null `avgMinutes` (no samples)
// collapses to 0; the chart will render an empty slot for it.
// We attach `samples` on the row so a future customTooltip can
// surface "no samples" copy without losing the data shape.
const chartData =
data?.buckets.map((b, i) => ({
day: DOW_SHORT_MON_FIRST[i],
[CATEGORY]: b.avgMinutes ?? 0,
samples: b.samples,
})) ?? []
return (
<section className="rounded-xl border border-border bg-card">
<header className="flex items-center justify-between gap-3 border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">
Average First Response Time
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Minutes to reply to a customer&apos;s first unreplied message, by
weekday
</p>
</div>
<div className="flex items-center gap-3 text-right text-xs">
{thresholdMinutes > 0 && (
<span className="rounded-full border border-rose-500/40 bg-rose-500/10 px-2 py-0.5 font-medium text-rose-300 tabular-nums">
target {thresholdMinutes}m
</span>
)}
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
<div>
<div className="text-muted-foreground">
This week:{' '}
<span className="font-medium text-foreground tabular-nums">
{fmt(data.thisWeekAvg)}
</span>
</div>
<div className="text-muted-foreground">
Last week:{' '}
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
</div>
</div>
)}
</div>
</header>
<div className="p-5">
{loading || !data ? (
<Skeleton className="h-[260px] w-full" />
) : !hasData ? (
<EmptyState
icon={Clock}
title="No replies recorded yet"
hint="This chart fills in as you reply to customer messages."
/>
) : (
<BarChart
data={chartData}
index="day"
categories={[CATEGORY]}
// 'violet' maps to Tailwind's `fill-violet-500` — matches
// the brand accent the hand-rolled bars used (#7c3aed).
colors={['violet']}
valueFormatter={(value) => `${value.toFixed(1)}m`}
showLegend={false}
yAxisWidth={48}
// Compact height so the chart sits well inside the card
// without dominating the row alongside the donut + activity feed.
className="h-[260px]"
/>
)}
</div>
</section>
)
}
function fmt(mins: number | null): string {
if (mins == null) return '—'
if (mins < 1) return `${Math.max(1, Math.round(mins * 60))}s`
if (mins < 60) return `${mins.toFixed(1)}m`
return `${(mins / 60).toFixed(1)}h`
}

View File

@@ -0,0 +1,25 @@
import { cn } from '@/lib/utils'
/**
* Shared skeleton primitive — a pulsing slate block sized to whatever
* container it's dropped into. Used by every dashboard widget while
* its data fetches.
*/
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} />
}
export function SkeletonCard({ className }: { className?: string }) {
return (
<div
className={cn(
'rounded-xl border border-border bg-card p-5',
className,
)}
>
<Skeleton className="h-4 w-32" />
<Skeleton className="mt-4 h-8 w-20" />
<Skeleton className="mt-2 h-3 w-16" />
</div>
)
}

View File

@@ -0,0 +1,611 @@
'use client';
/**
* Linear-list flow editor.
*
* Renders the trigger panel, entry-node picker, and the per-node card
* list. Header and validation panel are NOT owned here — they live
* once in FlowEditorShell so they show in both views (lifted in PR 3
* so canvas users can also save + see validator issues).
*
* State lives in the shared `useFlowEditor()` context — toggling
* Canvas ⇄ List never loses edits, and a drag on the canvas updates
* the same nodes the list view reads.
*
* What's still local: the `expanded` set (which cards are open) and
* the scroll refs used when the validator's flashKey changes — those
* are list-only and have no canvas analogue.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CircleAlert,
Plus,
Trash2,
ChevronDown,
ChevronUp,
CornerDownRight,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { type ValidationIssue } from '@/lib/flows/validate';
import {
NODE_META,
NodeIconChip,
groupNodeTypesByCategory,
nodeColors,
slugify,
summarizeNode,
type BuilderNode,
type NodeType,
} from './shared';
import { NodeConfigForm } from './forms/node-config-form';
import { NodeKeySelect } from './forms/fields';
import { IssueLine } from './validation-panel';
import { useFlowEditor, type BuilderState } from './flow-editor-state';
// ============================================================
// Local state shape — mirrors the DB but the configs are typed
// loosely (Record<string, unknown>) since each node_type carries a
// different shape. The sub-form components narrow as needed.
// ============================================================
// ============================================================
// Root component
// ============================================================
export function FlowBuilder() {
const {
state,
setState,
issues,
flashKey,
addNode: addNodeCtx,
updateNode,
updateNodeConfig,
removeNode: removeNodeCtx,
} = useFlowEditor();
// List-only UI state: which cards are expanded + scroll refs for
// jump-to-node. The flash itself is read from context (flashKey)
// so canvas + list share the same source of truth.
const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(state.nodes.map((n) => n.node_key))
);
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Wrap addNode so the new node opens expanded in the list view
// (matches the previous behaviour where adding always revealed the
// new card so the user could start editing immediately).
const addNode = useCallback(
(type: NodeType) => {
const key = addNodeCtx(type);
setExpanded((prev) => new Set([...prev, key]));
},
[addNodeCtx]
);
const removeNode = useCallback(
(key: string) => {
removeNodeCtx(key);
setExpanded((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
},
[removeNodeCtx]
);
const toggleExpanded = useCallback((key: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const setNodeRef = useCallback(
(key: string) => (el: HTMLDivElement | null) => {
if (el) nodeRefs.current.set(key, el);
else nodeRefs.current.delete(key);
},
[]
);
// React to validator jumps via the shared flashKey. We DERIVE the
// expanded-with-flash set (avoids the "setState inside effect"
// smell of mutating `expanded` from a useEffect on flashKey), then
// run a side-effect-only effect to scroll the row into view. The
// flash class is rendered by NodeCard when its key matches
// flashKey; the flash auto-clears in the context so no timer here.
const expandedWithFlash = useMemo(() => {
if (!flashKey || expanded.has(flashKey)) return expanded;
return new Set([...expanded, flashKey]);
}, [expanded, flashKey]);
useEffect(() => {
if (!flashKey) return;
// requestAnimationFrame defers the scroll until after React has
// committed any expand-induced layout shift.
requestAnimationFrame(() => {
const el = nodeRefs.current.get(flashKey);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}, [flashKey]);
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-7">
<TriggerPanel
state={state}
setState={setState}
triggerIssues={issues.filter((i) => i.scope === 'trigger')}
/>
<EntryPicker state={state} setState={setState} />
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-foreground text-sm font-semibold">
Nodes ({state.nodes.length})
</h2>
<AddNodeButton onAdd={addNode} />
</div>
{state.nodes.length === 0 ? (
<div className="border-border bg-card/50 text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm">
Add a <strong>Start</strong> node, then a{' '}
<strong>Send buttons</strong> node, then a <strong>Handoff</strong>{' '}
— that&apos;s the welcome-menu shape from the brief.
</div>
) : (
state.nodes.map((node) => (
<NodeCard
key={node.node_key}
node={node}
allNodes={state.nodes}
expanded={expandedWithFlash.has(node.node_key)}
isEntry={state.entry_node_id === node.node_key}
isFlashed={flashKey === node.node_key}
cardRef={setNodeRef(node.node_key)}
issues={issues.filter(
(i) => i.scope === 'node' && i.node_key === node.node_key
)}
onToggle={() => toggleExpanded(node.node_key)}
onUpdate={(patch) => updateNode(node.node_key, patch)}
onUpdateConfig={(patch) => updateNodeConfig(node.node_key, patch)}
onRemove={() => removeNode(node.node_key)}
onSetEntry={() =>
setState((s) => ({ ...s, entry_node_id: node.node_key }))
}
/>
))
)}
</section>
</div>
);
}
// ============================================================
// Keyword trigger input
// ============================================================
/**
* Comma-separated keyword entry. Keeps a local draft string so the
* comma (and trailing space) the user types survive until they're done
* — parsing into the keywords array on every keystroke stripped the
* trailing comma the instant it was typed, making it impossible to
* start a second keyword (issue #234). We commit on blur / Enter, then
* re-display the cleaned, rejoined form. Seeded once on mount; the
* component unmounts/remounts when the trigger type changes, so the
* seed stays in sync. Mirrors the automations builder's KeywordMatchConfig.
*/
function KeywordsInput({
keywords,
onChange,
}: {
keywords: string[];
onChange: (keywords: string[]) => void;
}) {
const [draft, setDraft] = useState(keywords.join(', '));
function commit() {
const parsed = draft
.split(',')
.map((k) => k.trim())
.filter(Boolean);
setDraft(parsed.join(', '));
onChange(parsed);
}
return (
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
}}
placeholder="support, help, hi"
className="bg-muted"
/>
);
}
// ============================================================
// Trigger panel
// ============================================================
function TriggerPanel({
state,
setState,
triggerIssues,
}: {
state: BuilderState;
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
triggerIssues: ValidationIssue[];
}) {
return (
<section className="border-border bg-card rounded-lg border p-4">
<h2 className="text-foreground mb-3 text-sm font-semibold">Trigger</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="text-muted-foreground mb-1 block text-xs">
When…
</label>
<Select
value={state.trigger_type}
onValueChange={(v) =>
setState((s) => ({
...s,
trigger_type: v as BuilderState['trigger_type'],
trigger_config:
v === 'keyword' ? { keywords: [] } : v === 'manual' ? {} : {},
}))
}
>
<SelectTrigger className="bg-muted">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="keyword">
A message contains a keyword
</SelectItem>
<SelectItem value="first_inbound_message">
Customer&apos;s first ever inbound message
</SelectItem>
<SelectItem value="manual">
Manual only (no auto-trigger)
</SelectItem>
</SelectContent>
</Select>
</div>
{state.trigger_type === 'keyword' && (
<div>
<label className="text-muted-foreground mb-1 block text-xs">
Keywords (comma-separated)
</label>
<KeywordsInput
keywords={
Array.isArray(state.trigger_config.keywords)
? (state.trigger_config.keywords as string[])
: []
}
onChange={(keywords) =>
setState((s) => ({
...s,
trigger_config: { ...s.trigger_config, keywords },
}))
}
/>
</div>
)}
</div>
{triggerIssues.length > 0 && (
<div className="mt-3 flex flex-col gap-1">
{triggerIssues.map((i, ix) => (
<IssueLine key={ix} issue={i} />
))}
</div>
)}
</section>
);
}
// ============================================================
// Entry-node picker
// ============================================================
function EntryPicker({
state,
setState,
}: {
state: BuilderState;
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
}) {
if (state.nodes.length === 0) return null;
return (
<section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3">
<CornerDownRight className="text-primary h-4 w-4 shrink-0" />
<span className="text-muted-foreground text-xs">Entry node:</span>
<NodeKeySelect
value={state.entry_node_id}
nodes={state.nodes}
onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
placeholder="Pick the first node…"
className="max-w-xs flex-1"
/>
</section>
);
}
// ============================================================
// Node card — collapsed summary + expanded config form
// ============================================================
function NodeCard({
node,
allNodes,
expanded,
isEntry,
isFlashed,
cardRef,
issues,
onToggle,
onUpdate,
onUpdateConfig,
onRemove,
onSetEntry,
}: {
node: BuilderNode;
allNodes: BuilderNode[];
expanded: boolean;
isEntry: boolean;
isFlashed: boolean;
cardRef: (el: HTMLDivElement | null) => void;
issues: ValidationIssue[];
onToggle: () => void;
onUpdate: (patch: Partial<BuilderNode>) => void;
onUpdateConfig: (patch: Record<string, unknown>) => void;
onRemove: () => void;
onSetEntry: () => void;
}) {
const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type);
const hasError = issues.some((i) => i.severity === 'error');
const preview = summarizeNode(node);
return (
<div
ref={cardRef}
className={cn(
'bg-card relative overflow-hidden rounded-xl border transition-shadow duration-500',
hasError
? 'border-red-500/40'
: isEntry
? 'border-primary/50'
: 'border-border',
isFlashed && 'ring-primary ring-offset-background ring-2 ring-offset-2'
)}
>
{/* type-colored left rail, ties the list row to the canvas hue */}
<span
className="absolute inset-y-0 left-0 w-[3px]"
style={{ background: c.solid }}
/>
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-3 px-4 py-3 pl-5 text-left"
>
<NodeIconChip type={node.node_type} size={32} iconSize={16} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className="truncate text-[11px] font-semibold tracking-wider uppercase"
style={{ color: c.text }}
>
{meta.label}
</span>
<code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
{node.node_key}
</code>
{isEntry && (
<Badge
variant="outline"
className="border-primary/40 bg-primary/10 text-primary text-[10px]"
>
Entry
</Badge>
)}
</div>
{!expanded && preview && (
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{preview}
</p>
)}
</div>
{hasError && (
<CircleAlert className="h-3.5 w-3.5 shrink-0 text-red-400" />
)}
{expanded ? (
<ChevronUp className="text-muted-foreground h-4 w-4" />
) : (
<ChevronDown className="text-muted-foreground h-4 w-4" />
)}
</button>
{expanded && (
<div className="border-border border-t px-4 py-4">
<NodeConfigWithAdvanced
node={node}
allNodes={allNodes}
onUpdate={onUpdate}
onUpdateConfig={onUpdateConfig}
/>
<div className="border-border mt-4 flex items-center justify-between border-t pt-3">
<div className="flex items-center gap-2">
{!isEntry && (
<Button variant="ghost" size="sm" onClick={onSetEntry}>
Set as entry
</Button>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={onRemove}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Remove node
</Button>
</div>
{issues.length > 0 && (
<div className="mt-3 flex flex-col gap-1 rounded-md bg-red-500/5 p-2">
{issues.map((i, ix) => (
<IssueLine key={ix} issue={i} />
))}
</div>
)}
</div>
)}
</div>
);
}
// ============================================================
// Per-node-type config form — wraps the extracted dispatcher with
// the list-view's "Show advanced" disclosure (which exposes the
// internal node_key for stable analytics, hidden by default).
// ============================================================
function NodeConfigWithAdvanced({
node,
allNodes,
onUpdate,
onUpdateConfig,
}: {
node: BuilderNode;
allNodes: BuilderNode[];
onUpdate: (patch: Partial<BuilderNode>) => void;
onUpdateConfig: (patch: Record<string, unknown>) => void;
}) {
const [showAdvanced, setShowAdvanced] = useState(false);
const hasReplyIds =
node.node_type === 'send_buttons' || node.node_type === 'send_list';
return (
<div className="flex flex-col gap-3">
<NodeConfigForm
node={node}
allNodes={allNodes}
showAdvanced={showAdvanced}
onUpdateConfig={onUpdateConfig}
/>
<div className="border-border border-t pt-3">
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
{showAdvanced ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
{showAdvanced ? 'Hide' : 'Show'} advanced
</button>
{showAdvanced && (
<div className="mt-3 flex flex-col gap-3">
<div>
<label className="text-muted-foreground mb-1 block text-xs">
Node key (internal identifier — keep stable for analytics)
</label>
<Input
value={node.node_key}
onChange={(e) =>
onUpdate({ node_key: slugify(e.target.value, node.node_key) })
}
className="bg-muted font-mono text-xs"
/>
</div>
{hasReplyIds && (
<p className="text-muted-foreground text-[10px]">
Reply IDs for each option are shown inline above. They&apos;re
returned by WhatsApp when a customer taps; you usually
don&apos;t need to touch them.
</p>
)}
</div>
)}
</div>
</div>
);
}
// ============================================================
// Add-node menu
// ============================================================
function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
const types: NodeType[] = [
'start',
'send_buttons',
'send_list',
'send_message',
'send_media',
'collect_input',
'condition',
'set_tag',
'handoff',
'end',
];
return (
<DropdownMenu>
<DropdownMenuTrigger
className="border-border bg-card text-foreground hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
aria-label="Add node"
>
<Plus className="h-3.5 w-3.5" />
Add node
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-border bg-popover">
{groupNodeTypesByCategory(types).map((group, i) => (
<div key={group.id}>
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
{group.label}
</DropdownMenuLabel>
{group.types.map((t) => {
const meta = NODE_META[t];
return (
<DropdownMenuItem key={t} onClick={() => onAdd(t)}>
<meta.icon className={cn('h-3.5 w-3.5', meta.color)} />
{meta.label}
</DropdownMenuItem>
);
})}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,775 @@
'use client';
/**
* Canvas / mind-map view of a flow. Editable, in parity with the
* list view for everything except the trigger / header / fallback
* panels (those are list-only — they don't fit visually inside a
* node graph and the user can switch to List for them).
*
* What this view does:
* - Renders every flow_node as a draggable tile, pan + zoom +
* minimap. Drag positions persist via the editor context
* (writing on dragStop, not every frame).
* - Renders edges between nodes, labeled per slot (button title,
* "true" / "false", list row title) so a branching flow reads
* as a real decision tree.
* - Click a node → side-sheet opens with the same per-node form
* the list view uses, plus "Set as entry" / "Delete".
* - Drag from a source handle on one node to a target handle on
* another → wires that slot's `next_node_key`. Per-slot handles
* for multi-outgoing types (condition, send_buttons, send_list)
* so the user picks which branch they're wiring.
* - Backspace / Delete on a selected node → removes it AND clears
* every inbound `next_node_key` reference (no dangling arrows).
* - Delete on a selected edge → clears just that slot.
* - "+ Add node" floating button drops a new node at the visible
* viewport center.
* - Runs dagre auto-layout once on mount for flows whose
* `position_x` / `position_y` are all zero (pre-canvas flows
* and brand-new flows) — otherwise everything would pile at
* the origin.
*
* The toggle in `flow-editor-shell.tsx` swaps this in for
* `<FlowBuilder>` on the same page. Both views share the same
* `BuilderState` via `useFlowEditor()` — toggling never resets
* unsaved edits, and a drag here updates the same nodes array the
* list view reads.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
applyNodeChanges,
Background,
BackgroundVariant,
Controls,
Handle,
MiniMap,
Panel,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Connection,
type Node as RfNode,
type Edge as RfEdge,
type NodeChange,
type NodeProps,
type OnNodeDrag,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Plus, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
applyEdgeConnection,
deriveCanvasEdges,
outgoingSlots,
} from '@/lib/flows/edges';
import { autoLayout, shouldAutoLayout } from '@/lib/flows/layout';
import {
NODE_META,
NodeIconChip,
groupNodeTypesByCategory,
nodeColors,
summarizeNode,
type BuilderNode,
type NodeType,
} from './shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useFlowEditor } from './flow-editor-state';
import { NodeConfigForm } from './forms/node-config-form';
// React-Flow node `data` payload — the bits our custom renderer needs.
interface NodeData extends Record<string, unknown> {
node: BuilderNode;
isEntry: boolean;
/** Validator's "look here" pulse — flashes the card border for
* ~1.6s. Drives a CSS animation, doesn't change layout. */
isFlashed: boolean;
}
const NODE_WIDTH = 240;
// Best-effort default; actual height varies by summary length but
// dagre needs SOMETHING to compute rank spacing. Underestimating is
// safer than over (tighter layout that still doesn't overlap).
const NODE_HEIGHT = 90;
// ============================================================
// Custom node — one card per flow node, styled to match the list
// view's collapsed card so the two views feel like the same product.
// ============================================================
// Echo the design's green/red branch ports for the condition node's
// true / false slots; every other slot inherits the node's own hue.
// The true/false hues are derived from the start (emerald) and handoff
// (rose) node colors so all branch/port colors stay single-sourced in
// NODE_HUE — a palette tweak there can't leave these stale.
function slotColor(nodeType: NodeType, slotId: string, fallback: string) {
if (nodeType === 'condition' && slotId === 'true') {
return nodeColors('start').solid;
}
if (nodeType === 'condition' && slotId === 'false') {
return nodeColors('handoff').solid;
}
return fallback;
}
function FlowNodeCard({ data, selected }: NodeProps) {
const { node, isEntry, isFlashed } = data as NodeData;
const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type);
const summary = summarizeNode(node);
const slots = outgoingSlots(node);
// Start nodes are entry-only; nothing ever targets them, so they
// don't need an incoming Handle. Every other node type accepts
// incoming edges (including terminal handoff / end — they're the
// common targets).
const hasTarget = node.node_type !== 'start';
// Single-slot nodes get a single source handle floated on the right
// edge of the card. Multi-slot nodes (condition, send_buttons,
// send_list) render slot rows inline so each handle visually sits
// next to the slot it represents.
const isMultiSlot = slots.length > 1;
return (
<div
style={
{
'--nc': c.solid,
'--nc-soft': c.soft,
'--nc-ring': c.ring,
'--nc-text': c.text,
borderColor: selected ? c.solid : undefined,
boxShadow: selected
? `0 0 0 1px ${c.solid}, 0 14px 36px -12px ${c.ring}`
: undefined,
} as React.CSSProperties
}
className={cn(
'bg-card relative max-w-[260px] min-w-[220px] rounded-xl border px-3.5 py-3 text-left shadow-[0_2px_6px_rgba(0,0,0,0.18)] transition-[box-shadow,border-color]',
selected
? 'border-[var(--nc)]'
: 'border-border hover:border-[var(--nc-ring)]',
// Flash overrides hover/selected colors briefly. Tailwind's
// built-in `animate-pulse` is too gentle; a ring with the
// amber accent matches the list view's flash semantics.
isFlashed && '!border-amber-400 ring-2 ring-amber-400/60'
)}
>
{hasTarget && (
<Handle
type="target"
position={Position.Left}
className="!bg-card !h-2.5 !w-2.5 !border-2 !border-[var(--nc-ring)]"
/>
)}
<div className="flex items-center gap-2">
<NodeIconChip
type={node.node_type}
size={24}
iconSize={14}
className="rounded-md"
/>
<span
className="truncate text-[10.5px] font-semibold tracking-wider uppercase"
style={{ color: c.text }}
>
{meta.label}
</span>
{isEntry && (
<span className="border-border text-muted-foreground ml-auto rounded border px-1.5 py-0.5 text-[8.5px] font-bold tracking-[0.1em] uppercase">
Entry
</span>
)}
</div>
<div className="text-muted-foreground mt-2 truncate font-mono text-[11px]">
{node.node_key}
</div>
{summary && (
<div className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
{summary}
</div>
)}
{isMultiSlot && (
<div className="border-border mt-2.5 flex flex-col gap-1 border-t pt-2.5">
{slots.map((slot) => (
<div
key={slot.id}
className="text-muted-foreground relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px]"
>
<span className="truncate" title={slot.label}>
{slot.label}
</span>
<Handle
type="source"
id={slot.id}
position={Position.Right}
style={{
borderColor: slotColor(node.node_type, slot.id, c.solid),
}}
// Override default absolute positioning so the handle
// sits flush with the right edge of the card instead
// of floating at vertical center. The negative offset
// matches the card's px-3 + the handle's own radius.
className="!bg-card !relative !top-auto !right-auto !h-2.5 !w-2.5 !translate-x-[14px] !transform-none !border-2"
/>
</div>
))}
</div>
)}
{!isMultiSlot && slots.length === 1 && (
<Handle
type="source"
id={slots[0].id}
position={Position.Right}
style={{ borderColor: c.solid }}
className="!bg-card !h-2.5 !w-2.5 !border-2"
/>
)}
</div>
);
}
const NODE_TYPES = { flow: FlowNodeCard };
// ============================================================
// Root canvas
// ============================================================
/**
* Outer wrapper provides the React-Flow context to the inner body,
* so `useReactFlow()` works from anywhere in `FlowCanvasInner`
* (notably, the pan-to-flash effect). The split is required because
* useReactFlow() must be called inside a ReactFlowProvider.
*/
export function FlowCanvas() {
return (
<ReactFlowProvider>
<FlowCanvasInner />
</ReactFlowProvider>
);
}
function FlowCanvasInner() {
const {
state,
setState,
updateNodeConfig,
updateNodePosition,
updateNodePositions,
removeNode,
flashKey,
} = useFlowEditor();
const reactFlow = useReactFlow();
const builderNodes = state.nodes;
const entryNodeId = state.entry_node_id;
// Side-panel state — which node's form is open. Canvas-only UI; the
// list view's analogue is the per-card expanded set in
// flow-builder.tsx.
const [selectedNodeKey, setSelectedNodeKey] = useState<string | null>(null);
const selectedNode = useMemo(
() =>
selectedNodeKey
? (builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null)
: null,
[selectedNodeKey, builderNodes]
);
const autoLayoutPositions = useMemo(() => {
const canvasEdges = deriveCanvasEdges(builderNodes);
return shouldAutoLayout(builderNodes)
? autoLayout(
builderNodes.map((n) => ({
id: n.node_key,
width: NODE_WIDTH,
height: NODE_HEIGHT,
})),
canvasEdges.map((e) => ({ source: e.source, target: e.target })),
{ direction: 'TB' }
)
: null;
}, [builderNodes]);
// If dagre had to place an all-zero flow, persist the generated
// positions into editor state once. Otherwise the next drag would
// save only the dragged node and every other node would fall back
// to (0,0), which feels like nodes teleporting around the canvas.
const persistedAutoLayoutRef = useRef(false);
useEffect(() => {
if (!autoLayoutPositions || persistedAutoLayoutRef.current) return;
persistedAutoLayoutRef.current = true;
updateNodePositions(
Object.fromEntries(
[...autoLayoutPositions].map(([key, pos]) => [key, pos])
)
);
}, [autoLayoutPositions, updateNodePositions]);
const derivedRfNodes = useMemo(() => {
const nodes: RfNode<NodeData>[] = builderNodes.map((n) => {
const fallback = autoLayoutPositions?.get(n.node_key);
return {
id: n.node_key,
type: 'flow',
position: {
x: fallback?.x ?? n.position_x ?? 0,
y: fallback?.y ?? n.position_y ?? 0,
},
data: {
node: n,
isEntry: n.node_key === entryNodeId,
isFlashed: n.node_key === flashKey,
},
};
});
return nodes;
}, [builderNodes, entryNodeId, flashKey, autoLayoutPositions]);
const [rfNodes, setRfNodes] = useState<RfNode<NodeData>[]>(derivedRfNodes);
useEffect(() => {
setRfNodes(derivedRfNodes);
}, [derivedRfNodes]);
const rfEdges = useMemo(() => {
const canvasEdges = deriveCanvasEdges(builderNodes);
// sourceHandle is now wired up — the FlowNodeCard renders a Handle
// per slot whose id matches the scheme in edges.ts, so React-Flow
// can hang the arrow off the right place on each card.
const rfEdges: RfEdge[] = canvasEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle,
label: e.label,
// Mode-aware via CSS tokens so edge chrome flips with light/dark.
labelStyle: { fill: 'var(--muted-foreground)', fontSize: 11 },
labelBgStyle: { fill: 'var(--card)' },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
style: { stroke: 'var(--border)', strokeWidth: 1.5 },
}));
return rfEdges;
}, [builderNodes]);
const handleNodesChange = useCallback(
(changes: NodeChange<RfNode<NodeData>>[]) => {
setRfNodes((nodes) => applyNodeChanges(changes, nodes));
},
[]
);
// Drag-to-position: React-Flow tracks the visual drag internally and
// fires this once on release. We write the final coordinate back to
// the editor context (which flips `dirty`); save then ships the new
// positions in the existing PUT /api/flows/[id] body (the route
// already destructures position_x / position_y per migration 010).
// Writing only on dragStop (not on every position-change tick during
// the drag) keeps state updates cheap on long drags.
const handleNodeDragStop = useCallback<OnNodeDrag<RfNode<NodeData>>>(
(_event, node) => {
updateNodePosition(node.id, node.position.x, node.position.y);
},
[updateNodePosition]
);
// Pan to the flashed node when the validator panel requests one.
// Animate over 400ms; landing zoom is whatever the user already has
// (don't force a zoom reset — that would be jarring mid-edit).
useEffect(() => {
if (!flashKey) return;
const node = builderNodes.find((n) => n.node_key === flashKey);
if (!node) return;
const x = (node.position_x ?? 0) + NODE_WIDTH / 2;
const y = (node.position_y ?? 0) + NODE_HEIGHT / 2;
reactFlow.setCenter(x, y, {
zoom: reactFlow.getZoom(),
duration: 400,
});
}, [flashKey, builderNodes, reactFlow]);
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: RfNode<NodeData>) => {
setSelectedNodeKey(node.id);
},
[]
);
// Drag-to-connect: React-Flow fires onConnect when the user drops a
// handle drag onto a target handle. We look up the source node,
// compute the right config patch via applyEdgeConnection (matches
// the same slot scheme as deriveCanvasEdges), and dispatch via
// updateNodeConfig. The resulting state change re-derives edges on
// the next render — no need to maintain a separate edge list.
const handleConnect = useCallback(
(connection: Connection) => {
if (
!connection.source ||
!connection.target ||
!connection.sourceHandle
) {
return;
}
const sourceNode = builderNodes.find(
(n) => n.node_key === connection.source
);
if (!sourceNode) return;
// Self-loops are a footgun (a button whose target is its own
// node = infinite reprompt). Reject silently — the user can
// still wire one via the per-node dropdown if they really want.
if (connection.source === connection.target) return;
const patch = applyEdgeConnection(
sourceNode,
connection.sourceHandle,
connection.target
);
if (patch) updateNodeConfig(connection.source, patch);
},
[builderNodes, updateNodeConfig]
);
// Keyboard delete (Backspace / Delete) + drag-to-trash. React-Flow
// fires this with the set of deleted-node objects; we route each
// through the editor context's removeNode (which now also unlinks
// inbound references so no dangling arrows survive). Closing the
// side panel on delete keeps the UI honest if the user deleted the
// node currently being edited.
const handleNodesDelete = useCallback(
(deleted: RfNode<NodeData>[]) => {
for (const n of deleted) {
removeNode(n.id);
if (selectedNodeKey === n.id) setSelectedNodeKey(null);
}
},
[removeNode, selectedNodeKey]
);
// Edge delete: clear the source node's slot rather than removing
// anything. Edges are derived from configs, so the only way to
// "delete" one is to null out its underlying next_node_key.
const handleEdgesDelete = useCallback(
(deleted: RfEdge[]) => {
for (const e of deleted) {
if (!e.sourceHandle) continue;
const sourceNode = builderNodes.find((n) => n.node_key === e.source);
if (!sourceNode) continue;
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, '');
if (patch) updateNodeConfig(e.source, patch);
}
},
[builderNodes, updateNodeConfig]
);
// Wrapped mutators that target the currently-selected node — pass to
// the form so each keystroke goes through the editor context (which
// flips `dirty` and feeds the validator).
const onSelectedUpdateConfig = useCallback(
(patch: Record<string, unknown>) => {
if (selectedNodeKey) updateNodeConfig(selectedNodeKey, patch);
},
[selectedNodeKey, updateNodeConfig]
);
const handleDeleteSelected = useCallback(() => {
if (!selectedNodeKey) return;
removeNode(selectedNodeKey);
setSelectedNodeKey(null);
}, [selectedNodeKey, removeNode]);
const handleSetEntry = useCallback(() => {
if (!selectedNodeKey) return;
setState((s) => ({ ...s, entry_node_id: selectedNodeKey }));
}, [selectedNodeKey, setState]);
if (rfNodes.length === 0) {
return (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-sm">
<p>No nodes yet.</p>
<CanvasAddNodeButton />
</div>
);
}
return (
<>
<div className="h-full w-full overflow-hidden">
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodeTypes={NODE_TYPES}
fitView
fitViewOptions={{ padding: 0.2, maxZoom: 1 }}
proOptions={{ hideAttribution: true }}
onNodesChange={handleNodesChange}
onNodeDragStop={handleNodeDragStop}
onNodeClick={handleNodeClick}
onConnect={handleConnect}
onNodesDelete={handleNodesDelete}
onEdgesDelete={handleEdgesDelete}
// Default is "Backspace" only — accept both so Mac users
// hitting Delete (Fn+Backspace) get the same behavior.
deleteKeyCode={['Backspace', 'Delete']}
nodesConnectable={true}
edgesFocusable={true}
elementsSelectable={true}
// Lower default min/max zoom than the lib's defaults; the
// tiles already truncate their summary at a reasonable
// size, so we don't need to zoom past 1.5x.
minZoom={0.2}
maxZoom={1.5}
>
{/* Dot grid, matching the design's faint canvas backdrop. */}
<Background
variant={BackgroundVariant.Dots}
gap={22}
size={1.4}
color="var(--border)"
/>
<Controls
className="!border-border !bg-card [&_button]:!border-border [&_button]:!bg-card [&_button:hover]:!bg-muted [&_button_svg]:!fill-foreground !overflow-hidden !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
showInteractive={false}
/>
<MiniMap
pannable
zoomable
nodeColor={(n) =>
nodeColors((n.data as NodeData).node.node_type).solid
}
nodeStrokeWidth={0}
nodeBorderRadius={3}
maskColor="color-mix(in oklch, var(--background) 70%, transparent)"
className="!border-border !bg-card !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
/>
<Panel position="top-left" className="!top-4 !left-4">
<CanvasAddNodeButton />
</Panel>
</ReactFlow>
</div>
<NodeEditSheet
node={selectedNode}
isEntry={selectedNode?.node_key === entryNodeId}
allNodes={builderNodes}
onClose={() => setSelectedNodeKey(null)}
onUpdateConfig={onSelectedUpdateConfig}
onDelete={handleDeleteSelected}
onSetEntry={handleSetEntry}
/>
</>
);
}
// ============================================================
// Side panel — opens when a canvas node is clicked. Mounts the
// shared NodeConfigForm dispatcher so edits made here behave
// identically to the list view's per-card editor.
// ============================================================
function NodeEditSheet({
node,
isEntry,
allNodes,
onClose,
onUpdateConfig,
onDelete,
onSetEntry,
}: {
node: BuilderNode | null;
isEntry: boolean;
allNodes: BuilderNode[];
onClose: () => void;
onUpdateConfig: (patch: Record<string, unknown>) => void;
onDelete: () => void;
onSetEntry: () => void;
}) {
// Sheet is controlled — opens when a node is selected, closes via
// Esc / overlay / close button (all delegated to onClose).
const open = node !== null;
if (!node) {
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent side="right" className="w-full sm:max-w-md" />
</Sheet>
);
}
const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type);
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="border-border bg-popover flex w-full flex-col gap-0 border-l p-0 sm:max-w-md"
>
<SheetHeader className="border-border flex-row items-center gap-3 space-y-0 border-b px-5 py-4">
<NodeIconChip type={node.node_type} size={36} iconSize={18} />
<div className="min-w-0 flex-1">
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold tracking-wider uppercase">
<span style={{ color: c.text }}>{meta.label}</span>
{isEntry && (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-emerald-300 uppercase">
Entry
</span>
)}
</SheetTitle>
<SheetDescription className="text-muted-foreground mt-0.5 text-xs">
{meta.blurb}
</SheetDescription>
</div>
<code className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
{node.node_key}
</code>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-5 py-4">
<NodeConfigForm
node={node}
allNodes={allNodes}
showAdvanced={false}
onUpdateConfig={onUpdateConfig}
/>
</div>
<SheetFooter className="border-border border-t px-5 py-3 sm:flex-row sm:justify-between">
{!isEntry ? (
<Button variant="ghost" size="sm" onClick={onSetEntry}>
Set as entry
</Button>
) : (
<span />
)}
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Delete node
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
// ============================================================
// Floating add-node button — bottom-right of the canvas. Mirrors
// the list view's AddNodeButton (same dropdown menu, same NodeType
// list, same icons via NODE_META) but drops the new node into the
// center of the visible viewport rather than appending to a list.
// ============================================================
const ADD_NODE_TYPES: NodeType[] = [
'start',
'send_buttons',
'send_list',
'send_message',
'send_media',
'collect_input',
'condition',
'set_tag',
'handoff',
'end',
];
function CanvasAddNodeButton() {
const reactFlow = useReactFlow();
const { addNode, updateNodePosition } = useFlowEditor();
const handleAdd = (type: NodeType) => {
const key = addNode(type);
// Place the new node at the visible canvas center. The Panel's
// own DOM lives inside ReactFlow so we can climb up to find the
// .react-flow root and read its bounding rect. If we can't find
// it (test envs, etc.), addNode's default (0, 0) is the fallback
// and the user can drag the node into view.
const root = document.querySelector('.react-flow') as HTMLElement | null;
if (!root) return;
const rect = root.getBoundingClientRect();
const center = reactFlow.screenToFlowPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
// NODE_WIDTH / NODE_HEIGHT are the dagre layout defaults; offset
// so the card sits visually centered rather than top-left at the
// viewport center.
updateNodePosition(
key,
center.x - NODE_WIDTH / 2,
center.y - NODE_HEIGHT / 2
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger
className="bg-primary text-primary-foreground hover:bg-primary-hover inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] font-medium shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors"
aria-label="Add node"
>
<Plus className="h-4 w-4" />
Add node
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="border-border bg-popover w-[268px] p-1.5"
>
{groupNodeTypesByCategory(ADD_NODE_TYPES).map((group, i) => (
<div key={group.id}>
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-muted-foreground px-2 py-1.5 text-[11px] font-semibold tracking-wider uppercase">
{group.label}
</DropdownMenuLabel>
{group.types.map((t) => {
const meta = NODE_META[t];
return (
<DropdownMenuItem
key={t}
onClick={() => handleAdd(t)}
className="gap-3 py-2"
>
<NodeIconChip
type={t}
size={28}
iconSize={16}
className="rounded-md"
/>
<span className="flex flex-col">
<span className="text-popover-foreground text-[13px] font-semibold">
{meta.label}
</span>
<span className="text-muted-foreground text-[11.5px]">
{meta.blurb}
</span>
</span>
</DropdownMenuItem>
);
})}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,208 @@
"use client";
/**
* View-switcher + chrome for the flow editor.
*
* Lays the editor out as one app-like column that fills the dashboard
* content area (toolbar → mode row → stage → validation bar), matching
* the Flow Builder design handoff:
* - A segmented Canvas / List control on the left of the mode row.
* - A node-type legend on the right so the canvas's per-type colors
* are decodable at a glance.
* - The active view is mounted inside a rounded "stage" that owns its
* own scroll/overflow, so the canvas can fill available height and
* the list scrolls internally.
*
* Why a separate component:
* - The page itself stays trivially small (loading + error + this).
* - Either view can stay unaware of the other — they share data
* (`{flow, nodes}`) and nothing else.
*
* View choice persists per-browser via localStorage so a power user
* who prefers the list isn't fighting the default on every load.
* Canvas is the default for everyone else — the original user
* feedback was that the list shape made flows "hard to understand".
*/
import { useEffect, useState } from "react";
import { GitFork, List } from "lucide-react";
import { FlowBuilder } from "./flow-builder";
import { FlowCanvas } from "./flow-canvas";
import { FlowEditorProvider } from "./flow-editor-state";
import { EditorHeader } from "./header";
import { ValidationPanel } from "./validation-panel";
import { NODE_META, nodeColors, type NodeType } from "./shared";
import { cn } from "@/lib/utils";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
/**
* Below this viewport width we force list view and hide the toggle.
* Canvas with drag-to-connect on a phone is unusable — handles are
* ~10px and live finger drags from one node to another aren't a
* practical workflow. Matches Tailwind's `md` breakpoint.
*/
const MOBILE_BREAKPOINT = "(max-width: 767px)";
type View = "canvas" | "list";
const STORAGE_KEY = "wacrm.flowEditor.view";
// Legend covers every node type, derived from NODE_META so a new type
// can't silently go undocumented. NODE_META's key order already reads
// the way a flow flows: start → talk → capture → branch → mutate → end.
const LEGEND_TYPES = Object.keys(NODE_META) as NodeType[];
interface Props {
initialFlow: FlowRow;
initialNodes: FlowNodeRow[];
}
export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
// Read the persisted choice in the useState initializer. Safe even
// though this is a client component because the parent page only
// mounts us AFTER a client-side fetch resolves — there's no SSR
// pass for this subtree, so no hydration mismatch to worry about.
// Default to `canvas` (the new default) when nothing is saved.
const [view, setView] = useState<View>(() => {
try {
const saved = window.localStorage.getItem(STORAGE_KEY);
if (saved === "canvas" || saved === "list") return saved;
} catch {
// Private browsing / disabled storage — fall through to default.
}
return "canvas";
});
// Live mobile detection. We don't render canvas under the
// breakpoint regardless of `view` — but we keep `view` itself
// intact so the user's preference comes back when they widen
// again (e.g. rotating a tablet, resizing a window).
const isMobile = useMatchMedia(MOBILE_BREAKPOINT);
const effectiveView: View = isMobile ? "list" : view;
const choose = (next: View) => {
setView(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// ignore
}
};
return (
<FlowEditorProvider initialFlow={initialFlow} initialNodes={initialNodes}>
<div className="flex h-full min-h-0 flex-col">
<EditorHeader />
{/* ---- mode row: view toggle + node-type legend ----
Omitted entirely on mobile (canvas is unavailable there and
the legend is lg-only), so there's no empty band above the
stage on small screens. */}
{!isMobile && (
<div className="flex items-center gap-4 px-6 py-3.5">
<div
role="group"
aria-label="Editor view"
className="inline-flex gap-0.5 rounded-lg border border-border bg-muted p-0.5"
>
<SegButton
active={effectiveView === "canvas"}
onClick={() => choose("canvas")}
icon={<GitFork className="h-3.5 w-3.5" />}
label="Canvas"
/>
<SegButton
active={effectiveView === "list"}
onClick={() => choose("list")}
icon={<List className="h-3.5 w-3.5" />}
label="List"
/>
</div>
<div className="ml-auto hidden flex-wrap items-center gap-x-3.5 gap-y-1.5 lg:flex">
{LEGEND_TYPES.map((t) => (
<span
key={t}
className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground"
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ background: nodeColors(t).solid }}
/>
{NODE_META[t].label}
</span>
))}
</div>
</div>
)}
{/* ---- stage: the active view, owning its own overflow ---- */}
<div className="relative mx-6 min-h-0 flex-1 overflow-hidden rounded-xl border border-border bg-card-2">
{effectiveView === "canvas" ? (
<FlowCanvas />
) : (
<div className="absolute inset-0 overflow-y-auto">
<FlowBuilder />
</div>
)}
</div>
{/* ---- validation / activate-readiness bar ---- */}
<div className="px-6 pb-5 pt-3">
<ValidationPanel />
</div>
</div>
</FlowEditorProvider>
);
}
/**
* Tiny `useMatchMedia` shim. We could pull in `react-responsive` but
* this is the only consumer and matchMedia is one of those browser
* APIs that doesn't need a dependency.
*/
function useMatchMedia(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
// Safari < 14 still uses addListener; addEventListener is the
// modern path. Both fire identically.
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
function SegButton({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[12.5px] font-medium transition-colors",
active
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{icon}
{label}
</button>
);
}

View File

@@ -0,0 +1,134 @@
import { describe, it, expect } from "vitest";
import {
applyNodePositions,
defaultConfigFor,
uniqueNodeKey,
} from "./flow-editor-state";
import type { BuilderNode, NodeType } from "./shared";
describe("uniqueNodeKey", () => {
it("returns the base key when it isn't taken", () => {
expect(uniqueNodeKey("menu", [])).toBe("menu");
expect(
uniqueNodeKey("menu", [
{ node_key: "other", node_type: "end", config: {} },
]),
).toBe("menu");
});
it("appends _2 when the base is taken", () => {
expect(
uniqueNodeKey("menu", [
{ node_key: "menu", node_type: "end", config: {} },
]),
).toBe("menu_2");
});
it("walks forward until it finds an unused suffix", () => {
const existing: BuilderNode[] = [
{ node_key: "menu", node_type: "end", config: {} },
{ node_key: "menu_2", node_type: "end", config: {} },
{ node_key: "menu_3", node_type: "end", config: {} },
];
expect(uniqueNodeKey("menu", existing)).toBe("menu_4");
});
});
describe("applyNodePositions", () => {
it("rounds and applies positions without changing unrelated nodes", () => {
const nodes: BuilderNode[] = [
{
node_key: "start",
node_type: "start",
config: {},
position_x: 0,
position_y: 0,
},
{
node_key: "message",
node_type: "send_message",
config: {},
position_x: 10,
position_y: 20,
},
];
expect(
applyNodePositions(nodes, {
start: { x: 10.4, y: 20.6 },
}),
).toEqual([
{
node_key: "start",
node_type: "start",
config: {},
position_x: 10,
position_y: 21,
},
{
node_key: "message",
node_type: "send_message",
config: {},
position_x: 10,
position_y: 20,
},
]);
});
});
describe("defaultConfigFor", () => {
// The hook's addNode and the validator both depend on these defaults
// being self-consistent. A broken default would surface as a
// validation error on a freshly-added node, which is exactly what
// these snapshots guard against.
const types: NodeType[] = [
"start",
"send_message",
"send_buttons",
"send_list",
"send_media",
"collect_input",
"condition",
"set_tag",
"handoff",
"end",
];
it("returns an object for every known node type", () => {
for (const t of types) {
expect(typeof defaultConfigFor(t)).toBe("object");
}
});
it("send_buttons default has at least one button row", () => {
const cfg = defaultConfigFor("send_buttons") as {
buttons?: Array<{ reply_id: string; title: string }>;
};
expect(cfg.buttons?.length).toBeGreaterThan(0);
expect(cfg.buttons?.[0].reply_id).toBeTruthy();
});
it("send_list default has at least one section with one row", () => {
const cfg = defaultConfigFor("send_list") as {
sections?: Array<{ rows: unknown[] }>;
};
expect(cfg.sections?.length).toBeGreaterThan(0);
expect(cfg.sections?.[0].rows.length).toBeGreaterThan(0);
});
it("send_media defaults to image (the most common case)", () => {
const cfg = defaultConfigFor("send_media") as { media_type?: string };
expect(cfg.media_type).toBe("image");
});
it("collect_input ships a valid var_key that passes the validator regex", () => {
const cfg = defaultConfigFor("collect_input") as { var_key?: string };
// Mirrors the regex in validate.ts: alphanumeric + underscore,
// starts with letter or underscore.
expect(cfg.var_key).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/);
});
it("end's default is an empty object (terminal — no config)", () => {
expect(defaultConfigFor("end")).toEqual({});
});
});

View File

@@ -0,0 +1,565 @@
"use client";
/**
* Single source of truth for the flow editor's state.
*
* Both views (list and canvas) read and mutate the same `BuilderState`
* via `useFlowEditor()`. The provider mounts once inside
* `FlowEditorShell`, so toggling views never resets unsaved edits.
*
* What lives here:
* - `BuilderState` shape (header fields, trigger config, nodes).
* - Dirty / saving / activating flags so the header save button
* and the beforeunload guard share the same source.
* - All mutations: name / description / trigger / fallback,
* addNode / updateNode / updateNodeConfig / updateNodePosition /
* removeNode, setEntryNodeId.
* - Side effects: save (PUT), setStatus (POST /activate),
* deleteFlow (DELETE then router.push).
* - Validation issues + the canActivate boolean.
*
* What does NOT live here:
* - List-view UI state (expanded card set, scroll refs,
* flash-on-jump) — those are list-only and stay in
* `flow-builder.tsx`.
* - Canvas-view UI state (selected node id, side-sheet open) —
* those are canvas-only and stay in `flow-canvas.tsx`.
*
* `removeNode` does NOT auto-clean inbound edges. The list-view's
* NodeKeySelect dropdowns and the validator both surface dangling
* `next_node_key` references; that visibility is enough for v1. PR 2b
* (canvas delete via keyboard) will revisit if the canvas adds an
* implicit-delete affordance that's easier to trip accidentally.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
validateFlowForActivation,
type ValidationIssue,
} from "@/lib/flows/validate";
import { unlinkNodeReferences } from "@/lib/flows/edges";
import type { FlowNodeRow, FlowRow } from "@/lib/flows/types";
import { NODE_META, slugify, type BuilderNode, type NodeType } from "./shared";
// ============================================================
// State shape
// ============================================================
export interface BuilderState {
name: string;
description: string;
trigger_type: "keyword" | "first_inbound_message" | "manual";
trigger_config: Record<string, unknown>;
entry_node_id: string | null;
status: FlowRow["status"];
nodes: BuilderNode[];
}
export interface FlowEditorContextValue {
/** Immutable post-load envelope: id, created_at, fallback_policy, etc. */
flow: FlowRow;
// Authored state
state: BuilderState;
/**
* Dirty-tracking React setState. Flips `dirty` on every call. Used
* by the list view's existing subcomponents (Header, TriggerPanel,
* EntryPicker) which mutate multiple fields atomically — granular
* setters below would force them to fan out the update.
*/
setState: (
updaterOrValue:
| BuilderState
| ((prev: BuilderState) => BuilderState),
) => void;
dirty: boolean;
saving: boolean;
activating: boolean;
issues: ValidationIssue[];
canActivate: boolean;
// Node mutations. addNode returns the generated key so the caller
// (a NodeCard "Add" button or canvas "+" button) can scroll to /
// focus / open the new node.
addNode: (type: NodeType) => string;
updateNode: (key: string, patch: Partial<BuilderNode>) => void;
updateNodeConfig: (key: string, patch: Record<string, unknown>) => void;
updateNodePosition: (key: string, x: number, y: number) => void;
updateNodePositions: (
positions: Record<string, { x: number; y: number }>,
) => void;
removeNode: (key: string) => void;
// Actions
save: () => Promise<void>;
setStatus: (status: BuilderState["status"]) => Promise<void>;
deleteFlow: () => Promise<void>;
/**
* Transient "look here" signal. Set when the validation panel's
* issue is clicked — both views subscribe: list scrolls the row
* into view and flashes its border, canvas pans the viewport to
* the node and flashes its card. Auto-clears after 1600ms so the
* flash is a one-shot.
*
* Lives in context (not local view state) so the panel can be
* rendered ONCE in the shell and trigger flashes in whichever
* view is currently mounted, without per-view plumbing.
*/
flashKey: string | null;
requestFlash: (key: string) => void;
}
// ============================================================
// Helpers — node_key generation + per-type default configs
// ============================================================
export function uniqueNodeKey(base: string, existing: BuilderNode[]): string {
if (!existing.some((n) => n.node_key === base)) return base;
let i = 2;
while (existing.some((n) => n.node_key === `${base}_${i}`)) i += 1;
return `${base}_${i}`;
}
export function defaultConfigFor(type: NodeType): Record<string, unknown> {
switch (type) {
case "start":
return { next_node_key: "" };
case "send_message":
return { text: "", next_node_key: "" };
case "send_buttons":
return {
text: "",
buttons: [{ reply_id: "yes", title: "Yes", next_node_key: "" }],
};
case "send_list":
return {
text: "",
button_label: "View options",
sections: [
{
title: "",
rows: [
{ reply_id: "row_1", title: "Option 1", next_node_key: "" },
],
},
],
};
case "send_media":
return {
media_type: "image",
media_url: "",
caption: "",
filename: "",
next_node_key: "",
};
case "collect_input":
return {
prompt_text: "",
var_key: "answer",
next_node_key: "",
};
case "condition":
return {
subject: "var",
subject_key: "",
operator: "equals",
value: "",
true_next: "",
false_next: "",
};
case "set_tag":
return { mode: "add", tag_id: "", next_node_key: "" };
case "handoff":
return { note: "" };
case "end":
return {};
}
}
export function applyNodePositions(
nodes: BuilderNode[],
positions: Record<string, { x: number; y: number }>,
): BuilderNode[] {
return nodes.map((n) => {
const next = positions[n.node_key];
return next
? {
...n,
position_x: Math.round(next.x),
position_y: Math.round(next.y),
}
: n;
});
}
// ============================================================
// Context
// ============================================================
const FlowEditorCtx = createContext<FlowEditorContextValue | null>(null);
export function useFlowEditor(): FlowEditorContextValue {
const ctx = useContext(FlowEditorCtx);
if (!ctx) {
throw new Error(
"useFlowEditor must be called inside <FlowEditorProvider>",
);
}
return ctx;
}
// ============================================================
// Provider
// ============================================================
interface ProviderProps {
initialFlow: FlowRow;
initialNodes: FlowNodeRow[];
children: ReactNode;
}
export function FlowEditorProvider({
initialFlow,
initialNodes,
children,
}: ProviderProps) {
const router = useRouter();
const [state, setStateRaw] = useState<BuilderState>(() => ({
name: initialFlow.name,
description: initialFlow.description ?? "",
trigger_type: initialFlow.trigger_type,
trigger_config: initialFlow.trigger_config as Record<string, unknown>,
entry_node_id: initialFlow.entry_node_id,
status: initialFlow.status,
nodes: initialNodes.map((n) => ({
node_key: n.node_key,
node_type: n.node_type as NodeType,
config: n.config as Record<string, unknown>,
position_x: n.position_x,
position_y: n.position_y,
})),
}));
const [saving, setSaving] = useState(false);
const [activating, setActivating] = useState(false);
// dirty flips on user edits; status-only updates (after the activate
// API succeeds) use setStateRaw so they don't falsely re-flag the
// form as dirty.
const [dirty, setDirty] = useState(false);
const setState = useCallback<typeof setStateRaw>((updaterOrValue) => {
setDirty(true);
setStateRaw(updaterOrValue);
}, []);
// Cross-view "look here" signal (see FlowEditorContextValue docs).
// Tracked via a ref alongside state so a rapid second click on a
// different issue cancels the previous timeout instead of letting
// the first flash linger past the new one.
const [flashKey, setFlashKey] = useState<string | null>(null);
const flashTimeoutRef = useRef<number | null>(null);
const requestFlash = useCallback((key: string) => {
if (flashTimeoutRef.current !== null) {
window.clearTimeout(flashTimeoutRef.current);
}
setFlashKey(key);
flashTimeoutRef.current = window.setTimeout(() => {
setFlashKey(null);
flashTimeoutRef.current = null;
}, 1600);
}, []);
useEffect(
() => () => {
if (flashTimeoutRef.current !== null) {
window.clearTimeout(flashTimeoutRef.current);
}
},
[],
);
// Browser-level reload / tab-close / external-link guard. SPA
// navigation (sidebar links, back button) isn't covered — Next 16
// routes through the App Router and beforeunload doesn't fire on
// client-side route changes. That's a follow-up; this catches the
// accidental refresh / closed-window class of data loss.
useEffect(() => {
if (!dirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Modern browsers ignore the return value but require something
// truthy to actually show the native prompt.
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [dirty]);
// ---- Validation ----
const issues = useMemo<ValidationIssue[]>(
() =>
validateFlowForActivation(
{
name: state.name,
trigger_type: state.trigger_type,
trigger_config: state.trigger_config,
entry_node_id: state.entry_node_id,
},
state.nodes,
),
[state],
);
const canActivate = useMemo(
() => issues.every((i) => i.severity !== "error"),
[issues],
);
// ---- Save (PUT) ----
const save = useCallback(async () => {
setSaving(true);
try {
const res = await fetch(`/api/flows/${initialFlow.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: state.name,
description: state.description || null,
trigger_type: state.trigger_type,
trigger_config: state.trigger_config,
entry_node_id: state.entry_node_id,
nodes: state.nodes,
}),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error ?? `Save failed: ${res.status}`);
}
setDirty(false);
toast.success("Saved.");
} catch (err) {
const msg = err instanceof Error ? err.message : "Save failed";
toast.error(msg);
} finally {
setSaving(false);
}
}, [initialFlow.id, state]);
// ---- Activate / Pause / Archive ----
const setStatus = useCallback(
async (next: BuilderState["status"]) => {
if (next === "active" && !canActivate) {
toast.error("Fix the issues below before activating.");
return;
}
setActivating(true);
try {
// Always save first so the activation validator sees the
// latest state — the user shouldn't have to remember "save
// then activate".
if (next === "active") {
await save();
}
const res = await fetch(`/api/flows/${initialFlow.id}/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: next }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error ?? `Status update failed: ${res.status}`);
}
setStateRaw((s) => ({ ...s, status: next }));
toast.success(
next === "active"
? "Flow activated."
: next === "archived"
? "Archived."
: "Saved as draft.",
);
} catch (err) {
const msg = err instanceof Error ? err.message : "Status update failed";
toast.error(msg);
} finally {
setActivating(false);
}
},
[canActivate, save, initialFlow.id],
);
// ---- Delete ----
const deleteFlow = useCallback(async () => {
const yes = window.confirm(
`Delete "${state.name}"? Any active runs end immediately. This can't be undone.`,
);
if (!yes) return;
try {
const res = await fetch(`/api/flows/${initialFlow.id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
router.push("/flows");
} catch (err) {
const msg = err instanceof Error ? err.message : "Delete failed";
toast.error(msg);
}
}, [initialFlow.id, router, state.name]);
// ---- Node mutations ----
const updateNode = useCallback(
(key: string, patch: Partial<BuilderNode>) => {
setState((s) => ({
...s,
nodes: s.nodes.map((n) =>
n.node_key === key ? { ...n, ...patch } : n,
),
}));
},
[setState],
);
const updateNodeConfig = useCallback(
(key: string, configPatch: Record<string, unknown>) => {
setState((s) => ({
...s,
nodes: s.nodes.map((n) =>
n.node_key === key
? { ...n, config: { ...n.config, ...configPatch } }
: n,
),
}));
},
[setState],
);
const updateNodePosition = useCallback(
(key: string, x: number, y: number) => {
setState((s) => ({
...s,
nodes: s.nodes.map((n) =>
n.node_key === key
? { ...n, position_x: Math.round(x), position_y: Math.round(y) }
: n,
),
}));
},
[setState],
);
const updateNodePositions = useCallback(
(positions: Record<string, { x: number; y: number }>) => {
// Initial Dagre layout hydration should not dirty the editor:
// opening a legacy all-zero flow must not enable Save or arm
// beforeunload before the user actually edits anything.
setStateRaw((s) => ({
...s,
nodes: applyNodePositions(s.nodes, positions),
}));
},
[],
);
const addNode = useCallback(
(type: NodeType): string => {
const meta = NODE_META[type];
const base = slugify(meta.label, type);
let createdKey = base;
setState((s) => {
const node_key = uniqueNodeKey(base, s.nodes);
createdKey = node_key;
const next: BuilderNode = {
node_key,
node_type: type,
config: defaultConfigFor(type),
};
return {
...s,
nodes: [...s.nodes, next],
// If this is the first node and it's a start, pick it as
// the entry automatically. Saves a click.
entry_node_id:
s.entry_node_id ??
(type === "start" ? node_key : s.entry_node_id ?? null),
};
});
return createdKey;
},
[setState],
);
const removeNode = useCallback(
(key: string) => {
// Auto-unlink inbound references so canvas / list deletes don't
// leave dangling arrows behind that the validator would flag.
// Cleared refs become "" (the "no target picked" sentinel the
// builder forms already use).
setState((s) => ({
...s,
nodes: unlinkNodeReferences(
s.nodes.filter((n) => n.node_key !== key),
key,
),
entry_node_id: s.entry_node_id === key ? null : s.entry_node_id,
}));
},
[setState],
);
const value = useMemo<FlowEditorContextValue>(
() => ({
flow: initialFlow,
state,
setState,
dirty,
saving,
activating,
issues,
canActivate,
addNode,
updateNode,
updateNodeConfig,
updateNodePosition,
updateNodePositions,
removeNode,
save,
setStatus,
deleteFlow,
flashKey,
requestFlash,
}),
[
initialFlow,
state,
setState,
dirty,
saving,
activating,
issues,
canActivate,
addNode,
updateNode,
updateNodeConfig,
updateNodePosition,
updateNodePositions,
removeNode,
save,
setStatus,
deleteFlow,
flashKey,
requestFlash,
],
);
return <FlowEditorCtx.Provider value={value}>{children}</FlowEditorCtx.Provider>;
}

View File

@@ -0,0 +1,134 @@
"use client";
/**
* Reusable field components shared across every per-node form.
*
* `NodeKeySelect` — picks a node from the flow's node list, rendered
* with the source node's icon so the dropdown reads as
* "destination = ◇ menu" rather than an opaque slug.
*
* `NextNodeRow` — wraps NodeKeySelect with a label; the most common
* per-node form row ("after this node, advance to…").
*
* `TextRow` — wraps Input or Textarea behind a label. Pure UI sugar
* to keep per-node forms uncluttered.
*
* Lives in src/components/flows/forms/ so both the list view's
* collapsed-card editor and the canvas view's side-panel editor
* (introduced in this PR) mount the exact same form components.
*/
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { NODE_META, type BuilderNode } from "../shared";
export function TextRow({
label,
value,
onChange,
rows = 1,
}: {
label: string;
value: string;
onChange: (v: string) => void;
rows?: number;
}) {
return (
<div>
<label className="mb-1 block text-xs text-muted-foreground">{label}</label>
{rows > 1 ? (
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
className="bg-muted"
/>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-muted"
/>
)}
</div>
);
}
export function NextNodeRow({
value,
allNodes,
currentKey,
onChange,
label,
}: {
value: string;
allNodes: BuilderNode[];
currentKey: string;
onChange: (v: string) => void;
label: string;
}) {
return (
<div>
<label className="mb-1 block text-xs text-muted-foreground">{label}</label>
<NodeKeySelect
value={value || null}
nodes={allNodes}
excludeKey={currentKey}
onChange={(v) => onChange(v ?? "")}
placeholder="Pick a next node…"
/>
</div>
);
}
export function NodeKeySelect({
value,
nodes,
excludeKey,
onChange,
placeholder,
className,
}: {
value: string | null;
nodes: BuilderNode[];
excludeKey?: string;
onChange: (v: string | null) => void;
placeholder?: string;
className?: string;
}) {
const options = nodes.filter((n) => n.node_key !== excludeKey);
return (
<Select
value={value ?? "__none__"}
onValueChange={(v) => onChange(v === "__none__" ? null : v)}
>
<SelectTrigger className={cn("bg-muted", className)}>
<SelectValue placeholder={placeholder ?? "—"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">— None —</SelectItem>
{options.map((n) => {
const Icon = NODE_META[n.node_type].icon;
return (
<SelectItem key={n.node_key} value={n.node_key}>
<span className="inline-flex items-center gap-1.5">
<Icon
className={cn("h-3 w-3", NODE_META[n.node_type].color)}
/>
{n.node_key}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
"use client";
/**
* Editor toolbar — flow name / description, status chip, dirty
* indicator, and the action buttons (Save, Activate/Pause, Delete,
* View runs, Back).
*
* Restyled to the Flow Builder design handoff: a single compact
* toolbar row (back · icon · inline-editable name · status chip ·
* edited dot on the left; Runs · Delete · Activate · Save on the
* right) followed by a subtle, full-width description "note" line.
* Replaces the old three-row stack so the editor reads as one app
* chrome bar above the canvas/list stage.
*
* Lifted out of flow-builder.tsx so the same toolbar renders above
* both views in FlowEditorShell. Without this, canvas users had no
* way to save without toggling to list view.
*
* Reads everything from the editor context (`useFlowEditor`) so it
* stays in sync with whichever view is mutating state, and routes
* router navigation locally (back to /flows, View runs to
* /flows/[id]/runs) — those don't belong in the hook.
*/
import { useRouter } from "next/navigation";
import {
ArrowLeft,
CircleDot,
History,
Loader2,
PauseCircle,
PlayCircle,
Save,
Trash2,
Workflow,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
useFlowEditor,
type BuilderState,
} from "./flow-editor-state";
export function EditorHeader() {
const router = useRouter();
const {
flow,
state,
setState,
dirty,
saving,
activating,
canActivate,
save,
setStatus,
deleteFlow,
} = useFlowEditor();
return (
<div className="flex flex-col gap-1.5 px-6 pt-5">
<div className="flex flex-wrap items-center gap-3">
{/* ---- left: back · icon · name · status · edited ---- */}
<button
type="button"
onClick={() => router.push("/flows")}
title="Back to Flows"
aria-label="Back to Flows"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
</button>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-soft text-primary">
<Workflow className="h-[18px] w-[18px]" />
</span>
<input
value={state.name}
onChange={(e) => setState((s) => ({ ...s, name: e.target.value }))}
placeholder="Flow name"
spellCheck={false}
aria-label="Flow name"
className="min-w-[120px] max-w-[340px] rounded-lg border border-transparent bg-transparent px-2 py-1 text-lg font-bold leading-tight tracking-tight text-foreground outline-none transition-colors hover:bg-muted focus:border-primary focus:bg-transparent focus:shadow-[0_0_0_3px_var(--primary-soft)]"
/>
<StatusChip status={state.status} />
{dirty && (
<span
className="inline-flex shrink-0 items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
title="Unsaved changes — hit Save to persist"
aria-live="polite"
>
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
Edited
</span>
)}
{/* ---- right: runs · delete · activate · save ---- */}
<div className="ml-auto flex flex-wrap items-center gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/flows/${flow.id}/runs`)}
>
<History className="h-3.5 w-3.5" />
Runs
<span className="ml-0.5 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
{flow.execution_count}
</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void deleteFlow()}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
{state.status === "active" ? (
<Button
variant="outline"
size="sm"
onClick={() => void setStatus("draft")}
disabled={activating}
>
{activating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PauseCircle className="h-3.5 w-3.5" />
)}
Pause
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => void setStatus("active")}
disabled={activating || !canActivate}
title={
!canActivate
? "Fix the issues below before activating"
: undefined
}
>
{activating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
Activate
</Button>
)}
<Button onClick={() => void save()} disabled={saving} size="sm">
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
Save
</Button>
</div>
</div>
{/* ---- description note (subtle, inline-editable) ---- */}
<input
value={state.description}
onChange={(e) =>
setState((s) => ({ ...s, description: e.target.value }))
}
placeholder="Add a short description (internal — customers don't see this)"
aria-label="Flow description"
className="w-full max-w-[78ch] rounded-md border border-transparent bg-transparent px-2 py-1 text-[13px] text-muted-foreground outline-none transition-colors placeholder:text-muted-foreground/60 hover:bg-muted/50 focus:border-primary focus:bg-transparent focus:text-foreground"
/>
</div>
);
}
function StatusChip({ status }: { status: BuilderState["status"] }) {
const cfg = {
draft: {
// Neutral, not amber — amber is reserved for the adjacent
// "Edited" dirty signal, so the two don't read as the same alert.
cls: "border-border bg-muted text-muted-foreground",
label: "Draft",
},
active: {
cls: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
label: "Active",
},
archived: {
cls: "border-border bg-muted/50 text-muted-foreground",
label: "Archived",
},
}[status];
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11.5px] font-medium",
cfg.cls,
)}
>
<CircleDot className="h-3 w-3" />
{cfg.label}
</span>
);
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
NODE_CATEGORIES,
NODE_META,
groupNodeTypesByCategory,
type NodeType,
} from './shared';
const ALL_TYPES = Object.keys(NODE_META) as NodeType[];
describe('node categories', () => {
it('assigns every node type to a known category', () => {
const known = new Set(NODE_CATEGORIES.map((c) => c.id));
for (const type of ALL_TYPES) {
expect(known.has(NODE_META[type].category)).toBe(true);
}
});
});
describe('groupNodeTypesByCategory', () => {
it('keeps the categories in NODE_CATEGORIES order and drops empty ones', () => {
// Only messaging + flow types — the logic group must not appear.
const groups = groupNodeTypesByCategory(['send_message', 'start', 'end']);
expect(groups.map((g) => g.id)).toEqual(['messaging', 'flow']);
});
it('preserves the input order within a category', () => {
const groups = groupNodeTypesByCategory([
'send_media',
'send_message',
'send_buttons',
]);
expect(groups).toHaveLength(1);
expect(groups[0].types).toEqual([
'send_media',
'send_message',
'send_buttons',
]);
});
it('partitions the full type list without losing or duplicating a type', () => {
const grouped = groupNodeTypesByCategory(ALL_TYPES).flatMap((g) => g.types);
expect([...grouped].sort()).toEqual([...ALL_TYPES].sort());
});
});

View File

@@ -0,0 +1,423 @@
/**
* Shared editor primitives used by both the linear-list and canvas
* views of a flow.
*
* What lives here vs in flow-builder.tsx / flow-canvas.tsx:
* - Types and metadata that BOTH views need to render a node
* consistently (icon, label, color, 1-line summary).
* - Editing-only helpers (defaultConfigFor, slugify, uniqueNodeKey,
* BuilderState) stay in flow-builder.tsx until the canvas grows
* editing affordances — pulled across in the PR that adds them.
*
* Why .tsx and not .ts: NODE_META holds lucide icon components, which
* are typed as React components; importing them from a .ts module
* works at runtime but trips TypeScript's
* `verbatimModuleSyntax`-related linting in some setups. Keeping the
* file .tsx future-proofs it for inline JSX in node-card renderers.
*/
import {
Flag,
GitFork,
Inbox,
ListChecks,
ListPlus,
MessageCircle,
Paperclip,
PlayCircle,
Tag,
UserPlus,
Workflow,
} from 'lucide-react';
import { cn } from '@/lib/utils';
// ============================================================
// Node-type union — single source of truth for every place the UI
// enumerates types (add menu, type pickers, switch statements). Kept
// in lockstep with `FlowNodeType` in src/lib/flows/types.ts (which
// drives the engine's exhaustiveness check); a divergence between the
// two is always a bug.
// ============================================================
export type NodeType =
| 'start'
| 'send_message'
| 'send_buttons'
| 'send_list'
| 'send_media'
| 'collect_input'
| 'condition'
| 'set_tag'
| 'handoff'
| 'end';
export interface BuilderNode {
node_key: string;
node_type: NodeType;
config: Record<string, unknown>;
/** Optional in v1 — defaults to 0 in the DB. Canvas view reads it
* to position nodes; list view ignores it. */
position_x?: number;
position_y?: number;
}
// ============================================================
// Per-node-type metadata used to render icons + labels everywhere
// the user sees a node summary.
// ============================================================
// ------------------------------------------------------------
// Node categories — buckets the add-step menu groups types under so
// the picker stays scannable as the type list grows, and so a fork
// adding its own node types has an obvious place to slot them.
//
// Note there's no "Events / Triggers" category: in wacrm a flow is
// triggered by flow-level config (`trigger_type`), not by a node on
// the canvas, so `start` is just the entry point under Flow control.
// ------------------------------------------------------------
export type NodeCategory = 'messaging' | 'logic' | 'flow';
/** Category labels + the order they render in the add-step menu. */
export const NODE_CATEGORIES: { id: NodeCategory; label: string }[] = [
{ id: 'messaging', label: 'Messaging' },
{ id: 'logic', label: 'Logic & data' },
{ id: 'flow', label: 'Flow control' },
];
export const NODE_META: Record<
NodeType,
{
label: string;
icon: typeof Workflow;
color: string;
blurb: string;
category: NodeCategory;
}
> = {
start: {
label: 'Start',
icon: PlayCircle,
color: 'text-emerald-400',
blurb: 'Entry point of the flow',
category: 'flow',
},
send_message: {
label: 'Send message',
icon: MessageCircle,
color: 'text-sky-400',
blurb: 'Sends a WhatsApp text message',
category: 'messaging',
},
send_buttons: {
label: 'Send buttons',
icon: ListChecks,
color: 'text-primary',
blurb: 'Sends quick-reply buttons',
category: 'messaging',
},
send_list: {
label: 'Send list',
icon: ListPlus,
color: 'text-indigo-400',
blurb: 'Sends a tappable list of options',
category: 'messaging',
},
send_media: {
label: 'Send media',
icon: Paperclip,
color: 'text-cyan-400',
blurb: 'Sends an image, video, or document',
category: 'messaging',
},
collect_input: {
label: 'Collect input',
icon: Inbox,
color: 'text-teal-400',
blurb: 'Asks a question, saves the reply',
category: 'logic',
},
condition: {
label: 'If / else',
icon: GitFork,
color: 'text-fuchsia-400',
blurb: 'Branches on a rule',
category: 'logic',
},
set_tag: {
label: 'Tag contact',
icon: Tag,
color: 'text-pink-400',
blurb: 'Adds or removes a contact tag',
category: 'logic',
},
handoff: {
label: 'Handoff to agent',
icon: UserPlus,
color: 'text-amber-400',
blurb: 'Hands the conversation to a human',
category: 'flow',
},
end: {
label: 'End',
icon: Flag,
color: 'text-muted-foreground',
blurb: 'Ends the flow',
category: 'flow',
},
};
/**
* Bucket an ordered list of node types by category, preserving both
* the category order (NODE_CATEGORIES) and the within-category order
* of the input list. Empty categories are dropped. Used by both the
* canvas and list add-step menus so they stay in lockstep.
*/
export function groupNodeTypesByCategory(
types: NodeType[]
): { id: NodeCategory; label: string; types: NodeType[] }[] {
return NODE_CATEGORIES.map(({ id, label }) => ({
id,
label,
types: types.filter((t) => NODE_META[t].category === id),
})).filter((group) => group.types.length > 0);
}
// ============================================================
// Per-node-type color system.
//
// Each node type gets its own hue so the canvas reads at a glance —
// what KIND of step is this. Kept as raw oklch (not Tailwind classes)
// so a node card can tint its icon chip, type label, selection ring,
// and edge ports from one source, the way the Flow Builder design
// handoff does. Hues sit in the same oklch family as the app tokens
// in globals.css; they don't replace --primary (the accent), they
// complement it. `nodeColors()` derives the soft/ring/text variants.
// ============================================================
const NODE_HUE: Record<NodeType, { l: number; c: number; h: number }> = {
start: { l: 0.62, c: 0.13, h: 162 }, // emerald — the start, echoes WhatsApp green
send_message: { l: 0.6, c: 0.18, h: 293 }, // violet — the workhorse
send_buttons: { l: 0.62, c: 0.16, h: 254 }, // cobalt
send_list: { l: 0.62, c: 0.15, h: 277 }, // indigo
send_media: { l: 0.65, c: 0.12, h: 210 }, // sky
collect_input: { l: 0.65, c: 0.1, h: 185 }, // teal — capture
condition: { l: 0.72, c: 0.15, h: 65 }, // amber — a fork in the road
set_tag: { l: 0.65, c: 0.15, h: 350 }, // pink
handoff: { l: 0.65, c: 0.17, h: 16 }, // rose — hands off
end: { l: 0.55, c: 0.01, h: 260 }, // neutral grey — terminal
};
export interface NodeColors {
/** Full-strength hue — icon glyph, selection ring, port fill. */
solid: string;
/** ~14% tint — icon chip background, soft fills. */
soft: string;
/** ~45% tint — hover border / focus ring. */
ring: string;
/** Hue for the uppercase type label, kept readable in BOTH modes. */
text: string;
}
export function nodeColors(type: NodeType): NodeColors {
const t = NODE_HUE[type];
const solid = `oklch(${t.l} ${t.c} ${t.h})`;
return {
solid,
soft: `oklch(${t.l} ${t.c} ${t.h} / 0.14)`,
ring: `oklch(${t.l} ${t.c} ${t.h} / 0.45)`,
// Blend the hue toward the live --foreground token so the label
// holds contrast in BOTH modes: in dark mode --foreground is
// near-white (the label lightens to read on the dark card), in
// light mode it's near-black (the label darkens to read on the
// white card). The old fixed-light value only worked on dark.
text: `color-mix(in oklch, ${solid}, var(--foreground) 38%)`,
};
}
// ============================================================
// Shared node icon chip — the per-type colored glyph badge used in
// the canvas node card, list-view card, inspector header, and the
// add-step menu. One component so a styling change (radius, contrast,
// hover) lands in every place at once and the `nodeColors()` lookup
// lives in exactly one spot.
// ============================================================
export function NodeIconChip({
type,
size = 24,
iconSize = 14,
className,
}: {
type: NodeType;
/** Chip side length in px. */
size?: number;
/** Glyph side length in px. */
iconSize?: number;
className?: string;
}) {
const meta = NODE_META[type];
const c = nodeColors(type);
const Icon = meta.icon;
return (
<span
className={cn(
'flex shrink-0 items-center justify-center rounded-lg',
className
)}
style={{ width: size, height: size, background: c.soft, color: c.solid }}
>
<Icon size={iconSize} />
</span>
);
}
// ============================================================
// Pure editing helpers — used by forms in both views.
// ============================================================
/**
* Coerce an arbitrary string into a stable identifier (node_key,
* reply_id, etc.). Lowercases, collapses non-alphanumerics into
* single underscores, and trims leading/trailing underscores. Falls
* back to `fallback` for inputs that reduce to an empty string.
*/
export function slugify(s: string, fallback: string): string {
const cleaned = s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return cleaned || fallback;
}
// ============================================================
// Summary helpers — short, single-line content previews used in
// collapsed node cards (list view) and node tiles (canvas view).
// Returns null when there's nothing meaningful to show (start/end,
// or a freshly-added node with no fields filled in).
// ============================================================
export function truncate(s: string, max = 80): string {
const clean = s.replace(/\s+/g, ' ').trim();
if (clean.length <= max) return clean;
return clean.slice(0, max - 1) + '…';
}
export function summarizeNode(node: BuilderNode): string | null {
const cfg = node.config;
switch (node.node_type) {
case 'start':
case 'end':
return null;
case 'send_message': {
const text = typeof cfg.text === 'string' ? cfg.text : '';
return text.length > 0 ? truncate(text) : null;
}
case 'send_buttons': {
const text = typeof cfg.text === 'string' ? cfg.text : '';
const buttons = Array.isArray(cfg.buttons)
? (cfg.buttons as Array<Record<string, unknown>>)
: [];
const titles = buttons
.map((b) => (typeof b.title === 'string' ? b.title : ''))
.filter(Boolean)
.join(' / ');
if (text.length > 0) {
return titles
? `${truncate(text, 40)} · ${truncate(titles, 35)}`
: truncate(text);
}
return titles || null;
}
case 'send_list': {
const text = typeof cfg.text === 'string' ? cfg.text : '';
const sections = Array.isArray(cfg.sections)
? (cfg.sections as Array<Record<string, unknown>>)
: [];
const rowCount = sections.reduce<number>((sum, s) => {
const rows = Array.isArray(s.rows) ? s.rows : [];
return sum + rows.length;
}, 0);
if (text.length > 0) {
return rowCount > 0
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? '' : 's'}`
: truncate(text);
}
return rowCount > 0
? `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}`
: null;
}
case 'send_media': {
const mediaType =
typeof cfg.media_type === 'string' ? cfg.media_type : '';
const filename = typeof cfg.filename === 'string' ? cfg.filename : '';
const url = typeof cfg.media_url === 'string' ? cfg.media_url : '';
const caption = typeof cfg.caption === 'string' ? cfg.caption : '';
const label = mediaType
? mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
: 'Media';
if (!url) return `${label} (no file uploaded)`;
const name = filename || url.split('/').pop() || 'file';
return caption
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
: `${label}: ${truncate(name, 60)}`;
}
case 'collect_input': {
const prompt = typeof cfg.prompt_text === 'string' ? cfg.prompt_text : '';
const varKey = typeof cfg.var_key === 'string' ? cfg.var_key : '';
if (prompt.length > 0) {
return varKey
? `${truncate(prompt, 50)} → vars.${varKey}`
: truncate(prompt);
}
return varKey ? `→ vars.${varKey}` : null;
}
case 'condition': {
const subjectKey =
typeof cfg.subject_key === 'string' ? cfg.subject_key : '';
if (!subjectKey) return null;
const subject =
cfg.subject === 'tag'
? 'tag'
: cfg.subject === 'contact_field'
? 'field'
: 'var';
const subjectStr =
subject === 'tag'
? `has tag ${truncate(subjectKey, 24)}`
: `${subject}.${subjectKey}`;
const op =
cfg.operator === 'equals'
? '=='
: cfg.operator === 'contains'
? 'contains'
: cfg.operator === 'present'
? 'exists'
: cfg.operator === 'absent'
? 'missing'
: '';
const value = typeof cfg.value === 'string' ? cfg.value : '';
const valStr =
(cfg.operator === 'equals' || cfg.operator === 'contains') && value
? ` "${truncate(value, 20)}"`
: '';
return subject === 'tag' ? subjectStr : `${subjectStr} ${op}${valStr}`;
}
case 'set_tag': {
const mode = cfg.mode === 'remove' ? 'Remove' : 'Add';
const tagId = typeof cfg.tag_id === 'string' ? cfg.tag_id : '';
// No tag name available without an async lookup here; show a
// short prefix of the UUID so users can disambiguate between
// multiple set_tag nodes at a glance.
return tagId
? `${mode} tag ${tagId.slice(0, 8)}…`
: `${mode} tag (none picked)`;
}
case 'handoff': {
const note = typeof cfg.note === 'string' ? cfg.note : '';
return note.length > 0 ? truncate(note) : null;
}
}
}

View File

@@ -0,0 +1,125 @@
"use client";
/**
* Validation panel — surfaces every error and warning from
* `validateFlowForActivation`. Lives once at the bottom of the
* editor shell so it's visible in both views (canvas + list).
*
* Node-scoped issues are clickable: tapping one calls
* `requestFlash(node_key)` on the editor context. List view's
* useEffect on `flashKey` expands + scrolls + flashes the row;
* canvas view's useEffect pans the viewport + flashes the card.
* Both views read the same flashKey so the panel doesn't need
* per-view plumbing.
*
* Trigger-scoped issues are NOT clickable from canvas — trigger
* config is a list-only panel (it's a flat form, not a graph
* concept). User can switch to List to address them.
*/
import { CircleAlert, CircleCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ValidationIssue } from "@/lib/flows/validate";
import { useFlowEditor } from "./flow-editor-state";
export function ValidationPanel() {
const { issues, requestFlash } = useFlowEditor();
if (issues.length === 0) {
// Slate-950 base + emerald accents so the panel stays readable when
// sticky-positioned over scrolled-behind node cards (a translucent
// bg-emerald-500/10 would bleed through ugly).
return (
<div className="flex items-center gap-2 rounded-lg border border-emerald-600/50 bg-background p-3 text-sm font-medium text-emerald-300">
<CircleCheck className="h-4 w-4 shrink-0" />
No issues. Ready to activate.
</div>
);
}
const errors = issues.filter((i) => i.severity === "error");
const warnings = issues.filter((i) => i.severity === "warning");
return (
<div
className={cn(
"rounded-lg border bg-background p-3",
errors.length > 0 ? "border-red-500/40" : "border-amber-500/40",
)}
>
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
{errors.length > 0 ? (
<CircleAlert className="h-4 w-4 text-red-400" />
) : (
<CircleAlert className="h-4 w-4 text-amber-400" />
)}
{errors.length} error{errors.length === 1 ? "" : "s"},{" "}
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
</div>
<div className="flex flex-col gap-1">
{issues.map((i, ix) => (
<IssueLine key={ix} issue={i} onJump={requestFlash} />
))}
</div>
</div>
);
}
/**
* Exported so the per-node card (list view) and the trigger panel
* can render the same "icon + node key chip + message" formatting
* for their own per-row issue lists without re-implementing the
* tone / icon / accessibility logic.
*/
export function IssueLine({
issue,
onJump,
}: {
issue: ValidationIssue;
onJump?: (key: string) => void;
}) {
const tone =
issue.severity === "error" ? "text-red-300" : "text-amber-300";
const iconTone =
issue.severity === "error" ? "text-red-400" : "text-amber-400";
const body = (
<>
<CircleAlert className={cn("mt-0.5 h-3 w-3 shrink-0", iconTone)} />
<span className="min-w-0 flex-1">
{issue.node_key && (
<code className="mr-1 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
{issue.node_key}
</code>
)}
{issue.message}
</span>
</>
);
// Only node-scoped issues can jump; trigger-scoped issues have no
// destination (the trigger panel is list-only and already at the
// top of that view).
if (issue.node_key && onJump) {
return (
<button
type="button"
onClick={() => onJump(issue.node_key!)}
className={cn(
"flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-muted/60",
tone,
)}
aria-label={`Jump to node ${issue.node_key}`}
>
{body}
</button>
);
}
return (
<div
className={cn(
"flex items-start gap-2 rounded-md px-2 py-1 text-xs",
tone,
)}
>
{body}
</div>
);
}

View File

@@ -0,0 +1,299 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import type { Contact, Deal, ContactNote, Tag } from "@/types";
import {
Phone,
Mail,
Copy,
Check,
User,
Tag as TagIcon,
DollarSign,
StickyNote,
Plus,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { format } from "date-fns";
interface ContactSidebarProps {
contact: Contact | null;
}
export function ContactSidebar({ contact }: ContactSidebarProps) {
const { accountId } = useAuth();
const [copied, setCopied] = useState(false);
const [deals, setDeals] = useState<Deal[]>([]);
const [notes, setNotes] = useState<ContactNote[]>([]);
const [tags, setTags] = useState<(Tag & { contact_tag_id: string })[]>([]);
const [newNote, setNewNote] = useState("");
const [addingNote, setAddingNote] = useState(false);
const fetchContactData = useCallback(async () => {
if (!contact) return;
const supabase = createClient();
// Fetch deals, notes, and tags in parallel
const [dealsRes, notesRes, tagsRes] = await Promise.all([
supabase
.from("deals")
.select("*, stage:pipeline_stages(*)")
.eq("contact_id", contact.id)
.order("created_at", { ascending: false }),
supabase
.from("contact_notes")
.select("*")
.eq("contact_id", contact.id)
.order("created_at", { ascending: false }),
supabase
.from("contact_tags")
.select("id, tag_id, tags(*)")
.eq("contact_id", contact.id),
]);
if (dealsRes.data) setDeals(dealsRes.data);
if (notesRes.data) setNotes(notesRes.data);
if (tagsRes.data) {
const mapped = tagsRes.data
.filter((ct: Record<string, unknown>) => ct.tags)
.map((ct: Record<string, unknown>) => ({
...(ct.tags as Tag),
contact_tag_id: ct.id as string,
}));
setTags(mapped);
}
}, [contact]);
// Load on contact change. setContactData/setTags run inside async
// Supabase callbacks, not synchronously in the effect body.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchContactData();
}, [fetchContactData]);
const handleCopyPhone = useCallback(async () => {
if (!contact?.phone) return;
await navigator.clipboard.writeText(contact.phone);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
// Dep is the whole `contact` object (not `contact?.phone`) so the
// React Compiler's inference agrees with the manual dep list —
// fixes the `preserve-manual-memoization` lint error.
}, [contact]);
const handleAddNote = useCallback(async () => {
if (!contact || !newNote.trim()) return;
if (!accountId) return;
setAddingNote(true);
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
const { data, error } = await supabase
.from("contact_notes")
.insert({
contact_id: contact.id,
account_id: accountId,
user_id: user?.id,
note_text: newNote.trim(),
})
.select()
.single();
if (!error && data) {
setNotes((prev) => [data, ...prev]);
setNewNote("");
}
setAddingNote(false);
}, [contact, newNote, accountId]);
if (!contact) {
return (
<div className="flex h-full w-70 items-center justify-center border-l border-border bg-card">
<p className="text-sm text-muted-foreground">Select a conversation</p>
</div>
);
}
const displayName = contact.name || contact.phone;
const initials = displayName.charAt(0).toUpperCase();
return (
<div className="flex h-full w-70 flex-col border-l border-border bg-card">
<ScrollArea className="flex-1">
<div className="p-4">
{/* Contact Info */}
<div className="flex flex-col items-center text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted text-lg font-semibold text-foreground">
{contact.avatar_url ? (
<img
src={contact.avatar_url}
alt={displayName}
className="h-16 w-16 rounded-full object-cover"
/>
) : (
initials
)}
</div>
<h3 className="mt-3 text-sm font-semibold text-foreground">
{displayName}
</h3>
{contact.company && (
<p className="text-xs text-muted-foreground">{contact.company}</p>
)}
</div>
{/* Phone */}
<div className="mt-4 space-y-2">
<button
onClick={handleCopyPhone}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted"
>
<Phone className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 text-left">{contact.phone}</span>
{copied ? (
<Check className="h-3 w-3 text-primary" />
) : (
<Copy className="h-3 w-3 text-muted-foreground" />
)}
</button>
{contact.email && (
<div className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground">
<Mail className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{contact.email}</span>
</div>
)}
</div>
{/* Divider */}
<div className="my-4 border-t border-border" />
{/* Tags */}
<div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<TagIcon className="h-3 w-3" />
Tags
</div>
<div className="mt-2 flex flex-wrap gap-1">
{tags.length === 0 ? (
<p className="px-1 text-xs text-muted-foreground">No tags</p>
) : (
tags.map((tag) => (
<span
key={tag.contact_tag_id}
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: `${tag.color}20`,
color: tag.color,
}}
>
{tag.name}
</span>
))
)}
</div>
</div>
{/* Divider */}
<div className="my-4 border-t border-border" />
{/* Active Deals */}
<div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<DollarSign className="h-3 w-3" />
Active Deals
</div>
<div className="mt-2 space-y-2">
{deals.length === 0 ? (
<p className="px-1 text-xs text-muted-foreground">No deals</p>
) : (
deals.map((deal) => (
<div
key={deal.id}
className="rounded-lg bg-muted px-3 py-2"
>
<p className="text-sm font-medium text-foreground">
{deal.title}
</p>
<div className="mt-1 flex items-center justify-between text-xs text-muted-foreground">
<span>
{deal.currency ?? "$"}
{deal.value.toLocaleString()}
</span>
{deal.stage && (
<span
className="rounded-full px-1.5 py-0.5 text-[10px]"
style={{
backgroundColor: `${deal.stage.color}20`,
color: deal.stage.color,
}}
>
{deal.stage.name}
</span>
)}
</div>
</div>
))
)}
</div>
</div>
{/* Divider */}
<div className="my-4 border-t border-border" />
{/* Notes */}
<div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<StickyNote className="h-3 w-3" />
Notes
</div>
<div className="mt-2">
<div className="flex gap-2">
<textarea
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
placeholder="Add a note..."
rows={2}
className="flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-xs text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
/>
<Button
size="sm"
className="h-auto bg-primary px-2 hover:bg-primary/90"
onClick={handleAddNote}
disabled={!newNote.trim() || addingNote}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="mt-2 space-y-2">
{notes.map((note) => (
<div
key={note.id}
className="rounded-lg bg-muted px-3 py-2"
>
<p className="whitespace-pre-wrap text-xs text-muted-foreground">
{note.note_text}
</p>
<p className="mt-1 text-[10px] text-muted-foreground">
{format(new Date(note.created_at), "MMM d, yyyy HH:mm")}
</p>
</div>
))}
</div>
</div>
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,496 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { createClient } from "@/lib/supabase/client";
import {
CONVERSATION_SELECT,
matchesContactFilters,
normalizeConversations,
} from "@/lib/inbox/conversations";
import { cn } from "@/lib/utils";
import type { Conversation, ConversationStatus, Tag } from "@/types";
import { Search, ChevronDown, X } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ConversationListProps {
activeConversationId: string | null;
onSelect: (conversation: Conversation) => void;
conversations: Conversation[];
onConversationsLoaded: (conversations: Conversation[]) => void;
/**
* Increment to force the fetch effect below to refire. The parent
* bumps this on realtime reconnect / tab visibility → visible so the
* list catches up on any events sent while the WS was disconnected
* or the tab was throttled. Optional so existing callers keep working.
*/
resyncToken?: number;
}
const STATUS_COLORS: Record<ConversationStatus, string> = {
open: "bg-primary",
pending: "bg-amber-500",
closed: "bg-muted-foreground",
};
type InboxFilter = ConversationStatus | "all" | "unread";
const FILTER_OPTIONS: { label: string; value: InboxFilter }[] = [
{ label: "All", value: "all" },
{ label: "Unread", value: "unread" },
{ label: "Open", value: "open" },
{ label: "Pending", value: "pending" },
{ label: "Closed", value: "closed" },
];
export function ConversationList({
activeConversationId,
onSelect,
conversations,
onConversationsLoaded,
resyncToken = 0,
}: ConversationListProps) {
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<InboxFilter>("all");
const [loading, setLoading] = useState(true);
// Contact-based filters (issue #272). Tags use OR logic (a conversation
// matches if its contact carries any selected tag), consistent with
// Broadcast audience filtering. Company is an exact match on the field.
const [tags, setTags] = useState<Tag[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
// Keep the latest callback in a ref so the fetch effect below can
// have a stable, empty-dep identity. Previously the fetch useCallback
// depended on `onConversationsLoaded`, which depends on the parent's
// `deepLinkConvId` — so every URL change (including one the parent
// triggered via router.replace after a click) caused a fresh
// conversations fetch. That extra refetch was the trigger for the
// deep-link auto-select running a second time and wiping the active
// thread's messages.
// Mutation lives in an effect (not render) per React 19's refs rule;
// the fetch runs once on mount so it's fine to read the slightly
// older value — the very next render updates the ref for any
// subsequent async completion.
const onConversationsLoadedRef = useRef(onConversationsLoaded);
useEffect(() => {
onConversationsLoadedRef.current = onConversationsLoaded;
});
useEffect(() => {
const supabase = createClient();
let cancelled = false;
(async () => {
const { data, error } = await supabase
.from("conversations")
.select(CONVERSATION_SELECT)
.order("last_message_at", { ascending: false });
if (cancelled) return;
if (error) {
// Supabase errors have non-enumerable properties — log fields explicitly
console.error("Failed to fetch conversations:", {
message: error.message,
details: error.details,
hint: error.hint,
code: error.code,
});
setLoading(false);
return;
}
onConversationsLoadedRef.current(normalizeConversations(data ?? []));
setLoading(false);
})();
return () => {
cancelled = true;
};
// `resyncToken` is included so the parent can force a refetch when
// the realtime channel reconnects or the tab regains focus — catches
// up on any events sent while the WS was disconnected or throttled.
}, [resyncToken]);
// Tag definitions for the filter picker — loaded once so labels/colours
// stay stable regardless of which conversations happen to be loaded.
useEffect(() => {
const supabase = createClient();
let cancelled = false;
(async () => {
const { data } = await supabase.from("tags").select("*").order("name");
if (!cancelled && data) setTags(data as Tag[]);
})();
return () => {
cancelled = true;
};
}, []);
// Company options are derived from the loaded conversations — there's no
// separate companies table, and only companies with a live conversation
// are worth offering as an inbox filter.
const companies = useMemo(() => {
const set = new Set<string>();
for (const c of conversations) {
const co = c.contact?.company?.trim();
if (co) set.add(co);
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [conversations]);
const tagsById = useMemo(() => {
const m = new Map<string, Tag>();
for (const t of tags) m.set(t.id, t);
return m;
}, [tags]);
const filtered = useMemo(() => {
let result = conversations;
if (filter === "unread") {
result = result.filter((c) => c.unread_count > 0);
} else if (filter !== "all") {
result = result.filter((c) => c.status === filter);
}
// Contact-based filters (tags via OR logic, exact company match).
if (selectedTagIds.length > 0 || selectedCompany !== null) {
result = result.filter((c) =>
matchesContactFilters(c, {
tagIds: selectedTagIds,
company: selectedCompany,
})
);
}
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter((c) => {
const name = c.contact?.name?.toLowerCase() ?? "";
const phone = c.contact?.phone?.toLowerCase() ?? "";
const lastMsg = c.last_message_text?.toLowerCase() ?? "";
return name.includes(q) || phone.includes(q) || lastMsg.includes(q);
});
}
return result;
}, [conversations, filter, search, selectedTagIds, selectedCompany]);
const toggleTag = useCallback((id: string) => {
setSelectedTagIds((prev) =>
prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
);
}, []);
const clearContactFilters = useCallback(() => {
setSelectedTagIds([]);
setSelectedCompany(null);
}, []);
const hasContactFilters = selectedTagIds.length > 0 || selectedCompany !== null;
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
},
[]
);
const handleSelect = useCallback(
(conv: Conversation) => {
onSelect(conv);
},
[onSelect]
);
const activeFilter = FILTER_OPTIONS.find((o) => o.value === filter);
return (
// w-full on mobile so the list occupies the whole viewport when it's
// the single pane showing; fixed 320px on desktop where it shares the
// row with the thread + contact sidebar.
<div className="flex h-full w-full flex-col border-r border-border bg-card lg:w-80">
{/* Search + Filter */}
<div className="space-y-2 border-b border-border p-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={handleSearchChange}
placeholder="Search conversations..."
className="border-border bg-muted pl-9 text-sm text-foreground placeholder-muted-foreground focus:border-primary/50"
/>
</div>
<div className="flex flex-wrap items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground rounded-md hover:bg-muted">
{activeFilter?.label ?? "All"}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="border-border bg-popover"
>
{FILTER_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => setFilter(opt.value)}
className={cn(
"text-sm",
filter === opt.value
? "text-primary"
: "text-popover-foreground"
)}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{tags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
selectedTagIds.length > 0
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
Tags
{selectedTagIds.length > 0 && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground">
{selectedTagIds.length}
</span>
)}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-64 w-56 border-border bg-popover"
>
{tags.map((t) => (
<DropdownMenuCheckboxItem
key={t.id}
checked={selectedTagIds.includes(t.id)}
onCheckedChange={() => toggleTag(t.id)}
className="text-sm text-popover-foreground"
>
<span className="flex items-center gap-2">
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: t.color }}
/>
<span className="truncate">{t.name}</span>
</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{companies.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"inline-flex max-w-40 items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
selectedCompany
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<span className="truncate">{selectedCompany ?? "Company"}</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-64 w-56 border-border bg-popover"
>
<DropdownMenuItem
onClick={() => setSelectedCompany(null)}
className={cn(
"text-sm",
selectedCompany === null
? "text-primary"
: "text-popover-foreground"
)}
>
All companies
</DropdownMenuItem>
{companies.map((co) => (
<DropdownMenuItem
key={co}
onClick={() => setSelectedCompany(co)}
className={cn(
"text-sm",
selectedCompany === co
? "text-primary"
: "text-popover-foreground"
)}
>
<span className="truncate">{co}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{hasContactFilters && (
<div className="flex flex-wrap items-center gap-1">
{selectedTagIds.map((id) => {
const tag = tagsById.get(id);
return (
<button
key={id}
onClick={() => toggleTag(id)}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-foreground hover:bg-muted/70"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: tag?.color ?? "var(--muted-foreground)" }}
/>
<span className="max-w-24 truncate">{tag?.name ?? "Tag"}</span>
<X className="h-3 w-3" />
</button>
);
})}
{selectedCompany && (
<button
onClick={() => setSelectedCompany(null)}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-foreground hover:bg-muted/70"
>
<span className="max-w-24 truncate">{selectedCompany}</span>
<X className="h-3 w-3" />
</button>
)}
<button
onClick={clearContactFilters}
className="px-1 text-[11px] text-muted-foreground hover:text-foreground"
>
Clear all
</button>
</div>
)}
</div>
{/* Conversation Items.
`min-h-0` is load-bearing: a flex child defaults to
min-height:auto, so without it this ScrollArea grows to fit
every conversation instead of shrinking to the remaining
space — the list then overflows and gets clipped by the
parent's overflow-hidden with no scrollbar (issue #229). */}
<ScrollArea className="min-h-0 flex-1">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center">
<p className="text-sm text-muted-foreground">No conversations found</p>
</div>
) : (
<div className="flex flex-col">
{filtered.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isActive={conv.id === activeConversationId}
onSelect={handleSelect}
/>
))}
</div>
)}
</ScrollArea>
</div>
);
}
interface ConversationItemProps {
conversation: Conversation;
isActive: boolean;
onSelect: (conversation: Conversation) => void;
}
function ConversationItem({
conversation,
isActive,
onSelect,
}: ConversationItemProps) {
const contact = conversation.contact;
const displayName = contact?.name || contact?.phone || "Unknown";
const initials = displayName.charAt(0).toUpperCase();
const handleClick = useCallback(() => {
onSelect(conversation);
}, [onSelect, conversation]);
const timeAgo = conversation.last_message_at
? formatDistanceToNow(new Date(conversation.last_message_at), {
addSuffix: false,
})
: "";
return (
<button
onClick={handleClick}
className={cn(
"flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-muted/50",
isActive && "border-l-2 border-primary bg-muted/70"
)}
>
{/* Avatar */}
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium text-foreground">
{contact?.avatar_url ? (
<img
src={contact.avatar_url}
alt={displayName}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
initials
)}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-foreground">
{displayName}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">{timeAgo}</span>
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className="truncate text-xs text-muted-foreground">
{conversation.last_message_text || "No messages yet"}
</p>
<div className="flex shrink-0 items-center gap-1.5">
{conversation.unread_count > 0 && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground">
{conversation.unread_count}
</span>
)}
<span
className={cn(
"h-2 w-2 rounded-full",
STATUS_COLORS[conversation.status]
)}
title={conversation.status}
/>
</div>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,148 @@
"use client";
import { useState, type ReactNode } from "react";
import { CornerUpLeft, Copy, SmilePlus } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { Message } from "@/types";
// WhatsApp's own quick-reaction bar starts with these six. Picking the same
// set keeps the affordance familiar without pulling in a 300KB emoji library.
const QUICK_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🙏"];
interface MessageActionsProps {
message: Message;
onReply: () => void;
onReact: (emoji: string) => void;
children: ReactNode;
}
/**
* Hover/long-press toolbar wrapper around a `<MessageBubble>`. The bubble
* itself stays a pure presenter — this component owns the action surface so
* the bubble's render path is unaffected when the toolbar isn't visible.
*/
export function MessageActions({
message,
onReply,
onReact,
children,
}: MessageActionsProps) {
// Touch devices have no hover. Long-press fires `contextmenu`; we capture
// it, suppress the native menu, and pin the toolbar open until the user
// interacts elsewhere.
const [touchOpen, setTouchOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const isAgent =
message.sender_type === "agent" || message.sender_type === "bot";
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setTouchOpen(true);
};
const handleCopy = async () => {
const text = message.content_text ?? "";
if (!text) {
toast.error("Nothing to copy");
return;
}
try {
await navigator.clipboard.writeText(text);
toast.success("Copied");
} catch {
toast.error("Copy failed");
}
setTouchOpen(false);
};
const handlePickEmoji = (emoji: string) => {
onReact(emoji);
setPickerOpen(false);
setTouchOpen(false);
};
const handleReply = () => {
onReply();
setTouchOpen(false);
};
// Row alignment lives here (not in MessageBubble) so the `group/actions`
// hover region matches the bubble's content width — hovering empty space
// in the row no longer reveals the toolbar.
return (
<div
className={cn(
"flex w-full",
isAgent ? "justify-end" : "justify-start",
)}
onContextMenu={handleContextMenu}
onBlur={() => setTouchOpen(false)}
>
{/* `min-w-0` lets this flex child actually respect the 75% cap.
* Default `min-width: auto` lets content (a long quote preview,
* an unbroken URL) push past the cap and shove the row past
* 100%, which used to bleed across into the contact-sidebar
* area. See issue #165. */}
<div className="group/actions relative min-w-0 max-w-[75%]">
{children}
<div
data-touch-open={touchOpen || pickerOpen ? "true" : undefined}
className={cn(
"absolute -top-3 z-10 flex h-7 items-center gap-0.5 rounded-full border border-border bg-popover/95 px-1 shadow-md backdrop-blur-sm transition-opacity",
"opacity-0 group-hover/actions:opacity-100 group-focus-within/actions:opacity-100",
"data-[touch-open=true]:opacity-100",
isAgent ? "right-3" : "left-3",
)}
>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="React"
>
<SmilePlus className="h-3.5 w-3.5" />
</PopoverTrigger>
<PopoverContent
className="flex w-auto flex-row gap-1 p-1.5"
sideOffset={6}
>
{QUICK_EMOJIS.map((e) => (
<button
key={e}
type="button"
onClick={() => handlePickEmoji(e)}
className="flex h-8 w-8 items-center justify-center rounded-full text-lg leading-none transition-transform hover:scale-125 hover:bg-muted"
aria-label={`React with ${e}`}
>
{e}
</button>
))}
</PopoverContent>
</Popover>
<button
type="button"
onClick={handleReply}
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="Reply"
>
<CornerUpLeft className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleCopy}
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="Copy"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,309 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import type { Message, MessageReaction } from "@/types";
import {
Clock,
Check,
CheckCheck,
XCircle,
FileText,
MapPin,
LayoutTemplate,
ImageOff,
CornerDownLeft,
} from "lucide-react";
import { format } from "date-fns";
import { ReplyQuote } from "./reply-quote";
import { MessageReactions } from "./message-reactions";
interface MessageBubbleProps {
message: Message;
/** Pre-computed quote info for messages that reply to another. */
reply?: { authorLabel: string; preview: string } | null;
reactions?: MessageReaction[];
currentUserId?: string;
onToggleReaction?: (emoji: string) => void;
}
function StatusIcon({ status }: { status: Message["status"] }) {
switch (status) {
case "sending":
return <Clock className="h-3 w-3 text-muted-foreground" />;
case "sent":
return <Check className="h-3 w-3 text-muted-foreground" />;
case "delivered":
return <CheckCheck className="h-3 w-3 text-muted-foreground" />;
case "read":
return <CheckCheck className="h-3 w-3 text-blue-400" />;
case "failed":
return <XCircle className="h-3 w-3 text-red-400" />;
default:
return null;
}
}
function MediaUnavailable({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<ImageOff className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{label} unavailable</span>
</div>
);
}
function MediaImage({ url, alt }: { url: string; alt: string }) {
const [src, setSrc] = useState<string | null>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(true);
const loadImage = useCallback(async () => {
if (!url) return;
// Proxy URLs need auth fetch to create blob URL
if (url.startsWith("/api/whatsapp/media/")) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to load media");
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
setSrc(blobUrl);
} catch {
setError(true);
} finally {
setLoading(false);
}
} else {
setSrc(url);
setLoading(false);
}
}, [url]);
useEffect(() => {
loadImage();
return () => {
if (src?.startsWith("blob:")) {
URL.revokeObjectURL(src);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadImage]);
if (error) {
return (
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-muted">
<ImageOff className="h-8 w-8 text-muted-foreground" />
</div>
);
}
if (loading) {
return (
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-muted">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
return (
<img
src={src ?? ""}
alt={alt}
className="max-h-64 max-w-60 rounded-lg object-cover"
onError={() => setError(true)}
/>
);
}
function MessageContent({ message }: { message: Message }) {
switch (message.content_type) {
case "text":
return (
<p className="whitespace-pre-wrap break-words text-sm">
{message.content_text}
</p>
);
case "image":
return (
<div>
{message.media_url ? (
<MediaImage url={message.media_url} alt="Shared image" />
) : (
<MediaUnavailable label="Image" />
)}
{message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
{message.content_text}
</p>
)}
</div>
);
case "video":
return (
<div>
{message.media_url ? (
<video
src={message.media_url}
controls
className="max-h-64 max-w-60 rounded-lg"
/>
) : (
<MediaUnavailable label="Video" />
)}
{message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
{message.content_text}
</p>
)}
</div>
);
case "audio":
return (
<div>
{message.media_url ? (
<audio src={message.media_url} controls className="max-w-60" />
) : (
<MediaUnavailable label="Audio" />
)}
</div>
);
case "document":
if (!message.media_url) {
return <MediaUnavailable label={message.content_text || "Document"} />;
}
return (
<a
href={message.media_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2 text-sm hover:bg-muted"
>
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
<span className="truncate">
{message.content_text || "Document"}
</span>
</a>
);
case "template":
return (
<div>
<span className="mb-1 inline-flex items-center gap-1 rounded bg-primary/20 px-1.5 py-0.5 text-[10px] font-medium text-primary">
<LayoutTemplate className="h-3 w-3" />
Template
</span>
{message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
{message.content_text}
</p>
)}
</div>
);
case "location":
return (
<div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{message.content_text || "Location shared"}</span>
</div>
);
case "interactive": {
// Customer tapped a reply button or list row on a message the bot
// sent. We show the tapped option's title (already in content_text,
// set by parseMessageContent in the webhook) with a small affordance
// so agents reading the inbox can tell at a glance that this is a
// tap rather than the customer typing the same words.
return (
<div className="flex flex-col gap-0.5">
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<CornerDownLeft className="h-3 w-3" />
Button reply
</span>
<p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Interactive reply]"}
</p>
</div>
);
}
default:
return (
<p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Unsupported message type]"}
</p>
);
}
}
export function MessageBubble({
message,
reply,
reactions,
currentUserId,
onToggleReaction,
}: MessageBubbleProps) {
const isAgent = message.sender_type === "agent" || message.sender_type === "bot";
const time = format(new Date(message.created_at), "HH:mm");
// Row alignment + width cap are owned by <MessageActions> so its hover
// group matches the bubble's content area, not the full row.
return (
<div
className={cn(
"flex flex-col",
isAgent ? "items-end" : "items-start",
)}
>
<div
className={cn(
"relative rounded-2xl px-3 py-2",
isAgent
? "rounded-br-md bg-primary text-primary-foreground"
: "rounded-bl-md bg-muted text-foreground",
)}
>
{reply && (
<ReplyQuote
authorLabel={reply.authorLabel}
preview={reply.preview}
onPrimary={isAgent}
/>
)}
<MessageContent message={message} />
<div
className={cn(
"mt-1 flex items-center gap-1",
isAgent ? "justify-end" : "justify-start",
)}
>
<span
className={cn(
"text-[10px]",
// Outbound bubbles sit on the primary fill, so the
// timestamp must read against that (not the neutral
// foreground) — otherwise it goes low-contrast in light
// mode. Inbound bubbles use the muted surface.
isAgent ? "text-primary-foreground/70" : "text-muted-foreground",
)}
>
{time}
</span>
{isAgent && <StatusIcon status={message.status} />}
</div>
</div>
{reactions && reactions.length > 0 && onToggleReaction && (
<MessageReactions
reactions={reactions}
currentUserId={currentUserId}
onToggle={onToggleReaction}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,726 @@
"use client";
import {
useState,
useRef,
useCallback,
useEffect,
KeyboardEvent,
} from "react";
import {
Send,
LayoutTemplate,
Paperclip,
Image as ImageIcon,
Video,
FileText,
Mic,
Square,
X,
Loader2,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { GatedButton } from "@/components/ui/gated-button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCan } from "@/hooks/use-can";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
uploadAccountMedia,
deleteAccountMedia,
MEDIA_MAX_BYTES_BY_KIND,
} from "@/lib/storage/upload-media";
import { ReplyQuote } from "./reply-quote";
/** Media content types an agent can send from the composer. */
export type ComposerMediaKind = "image" | "video" | "document" | "audio";
/** Supabase Storage bucket holding agent-sent chat attachments (migration 023). */
export const CHAT_MEDIA_BUCKET = "chat-media";
/** Meta caps media captions at 1024 chars. Enforced here and in the send route. */
export const MEDIA_CAPTION_MAX = 1024;
/** Hard cap on a single voice recording so it can't blow the upload/
* transcode limits — auto-stops the recorder when reached. */
const MAX_RECORDING_SECONDS = 5 * 60;
export interface SendMediaPayload {
kind: ComposerMediaKind;
/** Public chat-media URL Meta fetches at send time. */
mediaUrl: string;
/** Storage object path — lets the caller GC the object if the send fails. */
path: string;
/** Optional caption (image/video/document only). */
caption?: string;
/** Original file name — surfaced to the recipient for documents. */
filename?: string;
replyToId?: string;
}
interface ReplyDraft {
/** Internal UUID of the message being replied to — sent back through onSend. */
id: string;
authorLabel: string;
preview: string;
}
// Mirrors the chat-media bucket's allowed_mime_types (migration 023) for
// the file picker so unsupported files are rejected before upload rather
// than failing with a confusing Storage error. Audio has no picker — it's
// captured via the recorder.
const PICKER_ACCEPT: Record<"image" | "video" | "document", string> = {
image: "image/png,image/jpeg,image/webp",
video: "video/mp4,video/3gpp",
document:
"application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain",
};
interface MediaDraft {
kind: ComposerMediaKind;
mediaUrl: string;
/** Storage path — used to GC the object if the draft is discarded. */
path: string;
filename: string;
caption: string;
}
interface MessageComposerProps {
conversationId: string;
sessionExpired: boolean;
onSend: (text: string, replyToId?: string) => void;
onSendMedia: (payload: SendMediaPayload) => void;
onOpenTemplates: () => void;
replyTo?: ReplyDraft | null;
onClearReply?: () => void;
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
/** Worker that encodes mic input to Ogg/Opus entirely in the browser
* (vendored from opus-recorder into /public). Recording client-side in a
* Meta-accepted format means no server ffmpeg / transcode step. */
const OPUS_ENCODER_PATH = "/opus/encoderWorker.min.js";
export function MessageComposer({
conversationId,
sessionExpired,
onSend,
onSendMedia,
onOpenTemplates,
replyTo,
onClearReply,
}: MessageComposerProps) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [drafting, setDrafting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Media attachment state. `draft` holds an uploaded-but-not-yet-sent
// attachment; `busy` covers the upload/transcode window.
const [draft, setDraft] = useState<MediaDraft | null>(null);
const [busy, setBusy] = useState(false);
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const documentInputRef = useRef<HTMLInputElement>(null);
// Mirror of `draft` for the unmount cleanup, which can't read render
// state. Kept in sync below so navigating away with a staged-but-unsent
// attachment GCs the orphaned object.
const draftRef = useRef<MediaDraft | null>(null);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
// Best-effort GC of a staged object the user never sent. Fire-and-forget.
const removeStaged = useCallback((path: string | undefined) => {
if (!path) return;
void deleteAccountMedia(CHAT_MEDIA_BUCKET, path).catch(() => {});
}, []);
// Voice recording state. The recorder encodes Ogg/Opus in-browser
// (opus-recorder) so there's no server-side transcode.
const [recording, setRecording] = useState(false);
const [recordSeconds, setRecordSeconds] = useState(0);
const recorderRef = useRef<import("opus-recorder").default | null>(null);
const cancelledRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Viewers (read-only role) can browse the inbox but never send.
// For solo users this is always true — single-owner accounts pass
// every capability — so the disabled branch is a no-op there.
const canSend = useCan("send-messages");
const readOnly = !canSend;
// Media (like free-form text) is only allowed inside the 24h window.
const inputsDisabled = readOnly || sessionExpired;
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
// Tear down any live recording + timer on unmount so a mid-record
// navigation doesn't leak the mic, and GC a staged-but-unsent
// attachment so it doesn't orphan in the bucket.
useEffect(() => {
return () => {
clearTimer();
cancelledRef.current = true;
// stop() releases the mic stream + audio context inside opus-recorder.
void recorderRef.current?.stop().catch(() => {});
removeStaged(draftRef.current?.path);
};
}, [clearTimer, removeStaged]);
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
// Max 4 lines (~96px)
el.style.height = `${Math.min(el.scrollHeight, 96)}px`;
}, []);
const handleSend = useCallback(async () => {
const trimmed = text.trim();
if (!trimmed || sending || sessionExpired) return;
setSending(true);
try {
onSend(trimmed, replyTo?.id);
setText("");
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
} finally {
setSending(false);
}
}, [text, sending, sessionExpired, onSend, replyTo?.id]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(e.target.value);
adjustHeight();
},
[adjustHeight]
);
// Ask the AI assistant for a suggested reply and drop it into the
// composer for the agent to edit + send. Read-only server-side —
// nothing is sent until the agent hits Send.
const handleDraft = useCallback(async () => {
if (drafting) return;
setDrafting(true);
try {
const res = await fetch("/api/ai/draft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ conversation_id: conversationId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.code === "ai_not_configured") {
toast.error("AI isn't set up yet — enable it in Settings → AI Assistant.");
} else {
toast.error(data.error ?? "Couldn't draft a reply.");
}
return;
}
const draftText = typeof data.draft === "string" ? data.draft.trim() : "";
if (!draftText) {
toast.error("The assistant didn't return a reply.");
return;
}
setText(draftText);
// Let the textarea grow to fit and drop the cursor at the end so
// the agent can tweak immediately.
requestAnimationFrame(() => {
adjustHeight();
const el = textareaRef.current;
if (el) {
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
});
} catch {
toast.error("Couldn't reach the AI assistant.");
} finally {
setDrafting(false);
}
}, [drafting, conversationId, adjustHeight]);
// Upload a captured file to chat-media and stage it as a draft.
const stageUpload = useCallback(
async (kind: ComposerMediaKind, file: File) => {
// Per-kind ceiling mirrors Meta's caps (image 5 MB, etc.) so we
// reject before upload rather than orphaning an object that Meta
// would then refuse at send.
const max = MEDIA_MAX_BYTES_BY_KIND[kind];
if (file.size > max) {
toast.error(
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — ${kind} limit is ${Math.round(
max / 1024 / 1024,
)} MB.`,
);
return;
}
setBusy(true);
try {
const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
// Replacing an existing draft? GC the previous object first.
removeStaged(draftRef.current?.path);
setDraft({ kind, mediaUrl: publicUrl, path, filename: file.name, caption: "" });
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed.");
} finally {
setBusy(false);
}
},
[removeStaged],
);
const handlePicked = useCallback(
(kind: "image" | "video" | "document", file: File | undefined) => {
if (file) void stageUpload(kind, file);
},
[stageUpload],
);
// ---- Voice recording (client-side Ogg/Opus, no server transcode) ---
// The encoded Ogg/Opus file from opus-recorder → upload as an audio
// draft. WhatsApp renders Ogg/Opus as a playable voice note.
const finalizeRecording = useCallback(
async (bytes: Uint8Array) => {
// Uint8Array is a valid BlobPart at runtime; the cast sidesteps the
// lib.dom ArrayBufferLike-vs-ArrayBuffer generic mismatch.
const file = new File([bytes as unknown as BlobPart], `voice-${Date.now()}.ogg`, {
type: "audio/ogg",
});
if (file.size === 0) return; // cancelled / empty take
if (file.size > MEDIA_MAX_BYTES_BY_KIND.audio) {
toast.error("Recording is too long (over 16 MB).");
return;
}
setBusy(true);
try {
const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
removeStaged(draftRef.current?.path);
setDraft({ kind: "audio", mediaUrl: publicUrl, path, filename: file.name, caption: "" });
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed.");
} finally {
setBusy(false);
}
},
[removeStaged],
);
const startRecording = useCallback(async () => {
if (inputsDisabled || busy || recording) return;
if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") {
toast.error("Voice recording isn't supported in this browser.");
return;
}
try {
// Lazy-load the encoder (≈400 KB worker) only when the user records,
// keeping it out of the main bundle.
const { default: Recorder } = await import("opus-recorder");
const recorder = new Recorder({
encoderPath: OPUS_ENCODER_PATH,
numberOfChannels: 1,
encoderApplication: 2048, // VOIP — tuned for speech
encoderSampleRate: 48000,
streamPages: false, // one callback with the complete file on stop
});
cancelledRef.current = false;
recorder.ondataavailable = (bytes) => {
if (cancelledRef.current) return;
void finalizeRecording(bytes);
};
recorderRef.current = recorder;
await recorder.start();
setRecording(true);
setRecordSeconds(0);
timerRef.current = setInterval(() => setRecordSeconds((s) => s + 1), 1000);
} catch {
void recorderRef.current?.stop().catch(() => {});
recorderRef.current = null;
toast.error("Microphone access denied or unavailable.");
}
}, [inputsDisabled, busy, recording, finalizeRecording]);
const stopRecording = useCallback(() => {
clearTimer();
setRecording(false);
void recorderRef.current?.stop().catch(() => {});
}, [clearTimer]);
const cancelRecording = useCallback(() => {
cancelledRef.current = true;
clearTimer();
setRecording(false);
void recorderRef.current?.stop().catch(() => {});
}, [clearTimer]);
// Auto-stop at the cap so a forgotten recording can't blow the
// upload size limit.
useEffect(() => {
if (recording && recordSeconds >= MAX_RECORDING_SECONDS) {
stopRecording();
}
}, [recording, recordSeconds, stopRecording]);
// ---- Draft send / discard -----------------------------------------
const sendDraft = useCallback(() => {
if (!draft || busy) return;
onSendMedia({
kind: draft.kind,
mediaUrl: draft.mediaUrl,
path: draft.path,
// Audio takes no caption (Meta rejects it). Everything else: the
// trimmed caption, or undefined when blank.
caption:
draft.kind === "audio" ? undefined : draft.caption.trim() || undefined,
filename: draft.kind === "document" ? draft.filename : undefined,
replyToId: replyTo?.id,
});
// The object is now owned by the sent message — clear without GC.
setDraft(null);
onClearReply?.();
}, [draft, busy, onSendMedia, replyTo?.id, onClearReply]);
// Discard GCs the staged object — it was uploaded but never sent.
const discardDraft = useCallback(() => {
removeStaged(draft?.path);
setDraft(null);
}, [draft?.path, removeStaged]);
const setCaption = useCallback((caption: string) => {
setDraft((d) => (d ? { ...d, caption } : d));
}, []);
// ---- Render --------------------------------------------------------
return (
<div className="border-t border-border bg-card p-3">
{replyTo && (
<div className="mb-2">
<ReplyQuote
authorLabel={replyTo.authorLabel}
preview={replyTo.preview}
onDismiss={onClearReply}
/>
</div>
)}
{sessionExpired && (
<div className="mb-2 flex items-center justify-between rounded-lg bg-amber-500/10 px-3 py-2">
<p className="text-xs text-amber-400">
24-hour session expired. Use a template to re-engage.
</p>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-amber-400 hover:text-amber-300"
onClick={onOpenTemplates}
>
<LayoutTemplate className="mr-1 h-3 w-3" />
Templates
</Button>
</div>
)}
{/* Hidden file inputs driven by the attach menu. */}
<input
ref={imageInputRef}
type="file"
accept={PICKER_ACCEPT.image}
className="hidden"
onChange={(e) => {
handlePicked("image", e.target.files?.[0]);
e.target.value = "";
}}
/>
<input
ref={videoInputRef}
type="file"
accept={PICKER_ACCEPT.video}
className="hidden"
onChange={(e) => {
handlePicked("video", e.target.files?.[0]);
e.target.value = "";
}}
/>
<input
ref={documentInputRef}
type="file"
accept={PICKER_ACCEPT.document}
className="hidden"
onChange={(e) => {
handlePicked("document", e.target.files?.[0]);
e.target.value = "";
}}
/>
{draft ? (
<MediaDraftPreview
draft={draft}
busy={busy}
readOnly={readOnly}
onCaptionChange={setCaption}
onDiscard={discardDraft}
onSend={sendDraft}
/>
) : recording ? (
// Recording bar — replaces the composer while the mic is live.
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted px-4 py-2.5">
<span className="flex h-2.5 w-2.5 shrink-0 animate-pulse rounded-full bg-red-500" />
<span className="flex-1 text-sm text-foreground">
Recording… {formatDuration(recordSeconds)} /{" "}
{formatDuration(MAX_RECORDING_SECONDS)}
</span>
<button
type="button"
onClick={cancelRecording}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-card hover:text-foreground"
>
Cancel
</button>
<Button
size="sm"
onClick={stopRecording}
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90"
title="Stop and attach"
>
<Square className="h-4 w-4" />
</Button>
</div>
) : (
<div className="flex items-end gap-2">
{/* Attach menu — photo / video / document / voice. */}
<DropdownMenu>
<DropdownMenuTrigger
disabled={inputsDisabled || busy}
title={
readOnly
? "Read-only — your role can't send messages"
: inputsDisabled
? undefined
: "Attach media"
}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Paperclip className="h-4 w-4" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="border-border bg-popover">
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
<ImageIcon className="mr-2 h-4 w-4" />
Photo
</DropdownMenuItem>
<DropdownMenuItem onClick={() => videoInputRef.current?.click()}>
<Video className="mr-2 h-4 w-4" />
Video
</DropdownMenuItem>
<DropdownMenuItem onClick={() => documentInputRef.current?.click()}>
<FileText className="mr-2 h-4 w-4" />
Document
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void startRecording()}>
<Mic className="mr-2 h-4 w-4" />
Voice note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<GatedButton
variant="ghost"
size="sm"
canAct={!readOnly}
gateReason="send messages"
title={readOnly ? undefined : "Send template"}
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenTemplates}
>
<LayoutTemplate className="h-4 w-4" />
</GatedButton>
<GatedButton
variant="ghost"
size="sm"
canAct={!readOnly}
gateReason="send messages"
disabled={drafting}
title={readOnly ? undefined : "Draft a reply with AI"}
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-primary"
onClick={handleDraft}
>
{drafting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</GatedButton>
<textarea
ref={textareaRef}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={
readOnly
? "Read-only — viewers can browse but not reply"
: sessionExpired
? "Session expired - use a template"
: "Type a message... (Shift+Enter for new line)"
}
disabled={sessionExpired || readOnly}
rows={1}
// Textarea keeps its own inline title — the GatedButton
// wrapping pattern doesn't apply to non-button inputs.
// The placeholder text also surfaces the read-only state.
title={readOnly ? "Read-only — your role can't send messages" : undefined}
className={cn(
"flex-1 resize-none rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50",
(sessionExpired || readOnly) && "cursor-not-allowed opacity-50"
)}
/>
<GatedButton
size="sm"
canAct={!readOnly}
gateReason="send messages"
disabled={!text.trim() || sessionExpired || sending}
onClick={handleSend}
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40"
>
<Send className="h-4 w-4" />
</GatedButton>
</div>
)}
{/* Hint sits outside the flex row so its height doesn't push
`items-end` buttons below the textarea. Indented to line up
under the textarea left edge. */}
{!draft && !recording && (
<p className="mt-1 pl-[5.5rem] text-[10px] text-muted-foreground">
Tap the ✨ to draft a reply with AI — you can edit it before sending
</p>
)}
</div>
);
}
/**
* Staged-attachment preview with caption + send/discard. Declared at
* module scope (not nested in MessageComposer) so React keeps it mounted
* across the parent's re-renders — a nested component would remount the
* caption input on every keystroke and drop focus.
*/
function MediaDraftPreview({
draft,
busy,
readOnly,
onCaptionChange,
onDiscard,
onSend,
}: {
draft: MediaDraft;
busy: boolean;
readOnly: boolean;
onCaptionChange: (caption: string) => void;
onDiscard: () => void;
onSend: () => void;
}) {
return (
<div className="rounded-xl border border-border bg-muted/40 p-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
{draft.kind === "image" && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={draft.mediaUrl}
alt={draft.filename}
className="max-h-40 rounded-lg object-cover"
/>
)}
{draft.kind === "video" && (
<video src={draft.mediaUrl} controls className="max-h-40 rounded-lg" />
)}
{draft.kind === "audio" && (
<audio src={draft.mediaUrl} controls className="w-full" />
)}
{draft.kind === "document" && (
<div className="flex items-center gap-2 text-sm text-foreground">
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
<span className="truncate">{draft.filename}</span>
</div>
)}
</div>
<button
type="button"
onClick={onDiscard}
aria-label="Remove attachment"
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-2 flex items-end gap-2">
{draft.kind !== "audio" && (
<input
value={draft.caption}
maxLength={MEDIA_CAPTION_MAX}
onChange={(e) => onCaptionChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
}}
placeholder="Add a caption…"
className="flex-1 rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50"
/>
)}
<GatedButton
size="sm"
canAct={!readOnly}
gateReason="send messages"
disabled={busy}
onClick={onSend}
className={cn(
"h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40",
draft.kind === "audio" && "ml-auto",
)}
>
<Send className="h-4 w-4" />
</GatedButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import type { MessageReaction } from "@/types";
interface MessageReactionsProps {
reactions: MessageReaction[];
currentUserId: string | undefined;
/** Toggle the agent's reaction. If the agent already has this emoji →
* caller should send empty to remove; otherwise swap/add. */
onToggle: (emoji: string) => void;
}
interface ReactionGroup {
emoji: string;
count: number;
byCurrentUser: boolean;
}
function groupReactions(
reactions: MessageReaction[],
currentUserId: string | undefined,
): ReactionGroup[] {
const map = new Map<string, ReactionGroup>();
for (const r of reactions) {
const existing = map.get(r.emoji);
const isMine =
r.actor_type === "agent" &&
!!currentUserId &&
r.actor_id === currentUserId;
if (existing) {
existing.count += 1;
existing.byCurrentUser = existing.byCurrentUser || isMine;
} else {
map.set(r.emoji, { emoji: r.emoji, count: 1, byCurrentUser: isMine });
}
}
return [...map.values()];
}
export function MessageReactions({
reactions,
currentUserId,
onToggle,
}: MessageReactionsProps) {
const groups = useMemo(
() => groupReactions(reactions, currentUserId),
[reactions, currentUserId],
);
if (groups.length === 0) return null;
return (
<div className="mt-1 flex flex-wrap gap-1">
{groups.map((g) => (
<button
key={g.emoji}
type="button"
onClick={() => onToggle(g.emoji)}
aria-pressed={g.byCurrentUser}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[11px] leading-none transition-colors",
g.byCurrentUser
? "border-primary/60 bg-primary/15 text-primary hover:bg-primary/25"
: "border-border bg-muted/80 text-foreground hover:bg-muted",
)}
>
<span className="text-sm leading-none">{g.emoji}</span>
{g.count > 1 && <span>{g.count}</span>}
</button>
))}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
"use client";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Message } from "@/types";
interface ReplyQuoteProps {
/** Sender label of the quoted message: "You" for our own messages,
* contact name for customer-sent messages. Caller resolves this — the
* quote component doesn't see the parent Message. */
authorLabel: string;
/** Compact text preview. Falls back to a placeholder for media types. */
preview: string;
/** Present → renders the composer-chip variant with an X button. Absent →
* renders the embedded-in-bubble variant. */
onDismiss?: () => void;
/** True when embedded inside an outbound (primary-filled) bubble, so the
* quote must read against the primary surface rather than the neutral
* foreground — otherwise it goes low-contrast in light mode. */
onPrimary?: boolean;
}
export function ReplyQuote({
authorLabel,
preview,
onDismiss,
onPrimary = false,
}: ReplyQuoteProps) {
const isChip = !!onDismiss;
return (
<div
className={cn(
"flex items-start gap-2 border-l-2 px-2 py-1",
onPrimary ? "border-primary-foreground/50" : "border-primary",
isChip
? "rounded-md bg-muted/80"
: onPrimary
? "mb-1.5 rounded-md bg-primary-foreground/15"
: "mb-1.5 rounded-md bg-background/20",
)}
>
<div className="min-w-0 flex-1 overflow-hidden">
<div
className={cn(
"truncate text-[11px] font-medium",
onPrimary ? "text-primary-foreground" : "text-primary",
)}
>
{authorLabel}
</div>
{/* Wrap the preview instead of truncating to a single line.
* `truncate` (white-space: nowrap) forced the quote onto one
* impossibly-wide line and — because the parent flex chain
* lacked `min-w-0` at every step — pushed the entire inbox
* layout wider, shoving the contact sidebar off-screen.
* `break-words` also wraps long URLs that have no whitespace
* to break on. Issue #165. */}
<div className="whitespace-pre-wrap break-words text-xs text-foreground/80">
{preview}
</div>
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label="Cancel reply"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
/** Build the one-line preview text shown inside a reply quote. */
export function buildReplyPreview(message: Message): string {
if (message.content_text) return message.content_text;
switch (message.content_type) {
case "image":
return "[Image]";
case "video":
return "[Video]";
case "audio":
return "[Audio]";
case "document":
return "[Document]";
case "location":
return "[Location]";
case "template":
return "[Template]";
default:
return "[Message]";
}
}

View File

@@ -0,0 +1,346 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import type { MessageTemplate } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import {
ArrowLeft,
ChevronRight,
LayoutTemplate,
Loader2,
} from "lucide-react";
import { extractVariableIndices } from "@/lib/whatsapp/template-validators";
export interface TemplateSendValues {
body: string[];
headerText?: string;
buttonParams?: Record<number, string>;
}
interface TemplatePickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (template: MessageTemplate, values: TemplateSendValues) => void;
}
function renderBodyPreview(body: string, params: string[]): string {
return body.replace(/\{\{(\d+)\}\}/g, (_, raw) => {
const idx = Number(raw) - 1;
const value = params[idx];
return value && value.trim().length > 0 ? value : `{{${raw}}}`;
});
}
interface UrlButtonSlot {
index: number;
text: string;
url: string;
}
/**
* Templates may need values for: body variables, a text-header
* variable, and per-URL-button suffixes. Collect them all so the
* send-message path doesn't 400 on missing parameters.
*/
function collectVariableSlots(template: MessageTemplate): {
bodyVars: number[];
headerVarCount: number;
urlButtonSlots: UrlButtonSlot[];
} {
const bodyVars = extractVariableIndices(template.body_text);
const headerVarCount =
template.header_type === "text" && template.header_content
? extractVariableIndices(template.header_content).length
: 0;
const urlButtonSlots: UrlButtonSlot[] = [];
(template.buttons ?? []).forEach((b, i) => {
if (b.type === "URL" && extractVariableIndices(b.url).length > 0) {
urlButtonSlots.push({ index: i, text: b.text, url: b.url });
}
});
return { bodyVars, headerVarCount, urlButtonSlots };
}
export function TemplatePicker({
open,
onOpenChange,
onSelect,
}: TemplatePickerProps) {
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<MessageTemplate | null>(null);
const [params, setParams] = useState<string[]>([]);
const [headerText, setHeaderText] = useState<string>("");
const [buttonParams, setButtonParams] = useState<Record<number, string>>({});
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
setLoading(true);
const supabase = createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
if (!cancelled) {
setTemplates([]);
setLoading(false);
}
return;
}
// Scope by RLS (message_templates_select → is_account_member), NOT by
// user_id. Templates are account-owned, so filtering on the caller's
// user_id hid templates that a teammate created — leaving them unable
// to send approved templates in a shared account.
const { data, error } = await supabase
.from("message_templates")
.select("*")
.eq("status", "APPROVED")
.order("created_at", { ascending: false });
if (cancelled) return;
if (error) {
console.error("Failed to fetch templates:", error);
setTemplates([]);
} else {
setTemplates((data as MessageTemplate[]) ?? []);
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [open]);
function resetSelection() {
setSelected(null);
setParams([]);
setHeaderText("");
setButtonParams({});
}
function handleOpenChange(next: boolean) {
if (!next) resetSelection();
onOpenChange(next);
}
function pickTemplate(template: MessageTemplate) {
const slots = collectVariableSlots(template);
const noInputsNeeded =
slots.bodyVars.length === 0 &&
slots.headerVarCount === 0 &&
slots.urlButtonSlots.length === 0;
if (noInputsNeeded) {
onSelect(template, { body: [] });
handleOpenChange(false);
return;
}
setSelected(template);
setParams(new Array(slots.bodyVars.length).fill(""));
setHeaderText("");
setButtonParams({});
}
function confirm() {
if (!selected) return;
const values: TemplateSendValues = { body: params };
if (headerText.trim()) values.headerText = headerText.trim();
if (Object.keys(buttonParams).length > 0) {
values.buttonParams = Object.fromEntries(
Object.entries(buttonParams).map(([k, v]) => [Number(k), v.trim()]),
);
}
onSelect(selected, values);
handleOpenChange(false);
}
const slots = useMemo(
() => (selected ? collectVariableSlots(selected) : null),
[selected],
);
const canConfirm =
!!selected &&
!!slots &&
slots.bodyVars.every((_, i) => (params[i] ?? "").trim().length > 0) &&
(slots.headerVarCount === 0 || headerText.trim().length > 0) &&
slots.urlButtonSlots.every(
(s) => (buttonParams[s.index] ?? "").trim().length > 0,
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="border-border bg-popover sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<LayoutTemplate className="h-4 w-4 text-primary" />
{selected ? selected.name : "Send template"}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{selected
? "Fill in the placeholders to render this template. Meta requires every variable to be set."
: "Pick an approved WhatsApp template to send to this contact."}
</DialogDescription>
</DialogHeader>
{!selected ? (
<div className="max-h-[60vh] space-y-2 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : templates.length === 0 ? (
<div className="rounded-md border border-border bg-background/50 p-6 text-center">
<p className="text-sm text-popover-foreground">No approved templates</p>
<p className="mt-1 text-xs text-muted-foreground">
Approve a template in Meta WhatsApp Manager, then sync it
from Settings → Templates.
</p>
</div>
) : (
templates.map((t) => (
<button
key={t.id}
type="button"
onClick={() => pickTemplate(t)}
className="w-full rounded-md border border-border bg-background/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-popover"
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium text-popover-foreground">
{t.name}
</p>
<Badge className="border border-primary/30 bg-primary/20 text-[10px] text-primary">
{t.category}
</Badge>
{t.language && (
<span className="text-[10px] uppercase text-muted-foreground">
{t.language}
</span>
)}
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{t.body_text}
</p>
</div>
<ChevronRight className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</div>
</button>
))
)}
</div>
) : (
<div className="space-y-3">
<div className="rounded-md border border-border bg-background/50 p-3">
<p className="mb-1 text-xs text-muted-foreground">Preview</p>
<p className="whitespace-pre-wrap text-sm text-popover-foreground">
{renderBodyPreview(selected.body_text, params)}
</p>
{selected.footer_text && (
<p className="mt-2 text-xs italic text-muted-foreground">
{selected.footer_text}
</p>
)}
</div>
{slots && slots.headerVarCount > 0 && (
<div className="space-y-1">
<Label className="text-xs text-popover-foreground">
{`Header {{1}}`}
</Label>
<Input
value={headerText}
onChange={(e) => setHeaderText(e.target.value)}
placeholder="Value for the header variable"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
</div>
)}
{slots?.bodyVars.map((v, i) => (
<div key={v} className="space-y-1">
<Label className="text-xs text-popover-foreground">{`Body {{${v}}}`}</Label>
<Input
value={params[i] ?? ""}
onChange={(e) => {
const next = [...params];
next[i] = e.target.value;
setParams(next);
}}
placeholder={`Value for {{${v}}}`}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
</div>
))}
{slots?.urlButtonSlots.map((slot) => (
<div key={slot.index} className="space-y-1">
<Label className="text-xs text-popover-foreground">
{`URL button "${slot.text}" — value for `}{`{{1}}`}
</Label>
<Input
value={buttonParams[slot.index] ?? ""}
onChange={(e) =>
setButtonParams((prev) => ({
...prev,
[slot.index]: e.target.value,
}))
}
placeholder="URL suffix value"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
<p className="text-[10px] text-muted-foreground break-all">
Final URL: {slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}")}
</p>
</div>
))}
</div>
)}
<DialogFooter className="gap-2">
{selected ? (
<>
<Button
variant="outline"
onClick={resetSelection}
className="border-border text-popover-foreground hover:bg-muted"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button
disabled={!canConfirm}
onClick={confirm}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Send template
</Button>
</>
) : (
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
className="border-border text-popover-foreground hover:bg-muted"
>
Cancel
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { LogOut, Menu, Settings as SettingsIcon, User } from "lucide-react";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ModeToggle } from "@/components/layout/mode-toggle";
const pageTitles: Record<string, string> = {
"/dashboard": "Dashboard",
"/inbox": "Inbox",
"/notifications": "Notifications",
"/contacts": "Contacts",
"/pipelines": "Pipelines",
"/broadcasts": "Broadcasts",
"/automations": "Automations",
"/settings": "Settings",
};
function getPageTitle(pathname: string): string {
if (pageTitles[pathname]) return pageTitles[pathname];
const match = Object.entries(pageTitles).find(([path]) =>
pathname.startsWith(path),
);
return match ? match[1] : "Dashboard";
}
interface HeaderProps {
/** Wired to the shell's drawer state. Used only on mobile — the
* hamburger button is hidden on lg+. */
onOpenSidebar?: () => void;
}
export function Header({ onOpenSidebar }: HeaderProps) {
const pathname = usePathname();
const { profile, signOut } = useAuth();
const title = getPageTitle(pathname);
const initial =
profile?.full_name?.charAt(0)?.toUpperCase() ??
profile?.email?.charAt(0)?.toUpperCase() ??
"U";
return (
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border bg-background px-4 lg:px-6">
<div className="flex min-w-0 items-center gap-2">
{/* Hamburger — mobile only. 44×44 hit target per Apple HIG. */}
<button
type="button"
onClick={onOpenSidebar}
aria-label="Open menu"
className="flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
<h1 className="truncate text-base font-semibold text-foreground sm:text-lg">
{title}
</h1>
</div>
<div className="flex items-center gap-1 sm:gap-2">
<ModeToggle />
<DropdownMenu>
<DropdownMenuTrigger
className="flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-muted/70 focus:bg-muted/70 focus:outline-none data-popup-open:bg-muted/70 sm:gap-3 sm:pl-1 sm:pr-3"
aria-label="Open account menu"
>
<Avatar className="size-8">
{profile?.avatar_url ? (
<AvatarImage
src={profile.avatar_url}
alt={profile.full_name ?? "Avatar"}
/>
) : null}
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
{initial}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium text-foreground sm:inline">
{profile?.full_name ?? "User"}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={6}
className="min-w-56 bg-popover text-popover-foreground ring-border"
>
<div className="px-2 py-1.5">
<p className="truncate text-sm font-medium text-foreground">
{profile?.full_name ?? "User"}
</p>
<p className="truncate text-xs text-muted-foreground">
{profile?.email ?? ""}
</p>
</div>
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
render={
<Link
href="/settings?tab=profile"
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
/>
}
>
<User className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link
href="/settings?tab=whatsapp"
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
/>
}
>
<SettingsIcon className="size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
onClick={signOut}
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
>
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "@/hooks/use-theme";
import { cn } from "@/lib/utils";
/**
* Light/dark mode toggle — a single icon button that flips the app
* between the two modes. Sun shows in light mode (click → go dark),
* moon shows in dark mode (click → go light); the label always names
* the destination so screen-reader users hear what the click does.
*
* 40×40 hit target to match the header's other touch controls.
*/
export function ModeToggle({ className }: { className?: string }) {
const { mode, toggleMode } = useTheme();
const goingTo = mode === "dark" ? "light" : "dark";
return (
<button
type="button"
onClick={toggleMode}
aria-label={`Switch to ${goingTo} mode`}
title={`Switch to ${goingTo} mode`}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
className,
)}
>
{mode === "dark" ? (
<Moon className="h-5 w-5" />
) : (
<Sun className="h-5 w-5" />
)}
</button>
);
}

View File

@@ -0,0 +1,398 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth";
import { useTotalUnread } from "@/hooks/use-total-unread";
import { useUnreadNotifications } from "@/hooks/use-unread-notifications";
import {
Bell,
Bot,
Crown,
GitBranch,
LayoutDashboard,
LogOut,
MessageSquare,
Radio,
Settings,
Shield,
User,
UserCog,
Users,
UsersRound,
Workflow,
X,
Zap,
} from "lucide-react";
import type { AccountRole } from "@/lib/auth/roles";
// Per-role chip metadata used in the sidebar's account strip + the
// Members tab roster. Keeping this near both consumers in a single
// place avoids drift between the two surfaces — when a designer
// wants to recolour "agent" rows, this is the one diff.
const ROLE_CHIP: Record<
AccountRole,
{ icon: typeof Crown; label: string; className: string }
> = {
owner: {
icon: Crown,
label: "Owner",
// Amber: scarce, immutable, "the boss" — gets visual emphasis.
className:
"border-amber-500/40 bg-amber-500/10 text-amber-300",
},
admin: {
icon: Shield,
label: "Admin",
// Primary-tinted: significant but not as scarce as owner.
className:
"border-primary/40 bg-primary/10 text-primary",
},
agent: {
icon: UserCog,
label: "Agent",
// Neutral slate: the operational default.
className:
"border-border bg-muted text-foreground",
},
viewer: {
icon: User,
label: "Viewer",
// Muted slate: read-only role; visually quieter than agent.
className:
"border-border bg-card text-muted-foreground",
},
};
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface NavItem {
href: string;
label: string;
icon: typeof LayoutDashboard;
/**
* When true, the nav row renders a small "Beta" chip after the label.
* Purely informational — doesn't affect routing or access.
*/
beta?: boolean;
}
const navItems: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/inbox", label: "Inbox", icon: MessageSquare },
{ href: "/notifications", label: "Notifications", icon: Bell },
{ href: "/contacts", label: "Contacts", icon: Users },
{ href: "/pipelines", label: "Pipelines", icon: GitBranch },
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
{ href: "/automations", label: "Automations", icon: Zap },
{ href: "/flows", label: "Flows", icon: Workflow, beta: true },
{ href: "/agents", label: "AI Agents", icon: Bot },
];
const bottomNavItems = [
{ href: "/settings", label: "Settings", icon: Settings },
];
interface SidebarProps {
/** Controlled on mobile by the Header's hamburger button. Ignored on lg+. */
open?: boolean;
onClose?: () => void;
}
export function Sidebar({ open = false, onClose }: SidebarProps) {
const pathname = usePathname();
const { profile, profileLoading, account, accountRole, signOut } = useAuth();
const totalUnread = useTotalUnread();
const unreadNotifications = useUnreadNotifications();
// Only surface the account-name strip when it actually carries
// information. A solo user's personal account is named after them
// (the 017 signup trigger seeds it from `full_name`), so showing it
// here would just duplicate the user name in the footer below. Once
// the account is renamed or the user joins a shared account, the
// name diverges and the strip becomes meaningful — that's the signal
// we gate on. Wait for the profile fetch to settle first, otherwise
// the strip flashes in once the row resolves (a layout jump).
const showAccountStrip =
!profileLoading &&
!!account?.name &&
account.name !== profile?.full_name;
// Close the drawer when route changes — users opened it to navigate,
// so once they pick a destination the drawer should get out of the way.
useEffect(() => {
onClose?.();
// Only pathname drives this — onClose identity doesn't need to re-run it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
// Lock body scroll and allow Escape to close while the drawer is open on
// mobile. No-ops on desktop because the sidebar isn't positioned there.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose?.();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
return (
<>
{/* Backdrop — only exists on mobile and only when open. Clicking
it closes the drawer. Hidden from lg+ since the sidebar is
part of the main flex row there. */}
<button
type="button"
aria-label="Close menu"
onClick={onClose}
className={cn(
"fixed inset-0 z-30 bg-background/70 backdrop-blur-sm transition-opacity lg:hidden",
open
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0",
)}
/>
<aside
className={cn(
// Mobile: fixed drawer that slides in from the left.
"fixed inset-y-0 left-0 z-40 flex h-full w-64 flex-col border-r border-border bg-card",
"transition-transform duration-200 ease-out will-change-transform",
open ? "translate-x-0" : "-translate-x-full",
// Desktop: static, always visible — reset all the mobile framing.
"lg:static lg:z-0 lg:w-60 lg:translate-x-0 lg:transition-none",
)}
aria-label="Primary"
>
{/* Logo row. On mobile we put a close button here; on desktop the
close button is hidden since the sidebar is always-visible. */}
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border px-4">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<MessageSquare className="h-4 w-4" />
</div>
<span className="text-sm font-semibold text-foreground">
CRM Template for WhatsApp
</span>
</Link>
<button
type="button"
onClick={onClose}
aria-label="Close menu"
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground lg:hidden"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Main navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="flex flex-col gap-1">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/dashboard" && pathname.startsWith(item.href));
const showUnreadDot =
item.href === "/inbox" && totalUnread > 0 && !isActive;
// Unlike the inbox dot, the notifications count stays visible
// even while the page is active — it reflects unread state
// (cleared by marking notifications read), not "currently
// viewing this section".
const showNotificationBadge =
item.href === "/notifications" && unreadNotifications > 0;
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
// Taller on mobile so fingers can hit the row reliably (≥44px).
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<item.icon className="h-4 w-4" />
<span className="flex-1">{item.label}</span>
{item.beta && (
<span
aria-label="Beta feature"
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-300"
>
Beta
</span>
)}
{showUnreadDot && (
<span
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}
className="relative flex h-2 w-2"
>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
</span>
)}
{showNotificationBadge && (
<span
aria-label={`${unreadNotifications} unread notification${unreadNotifications === 1 ? "" : "s"}`}
className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground"
>
{unreadNotifications > 9 ? "9+" : unreadNotifications}
</span>
)}
</Link>
</li>
);
})}
</ul>
<div className="my-4 border-t border-border" />
<ul className="flex flex-col gap-1">
{bottomNavItems.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
{/* User section */}
<div className="shrink-0 border-t border-border p-3">
{/* Account name display — surfaced only when the account
name differs from the user's own name (see
`showAccountStrip`). For a default solo account the two
match, so we hide it to avoid duplicating the user name
below; for renamed or shared accounts it tells the user
which account they're acting in. */}
{showAccountStrip && account?.name ? (
<div className="mb-2 flex items-center gap-2 px-3 text-xs text-muted-foreground">
<UsersRound className="size-3.5 shrink-0" />
{/* `title=` exposes the full name on hover when it
gets truncated (long account names + narrow
sidebars). Cheap a11y win. */}
<span className="truncate" title={account.name}>
{account.name}
</span>
{accountRole ? (
// Always render the chip — owners used to be
// invisible here, which made them indistinguishable
// from admins at a glance. Now everyone sees their
// role (with a colour cue) regardless of tier.
(() => {
const meta = ROLE_CHIP[accountRole];
const Icon = meta.icon;
return (
<span
className={`ml-auto inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ${meta.className}`}
>
<Icon className="size-3" />
{meta.label}
</span>
);
})()
) : null}
</div>
) : null}
<DropdownMenu>
<DropdownMenuTrigger className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-muted/60 focus:bg-muted/60 focus:outline-none data-popup-open:bg-muted/60">
<Avatar className="size-8 shrink-0">
{profile?.avatar_url ? (
<AvatarImage
src={profile.avatar_url}
alt={profile.full_name ?? "Avatar"}
/>
) : null}
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
{profile?.full_name?.charAt(0)?.toUpperCase() ??
profile?.email?.charAt(0)?.toUpperCase() ??
"U"}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{profile?.full_name ?? "User"}
</p>
<p className="truncate text-xs text-muted-foreground">
{profile?.email ?? ""}
</p>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="top"
sideOffset={6}
className="min-w-56 bg-popover text-popover-foreground ring-border"
>
<DropdownMenuItem
render={
<Link
href="/settings?tab=profile"
onClick={onClose}
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
/>
}
>
<User className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link
href="/settings?tab=whatsapp"
onClick={onClose}
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
/>
}
>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
onClick={signOut}
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
>
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import type { Deal, PipelineStage } from "@/types";
import { Calendar, Check, X } from "lucide-react";
import { formatCurrency } from "@/lib/currency";
interface DealCardProps {
deal: Deal;
stage: PipelineStage | null;
onEdit: (deal: Deal) => void;
isOverlay?: boolean;
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function initials(name?: string, fallback?: string) {
const source = (name || fallback || "?").trim();
if (!source) return "?";
return source.charAt(0).toUpperCase();
}
export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
const contactLabel = deal.contact?.name || deal.contact?.phone || "No contact";
const assigneeLabel = deal.assignee?.full_name || null;
return (
<button
type="button"
onClick={(e) => {
// `onClick` still fires after a non-drag tap because the PointerSensor
// requires 5px movement before it counts as a drag.
if (isOverlay) return;
e.stopPropagation();
onEdit(deal);
}}
className={`group relative w-full cursor-pointer rounded-xl border border-border/50 bg-muted/70 pl-4 pr-3 py-3 text-left shadow-sm transition-all ${
isOverlay
? "shadow-xl"
: "hover:-translate-y-0.5 hover:border-border hover:bg-muted hover:shadow-lg"
}`}
>
{/* 4px left accent bar using stage color */}
<span
aria-hidden
className="absolute left-0 top-0 h-full w-1 rounded-l-xl"
style={{ backgroundColor: stage?.color ?? "#94a3b8" }}
/>
<div className="flex items-start justify-between gap-2">
<h4 className="flex-1 text-sm font-semibold leading-snug text-foreground break-words">
{deal.title}
</h4>
{deal.status === "won" && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold text-primary">
<Check className="h-3 w-3" />
Won
</span>
)}
{deal.status === "lost" && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-semibold text-red-400">
<X className="h-3 w-3" />
Lost
</span>
)}
</div>
{/* Contact row */}
<div className="mt-2 flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-foreground">
{initials(deal.contact?.name, deal.contact?.phone)}
</span>
<span className="truncate text-xs text-muted-foreground">{contactLabel}</span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="text-sm font-bold text-primary">
{formatCurrency(deal.value, deal.currency)}
</span>
{deal.expected_close_date && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Calendar className="h-3 w-3" />
{formatDate(deal.expected_close_date)}
</span>
)}
</div>
{assigneeLabel && (
<div className="mt-2 flex items-center justify-end">
<span
title={assigneeLabel}
className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/15 text-[10px] font-semibold text-primary"
>
{initials(assigneeLabel)}
</span>
</div>
)}
</button>
);
}

View File

@@ -0,0 +1,485 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { CURRENCIES } from "@/lib/currency";
import type {
Contact,
Conversation,
Deal,
DealStatus,
PipelineStage,
Profile,
} from "@/types";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Check,
X,
Trash2,
MessageSquare,
DollarSign,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
interface DealFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
deal?: Deal | null;
pipelineId: string;
stages: PipelineStage[];
defaultStageId?: string;
onSaved: () => void;
}
export function DealForm({
open,
onOpenChange,
deal,
pipelineId,
stages,
defaultStageId,
onSaved,
}: DealFormProps) {
const supabase = createClient();
const { accountId, defaultCurrency } = useAuth();
const [title, setTitle] = useState("");
const [value, setValue] = useState("");
const [currency, setCurrency] = useState(defaultCurrency);
const [contactId, setContactId] = useState("");
const [stageId, setStageId] = useState("");
const [assignedTo, setAssignedTo] = useState("");
const [expectedCloseDate, setExpectedCloseDate] = useState("");
const [notes, setNotes] = useState("");
const [contacts, setContacts] = useState<Contact[]>([]);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [linkedConversation, setLinkedConversation] =
useState<Conversation | null>(null);
const [saving, setSaving] = useState(false);
const [statusAction, setStatusAction] = useState<DealStatus | null>(null);
const [deleting, setDeleting] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
// Reset the form fields every time the sheet opens or its input
// props change. This is a legitimate prop-driven sync; the rule is
// over-cautious here, hence the block-level disable.
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (!open) return;
setConfirmDelete(false);
if (deal) {
setTitle(deal.title);
setValue(String(deal.value ?? ""));
setCurrency(deal.currency || defaultCurrency);
// contact_id is nullable when the contact has been deleted
// (migration 004: ON DELETE SET NULL). "" means "no selection".
setContactId(deal.contact_id ?? "");
setStageId(deal.stage_id);
setAssignedTo(deal.assigned_to ?? "");
setExpectedCloseDate(deal.expected_close_date ?? "");
setNotes(deal.notes ?? "");
} else {
setTitle("");
setValue("");
setCurrency(defaultCurrency);
setContactId("");
setStageId(defaultStageId || stages[0]?.id || "");
setAssignedTo("");
setExpectedCloseDate("");
setNotes("");
}
}, [open, deal, defaultStageId, stages, defaultCurrency]);
/* eslint-enable react-hooks/set-state-in-effect */
// Load supporting data once the sheet is open
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
const [c, p] = await Promise.all([
supabase.from("contacts").select("*").order("name"),
supabase.from("profiles").select("*").order("full_name"),
]);
if (cancelled) return;
setContacts((c.data ?? []) as Contact[]);
setProfiles((p.data ?? []) as Profile[]);
})();
return () => {
cancelled = true;
};
}, [open, supabase]);
// Fetch linked conversation for the selected contact (newest open one).
// Clearing on no-selection is sync with prop state; the populated
// case runs setLinkedConversation inside the async fetch callback.
useEffect(() => {
if (!open || !contactId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLinkedConversation(null);
return;
}
let cancelled = false;
(async () => {
const { data } = await supabase
.from("conversations")
.select("*")
.eq("contact_id", contactId)
.order("last_message_at", { ascending: false })
.limit(1)
.maybeSingle();
if (cancelled) return;
setLinkedConversation((data as Conversation | null) ?? null);
})();
return () => {
cancelled = true;
};
}, [open, contactId, supabase]);
async function handleSave() {
if (!title.trim() || !contactId || !stageId) {
toast.error("Title, contact, and stage are required");
return;
}
setSaving(true);
const payload = {
title: title.trim(),
value: parseFloat(value) || 0,
currency,
contact_id: contactId,
pipeline_id: pipelineId,
stage_id: stageId,
assigned_to: assignedTo || null,
notes: notes.trim() || null,
expected_close_date: expectedCloseDate || null,
};
if (deal) {
const { error } = await supabase
.from("deals")
.update(payload)
.eq("id", deal.id);
if (error) {
toast.error("Failed to save deal");
setSaving(false);
return;
}
} else {
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) {
toast.error("Not signed in");
setSaving(false);
return;
}
if (!accountId) {
toast.error("Your profile is not linked to an account.");
setSaving(false);
return;
}
const { error } = await supabase
.from("deals")
.insert({ ...payload, user_id: user.id, account_id: accountId, status: "open" });
if (error) {
toast.error("Failed to create deal");
setSaving(false);
return;
}
}
setSaving(false);
toast.success(deal ? "Deal updated" : "Deal created");
onOpenChange(false);
onSaved();
}
async function handleStatusChange(status: DealStatus) {
if (!deal) return;
setStatusAction(status);
const { error } = await supabase
.from("deals")
.update({ status })
.eq("id", deal.id);
setStatusAction(null);
if (error) {
toast.error("Failed to update deal status");
return;
}
toast.success(
status === "won" ? "Marked as won" : status === "lost" ? "Marked as lost" : "Deal reopened",
);
onOpenChange(false);
onSaved();
}
async function handleDelete() {
if (!deal) return;
setDeleting(true);
const { error } = await supabase.from("deals").delete().eq("id", deal.id);
setDeleting(false);
if (error) {
toast.error("Failed to delete deal");
return;
}
toast.success("Deal deleted");
setConfirmDelete(false);
onOpenChange(false);
onSaved();
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="bg-popover border-border text-popover-foreground sm:max-w-lg w-full p-0"
>
<div className="flex h-full flex-col">
<SheetHeader className="border-b border-border/50 p-4">
<SheetTitle className="text-popover-foreground">
{deal ? "Edit Deal" : "New Deal"}
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="grid gap-2">
<Label className="text-muted-foreground">Title</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Deal title"
className="border-border bg-muted text-foreground"
/>
</div>
<div className="grid gap-2">
<Label className="text-muted-foreground">Contact</Label>
<select
value={contactId}
onChange={(e) => setContactId(e.target.value)}
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
>
<option value="">Select a contact</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name || c.phone}
</option>
))}
</select>
{linkedConversation && (
<Link
href="/inbox"
className="mt-1 inline-flex items-center gap-1.5 self-start rounded-md bg-primary/10 px-2 py-1 text-xs text-primary hover:bg-primary/20"
>
<MessageSquare className="h-3 w-3" />
Link to Conversation
</Link>
)}
</div>
<div className="grid grid-cols-[1fr_110px] gap-3">
<div className="grid gap-2">
<Label className="text-muted-foreground">Value</Label>
<div className="relative">
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="number"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0"
className="border-border bg-muted pl-7 text-foreground"
/>
</div>
</div>
<div className="grid gap-2">
<Label className="text-muted-foreground">Currency</Label>
<select
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary"
>
{CURRENCIES.map((c) => (
<option key={c.code} value={c.code}>
{c.code}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-2">
<Label className="text-muted-foreground">Expected Close Date</Label>
<Input
type="date"
value={expectedCloseDate}
onChange={(e) => setExpectedCloseDate(e.target.value)}
className="border-border bg-muted text-foreground"
/>
</div>
<div className="grid gap-2">
<Label className="text-muted-foreground">Stage</Label>
<select
value={stageId}
onChange={(e) => setStageId(e.target.value)}
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary"
>
{stages.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="grid gap-2">
<Label className="text-muted-foreground">Assigned To</Label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary"
>
<option value="">Unassigned</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.full_name || p.email}
</option>
))}
</select>
</div>
<div className="grid gap-2">
<Label className="text-muted-foreground">Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes..."
className="min-h-[100px] border-border bg-muted text-foreground"
/>
</div>
{deal && (
<div className="space-y-2 rounded-lg border border-border bg-muted/50 p-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Status
</p>
<div className="flex gap-2">
<Button
type="button"
onClick={() => handleStatusChange("won")}
disabled={!!statusAction || deal.status === "won"}
className="flex-1 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{statusAction === "won" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-1 h-4 w-4" />
Mark as Won
</>
)}
</Button>
<Button
type="button"
onClick={() => handleStatusChange("lost")}
disabled={!!statusAction || deal.status === "lost"}
className="flex-1 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{statusAction === "lost" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<X className="mr-1 h-4 w-4" />
Mark as Lost
</>
)}
</Button>
</div>
{deal.status && deal.status !== "open" && (
<Button
type="button"
variant="ghost"
onClick={() => handleStatusChange("open")}
disabled={!!statusAction}
className="w-full text-muted-foreground hover:text-foreground"
>
Reopen deal
</Button>
)}
</div>
)}
</div>
<div className="border-t border-border/50 bg-popover/80 p-4">
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving || !title.trim() || !contactId || !stageId}
className="flex-1 bg-primary text-primary-foreground hover:bg-primary/90"
>
{saving ? "Saving..." : deal ? "Save Changes" : "Create Deal"}
</Button>
</div>
{deal &&
(confirmDelete ? (
<div className="mt-3 flex items-center justify-between gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs">
<span className="text-red-300">Delete this deal?</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="rounded px-2 py-1 text-muted-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? "Deleting..." : "Confirm"}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-red-400 hover:text-red-300"
>
<Trash2 className="h-3 w-3" />
Delete Deal
</button>
))}
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import { useMemo } from "react";
import type { Deal, PipelineStage } from "@/types";
import {
DollarSign,
TrendingUp,
Target,
BarChart3,
Trophy,
XCircle,
Info,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAuth } from "@/hooks/use-auth";
import { formatCurrency } from "@/lib/currency";
interface PipelineAnalyticsProps {
stages: PipelineStage[];
deals: Deal[];
}
/**
* Weighted pipeline value: value × per-stage probability.
* First stage ≈ 10%, stages interpolate up to 90% before the final stage,
* final stage (Won) = 100%. Lost deals excluded.
*/
function computeStageProbability(
stage: PipelineStage,
sortedStages: PipelineStage[],
): number {
const n = sortedStages.length;
if (n <= 1) return 1;
const index = sortedStages.findIndex((s) => s.id === stage.id);
if (index < 0) return 0;
if (index === n - 1) return 1;
const slots = n - 1;
if (slots <= 1) return 0.1;
const t = index / (slots - 1);
return 0.1 + t * (0.9 - 0.1);
}
export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
const { defaultCurrency } = useAuth();
const sortedStages = useMemo(
() => [...stages].sort((a, b) => a.position - b.position),
[stages],
);
const stats = useMemo(() => {
const active = deals.filter((d) => d.status !== "lost");
const openDeals = active.filter((d) => d.status !== "won");
const totalCount = active.length;
const totalValue = active.reduce((sum, d) => sum + Number(d.value || 0), 0);
const avgValue = totalCount > 0 ? totalValue / totalCount : 0;
const stageById = new Map(sortedStages.map((s) => [s.id, s]));
const weightedValue = openDeals.reduce((sum, d) => {
const stage = stageById.get(d.stage_id);
if (!stage) return sum;
const prob = computeStageProbability(stage, sortedStages);
return sum + Number(d.value || 0) * prob;
}, 0);
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const thisMonth = (d: Deal) => {
const ts = d.updated_at ?? d.created_at;
return ts ? new Date(ts) >= monthStart : false;
};
const wonThisMonth = deals.filter(
(d) => d.status === "won" && thisMonth(d),
).length;
const lostThisMonth = deals.filter(
(d) => d.status === "lost" && thisMonth(d),
).length;
return {
totalCount,
totalValue,
avgValue,
weightedValue,
wonThisMonth,
lostThisMonth,
};
}, [deals, sortedStages]);
return (
<TooltipProvider>
<div className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-card/60 p-4 sm:grid-cols-3 xl:grid-cols-6">
<Metric
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
label="Total Deals"
value={String(stats.totalCount)}
tooltip="Count of every deal in this pipeline that isn't marked as Lost. Won deals are still included."
/>
<Metric
icon={<DollarSign className="h-4 w-4 text-primary" />}
label="Pipeline Value"
value={formatCurrency(stats.totalValue, defaultCurrency)}
tooltip="Sum of the dollar values of all deals in this pipeline, excluding deals marked as Lost."
/>
<Metric
icon={<Target className="h-4 w-4 text-blue-400" />}
label="Avg Deal Size"
value={formatCurrency(stats.avgValue, defaultCurrency)}
tooltip="Pipeline Value divided by Total Deals — the average value of a single non-lost deal."
/>
<Metric
icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
label="Weighted Value"
value={formatCurrency(stats.weightedValue, defaultCurrency)}
tooltip="Expected revenue: each open deal's value × its stage probability. First stage ≈ 10%, stages progress up to 90%, Won = 100%. Lost deals are excluded."
/>
<Metric
icon={<Trophy className="h-4 w-4 text-primary" />}
label="Won This Month"
value={String(stats.wonThisMonth)}
tooltip="Deals marked as Won since the first day of the current month."
/>
<Metric
icon={<XCircle className="h-4 w-4 text-red-400" />}
label="Lost This Month"
value={String(stats.lostThisMonth)}
tooltip="Deals marked as Lost since the first day of the current month."
/>
</div>
</TooltipProvider>
);
}
function Metric({
icon,
label,
value,
tooltip,
}: {
icon: React.ReactNode;
label: string;
value: string;
tooltip: string;
}) {
return (
<div className="rounded-lg bg-muted/50 p-3">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{icon}
<span>{label}</span>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={`How ${label} is calculated`}
className="ml-auto text-muted-foreground hover:text-foreground focus:outline-none"
/>
}
>
<Info className="h-3 w-3" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-left">
{tooltip}
</TooltipContent>
</Tooltip>
</div>
<p className="mt-1 text-base font-semibold text-foreground">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,290 @@
"use client";
import { useMemo, useState } from "react";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
useDroppable,
useDraggable,
closestCorners,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import type { Deal, PipelineStage } from "@/types";
import { DealCard } from "./deal-card";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { formatCurrency } from "@/lib/currency";
interface PipelineBoardProps {
stages: PipelineStage[];
deals: Deal[];
onDealMoved: (dealId: string, newStageId: string) => void;
onAddDeal: (stageId: string) => void;
onEditDeal: (deal: Deal) => void;
}
export function PipelineBoard({
stages,
deals,
onDealMoved,
onAddDeal,
onEditDeal,
}: PipelineBoardProps) {
const { defaultCurrency } = useAuth();
const [activeDealId, setActiveDealId] = useState<string | null>(null);
const sortedStages = useMemo(
() => [...stages].sort((a, b) => a.position - b.position),
[stages],
);
const dealsByStage = useMemo(() => {
const map = new Map<string, Deal[]>();
for (const stage of sortedStages) map.set(stage.id, []);
for (const deal of deals) {
const bucket = map.get(deal.stage_id);
if (bucket) bucket.push(deal);
}
return map;
}, [sortedStages, deals]);
const sensors = useSensors(
// 5px activation distance avoids clicks being interpreted as drags.
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
// Keyboard drag support: focus a card, Space to pick up, arrows to move,
// Space to drop, Escape to cancel.
useSensor(KeyboardSensor),
);
const activeDeal = activeDealId
? deals.find((d) => d.id === activeDealId) ?? null
: null;
function handleDragStart(event: DragStartEvent) {
setActiveDealId(String(event.active.id));
}
function handleDragEnd(event: DragEndEvent) {
setActiveDealId(null);
const { active, over } = event;
if (!over) return;
const dealId = String(active.id);
const targetStageId = String(over.id);
const deal = deals.find((d) => d.id === dealId);
if (!deal || deal.stage_id === targetStageId) return;
if (!sortedStages.some((s) => s.id === targetStageId)) return;
onDealMoved(dealId, targetStageId);
}
function handleDragCancel() {
setActiveDealId(null);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{/* snap-x + snap-mandatory on mobile so swipes land the next
stage cleanly at the viewport edge instead of mid-column.
Disabled on lg+ where snapping would interfere with the
natural layout. The board can still overflow horizontally on
lg+ once a pipeline has many stages (columns keep a 260px
min-width), so a thin scrollbar stays visible on desktop. */}
<div className="pipeline-scroll flex snap-x snap-mandatory gap-3 overflow-x-auto pb-4 lg:snap-none">
{sortedStages.map((stage) => {
const stageDeals = dealsByStage.get(stage.id) ?? [];
const totalValue = stageDeals.reduce(
(s, d) => s + Number(d.value || 0),
0,
);
return (
<StageColumn
key={stage.id}
stage={stage}
deals={stageDeals}
totalValue={totalValue}
currency={defaultCurrency}
onAddDeal={onAddDeal}
onEditDeal={onEditDeal}
/>
);
})}
</div>
<DragOverlay
dropAnimation={{
duration: 200,
easing: "cubic-bezier(0.2, 0, 0, 1)",
}}
>
{activeDeal ? (
<div className="opacity-90">
<DealCard
deal={activeDeal}
stage={
sortedStages.find((s) => s.id === activeDeal.stage_id) ?? null
}
onEdit={() => {}}
isOverlay
/>
</div>
) : null}
</DragOverlay>
<style jsx>{`
.pipeline-scroll {
scroll-behavior: smooth;
}
/* On touch devices the peek/snap layout already signals there's
more to swipe, so the scrollbar is hidden for a clean look.
On desktop (mouse) the board can overflow with many stages
and there is no peek hint, so keep a thin, themed scrollbar
visible to make the overflow discoverable and usable. */
@media (hover: none), (pointer: coarse) {
.pipeline-scroll::-webkit-scrollbar {
height: 0;
display: none;
}
.pipeline-scroll {
scrollbar-width: none;
}
}
@media (hover: hover) and (pointer: fine) {
.pipeline-scroll {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.pipeline-scroll::-webkit-scrollbar {
height: 8px;
}
.pipeline-scroll::-webkit-scrollbar-track {
background: transparent;
}
.pipeline-scroll::-webkit-scrollbar-thumb {
background-color: var(--border);
border-radius: 9999px;
}
.pipeline-scroll::-webkit-scrollbar-thumb:hover {
background-color: var(--muted-foreground);
}
}
`}</style>
</DndContext>
);
}
function StageColumn({
stage,
deals,
totalValue,
currency,
onAddDeal,
onEditDeal,
}: {
stage: PipelineStage;
deals: Deal[];
totalValue: number;
currency: string;
onAddDeal: (stageId: string) => void;
onEditDeal: (deal: Deal) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: stage.id });
return (
// On mobile each column is `w-[85vw]` (with a reasonable min/max)
// so the next column's edge peeks in — a "there's more here" hint.
// snap-start lands each column cleanly when swiping. On lg+ we
// restore the flex-1 share-the-row behavior. The droppable ref is
// on the inner messages region below — intentionally NOT here, so
// a drag over the column header doesn't highlight the whole column.
<div className="flex w-[85vw] min-w-[260px] max-w-[320px] shrink-0 snap-start flex-col rounded-xl border border-border bg-card/60 p-4 lg:w-auto lg:max-w-none lg:flex-1 lg:basis-[260px] lg:shrink lg:snap-none">
{/* 3px colored top border — sits above the column's padding */}
<div
className="-mx-4 -mt-4 h-[3px] rounded-t-xl"
style={{ backgroundColor: stage.color }}
/>
<div className="flex items-center justify-between pt-3">
<h3 className="truncate text-sm font-semibold text-foreground">
{stage.name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{deals.length}
</span>
</div>
<p className="text-xs text-muted-foreground">
{formatCurrency(totalValue, currency)}
</p>
<div
ref={setNodeRef}
className={`mt-3 flex flex-1 flex-col gap-2 rounded-lg transition-all ${
isOver
? "bg-primary/5 outline outline-2 outline-dashed outline-primary outline-offset-2"
: ""
}`}
>
{deals.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-border py-10 text-xs text-muted-foreground">
Drop a deal here
</div>
) : (
deals.map((deal) => (
<DraggableDealCard
key={deal.id}
deal={deal}
stage={stage}
onEdit={onEditDeal}
/>
))
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onAddDeal(stage.id)}
className="mt-3 w-full justify-start border border-dashed border-border bg-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground"
>
<Plus className="mr-1 h-3 w-3" />
Add Deal
</Button>
</div>
);
}
function DraggableDealCard({
deal,
stage,
onEdit,
}: {
deal: Deal;
stage: PipelineStage;
onEdit: (deal: Deal) => void;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: deal.id,
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
style={{ opacity: isDragging ? 0.3 : 1, touchAction: "none" }}
>
<DealCard deal={deal} stage={stage} onEdit={onEdit} />
</div>
);
}

View File

@@ -0,0 +1,465 @@
"use client";
import { useEffect, useState } from "react";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
arrayMove,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { createClient } from "@/lib/supabase/client";
import type { Pipeline, PipelineStage } from "@/types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Trash2,
Plus,
GripVertical,
AlertTriangle,
} from "lucide-react";
import { toast } from "sonner";
const STAGE_COLORS = [
"#3b82f6",
"#6366f1",
"#8b5cf6",
"#ec4899",
"#f43f5e",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#06b6d4",
];
interface PipelineSettingsProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pipeline: Pipeline;
stages: PipelineStage[];
onPipelinesChanged: () => void;
onStagesChanged: () => void;
onCreateNewPipeline: () => void;
}
export function PipelineSettings({
open,
onOpenChange,
pipeline,
stages,
onPipelinesChanged,
onStagesChanged,
onCreateNewPipeline,
}: PipelineSettingsProps) {
const supabase = createClient();
const [name, setName] = useState(pipeline.name);
const [localStages, setLocalStages] = useState<PipelineStage[]>(stages);
const [newStageName, setNewStageName] = useState("");
const [newStageColor, setNewStageColor] = useState(STAGE_COLORS[0]);
const [saving, setSaving] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
// Reset form state when the dialog opens or its prop inputs change
// — legitimate prop-driven sync.
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (!open) return;
setName(pipeline.name);
setLocalStages([...stages].sort((a, b) => a.position - b.position));
setShowDeleteConfirm(false);
}, [open, pipeline, stages]);
/* eslint-enable react-hooks/set-state-in-effect */
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
function handleReorder(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = localStages.findIndex((s) => s.id === active.id);
const newIndex = localStages.findIndex((s) => s.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
setLocalStages(arrayMove(localStages, oldIndex, newIndex));
}
async function handleSave() {
setSaving(true);
// One upsert for all stages — batches N stage writes into a single
// round-trip. Previous implementation did N sequential UPDATEs which
// latency-scaled linearly with stage count.
const stageRows = localStages.map((s, i) => ({
id: s.id,
pipeline_id: s.pipeline_id,
name: s.name,
color: s.color,
position: i,
}));
const [renameRes, stagesRes] = await Promise.all([
supabase
.from("pipelines")
.update({ name: name.trim() })
.eq("id", pipeline.id),
supabase.from("pipeline_stages").upsert(stageRows, { onConflict: "id" }),
]);
setSaving(false);
if (renameRes.error || stagesRes.error) {
toast.error("Failed to save pipeline");
return;
}
onOpenChange(false);
onPipelinesChanged();
onStagesChanged();
toast.success("Pipeline saved");
}
async function handleAddStage() {
const trimmed = newStageName.trim();
if (!trimmed) return;
const { data, error } = await supabase
.from("pipeline_stages")
.insert({
pipeline_id: pipeline.id,
name: trimmed,
color: newStageColor,
position: localStages.length,
})
.select()
.single();
if (error || !data) {
toast.error("Failed to add stage");
return;
}
setLocalStages([...localStages, data as PipelineStage]);
setNewStageName("");
setNewStageColor(STAGE_COLORS[(localStages.length + 1) % STAGE_COLORS.length]);
}
async function handleRemoveStage(stageId: string) {
// Refuse to delete if deals still reference the stage (FK would fail).
const { count } = await supabase
.from("deals")
.select("id", { count: "exact", head: true })
.eq("stage_id", stageId);
if (count && count > 0) {
toast.error("Move or delete deals in this stage first");
return;
}
const { error } = await supabase
.from("pipeline_stages")
.delete()
.eq("id", stageId);
if (error) {
toast.error("Failed to delete stage");
return;
}
setLocalStages(localStages.filter((s) => s.id !== stageId));
}
async function handleDeletePipeline() {
setDeleting(true);
// ON DELETE CASCADE handles deals + stages.
const { error } = await supabase
.from("pipelines")
.delete()
.eq("id", pipeline.id);
setDeleting(false);
if (error) {
toast.error("Failed to delete pipeline");
return;
}
onOpenChange(false);
onPipelinesChanged();
toast.success("Pipeline deleted");
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-popover border-border max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-popover-foreground">Manage Pipeline</DialogTitle>
</DialogHeader>
{showDeleteConfirm ? (
<div className="py-4">
<div className="flex items-center gap-3 rounded-lg border border-red-500/30 bg-red-500/10 p-4">
<AlertTriangle className="h-5 w-5 shrink-0 text-red-400" />
<div>
<p className="text-sm font-medium text-red-400">
Delete Pipeline
</p>
<p className="mt-1 text-xs text-muted-foreground">
This will archive all deals in this pipeline. This cannot be
undone.
</p>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={handleDeletePipeline}
disabled={deleting}
className="bg-red-600 text-white hover:bg-red-700"
>
{deleting ? "Deleting..." : "Delete Pipeline"}
</Button>
</div>
</div>
) : (
<>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label className="text-muted-foreground">Pipeline Name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="border-border bg-muted text-foreground"
/>
</div>
<div className="grid gap-2">
<Label className="text-muted-foreground">Stages</Label>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleReorder}
>
<SortableContext
items={localStages.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{localStages.map((stage, index) => (
<SortableStageRow
key={stage.id}
stage={stage}
onNameChange={(v) => {
const updated = [...localStages];
updated[index] = { ...updated[index], name: v };
setLocalStages(updated);
}}
onColorChange={(v) => {
const updated = [...localStages];
updated[index] = { ...updated[index], color: v };
setLocalStages(updated);
}}
onRemove={() => handleRemoveStage(stage.id)}
colors={STAGE_COLORS}
/>
))}
</div>
</SortableContext>
</DndContext>
{/* Add new stage */}
<div className="mt-1 flex flex-wrap gap-1">
{STAGE_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewStageColor(color)}
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
style={{
backgroundColor: color,
borderColor:
newStageColor === color
? "var(--foreground)"
: "transparent",
}}
aria-label={`Pick color ${color}`}
/>
))}
</div>
<div className="flex items-center gap-2">
<Input
value={newStageName}
onChange={(e) => setNewStageName(e.target.value)}
placeholder="New stage name"
className="border-border bg-muted text-sm text-foreground"
onKeyDown={(e) => {
if (e.key === "Enter") handleAddStage();
}}
/>
<Button
variant="outline"
size="sm"
onClick={handleAddStage}
disabled={!newStageName.trim()}
className="shrink-0 border-border bg-transparent text-muted-foreground hover:bg-muted"
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
</div>
<Button
variant="outline"
onClick={onCreateNewPipeline}
className="w-full border-border bg-transparent text-muted-foreground hover:bg-muted"
>
<Plus className="mr-1 h-3 w-3" />
Create a new pipeline
</Button>
</div>
<DialogFooter className="border-border bg-popover/50">
<Button
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
className="mr-auto bg-red-600 hover:bg-red-700"
>
Delete Pipeline
</Button>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving || !name.trim()}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{saving ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}
function SortableStageRow({
stage,
onNameChange,
onColorChange,
onRemove,
colors,
}: {
stage: PipelineStage;
onNameChange: (v: string) => void;
onColorChange: (v: string) => void;
onRemove: () => void;
colors: string[];
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: stage.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2 rounded-lg border border-border bg-muted p-2"
>
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4" />
</button>
<ColorSwatch value={stage.color} onChange={onColorChange} colors={colors} />
<Input
value={stage.name}
onChange={(e) => onNameChange(e.target.value)}
className="h-7 flex-1 border-transparent bg-transparent text-sm text-foreground focus:border-border"
/>
<Button
variant="ghost"
size="icon-xs"
onClick={onRemove}
className="text-muted-foreground hover:text-red-400"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
}
function ColorSwatch({
value,
onChange,
colors,
}: {
value: string;
onChange: (v: string) => void;
colors: string[];
}) {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="h-4 w-4 rounded-full border border-border"
style={{ backgroundColor: value }}
aria-label="Change color"
/>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-6 z-20 flex flex-wrap gap-1 rounded-lg border border-border bg-popover p-2 shadow-lg w-36">
{colors.map((c) => (
<button
key={c}
type="button"
onClick={() => {
onChange(c);
setOpen(false);
}}
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
style={{
backgroundColor: c,
borderColor:
c === value ? "var(--foreground)" : "transparent",
}}
/>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { cn } from "@/lib/utils";
import type { PresenceStatus } from "@/lib/presence";
// Single source of truth for presence colours. Semantic accents
// (emerald / amber / muted), mirroring the role-chip palette already
// used across settings, so they're intentionally not tokenized.
export const PRESENCE_DOT_CLASS: Record<PresenceStatus, string> = {
online: "bg-emerald-500",
away: "bg-amber-500",
offline: "bg-muted-foreground/50",
};
/**
* A small presence dot. Used inline (e.g. the inbox Assign dropdown)
* and, with `asAvatarBadge`-style positioning supplied via className,
* layered onto an avatar. `label` powers the native title/aria for
* the lightweight inline case; the roster wraps it in a real Tooltip.
*/
export function PresenceDot({
status,
label,
className,
}: {
status: PresenceStatus;
label?: string;
className?: string;
}) {
return (
<span
role={label ? "img" : undefined}
aria-label={label}
title={label}
className={cn(
"inline-block size-2 shrink-0 rounded-full",
PRESENCE_DOT_CLASS[status],
className,
)}
/>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef } from "react";
import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { HEARTBEAT_MS, IDLE_AFTER_MS, type StoredPresence } from "@/lib/presence";
/**
* PresenceHeartbeat — headless. Mount ONCE per signed-in dashboard tab
* (in the dashboard shell, below the auth gate). Reports this tab's
* presence to the `member_presence` table via the `touch_presence` RPC
* roughly every HEARTBEAT_MS.
*
* The client only ever reports 'online' or 'away':
* - 'away' when the tab is hidden, or no user input for IDLE_AFTER_MS
* - 'online' otherwise
* It keeps heartbeating while away (so the row stays fresh, i.e. not
* offline). When the tab closes the beats simply stop and viewers derive
* 'offline' from staleness — no unreliable unload write needed.
*/
export function PresenceHeartbeat() {
const { accountId } = useAuth();
// 0 = "never recorded"; set on mount so we don't read the clock during
// render (impure). Until the effect runs the tab counts as active.
const lastActivityRef = useRef<number>(0);
useEffect(() => {
// Hold off until the account is known. Beating during the brief
// window on a fresh signup — authed but profile/account row not yet
// created — would make touch_presence raise "No account for caller"
// and log a spurious error. The effect re-runs once accountId lands.
if (!accountId) return;
const supabase = createClient();
let cancelled = false;
let lastBeatAt = 0;
lastActivityRef.current = Date.now();
const markActive = () => {
lastActivityRef.current = Date.now();
};
const currentStatus = (): StoredPresence => {
if (typeof document !== "undefined" && document.hidden) return "away";
if (Date.now() - lastActivityRef.current > IDLE_AFTER_MS) return "away";
return "online";
};
const beat = async () => {
if (cancelled) return;
// Coalesce bursts: a tab refocus fires visibilitychange AND focus
// together, so skip a beat within 1s of the last to avoid two RPCs
// in the same frame. The 30s interval is never affected.
const t = Date.now();
if (t - lastBeatAt < 1_000) return;
lastBeatAt = t;
const { error } = await supabase.rpc("touch_presence", {
p_status: currentStatus(),
});
if (error && !cancelled) {
// Non-fatal: presence is best-effort. Log once per failure so a
// misconfigured RPC is visible without spamming.
console.error("[PresenceHeartbeat] touch_presence failed:", error.message);
}
};
// Activity listeners. `passive` so we never block scroll/input.
const activityEvents: (keyof DocumentEventMap)[] = [
"mousemove",
"keydown",
"pointerdown",
"scroll",
];
activityEvents.forEach((e) =>
document.addEventListener(e, markActive, { passive: true }),
);
// Returning to the tab should beat immediately so a member flips
// back to online without a 30s wait. The debounce in beat() absorbs
// the visibilitychange + focus double-fire.
const onReturn = () => {
if (!document.hidden) markActive();
void beat();
};
document.addEventListener("visibilitychange", onReturn);
window.addEventListener("focus", onReturn);
void beat();
const interval = setInterval(() => void beat(), HEARTBEAT_MS);
return () => {
cancelled = true;
clearInterval(interval);
activityEvents.forEach((e) =>
document.removeEventListener(e, markActive),
);
document.removeEventListener("visibilitychange", onReturn);
window.removeEventListener("focus", onReturn);
};
}, [accountId]);
return null;
}

View File

@@ -0,0 +1,487 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { Loader2, Sparkles, CheckCircle2, Trash2, Eye, EyeOff } from 'lucide-react';
import { useAuth } from '@/hooks/use-auth';
import { canEditSettings } from '@/lib/auth/roles';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SettingsPanelHead } from './settings-panel-head';
import { AiKnowledgeCard } from './ai-knowledge';
import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
import type { AiProvider } from '@/lib/ai/types';
const MASKED_KEY = '••••••••••••••••';
const PROVIDER_LABEL: Record<AiProvider, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic (Claude)',
};
const KEY_PLACEHOLDER: Record<AiProvider, string> = {
openai: 'sk-...',
anthropic: 'sk-ant-...',
};
export function AiConfig() {
const { accountId, accountRole, profileLoading } = useAuth();
const canEdit = accountRole ? canEditSettings(accountRole) : false;
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [removing, setRemoving] = useState(false);
const [configured, setConfigured] = useState(false);
const [provider, setProvider] = useState<AiProvider>('openai');
const [model, setModel] = useState(AI_PROVIDER_DEFAULT_MODEL.openai);
const [apiKey, setApiKey] = useState('');
const [keyEdited, setKeyEdited] = useState(false);
const [showKey, setShowKey] = useState(false);
const [hasStoredKey, setHasStoredKey] = useState(false);
const [embeddingsKey, setEmbeddingsKey] = useState('');
const [embeddingsKeyEdited, setEmbeddingsKeyEdited] = useState(false);
const [hasStoredEmbeddingsKey, setHasStoredEmbeddingsKey] = useState(false);
const [systemPrompt, setSystemPrompt] = useState('');
const [isActive, setIsActive] = useState(false);
const [autoReplyEnabled, setAutoReplyEnabled] = useState(false);
const [maxPerConversation, setMaxPerConversation] = useState(3);
// Guard keyed on the account (not a bare boolean) so an in-place
// account switch — ownership transfer, multi-account membership —
// refetches instead of showing the previous account's config. Mirrors
// the loadedAccountIdRef pattern in whatsapp-config.tsx.
const loadedAccountIdRef = useRef<string | null>(null);
const fetchConfig = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/ai/config');
const data = await res.json();
if (!res.ok) {
toast.error(data.error ?? 'Failed to load AI configuration');
return;
}
if (data.configured) {
setConfigured(true);
setProvider(data.provider);
setModel(data.model);
setSystemPrompt(data.system_prompt ?? '');
setIsActive(data.is_active);
setAutoReplyEnabled(data.auto_reply_enabled);
setMaxPerConversation(data.auto_reply_max_per_conversation ?? 3);
setHasStoredKey(Boolean(data.has_key));
setApiKey(data.has_key ? MASKED_KEY : '');
setKeyEdited(false);
setHasStoredEmbeddingsKey(Boolean(data.has_embeddings_key));
setEmbeddingsKey(data.has_embeddings_key ? MASKED_KEY : '');
setEmbeddingsKeyEdited(false);
}
} catch {
toast.error('Failed to load AI configuration');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!accountId || loadedAccountIdRef.current === accountId) return;
loadedAccountIdRef.current = accountId;
void fetchConfig();
}, [accountId, fetchConfig]);
// Swap the model default when the provider changes, unless the user
// typed a custom model.
const handleProviderChange = (next: AiProvider) => {
setProvider(next);
const isDefaultModel =
model === AI_PROVIDER_DEFAULT_MODEL.openai ||
model === AI_PROVIDER_DEFAULT_MODEL.anthropic ||
model.trim() === '';
if (isDefaultModel) setModel(AI_PROVIDER_DEFAULT_MODEL[next]);
};
const keyPayload = () => (keyEdited ? apiKey.trim() : undefined);
// undefined = leave unchanged; '' typed = null (clear); text = set.
const embeddingsKeyPayload = () =>
embeddingsKeyEdited ? embeddingsKey.trim() || null : undefined;
const buildBody = () => ({
provider,
model: model.trim(),
api_key: keyPayload(),
embeddings_api_key: embeddingsKeyPayload(),
system_prompt: systemPrompt.trim() || null,
is_active: isActive,
auto_reply_enabled: autoReplyEnabled,
auto_reply_max_per_conversation: maxPerConversation,
});
const handleTest = async () => {
setTesting(true);
try {
const res = await fetch('/api/ai/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider,
model: model.trim(),
api_key: keyPayload(),
}),
});
const data = await res.json();
if (res.ok) toast.success('Key works — the provider responded.');
else toast.error(data.error ?? 'The provider rejected the request.');
} catch {
toast.error('Could not reach the provider.');
} finally {
setTesting(false);
}
};
const handleSave = async () => {
if (!model.trim()) {
toast.error('Enter a model name.');
return;
}
if (!configured && !keyEdited) {
toast.error('Enter your API key.');
return;
}
setSaving(true);
try {
const res = await fetch('/api/ai/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildBody()),
});
const data = await res.json();
if (res.ok) {
toast.success('AI assistant saved.');
await fetchConfig();
} else {
toast.error(data.error ?? 'Failed to save.');
}
} catch {
toast.error('Failed to save.');
} finally {
setSaving(false);
}
};
const handleRemove = async () => {
setRemoving(true);
try {
const res = await fetch('/api/ai/config', { method: 'DELETE' });
if (res.ok) {
toast.success('AI configuration removed.');
setConfigured(false);
setHasStoredKey(false);
setApiKey('');
setKeyEdited(false);
setIsActive(false);
setAutoReplyEnabled(false);
setSystemPrompt('');
} else {
const data = await res.json();
toast.error(data.error ?? 'Failed to remove.');
}
} catch {
toast.error('Failed to remove.');
} finally {
setRemoving(false);
}
};
if (loading || profileLoading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading…
</div>
);
}
const disabled = !canEdit || saving;
return (
<div>
<SettingsPanelHead
title="Agent setup"
description="Bring your own OpenAI or Anthropic key. wacrm calls the provider directly with your key — no per-seat AI fees, and your data stays yours. This powers AI-drafted replies in the inbox, the auto-reply bot, and the Playground."
/>
{!canEdit && (
<p className="mb-4 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
Only admins and owners can change the AI configuration.
</p>
)}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 text-primary" /> Provider & key
</CardTitle>
<CardDescription>
Your key is encrypted at rest (AES-256-GCM) and never shown again
after saving.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Provider</Label>
<Select
value={provider}
onValueChange={(v) => handleProviderChange(v as AiProvider)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">{PROVIDER_LABEL.openai}</SelectItem>
<SelectItem value="anthropic">
{PROVIDER_LABEL.anthropic}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">Model</Label>
<Input
id="ai-model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={AI_PROVIDER_DEFAULT_MODEL[provider]}
disabled={disabled}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ai-key">API key</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="ai-key"
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);
setKeyEdited(true);
}}
onFocus={() => {
if (!keyEdited && hasStoredKey) {
setApiKey('');
setKeyEdited(true);
}
}}
placeholder={KEY_PLACEHOLDER[provider]}
disabled={disabled}
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowKey((s) => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
tabIndex={-1}
>
{showKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<Button
variant="outline"
onClick={handleTest}
disabled={disabled || testing}
>
{testing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
Test key
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ai-embeddings-key">
Embeddings key{' '}
<span className="font-normal text-muted-foreground">
(optional — enables semantic knowledge-base search)
</span>
</Label>
<Input
id="ai-embeddings-key"
type="password"
value={embeddingsKey}
onChange={(e) => {
setEmbeddingsKey(e.target.value);
setEmbeddingsKeyEdited(true);
}}
onFocus={() => {
if (!embeddingsKeyEdited && hasStoredEmbeddingsKey) {
setEmbeddingsKey('');
setEmbeddingsKeyEdited(true);
}
}}
placeholder="sk-... (OpenAI)"
disabled={disabled}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
An OpenAI key used only to embed your knowledge base
(text-embedding-3-small)
{provider === 'openai' ? ' — can be the same key as above' : ''}.
Leave blank to use keyword search instead. Clear it to turn
semantic search off.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Behaviour</CardTitle>
<CardDescription>
Tell the assistant about your business — products, tone, what it
may and may not promise. This context feeds both drafts and
auto-replies.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ai-prompt">Business context & instructions</Label>
<Textarea
id="ai-prompt"
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder="e.g. We are Acme, a coffee-equipment store. Be warm and concise. Never quote prices or delivery dates — hand off to a human for those."
rows={5}
disabled={disabled}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border border-border p-3">
<div>
<p className="text-sm font-medium text-foreground">
Enable AI assistant
</p>
<p className="text-xs text-muted-foreground">
Master switch. Turns on the “Draft with AI” button in the
inbox.
</p>
</div>
<Switch
checked={isActive}
onCheckedChange={setIsActive}
disabled={disabled}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border border-border p-3">
<div>
<p className="text-sm font-medium text-foreground">
Auto-reply to inbound messages
</p>
<p className="text-xs text-muted-foreground">
The bot answers new inbound messages automatically (only when
no flow handles them and no agent is assigned). Hands off to a
human when it can’t help.
</p>
</div>
<Switch
checked={autoReplyEnabled}
onCheckedChange={setAutoReplyEnabled}
disabled={disabled || !isActive}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<Label htmlFor="ai-max">Max auto-replies per conversation</Label>
<p className="text-xs text-muted-foreground">
After this many bot replies in one thread, the bot goes quiet.
</p>
</div>
<Input
id="ai-max"
type="number"
min={1}
max={20}
value={maxPerConversation}
onChange={(e) =>
setMaxPerConversation(
Math.min(20, Math.max(1, Number(e.target.value) || 1)),
)
}
disabled={disabled || !autoReplyEnabled}
className="w-20"
/>
</div>
</CardContent>
</Card>
<AiKnowledgeCard
accountId={accountId}
canEdit={canEdit}
hasEmbeddingsKey={
embeddingsKeyEdited
? embeddingsKey.trim().length > 0
: hasStoredEmbeddingsKey
}
/>
<div className="flex items-center justify-between">
{configured ? (
<Button
variant="ghost"
onClick={handleRemove}
disabled={!canEdit || removing}
className="text-destructive hover:text-destructive"
>
{removing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
) : (
<span />
)}
<Button onClick={handleSave} disabled={disabled}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,286 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { Loader2, Plus, Trash2, Pencil, RefreshCw, BookOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
interface DocSummary {
id: string;
title: string;
updated_at: string;
}
/** Editor target: 'new' when creating, a doc id when editing, null when closed. */
type EditTarget = 'new' | string | null;
export function AiKnowledgeCard({
accountId,
canEdit,
hasEmbeddingsKey,
}: {
accountId: string | null;
canEdit: boolean;
hasEmbeddingsKey: boolean;
}) {
const [docs, setDocs] = useState<DocSummary[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<EditTarget>(null);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [saving, setSaving] = useState(false);
const [reindexing, setReindexing] = useState(false);
const loadedAccountIdRef = useRef<string | null>(null);
const fetchDocs = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/ai/knowledge');
const data = await res.json();
if (res.ok) setDocs(data.documents ?? []);
else toast.error(data.error ?? 'Failed to load knowledge base');
} catch {
toast.error('Failed to load knowledge base');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!accountId || loadedAccountIdRef.current === accountId) return;
loadedAccountIdRef.current = accountId;
void fetchDocs();
}, [accountId, fetchDocs]);
const openNew = () => {
setEditing('new');
setTitle('');
setContent('');
};
const openEdit = async (id: string) => {
try {
const res = await fetch(`/api/ai/knowledge/${id}`);
const data = await res.json();
if (!res.ok) {
toast.error(data.error ?? 'Failed to open document');
return;
}
setEditing(id);
setTitle(data.title ?? '');
setContent(data.content ?? '');
} catch {
toast.error('Failed to open document');
}
};
const cancelEdit = () => {
setEditing(null);
setTitle('');
setContent('');
};
const save = async () => {
if (!title.trim() || !content.trim()) {
toast.error('Title and content are required.');
return;
}
setSaving(true);
try {
const isNew = editing === 'new';
const res = await fetch(
isNew ? '/api/ai/knowledge' : `/api/ai/knowledge/${editing}`,
{
method: isNew ? 'POST' : 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.trim(), content: content.trim() }),
},
);
const data = await res.json();
if (res.ok) {
// A 200 with `warning` means saved but indexing degraded.
if (data.warning) toast.warning(data.warning);
else toast.success(isNew ? 'Document added.' : 'Document updated.');
cancelEdit();
await fetchDocs();
} else {
toast.error(data.error ?? 'Failed to save.');
}
} catch {
toast.error('Failed to save.');
} finally {
setSaving(false);
}
};
const remove = async (id: string) => {
try {
const res = await fetch(`/api/ai/knowledge/${id}`, { method: 'DELETE' });
if (res.ok) {
toast.success('Document removed.');
setDocs((d) => d.filter((x) => x.id !== id));
} else {
const data = await res.json();
toast.error(data.error ?? 'Failed to remove.');
}
} catch {
toast.error('Failed to remove.');
}
};
const reindex = async () => {
setReindexing(true);
try {
const res = await fetch('/api/ai/knowledge/reindex', { method: 'POST' });
const data = await res.json();
if (res.ok && data.success) {
toast.success(`Reindexed ${data.reindexed} document(s).`);
} else {
toast.error(data.error ?? 'Reindex failed.');
}
} catch {
toast.error('Reindex failed.');
} finally {
setReindexing(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BookOpen className="h-4 w-4 text-primary" /> Knowledge base
</CardTitle>
<CardDescription>
Add FAQs, policies, or product details. The assistant retrieves the
relevant pieces when drafting and auto-replying, so it can answer
instead of handing off.
{hasEmbeddingsKey
? ' Semantic search is on (embeddings key set).'
: ' Using keyword search — add an embeddings key above for semantic search.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="flex items-center py-4 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading…
</div>
) : (
<>
{docs.length === 0 && editing === null && (
<p className="text-sm text-muted-foreground">
No documents yet.
</p>
)}
{docs.length > 0 && (
<ul className="divide-y divide-border rounded-md border border-border">
{docs.map((doc) => (
<li
key={doc.id}
className="flex items-center justify-between gap-2 px-3 py-2"
>
<span className="min-w-0 truncate text-sm text-foreground">
{doc.title}
</span>
{canEdit && (
<span className="flex shrink-0 gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => void openEdit(doc.id)}
title="Edit"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
onClick={() => void remove(doc.id)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
</span>
)}
</li>
))}
</ul>
)}
{editing !== null ? (
<div className="space-y-3 rounded-md border border-border p-3">
<div className="space-y-2">
<Label htmlFor="kb-title">Title</Label>
<Input
id="kb-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Returns & refunds policy"
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="kb-content">Content</Label>
<Textarea
id="kb-content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Paste the FAQ answer, policy text, or product details…"
rows={8}
disabled={saving}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={cancelEdit} disabled={saving}>
Cancel
</Button>
<Button onClick={save} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save document
</Button>
</div>
</div>
) : (
canEdit && (
<div className="flex items-center justify-between">
<Button variant="outline" size="sm" onClick={openNew}>
<Plus className="mr-2 h-4 w-4" /> Add document
</Button>
{hasEmbeddingsKey && docs.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={reindex}
disabled={reindexing}
title="Re-embed all documents (e.g. after adding an embeddings key)"
>
{reindexing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Reindex
</Button>
)}
</div>
)
)}
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,481 @@
'use client';
// ============================================================
// ApiKeysSettings — Settings → API keys
//
// Manage the credentials that authenticate the public REST API
// (`/api/v1/*`). Any member sees the roster (read-only); admin+ can
// mint and revoke (gated by <RequireRole min="admin"> here and the
// admin-only API routes + RLS on the server).
//
// One-time reveal: a freshly-minted key's plaintext is shown ONCE in
// the creation dialog. After it closes, only the prefix remains —
// the server stores just the hash. The UI states this explicitly so
// the absence of a "copy again" button reads as intentional, not a
// bug (same lesson as the invite-link flow).
// ============================================================
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RequireRole } from '@/components/auth/require-role';
import { useAuth } from '@/hooks/use-auth';
import {
API_SCOPES,
SCOPE_DESCRIPTIONS,
type ApiScope,
} from '@/lib/api-keys/scopes';
import { SettingsPanelHead } from './settings-panel-head';
interface ApiKey {
id: string;
name: string;
key_prefix: string;
scopes: string[];
last_used_at: string | null;
expires_at: string | null;
revoked_at: string | null;
created_at: string;
}
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function keyStatus(k: ApiKey): 'active' | 'revoked' | 'expired' {
if (k.revoked_at) return 'revoked';
if (k.expires_at && new Date(k.expires_at).getTime() <= Date.now())
return 'expired';
return 'active';
}
export function ApiKeysSettings() {
const { canEditSettings } = useAuth();
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [revoking, setRevoking] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const res = await fetch('/api/account/api-keys', { cache: 'no-store' });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to load API keys');
return;
}
const data = (await res.json()) as { keys: ApiKey[] };
setKeys(data.keys);
} catch (err) {
console.error('[ApiKeysSettings] load error:', err);
toast.error('Could not reach the server');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function handleRevoke(key: ApiKey) {
setRevoking(key.id);
try {
const res = await fetch(`/api/account/api-keys/${key.id}`, {
method: 'DELETE',
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to revoke key');
return;
}
toast.success(`Revoked "${key.name}"`);
// Reflect the revoke locally without a refetch.
setKeys((prev) =>
prev.map((k) =>
k.id === key.id ? { ...k, revoked_at: new Date().toISOString() } : k
)
);
} catch (err) {
console.error('[ApiKeysSettings] revoke error:', err);
toast.error('Could not reach the server');
} finally {
setRevoking(null);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="text-primary size-6 animate-spin" />
</div>
);
}
return (
<section className="animate-in fade-in-50 space-y-6 duration-200">
<SettingsPanelHead
title="API keys"
description={
<>
Keys authenticate the public REST API (
<code className="text-xs">/api/v1</code>) so you can build your own
automations. Send them as{' '}
<code className="text-xs">Authorization: Bearer &lt;key&gt;</code>.
</>
}
action={
<RequireRole min="admin">
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New API key
</Button>
</RequireRole>
}
/>
{keys.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
<KeyRound className="text-muted-foreground size-6" />
<p className="text-muted-foreground mt-2 text-sm">
No API keys yet.
</p>
{canEditSettings ? (
<p className="text-muted-foreground mt-1 text-xs">
Click <span className="text-foreground">New API key</span> to
create one.
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Ask an admin to create one.
</p>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<ul className="divide-border divide-y">
{keys.map((k) => {
const status = keyStatus(k);
const inactive = status !== 'active';
return (
<li
key={k.id}
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={`truncate text-sm font-medium ${
inactive
? 'text-muted-foreground line-through'
: 'text-foreground'
}`}
>
{k.name}
</span>
{status === 'revoked' && (
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
Revoked
</Badge>
)}
{status === 'expired' && (
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
Expired
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 font-mono text-xs">
{k.key_prefix}…
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{k.scopes.length === 0 ? (
<span className="text-muted-foreground text-xs">
No scopes
</span>
) : (
k.scopes.map((s) => (
<Badge
key={s}
className="border-border bg-muted text-muted-foreground text-[10px]"
>
{s}
</Badge>
))
)}
</div>
<p className="text-muted-foreground mt-1.5 text-xs">
Created {fmtDate(k.created_at)}
{' · '}
{k.last_used_at
? `last used ${fmtDate(k.last_used_at)}`
: 'never used'}
{k.expires_at && status !== 'expired'
? ` · expires ${fmtDate(k.expires_at)}`
: ''}
</p>
</div>
{status === 'active' && (
<RequireRole min="admin">
<Button
variant="outline"
size="sm"
onClick={() => handleRevoke(k)}
disabled={revoking === k.id}
className="self-start border-red-500/40 bg-red-500/10 text-red-300 hover:border-red-500/60 hover:bg-red-500/20 hover:text-red-200 sm:self-auto"
>
{revoking === k.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
Revoke
</Button>
</RequireRole>
)}
</li>
);
})}
</ul>
</CardContent>
</Card>
)}
<CreateKeyDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={load}
/>
</section>
);
}
// ------------------------------------------------------------
// Create dialog — form → one-time plaintext reveal.
// ------------------------------------------------------------
function CreateKeyDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}) {
const [name, setName] = useState('');
const [scopes, setScopes] = useState<ApiScope[]>([]);
const [submitting, setSubmitting] = useState(false);
// Once set, we switch from the form to the reveal view.
const [createdKey, setCreatedKey] = useState<string | null>(null);
function reset() {
setName('');
setScopes([]);
setSubmitting(false);
setCreatedKey(null);
}
function toggleScope(scope: ApiScope, checked: boolean) {
setScopes((prev) =>
checked ? [...prev, scope] : prev.filter((s) => s !== scope)
);
}
async function handleCreate() {
const trimmed = name.trim();
if (!trimmed) {
toast.error('Give the key a name');
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/account/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed, scopes }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
toast.error(payload.error || 'Failed to create key');
return;
}
setCreatedKey(payload.plaintext as string);
onCreated();
} catch (err) {
console.error('[CreateKeyDialog] create error:', err);
toast.error('Could not reach the server');
} finally {
setSubmitting(false);
}
}
async function copyKey() {
if (!createdKey) return;
try {
await navigator.clipboard.writeText(createdKey);
toast.success('API key copied');
} catch {
toast.error('Copy failed — select and copy manually');
}
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) reset();
onOpenChange(next);
}}
>
<DialogContent className="border-border bg-popover sm:max-w-md">
{createdKey ? (
<>
<DialogHeader>
<DialogTitle className="text-popover-foreground">
Copy your API key
</DialogTitle>
<DialogDescription className="text-muted-foreground">
This is the only time the full key is shown. Store it somewhere
safe — if you lose it, revoke it and create a new one.
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label className="text-muted-foreground">API key</Label>
<div className="flex gap-2">
<Input
readOnly
value={createdKey}
className="font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
<Button type="button" variant="outline" onClick={copyKey}>
<Copy className="size-4" />
Copy
</Button>
</div>
</div>
<DialogFooter>
<Button
onClick={() => {
reset();
onOpenChange(false);
}}
>
Done
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle className="text-popover-foreground">
New API key
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Name it after the integration that will use it, and grant only
the scopes it needs.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="api-key-name" className="text-muted-foreground">
Name
</Label>
<Input
id="api-key-name"
value={name}
maxLength={80}
placeholder="e.g. Zapier automation"
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Scopes</Label>
<div className="border-border space-y-2 rounded-md border p-3">
{API_SCOPES.map((scope) => (
<label
key={scope}
className="flex cursor-pointer items-start gap-2.5"
>
<Checkbox
checked={scopes.includes(scope)}
onCheckedChange={(checked) =>
toggleScope(scope, checked === true)
}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="text-foreground block font-mono text-xs">
{scope}
</span>
<span className="text-muted-foreground block text-xs">
{SCOPE_DESCRIPTIONS[scope]}
</span>
</span>
</label>
))}
</div>
<p className="text-muted-foreground text-xs">
A key with no scopes can still call{' '}
<code className="text-[11px]">GET /api/v1/me</code> to verify
it works.
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
reset();
onOpenChange(false);
}}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button onClick={handleCreate} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating…
</>
) : (
'Create key'
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,183 @@
"use client";
import { Check, Moon, Palette, SunMoon, Sun } from "lucide-react";
import { useTheme } from "@/hooks/use-theme";
import { MODES, THEMES, type Mode, type ThemeId } from "@/lib/themes";
import { cn } from "@/lib/utils";
import { SettingsPanelHead } from "./settings-panel-head";
/**
* Appearance panel — light/dark mode + accent-color picker.
*
* Two independent controls: a mode toggle (light / dark) and the
* accent grid. Either applies + persists immediately. No save button:
* each change is a single attribute swap on <html>, there's nothing
* to roll back.
*
* Persistence: localStorage only (device-scoped). The boot script in
* layout.tsx replays both choices before first paint on subsequent
* loads.
*/
export function AppearancePanel() {
const { theme, setTheme, mode, setMode } = useTheme();
return (
<section className="max-w-3xl 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">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<SunMoon className="size-4 text-muted-foreground" />
Mode
</h3>
<div
role="radiogroup"
aria-label="Color mode"
className="grid max-w-md grid-cols-2 gap-3"
>
{MODES.map((m) => (
<ModeCard
key={m}
mode={m}
isActive={m === mode}
onPick={() => setMode(m)}
/>
))}
</div>
</div>
<div className="mt-8 space-y-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Palette className="size-4 text-muted-foreground" />
Accent color
</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{THEMES.map((t) => (
<ThemeCard
key={t.id}
id={t.id}
name={t.name}
tagline={t.tagline}
swatch={t.swatch}
isActive={t.id === theme}
onPick={() => setTheme(t.id)}
/>
))}
</div>
</div>
</section>
);
}
function ModeCard({
mode,
isActive,
onPick,
}: {
mode: Mode;
isActive: boolean;
onPick: () => void;
}) {
const isLight = mode === "light";
const Icon = isLight ? Sun : Moon;
return (
<button
type="button"
role="radio"
onClick={onPick}
aria-checked={isActive}
aria-label={`Use ${mode} mode`}
className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
isActive
? "border-primary/60 ring-2 ring-primary/40"
: "border-border hover:border-border hover:bg-muted/40",
)}
>
<span
aria-hidden
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-foreground"
>
<Icon className="h-4 w-4" />
</span>
<span className="flex-1 text-sm font-semibold capitalize text-foreground">
{mode}
</span>
{isActive && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
<Check className="h-3 w-3" />
Active
</span>
)}
</button>
);
}
function ThemeCard({
id,
name,
tagline,
swatch,
isActive,
onPick,
}: {
id: ThemeId;
name: string;
tagline: string;
swatch: string;
isActive: boolean;
onPick: () => void;
}) {
return (
<button
type="button"
onClick={onPick}
aria-pressed={isActive}
aria-label={`Use ${name} theme`}
className={cn(
"flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
isActive
? "border-primary/60 ring-2 ring-primary/40"
: "border-border hover:border-border hover:bg-muted/40",
)}
>
<div className="flex items-center justify-between">
<span
aria-hidden
className="h-8 w-8 shrink-0 rounded-full"
style={{
background: swatch,
boxShadow: "inset 0 0 0 1px oklch(1 0 0 / 0.15)",
}}
/>
{isActive && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
<Check className="h-3 w-3" />
Active
</span>
)}
</div>
<div>
<div className="text-sm font-semibold text-foreground">{name}</div>
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">
{tagline}
</div>
</div>
<div
className="mt-1 flex h-2 overflow-hidden rounded-full"
aria-hidden
>
<span className="flex-1" style={{ background: swatch }} />
<span className="w-3 bg-muted-foreground/60" />
<span className="w-3 bg-muted" />
<span className="w-3 bg-card" />
</div>
<span className="sr-only">Theme id: {id}</span>
</button>
);
}

View File

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

View File

@@ -0,0 +1,130 @@
"use client";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Coins, Loader2 } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { CURRENCIES } from "@/lib/currency";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { SettingsPanelHead } from "./settings-panel-head";
/**
* Deals settings — account-wide default currency.
*
* One currency per account (issue #218): the chosen code seeds new
* deals and formats every aggregated total. Existing deals keep their
* own saved currency. Writes go straight to `accounts.default_currency`;
* the `accounts_update` RLS policy (017) already restricts that to
* admins+, so non-admins see a disabled, read-only control.
*/
export function DealsSettings() {
const supabase = createClient();
const {
accountId,
defaultCurrency,
canEditSettings,
profileLoading,
refreshProfile,
} = useAuth();
const [selected, setSelected] = useState(defaultCurrency);
const [saving, setSaving] = useState(false);
// Keep the select in sync once the profile (and its account default)
// resolves, and after a save round-trips through refreshProfile.
useEffect(() => {
setSelected(defaultCurrency);
}, [defaultCurrency]);
const dirty = selected !== defaultCurrency;
async function handleSave() {
if (!accountId || !dirty) return;
setSaving(true);
const { error } = await supabase
.from("accounts")
.update({ default_currency: selected })
.eq("id", accountId);
if (error) {
toast.error("Failed to save default currency");
setSaving(false);
return;
}
// Pull the new value back into the auth context so the deal form
// and every total pick it up without a full reload.
await refreshProfile();
setSaving(false);
toast.success("Default currency updated");
}
return (
<section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Deals & currency"
description="The currency used for new deals and for pipeline and dashboard totals."
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<Coins className="size-4 text-primary" />
Default currency
</CardTitle>
<CardDescription className="text-muted-foreground">
New deals default to this currency, and pipeline and
dashboard totals are shown in it. Existing deals keep the
currency they were saved with.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 sm:max-w-xs">
<Label className="text-muted-foreground">Currency</Label>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={!canEditSettings || profileLoading}
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{CURRENCIES.map((c) => (
<option key={c.code} value={c.code}>
{c.code} — {c.label}
</option>
))}
</select>
{!canEditSettings && (
<p className="text-xs text-muted-foreground">
Only account admins can change the default currency.
</p>
)}
</div>
{canEditSettings && (
<Button
onClick={handleSave}
disabled={saving || !dirty}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
)}
</CardContent>
</Card>
</section>
);
}

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="max-w-3xl 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

@@ -0,0 +1,367 @@
'use client';
// ============================================================
// InviteMemberDialog
//
// Two-step modal:
// 1. Form — role + expiry + optional label → POST creates the invite.
// 2. Result — the share URL, returned ONCE. Copy-to-clipboard, plus a
// "Send via WhatsApp" deep link that pre-fills wa.me with
// a friendly message containing the URL.
//
// The plaintext token is server-stored only as a SHA-256 hash, so once
// the result step is dismissed the link is gone forever — the dialog
// shouts this in copy.
// ============================================================
import { useState } from 'react';
import { toast } from 'sonner';
import { Copy, Loader2, MessageCircle, Sparkles } from 'lucide-react';
import { Button, buttonVariants } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAuth } from '@/hooks/use-auth';
type InviteRole = 'admin' | 'agent' | 'viewer';
interface InviteMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a successful create so the parent re-fetches the
* pending-invitations list. */
onCreated: () => void;
}
const EXPIRY_OPTIONS: { value: string; label: string }[] = [
{ value: '1', label: '1 day' },
{ value: '7', label: '7 days' },
{ value: '30', label: '30 days' },
];
const ROLE_DESCRIPTIONS: Record<InviteRole, string> = {
admin:
'Can invite teammates, manage settings, send messages, and edit data.',
agent:
'Can use the inbox, contacts, broadcasts, automations, and flows. No settings or member access.',
viewer: 'Read-only access across every page. Cannot send or edit anything.',
};
// Server caps label at 80 chars (see src/app/api/account/invitations/route.ts).
// Mirror it on the client so we short-circuit before the round-trip
// rather than letting the user submit and bounce off a 400.
const MAX_LABEL_LEN = 80;
interface CreatedInvite {
url: string;
role: InviteRole;
expiresInDays: number;
/** Snapshotted at creation time so a later account rename can't
* retroactively change the wa.me message text on the result step. */
accountName: string;
}
export function InviteMemberDialog({
open,
onOpenChange,
onCreated,
}: InviteMemberDialogProps) {
const { account } = useAuth();
const [role, setRole] = useState<InviteRole>('agent');
const [expiry, setExpiry] = useState<string>('7');
const [label, setLabel] = useState('');
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<CreatedInvite | null>(null);
function reset() {
setRole('agent');
setExpiry('7');
setLabel('');
setResult(null);
setSubmitting(false);
}
async function handleCreate() {
// Mirror the server's max-length check so we don't ship an
// obviously-too-long label across the wire just to bounce off
// a 400. The Input also has a `maxLength={MAX_LABEL_LEN}` cap
// but a paste can land an over-limit string into state before
// the limit kicks in on the next keystroke — this is the safety
// net for that path.
const trimmedLabel = label.trim();
if (trimmedLabel.length > MAX_LABEL_LEN) {
toast.error(`Label must be ${MAX_LABEL_LEN} characters or fewer`);
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/account/invitations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role,
expiresInDays: Number(expiry),
label: trimmedLabel || undefined,
}),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to create invitation');
return;
}
const data = (await res.json()) as {
url: string;
expiresInDays: number;
};
setResult({
url: data.url,
role,
expiresInDays: data.expiresInDays,
// Snapshot the account name into the result so the wa.me
// share message has team context. Falls back to a generic
// string if `account` hasn't loaded yet (shouldn't happen
// — the dialog requires admin+ which requires a loaded
// profile — but stay safe).
accountName: account?.name ?? 'our wacrm account',
});
onCreated();
} catch (err) {
console.error('[InviteMemberDialog] create error:', err);
toast.error('Could not reach the server. Try again?');
} finally {
setSubmitting(false);
}
}
async function copyToClipboard() {
if (!result) return;
try {
await navigator.clipboard.writeText(result.url);
toast.success('Invite link copied');
} catch {
// Most likely "not in a secure context" — happens on http://
// local IPs. Surface the link in the toast so the admin can
// hand-copy it.
toast.error('Clipboard blocked — copy the link manually');
}
}
function whatsappShareUrl(url: string): string {
// Include the account name so the recipient knows which team
// they're being invited to before clicking through. This matters
// for users in multi-team contexts where "our wacrm account"
// wouldn't be enough to disambiguate.
const accountName = result?.accountName ?? 'our wacrm account';
const message = `Join ${accountName} on wacrm using this link (valid for ${result?.expiresInDays} days): ${url}`;
return `https://wa.me/?text=${encodeURIComponent(message)}`;
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
// Reset state when the dialog closes — both for cancel and
// for dismissal after a successful create. The plaintext URL
// is intentionally NOT preserved across opens.
if (!next) reset();
onOpenChange(next);
}}
>
<DialogContent className="bg-popover border-border sm:max-w-md">
{result ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<Sparkles className="size-4 text-primary" />
Invite created
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Share this link with your new teammate. They&apos;ll be able
to sign up (or sign in) and join the account as{' '}
<span className="font-medium text-muted-foreground">{result.role}</span>
. The link is valid for{' '}
<span className="font-medium text-muted-foreground">
{result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'}
</span>
.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<Label className="text-muted-foreground">Invite link</Label>
<div className="flex gap-2">
<Input
readOnly
value={result.url}
className="bg-muted border-border text-foreground font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
<Button
type="button"
onClick={copyToClipboard}
className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0"
>
<Copy className="size-4" />
Copy
</Button>
</div>
{/* Higher-contrast amber than the original 10% / amber-200.
Reviewed against slate-900 to meet WCAG AAA for body
text (target ratio 7:1). Border bumped to /50, bg to
/15, foreground promoted to amber-100 for the strong
intro, amber-200 for the body. */}
<div className="rounded-md border border-amber-500/50 bg-amber-500/15 px-3 py-2 text-xs text-amber-200">
<strong className="font-semibold text-amber-100">
Save this link now.
</strong>{' '}
We never store the plaintext — once you close this dialog
the URL is gone. To re-share, revoke this invite and create
a new one.
</div>
{/* Anchor styled with `buttonVariants` rather than wrapping
in <Button asChild>. The wacrm Button is the Base UI
ButtonPrimitive — it has no Radix-style asChild slot.
Direct anchor preserves right-click "Open in new tab"
behaviour too. */}
<a
href={whatsappShareUrl(result.url)}
target="_blank"
rel="noreferrer noopener"
className={buttonVariants({
variant: 'outline',
className:
'w-full border-border text-muted-foreground hover:bg-muted',
})}
>
<MessageCircle className="size-4" />
Send via WhatsApp
</a>
</div>
<DialogFooter className="bg-popover border-border">
<Button
onClick={() => onOpenChange(false)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
Done
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle className="text-popover-foreground">Invite a teammate</DialogTitle>
<DialogDescription className="text-muted-foreground">
Generate a one-time invite link. Share it via WhatsApp,
Slack, or any channel you like — no email service required.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label className="text-muted-foreground">Role</Label>
<Select
value={role}
onValueChange={(v) => v && setRole(v as InviteRole)}
>
<SelectTrigger className="w-full bg-muted border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="agent">Agent</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{ROLE_DESCRIPTIONS[role]}
</p>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Link valid for</Label>
<Select
value={expiry}
onValueChange={(v) => v && setExpiry(v)}
>
<SelectTrigger className="w-full bg-muted border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">
Label{' '}
<span className="text-xs text-muted-foreground">(optional)</span>
</Label>
<Input
placeholder="e.g. Sara — support team"
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={MAX_LABEL_LEN}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<p className="text-xs text-muted-foreground">
Helps you remember who you sent the link to in the pending
list below.
</p>
</div>
</div>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={submitting}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating...
</>
) : (
'Generate link'
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,619 @@
'use client';
// ============================================================
// MembersTab — Settings → Members
//
// Two stacked sections:
// 1. Roster — every member of the account. Admin+ can change a
// teammate's role inline and remove them. Owner row
// is non-editable everywhere (transfer is its own
// separate flow, deferred to a later PR).
// 2. Pending — outstanding invite links. Admin+ can revoke. The
// plaintext URL is gone after the create dialog
// closes, so we surface a "revoke + new link" hint
// rather than pretending we can resurface it.
//
// Role-gating
// The tab itself is reachable by any member, but mutation buttons
// are wrapped in `<RequireRole min="admin">` / `useCan` so an
// agent or viewer sees the roster read-only. The server-side
// RPCs (set_member_role, remove_account_member) double-check
// the role anyway.
// ============================================================
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
AlertTriangle,
Loader2,
Mail,
MailX,
Plus,
Trash2,
UsersRound,
} from 'lucide-react';
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RequireRole } from '@/components/auth/require-role';
import { useAuth } from '@/hooks/use-auth';
import { usePresence } from '@/hooks/use-presence';
import type { AccountRole } from '@/lib/auth/roles';
import { presenceLabel, summarize } from '@/lib/presence';
import {
PRESENCE_DOT_CLASS,
PresenceDot,
} from '@/components/presence/presence-dot';
import { InviteMemberDialog } from './invite-member-dialog';
import { SettingsPanelHead } from './settings-panel-head';
import { ROLE_META } from './role-meta';
interface Member {
user_id: string;
full_name: string;
email: string | null;
avatar_url: string | null;
role: AccountRole;
joined_at: string;
}
interface Invitation {
id: string;
role: 'admin' | 'agent' | 'viewer';
label: string | null;
created_at: string;
expires_at: string;
}
// Editable roles in the inline dropdown. Owner is never an option —
// promotions go through the (deferred) Transfer Ownership flow.
const EDITABLE_ROLES: { value: AccountRole; label: string; hint: string }[] = [
{ value: 'admin', label: 'Admin', hint: 'Manage members + everything' },
{ value: 'agent', label: 'Agent', hint: 'Use features; no settings' },
{ value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' },
];
// Per-role chip metadata (icon / label / colour) lives in the shared
// ROLE_META module so this roster and the Overview identity chip can't
// drift. The colour scale runs amber (owner — scarce, immutable) →
// primary (admin) → muted (agent / viewer).
function fmtDate(iso: string): string {
// Match the rest of the dashboard's locale-light formatting.
const d = new Date(iso);
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function fmtExpiresIn(iso: string): string {
const ms = new Date(iso).getTime() - Date.now();
if (ms <= 0) return 'expired';
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
if (days >= 1) return `expires in ${days} day${days === 1 ? '' : 's'}`;
const hours = Math.max(1, Math.floor(ms / (60 * 60 * 1000)));
return `expires in ${hours} hour${hours === 1 ? '' : 's'}`;
}
export function MembersTab() {
const { user, canManageMembers } = useAuth();
const { getPresence, getRow, now } = usePresence();
const [members, setMembers] = useState<Member[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(true);
const [inviteOpen, setInviteOpen] = useState(false);
const [removingMember, setRemovingMember] = useState<Member | null>(null);
const [pendingMemberAction, setPendingMemberAction] = useState<string | null>(
null,
);
const loadEverything = useCallback(async () => {
try {
const [mres, ires] = await Promise.all([
fetch('/api/account/members', { cache: 'no-store' }),
canManageMembers
? fetch('/api/account/invitations', { cache: 'no-store' })
: Promise.resolve(null),
]);
if (!mres.ok) {
const payload = await mres.json().catch(() => ({}));
toast.error(payload.error || 'Failed to load members');
return;
}
const mdata = (await mres.json()) as { members: Member[] };
setMembers(mdata.members);
if (ires) {
if (!ires.ok) {
const payload = await ires.json().catch(() => ({}));
toast.error(payload.error || 'Failed to load invitations');
return;
}
const idata = (await ires.json()) as { invitations: Invitation[] };
setInvitations(idata.invitations);
} else {
setInvitations([]);
}
} catch (err) {
console.error('[MembersTab] load error:', err);
toast.error('Could not reach the server');
} finally {
setLoading(false);
}
}, [canManageMembers]);
useEffect(() => {
void loadEverything();
}, [loadEverything]);
async function handleRoleChange(member: Member, nextRole: AccountRole) {
if (member.role === nextRole) return;
// Optimistic update — flip the dropdown immediately so the UI
// feels snappy. If the server PATCH fails we revert below so
// the dropdown doesn't lie about the persisted state.
const previousRole = member.role;
setPendingMemberAction(member.user_id);
setMembers((prev) =>
prev.map((m) =>
m.user_id === member.user_id ? { ...m, role: nextRole } : m,
),
);
try {
const res = await fetch(`/api/account/members/${member.user_id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: nextRole }),
});
if (!res.ok) {
// Revert the optimistic flip. The toast on its own wasn't
// enough — the dropdown was left showing the new role
// forever, so the next interaction operated on a wrong
// baseline (re-trying the same change would no-op via the
// `member.role === nextRole` guard at the top).
setMembers((prev) =>
prev.map((m) =>
m.user_id === member.user_id ? { ...m, role: previousRole } : m,
),
);
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to update role');
return;
}
toast.success(`Updated ${member.full_name || 'member'} to ${nextRole}`);
} catch (err) {
// Same revert on network failure.
setMembers((prev) =>
prev.map((m) =>
m.user_id === member.user_id ? { ...m, role: previousRole } : m,
),
);
console.error('[MembersTab] role change error:', err);
toast.error('Could not reach the server');
} finally {
setPendingMemberAction(null);
}
}
async function handleRemove() {
if (!removingMember) return;
setPendingMemberAction(removingMember.user_id);
try {
const res = await fetch(
`/api/account/members/${removingMember.user_id}`,
{ method: 'DELETE' },
);
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to remove member');
return;
}
toast.success(`Removed ${removingMember.full_name || 'member'}`);
setMembers((prev) =>
prev.filter((m) => m.user_id !== removingMember.user_id),
);
setRemovingMember(null);
} catch (err) {
console.error('[MembersTab] remove error:', err);
toast.error('Could not reach the server');
} finally {
setPendingMemberAction(null);
}
}
async function handleRevoke(invite: Invitation) {
try {
const res = await fetch(`/api/account/invitations/${invite.id}`, {
method: 'DELETE',
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to revoke invitation');
return;
}
toast.success('Invitation revoked');
setInvitations((prev) => prev.filter((i) => i.id !== invite.id));
} catch (err) {
console.error('[MembersTab] revoke error:', err);
toast.error('Could not reach the server');
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
);
}
return (
<section className="animate-in fade-in-50 space-y-6 duration-200">
<SettingsPanelHead
title="Team members"
description="People with access to this account. Roles control what each teammate can do."
action={
<RequireRole min="admin">
<Button onClick={() => setInviteOpen(true)}>
<Plus className="size-4" />
Invite member
</Button>
</RequireRole>
}
/>
{/* Live presence summary across the roster. Updates without a
full refresh as heartbeats and the local re-derive tick land. */}
{members.length > 0 &&
(() => {
const counts = summarize(members.map((m) => getPresence(m.user_id)));
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="online" />
{counts.online} online
</span>
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="away" />
{counts.away} away
</span>
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="offline" />
{counts.offline} offline
</span>
<span className="text-muted-foreground/70">
· {members.length} member{members.length === 1 ? '' : 's'}
</span>
</div>
);
})()}
{/* Roster */}
<Card>
<CardContent className="p-0">
<ul className="divide-y divide-border">
{members.map((member) => {
const roleMeta = ROLE_META[member.role];
const RoleIcon = roleMeta.icon;
const isSelf = member.user_id === user?.id;
const isOwnerRow = member.role === 'owner';
const isBusy = pendingMemberAction === member.user_id;
const presence = getPresence(member.user_id);
const presenceRow = getRow(member.user_id);
const presenceText = presenceLabel(
presence,
presenceRow?.last_seen_at ?? null,
now,
);
return (
<li
key={member.user_id}
// Mobile: stack identity (avatar+name+email) above the
// role/remove actions so the role dropdown's fixed
// 128px width doesn't force the name into a 50-pixel
// truncation. Desktop (sm+): everything inline as
// before.
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<Tooltip>
<TooltipTrigger
render={
<Avatar className="size-9 shrink-0">
{member.avatar_url ? (
<AvatarImage
src={member.avatar_url}
alt={member.full_name || 'Member'}
/>
) : null}
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
{(member.full_name || member.email || 'U')
.charAt(0)
.toUpperCase()}
</AvatarFallback>
{/* role+label so screen readers announce
presence — the hover tooltip alone isn't
reachable by keyboard/AT on a non-focusable
avatar. */}
<AvatarBadge
role="img"
aria-label={presenceText}
className={PRESENCE_DOT_CLASS[presence]}
/>
</Avatar>
}
/>
<TooltipContent>{presenceText}</TooltipContent>
</Tooltip>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{member.full_name || 'Unnamed'}
</span>
{isSelf && (
<Badge className="bg-muted text-muted-foreground border-border text-[10px] uppercase tracking-wide">
You
</Badge>
)}
</div>
{member.email && (
<p className="truncate text-xs text-muted-foreground">
{member.email}
</p>
)}
</div>
</div>
{/* Joined date stays desktop-only. The mobile row's
vertical density makes the joined date noise. */}
<div className="hidden sm:block text-right text-xs text-muted-foreground">
Joined {fmtDate(member.joined_at)}
</div>
{/* Actions cluster. On mobile this is its own row
below the identity block; on desktop it sits
inline. Items align to the start on mobile so the
role dropdown lines up under the avatar. */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Role display / editor. Inline Select is admin+
only AND not allowed on the owner row (owner
changes go through transfer, which lands later). */}
{canManageMembers && !isOwnerRow && !isSelf ? (
<Select
value={member.role}
onValueChange={(v) =>
// Base UI Select can emit null on clear. We
// don't expose a clear affordance, so the
// guard is defensive — but the typed
// signature requires it.
v && handleRoleChange(member, v as AccountRole)
}
>
<SelectTrigger
className="w-32 bg-muted border-border text-foreground"
disabled={isBusy}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EDITABLE_ROLES.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium ${roleMeta.className}`}
>
<RoleIcon className="size-3.5" />
{roleMeta.label}
</span>
)}
{/* Remove. Admin+ only; never on the owner row;
never on yourself. Pre-polish styling was
neutral-default + red-on-hover — the
destructive intent was invisible until the
user moused over. Now red is the default
state with a darker shade on hover so the
affordance reads at-a-glance. */}
{canManageMembers && !isOwnerRow && !isSelf && (
<Button
variant="outline"
size="sm"
onClick={() => setRemovingMember(member)}
disabled={isBusy}
className="border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20 hover:border-red-500/60 hover:text-red-200"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</li>
);
})}
</ul>
</CardContent>
</Card>
{/* Pending invitations — admin+ only */}
<RequireRole min="admin">
<div>
<div className="mb-2 flex items-center gap-2">
<UsersRound className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
Pending invitations
</h3>
<Badge className="bg-muted text-muted-foreground border-border">
{invitations.length}
</Badge>
</div>
{/* P10 — make the no-resend design explicit. Admins were
confused why the pending list shows roles + expiry but
no "copy link again" button. Stating the constraint up
front (rather than letting the user discover it by
looking for a button) keeps it from feeling like a bug. */}
{invitations.length > 0 ? (
<p className="mb-3 text-xs text-muted-foreground">
The plaintext invite URL is only shown once at creation
for security — to re-share, revoke the invite below and
create a new one.
</p>
) : null}
{invitations.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Mail className="size-6 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground">
No pending invitations.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Click <span className="text-muted-foreground">Invite member</span>{' '}
above to generate a shareable link.
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<ul className="divide-y divide-border">
{invitations.map((inv) => {
const inviteRoleMeta = ROLE_META[inv.role];
const InviteRoleIcon = inviteRoleMeta.icon;
return (
<li
key={inv.id}
className="flex items-center gap-4 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{inv.label || 'Untitled invite'}
</span>
<span
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium ${inviteRoleMeta.className}`}
>
<InviteRoleIcon className="size-3" />
{inviteRoleMeta.label}
</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)}
</p>
</div>
{/* Revoke: red default state, mirrors the
members-tab Remove button. Pre-polish version
read as a neutral secondary button until
hover. */}
<Button
variant="outline"
size="sm"
onClick={() => handleRevoke(inv)}
className="border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20 hover:border-red-500/60 hover:text-red-200"
>
<MailX className="size-4" />
Revoke
</Button>
</li>
);
})}
</ul>
</CardContent>
</Card>
)}
</div>
</RequireRole>
<InviteMemberDialog
open={inviteOpen}
onOpenChange={setInviteOpen}
onCreated={loadEverything}
/>
<Dialog
open={removingMember !== null}
onOpenChange={(open) => {
if (!open) setRemovingMember(null);
}}
>
<DialogContent className="bg-popover border-border sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<AlertTriangle className="size-4 text-amber-400" />
Remove member
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Remove{' '}
<span className="font-medium text-muted-foreground">
{removingMember?.full_name || 'this teammate'}
</span>{' '}
from the account? They&apos;ll be signed out of this account
and given a fresh personal account on their next sign-in. Their
login isn&apos;t deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setRemovingMember(null)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={handleRemove}
disabled={!!pendingMemberAction}
className="bg-red-600 hover:bg-red-700 text-white"
>
{pendingMemberAction ? (
<>
<Loader2 className="size-4 animate-spin" />
Removing...
</>
) : (
'Remove member'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import { useState } from 'react';
import { toast } from 'sonner';
import { Loader2, KeyRound } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
const MIN_PASSWORD = 8;
export function PasswordForm() {
const { profile } = useAuth();
const supabase = createClient();
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [confirm, setConfirm] = useState('');
const [saving, setSaving] = useState(false);
const [confirmError, setConfirmError] = useState<string | null>(null);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!profile?.email) {
toast.error('Cannot change password without a current email');
return;
}
if (next.length < MIN_PASSWORD) {
setConfirmError(`Password must be at least ${MIN_PASSWORD} characters`);
return;
}
if (next !== confirm) {
setConfirmError('New password and confirmation do not match');
return;
}
setConfirmError(null);
setSaving(true);
try {
// Supabase doesn't expose a "verify password without issuing a
// session" API, so we re-authenticate with the provided current
// password. If it matches, the session refreshes silently; if it
// doesn't, we abort before calling updateUser.
const { error: signInError } = await supabase.auth.signInWithPassword({
email: profile.email,
password: current,
});
if (signInError) {
toast.error('Current password is incorrect');
return;
}
const { error: updateError } = await supabase.auth.updateUser({
password: next,
});
if (updateError) {
toast.error(`Password update failed: ${updateError.message}`);
return;
}
setCurrent('');
setNext('');
setConfirm('');
toast.success('Password updated');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast.error(msg);
} finally {
setSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<KeyRound className="size-4 text-primary" />
Password
</CardTitle>
<CardDescription className="text-muted-foreground">
Use at least {MIN_PASSWORD} characters. You will stay signed in on
this device after changing it.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password" className="text-foreground">
Current password
</Label>
<Input
id="current-password"
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
disabled={saving}
required
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="new-password" className="text-foreground">
New password
</Label>
<Input
id="new-password"
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
autoComplete="new-password"
minLength={MIN_PASSWORD}
disabled={saving}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password" className="text-foreground">
Confirm new password
</Label>
<Input
id="confirm-password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
minLength={MIN_PASSWORD}
disabled={saving}
required
/>
</div>
</div>
{confirmError && (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{confirmError}
</p>
)}
<div className="flex justify-end">
<Button
type="submit"
disabled={saving || !current || !next || !confirm}
>
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Updating…
</>
) : (
'Update password'
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,353 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { Loader2, Upload, Trash2, Mail, CircleAlert } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import { Card, CardContent } from '@/components/ui/card';
import { SettingsPanelHead } from './settings-panel-head';
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
const ALLOWED_MIME = new Set([
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
]);
// Rough email shape check — the real validator is Supabase Auth, which
// rejects anything malformed when we call updateUser({ email }). We
// just want to stop obvious typos before making a network call.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function ProfileForm() {
const { user, profile, refreshProfile } = useAuth();
const supabase = createClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [removeAvatar, setRemoveAvatar] = useState(false);
const [saving, setSaving] = useState(false);
const [emailChangePending, setEmailChangePending] = useState(false);
// Seed form state once the profile loads.
useEffect(() => {
if (!profile) return;
setFullName(profile.full_name ?? '');
setEmail(profile.email ?? '');
}, [profile]);
// Cleanup object URLs to avoid leaks.
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const currentAvatar =
previewUrl ?? (!removeAvatar ? profile?.avatar_url ?? null : null);
const initial = (fullName || profile?.full_name || profile?.email || 'U')
.charAt(0)
.toUpperCase();
const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ''; // reset so the same file can be re-picked
if (!file) return;
if (!ALLOWED_MIME.has(file.type)) {
toast.error('Unsupported image type', {
description: 'Use PNG, JPG, WebP, or GIF.',
});
return;
}
if (file.size > MAX_AVATAR_BYTES) {
toast.error('Image is too large', {
description: 'Maximum 2 MB.',
});
return;
}
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPendingAvatar(file);
setPreviewUrl(URL.createObjectURL(file));
setRemoveAvatar(false);
};
const onRemoveAvatar = () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPendingAvatar(null);
setPreviewUrl(null);
setRemoveAvatar(true);
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user || !profile) return;
const trimmedName = fullName.trim();
if (!trimmedName) {
toast.error('Display name is required');
return;
}
const trimmedEmail = email.trim();
if (!EMAIL_RE.test(trimmedEmail)) {
toast.error('Enter a valid email address');
return;
}
setSaving(true);
try {
let nextAvatarUrl: string | null = profile.avatar_url ?? null;
// Upload a newly-staged image, if any.
if (pendingAvatar) {
const ext =
pendingAvatar.name.split('.').pop()?.toLowerCase() || 'png';
const path = `${user.id}/avatar-${Date.now()}.${ext}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(path, pendingAvatar, {
cacheControl: '3600',
upsert: true,
contentType: pendingAvatar.type,
});
if (uploadError) {
throw new Error(`Upload failed: ${uploadError.message}`);
}
const {
data: { publicUrl },
} = supabase.storage.from('avatars').getPublicUrl(path);
nextAvatarUrl = publicUrl;
} else if (removeAvatar) {
nextAvatarUrl = null;
}
// Persist name + avatar to profiles.
const { error: updateError } = await supabase
.from('profiles')
.update({
full_name: trimmedName,
avatar_url: nextAvatarUrl,
})
.eq('user_id', user.id);
if (updateError) {
throw new Error(`Save failed: ${updateError.message}`);
}
// Email change goes through Supabase Auth, which emails a
// confirmation to both the old and new addresses. We don't
// touch profiles.email — Supabase will push the change there
// after the user clicks the link (handled by the handle_new_user
// trigger pattern in production deployments).
let emailSent = false;
if (trimmedEmail.toLowerCase() !== profile.email.toLowerCase()) {
const { error: emailError } = await supabase.auth.updateUser({
email: trimmedEmail,
});
if (emailError) {
// Partial success: name/avatar saved but email didn't.
toast.success('Profile saved');
toast.error(`Email change failed: ${emailError.message}`);
setSaving(false);
await refreshProfile();
return;
}
emailSent = true;
}
setEmailChangePending(emailSent);
setPendingAvatar(null);
setPreviewUrl(null);
setRemoveAvatar(false);
await refreshProfile();
toast.success(
emailSent
? 'Profile saved — check your email to confirm the address change'
: 'Profile saved',
);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast.error(msg);
} finally {
setSaving(false);
}
};
const dirty =
!!profile &&
(fullName.trim() !== (profile.full_name ?? '') ||
email.trim().toLowerCase() !== (profile.email ?? '').toLowerCase() ||
pendingAvatar !== null ||
removeAvatar);
const joined = user?.created_at
? new Date(user.created_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: '—';
return (
<section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Your profile"
description="How you show up across the app. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you."
/>
<form onSubmit={onSubmit} className="space-y-4">
<Card>
<CardContent className="space-y-6">
{/* Avatar row */}
<div className="flex flex-wrap items-center gap-5">
<Avatar size="lg" className="size-16">
{currentAvatar ? (
<AvatarImage src={currentAvatar} alt={fullName || 'Avatar'} />
) : null}
<AvatarFallback className="bg-primary/10 text-base text-primary">
{initial}
</AvatarFallback>
</Avatar>
<div className="flex flex-wrap gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={onPickFile}
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={saving}
>
<Upload className="size-4" />
{currentAvatar ? 'Change photo' : 'Upload photo'}
</Button>
{currentAvatar && (
<Button
type="button"
variant="ghost"
onClick={onRemoveAvatar}
disabled={saving}
className="text-muted-foreground hover:text-foreground"
>
<Trash2 className="size-4" />
Remove
</Button>
)}
<p className="w-full text-xs text-muted-foreground">
PNG, JPG, WebP, or GIF. Up to 2 MB.
</p>
</div>
</div>
{/* Name */}
<div className="space-y-2">
<Label htmlFor="profile-full-name" className="text-foreground">
Display name
</Label>
<Input
id="profile-full-name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Ada Lovelace"
maxLength={120}
disabled={saving}
required
/>
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="profile-email" className="text-foreground">
Email
</Label>
<Input
id="profile-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={saving}
required
/>
{emailChangePending && (
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<Mail className="mt-0.5 size-3.5 shrink-0" />
<span>
Check the inbox for <strong>{profile?.email}</strong> and{' '}
<strong>{email}</strong> — both need to confirm before the
change takes effect.
</span>
</p>
)}
</div>
{/* Read-only block */}
<div className="rounded-lg border border-border bg-muted p-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Account details
</p>
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Role</dt>
<dd className="mt-0.5 font-mono text-foreground">
{profile?.role ?? 'user'}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Joined</dt>
<dd className="mt-0.5 text-foreground">{joined}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-muted-foreground">User ID</dt>
<dd className="mt-0.5 break-all font-mono text-xs text-muted-foreground">
{user?.id ?? '—'}
</dd>
</div>
</dl>
</div>
{!profile && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<CircleAlert className="size-4" />
Loading your profile…
</p>
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={saving || !dirty || !profile}>
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving…
</>
) : (
'Save changes'
)}
</Button>
</div>
</form>
</section>
);
}

View File

@@ -0,0 +1,51 @@
import {
Crown,
Shield,
UserCog,
UserIcon,
type LucideIcon,
} from 'lucide-react';
import type { AccountRole } from '@/lib/auth/roles';
import type { ChipVariant } from './settings-chip';
/**
* Single source of truth for per-role chip metadata across settings
* surfaces (the Overview identity chip and the Members roster/invite
* chips). Previously duplicated in both files; hoisted here so a label,
* icon, or colour change lands once.
*
* `variant` drives the token-based <SettingsChip>; `className` is the
* inline Tailwind string the Members tab applies to its own spans.
*/
export const ROLE_META: Record<
AccountRole,
{ icon: LucideIcon; label: string; variant: ChipVariant; className: string }
> = {
owner: {
icon: Crown,
label: 'Owner',
variant: 'owner',
className: 'border-amber-500/40 bg-amber-500/10 text-amber-300',
},
admin: {
icon: Shield,
label: 'Admin',
variant: 'admin',
className: 'border-primary/40 bg-primary/10 text-primary',
},
agent: {
icon: UserCog,
label: 'Agent',
variant: 'muted',
className: 'border-border bg-muted text-muted-foreground',
},
viewer: {
icon: UserIcon,
label: 'Viewer',
variant: 'muted',
// Outline-only so it stays quieter than the filled Agent chip in
// both modes — bg-card would blend into a card surface in light mode.
className: 'border-border bg-transparent text-muted-foreground',
},
};

View File

@@ -0,0 +1,24 @@
'use client';
import { PasswordForm } from './password-form';
import { SessionsCard } from './sessions-card';
import { SettingsPanelHead } from './settings-panel-head';
/**
* "Login & security" section — groups the former Profile-tab password
* and active-sessions cards into their own dedicated home.
*/
export function SecurityPanel() {
return (
<section className="max-w-2xl 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

@@ -0,0 +1,109 @@
'use client';
import { useState } from 'react';
import { toast } from 'sonner';
import { Loader2, LogOut } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
export function SessionsCard() {
const supabase = createClient();
const [open, setOpen] = useState(false);
const [signingOut, setSigningOut] = useState(false);
const onConfirm = async () => {
setSigningOut(true);
try {
// scope: 'global' revokes every refresh token for this user
// across all devices; the next auth-state change on this tab
// triggers the usual redirect.
const { error } = await supabase.auth.signOut({ scope: 'global' });
if (error) {
toast.error(`Sign-out failed: ${error.message}`);
return;
}
window.location.href = '/login';
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast.error(msg);
} finally {
setSigningOut(false);
}
};
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<LogOut className="size-4 text-primary" />
Active sessions
</CardTitle>
<CardDescription className="text-muted-foreground">
Sign out of every device where you&apos;re logged in — including
this one. Useful if you lost a laptop or shared your password.
</CardDescription>
</CardHeader>
<CardContent>
<Button
type="button"
variant="outline"
onClick={() => setOpen(true)}
>
<LogOut className="size-4" />
Sign out of all devices
</Button>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sign out everywhere?</DialogTitle>
<DialogDescription>
Every device logged into this account will be signed out and
will need to log in again. You will be redirected to the login
page.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
disabled={signingOut}
>
Cancel
</Button>
<Button type="button" onClick={onConfirm} disabled={signingOut}>
{signingOut ? (
<>
<Loader2 className="size-4 animate-spin" />
Signing out…
</>
) : (
'Sign out everywhere'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

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?.id, 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 xl:grid-cols-3">
{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,81 @@
import {
Coins,
FileText,
KeyRound,
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',
'api',
] 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' },
api: { id: 'api', label: 'API keys', icon: KeyRound, 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

@@ -0,0 +1,289 @@
'use client';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Loader2, Plus, Tag as TagIcon, X } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import type { Tag } from '@/types';
const PRESET_COLORS = [
{ name: 'Red', value: '#ef4444' },
{ name: 'Orange', value: '#f97316' },
{ name: 'Amber', value: '#f59e0b' },
{ name: 'Emerald', value: '#10b981' },
{ name: 'Cyan', value: '#06b6d4' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Violet', value: '#8b5cf6' },
{ name: 'Pink', value: '#ec4899' },
];
/**
* Tags card — colour-coded contact labels. Creation is an inline row
* (name + colour swatch + Add); deletion goes through a confirmation
* dialog since it detaches the tag from every contact.
*/
export function TagManager() {
const supabase = createClient();
const { user, accountId, loading: authLoading } = useAuth();
const [loading, setLoading] = useState(true);
const [tags, setTags] = useState<Tag[]>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [newTagName, setNewTagName] = useState('');
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[3].value);
useEffect(() => {
if (authLoading) return;
if (!user) {
setLoading(false);
return;
}
fetchTags(user.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authLoading, user?.id]);
async function fetchTags(userId: string) {
try {
setLoading(true);
const { data, error } = await supabase
.from('tags')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: true });
if (error) throw error;
setTags(data || []);
} catch (err) {
console.error('Failed to fetch tags:', err);
toast.error('Failed to load tags');
} finally {
setLoading(false);
}
}
async function handleCreate() {
if (!newTagName.trim()) {
toast.error('Tag name is required');
return;
}
try {
setSaving(true);
if (!user || !accountId) {
toast.error('Not authenticated');
return;
}
// account_id is mandatory on every account-scoped insert (NOT
// NULL + RLS, no DB default).
const { error } = await supabase.from('tags').insert({
user_id: user.id,
account_id: accountId,
name: newTagName.trim(),
color: selectedColor,
});
if (error) throw error;
toast.success('Tag created');
setNewTagName('');
setSelectedColor(PRESET_COLORS[3].value);
await fetchTags(user.id);
} catch (err) {
console.error('Create error:', err);
toast.error('Failed to create tag');
} finally {
setSaving(false);
}
}
function confirmDelete(tag: Tag) {
setTagToDelete(tag);
setDeleteDialogOpen(true);
}
async function handleDelete() {
if (!tagToDelete) return;
try {
setDeleting(true);
const { error } = await supabase
.from('tags')
.delete()
.eq('id', tagToDelete.id);
if (error) throw error;
toast.success('Tag deleted');
setTags((prev) => prev.filter((t) => t.id !== tagToDelete.id));
setDeleteDialogOpen(false);
setTagToDelete(null);
} catch (err) {
console.error('Delete error:', err);
toast.error('Failed to delete tag');
} finally {
setDeleting(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<TagIcon className="size-4 text-primary" />
Tags
</CardTitle>
<CardDescription className="text-muted-foreground">
Colour-coded labels for grouping and filtering contacts.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : (
<>
{tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
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
className="size-2 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
<button
type="button"
onClick={() => confirmDelete(tag)}
aria-label={`Delete ${tag.name}`}
className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
>
<X className="size-3" />
</button>
</span>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No tags yet — create your first one below.
</p>
)}
{/* Inline create row */}
<div className="flex flex-wrap items-center gap-2.5">
<Input
placeholder="e.g. Newsletter"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
}}
disabled={saving}
maxLength={40}
className="min-w-[180px] flex-1"
/>
<div className="flex gap-1.5">
{PRESET_COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setSelectedColor(color.value)}
aria-label={`Use ${color.name}`}
aria-pressed={selectedColor === color.value}
className={cn(
'size-6 rounded-md transition-transform hover:scale-110',
selectedColor === color.value &&
'outline outline-2 outline-offset-2 outline-primary',
)}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={handleCreate}
disabled={saving || !newTagName.trim()}
>
{saving ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
Add tag
</Button>
</div>
</>
)}
</CardContent>
{/* Delete confirmation */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete tag</DialogTitle>
<DialogDescription>
Delete the tag &quot;{tagToDelete?.name}&quot;? This removes it
from all contacts and cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
</>
) : (
'Delete tag'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,856 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { toast } from 'sonner';
import {
Eye,
EyeOff,
Copy,
CheckCircle2,
XCircle,
Loader2,
ExternalLink,
Zap,
AlertTriangle,
RotateCcw,
} from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { SettingsPanelHead } from './settings-panel-head';
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion';
import type { WhatsAppConfig as WhatsAppConfigType } from '@/types';
const MASKED_TOKEN = '••••••••••••••••';
type ConnectionStatus = 'connected' | 'disconnected' | 'unknown';
type ResetReason = 'token_corrupted' | 'meta_api_error' | null;
export function WhatsAppConfig() {
const supabase = createClient();
// After multi-user, whatsapp_config is one-row-per-account, not
// one-row-per-user. We pull `accountId` straight off the auth
// context and key every read off it — so a teammate who just
// joined an account sees the inviter's saved config without
// having to re-enter anything.
const { user, accountId, loading: authLoading, profileLoading } = useAuth();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [resetting, setResetting] = useState(false);
const [showToken, setShowToken] = useState(false);
const [config, setConfig] = useState<WhatsAppConfigType | null>(null);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('unknown');
const [resetReason, setResetReason] = useState<ResetReason>(null);
const [statusMessage, setStatusMessage] = useState<string>('');
// Guards against re-hydrating the form when the load effect below
// re-runs for reasons unrelated to actually switching accounts —
// e.g. Supabase's onAuthStateChange fires a token refresh (new
// `user` object, profileLoading flips true/false) when the browser
// tab regains focus. Without this, that churn calls fetchConfig()
// again and overwrites whatever the user typed but hadn't saved yet.
const loadedAccountIdRef = useRef<string | null>(null);
const [phoneNumberId, setPhoneNumberId] = useState('');
const [wabaId, setWabaId] = useState('');
const [accessToken, setAccessToken] = useState('');
const [verifyToken, setVerifyToken] = useState('');
const [pin, setPin] = useState('');
const [tokenEdited, setTokenEdited] = useState(false);
// True once /register has succeeded on Meta's side (timestamp set
// in the row). When false, the saved config is metadata-only and
// Meta will silently drop every inbound event — that's the
// multi-number bug that prompted this work.
const isRegistered = Boolean(config?.registered_at);
const lastRegistrationError = config?.last_registration_error ?? null;
const [verifyingRegistration, setVerifyingRegistration] = useState(false);
type RegistrationProbe = {
live: boolean;
checks: Record<string, boolean | null>;
errors?: string[];
last_registration_error?: string | null;
registered_at?: string | null;
subscribed_apps_at?: string | null;
};
const [registrationProbe, setRegistrationProbe] =
useState<RegistrationProbe | null>(null);
const webhookUrl =
typeof window !== 'undefined'
? `${window.location.origin}/api/whatsapp/webhook`
: '';
const fetchConfig = useCallback(async (acctId: string) => {
setLoading(true);
try {
// Load form values from Supabase (shows what's in DB).
// Switched from `user_id` (which would only match the row's
// original author) to `account_id` so every member of the
// account sees the same saved configuration. UNIQUE(account_id)
// on the table guarantees the .maybeSingle() return type
// remains accurate.
const { data, error } = await supabase
.from('whatsapp_config')
.select('*')
.eq('account_id', acctId)
.maybeSingle();
if (error) {
console.error('Failed to load config row:', error);
}
if (data) {
setConfig(data);
setPhoneNumberId(data.phone_number_id || '');
setWabaId(data.waba_id || '');
setAccessToken(MASKED_TOKEN);
setVerifyToken('');
setPin('');
setTokenEdited(false);
} else {
setConfig(null);
setPhoneNumberId('');
setWabaId('');
setAccessToken('');
setVerifyToken('');
setPin('');
setTokenEdited(false);
}
// Clear any stale probe result when reloading the row.
setRegistrationProbe(null);
// Then verify health via the API (decrypts token + pings Meta)
if (data) {
try {
const res = await fetch('/api/whatsapp/config', { method: 'GET' });
const payload = await res.json();
if (payload.connected) {
setConnectionStatus('connected');
setResetReason(null);
setStatusMessage('');
} else {
setConnectionStatus('disconnected');
setResetReason(payload.needs_reset ? 'token_corrupted' : payload.reason === 'meta_api_error' ? 'meta_api_error' : null);
setStatusMessage(payload.message || '');
}
} catch (err) {
console.error('Health check failed:', err);
setConnectionStatus('disconnected');
}
} else {
setConnectionStatus('disconnected');
setResetReason(null);
setStatusMessage('');
}
} catch (err) {
console.error('fetchConfig error:', err);
toast.error('Failed to load WhatsApp configuration');
} finally {
setLoading(false);
}
}, [supabase]);
useEffect(() => {
// Need both the auth session (`!authLoading`) AND the profile
// (`!profileLoading`, which carries `accountId`). Without the
// second guard, the effect would fire with `accountId === null`
// for the first render window and bail without ever retrying
// once the profile arrives.
if (authLoading || profileLoading) return;
if (!user || !accountId) {
loadedAccountIdRef.current = null;
setLoading(false);
return;
}
if (loadedAccountIdRef.current === accountId) return;
loadedAccountIdRef.current = accountId;
fetchConfig(accountId);
}, [authLoading, profileLoading, user?.id, accountId, fetchConfig]);
async function handleSave() {
if (!phoneNumberId.trim()) {
toast.error('Phone Number ID is required');
return;
}
if (!config && (!accessToken.trim() || !tokenEdited)) {
toast.error('Access Token is required for initial setup');
return;
}
try {
setSaving(true);
// Always POST through the API — it verifies with Meta and encrypts
// the access_token server-side with ENCRYPTION_KEY. Skipping this
// and writing direct to Supabase stores the token in plaintext,
// which then fails decryption on every subsequent health check.
const payload: Record<string, unknown> = {
phone_number_id: phoneNumberId.trim(),
waba_id: wabaId.trim() || null,
verify_token: verifyToken.trim() || null,
// Optional — only sent when the user filled it in. The server
// requires it on first save or when changing numbers; for a
// simple token rotation, leaving it blank skips re-register.
pin: pin.trim() || null,
};
if (tokenEdited && accessToken !== MASKED_TOKEN && accessToken.trim()) {
payload.access_token = accessToken.trim();
} else if (config) {
// Existing config — reuse stored encrypted token by decrypting on the
// server. But our POST handler requires an access_token to verify
// with Meta. If the user didn't change the token, we need to signal
// that. Simplest: require token re-entry if they're updating.
toast.error('Please re-enter the Access Token to save changes');
setSaving(false);
return;
}
const res = await fetch('/api/whatsapp/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to save configuration');
setSaving(false);
return;
}
// The route now returns a structured outcome:
// * registered=true → number is live, events will flow
// * registered=false → credentials saved but /register
// failed; UI shows the specific error
// and a retry path. registration_error
// is human-readable from Meta.
if (data.registered === false && data.registration_error) {
toast.error(
`Saved, but Meta couldn't register the number: ${data.registration_error}`,
{ duration: 12000 },
);
} else if (data.registration_skipped) {
// Credentials saved + verified, but /register was skipped
// because no PIN was supplied (e.g. a Meta test number).
// Don't claim the number is "Live" — point at the
// Registration status banner instead.
toast.success(
'Credentials saved and verified. Inbound registration was skipped (no PIN) — see Registration status below.',
{ duration: 10000 },
);
setPin('');
} else {
toast.success(
data.phone_info?.verified_name
? `Live — ${data.phone_info.verified_name} can now receive events.`
: 'WhatsApp connected. Events will start flowing within a minute.',
);
// Clear the PIN so subsequent saves don't accidentally
// re-register (which would void the active subscription if
// the PIN became stale).
setPin('');
}
if (accountId) await fetchConfig(accountId);
} catch (err) {
console.error('Save error:', err);
toast.error('Failed to save configuration');
} finally {
setSaving(false);
}
}
async function handleTestConnection() {
try {
setTesting(true);
const res = await fetch('/api/whatsapp/config', { method: 'GET' });
const payload = await res.json();
if (payload.connected) {
setConnectionStatus('connected');
setResetReason(null);
setStatusMessage('');
toast.success(
payload.phone_info?.verified_name
? `Connected to ${payload.phone_info.verified_name}`
: 'API connection successful'
);
} else {
setConnectionStatus('disconnected');
setResetReason(payload.needs_reset ? 'token_corrupted' : payload.reason === 'meta_api_error' ? 'meta_api_error' : null);
setStatusMessage(payload.message || '');
toast.error(payload.message || 'API connection failed');
}
} catch (err) {
console.error('Test connection error:', err);
setConnectionStatus('disconnected');
toast.error('Connection test failed. Check network and try again.');
} finally {
setTesting(false);
}
}
async function handleVerifyRegistration() {
setVerifyingRegistration(true);
setRegistrationProbe(null);
try {
const res = await fetch('/api/whatsapp/config/verify-registration', {
method: 'GET',
});
const data = (await res.json()) as RegistrationProbe;
setRegistrationProbe(data);
if (data.live) {
toast.success('Number is fully wired — Meta is delivering events.');
} else {
toast.error(
'Number is not fully registered. See the checks below for which step failed.',
{ duration: 8000 },
);
}
if (accountId) await fetchConfig(accountId);
} catch (err) {
console.error('verify-registration failed:', err);
toast.error('Could not reach the verification endpoint.');
} finally {
setVerifyingRegistration(false);
}
}
async function handleReset() {
if (!confirm('This will delete the current WhatsApp config so you can re-enter it. Continue?')) {
return;
}
try {
setResetting(true);
const res = await fetch('/api/whatsapp/config', { method: 'DELETE' });
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to reset configuration');
return;
}
toast.success('Configuration cleared. You can now re-enter your credentials.');
setConfig(null);
setPhoneNumberId('');
setWabaId('');
setAccessToken('');
setVerifyToken('');
setTokenEdited(false);
setConnectionStatus('disconnected');
setResetReason(null);
setStatusMessage('');
} catch (err) {
console.error('Reset error:', err);
toast.error('Failed to reset configuration');
} finally {
setResetting(false);
}
}
function handleCopyWebhookUrl() {
navigator.clipboard.writeText(webhookUrl);
toast.success('Webhook URL copied to clipboard');
}
if (loading) {
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">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
</section>
);
}
const showResetBanner = resetReason === 'token_corrupted';
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="grid gap-6 lg:grid-cols-[1fr_380px]">
{/* Main config form */}
<div className="space-y-6">
{/* Corrupted-token reset banner */}
{showResetBanner && (
<Alert className="bg-amber-950/40 border-amber-600/40">
<div className="flex items-start gap-3">
<AlertTriangle className="size-5 text-amber-400 mt-0.5 shrink-0" />
<div className="flex-1">
<AlertTitle className="text-amber-200 mb-1">
Stored token can&apos;t be decrypted
</AlertTitle>
<AlertDescription className="text-amber-100/80 text-sm">
{statusMessage}
</AlertDescription>
<Button
onClick={handleReset}
disabled={resetting}
size="sm"
className="mt-3 bg-amber-600 hover:bg-amber-700 text-white"
>
{resetting ? (
<>
<Loader2 className="size-4 animate-spin" />
Resetting...
</>
) : (
<>
<RotateCcw className="size-4" />
Reset Configuration
</>
)}
</Button>
</div>
</div>
</Alert>
)}
{/* Connection Status */}
<Alert className="bg-card border-border">
<div className="flex items-center gap-2">
{connectionStatus === 'connected' ? (
<CheckCircle2 className="size-4 text-primary" />
) : (
<XCircle className="size-4 text-red-500" />
)}
<AlertTitle className="text-foreground mb-0">
{connectionStatus === 'connected' ? 'Credentials valid' : 'Not Connected'}
</AlertTitle>
</div>
<AlertDescription className="text-muted-foreground">
{connectionStatus === 'connected'
? 'Your access token authenticates with Meta. See Registration status below for whether webhooks are actually wired.'
: statusMessage ||
'Configure your Meta API credentials below to connect your WhatsApp Business account.'}
</AlertDescription>
</Alert>
{/* Registration Status — the "is it actually live?" check.
Credentials being valid is necessary but not sufficient;
without a successful /register call the number won't
receive inbound events. Surface this dimension separately
so users don't trust a misleading green banner. */}
{config && (
<Alert
className={
isRegistered
? 'bg-emerald-950/30 border-emerald-700/50'
: 'bg-amber-950/30 border-amber-700/50'
}
>
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
{isRegistered ? (
<CheckCircle2 className="size-4 text-emerald-400" />
) : (
<AlertTriangle className="size-4 text-amber-400" />
)}
<AlertTitle
className={
'mb-0 ' + (isRegistered ? 'text-emerald-200' : 'text-amber-200')
}
>
{isRegistered
? 'Registered — Meta will deliver events to wacrm'
: 'Not registered — Meta will not deliver events'}
</AlertTitle>
</div>
<Button
variant="outline"
size="sm"
onClick={handleVerifyRegistration}
disabled={verifyingRegistration}
className="border-border bg-transparent text-foreground hover:bg-muted h-7"
>
{verifyingRegistration ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Zap className="size-3.5" />
)}
Verify with Meta
</Button>
</div>
<AlertDescription className="text-muted-foreground mt-2 text-xs leading-relaxed">
{isRegistered ? (
<>
Subscribed since{' '}
{config.registered_at
? new Date(config.registered_at).toLocaleString()
: 'unknown'}
. Click <strong>Verify with Meta</strong> if events
stop arriving.
</>
) : lastRegistrationError ? (
<>
Last attempt failed with:{' '}
<span className="text-red-300">
&quot;{lastRegistrationError}&quot;
</span>
. Enter (or correct) the 2-step PIN below and click
Save Configuration to retry.
</>
) : (
<>
This number was saved before registration tracking
existed, or registration was skipped. Enter the
2-step PIN below and click Save Configuration to
subscribe it.
</>
)}
</AlertDescription>
{registrationProbe && (
<div className="mt-3 rounded border border-border bg-card/60 px-3 py-2 space-y-1.5 text-[11px]">
<p className="font-medium text-foreground">
Diagnostic — last run: {' '}
<span className={registrationProbe.live ? 'text-emerald-400' : 'text-amber-400'}>
{registrationProbe.live ? 'live' : 'not live'}
</span>
</p>
<ul className="space-y-0.5 text-muted-foreground">
{Object.entries(registrationProbe.checks).map(([k, v]) => (
<li key={k} className="flex items-center gap-1.5">
{v === true ? (
<CheckCircle2 className="size-3 text-emerald-400 shrink-0" />
) : v === false ? (
<XCircle className="size-3 text-red-400 shrink-0" />
) : (
<span className="size-3 rounded-full border border-border shrink-0" />
)}
<code className="text-muted-foreground">{k}</code>
</li>
))}
</ul>
{(registrationProbe.errors ?? []).length > 0 && (
<ul className="pt-1 space-y-0.5 text-red-300">
{registrationProbe.errors?.map((e, i) => (
<li key={i}>• {e}</li>
))}
</ul>
)}
</div>
)}
</Alert>
)}
{/* API Credentials */}
<Card>
<CardHeader>
<CardTitle className="text-foreground">API Credentials</CardTitle>
<CardDescription className="text-muted-foreground">
Enter your Meta WhatsApp Business API credentials.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-muted-foreground">Phone Number ID</Label>
<Input
placeholder="e.g. 100234567890123"
value={phoneNumberId}
onChange={(e) => setPhoneNumberId(e.target.value)}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">WhatsApp Business Account ID</Label>
<Input
placeholder="e.g. 100234567890456"
value={wabaId}
onChange={(e) => setWabaId(e.target.value)}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Permanent Access Token</Label>
<div className="relative">
<Input
type={showToken ? 'text' : 'password'}
placeholder="Enter your access token"
value={accessToken}
onChange={(e) => {
setAccessToken(e.target.value);
setTokenEdited(true);
}}
onFocus={() => {
if (accessToken === MASKED_TOKEN) {
setAccessToken('');
setTokenEdited(true);
}
}}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground pr-10"
/>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showToken ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
{config && !tokenEdited && (
<p className="text-xs text-muted-foreground">
Token is hidden for security. Re-enter it to update configuration.
</p>
)}
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Webhook Verify Token</Label>
<Input
placeholder="Create a custom verify token"
value={verifyToken}
onChange={(e) => setVerifyToken(e.target.value)}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<p className="text-xs text-muted-foreground">
A custom string you create. Must match the token you set in Meta webhook settings.
</p>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">
Two-step verification PIN
<span className="ml-1 text-muted-foreground">(optional)</span>
</Label>
<Input
type="text"
inputMode="numeric"
maxLength={6}
placeholder="6-digit PIN from Meta WhatsApp Manager"
value={pin}
onChange={(e) =>
setPin(e.target.value.replace(/\D/g, '').slice(0, 6))
}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground tracking-widest"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
Needed only to wire <strong className="text-muted-foreground">inbound</strong> messages
for a <strong className="text-muted-foreground">production</strong> number. Set it in{' '}
<strong className="text-muted-foreground">
Meta Business Manager → WhatsApp Accounts → Phone
Numbers → Two-step verification
</strong>
, then paste it here so wacrm can subscribe the number —
otherwise Meta routes inbound events to whichever app
last claimed it (the symptom that hits second numbers
under a shared WABA).{' '}
<strong className="text-muted-foreground">Meta test numbers</strong> have no
PIN and are pre-registered — leave this blank for them.
Leaving it blank also keeps an existing registration
untouched.
</p>
</div>
</CardContent>
</Card>
{/* Webhook URL */}
<Card>
<CardHeader>
<CardTitle className="text-foreground">Webhook Configuration</CardTitle>
<CardDescription className="text-muted-foreground">
Use this URL as your webhook callback in the Meta App Dashboard.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label className="text-muted-foreground">Webhook Callback URL</Label>
<div className="flex gap-2">
<Input
readOnly
value={webhookUrl}
className="bg-muted border-border text-muted-foreground font-mono text-sm"
/>
<Button
variant="outline"
size="icon"
onClick={handleCopyWebhookUrl}
className="shrink-0 border-border text-muted-foreground hover:text-foreground hover:bg-muted"
>
<Copy className="size-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex flex-wrap gap-3">
<Button
onClick={handleSave}
disabled={saving}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
'Save Configuration'
)}
</Button>
<Button
variant="outline"
onClick={handleTestConnection}
disabled={testing || !config}
className="border-border text-muted-foreground hover:text-foreground hover:bg-muted"
>
{testing ? (
<>
<Loader2 className="size-4 animate-spin" />
Testing...
</>
) : (
<>
<Zap className="size-4" />
Test API Connection
</>
)}
</Button>
{config && (
<Button
variant="outline"
onClick={handleReset}
disabled={resetting}
className="border-red-900 text-red-400 hover:text-red-300 hover:bg-red-950/40"
>
{resetting ? (
<>
<Loader2 className="size-4 animate-spin" />
Resetting...
</>
) : (
<>
<RotateCcw className="size-4" />
Reset Configuration
</>
)}
</Button>
)}
</div>
</div>
{/* Setup Instructions Sidebar */}
<div>
<Card>
<CardHeader>
<CardTitle className="text-foreground text-base">Setup Instructions</CardTitle>
<CardDescription className="text-muted-foreground">
Follow these steps to connect your WhatsApp Business API.
</CardDescription>
</CardHeader>
<CardContent>
<Accordion>
<AccordionItem className="border-border">
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
<span className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">1</span>
Create a Meta App
</span>
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to <span className="text-primary">developers.facebook.com</span></li>
<li>Click &quot;My Apps&quot; and then &quot;Create App&quot;</li>
<li>Select &quot;Business&quot; as the app type</li>
<li>Fill in app details and create</li>
</ol>
</AccordionContent>
</AccordionItem>
<AccordionItem className="border-border">
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
<span className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">2</span>
Add WhatsApp Product
</span>
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>In your app dashboard, click &quot;Add Product&quot;</li>
<li>Find &quot;WhatsApp&quot; and click &quot;Set Up&quot;</li>
<li>Follow the setup wizard to link your business</li>
</ol>
</AccordionContent>
</AccordionItem>
<AccordionItem className="border-border">
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
<span className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">3</span>
Get API Credentials
</span>
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to WhatsApp &gt; API Setup</li>
<li>Copy your <strong className="text-foreground">Phone Number ID</strong></li>
<li>Copy your <strong className="text-foreground">WhatsApp Business Account ID</strong></li>
<li>Generate a <strong className="text-foreground">Permanent Access Token</strong> from Business Settings &gt; System Users</li>
</ol>
</AccordionContent>
</AccordionItem>
<AccordionItem className="border-border">
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
<span className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">4</span>
Configure Webhooks
</span>
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to WhatsApp &gt; Configuration</li>
<li>Click &quot;Edit&quot; on the Webhook section</li>
<li>Paste the <strong className="text-foreground">Webhook Callback URL</strong> from above</li>
<li>Enter the same <strong className="text-foreground">Verify Token</strong> you set here</li>
<li>Subscribe to &quot;messages&quot; webhook field</li>
</ol>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-4 pt-4 border-t border-border">
<a
href="https://developers.facebook.com/docs/whatsapp/cloud-api/get-started"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-primary hover:text-primary/80 transition-colors"
>
<ExternalLink className="size-3.5" />
Meta WhatsApp API Documentation
</a>
</div>
</CardContent>
</Card>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useSyncExternalStore } from "react";
import { Toaster } from "sonner";
import { useTheme } from "@/hooks/use-theme";
import { DEFAULT_MODE } from "@/lib/themes";
// Returns false during SSR and the first hydration render, true after —
// the sanctioned (warning-free, no setState-in-effect) way to diverge
// server vs client. Lets us match the server-rendered default on first
// paint, then adopt the real mode.
const noopSubscribe = () => () => {};
function useIsClient() {
return useSyncExternalStore(
noopSubscribe,
() => true,
() => false,
);
}
/**
* Toaster wrapper that tracks the active light/dark mode.
*
* Lives inside <ThemeProvider> (see layout.tsx) so it can read the
* current mode and hand it to sonner. Colors are driven off the same
* CSS tokens as the rest of the app, so a toast looks at home in
* either mode without a second palette to maintain.
*
* The theme is gated behind `useIsClient`: the server renders
* DEFAULT_MODE, so first client paint must too, otherwise a light-mode
* user hydrates with a different sonner `theme` attribute than the
* server emitted and React logs a hydration mismatch.
*/
export function ThemedToaster() {
const { mode } = useTheme();
const isClient = useIsClient();
return (
<Toaster
theme={isClient ? mode : DEFAULT_MODE}
position="top-right"
toastOptions={{
style: {
background: "var(--popover)",
border: "1px solid var(--border)",
color: "var(--popover-foreground)",
},
}}
/>
);
}

View File

@@ -0,0 +1,889 @@
// ============================================================
// Tremor BarChart [v1.0.0] — vendored from tremorlabs/tremor.
//
// Two local adaptations vs the upstream file:
// 1. `cx` (Tremor's `clsx`+`twMerge` helper) is replaced with
// our own `cn` from `@/lib/utils`. Aliased locally so the
// diff against upstream stays scoped to this `import` line.
// 2. The legend slider's arrow icons (`RiArrowLeftSLine` /
// `RiArrowRightSLine` from `@remixicon/react`) are swapped
// for `ChevronLeft` / `ChevronRight` from `lucide-react`,
// which is already in the project's deps.
//
// Everything else — the chart logic, accessibility behaviour,
// tooltip / legend / slider implementation — is unchanged from
// the canonical Tremor Raw source.
//
// License: Apache 2.0 (Tremor).
// Source: https://github.com/tremorlabs/tremor/blob/main/src/components/BarChart/BarChart.tsx
// ============================================================
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client"
import React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import {
Bar,
CartesianGrid,
Label,
BarChart as RechartsBarChart,
Legend as RechartsLegend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import type { AxisDomain } from "recharts/types/util/types"
import { cn as cx } from "@/lib/utils"
import {
AvailableChartColors,
type AvailableChartColorsKeys,
constructCategoryColors,
getColorClassName,
} from "./chart-colors"
import { getYAxisDomain } from "./get-y-axis-domain"
import { useOnWindowResize } from "./use-on-window-resize"
//#region Shape
function deepEqual<T>(obj1: T, obj2: T): boolean {
if (obj1 === obj2) return true
if (
typeof obj1 !== "object" ||
typeof obj2 !== "object" ||
obj1 === null ||
obj2 === null
) {
return false
}
const keys1 = Object.keys(obj1) as Array<keyof T>
const keys2 = Object.keys(obj2) as Array<keyof T>
if (keys1.length !== keys2.length) return false
for (const key of keys1) {
if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) return false
}
return true
}
const renderShape = (
props: any,
activeBar: any | undefined,
activeLegend: string | undefined,
layout: string,
) => {
const { fillOpacity, name, payload, value } = props
let { x, width, y, height } = props
if (layout === "horizontal" && height < 0) {
y += height
height = Math.abs(height)
} else if (layout === "vertical" && width < 0) {
x += width
width = Math.abs(width)
}
return (
<rect
x={x}
y={y}
width={width}
height={height}
opacity={
activeBar || (activeLegend && activeLegend !== name)
? deepEqual(activeBar, { ...payload, value })
? fillOpacity
: 0.3
: fillOpacity
}
/>
)
}
//#region Legend
interface LegendItemProps {
name: string
color: AvailableChartColorsKeys
onClick?: (name: string, color: AvailableChartColorsKeys) => void
activeLegend?: string
}
const LegendItem = ({
name,
color,
onClick,
activeLegend,
}: LegendItemProps) => {
const hasOnValueChange = !!onClick
return (
<li
className={cx(
"group inline-flex flex-nowrap items-center gap-1.5 rounded-sm px-2 py-1 whitespace-nowrap transition",
hasOnValueChange
? "cursor-pointer hover:bg-muted"
: "cursor-default",
)}
onClick={(e) => {
e.stopPropagation()
onClick?.(name, color)
}}
>
<span
className={cx(
"size-2 shrink-0 rounded-xs",
getColorClassName(color, "bg"),
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
)}
aria-hidden={true}
/>
<p
className={cx(
"truncate text-xs whitespace-nowrap",
"text-muted-foreground",
hasOnValueChange &&
"group-hover:text-foreground",
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
)}
>
{name}
</p>
</li>
)
}
interface ScrollButtonProps {
icon: React.ElementType
onClick?: () => void
disabled?: boolean
}
const ScrollButton = ({ icon, onClick, disabled }: ScrollButtonProps) => {
const Icon = icon
const [isPressed, setIsPressed] = React.useState(false)
const intervalRef = React.useRef<NodeJS.Timeout | null>(null)
React.useEffect(() => {
if (isPressed) {
intervalRef.current = setInterval(() => {
onClick?.()
}, 300)
} else {
clearInterval(intervalRef.current as NodeJS.Timeout)
}
return () => clearInterval(intervalRef.current as NodeJS.Timeout)
}, [isPressed, onClick])
React.useEffect(() => {
if (disabled) {
clearInterval(intervalRef.current as NodeJS.Timeout)
setIsPressed(false)
}
}, [disabled])
return (
<button
type="button"
className={cx(
"group inline-flex size-5 items-center truncate rounded-sm transition",
disabled
? "cursor-not-allowed text-muted-foreground"
: "cursor-pointer text-muted-foreground hover:bg-muted hover:text-foreground",
)}
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onClick?.()
}}
onMouseDown={(e) => {
e.stopPropagation()
setIsPressed(true)
}}
onMouseUp={(e) => {
e.stopPropagation()
setIsPressed(false)
}}
>
<Icon className="size-full" aria-hidden="true" />
</button>
)
}
interface LegendProps extends React.OlHTMLAttributes<HTMLOListElement> {
categories: string[]
colors?: AvailableChartColorsKeys[]
onClickLegendItem?: (category: string, color: string) => void
activeLegend?: string
enableLegendSlider?: boolean
}
type HasScrollProps = {
left: boolean
right: boolean
}
const Legend = React.forwardRef<HTMLOListElement, LegendProps>((props, ref) => {
const {
categories,
colors = AvailableChartColors,
className,
onClickLegendItem,
activeLegend,
enableLegendSlider = false,
...other
} = props
const scrollableRef = React.useRef<HTMLInputElement>(null)
const scrollButtonsRef = React.useRef<HTMLDivElement>(null)
const [hasScroll, setHasScroll] = React.useState<HasScrollProps | null>(null)
const [isKeyDowned, setIsKeyDowned] = React.useState<string | null>(null)
const intervalRef = React.useRef<NodeJS.Timeout | null>(null)
const checkScroll = React.useCallback(() => {
const scrollable = scrollableRef?.current
if (!scrollable) return
const hasLeftScroll = scrollable.scrollLeft > 0
const hasRightScroll =
scrollable.scrollWidth - scrollable.clientWidth > scrollable.scrollLeft
setHasScroll({ left: hasLeftScroll, right: hasRightScroll })
}, [setHasScroll])
const scrollToTest = React.useCallback(
(direction: "left" | "right") => {
const element = scrollableRef?.current
const scrollButtons = scrollButtonsRef?.current
const scrollButtonsWith = scrollButtons?.clientWidth ?? 0
const width = element?.clientWidth ?? 0
if (element && enableLegendSlider) {
element.scrollTo({
left:
direction === "left"
? element.scrollLeft - width + scrollButtonsWith
: element.scrollLeft + width - scrollButtonsWith,
behavior: "smooth",
})
setTimeout(() => {
checkScroll()
}, 400)
}
},
[enableLegendSlider, checkScroll],
)
React.useEffect(() => {
const keyDownHandler = (key: string) => {
if (key === "ArrowLeft") {
scrollToTest("left")
} else if (key === "ArrowRight") {
scrollToTest("right")
}
}
if (isKeyDowned) {
keyDownHandler(isKeyDowned)
intervalRef.current = setInterval(() => {
keyDownHandler(isKeyDowned)
}, 300)
} else {
clearInterval(intervalRef.current as NodeJS.Timeout)
}
return () => clearInterval(intervalRef.current as NodeJS.Timeout)
}, [isKeyDowned, scrollToTest])
const keyDown = (e: KeyboardEvent) => {
e.stopPropagation()
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault()
setIsKeyDowned(e.key)
}
}
const keyUp = (e: KeyboardEvent) => {
e.stopPropagation()
setIsKeyDowned(null)
}
React.useEffect(() => {
const scrollable = scrollableRef?.current
if (enableLegendSlider) {
checkScroll()
scrollable?.addEventListener("keydown", keyDown)
scrollable?.addEventListener("keyup", keyUp)
}
return () => {
scrollable?.removeEventListener("keydown", keyDown)
scrollable?.removeEventListener("keyup", keyUp)
}
}, [checkScroll, enableLegendSlider])
return (
<ol
ref={ref}
className={cx("relative overflow-hidden", className)}
{...other}
>
<div
ref={scrollableRef}
tabIndex={0}
className={cx(
"flex h-full",
enableLegendSlider
? hasScroll?.right || hasScroll?.left
? "snap-mandatory items-center overflow-auto pr-12 pl-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
: ""
: "flex-wrap",
)}
>
{categories.map((category, index) => (
<LegendItem
key={`item-${index}`}
name={category}
color={colors[index] as AvailableChartColorsKeys}
onClick={onClickLegendItem}
activeLegend={activeLegend}
/>
))}
</div>
{enableLegendSlider && (hasScroll?.right || hasScroll?.left) ? (
<>
<div
ref={scrollButtonsRef}
className={cx(
"absolute top-0 right-0 bottom-0 flex h-full items-center justify-center pr-1",
"bg-background",
)}
>
<ScrollButton
icon={ChevronLeft}
onClick={() => {
setIsKeyDowned(null)
scrollToTest("left")
}}
disabled={!hasScroll?.left}
/>
<ScrollButton
icon={ChevronRight}
onClick={() => {
setIsKeyDowned(null)
scrollToTest("right")
}}
disabled={!hasScroll?.right}
/>
</div>
</>
) : null}
</ol>
)
})
Legend.displayName = "Legend"
const ChartLegend = (
{ payload }: any,
categoryColors: Map<string, AvailableChartColorsKeys>,
setLegendHeight: React.Dispatch<React.SetStateAction<number>>,
activeLegend: string | undefined,
onClick?: (category: string, color: string) => void,
enableLegendSlider?: boolean,
legendPosition?: "left" | "center" | "right",
yAxisWidth?: number,
) => {
const legendRef = React.useRef<HTMLDivElement>(null)
useOnWindowResize(() => {
const calculateHeight = (height: number | undefined) =>
height ? Number(height) + 15 : 60
setLegendHeight(calculateHeight(legendRef.current?.clientHeight))
})
const filteredPayload = payload.filter((item: any) => item.type !== "none")
const paddingLeft =
legendPosition === "left" && yAxisWidth ? yAxisWidth - 8 : 0
return (
<div
style={{ paddingLeft: paddingLeft }}
ref={legendRef}
className={cx(
"flex items-center",
{ "justify-center": legendPosition === "center" },
{
"justify-start": legendPosition === "left",
},
{ "justify-end": legendPosition === "right" },
)}
>
<Legend
categories={filteredPayload.map((entry: any) => entry.value)}
colors={filteredPayload.map((entry: any) =>
categoryColors.get(entry.value),
)}
onClickLegendItem={onClick}
activeLegend={activeLegend}
enableLegendSlider={enableLegendSlider}
/>
</div>
)
}
//#region Tooltip
type TooltipProps = Pick<ChartTooltipProps, "active" | "payload" | "label">
type PayloadItem = {
category: string
value: number
index: string
color: AvailableChartColorsKeys
type?: string
payload: any
}
interface ChartTooltipProps {
active: boolean | undefined
payload: PayloadItem[]
// Recharts 3.x widened this from `string` to `string | number | undefined`.
// Tremor's source (written against recharts ^2.13.3) still types it as
// `string`; we widen here so the vendored file typechecks against the
// current recharts release without forcing a downgrade.
label: string | number | undefined
valueFormatter: (value: number) => string
}
const ChartTooltip = ({
active,
payload,
label,
valueFormatter,
}: ChartTooltipProps) => {
if (active && payload && payload.length) {
return (
<div
className={cx(
"rounded-md border text-sm shadow-md",
"border-border",
"bg-popover",
)}
>
<div className={cx("border-b border-inherit px-4 py-2")}>
<p
className={cx("font-medium", "text-popover-foreground")}
>
{label}
</p>
</div>
<div className={cx("space-y-1 px-4 py-2")}>
{payload.map(({ value, category, color }, index) => (
<div
key={`id-${index}`}
className="flex items-center justify-between space-x-8"
>
<div className="flex items-center space-x-2">
<span
aria-hidden="true"
className={cx(
"size-2 shrink-0 rounded-xs",
getColorClassName(color, "bg"),
)}
/>
<p
className={cx(
"text-right whitespace-nowrap",
"text-muted-foreground",
)}
>
{category}
</p>
</div>
<p
className={cx(
"text-right font-medium whitespace-nowrap tabular-nums",
"text-popover-foreground",
)}
>
{valueFormatter(value)}
</p>
</div>
))}
</div>
</div>
)
}
return null
}
//#region BarChart
type BaseEventProps = {
eventType: "category" | "bar"
categoryClicked: string
[key: string]: number | string
}
type BarChartEventProps = BaseEventProps | null | undefined
interface BarChartProps extends React.HTMLAttributes<HTMLDivElement> {
data: Record<string, any>[]
index: string
categories: string[]
colors?: AvailableChartColorsKeys[]
valueFormatter?: (value: number) => string
startEndOnly?: boolean
showXAxis?: boolean
showYAxis?: boolean
showGridLines?: boolean
yAxisWidth?: number
intervalType?: "preserveStartEnd" | "equidistantPreserveStart"
showTooltip?: boolean
showLegend?: boolean
autoMinValue?: boolean
minValue?: number
maxValue?: number
allowDecimals?: boolean
onValueChange?: (value: BarChartEventProps) => void
enableLegendSlider?: boolean
tickGap?: number
barCategoryGap?: string | number
xAxisLabel?: string
yAxisLabel?: string
layout?: "vertical" | "horizontal"
type?: "default" | "stacked" | "percent"
legendPosition?: "left" | "center" | "right"
tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void
customTooltip?: React.ComponentType<TooltipProps>
}
const BarChart = React.forwardRef<HTMLDivElement, BarChartProps>(
(props, forwardedRef) => {
const {
data = [],
categories = [],
index,
colors = AvailableChartColors,
valueFormatter = (value: number) => value.toString(),
startEndOnly = false,
showXAxis = true,
showYAxis = true,
showGridLines = true,
yAxisWidth = 56,
intervalType = "equidistantPreserveStart",
showTooltip = true,
showLegend = true,
autoMinValue = false,
minValue,
maxValue,
allowDecimals = true,
className,
onValueChange,
enableLegendSlider = false,
barCategoryGap,
tickGap = 5,
xAxisLabel,
yAxisLabel,
layout = "horizontal",
type = "default",
legendPosition = "right",
tooltipCallback,
customTooltip,
...other
} = props
const CustomTooltip = customTooltip
const paddingValue =
(!showXAxis && !showYAxis) || (startEndOnly && !showYAxis) ? 0 : 20
const [legendHeight, setLegendHeight] = React.useState(60)
const [activeLegend, setActiveLegend] = React.useState<string | undefined>(
undefined,
)
const categoryColors = constructCategoryColors(categories, colors)
const [activeBar, setActiveBar] = React.useState<any | undefined>(undefined)
const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue)
const hasOnValueChange = !!onValueChange
const stacked = type === "stacked" || type === "percent"
const prevActiveRef = React.useRef<boolean | undefined>(undefined)
// Widened from `string | undefined` to match the recharts 3.x
// `label` type (see the ChartTooltipProps note below).
const prevLabelRef = React.useRef<string | number | undefined>(undefined)
function valueToPercent(value: number) {
return `${(value * 100).toFixed(0)}%`
}
function onBarClick(data: any, _: any, event: React.MouseEvent) {
event.stopPropagation()
if (!onValueChange) return
if (deepEqual(activeBar, { ...data.payload, value: data.value })) {
setActiveLegend(undefined)
setActiveBar(undefined)
onValueChange?.(null)
} else {
setActiveLegend(data.tooltipPayload?.[0]?.dataKey)
setActiveBar({
...data.payload,
value: data.value,
})
onValueChange?.({
eventType: "bar",
categoryClicked: data.tooltipPayload?.[0]?.dataKey,
...data.payload,
})
}
}
function onCategoryClick(dataKey: string) {
if (!hasOnValueChange) return
if (dataKey === activeLegend && !activeBar) {
setActiveLegend(undefined)
onValueChange?.(null)
} else {
setActiveLegend(dataKey)
onValueChange?.({
eventType: "category",
categoryClicked: dataKey,
})
}
setActiveBar(undefined)
}
return (
<div
ref={forwardedRef}
className={cx("h-80 w-full", className)}
tremor-id="tremor-raw"
{...other}
>
<ResponsiveContainer>
<RechartsBarChart
data={data}
onClick={
hasOnValueChange && (activeLegend || activeBar)
? () => {
setActiveBar(undefined)
setActiveLegend(undefined)
onValueChange?.(null)
}
: undefined
}
margin={{
bottom: xAxisLabel ? 30 : undefined,
left: yAxisLabel ? 20 : undefined,
right: yAxisLabel ? 5 : undefined,
top: 5,
}}
stackOffset={type === "percent" ? "expand" : undefined}
layout={layout}
barCategoryGap={barCategoryGap}
>
{showGridLines ? (
<CartesianGrid
className={cx("stroke-border stroke-1")}
horizontal={layout !== "vertical"}
vertical={layout === "vertical"}
/>
) : null}
<XAxis
hide={!showXAxis}
tick={{
transform:
layout !== "vertical" ? "translate(0, 6)" : undefined,
}}
fill=""
stroke=""
className={cx(
"text-xs",
"fill-muted-foreground",
{ "mt-4": layout !== "vertical" },
)}
tickLine={false}
axisLine={false}
minTickGap={tickGap}
{...(layout !== "vertical"
? {
padding: {
left: paddingValue,
right: paddingValue,
},
dataKey: index,
interval: startEndOnly ? "preserveStartEnd" : intervalType,
ticks: startEndOnly
? [data[0][index], data[data.length - 1][index]]
: undefined,
}
: {
type: "number",
domain: yAxisDomain as AxisDomain,
tickFormatter:
type === "percent" ? valueToPercent : valueFormatter,
allowDecimals: allowDecimals,
})}
>
{xAxisLabel && (
<Label
position="insideBottom"
offset={-20}
className="fill-foreground text-sm font-medium"
>
{xAxisLabel}
</Label>
)}
</XAxis>
<YAxis
width={yAxisWidth}
hide={!showYAxis}
axisLine={false}
tickLine={false}
fill=""
stroke=""
className={cx(
"text-xs",
"fill-muted-foreground",
)}
tick={{
transform:
layout !== "vertical"
? "translate(-3, 0)"
: "translate(0, 0)",
}}
{...(layout !== "vertical"
? {
type: "number",
domain: yAxisDomain as AxisDomain,
tickFormatter:
type === "percent" ? valueToPercent : valueFormatter,
allowDecimals: allowDecimals,
}
: {
dataKey: index,
ticks: startEndOnly
? [data[0][index], data[data.length - 1][index]]
: undefined,
type: "category",
interval: "equidistantPreserveStart",
})}
>
{yAxisLabel && (
<Label
position="insideLeft"
style={{ textAnchor: "middle" }}
angle={-90}
offset={-15}
className="fill-foreground text-sm font-medium"
>
{yAxisLabel}
</Label>
)}
</YAxis>
<Tooltip
wrapperStyle={{ outline: "none" }}
isAnimationActive={true}
animationDuration={100}
cursor={{ fill: "#d1d5db", opacity: "0.15" }}
offset={20}
position={{
y: layout === "horizontal" ? 0 : undefined,
x: layout === "horizontal" ? undefined : yAxisWidth + 20,
}}
content={({ active, payload, label }) => {
const cleanPayload: TooltipProps["payload"] = payload
? payload.map((item: any) => ({
category: item.dataKey,
value: item.value,
index: item.payload[index],
color: categoryColors.get(
item.dataKey,
) as AvailableChartColorsKeys,
type: item.type,
payload: item.payload,
}))
: []
if (
tooltipCallback &&
(active !== prevActiveRef.current ||
label !== prevLabelRef.current)
) {
tooltipCallback({ active, payload: cleanPayload, label })
prevActiveRef.current = active
prevLabelRef.current = label
}
return showTooltip && active ? (
CustomTooltip ? (
<CustomTooltip
active={active}
payload={cleanPayload}
label={label}
/>
) : (
<ChartTooltip
active={active}
payload={cleanPayload}
label={label}
valueFormatter={valueFormatter}
/>
)
) : null
}}
/>
{showLegend ? (
<RechartsLegend
verticalAlign="top"
height={legendHeight}
content={({ payload }) =>
ChartLegend(
{ payload },
categoryColors,
setLegendHeight,
activeLegend,
hasOnValueChange
? (clickedLegendItem: string) =>
onCategoryClick(clickedLegendItem)
: undefined,
enableLegendSlider,
legendPosition,
yAxisWidth,
)
}
/>
) : null}
{categories.map((category) => (
<Bar
className={cx(
getColorClassName(
categoryColors.get(category) as AvailableChartColorsKeys,
"fill",
),
onValueChange ? "cursor-pointer" : "",
)}
key={category}
name={category}
type="linear"
dataKey={category}
stackId={stacked ? "stack" : undefined}
isAnimationActive={false}
fill=""
shape={(props: any) =>
renderShape(props, activeBar, activeLegend, layout)
}
onClick={onBarClick}
/>
))}
</RechartsBarChart>
</ResponsiveContainer>
</div>
)
},
)
BarChart.displayName = "BarChart"
export { BarChart, type BarChartEventProps, type BarChartProps, type TooltipProps }

View File

@@ -0,0 +1,105 @@
// ============================================================
// Tremor chartColors [v0.1.0] — copied from tremorlabs/tremor.
//
// The Tremor charts source uses these helpers to map a category
// name to a stable Tailwind color class (`bg-violet-500`,
// `fill-blue-500`, …). Tremor's "Raw" distribution is copy-paste
// — there is no `@tremor/raw` npm package — so the canonical
// approach is to vendor the file unchanged and customise locally.
//
// Source: https://github.com/tremorlabs/tremor/blob/main/src/utils/chartColors.ts
// License: Apache 2.0 (Tremor)
// ============================================================
export type ColorUtility = "bg" | "stroke" | "fill" | "text"
export const chartColors = {
blue: {
bg: "bg-blue-500",
stroke: "stroke-blue-500",
fill: "fill-blue-500",
text: "text-blue-500",
},
emerald: {
bg: "bg-emerald-500",
stroke: "stroke-emerald-500",
fill: "fill-emerald-500",
text: "text-emerald-500",
},
violet: {
bg: "bg-violet-500",
stroke: "stroke-violet-500",
fill: "fill-violet-500",
text: "text-violet-500",
},
amber: {
bg: "bg-amber-500",
stroke: "stroke-amber-500",
fill: "fill-amber-500",
text: "text-amber-500",
},
gray: {
bg: "bg-muted",
stroke: "stroke-muted-foreground",
fill: "fill-muted-foreground",
text: "text-muted-foreground",
},
cyan: {
bg: "bg-cyan-500",
stroke: "stroke-cyan-500",
fill: "fill-cyan-500",
text: "text-cyan-500",
},
pink: {
bg: "bg-pink-500",
stroke: "stroke-pink-500",
fill: "fill-pink-500",
text: "text-pink-500",
},
lime: {
bg: "bg-lime-500",
stroke: "stroke-lime-500",
fill: "fill-lime-500",
text: "text-lime-500",
},
fuchsia: {
bg: "bg-fuchsia-500",
stroke: "stroke-fuchsia-500",
fill: "fill-fuchsia-500",
text: "text-fuchsia-500",
},
} as const satisfies {
[color: string]: {
[key in ColorUtility]: string
}
}
export type AvailableChartColorsKeys = keyof typeof chartColors
export const AvailableChartColors: AvailableChartColorsKeys[] = Object.keys(
chartColors,
) as Array<AvailableChartColorsKeys>
export const constructCategoryColors = (
categories: string[],
colors: AvailableChartColorsKeys[],
): Map<string, AvailableChartColorsKeys> => {
const categoryColors = new Map<string, AvailableChartColorsKeys>()
categories.forEach((category, index) => {
categoryColors.set(category, colors[index % colors.length])
})
return categoryColors
}
export const getColorClassName = (
color: AvailableChartColorsKeys,
type: ColorUtility,
): string => {
const fallbackColor = {
bg: "bg-muted",
stroke: "stroke-muted-foreground",
fill: "fill-muted-foreground",
text: "text-muted-foreground",
}
return chartColors[color]?.[type] ?? fallbackColor[type]
}

View File

@@ -0,0 +1,13 @@
// Tremor getYAxisDomain — copied from tremorlabs/tremor.
// License: Apache 2.0 (Tremor).
// Source: https://github.com/tremorlabs/tremor/blob/main/src/utils/getYAxisDomain.ts
export const getYAxisDomain = (
autoMinValue: boolean,
minValue: number | undefined,
maxValue: number | undefined,
) => {
const minDomain = autoMinValue ? "auto" : (minValue ?? 0)
const maxDomain = maxValue ?? "auto"
return [minDomain, maxDomain]
}

View File

@@ -0,0 +1,17 @@
// Tremor useOnWindowResize hook — copied from tremorlabs/tremor.
// License: Apache 2.0 (Tremor).
// Source: https://github.com/tremorlabs/tremor/blob/main/src/hooks/useOnWindowResize.ts
import * as React from "react"
export const useOnWindowResize = (handler: () => void) => {
React.useEffect(() => {
const handleResize = () => {
handler()
}
handleResize()
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [handler])
}

View File

@@ -0,0 +1,72 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,42 @@
"use client"
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { Check, Minus } from "lucide-react"
import { cn } from "@/lib/utils"
// Root: primary token when checked or indeterminate (responds to the active
// color theme), input border when unchecked. Mirrors switch.tsx conventions.
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 cursor-pointer rounded-[4px] border border-input bg-card shadow-sm transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[checked]:border-primary data-[checked]:bg-primary data-[checked]:text-primary-foreground",
"data-[indeterminate]:border-primary data-[indeterminate]:bg-primary data-[indeterminate]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current"
>
{props.indeterminate ? (
<Minus className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-background/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,101 @@
"use client";
// ============================================================
// GatedButton — Button + role-gated "Read-only" tooltip helper.
//
// The wider problem this solves:
//
// A bare `<Button disabled title="Read-only — ...">` doesn't
// render a tooltip in Safari or older Firefox because those
// browsers don't fire mouse events on disabled form controls.
// Title attributes only render when the element receives a
// mouseover. The 9-PR multi-user series relied on this pattern
// for every "read-only for viewer" CTA across the app, which
// meant viewers on those browsers saw a silently-disabled
// button with no explanation.
//
// Wrapping the disabled button in a `<span title=...>` makes
// the tooltip target a non-disabled ancestor that does receive
// mouseover, so the tooltip renders everywhere. The span also
// serves as a single mounting point for `aria-label` /
// `aria-disabled` if a screen reader needs richer signalling
// later.
//
// The minor problem it also solves:
//
// Five list pages had near-identical
// `READ_ONLY_TITLE = "Read-only — your role can't ..."`
// constants. GatedButton takes a single `gateReason` prop
// and centralises the tooltip wording (with per-action
// defaults).
//
// Use it like:
//
// <GatedButton
// canAct={canCreate}
// gateReason="create broadcasts"
// onClick={() => router.push("/broadcasts/new")}
// >
// <Plus className="h-4 w-4" /> New Broadcast
// </GatedButton>
//
// `canAct` defaults to true so unrelated usages still work.
// When `canAct` is false, the button is `disabled` and the
// wrapping span gets a `title` of `"Read-only — your role
// can't ${gateReason}"`.
// ============================================================
import type { ComponentProps, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface GatedButtonProps extends Omit<ComponentProps<typeof Button>, "title"> {
/** False → button is disabled and the wrapper span shows the
* "Read-only" tooltip. Defaults to `true` so a `<GatedButton>`
* without the prop is just a Button. */
canAct?: boolean;
/** Verb phrase that completes the sentence
* `"Read-only — your role can't <gateReason>"`. Provided
* per-call so each CTA can name what it does ("create flows",
* "send messages", "add contacts"). */
gateReason?: string;
/** Optional fallback title for the non-gated case. */
title?: string;
children?: ReactNode;
}
export function GatedButton({
canAct = true,
gateReason,
title,
disabled,
className,
children,
...rest
}: GatedButtonProps) {
const effectivelyDisabled = disabled || !canAct;
const tooltip = !canAct && gateReason
? `Read-only — your role can't ${gateReason}`
: title;
return (
<span
// `inline-flex` so the span sizes to the button and doesn't
// collapse to zero width / break inline layouts. `title`
// here (not on the button) is what makes the tooltip work
// in Safari / older Firefox — those browsers don't fire
// mouseover on disabled buttons.
className={cn("inline-flex", !canAct && "cursor-not-allowed")}
title={tooltip}
>
<Button
disabled={effectivelyDisabled}
className={className}
{...rest}
>
{children}
</Button>
</span>
);
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-heading font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,38 @@
"use client"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-background/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,37 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
// Root: primary token when checked (responds to the active color theme),
// slate when unchecked.
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[checked]:bg-primary data-[unchecked]:bg-muted",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-card shadow-sm ring-0 transition-transform",
"data-[checked]:translate-x-4 data-[unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }