refactor(web): RegistrationPanel InviteRow → collapsed/expanded model
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user