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:
ryan
2026-03-11 09:22:34 -04:00
parent 53b2b3b366
commit d1cb55ff1a
17 changed files with 2439 additions and 3327 deletions

View File

@@ -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>