tia/src/app/circle/[id]/page.tsx
Mannu 560761968b feat: collapsible post cards in circle feed
Posts start collapsed showing author, timestamp, a thumbnail (if image)
and truncated text preview with ▼ chevron. Tap the author row to expand
the full post (body, full image, reactions, comments). Tap again to
collapse. Lets users scan many posts quickly and expand only what
interests them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:01:56 +05:30

678 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect, useCallback, use } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "../../FamilyProvider";
import type { CirclePost, CircleComment, Circle } from "@/types";
const REACTIONS = ["❤️", "😂", "👍", "🙏", "😮"];
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
// ── Post card ─────────────────────────────────────────────────────────────────
function PostCard({
post, myFamilyId, circleId, isAdmin,
onDeleted, onReact,
}: {
post: CirclePost;
myFamilyId: string;
circleId: string;
isAdmin: boolean;
onDeleted: (id: string) => void;
onReact: (postId: string, emoji: string) => void;
}) {
const [collapsed, setCollapsed] = useState(true);
const [showComments, setShowComments] = useState(false);
const [comments, setComments] = useState<CircleComment[]>([]);
const [commentText, setCommentText] = useState("");
const [posting, setPosting] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [reportSent, setReportSent] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [editMode, setEditMode] = useState(false);
const [editBody, setEditBody] = useState(post.body ?? "");
const [savingEdit, setSavingEdit] = useState(false);
const [lightbox, setLightbox] = useState(false);
const loadComments = async () => {
const res = await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`);
const data = await res.json();
setComments(data.comments ?? []);
};
const toggleComments = () => {
if (!showComments) loadComments();
setShowComments(v => !v);
};
const addComment = async () => {
if (!commentText.trim()) return;
setPosting(true);
await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: commentText.trim() }),
});
setCommentText("");
loadComments();
setPosting(false);
};
const sendReport = async (reason: string) => {
await fetch(`/api/circles/${circleId}/posts/${post.id}/report`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
setReportSent(true);
setShowMenu(false);
};
const deletePost = async () => {
await fetch(`/api/circles/${circleId}/posts/${post.id}`, { method: "DELETE" });
onDeleted(post.id);
};
const saveEdit = async () => {
setSavingEdit(true);
await fetch(`/api/circles/${circleId}/posts/${post.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: editBody }),
});
post.body = editBody; // optimistic local update
setEditMode(false);
setSavingEdit(false);
};
const isOwn = post.authorFamilyId === myFamilyId;
return (
<>
{/* Fullscreen image lightbox */}
{lightbox && post.imageUrl && (
<div
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4"
onClick={() => setLightbox(false)}
>
<img
src={post.imageUrl}
alt="Full size"
className="max-w-full max-h-full object-contain rounded-xl"
/>
<button className="absolute top-4 right-4 text-white text-2xl bg-black/40 rounded-full w-10 h-10 flex items-center justify-center"></button>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm">
{/* Author row — tap to collapse/expand */}
<div className="flex items-center justify-between px-4 pt-3 pb-2">
<button
onClick={() => setCollapsed(v => !v)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<div className="w-8 h-8 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-sm flex-shrink-0">👨👩👧</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{post.authorFamilyName}</p>
<p className="text-xs text-gray-400">{timeAgo(post.createdAt)}{post.sourceKind ? ` · shared a ${post.sourceKind}` : ""}</p>
</div>
{/* Collapsed preview */}
{collapsed && (
<div className="flex items-center gap-2 flex-shrink-0 max-w-[45%]">
{post.imageUrl && (
<img src={post.imageUrl} alt="" className="w-10 h-10 rounded-lg object-cover flex-shrink-0" />
)}
{post.body && (
<span className="text-xs text-gray-400 truncate">{post.body}</span>
)}
<span className="text-gray-300 text-xs flex-shrink-0"></span>
</div>
)}
{!collapsed && <span className="text-gray-300 text-xs flex-shrink-0 ml-2"></span>}
</button>
{/* ⋯ menu — rendered outside overflow:hidden so it doesn't get clipped */}
<div className="relative">
<button onClick={() => setShowMenu(v => !v)} className="p-2 text-gray-400 text-lg"></button>
{showMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
<div className="absolute right-0 top-9 z-50 bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 min-w-[170px] py-1">
{isOwn && (
<button
onClick={() => { setShowMenu(false); setEditMode(true); setEditBody(post.body ?? ""); }}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
> Edit post</button>
)}
{(isOwn || isAdmin) && (
<button
onClick={() => { setShowMenu(false); setDeleteConfirm(true); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
>🗑 Delete post</button>
)}
{!isOwn && !reportSent && (
<button
onClick={() => sendReport("inappropriate")}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
>🚩 Report post</button>
)}
{reportSent && <p className="px-4 py-2.5 text-xs text-gray-400">Reported thank you</p>}
</div>
</>
)}
</div>
</div>
{/* Collapsed divider */}
{collapsed && <div className="border-t border-gray-50 dark:border-gray-700 rounded-b-2xl" />}
{/* Expanded content */}
{!collapsed && <>
{/* Delete confirmation */}
{deleteConfirm && (
<div className="mx-4 mb-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-xl space-y-2">
<p className="text-sm text-red-600 font-medium">Delete this post?</p>
<div className="flex gap-2">
<button onClick={deletePost} className="flex-1 py-1.5 bg-red-500 text-white rounded-lg text-sm">Delete</button>
<button onClick={() => setDeleteConfirm(false)} className="flex-1 py-1.5 text-gray-400 text-sm">Cancel</button>
</div>
</div>
)}
{/* Body — inline edit or display */}
{editMode ? (
<div className="px-4 pb-3 space-y-2">
<textarea
value={editBody}
onChange={e => setEditBody(e.target.value)}
rows={3}
autoFocus
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none resize-none border border-rose-300"
/>
<div className="flex gap-2">
<button
onClick={saveEdit}
disabled={savingEdit}
className="flex-1 py-1.5 bg-rose-400 text-white rounded-lg text-sm disabled:opacity-50"
>{savingEdit ? "Saving…" : "Save"}</button>
<button onClick={() => setEditMode(false)} className="flex-1 py-1.5 text-gray-400 text-sm">Cancel</button>
</div>
</div>
) : (
post.body && <p className="px-4 pb-3 text-sm leading-relaxed">{post.body}</p>
)}
{/* Image — contain (no crop) + tap for fullscreen */}
{post.imageUrl && (
<div className="overflow-hidden rounded-none mx-0 mb-0">
<img
src={post.imageUrl}
alt="Post"
onClick={() => setLightbox(true)}
className="w-full max-h-80 object-contain bg-gray-50 dark:bg-gray-900 cursor-pointer"
/>
<p className="text-center text-xs text-gray-400 py-1">Tap to view full size</p>
</div>
)}
{/* Reactions */}
<div className="px-4 py-2 flex items-center gap-1 flex-wrap border-t border-gray-50 dark:border-gray-700">
{REACTIONS.map(emoji => {
const r = post.reactions.find(x => x.emoji === emoji);
return (
<button
key={emoji}
onClick={() => onReact(post.id, emoji)}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors ${
r?.includedMe
? "bg-rose-100 dark:bg-rose-900/40 text-rose-600"
: "bg-gray-50 dark:bg-gray-700 text-gray-500"
}`}
>
{emoji}{r?.count ? ` ${r.count}` : ""}
</button>
);
})}
<button
onClick={toggleComments}
className="ml-auto flex items-center gap-1 text-xs text-gray-400 px-2 py-1"
>
💬 {post.commentCount > 0 ? post.commentCount : ""} {showComments ? "▲" : "▼"}
</button>
</div>
{/* Comments */}
{showComments && (
<div className="border-t border-gray-50 dark:border-gray-700 px-4 py-3 space-y-3">
{comments.map(c => (
<div key={c.id} className="flex gap-2">
<div className="w-7 h-7 bg-rose-50 dark:bg-rose-900/30 rounded-full flex items-center justify-center text-xs flex-shrink-0">👨👩👧</div>
<div className="flex-1 bg-gray-50 dark:bg-gray-700 rounded-xl px-3 py-2">
<p className="text-xs font-medium text-gray-600 dark:text-gray-300">{c.authorFamilyName}</p>
<p className="text-sm">{c.body}</p>
</div>
</div>
))}
<div className="flex gap-2 pt-1">
<input
id="comment-input"
name="comment-input"
autoComplete="off"
value={commentText}
onChange={e => setCommentText(e.target.value)}
onKeyDown={e => e.key === "Enter" && addComment()}
placeholder="Add a comment…"
className="flex-1 px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none"
/>
<button
onClick={addComment}
disabled={posting || !commentText.trim()}
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm disabled:opacity-40"
></button>
</div>
</div>
)}
</>} {/* end !collapsed */}
</div>
</>
);
}
// ── Create Post Modal (C5 + C9 consent) ──────────────────────────────────────
function CreatePostModal({
circleId, circleName, memberCount,
onClose, onPosted,
}: {
circleId: string;
circleName: string;
memberCount: number;
onClose: () => void;
onPosted: () => void;
}) {
const [body, setBody] = useState("");
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [posting, setPosting] = useState(false);
const [step, setStep] = useState<"compose" | "confirm">("compose");
const [postError, setPostError] = useState<string | null>(null);
const pickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
setImageFile(f);
setImagePreview(URL.createObjectURL(f));
setPostError(null);
};
const submit = async () => {
setPosting(true);
setPostError(null);
try {
let tmpKey: string | null = null;
if (imageFile) {
// Upload via server (avoids CORS issues with direct R2 PUT)
const fd = new FormData();
fd.append("file", imageFile);
const uploadRes = await fetch(`/api/circles/${circleId}/posts/upload`, {
method: "POST",
body: fd,
});
const uploadData = await uploadRes.json();
if (!uploadRes.ok) {
setPostError(uploadData.error ?? "Image upload failed");
setPosting(false);
return;
}
tmpKey = uploadData.tmpKey;
}
const postRes = await fetch(`/api/circles/${circleId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ postBody: body, tmpKey }),
});
const postData = await postRes.json();
if (!postRes.ok) {
setPostError(postData.error ?? "Failed to create post");
setPosting(false);
return;
}
onPosted();
onClose();
} catch (err) {
setPostError(err instanceof Error ? err.message : "Something went wrong");
}
setPosting(false);
};
return (
<>
<div className="fixed inset-0 bg-black/50 z-50" onClick={onClose} />
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-4 pb-10 shadow-xl max-h-[85vh] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">{step === "confirm" ? "Confirm post" : "New post"}</h3>
<button onClick={onClose} className="text-gray-400"></button>
</div>
{step === "compose" ? (
<>
<textarea
id="post-body"
name="post-body"
autoFocus
value={body}
onChange={e => setBody(e.target.value)}
placeholder="What's on your mind?"
rows={4}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none resize-none mb-3"
/>
{imagePreview && (
<div className="relative mb-3">
<img src={imagePreview} alt="Preview" className="w-full max-h-48 object-cover rounded-xl" />
<button
onClick={() => { setImageFile(null); setImagePreview(null); }}
className="absolute top-2 right-2 w-6 h-6 bg-black/50 text-white rounded-full text-xs"
></button>
</div>
)}
<label className="flex items-center gap-2 text-sm text-gray-500 cursor-pointer mb-4">
<span className="text-xl">📷</span> Add photo
<input id="post-image" name="post-image" type="file" accept="image/*" className="hidden" onChange={pickImage} />
</label>
<button
onClick={() => setStep("confirm")}
disabled={!body.trim() && !imageFile}
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-40"
>Continue </button>
</>
) : (
/* C9 — Explicit consent surface */
<div className="flex-1 flex flex-col gap-4">
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-2xl border border-amber-200 dark:border-amber-700">
<p className="text-sm font-semibold text-amber-800 dark:text-amber-200 mb-1">📣 Heads up</p>
<p className="text-sm text-amber-700 dark:text-amber-300">
This will be visible to all <strong>{memberCount} {memberCount === 1 ? "family" : "families"}</strong> in{" "}
<strong>{circleName}</strong>.{" "}
{imageFile && "The photo you selected will also be shared. "}
Once posted, other members can see it.
</p>
</div>
{body && <p className="text-sm bg-gray-50 dark:bg-gray-700 rounded-xl px-3 py-2 italic text-gray-600 dark:text-gray-300">"{body}"</p>}
{postError && (
<p className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded-xl px-3 py-2 mb-2">
{postError}
</p>
)}
<div className="flex gap-3 mt-auto">
<button onClick={() => setStep("compose")} className="flex-1 py-3 border border-gray-200 dark:border-gray-600 rounded-xl text-sm"> Edit</button>
<button
onClick={submit}
disabled={posting}
className="flex-1 py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
>{posting ? "Posting…" : "Post to Circle ✓"}</button>
</div>
</div>
)}
</div>
</>
);
}
// ── Circle feed page ──────────────────────────────────────────────────────────
export default function CircleFeedPage({ params }: { params: Promise<{ id: string }> }) {
const { id: circleId } = use(params);
const router = useRouter();
const { familyId } = useFamily();
const [circle, setCircle] = useState<Circle | null>(null);
const [posts, setPosts] = useState<CirclePost[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [showMembers, setShowMembers] = useState(false);
const [members, setMembers] = useState<{ familyId: string; familyName: string; role: string }[]>([]);
const [myRole, setMyRole] = useState<"admin" | "member">("member");
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteSending, setInviteSending] = useState(false);
const [inviteResult, setInviteResult] = useState<{ type: "success" | "error"; msg: string } | null>(null);
const fetchFeed = useCallback(async () => {
try {
const [circleRes, postsRes] = await Promise.all([
fetch(`/api/circles/${circleId}`),
fetch(`/api/circles/${circleId}/posts`),
]);
const circleData = await circleRes.json();
const postsData = await postsRes.json();
if (circleData.circle) {
setCircle(circleData.circle);
setMembers(circleData.members ?? []);
setMyRole(circleData.circle.role ?? "member");
}
setPosts(postsData.posts ?? []);
} catch { /* silent */ }
setLoading(false);
}, [circleId]);
useEffect(() => { fetchFeed(); }, [fetchFeed]);
const handleReact = async (postId: string, emoji: string) => {
await fetch(`/api/circles/${circleId}/posts/${postId}/reactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emoji }),
});
// Refresh just this post's reactions
fetchFeed();
};
const handleInviteSend = async () => {
if (!inviteEmail.trim()) return;
setInviteSending(true);
setInviteResult(null);
try {
const res = await fetch(`/api/circles/${circleId}/invite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: inviteEmail.trim() }),
});
const data = await res.json();
if (data.success) {
const msg = data.type === "in_app"
? `Invite sent! ${inviteEmail} will see it on their Circles page.`
: `Invite email sent to ${inviteEmail}!`;
setInviteResult({ type: "success", msg });
setInviteEmail("");
} else {
setInviteResult({ type: "error", msg: data.error ?? "Failed to send invite" });
}
} catch {
setInviteResult({ type: "error", msg: "Something went wrong" });
}
setInviteSending(false);
};
const handleLeave = async () => {
if (!confirm("Leave this circle?")) return;
const res = await fetch(`/api/circles/${circleId}/members`, { method: "DELETE" });
const data = await res.json();
if (data.success) router.push("/circle");
else alert(data.error);
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<div className="flex gap-3 text-3xl">
{["👨‍👩‍👧", "💬", "❤️"].map((e, i) => (
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
))}
</div>
</div>
);
}
if (!circle) {
return (
<div className="min-h-screen flex items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-4xl mb-2">🔒</p>
<p>Circle not found or you are not a member.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
{/* Header */}
<div className="p-4 flex items-center gap-3">
<button onClick={() => router.push("/circle")} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<div className="flex-1 min-w-0">
<h1 className="text-lg font-bold truncate">{circle.name}</h1>
<p className="text-xs text-gray-400">{circle.memberCount} {circle.memberCount === 1 ? "family" : "families"}</p>
</div>
{/* Members panel toggle */}
<button
onClick={() => setShowMembers(v => !v)}
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-sm"
>👥</button>
{/* Admin: invite */}
{myRole === "admin" && (
<button
onClick={() => { setShowInviteModal(true); setInviteResult(null); setInviteEmail(""); }}
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
>+ Invite</button>
)}
</div>
{/* Members panel */}
{showMembers && (
<div className="mx-4 mb-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">Members</p>
<button onClick={handleLeave} className="text-xs text-red-400">Leave circle</button>
</div>
<div className="space-y-2">
{members.map(m => (
<div key={m.familyId} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-xs">👨👩👧</div>
<span className="text-sm">{m.familyName}</span>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${m.role === "admin" ? "bg-rose-100 text-rose-600" : "bg-gray-100 text-gray-500"}`}>
{m.role}
</span>
{myRole === "admin" && m.familyId !== familyId && (
<button
onClick={async () => {
await fetch(`/api/circles/${circleId}/members/${m.familyId}`, { method: "DELETE" });
fetchFeed();
}}
className="text-xs text-red-400"
>Remove</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Posts feed */}
<div className="px-4 space-y-4">
{posts.length === 0 ? (
<div className="text-center py-16 px-8">
<p className="text-4xl mb-3">💬</p>
<p className="font-semibold text-gray-700 dark:text-gray-200">No posts yet</p>
<p className="text-sm text-gray-400 mt-1">Be the first to share something with {circle.name}!</p>
</div>
) : (
posts.map(p => (
<PostCard
key={p.id}
post={p}
myFamilyId={familyId ?? ""}
circleId={circleId}
isAdmin={myRole === "admin"}
onDeleted={id => setPosts(prev => prev.filter(x => x.id !== id))}
onReact={handleReact}
/>
))
)}
</div>
{/* FAB — create post */}
<button
onClick={() => setShowCreate(true)}
className="fixed bottom-20 right-5 w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl z-40"
>+</button>
{showCreate && circle && familyId && (
<CreatePostModal
circleId={circleId}
circleName={circle.name}
memberCount={circle.memberCount}
onClose={() => setShowCreate(false)}
onPosted={fetchFeed}
/>
)}
{/* Invite by email modal */}
{showInviteModal && (
<>
<div className="fixed inset-0 bg-black/50 z-50" onClick={() => setShowInviteModal(false)} />
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-5 pb-10 shadow-xl">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">Invite to {circle?.name}</h3>
<button onClick={() => setShowInviteModal(false)} className="text-gray-400 text-xl"></button>
</div>
<p className="text-sm text-gray-500 mb-4">
Enter the email address of the person you want to invite.
If they already have a Tia account they'll get a notification;
otherwise we'll email them a link to sign up and join.
</p>
<input
id="invite-email"
name="invite-email"
type="email"
autoFocus
value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleInviteSend()}
placeholder="their@email.com"
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none mb-3 border border-gray-200 dark:border-gray-600"
/>
{inviteResult && (
<p className={`text-sm mb-3 px-3 py-2 rounded-xl ${
inviteResult.type === "success"
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
: "bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-300"
}`}>
{inviteResult.type === "success" ? "✓ " : "✗ "}{inviteResult.msg}
</p>
)}
<button
onClick={handleInviteSend}
disabled={inviteSending || !inviteEmail.trim()}
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-40"
>
{inviteSending ? "Sending…" : "Send Invite"}
</button>
</div>
</>
)}
</div>
);
}