refactor: ui improvements
This commit is contained in:
parent
e45dd79e8a
commit
abf5029579
6 changed files with 1213 additions and 445 deletions
383
src/components/fields-dialog.tsx
Normal file
383
src/components/fields-dialog.tsx
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2, Check, X } from "lucide-react";
|
||||
import { customFieldSchemasQuery } from "#/lib/query";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "#/components/ui/dialog";
|
||||
|
||||
interface FieldSchema {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
fieldType: "text" | "number" | "bool" | "date";
|
||||
required: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
text: "Text",
|
||||
number: "Number",
|
||||
bool: "Boolean",
|
||||
date: "Date",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
appId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: schemasData, isPending } = useQuery<FieldSchema[]>(
|
||||
customFieldSchemasQuery(appId),
|
||||
);
|
||||
const schemas = schemasData ?? [];
|
||||
|
||||
// create
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
const [newType, setNewType] = useState<"text" | "number" | "bool" | "date">("text");
|
||||
const [newRequired, setNewRequired] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
||||
// inline edit
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [editLabel, setEditLabel] = useState("");
|
||||
const [editRequired, setEditRequired] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// delete
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
function openEdit(s: FieldSchema) {
|
||||
setEditId(s.id);
|
||||
setEditLabel(s.label);
|
||||
setEditRequired(s.required);
|
||||
setDeleteId(null);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditId(null);
|
||||
}
|
||||
|
||||
async function createSchema(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreateError("");
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/admin/custom-field-schemas?app_id=${encodeURIComponent(appId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
key: newKey.trim(),
|
||||
label: newLabel.trim(),
|
||||
field_type: newType,
|
||||
required: newRequired,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.error ?? "Failed");
|
||||
}
|
||||
setShowCreate(false);
|
||||
setNewKey("");
|
||||
setNewLabel("");
|
||||
setNewType("text");
|
||||
setNewRequired(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["custom-field-schemas", appId] });
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch(`/api/v1/admin/custom-field-schemas/${editId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ app_id: appId, label: editLabel, required: editRequired }),
|
||||
});
|
||||
setEditId(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["custom-field-schemas", appId] });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSchema() {
|
||||
if (!deleteId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await fetch(
|
||||
`/api/v1/admin/custom-field-schemas/${deleteId}?app_id=${encodeURIComponent(appId)}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["custom-field-schemas", appId] });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setShowCreate(false);
|
||||
setEditId(null);
|
||||
setDeleteId(null);
|
||||
}
|
||||
onOpenChange(o);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[15px]">Custom field schemas</DialogTitle>
|
||||
<DialogDescription className="text-[13px]">
|
||||
Define custom fields stored on contacts in this app.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 max-h-[420px] overflow-y-auto pr-1">
|
||||
{Boolean(isPending) && (
|
||||
<p className="text-[13px] text-gray-400 py-2">Loading…</p>
|
||||
)}
|
||||
{!(isPending as boolean) && schemas.length === 0 && !showCreate && (
|
||||
<p className="text-[13px] text-gray-400 py-2">No custom fields defined.</p>
|
||||
)}
|
||||
|
||||
{schemas.map((s) => (
|
||||
<div key={s.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
{/* row */}
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<code className="text-[11px] font-mono text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded shrink-0">
|
||||
{s.key}
|
||||
</code>
|
||||
<span className="text-[13px] font-medium text-gray-900 truncate">
|
||||
{s.label}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{TYPE_LABELS[s.fieldType]}
|
||||
</Badge>
|
||||
{s.required && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{editId !== s.id && deleteId !== s.id && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => openEdit(s)}
|
||||
className="text-[12px] text-gray-500 hover:text-gray-900 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDeleteId(s.id); setEditId(null); }}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={1.75} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* inline edit */}
|
||||
{editId === s.id && (
|
||||
<div className="border-t border-gray-100 bg-gray-50 px-3 py-3 space-y-2.5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[12px] text-gray-600">Label</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
value={editLabel}
|
||||
onChange={(e) => setEditLabel(e.target.value)}
|
||||
className="h-8 text-[13px]"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editRequired}
|
||||
onChange={(e) => setEditRequired(e.target.checked)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="text-[12px] text-gray-700">Required field</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveEdit}
|
||||
disabled={saving}
|
||||
className="h-7 px-3 text-[12px] bg-zinc-900 hover:bg-zinc-800 text-white"
|
||||
>
|
||||
<Check size={11} strokeWidth={2.5} className="mr-1" />
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={cancelEdit}
|
||||
className="h-7 px-3 text-[12px]"
|
||||
>
|
||||
<X size={11} strokeWidth={2} className="mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* inline delete confirm */}
|
||||
{deleteId === s.id && (
|
||||
<div className="border-t border-red-100 bg-red-50 px-3 py-2.5 flex items-center justify-between">
|
||||
<p className="text-[12px] text-red-700">
|
||||
Delete <strong>{s.label}</strong>? Existing data is not removed.
|
||||
</p>
|
||||
<div className="flex gap-2 shrink-0 ml-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDeleteId(null)}
|
||||
className="h-7 px-3 text-[12px]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={deleteSchema}
|
||||
className="h-7 px-3 text-[12px]"
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* inline create form */}
|
||||
{showCreate && (
|
||||
<div className="border border-gray-200 rounded-lg px-3 py-3 space-y-2.5 bg-gray-50">
|
||||
<p className="text-[12px] font-semibold text-gray-700">New field</p>
|
||||
<form onSubmit={createSchema} className="space-y-2.5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[12px] text-gray-600">Key</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="e.g. tier"
|
||||
value={newKey}
|
||||
onChange={(e) =>
|
||||
setNewKey(e.target.value.replace(/\s+/g, "_").toLowerCase())
|
||||
}
|
||||
className="h-8 text-[13px]"
|
||||
required
|
||||
/>
|
||||
<p className="text-[11px] text-gray-400">Immutable after creation</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[12px] text-gray-600">Label</Label>
|
||||
<Input
|
||||
placeholder="e.g. Customer Tier"
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
className="h-8 text-[13px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[12px] text-gray-600">Type</Label>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) =>
|
||||
setNewType(e.target.value as "text" | "number" | "bool" | "date")
|
||||
}
|
||||
className="w-full h-8 text-[13px] border border-input rounded-md px-3 bg-background"
|
||||
>
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="bool">Boolean</option>
|
||||
<option value="date">Date</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newRequired}
|
||||
onChange={(e) => setNewRequired(e.target.checked)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="text-[12px] text-gray-700">Required field</span>
|
||||
</label>
|
||||
{createError && (
|
||||
<p className="text-[12px] text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5">
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={creating}
|
||||
className="h-7 px-3 text-[12px] bg-zinc-900 hover:bg-zinc-800 text-white"
|
||||
>
|
||||
{creating ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => { setShowCreate(false); setCreateError(""); }}
|
||||
className="h-7 px-3 text-[12px]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!showCreate && (
|
||||
<button
|
||||
onClick={() => { setShowCreate(true); setEditId(null); setDeleteId(null); }}
|
||||
className="flex items-center gap-1.5 text-[13px] text-gray-500 hover:text-gray-900 transition-colors mt-1"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2} />
|
||||
New field
|
||||
</button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import {
|
|||
Plus,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
|
|
@ -47,7 +46,15 @@ interface ContactRow {
|
|||
pushName: string | null;
|
||||
}
|
||||
|
||||
function GroupRow({ appId, group }: { appId: string; group: Group }) {
|
||||
function GroupRow({
|
||||
appId,
|
||||
group,
|
||||
onDelete,
|
||||
}: {
|
||||
appId: string;
|
||||
group: Group;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
|
@ -93,71 +100,145 @@ function GroupRow({ appId, group }: { appId: string; group: Group }) {
|
|||
}
|
||||
}
|
||||
|
||||
const initials = group.name.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white rounded-xl border border-gray-200 overflow-hidden transition-shadow duration-200",
|
||||
expanded && "shadow-sm",
|
||||
)}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||
{/* Avatar */}
|
||||
<div className="w-9 h-9 rounded-lg bg-zinc-900 flex items-center justify-center shrink-0">
|
||||
<span className="text-[11px] font-bold text-white tracking-wide">
|
||||
{initials}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expand trigger */}
|
||||
<button
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors text-left"
|
||||
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{group.name}</p>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] font-semibold text-gray-900 truncate leading-snug">
|
||||
{group.name}
|
||||
</p>
|
||||
{group.description && (
|
||||
<p className="text-xs text-muted-foreground">{group.description}</p>
|
||||
<p className="text-[12px] text-gray-400 truncate mt-0.5">
|
||||
{group.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{members.length} member{members.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Member pill */}
|
||||
<span className="text-[11px] font-medium text-gray-500 bg-gray-100 rounded-full px-2.5 py-1 shrink-0 tabular-nums">
|
||||
{members.length} {members.length === 1 ? "member" : "members"}
|
||||
</span>
|
||||
|
||||
{/* Chevron */}
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-gray-400 hover:text-gray-700 transition-colors shrink-0"
|
||||
aria-label={expanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
className={cn(
|
||||
"transition-transform duration-200",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-gray-300 hover:text-red-500 hover:bg-red-50 shrink-0"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expanded panel */}
|
||||
{expanded && (
|
||||
<div className="border-t px-4 py-3">
|
||||
<div className="border-t border-gray-100 bg-gray-50/60">
|
||||
{members.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No members.</p>
|
||||
<div className="flex flex-col items-center py-8 gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center">
|
||||
<Users size={16} className="text-gray-400" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-[13px] text-gray-400">
|
||||
No contacts in this group yet
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="h-8 px-4 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
|
||||
>
|
||||
<UserPlus size={13} strokeWidth={2} />
|
||||
Add contacts
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 mb-3">
|
||||
{members.map((m) => (
|
||||
<div className="px-4 py-3">
|
||||
<div className="divide-y divide-gray-100">
|
||||
{members.map((m) => {
|
||||
const displayName = m.name ?? m.pushName ?? m.phone;
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between py-1"
|
||||
className="flex items-center gap-3 py-2.5 group"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm">{m.name ?? m.pushName ?? m.phone}</span>
|
||||
<div className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center shrink-0">
|
||||
<span className="text-[10px] font-semibold text-gray-600">
|
||||
{initial}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex items-baseline gap-2">
|
||||
<span className="text-[13px] font-medium text-gray-900 truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
{m.name && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
<span className="text-[11px] text-gray-400 font-mono shrink-0">
|
||||
{m.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
||||
size="icon-sm"
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 hover:bg-red-50 transition-all"
|
||||
onClick={() => removeMember(m.id)}
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5" />
|
||||
<UserMinus />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-3 pb-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="h-8 px-3 text-[13px] gap-1.5 border-dashed text-gray-500"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
<UserPlus size={13} strokeWidth={2} />
|
||||
Add contacts
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add contacts dialog */}
|
||||
|
|
@ -185,17 +266,21 @@ function GroupRow({ appId, group }: { appId: string; group: Group }) {
|
|||
<button
|
||||
key={c.id}
|
||||
disabled={adding}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-left transition-colors"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-left transition-colors disabled:opacity-50"
|
||||
onClick={() => addMember(c.id)}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||||
<span className="text-[10px] font-medium uppercase">
|
||||
<div className="w-7 h-7 rounded-full bg-zinc-900 flex items-center justify-center shrink-0">
|
||||
<span className="text-[10px] font-bold text-white uppercase">
|
||||
{(c.name ?? c.pushName ?? c.phone).charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm">{c.name ?? c.pushName ?? c.phone}</p>
|
||||
<p className="text-xs text-muted-foreground">{c.phone}</p>
|
||||
<p className="text-[13px] font-medium text-gray-900">
|
||||
{c.name ?? c.pushName ?? c.phone}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-400 font-mono">
|
||||
{c.phone}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
|
|
@ -268,100 +353,175 @@ function GroupsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link to="/apps/$appId/contacts/" params={{ appId }}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" /> Contacts
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Users className="h-5 w-5" /> Groups
|
||||
</h1>
|
||||
<div className="ml-auto">
|
||||
<Button size="sm" onClick={() => setNewOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> New group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(isPending as boolean) && (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
)}
|
||||
{!(isPending as boolean) && groups.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No groups yet.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{groups.map((g) => (
|
||||
<div key={g.id} className="relative">
|
||||
<GroupRow appId={appId} group={g} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-7 w-7 text-muted-foreground hover:text-red-500"
|
||||
onClick={() => setDeleteId(g.id)}
|
||||
<div className="min-h-full">
|
||||
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/apps/$appId/contacts/"
|
||||
params={{ appId }}
|
||||
className="flex items-center gap-1 text-[13px] text-gray-500 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<ArrowLeft size={14} strokeWidth={1.75} />
|
||||
Contacts
|
||||
</Link>
|
||||
<span className="text-gray-300">/</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={15} className="text-gray-400" strokeWidth={1.75} />
|
||||
<h1 className="text-[14px] font-semibold text-gray-900">Groups</h1>
|
||||
{groups.length > 0 && (
|
||||
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
|
||||
{groups.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.5} />
|
||||
New group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{(isPending as boolean) && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-5 h-5 border-2 border-gray-200 border-t-gray-600 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!(isPending as boolean) && groups.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center">
|
||||
<Users size={22} className="text-gray-400" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[14px] font-semibold text-gray-900 mb-1">
|
||||
No groups yet
|
||||
</p>
|
||||
<p className="text-[13px] text-gray-500">
|
||||
Create a group to segment your contacts
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="h-8 px-4 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.5} />
|
||||
New group
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{groups.map((g) => (
|
||||
<GroupRow
|
||||
key={g.id}
|
||||
appId={appId}
|
||||
group={g}
|
||||
onDelete={() => setDeleteId(g.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={newOpen} onOpenChange={setNewOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New group</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogTitle className="text-[15px]">New group</DialogTitle>
|
||||
<DialogDescription className="text-[13px]">
|
||||
Create a contact group to segment your audience.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={createGroup} className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Name</Label>
|
||||
<form onSubmit={createGroup} className="space-y-3 pt-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[13px] text-zinc-700">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="e.g. VIP Customers"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Description (optional)</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[13px] text-zinc-700">
|
||||
Description{" "}
|
||||
<span className="text-gray-400 font-normal">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="What this group represents"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{createError && (
|
||||
<p className="text-sm text-red-500">{createError}</p>
|
||||
<p className="text-[13px] text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={creating}>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setNewOpen(false)}
|
||||
className="h-8 px-3 text-[13px]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={creating || !newName.trim()}
|
||||
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white"
|
||||
>
|
||||
{creating ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirm */}
|
||||
<Dialog open={!!deleteId} onOpenChange={(o) => { if (!o) setDeleteId(null); }}>
|
||||
<DialogContent>
|
||||
<Dialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeleteId(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[360px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete group</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogTitle className="text-[15px] text-red-600">
|
||||
Delete group?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[13px]">
|
||||
Removes the group. Contacts are not deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteId(null)}
|
||||
className="h-8 px-3 text-[13px]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
onClick={deleteGroup}
|
||||
className="h-8 px-3 text-[13px]"
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -24,6 +24,7 @@ import {
|
|||
DialogDescription,
|
||||
} from "#/components/ui/dialog";
|
||||
import { cn } from "#/lib/utils";
|
||||
import { FieldsDialog } from "#/components/fields-dialog";
|
||||
|
||||
export const Route = createFileRoute("/apps/$appId/contacts/")({
|
||||
component: ContactsPage,
|
||||
|
|
@ -74,6 +75,7 @@ function ContactsPage() {
|
|||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [fieldsOpen, setFieldsOpen] = useState(false);
|
||||
|
||||
const { data, refetch, isPending } = useQuery<{
|
||||
contacts: DecryptedContact[];
|
||||
|
|
@ -193,16 +195,15 @@ function ContactsPage() {
|
|||
Groups
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/apps/$appId/contacts/settings/" params={{ appId }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-[13px] gap-1.5"
|
||||
onClick={() => setFieldsOpen(true)}
|
||||
>
|
||||
<Settings size={12} strokeWidth={2} />
|
||||
Fields
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/apps/$appId/contacts/import" params={{ appId }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -566,6 +567,8 @@ function ContactsPage() {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<FieldsDialog appId={appId} open={fieldsOpen} onOpenChange={setFieldsOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,11 +146,17 @@ function WebhooksPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="min-h-full">
|
||||
{/* Page header */}
|
||||
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Webhook className="h-5 w-5" />
|
||||
<h1 className="text-xl font-semibold">Webhooks</h1>
|
||||
<Webhook size={15} className="text-gray-400" strokeWidth={1.75} />
|
||||
<h1 className="text-[14px] font-semibold text-gray-900">Webhooks</h1>
|
||||
{webhooks.length > 0 && (
|
||||
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
|
||||
{webhooks.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -158,11 +164,13 @@ function WebhooksPage() {
|
|||
setNewOpen(true);
|
||||
setCreatedSecret(null);
|
||||
}}
|
||||
className="h-8 px-3 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add endpoint
|
||||
<Plus size={13} strokeWidth={2.5} /> Add endpoint
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 max-w-4xl animate-fade-up">
|
||||
{(isPending as boolean) && (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
)}
|
||||
|
|
@ -362,5 +370,6 @@ function WebhooksPage() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ describe("retry delay schedule (FR-W4)", () => {
|
|||
|
||||
it("delays are strictly increasing", () => {
|
||||
for (let i = 1; i < RETRY_DELAYS.length; i++) {
|
||||
expect(RETRY_DELAYS[i]).toBeGreaterThan(RETRY_DELAYS[i - 1]!);
|
||||
expect(RETRY_DELAYS[i]).toBeGreaterThan(RETRY_DELAYS[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -389,7 +389,7 @@ function pruneDeliveries(
|
|||
|
||||
if (forEndpoint.length < retain) return deliveries;
|
||||
|
||||
const cutoff = forEndpoint[retain - 1]!.createdAt;
|
||||
const cutoff = forEndpoint[retain - 1].createdAt;
|
||||
return deliveries.filter(
|
||||
(d) => d.endpointId !== endpointId || d.createdAt >= cutoff,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue