refactor(web): RegistrationPanel InviteRow → collapsed/expanded model

This commit is contained in:
Jannis Braun
2026-04-29 02:40:33 +02:00
parent 3fdf501620
commit b91bc89c58
@@ -974,62 +974,34 @@ function RedemptionsModal({ invite, onClose }: RedemptionsModalProps) {
interface InviteRowProps { interface InviteRowProps {
invite: InviteLinkSummary; invite: InviteLinkSummary;
expanded: boolean;
onToggleExpand: () => void;
onMutate: () => void; onMutate: () => void;
} }
/** /**
* One row in the invite list. Owns its own modal/popover/confirm-dialog state so the * One row in the invite list. Renders as a clickable collapsed header that, when
* parent panel only deals with the list-level fetch + tab state. * expanded, reveals a meta grid + status-specific action row. Owns its own modal
* and confirm-dialog state so the parent panel only manages list-level fetch +
* single-row expansion state (`expandedInviteId`).
* *
* Action surface depends on `invite.status`: * Action surface depends on `invite.status`:
* - active → Copy link · Edit · Revoke · ⋯ (View redemptions, Delete) * - active → Copy link · Edit · Revoke · View redemptions
* - non-active → Reinstate · ⋯ (View redemptions, Delete) * - non-active → Reinstate · Delete permanently · View redemptions
*
* The kebab popover dismisses on outside click and Escape; clicks inside its items
* already call `setShowKebab(false)` before triggering the action.
*/ */
function InviteRow({ invite, onMutate }: InviteRowProps) { function InviteRow({ invite, expanded, onToggleExpand, onMutate }: InviteRowProps) {
const addToast = useUIStore((s) => s.addToast); const addToast = useUIStore((s) => s.addToast);
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
const [showReinstate, setShowReinstate] = useState(false); const [showReinstate, setShowReinstate] = useState(false);
const [showRedemptions, setShowRedemptions] = useState(false); const [showRedemptions, setShowRedemptions] = useState(false);
const [showKebab, setShowKebab] = useState(false);
const [confirmRevoke, setConfirmRevoke] = useState(false); const [confirmRevoke, setConfirmRevoke] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const kebabContainerRef = useRef<HTMLDivElement>(null);
// Dismiss kebab popover on outside click + Escape. Mirrors the pattern used in
// TransferOwnershipModal — listen on document, check containment via ref.
useEffect(() => {
if (!showKebab) return;
const handleMouseDown = (e: MouseEvent) => {
if (
kebabContainerRef.current &&
!kebabContainerRef.current.contains(e.target as Node)
) {
setShowKebab(false);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// Stop propagation so the parent settings modal doesn't ALSO close.
e.stopPropagation();
setShowKebab(false);
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKey, true);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKey, true);
};
}, [showKebab]);
const usageLabel = const usageLabel =
invite.maxUses === null invite.maxUses === null
? `${invite.usedCount} use${invite.usedCount === 1 ? '' : 's'} · unlimited` ? `${invite.usedCount} / ∞`
: `${invite.usedCount} / ${invite.maxUses} uses`; : `${invite.usedCount} / ${invite.maxUses}`;
const usageNearLimit = const usageNearLimit =
invite.maxUses !== null && invite.maxUses > 0 && invite.usedCount / invite.maxUses >= 0.8; invite.maxUses !== null && invite.maxUses > 0 && invite.usedCount / invite.maxUses >= 0.8;
@@ -1071,111 +1043,192 @@ function InviteRow({ invite, onMutate }: InviteRowProps) {
} }
}; };
const statusPillClass = const isActive = invite.status === 'active';
invite.status === 'expired' const createdByLabel = invite.createdByUsername ?? 'Unknown';
? 'bg-rose-500/20 text-rose-400'
: invite.status === 'exhausted' // Subtitle (collapsed view)
? 'bg-amber-500/20 text-amber-400' // active → "X / Y uses · Expires in 3 days · Created by alice"
: 'bg-white/10 text-txt-tertiary'; // archived → "X / Y uses · Revoked 4/12/2026 · 2d ago"
const subtitle = isActive
? `${usageLabel} uses · ${formatExpiry(invite)} · Created by ${createdByLabel}`
: `${usageLabel} uses · ${formatExpiry(invite)} · ${formatRelative(invite.createdAt)}`;
// Archived row 1 second-cell label + value. EXHAUSTED has no dedicated terminal
// timestamp on the invite, so we surface lastRedeemedAt (the moment that drove
// it to exhausted) when known, falling back to em-dash if absent.
let archivedTerminalLabel: string;
let archivedTerminalValue: string;
if (invite.status === 'expired') {
archivedTerminalLabel = 'EXPIRED AT';
archivedTerminalValue = invite.expiresAt !== null ? formatRelative(invite.expiresAt) : '—';
} else if (invite.status === 'revoked') {
archivedTerminalLabel = 'REVOKED AT';
archivedTerminalValue = invite.revokedAt !== null ? formatRelative(invite.revokedAt) : '—';
} else {
// exhausted
archivedTerminalLabel = 'EXHAUSTED';
archivedTerminalValue =
invite.lastRedeemedAt !== null ? formatRelative(invite.lastRedeemedAt) : '—';
}
const tokenDisplay = `${invite.token.slice(-6)}`;
const lastRedeemedDisplay =
invite.lastRedeemedAt !== null ? formatRelative(invite.lastRedeemedAt) : '—';
return ( return (
<> <>
<div className="bg-white/[0.02] rounded-lg p-3 flex items-start gap-3"> <div
<div className="flex-1 min-w-0"> className={`bg-white/[0.02] rounded-md transition-colors ${
<div className="flex items-center gap-2 flex-wrap"> expanded ? 'border border-white/[0.06]' : ''
<span }`}
className="text-sm font-medium text-txt-primary truncate" >
{/* Collapsed clickable header */}
<div
className="flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-white/[0.02] rounded-md"
onClick={onToggleExpand}
>
<div className="flex items-center gap-2.5 min-w-0">
<div className={`w-2 h-2 rounded-full shrink-0 ${inviteStatusDotColor(invite.status)}`} />
<div className="min-w-0">
<div
className="text-sm font-medium truncate text-txt-primary"
title={invite.name} title={invite.name}
> >
{invite.name} {invite.name}
</span> </div>
<div className="text-[11px] text-txt-tertiary truncate">{subtitle}</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
{!isActive && (
<span <span
className={`text-xs ${usageNearLimit ? 'text-accent-amber' : 'text-txt-tertiary'}`} className={`inline-flex items-center text-[10px] font-medium px-1.5 py-0.5 rounded ${inviteStatusPillColor(invite.status)}`}
> >
· {usageLabel} {inviteStatusLabel(invite.status)}
</span>
{invite.status !== 'active' && (
<span
className={`text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded ${statusPillClass}`}
>
{invite.status}
</span> </span>
)} )}
</div> <span className="text-txt-tertiary text-xs">{expanded ? '▾' : '▸'}</span>
<div className="text-xs text-txt-tertiary mt-1">
{formatExpiry(invite)} · Created by {invite.createdByUsername ?? 'Unknown'} ·{' '}
{formatRelative(invite.createdAt)}
</div> </div>
</div> </div>
<div className="flex items-center gap-1 shrink-0 relative" ref={kebabContainerRef}> {/* Expanded body */}
{invite.status === 'active' ? ( {expanded && (
<div className="px-3 pb-3">
<div className="border-t border-white/[0.05] pt-3">
{/* Row 1: USED · (EXPIRES | terminal-status AT) · CREATED */}
<div className="grid grid-cols-3 gap-3 mb-3">
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Used</div>
<div
className={`text-xs ${
usageNearLimit ? 'text-accent-amber font-medium' : 'text-txt-secondary'
}`}
>
{usageLabel}
</div>
</div>
{isActive ? (
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Expires</div>
<div className="text-xs text-txt-secondary">{formatExpiry(invite)}</div>
</div>
) : (
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">
{archivedTerminalLabel}
</div>
<div className="text-xs text-txt-secondary">{archivedTerminalValue}</div>
</div>
)}
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Created</div>
<div className="text-xs text-txt-secondary">{formatRelative(invite.createdAt)}</div>
</div>
</div>
{/* Row 2: CREATED BY · TOKEN · LAST REDEEMED */}
<div className="grid grid-cols-3 gap-3 mb-3">
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Created by</div>
<div className="text-xs text-txt-secondary truncate" title={createdByLabel}>
{createdByLabel}
</div>
</div>
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Token</div>
<div className="text-xs font-mono text-txt-secondary" title={invite.token}>
{tokenDisplay}
</div>
</div>
<div>
<div className="text-[10px] text-txt-tertiary uppercase tracking-wider mb-0.5">Last redeemed</div>
<div className="text-xs text-txt-secondary">{lastRedeemedDisplay}</div>
</div>
</div>
{/* Action row */}
<div className="flex items-center gap-2 flex-wrap">
{isActive ? (
<> <>
<button <button
type="button" type="button"
onClick={handleCopy} onClick={(e) => { e.stopPropagation(); handleCopy(); }}
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors" className="px-3 py-1.5 text-xs font-medium bg-accent-lavender/10 text-accent-lavender hover:bg-accent-lavender/20 rounded transition-colors"
> >
Copy link Copy link
</button> </button>
<button <button
type="button" type="button"
onClick={() => setShowEdit(true)} onClick={(e) => { e.stopPropagation(); setShowEdit(true); }}
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors" className="px-3 py-1.5 text-xs font-medium bg-accent-lavender/10 text-accent-lavender hover:bg-accent-lavender/20 rounded transition-colors"
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => setConfirmRevoke(true)} onClick={(e) => { e.stopPropagation(); setConfirmRevoke(true); }}
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors" className="px-3 py-1.5 text-xs font-medium bg-accent-rose/10 text-txt-danger hover:bg-accent-rose/20 rounded transition-colors"
> >
Revoke Revoke
</button> </button>
</>
) : (
<button <button
type="button" type="button"
onClick={() => setShowReinstate(true)} onClick={(e) => { e.stopPropagation(); setShowRedemptions(true); }}
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors" className="text-[11px] text-txt-tertiary hover:text-txt-secondary underline decoration-dotted transition-colors ml-1"
>
Reinstate
</button>
)}
<button
type="button"
aria-label="More actions"
onClick={() => setShowKebab((v) => !v)}
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors"
>
</button>
{showKebab && (
<div className="glass absolute right-0 top-full mt-1 py-1 w-44 z-10 rounded-md">
<button
type="button"
onClick={() => {
setShowKebab(false);
setShowRedemptions(true);
}}
className="w-full text-left px-3 py-1.5 text-xs text-txt-secondary hover:text-txt-primary hover:bg-white/[0.04] transition-colors"
> >
View redemptions View redemptions
</button> </button>
</>
) : (
<>
<button <button
type="button" type="button"
onClick={() => { onClick={(e) => { e.stopPropagation(); setShowReinstate(true); }}
setShowKebab(false); className="px-3 py-1.5 text-xs font-medium bg-status-online/10 text-status-online hover:bg-status-online/20 rounded transition-colors"
setConfirmDelete(true); >
}} Reinstate
className="w-full text-left px-3 py-1.5 text-xs text-accent-rose hover:bg-accent-rose/10 transition-colors" </button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
className="px-3 py-1.5 text-xs font-medium bg-accent-rose/10 text-txt-danger hover:bg-accent-rose/20 rounded transition-colors"
> >
Delete permanently Delete permanently
</button> </button>
</div> <button
type="button"
onClick={(e) => { e.stopPropagation(); setShowRedemptions(true); }}
className="text-[11px] text-txt-tertiary hover:text-txt-secondary underline decoration-dotted transition-colors ml-1"
>
View redemptions
</button>
</>
)} )}
</div> </div>
</div> </div>
</div>
)}
</div>
{showEdit && ( {showEdit && (
<EditInviteModal <EditInviteModal
@@ -1247,6 +1300,15 @@ export function RegistrationPanel() {
const [invitesLoading, setInvitesLoading] = useState(false); const [invitesLoading, setInvitesLoading] = useState(false);
const [activeCount, setActiveCount] = useState<number>(0); const [activeCount, setActiveCount] = useState<number>(0);
const [archivedCount, setArchivedCount] = useState<number>(0); const [archivedCount, setArchivedCount] = useState<number>(0);
// Single-row expansion state — only one InviteRow at a time may be expanded.
// Lifted to the panel so switching tabs can reset it; otherwise an expanded row
// that scrolls out of the visible list keeps stale state.
const [expandedInviteId, setExpandedInviteId] = useState<string | null>(null);
const handleTabSwitch = (next: 'active' | 'archived') => {
setTab(next);
setExpandedInviteId(null);
};
// Tracks the currently displayed tab so in-flight fetches can detect when // Tracks the currently displayed tab so in-flight fetches can detect when
// the user has switched tabs and discard their stale response. Without this // the user has switched tabs and discard their stale response. Without this
@@ -1408,7 +1470,7 @@ export function RegistrationPanel() {
<div className="flex bg-white/[0.04] rounded-md p-0.5"> <div className="flex bg-white/[0.04] rounded-md p-0.5">
<button <button
type="button" type="button"
onClick={() => setTab('active')} onClick={() => handleTabSwitch('active')}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${ className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
tab === 'active' ? 'bg-white/[0.08] text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary' tab === 'active' ? 'bg-white/[0.08] text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`} }`}
@@ -1417,7 +1479,7 @@ export function RegistrationPanel() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => setTab('archived')} onClick={() => handleTabSwitch('archived')}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${ className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
tab === 'archived' ? 'bg-white/[0.08] text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary' tab === 'archived' ? 'bg-white/[0.08] text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`} }`}
@@ -1437,7 +1499,15 @@ export function RegistrationPanel() {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{invites.map((inv) => ( {invites.map((inv) => (
<InviteRow key={inv.id} invite={inv} onMutate={() => { fetchInvites(tab); refreshCounts(); }} /> <InviteRow
key={inv.id}
invite={inv}
expanded={expandedInviteId === inv.id}
onToggleExpand={() =>
setExpandedInviteId(expandedInviteId === inv.id ? null : inv.id)
}
onMutate={() => { fetchInvites(tab); refreshCounts(); }}
/>
))} ))}
</div> </div>
)} )}