feat: email-based circle invites with in-app notifications
- Admin invites by entering email instead of copying a link - If email matches existing Tia user → creates pending invite visible on their Circles page with Accept/Decline buttons - If email is not registered → sends Resend email with signup link that lands them directly in the circle after account creation - DB migration adds invited_email + invited_family_id to circle_invites - New GET /api/circles/invites endpoint for pending invite banners - Remove clipboard-copy approach entirely Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9f7ab870ba
commit
c24392f0a1
6 changed files with 306 additions and 31 deletions
9
drizzle/0004_circle_invite_email.sql
Normal file
9
drizzle/0004_circle_invite_email.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Add email-based invite columns to circle_invites.
|
||||||
|
-- invited_email: the address that was invited
|
||||||
|
-- invited_family_id: set when the email matches an existing family (for in-app notification)
|
||||||
|
|
||||||
|
ALTER TABLE circle_invites ADD COLUMN IF NOT EXISTS invited_email text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE circle_invites ADD COLUMN IF NOT EXISTS invited_family_id uuid REFERENCES families(id);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS circle_invites_invited_family_idx ON circle_invites(invited_family_id) WHERE invited_family_id IS NOT NULL;
|
||||||
|
|
@ -29,6 +29,13 @@
|
||||||
"when": 1748134800000,
|
"when": 1748134800000,
|
||||||
"tag": "0003_circles",
|
"tag": "0003_circles",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1748221200000,
|
||||||
|
"tag": "0004_circle_invite_email",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -2,10 +2,15 @@ import { NextResponse } from "next/server";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { requireFamily } from "@/lib/auth";
|
import { requireFamily } from "@/lib/auth";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
|
import { Resend } from "resend";
|
||||||
|
|
||||||
// POST — create a single-use invite link (admin only)
|
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||||
|
const EMAIL_FROM = process.env.EMAIL_FROM || "Tia <tia@manohargupta.com>";
|
||||||
|
const APP_URL = process.env.NEXT_PUBLIC_APP_URL || "https://tia.manohargupta.com";
|
||||||
|
|
||||||
|
// POST — invite a person by email (admin only)
|
||||||
export async function POST(
|
export async function POST(
|
||||||
_req: Request,
|
req: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -15,31 +20,117 @@ export async function POST(
|
||||||
const familyId = auth.session!.familyId!;
|
const familyId = auth.session!.familyId!;
|
||||||
const { id: circleId } = await params;
|
const { id: circleId } = await params;
|
||||||
|
|
||||||
// Only admins can create invites
|
// Only admins can invite
|
||||||
const rows = await sql.unsafe(
|
const rows = await sql.unsafe(
|
||||||
`SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
|
`SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
|
||||||
[circleId, familyId]
|
[circleId, familyId]
|
||||||
);
|
);
|
||||||
if (!rows[0] || rows[0].role !== "admin") {
|
if (!rows[0] || rows[0].role !== "admin") {
|
||||||
return NextResponse.json({ error: "Only circle admins can create invites" }, { status: 403 });
|
return NextResponse.json({ error: "Only circle admins can invite members" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cryptographically random 32-byte token (64 hex chars) — unguessable
|
const body = await req.json();
|
||||||
const token = randomBytes(32).toString("hex");
|
const email: string = (body.email ?? "").trim().toLowerCase();
|
||||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
|
if (!email || !email.includes("@")) {
|
||||||
|
return NextResponse.json({ error: "Valid email address required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const [invite] = await sql.unsafe(
|
// Fetch circle info for the email
|
||||||
`INSERT INTO circle_invites (circle_id, token, created_by, expires_at)
|
const [circle] = await sql.unsafe(
|
||||||
VALUES ($1, $2, $3, $4)
|
`SELECT name FROM circles WHERE id = $1`,
|
||||||
RETURNING id, token, expires_at as "expiresAt"`,
|
[circleId]
|
||||||
[circleId, token, familyId, expiresAt]
|
);
|
||||||
|
const [inviterFamily] = await sql.unsafe(
|
||||||
|
`SELECT name FROM families WHERE id = $1`,
|
||||||
|
[familyId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://tia.manohargupta.com";
|
// Look up if the email belongs to an existing user → find their family
|
||||||
return NextResponse.json({
|
const userRows = await sql.unsafe(
|
||||||
success: true,
|
`SELECT u.id as user_id, fm.family_id
|
||||||
invite: { ...invite, joinUrl: `${baseUrl}/circle/join/${token}` },
|
FROM users u
|
||||||
});
|
LEFT JOIN family_members fm ON fm.user_id = u.id
|
||||||
|
WHERE u.email = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
const invitedFamilyId: string | null = userRows[0]?.family_id ?? null;
|
||||||
|
|
||||||
|
// Don't invite someone already in the circle
|
||||||
|
if (invitedFamilyId) {
|
||||||
|
const already = await sql.unsafe(
|
||||||
|
`SELECT 1 FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
|
||||||
|
[circleId, invitedFamilyId]
|
||||||
|
);
|
||||||
|
if (already.length > 0) {
|
||||||
|
return NextResponse.json({ error: "This family is already a member of the circle" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for an existing pending invite to the same family
|
||||||
|
const pending = await sql.unsafe(
|
||||||
|
`SELECT 1 FROM circle_invites
|
||||||
|
WHERE circle_id = $1 AND invited_family_id = $2 AND consumed_at IS NULL AND expires_at > now()`,
|
||||||
|
[circleId, invitedFamilyId]
|
||||||
|
);
|
||||||
|
if (pending.length > 0) {
|
||||||
|
return NextResponse.json({ error: "An invite is already pending for this person" }, { status: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = randomBytes(32).toString("hex");
|
||||||
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const joinUrl = `${APP_URL}/circle/join/${token}`;
|
||||||
|
|
||||||
|
await sql.unsafe(
|
||||||
|
`INSERT INTO circle_invites (circle_id, token, created_by, expires_at, invited_email, invited_family_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[circleId, token, familyId, expiresAt, email, invitedFamilyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invitedFamilyId) {
|
||||||
|
// Existing user — they'll see the pending invite on their circles page
|
||||||
|
return NextResponse.json({ success: true, type: "in_app" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-registered user — send email via Resend
|
||||||
|
if (RESEND_API_KEY) {
|
||||||
|
try {
|
||||||
|
const resend = new Resend(RESEND_API_KEY);
|
||||||
|
await resend.emails.send({
|
||||||
|
from: EMAIL_FROM,
|
||||||
|
to: email,
|
||||||
|
subject: `${inviterFamily?.name ?? "Someone"} invited you to join ${circle?.name ?? "a circle"} on Tia`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family:system-ui,sans-serif;max-width:520px;margin:0 auto;padding:24px">
|
||||||
|
<h2 style="margin-bottom:8px">You're invited! 👨👩👧👦</h2>
|
||||||
|
<p style="color:#374151">
|
||||||
|
<strong>${inviterFamily?.name ?? "A family"}</strong> has invited you to join
|
||||||
|
their circle <strong>"${circle?.name ?? "a circle"}"</strong> on Tia — a baby tracking app for families.
|
||||||
|
</p>
|
||||||
|
<p style="color:#6b7280;font-size:14px">
|
||||||
|
Create a free account to join them and start sharing milestones, memories, and updates.
|
||||||
|
</p>
|
||||||
|
<a href="${APP_URL}/login?next=/circle/join/${token}"
|
||||||
|
style="display:inline-block;background:#fb7185;color:white;padding:12px 24px;
|
||||||
|
text-decoration:none;border-radius:10px;margin:16px 0;font-weight:600">
|
||||||
|
Create Account & Join Circle
|
||||||
|
</a>
|
||||||
|
<p style="color:#6b7280;font-size:13px">
|
||||||
|
Already have an account?
|
||||||
|
<a href="${joinUrl}" style="color:#fb7185">Click here to join directly</a>.
|
||||||
|
</p>
|
||||||
|
<p style="color:#9ca3af;font-size:12px;margin-top:24px">
|
||||||
|
This invite expires in 7 days. If you didn't expect this, you can ignore this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (emailErr) {
|
||||||
|
console.error("[CIRCLE-INVITE-EMAIL-FAILED]", emailErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, type: "email_sent" });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
return NextResponse.json({ error: msg }, { status: 500 });
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
|
|
||||||
34
src/app/api/circles/invites/route.ts
Normal file
34
src/app/api/circles/invites/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
import { requireFamily } from "@/lib/auth";
|
||||||
|
|
||||||
|
// GET — pending circle invites for the current family
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const auth = await requireFamily();
|
||||||
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
|
||||||
|
const familyId = auth.session!.familyId!;
|
||||||
|
|
||||||
|
const invites = await sql.unsafe(
|
||||||
|
`SELECT ci.id, ci.token, ci.circle_id as "circleId",
|
||||||
|
c.name as "circleName",
|
||||||
|
c.id as "circleId",
|
||||||
|
f.name as "inviterName",
|
||||||
|
(SELECT COUNT(*) FROM circle_members WHERE circle_id = c.id)::int as "memberCount",
|
||||||
|
ci.expires_at as "expiresAt"
|
||||||
|
FROM circle_invites ci
|
||||||
|
JOIN circles c ON c.id = ci.circle_id
|
||||||
|
JOIN families f ON f.id = ci.created_by
|
||||||
|
WHERE ci.invited_family_id = $1
|
||||||
|
AND ci.consumed_at IS NULL
|
||||||
|
AND ci.expires_at > now()
|
||||||
|
ORDER BY ci.created_at DESC`,
|
||||||
|
[familyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ invites });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return NextResponse.json({ error: err instanceof Error ? err.message : String(err) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -330,6 +330,10 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin
|
||||||
const [showMembers, setShowMembers] = useState(false);
|
const [showMembers, setShowMembers] = useState(false);
|
||||||
const [members, setMembers] = useState<{ familyId: string; familyName: string; role: string }[]>([]);
|
const [members, setMembers] = useState<{ familyId: string; familyName: string; role: string }[]>([]);
|
||||||
const [myRole, setMyRole] = useState<"admin" | "member">("member");
|
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 () => {
|
const fetchFeed = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -361,13 +365,30 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin
|
||||||
fetchFeed();
|
fetchFeed();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInvite = async () => {
|
const handleInviteSend = async () => {
|
||||||
const res = await fetch(`/api/circles/${circleId}/invite`, { method: "POST" });
|
if (!inviteEmail.trim()) return;
|
||||||
const data = await res.json();
|
setInviteSending(true);
|
||||||
if (data.invite?.joinUrl) {
|
setInviteResult(null);
|
||||||
await navigator.clipboard.writeText(data.invite.joinUrl).catch(() => {});
|
try {
|
||||||
alert(`Invite link copied!\n\n${data.invite.joinUrl}\n\nExpires in 7 days.`);
|
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 () => {
|
const handleLeave = async () => {
|
||||||
|
|
@ -418,7 +439,7 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin
|
||||||
{/* Admin: invite */}
|
{/* Admin: invite */}
|
||||||
{myRole === "admin" && (
|
{myRole === "admin" && (
|
||||||
<button
|
<button
|
||||||
onClick={handleInvite}
|
onClick={() => { setShowInviteModal(true); setInviteResult(null); setInviteEmail(""); }}
|
||||||
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
|
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
|
||||||
>+ Invite</button>
|
>+ Invite</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -496,6 +517,51 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin
|
||||||
onPosted={fetchFeed}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,29 @@ import Link from "next/link";
|
||||||
import { useFamily } from "../FamilyProvider";
|
import { useFamily } from "../FamilyProvider";
|
||||||
import type { Circle } from "@/types";
|
import type { Circle } from "@/types";
|
||||||
|
|
||||||
|
type PendingInvite = {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
circleId: string;
|
||||||
|
circleName: string;
|
||||||
|
inviterName: string;
|
||||||
|
memberCount: number;
|
||||||
|
expiresAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function CirclePage() {
|
export default function CirclePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { familyId } = useFamily();
|
const { familyId } = useFamily();
|
||||||
const [circles, setCircles] = useState<Circle[]>([]);
|
const [circles, setCircles] = useState<Circle[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [pendingInvites, setPendingInvites] = useState<PendingInvite[]>([]);
|
||||||
const [creating, setCreating] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [newName, setNewName] = useState("");
|
const [creating, setCreating] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [newName, setNewName] = useState("");
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [joiningId, setJoiningId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (familyId) fetchCircles();
|
if (familyId) { fetchCircles(); fetchPendingInvites(); }
|
||||||
}, [familyId]);
|
}, [familyId]);
|
||||||
|
|
||||||
const fetchCircles = async () => {
|
const fetchCircles = async () => {
|
||||||
|
|
@ -28,6 +40,14 @@ export default function CirclePage() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchPendingInvites = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/circles/invites");
|
||||||
|
const data = await res.json();
|
||||||
|
setPendingInvites(data.invites ?? []);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
};
|
||||||
|
|
||||||
const createCircle = async () => {
|
const createCircle = async () => {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
|
|
@ -47,6 +67,25 @@ export default function CirclePage() {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const acceptInvite = async (invite: PendingInvite) => {
|
||||||
|
setJoiningId(invite.id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/circle/join/${invite.token}`, { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
router.push(`/circle/${data.circleId}`);
|
||||||
|
} else {
|
||||||
|
alert(data.error ?? "Could not join circle");
|
||||||
|
setPendingInvites(prev => prev.filter(i => i.id !== invite.id));
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
setJoiningId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const declineInvite = (inviteId: string) => {
|
||||||
|
setPendingInvites(prev => prev.filter(i => i.id !== inviteId));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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 */}
|
{/* Header */}
|
||||||
|
|
@ -88,6 +127,35 @@ export default function CirclePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pending invites */}
|
||||||
|
{pendingInvites.length > 0 && (
|
||||||
|
<div className="px-4 mb-4 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-1 mb-1">Pending Invites</p>
|
||||||
|
{pendingInvites.map(inv => (
|
||||||
|
<div key={inv.id} className="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-2xl p-4 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-xl flex-shrink-0">
|
||||||
|
👨👩👧👦
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold truncate">{inv.circleName}</p>
|
||||||
|
<p className="text-xs text-gray-500">{inv.inviterName} invited you · {inv.memberCount} {inv.memberCount === 1 ? "family" : "families"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => acceptInvite(inv)}
|
||||||
|
disabled={joiningId === inv.id}
|
||||||
|
className="px-3 py-1.5 bg-rose-400 text-white rounded-xl text-xs font-medium disabled:opacity-50"
|
||||||
|
>{joiningId === inv.id ? "…" : "Join"}</button>
|
||||||
|
<button
|
||||||
|
onClick={() => declineInvite(inv.id)}
|
||||||
|
className="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-gray-500 rounded-xl text-xs"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="px-4 space-y-3">
|
<div className="px-4 space-y-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -98,7 +166,7 @@ export default function CirclePage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : circles.length === 0 ? (
|
) : circles.length === 0 && pendingInvites.length === 0 ? (
|
||||||
<div className="text-center py-16 px-8">
|
<div className="text-center py-16 px-8">
|
||||||
<p className="text-4xl mb-3">👨👩👧👦</p>
|
<p className="text-4xl mb-3">👨👩👧👦</p>
|
||||||
<p className="font-semibold text-gray-700 dark:text-gray-200">No circles yet</p>
|
<p className="font-semibold text-gray-700 dark:text-gray-200">No circles yet</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue