refactor: ui improvements

This commit is contained in:
Edqe14 2026-05-16 14:59:43 +07:00
parent e45dd79e8a
commit abf5029579
Signed by: yukabi
GPG key ID: A1AAC4DC1157D70D
6 changed files with 1213 additions and 445 deletions

View 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>
);
}

View file

@ -7,7 +7,6 @@ import {
Plus, Plus,
Trash2, Trash2,
ChevronDown, ChevronDown,
ChevronRight,
UserMinus, UserMinus,
UserPlus, UserPlus,
} from "lucide-react"; } from "lucide-react";
@ -47,7 +46,15 @@ interface ContactRow {
pushName: string | null; 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 queryClient = useQueryClient();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
@ -93,70 +100,144 @@ function GroupRow({ appId, group }: { appId: string; group: Group }) {
} }
} }
const initials = group.name.slice(0, 2).toUpperCase();
return ( return (
<div className="border rounded-lg overflow-hidden"> <div
<button className={cn(
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors text-left" "bg-white rounded-xl border border-gray-200 overflow-hidden transition-shadow duration-200",
onClick={() => setExpanded((v) => !v)} expanded && "shadow-sm",
> )}
<div className="flex items-center gap-3"> >
{expanded ? ( {/* Header row */}
<ChevronDown className="h-4 w-4 text-muted-foreground" /> <div className="flex items-center gap-3 px-4 py-3.5">
) : ( {/* Avatar */}
<ChevronRight className="h-4 w-4 text-muted-foreground" /> <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">
<div> {initials}
<p className="font-medium text-sm">{group.name}</p> </span>
</div>
{/* Expand trigger */}
<button
className="flex-1 flex items-center gap-2 text-left min-w-0"
onClick={() => setExpanded((v) => !v)}
>
<div className="min-w-0">
<p className="text-[14px] font-semibold text-gray-900 truncate leading-snug">
{group.name}
</p>
{group.description && ( {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>
</div> </button>
<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 && ( {expanded && (
<div className="border-t px-4 py-3"> <div className="border-t border-gray-100 bg-gray-50/60">
{members.length === 0 ? ( {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"> <div className="px-4 py-3">
{members.map((m) => ( <div className="divide-y divide-gray-100">
<div {members.map((m) => {
key={m.id} const displayName = m.name ?? m.pushName ?? m.phone;
className="flex items-center justify-between py-1" const initial = displayName.charAt(0).toUpperCase();
return (
<div
key={m.id}
className="flex items-center gap-3 py-2.5 group"
>
<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-[11px] text-gray-400 font-mono shrink-0">
{m.phone}
</span>
)}
</div>
<Button
variant="ghost"
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 />
</Button>
</div>
);
})}
</div>
<div className="pt-3 pb-1">
<Button
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
className="h-8 px-3 text-[13px] gap-1.5 border-dashed text-gray-500"
> >
<div> <UserPlus size={13} strokeWidth={2} />
<span className="text-sm">{m.name ?? m.pushName ?? m.phone}</span> Add contacts
{m.name && ( </Button>
<span className="text-xs text-muted-foreground ml-2"> </div>
{m.phone}
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={() => removeMember(m.id)}
>
<UserMinus className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div> </div>
)} )}
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={() => setAddOpen(true)}
>
<UserPlus className="h-3.5 w-3.5" />
Add contacts
</Button>
</div> </div>
)} )}
@ -185,17 +266,21 @@ function GroupRow({ appId, group }: { appId: string; group: Group }) {
<button <button
key={c.id} key={c.id}
disabled={adding} 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)} onClick={() => addMember(c.id)}
> >
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0"> <div className="w-7 h-7 rounded-full bg-zinc-900 flex items-center justify-center shrink-0">
<span className="text-[10px] font-medium uppercase"> <span className="text-[10px] font-bold text-white uppercase">
{(c.name ?? c.pushName ?? c.phone).charAt(0)} {(c.name ?? c.pushName ?? c.phone).charAt(0)}
</span> </span>
</div> </div>
<div> <div>
<p className="text-sm">{c.name ?? c.pushName ?? c.phone}</p> <p className="text-[13px] font-medium text-gray-900">
<p className="text-xs text-muted-foreground">{c.phone}</p> {c.name ?? c.pushName ?? c.phone}
</p>
<p className="text-[11px] text-gray-400 font-mono">
{c.phone}
</p>
</div> </div>
</button> </button>
)) ))
@ -268,100 +353,175 @@ function GroupsPage() {
} }
return ( return (
<div className="p-6 max-w-3xl"> <div className="min-h-full">
<div className="flex items-center gap-3 mb-6"> <div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<Link to="/apps/$appId/contacts/" params={{ appId }}> <div className="flex items-center gap-3">
<Button variant="ghost" size="sm"> <Link
<ArrowLeft className="h-4 w-4 mr-1" /> Contacts to="/apps/$appId/contacts/"
</Button> params={{ appId }}
</Link> className="flex items-center gap-1 text-[13px] text-gray-500 hover:text-gray-900 transition-colors"
<h1 className="text-xl font-semibold flex items-center gap-2"> >
<Users className="h-5 w-5" /> Groups <ArrowLeft size={14} strokeWidth={1.75} />
</h1> Contacts
<div className="ml-auto"> </Link>
<Button size="sm" onClick={() => setNewOpen(true)}> <span className="text-gray-300">/</span>
<Plus className="h-4 w-4 mr-1" /> New group <div className="flex items-center gap-2">
</Button> <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> </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>
{(isPending as boolean) && ( <div className="p-8">
<p className="text-sm text-muted-foreground">Loading…</p> {(isPending as boolean) && (
)} <div className="flex items-center justify-center py-16">
{!(isPending as boolean) && groups.length === 0 && ( <div className="w-5 h-5 border-2 border-gray-200 border-t-gray-600 rounded-full animate-spin" />
<p className="text-sm text-muted-foreground">No groups yet.</p> </div>
)} )}
<div className="space-y-2"> {!(isPending as boolean) && groups.length === 0 && (
{groups.map((g) => ( <div className="flex flex-col items-center justify-center py-20 gap-4">
<div key={g.id} className="relative"> <div className="w-14 h-14 rounded-2xl bg-gray-100 border border-gray-200 flex items-center justify-center">
<GroupRow appId={appId} group={g} /> <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 <Button
variant="ghost" size="sm"
size="icon" onClick={() => setNewOpen(true)}
className="absolute top-2 right-2 h-7 w-7 text-muted-foreground hover:text-red-500" className="h-8 px-4 text-[13px] bg-zinc-900 hover:bg-zinc-800 text-white gap-1.5"
onClick={() => setDeleteId(g.id)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Plus size={13} strokeWidth={2.5} />
New group
</Button> </Button>
</div> </div>
))} )}
<div className="space-y-3">
{groups.map((g) => (
<GroupRow
key={g.id}
appId={appId}
group={g}
onDelete={() => setDeleteId(g.id)}
/>
))}
</div>
</div> </div>
{/* Create dialog */} {/* Create dialog */}
<Dialog open={newOpen} onOpenChange={setNewOpen}> <Dialog open={newOpen} onOpenChange={setNewOpen}>
<DialogContent> <DialogContent className="sm:max-w-[400px]">
<DialogHeader> <DialogHeader>
<DialogTitle>New group</DialogTitle> <DialogTitle className="text-[15px]">New group</DialogTitle>
<DialogDescription> <DialogDescription className="text-[13px]">
Create a contact group to segment your audience. Create a contact group to segment your audience.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={createGroup} className="space-y-3"> <form onSubmit={createGroup} className="space-y-3 pt-1">
<div className="space-y-1"> <div className="space-y-1.5">
<Label>Name</Label> <Label className="text-[13px] text-zinc-700">
Name <span className="text-red-500">*</span>
</Label>
<Input <Input
autoFocus
placeholder="e.g. VIP Customers" placeholder="e.g. VIP Customers"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
className="h-9 text-sm"
required required
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1.5">
<Label>Description (optional)</Label> <Label className="text-[13px] text-zinc-700">
Description{" "}
<span className="text-gray-400 font-normal">(optional)</span>
</Label>
<Input <Input
placeholder="What this group represents" placeholder="What this group represents"
value={newDesc} value={newDesc}
onChange={(e) => setNewDesc(e.target.value)} onChange={(e) => setNewDesc(e.target.value)}
className="h-9 text-sm"
/> />
</div> </div>
{createError && ( {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">
{creating ? "Creating…" : "Create"} <Button
</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> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Delete confirm */} {/* Delete confirm */}
<Dialog open={!!deleteId} onOpenChange={(o) => { if (!o) setDeleteId(null); }}> <Dialog
<DialogContent> open={!!deleteId}
onOpenChange={(o) => {
if (!o) setDeleteId(null);
}}
>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Delete group</DialogTitle> <DialogTitle className="text-[15px] text-red-600">
<DialogDescription> Delete group?
</DialogTitle>
<DialogDescription className="text-[13px]">
Removes the group. Contacts are not deleted. Removes the group. Contacts are not deleted.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setDeleteId(null)}> <Button
variant="outline"
size="sm"
onClick={() => setDeleteId(null)}
className="h-8 px-3 text-[13px]"
>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
size="sm"
disabled={deleting} disabled={deleting}
onClick={deleteGroup} onClick={deleteGroup}
className="h-8 px-3 text-[13px]"
> >
{deleting ? "Deleting…" : "Delete"} {deleting ? "Deleting…" : "Delete"}
</Button> </Button>

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@ import {
DialogDescription, DialogDescription,
} from "#/components/ui/dialog"; } from "#/components/ui/dialog";
import { cn } from "#/lib/utils"; import { cn } from "#/lib/utils";
import { FieldsDialog } from "#/components/fields-dialog";
export const Route = createFileRoute("/apps/$appId/contacts/")({ export const Route = createFileRoute("/apps/$appId/contacts/")({
component: ContactsPage, component: ContactsPage,
@ -74,6 +75,7 @@ function ContactsPage() {
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [fieldsOpen, setFieldsOpen] = useState(false);
const { data, refetch, isPending } = useQuery<{ const { data, refetch, isPending } = useQuery<{
contacts: DecryptedContact[]; contacts: DecryptedContact[];
@ -193,16 +195,15 @@ function ContactsPage() {
Groups Groups
</Button> </Button>
</Link> </Link>
<Link to="/apps/$appId/contacts/settings/" params={{ appId }}> <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" className="h-8 px-3 text-[13px] gap-1.5"
className="h-8 px-3 text-[13px] gap-1.5" onClick={() => setFieldsOpen(true)}
> >
<Settings size={12} strokeWidth={2} /> <Settings size={12} strokeWidth={2} />
Fields Fields
</Button> </Button>
</Link>
<Link to="/apps/$appId/contacts/import" params={{ appId }}> <Link to="/apps/$appId/contacts/import" params={{ appId }}>
<Button <Button
variant="outline" variant="outline"
@ -566,6 +567,8 @@ function ContactsPage() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<FieldsDialog appId={appId} open={fieldsOpen} onOpenChange={setFieldsOpen} />
</div> </div>
); );
} }

View file

@ -146,11 +146,17 @@ function WebhooksPage() {
} }
return ( return (
<div className="p-6 max-w-4xl"> <div className="min-h-full">
<div className="flex items-center justify-between mb-6"> {/* 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"> <div className="flex items-center gap-2">
<Webhook className="h-5 w-5" /> <Webhook size={15} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-xl font-semibold">Webhooks</h1> <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> </div>
<Button <Button
size="sm" size="sm"
@ -158,11 +164,13 @@ function WebhooksPage() {
setNewOpen(true); setNewOpen(true);
setCreatedSecret(null); 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> </Button>
</div> </div>
<div className="px-8 py-6 max-w-4xl animate-fade-up">
{(isPending as boolean) && ( {(isPending as boolean) && (
<p className="text-sm text-muted-foreground">Loading…</p> <p className="text-sm text-muted-foreground">Loading…</p>
)} )}
@ -361,6 +369,7 @@ function WebhooksPage() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>
</div> </div>
); );
} }

View file

@ -133,7 +133,7 @@ describe("retry delay schedule (FR-W4)", () => {
it("delays are strictly increasing", () => { it("delays are strictly increasing", () => {
for (let i = 1; i < RETRY_DELAYS.length; i++) { 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; if (forEndpoint.length < retain) return deliveries;
const cutoff = forEndpoint[retain - 1]!.createdAt; const cutoff = forEndpoint[retain - 1].createdAt;
return deliveries.filter( return deliveries.filter(
(d) => d.endpointId !== endpointId || d.createdAt >= cutoff, (d) => d.endpointId !== endpointId || d.createdAt >= cutoff,
); );