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>
246 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|