refactor members page, add pending filter tab

This commit is contained in:
Justin Edmund 2025-12-16 14:45:49 -08:00
parent 5fb2331958
commit c875f3cefb
2 changed files with 156 additions and 389 deletions

View file

@ -10,7 +10,7 @@ export type CrewRole = 'member' | 'vice_captain' | 'captain'
export type InvitationStatus = 'pending' | 'accepted' | 'rejected' | 'expired' export type InvitationStatus = 'pending' | 'accepted' | 'rejected' | 'expired'
// Member filter options for GET /crew/members // Member filter options for GET /crew/members
export type MemberFilter = 'active' | 'retired' | 'phantom' | 'all' export type MemberFilter = 'active' | 'retired' | 'phantom' | 'pending' | 'all'
// Crew from CrewBlueprint // Crew from CrewBlueprint
export interface Crew { export interface Crew {

View file

@ -21,10 +21,19 @@
import SettingsRow from '$lib/components/ui/SettingsRow.svelte' import SettingsRow from '$lib/components/ui/SettingsRow.svelte'
import Switch from '$lib/components/ui/switch/Switch.svelte' import Switch from '$lib/components/ui/switch/Switch.svelte'
import CrewHeader from '$lib/components/crew/CrewHeader.svelte' import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
import MemberRow from '$lib/components/crew/MemberRow.svelte'
import PhantomRow from '$lib/components/crew/PhantomRow.svelte'
import ScoutUserModal from '$lib/components/crew/ScoutUserModal.svelte' import ScoutUserModal from '$lib/components/crew/ScoutUserModal.svelte'
import BulkPhantomModal from '$lib/components/crew/BulkPhantomModal.svelte' import BulkPhantomModal from '$lib/components/crew/BulkPhantomModal.svelte'
import AssignPhantomModal from '$lib/components/crew/AssignPhantomModal.svelte'
import ConfirmClaimModal from '$lib/components/crew/ConfirmClaimModal.svelte'
import { DropdownMenu as DropdownMenuBase } from 'bits-ui' import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
import type { MemberFilter, CrewMembership, PhantomPlayer, CrewInvitation } from '$lib/types/api/crew' import type {
MemberFilter,
CrewMembership,
PhantomPlayer,
CrewInvitation
} from '$lib/types/api/crew'
import type { PageData } from './$types' import type { PageData } from './$types'
interface Props { interface Props {
@ -59,13 +68,9 @@
const activeRosterSize = $derived.by(() => { const activeRosterSize = $derived.by(() => {
// Use active filter data if viewing active, otherwise use dedicated query // Use active filter data if viewing active, otherwise use dedicated query
const activeMembers = const activeMembers =
filter === 'active' filter === 'active' ? (membersQuery.data?.members ?? []) : (activeQuery.data?.members ?? [])
? (membersQuery.data?.members ?? [])
: (activeQuery.data?.members ?? [])
const activePhantoms = const activePhantoms =
filter === 'active' filter === 'active' ? (membersQuery.data?.phantoms ?? []) : (activeQuery.data?.phantoms ?? [])
? (membersQuery.data?.phantoms ?? [])
: (activeQuery.data?.phantoms ?? [])
const activeMemberCount = activeMembers.filter((m) => !m.retired).length const activeMemberCount = activeMembers.filter((m) => !m.retired).length
const activePhantomCount = activePhantoms.filter((p) => !p.retired).length const activePhantomCount = activePhantoms.filter((p) => !p.retired).length
@ -79,13 +84,19 @@
const updateMembershipMutation = useUpdateMembership() const updateMembershipMutation = useUpdateMembership()
const deletePhantomMutation = useDeletePhantom() const deletePhantomMutation = useDeletePhantom()
// Filter options // Filter options - Pending only shown to officers
const filterOptions: { value: MemberFilter; label: string }[] = [ const filterOptions = $derived.by(() => {
{ value: 'all', label: 'All' }, const options: { value: MemberFilter; label: string }[] = [
{ value: 'active', label: 'Active' }, { value: 'all', label: 'All' },
{ value: 'phantom', label: 'Phantoms' }, { value: 'active', label: 'Active' },
{ value: 'retired', label: 'Retired' } { value: 'phantom', label: 'Phantoms' }
] ]
if (crewStore.isOfficer) {
options.push({ value: 'pending', label: 'Pending' })
}
options.push({ value: 'retired', label: 'Retired' })
return options
})
// Change filter // Change filter
function handleFilterChange(newFilter: MemberFilter) { function handleFilterChange(newFilter: MemberFilter) {
@ -113,9 +124,6 @@
// Dialog state for scout modal // Dialog state for scout modal
let scoutModalOpen = $state(false) let scoutModalOpen = $state(false)
// Pending invitations section visibility
let invitationsSectionOpen = $state(true)
// Dialog state for phantom creation // Dialog state for phantom creation
let bulkPhantomDialogOpen = $state(false) let bulkPhantomDialogOpen = $state(false)
@ -123,28 +131,13 @@
let deletePhantomDialogOpen = $state(false) let deletePhantomDialogOpen = $state(false)
let phantomToDelete = $state<PhantomPlayer | null>(null) let phantomToDelete = $state<PhantomPlayer | null>(null)
// Role display helpers // Dialog state for phantom assignment
function getRoleLabel(role: string): string { let assignPhantomDialogOpen = $state(false)
switch (role) { let phantomToAssign = $state<PhantomPlayer | null>(null)
case 'captain':
return 'Captain'
case 'vice_captain':
return 'Vice Captain'
default:
return 'Member'
}
}
function getRoleClass(role: string): string { // Dialog state for confirm claim
switch (role) { let confirmClaimDialogOpen = $state(false)
case 'captain': let phantomToClaim = $state<PhantomPlayer | null>(null)
return 'captain'
case 'vice_captain':
return 'officer'
default:
return ''
}
}
// Member actions // Member actions
function openRemoveDialog(member: CrewMembership) { function openRemoveDialog(member: CrewMembership) {
@ -201,7 +194,7 @@
editingMember = member editingMember = member
editingPhantom = null editingPhantom = null
// Format date for input // Format date for input
editJoinDate = member.joinedAt ? member.joinedAt.split('T')[0] ?? '' : '' editJoinDate = member.joinedAt ? (member.joinedAt.split('T')[0] ?? '') : ''
editRetired = member.retired editRetired = member.retired
editDialogOpen = true editDialogOpen = true
} }
@ -209,7 +202,7 @@
function openEditPhantomDialog(phantom: PhantomPlayer) { function openEditPhantomDialog(phantom: PhantomPlayer) {
editingPhantom = phantom editingPhantom = phantom
editingMember = null editingMember = null
editJoinDate = phantom.joinedAt ? phantom.joinedAt.split('T')[0] ?? '' : '' editJoinDate = phantom.joinedAt ? (phantom.joinedAt.split('T')[0] ?? '') : ''
editRetired = phantom.retired editRetired = phantom.retired
editDialogOpen = true editDialogOpen = true
} }
@ -267,6 +260,18 @@
phantomToDelete = null phantomToDelete = null
} }
// Phantom assignment
function openAssignPhantomDialog(phantom: PhantomPlayer) {
phantomToAssign = phantom
assignPhantomDialogOpen = true
}
// Confirm claim
function openConfirmClaimDialog(phantom: PhantomPlayer) {
phantomToClaim = phantom
confirmClaimDialogOpen = true
}
// Format date // Format date
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, { return new Date(dateString).toLocaleDateString(undefined, {
@ -285,6 +290,11 @@
const pendingInvitationsCount = $derived( const pendingInvitationsCount = $derived(
invitationsQuery.data?.filter((inv) => !isInvitationExpired(inv.expiresAt)).length ?? 0 invitationsQuery.data?.filter((inv) => !isInvitationExpired(inv.expiresAt)).length ?? 0
) )
// Get phantoms with pending claims (assigned but not confirmed)
const pendingClaimPhantoms = $derived(
membersQuery.data?.phantoms?.filter((p) => p.claimedBy && !p.claimConfirmed) ?? []
)
</script> </script>
<svelte:head> <svelte:head>
@ -293,7 +303,7 @@
<div class="page"> <div class="page">
<div class="card"> <div class="card">
<CrewHeader title="Members"> <CrewHeader title="Members" backHref="/crew">
{#snippet belowTitle()} {#snippet belowTitle()}
<div class="filter-tabs"> <div class="filter-tabs">
{#each filterOptions as option} {#each filterOptions as option}
@ -334,50 +344,41 @@
{/snippet} {/snippet}
</CrewHeader> </CrewHeader>
<!-- Pending Invitations Section (officers only) --> <!-- Pending Invitations (shown when filter is 'pending') -->
{#if crewStore.isOfficer && invitationsQuery.data && invitationsQuery.data.length > 0} {#if filter === 'pending'}
<div class="invitations-section"> {#if invitationsQuery.isLoading}
<button <div class="loading-state">
class="invitations-header" <p>Loading...</p>
onclick={() => (invitationsSectionOpen = !invitationsSectionOpen)} </div>
> {:else if invitationsQuery.data && invitationsQuery.data.length > 0}
<span class="invitations-title"> <ul class="member-list">
Pending Invitations {#each invitationsQuery.data as invitation}
{#if pendingInvitationsCount > 0} {@const expired = isInvitationExpired(invitation.expiresAt)}
<span class="invitations-count">{pendingInvitationsCount}</span> <li class="invitation-row" class:expired>
{/if} <div class="invitation-info">
</span> <span class="invited-user">{invitation.user?.username ?? 'Unknown'}</span>
<span class="toggle-icon" class:open={invitationsSectionOpen}>▼</span> {#if invitation.invitedBy}
</button> <span class="invited-by">
Invited by {invitation.invitedBy.username}
{#if invitationsSectionOpen} </span>
<ul class="invitations-list"> {/if}
{#each invitationsQuery.data as invitation} </div>
{@const expired = isInvitationExpired(invitation.expiresAt)} <div class="invitation-status">
<li class="invitation-item" class:expired> {#if expired}
<div class="invitation-info"> <span class="status-badge expired">Expired</span>
<span class="invited-user">{invitation.user?.username ?? 'Unknown'}</span> {:else}
{#if invitation.invitedBy} <span class="expires-text">Expires {formatDate(invitation.expiresAt)}</span>
<span class="invited-by"> {/if}
Invited by {invitation.invitedBy.username} </div>
</span> </li>
{/if} {/each}
</div> </ul>
<div class="invitation-status"> {:else}
{#if expired} <div class="empty-state">
<span class="status-badge expired">Expired</span> <p>No pending invitations.</p>
{:else} </div>
<span class="expires-text">Expires {formatDate(invitation.expiresAt)}</span> {/if}
{/if} {:else if membersQuery.isLoading}
</div>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
{#if membersQuery.isLoading}
<div class="loading-state"> <div class="loading-state">
<p>Loading...</p> <p>Loading...</p>
</div> </div>
@ -390,62 +391,38 @@
{#if membersQuery.data?.members && membersQuery.data.members.length > 0} {#if membersQuery.data?.members && membersQuery.data.members.length > 0}
<ul class="member-list"> <ul class="member-list">
{#each membersQuery.data.members as member} {#each membersQuery.data.members as member}
<li class="member-item" class:retired={member.retired}> <MemberRow
<div class="member-info"> {member}
<span class="username">{member.user?.username ?? 'Unknown'}</span> onEdit={() => openEditMemberDialog(member)}
<span class="role-badge {getRoleClass(member.role)}"> onPromote={() => openPromoteDialog(member)}
{getRoleLabel(member.role)} onDemote={() => openDemoteDialog(member)}
</span> onRemove={() => openRemoveDialog(member)}
{#if member.joinedAt} />
<span class="joined-date">Joined {formatDate(member.joinedAt)}</span>
{/if}
</div>
{#if crewStore.isOfficer}
<DropdownMenu>
{#snippet trigger({ props })}
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
{/snippet}
{#snippet menu()}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openEditMemberDialog(member)}
>
Edit
</DropdownMenuBase.Item>
{#if crewStore.canActOnMember(member.role) && !member.retired && member.id !== crewStore.membership?.id}
{#if member.role === 'member' && crewStore.canPromoteTo('vice_captain')}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openPromoteDialog(member)}
>
Promote
</DropdownMenuBase.Item>
{/if}
{#if member.role === 'vice_captain' && crewStore.canDemote('vice_captain')}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openDemoteDialog(member)}
>
Demote
</DropdownMenuBase.Item>
{/if}
<DropdownMenuBase.Item
class="dropdown-menu-item danger"
onclick={() => openRemoveDialog(member)}
>
Remove
</DropdownMenuBase.Item>
{/if}
{/snippet}
</DropdownMenu>
{/if}
</li>
{/each} {/each}
</ul> </ul>
{:else if filter !== 'phantom'} {:else if filter !== 'phantom'}
<p class="empty-state">No members found</p> <p class="empty-state">No members found</p>
{/if} {/if}
<!-- Pending Claims Section (officers only) -->
{#if crewStore.isOfficer && pendingClaimPhantoms.length > 0 && (filter === 'all' || filter === 'phantom')}
<div class="section-divider pending-claims">
<span>Pending Claims ({pendingClaimPhantoms.length})</span>
</div>
<ul class="member-list">
{#each pendingClaimPhantoms as phantom}
<PhantomRow
{phantom}
currentUserId={crewStore.membership?.user?.id}
onEdit={() => openEditPhantomDialog(phantom)}
onDelete={() => openDeletePhantomDialog(phantom)}
onAssign={() => openAssignPhantomDialog(phantom)}
onConfirmClaim={() => openConfirmClaimDialog(phantom)}
/>
{/each}
</ul>
{/if}
<!-- Phantom players --> <!-- Phantom players -->
{#if membersQuery.data?.phantoms && membersQuery.data.phantoms.length > 0} {#if membersQuery.data?.phantoms && membersQuery.data.phantoms.length > 0}
{#if filter === 'all' && membersQuery.data.members.length > 0} {#if filter === 'all' && membersQuery.data.members.length > 0}
@ -455,51 +432,14 @@
{/if} {/if}
<ul class="member-list"> <ul class="member-list">
{#each membersQuery.data.phantoms as phantom} {#each membersQuery.data.phantoms as phantom}
<li class="member-item" class:retired={phantom.retired}> <PhantomRow
<div class="member-info"> {phantom}
<div class="phantom-details"> currentUserId={crewStore.membership?.user?.id}
<span class="username">{phantom.name}</span> onEdit={() => openEditPhantomDialog(phantom)}
{#if phantom.granblueId} onDelete={() => openDeletePhantomDialog(phantom)}
<span class="granblue-id">ID: {phantom.granblueId}</span> onAssign={() => openAssignPhantomDialog(phantom)}
{/if} onConfirmClaim={() => openConfirmClaimDialog(phantom)}
{#if phantom.joinedAt} />
<span class="joined-date">Joined {formatDate(phantom.joinedAt)}</span>
{/if}
</div>
{#if phantom.claimConfirmed && phantom.claimedBy}
<span class="status-badge claimed">
Claimed by {phantom.claimedBy.username}
</span>
{:else if phantom.claimedBy}
<span class="status-badge pending">
Pending: {phantom.claimedBy.username}
</span>
{:else}
<span class="status-badge unclaimed">Unclaimed</span>
{/if}
</div>
{#if crewStore.isOfficer}
<DropdownMenu>
{#snippet trigger({ props })}
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
{/snippet}
{#snippet menu()}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openEditPhantomDialog(phantom)}
>
Edit
</DropdownMenuBase.Item>
<DropdownMenuBase.Item
class="dropdown-menu-item danger"
onclick={() => openDeletePhantomDialog(phantom)}
>
Delete
</DropdownMenuBase.Item>
{/snippet}
</DropdownMenu>
{/if}
</li>
{/each} {/each}
</ul> </ul>
{:else if filter === 'phantom'} {:else if filter === 'phantom'}
@ -543,7 +483,12 @@
<ModalFooter <ModalFooter
onCancel={() => (confirmDialogOpen = false)} onCancel={() => (confirmDialogOpen = false)}
primaryAction={{ primaryAction={{
label: confirmAction === 'remove' ? 'Remove' : confirmAction === 'promote' ? 'Promote' : 'Demote', label:
confirmAction === 'remove'
? 'Remove'
: confirmAction === 'promote'
? 'Promote'
: 'Demote',
onclick: handleConfirmAction, onclick: handleConfirmAction,
destructive: confirmAction === 'remove' destructive: confirmAction === 'remove'
}} }}
@ -567,10 +512,7 @@
This date is used to determine which events a member was active for when adding This date is used to determine which events a member was active for when adding
historical GW scores. historical GW scores.
</p> </p>
<SettingsRow <SettingsRow title="Retired" subtitle="This player is no longer a part of the crew">
title="Retired"
subtitle="This player is no longer a part of the crew"
>
{#snippet control()} {#snippet control()}
<Switch bind:checked={editRetired} name="retired" /> <Switch bind:checked={editRetired} name="retired" />
{/snippet} {/snippet}
@ -617,6 +559,16 @@
{#if crewStore.crew?.id} {#if crewStore.crew?.id}
<ScoutUserModal bind:open={scoutModalOpen} crewId={crewStore.crew.id} /> <ScoutUserModal bind:open={scoutModalOpen} crewId={crewStore.crew.id} />
<BulkPhantomModal bind:open={bulkPhantomDialogOpen} crewId={crewStore.crew.id} /> <BulkPhantomModal bind:open={bulkPhantomDialogOpen} crewId={crewStore.crew.id} />
<AssignPhantomModal
bind:open={assignPhantomDialogOpen}
crewId={crewStore.crew.id}
phantom={phantomToAssign}
/>
<ConfirmClaimModal
bind:open={confirmClaimDialogOpen}
crewId={crewStore.crew.id}
phantom={phantomToClaim}
/>
{/if} {/if}
<style lang="scss"> <style lang="scss">
@ -627,7 +579,6 @@
@use '$src/themes/layout' as layout; @use '$src/themes/layout' as layout;
.page { .page {
padding: spacing.$unit-2x 0;
margin: 0 auto; margin: 0 auto;
max-width: var(--main-max-width); max-width: var(--main-max-width);
} }
@ -638,6 +589,10 @@
border-radius: layout.$page-corner; border-radius: layout.$page-corner;
box-shadow: effects.$page-elevation; box-shadow: effects.$page-elevation;
overflow: hidden; overflow: hidden;
:global(.header-info) {
gap: spacing.$unit-2x;
}
} }
.filter-tabs { .filter-tabs {
@ -700,86 +655,6 @@
padding: spacing.$unit; padding: spacing.$unit;
} }
.member-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner;
transition: background-color 0.15s;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
&.retired {
opacity: 0.6;
}
}
.member-info {
display: flex;
align-items: center;
gap: spacing.$unit;
}
.phantom-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.username {
font-size: typography.$font-small;
font-weight: typography.$medium;
}
.granblue-id {
font-size: typography.$font-small;
color: var(--text-secondary);
}
.role-badge {
display: inline-block;
padding: 2px 8px;
border-radius: layout.$item-corner-small;
font-size: typography.$font-small;
font-weight: typography.$medium;
background: rgba(0, 0, 0, 0.04);
&.captain {
background: var(--color-gold-light, #fef3c7);
color: var(--color-gold-dark, #92400e);
}
&.officer {
background: var(--color-blue-light, #dbeafe);
color: var(--color-blue-dark, #1e40af);
}
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: layout.$item-corner-small;
font-size: typography.$font-small;
&.unclaimed {
background: rgba(0, 0, 0, 0.04);
color: var(--text-secondary);
}
&.pending {
background: var(--color-yellow-light, #fef9c3);
color: var(--color-yellow-dark, #854d0e);
}
&.claimed {
background: var(--color-green-light, #dcfce7);
color: var(--color-green-dark, #166534);
}
}
.section-divider { .section-divider {
display: flex; display: flex;
align-items: center; align-items: center;
@ -787,6 +662,15 @@
background: rgba(0, 0, 0, 0.02); background: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.06); border-bottom: 1px solid rgba(0, 0, 0, 0.06);
&.pending-claims {
background: var(--color-yellow-light, #fef9c3);
border-bottom-color: var(--color-yellow-dark, #854d0e);
span {
color: var(--color-yellow-dark, #854d0e);
}
}
span { span {
font-size: typography.$font-small; font-size: typography.$font-small;
font-weight: typography.$medium; font-weight: typography.$medium;
@ -794,35 +678,6 @@
} }
} }
.action-buttons {
display: flex;
gap: spacing.$unit-half;
}
.action-btn {
padding: 4px 8px;
border: none;
border-radius: layout.$item-corner-small;
background: rgba(0, 0, 0, 0.04);
color: var(--text-secondary);
font-size: typography.$font-small;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(0, 0, 0, 0.08);
color: var(--text-primary);
}
&.danger {
color: colors.$error;
&:hover {
background: rgba(colors.$error, 0.1);
}
}
}
// Confirm dialog styles // Confirm dialog styles
.confirm-message { .confirm-message {
color: var(--text-primary); color: var(--text-primary);
@ -852,31 +707,6 @@
font-size: typography.$font-small; font-size: typography.$font-small;
font-weight: typography.$medium; font-weight: typography.$medium;
color: var(--text-primary); color: var(--text-primary);
.optional {
font-weight: typography.$normal;
color: var(--text-secondary);
}
}
textarea {
padding: spacing.$unit-2x;
border: none;
border-radius: layout.$input-corner;
font-size: typography.$font-regular;
font-family: inherit;
background: var(--input-bound-bg);
color: var(--text-primary);
resize: vertical;
min-height: 60px;
&:hover {
background: var(--input-bound-bg-hover);
}
&::placeholder {
color: var(--text-tertiary);
}
} }
} }
@ -886,11 +716,6 @@
margin: 0; margin: 0;
} }
.joined-date {
font-size: typography.$font-small;
color: var(--text-tertiary);
}
.date-input { .date-input {
padding: spacing.$unit spacing.$unit-2x; padding: spacing.$unit spacing.$unit-2x;
border: none; border: none;
@ -918,75 +743,17 @@
line-height: 1.4; line-height: 1.4;
} }
// Pending invitations section // Invitation row styles
.invitations-section { .invitation-row {
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.invitations-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; padding: spacing.$unit spacing.$unit spacing.$unit spacing.$unit-2x;
padding: spacing.$unit-2x spacing.$unit-3x; border-radius: layout.$item-corner;
background: rgba(0, 0, 0, 0.02);
border: none;
cursor: pointer;
transition: background-color 0.15s; transition: background-color 0.15s;
&:hover { &:hover {
background: rgba(0, 0, 0, 0.04); background: rgba(0, 0, 0, 0.03);
}
}
.invitations-title {
display: flex;
align-items: center;
gap: spacing.$unit;
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-secondary);
}
.invitations-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
background: colors.$error;
color: white;
border-radius: 9px;
font-size: 11px;
font-weight: typography.$medium;
}
.toggle-icon {
font-size: 10px;
color: var(--text-tertiary);
transition: transform 0.2s;
&.open {
transform: rotate(180deg);
}
}
.invitations-list {
list-style: none;
margin: 0;
padding: spacing.$unit;
}
.invitation-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner;
&:hover {
background: rgba(0, 0, 0, 0.02);
} }
&.expired { &.expired {