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,
|
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
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue