Frontend revamp: Animal Crossing × Claude design with shadcn components
- New palette: deep nook green sidebar, sage user bubbles, warm cream answer cards - shadcn-style UI primitives: Button (CVA variants), Textarea, Input, Badge, Table - cn() utility (clsx + tailwind-merge) - lucide-react icons throughout (no more text-only buttons) - Simba mode: custom CSS toggle switch - Send button: circular amber button with arrow icon - AnswerBubble: amber gradient accent bar, loading dots animation - QuestionBubble: sage green pill with rounded-3xl - ToolBubble: centered leaf-green badge pill - ConversationList: active item highlighting, proper selectedId prop - Sidebar: collapsible with PanelLeftClose/Open icons, icon-only collapsed state - LoginScreen: decorative background blobs, refined rounded card - AdminPanel: proper icon buttons, leaf-green save confirmation - Fonts: Playfair Display (brand) + Nunito 800 weight added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Phone, PhoneOff, Pencil, Check } from "lucide-react";
|
||||
import { userService, type AdminUserRecord } from "../api/userService";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "./ui/table";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
@@ -24,8 +36,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
const startEdit = (user: AdminUserRecord) => {
|
||||
setEditingId(user.id);
|
||||
setEditValue(user.whatsapp_number ?? "");
|
||||
setRowError((prev) => ({ ...prev, [user.id]: "" }));
|
||||
setRowSuccess((prev) => ({ ...prev, [user.id]: "" }));
|
||||
setRowError((p) => ({ ...p, [user.id]: "" }));
|
||||
setRowSuccess((p) => ({ ...p, [user.id]: "" }));
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
@@ -34,33 +46,33 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
};
|
||||
|
||||
const saveWhatsapp = async (userId: string) => {
|
||||
setRowError((prev) => ({ ...prev, [userId]: "" }));
|
||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||
try {
|
||||
const updated = await userService.adminSetWhatsapp(userId, editValue);
|
||||
setUsers((prev) => prev.map((u) => (u.id === userId ? updated : u)));
|
||||
setRowSuccess((prev) => ({ ...prev, [userId]: "Saved" }));
|
||||
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Saved ✓" }));
|
||||
setEditingId(null);
|
||||
setTimeout(() => setRowSuccess((prev) => ({ ...prev, [userId]: "" })), 2000);
|
||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||
} catch (err) {
|
||||
setRowError((prev) => ({
|
||||
...prev,
|
||||
setRowError((p) => ({
|
||||
...p,
|
||||
[userId]: err instanceof Error ? err.message : "Failed to save",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkWhatsapp = async (userId: string) => {
|
||||
setRowError((prev) => ({ ...prev, [userId]: "" }));
|
||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||
try {
|
||||
await userService.adminUnlinkWhatsapp(userId);
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)),
|
||||
setUsers((p) =>
|
||||
p.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)),
|
||||
);
|
||||
setRowSuccess((prev) => ({ ...prev, [userId]: "Unlinked" }));
|
||||
setTimeout(() => setRowSuccess((prev) => ({ ...prev, [userId]: "" })), 2000);
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked ✓" }));
|
||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||
} catch (err) {
|
||||
setRowError((prev) => ({
|
||||
...prev,
|
||||
setRowError((p) => ({
|
||||
...p,
|
||||
[userId]: err instanceof Error ? err.message : "Failed to unlink",
|
||||
}));
|
||||
}
|
||||
@@ -68,110 +80,152 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-charcoal/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="bg-warm-white rounded-2xl shadow-2xl w-full max-w-3xl mx-4 max-h-[80vh] flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-warm-white rounded-3xl shadow-2xl shadow-charcoal/20",
|
||||
"w-full max-w-3xl mx-4 max-h-[82vh] flex flex-col",
|
||||
"border border-sand-light/60",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light">
|
||||
<h2 className="text-base font-semibold text-charcoal">Admin: WhatsApp Numbers</h2>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-leaf-pale flex items-center justify-center">
|
||||
<Phone size={14} className="text-leaf-dark" />
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-charcoal">
|
||||
Admin · WhatsApp Numbers
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
className="text-warm-gray hover:text-charcoal text-xl leading-none cursor-pointer"
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
|
||||
>
|
||||
×
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<div className="overflow-y-auto flex-1 rounded-b-3xl">
|
||||
{loading ? (
|
||||
<div className="px-6 py-10 text-center text-warm-gray text-sm">Loading…</div>
|
||||
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||
<div className="flex justify-center gap-1.5 mb-3">
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
</div>
|
||||
Loading users…
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-sand-light text-left text-warm-gray">
|
||||
<th className="px-6 py-3 font-medium">Username</th>
|
||||
<th className="px-6 py-3 font-medium">Email</th>
|
||||
<th className="px-6 py-3 font-medium">WhatsApp</th>
|
||||
<th className="px-6 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>WhatsApp</TableHead>
|
||||
<TableHead className="w-28">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="border-b border-sand-light/50 hover:bg-cream/40 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-3 text-charcoal font-medium">{user.username}</td>
|
||||
<td className="px-6 py-3 text-warm-gray">{user.email}</td>
|
||||
<td className="px-6 py-3">
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium text-charcoal">
|
||||
{user.username}
|
||||
</TableCell>
|
||||
<TableCell className="text-warm-gray">{user.email}</TableCell>
|
||||
<TableCell>
|
||||
{editingId === user.id ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
className="border border-sand-light rounded-lg px-2 py-1 text-sm text-charcoal bg-cream focus:outline-none focus:ring-1 focus:ring-amber-soft w-52"
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
placeholder="whatsapp:+15551234567"
|
||||
className="w-52"
|
||||
autoFocus
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && saveWhatsapp(user.id)
|
||||
}
|
||||
/>
|
||||
{rowError[user.id] && (
|
||||
<span className="text-xs text-red-500">{rowError[user.id]}</span>
|
||||
<span className="text-xs text-red-500">
|
||||
{rowError[user.id]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={user.whatsapp_number ? "text-charcoal" : "text-warm-gray/50 italic"}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
user.whatsapp_number
|
||||
? "text-charcoal"
|
||||
: "text-warm-gray/40 italic",
|
||||
)}
|
||||
>
|
||||
{user.whatsapp_number ?? "—"}
|
||||
</span>
|
||||
{rowSuccess[user.id] && (
|
||||
<span className="text-xs text-green-600">{rowSuccess[user.id]}</span>
|
||||
<span className="text-xs text-leaf-dark">
|
||||
{rowSuccess[user.id]}
|
||||
</span>
|
||||
)}
|
||||
{rowError[user.id] && (
|
||||
<span className="text-xs text-red-500">{rowError[user.id]}</span>
|
||||
<span className="text-xs text-red-500">
|
||||
{rowError[user.id]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editingId === user.id ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="text-xs px-2.5 py-1 rounded-lg bg-amber-soft/80 text-charcoal hover:bg-amber-soft transition-colors cursor-pointer"
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => saveWhatsapp(user.id)}
|
||||
>
|
||||
<Check size={12} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
className="text-xs px-2.5 py-1 rounded-lg text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost-dark"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="text-xs px-2.5 py-1 rounded-lg text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost-dark"
|
||||
onClick={() => startEdit(user)}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
Edit
|
||||
</button>
|
||||
</Button>
|
||||
{user.whatsapp_number && (
|
||||
<button
|
||||
className="text-xs px-2.5 py-1 rounded-lg text-red-400 hover:text-red-600 hover:bg-red-50 transition-colors cursor-pointer"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => unlinkWhatsapp(user.id)}
|
||||
>
|
||||
<PhoneOff size={11} />
|
||||
Unlink
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user