UMBRA/frontend/src/components/calendar/InviteeSection.tsx
Kyle Pope f54ab5079e Fix QA review findings: C-01, C-02, W-01, W-02, W-04, S-01, S-02, S-03
C-01: Remove nginx rate limit on event invitations endpoint — was
      blocking GET (invitee list) on rapid event switching. Backend
      already caps at 20 invitations per event with connection validation.

C-02: respondingRef uses string prefixes (conn-, cal-, event-) instead
      of fragile numeric offsets (+100000/+200000) to prevent collisions.

W-01: get_accessible_event_scope combined into single UNION ALL query
      (3 DB round-trips → 1) for calendar IDs + invitation IDs.

W-02: Dashboard and upcoming endpoints now include is_invited,
      invitation_status, and display_calendar_id on event items.

W-04: LeaveEventDialog closes on error (.finally) instead of staying
      open when mutation rejects.

S-01: Migration 055 FK constraint gets explicit name for consistency.

S-02: InviteSearch dropdown dismisses on blur (150ms delay for clicks).

S-03: Display calendar picker shows only owned calendars, not shared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:27:01 +08:00

246 lines
8.7 KiB
TypeScript

import { useState, useMemo } from 'react';
import { Users, UserPlus, Search, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import type { EventInvitation, Connection } from '@/types';
// ── Status display helpers ──
const STATUS_CONFIG = {
accepted: { label: 'Going', dotClass: 'bg-green-400', textClass: 'text-green-400' },
tentative: { label: 'Tentative', dotClass: 'bg-amber-400', textClass: 'text-amber-400' },
declined: { label: 'Declined', dotClass: 'bg-red-400', textClass: 'text-red-400' },
pending: { label: 'Pending', dotClass: 'bg-neutral-500', textClass: 'text-muted-foreground' },
} as const;
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.pending;
return (
<div className="flex items-center gap-1.5">
<span className={`w-[7px] h-[7px] rounded-full ${config.dotClass}`} />
<span className={`text-xs ${config.textClass}`}>{config.label}</span>
</div>
);
}
function AvatarCircle({ name }: { name: string }) {
const letter = name?.charAt(0)?.toUpperCase() || '?';
return (
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0">
<span className="text-xs font-medium text-muted-foreground">{letter}</span>
</div>
);
}
// ── View Mode: InviteeList ──
interface InviteeListProps {
invitees: EventInvitation[];
isRecurringChild?: boolean;
}
export function InviteeList({ invitees, isRecurringChild }: InviteeListProps) {
if (invitees.length === 0) return null;
const goingCount = invitees.filter((i) => i.status === 'accepted').length;
const countLabel = goingCount > 0 ? `${goingCount} going` : null;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Users className="h-3 w-3" />
Invitees
</div>
{countLabel && (
<span className="text-[11px] text-muted-foreground">{countLabel}</span>
)}
</div>
<div className="space-y-1">
{invitees.map((inv) => (
<div key={inv.id} className="flex items-center gap-2 py-1">
<AvatarCircle name={inv.invitee_name} />
<span className="text-sm flex-1 truncate">{inv.invitee_name}</span>
<StatusBadge status={inv.status} />
</div>
))}
</div>
{isRecurringChild && (
<p className="text-[11px] text-muted-foreground mt-1">
Status shown for this occurrence
</p>
)}
</div>
);
}
// ── Edit Mode: InviteSearch ──
interface InviteSearchProps {
connections: Connection[];
existingInviteeIds: Set<number>;
onInvite: (userIds: number[]) => void;
isInviting: boolean;
}
export function InviteSearch({ connections, existingInviteeIds, onInvite, isInviting }: InviteSearchProps) {
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const searchResults = useMemo(() => {
if (!search.trim()) return [];
const q = search.toLowerCase();
return connections
.filter((c) =>
!existingInviteeIds.has(c.connected_user_id) &&
!selectedIds.includes(c.connected_user_id) &&
(
(c.connected_preferred_name?.toLowerCase().includes(q)) ||
c.connected_umbral_name.toLowerCase().includes(q)
)
)
.slice(0, 6);
}, [search, connections, existingInviteeIds, selectedIds]);
const selectedConnections = connections.filter((c) => selectedIds.includes(c.connected_user_id));
const handleAdd = (userId: number) => {
setSelectedIds((prev) => [...prev, userId]);
setSearch('');
};
const handleRemove = (userId: number) => {
setSelectedIds((prev) => prev.filter((id) => id !== userId));
};
const handleSend = () => {
if (selectedIds.length === 0) return;
onInvite(selectedIds);
setSelectedIds([]);
};
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<UserPlus className="h-3 w-3" />
Invite People
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
onBlur={() => setTimeout(() => setSearch(''), 150)}
placeholder="Search connections..."
className="h-8 pl-8 text-xs"
/>
{search.trim() && searchResults.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden">
{searchResults.map((conn) => (
<button
key={conn.connected_user_id}
type="button"
onClick={() => handleAdd(conn.connected_user_id)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
>
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{conn.connected_preferred_name || conn.connected_umbral_name}</span>
{conn.connected_preferred_name && (
<span className="text-[11px] text-muted-foreground">@{conn.connected_umbral_name}</span>
)}
</div>
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
</button>
))}
</div>
)}
{search.trim() && searchResults.length === 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg p-3">
<p className="text-xs text-muted-foreground text-center">No connections found</p>
</div>
)}
</div>
{/* Selected invitees */}
{selectedConnections.length > 0 && (
<div className="space-y-1">
{selectedConnections.map((conn) => (
<div key={conn.connected_user_id} className="flex items-center gap-2 py-1">
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
<span className="text-sm flex-1 truncate">{conn.connected_preferred_name || conn.connected_umbral_name}</span>
<button
type="button"
onClick={() => handleRemove(conn.connected_user_id)}
className="p-0.5 rounded hover:bg-card-elevated text-muted-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
<Button
size="sm"
onClick={handleSend}
disabled={isInviting}
className="w-full mt-1"
>
{isInviting ? 'Sending...' : `Send ${selectedIds.length === 1 ? 'Invite' : `${selectedIds.length} Invites`}`}
</Button>
</div>
)}
</div>
);
}
// ── RSVP Buttons (for invitee view) ──
interface RsvpButtonsProps {
currentStatus: string;
onRespond: (status: 'accepted' | 'tentative' | 'declined') => void;
isResponding: boolean;
}
export function RsvpButtons({ currentStatus, onRespond, isResponding }: RsvpButtonsProps) {
return (
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onRespond('accepted')}
disabled={isResponding}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
currentStatus === 'accepted'
? 'bg-green-500/20 text-green-400'
: 'text-muted-foreground hover:bg-card-elevated'
}`}
>
Going
</button>
<button
type="button"
onClick={() => onRespond('tentative')}
disabled={isResponding}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
currentStatus === 'tentative'
? 'bg-amber-500/20 text-amber-400'
: 'text-muted-foreground hover:bg-card-elevated'
}`}
>
Maybe
</button>
<button
type="button"
onClick={() => onRespond('declined')}
disabled={isResponding}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
currentStatus === 'declined'
? 'bg-red-500/20 text-red-400'
: 'text-muted-foreground hover:bg-card-elevated'
}`}
>
Decline
</button>
</div>
);
}