show phantom claims in notifications modal

- renamed Invitations modal to Notifications
- shows pending phantom assignments with accept/decline
- badge counts both invitations and phantom claims
- modal accessible to all users with pending notifications
This commit is contained in:
Justin Edmund 2025-12-17 18:29:52 -08:00
parent ef95a294b3
commit 907b4503dd
3 changed files with 245 additions and 86 deletions

View file

@ -151,8 +151,16 @@
enabled: isAuth
}))
// Derived count of pending invitations
// Query for pending phantom claims (only when authenticated and in a crew)
const pendingPhantomClaimsQuery = createQuery(() => ({
...crewQueries.pendingPhantomClaims(),
enabled: isAuth && crewStore.isInCrew
}))
// Derived counts
const pendingInvitationCount = $derived(pendingInvitationsQuery.data?.length ?? 0)
const pendingPhantomClaimCount = $derived(pendingPhantomClaimsQuery.data?.length ?? 0)
const totalNotificationCount = $derived(pendingInvitationCount + pendingPhantomClaimCount)
// Handle logout
async function handleLogout() {
@ -267,9 +275,9 @@
height="24"
/>
{/if}
{#if pendingInvitationCount > 0}
{#if totalNotificationCount > 0}
<span class="avatar-badge">
<NotificationBadge count={pendingInvitationCount} />
<NotificationBadge count={totalNotificationCount} />
</span>
{/if}
</span>
@ -309,13 +317,11 @@
</DropdownItem>
<DropdownMenu.Separator class="dropdown-separator" />
{/if}
{#if isAuth && !crewStore.isInCrew}
{#if isAuth && totalNotificationCount > 0}
<DropdownItem>
<button class="dropdown-button-with-badge" onclick={() => (invitationsModalOpen = true)}>
<span>Invitations</span>
{#if pendingInvitationCount > 0}
<NotificationBadge count={pendingInvitationCount} showCount />
{/if}
<span>Notifications</span>
<NotificationBadge count={totalNotificationCount} showCount />
</button>
</DropdownItem>
{/if}
@ -385,12 +391,13 @@
/>
{/if}
<!-- Invitations Modal -->
<!-- Notifications Modal (invitations + phantom claims) -->
{#if isAuth}
<InvitationsModal
bind:open={invitationsModalOpen}
invitations={pendingInvitationsQuery.data ?? []}
isLoading={pendingInvitationsQuery.isLoading}
phantomClaims={pendingPhantomClaimsQuery.data ?? []}
isLoading={pendingInvitationsQuery.isLoading || pendingPhantomClaimsQuery.isLoading}
/>
{/if}

View file

@ -2,31 +2,46 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { useAcceptInvitation, useRejectInvitation } from '$lib/api/mutations/crew.mutations'
import {
useAcceptInvitation,
useRejectInvitation,
useConfirmPhantomClaim,
useDeclinePhantomClaim
} from '$lib/api/mutations/crew.mutations'
import Dialog from '$lib/components/ui/Dialog.svelte'
import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
import ModalBody from '$lib/components/ui/ModalBody.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
import type { CrewInvitation } from '$lib/types/api/crew'
import type { CrewInvitation, PhantomPlayer } from '$lib/types/api/crew'
interface Props {
open: boolean
invitations: CrewInvitation[]
phantomClaims: PhantomPlayer[]
isLoading?: boolean
}
let { open = $bindable(false), invitations, isLoading = false }: Props = $props()
let { open = $bindable(false), invitations, phantomClaims, isLoading = false }: Props = $props()
// Mutations
// Mutations for crew invitations
const acceptMutation = useAcceptInvitation()
const rejectMutation = useRejectInvitation()
// Track which invitation is being processed
// Mutations for phantom claims
const confirmClaimMutation = useConfirmPhantomClaim()
const declineClaimMutation = useDeclinePhantomClaim()
// Track which item is being processed
let processingId = $state<string | null>(null)
// Derived state
const hasInvitations = $derived(invitations.length > 0)
const hasPhantomClaims = $derived(phantomClaims.length > 0)
const hasNotifications = $derived(hasInvitations || hasPhantomClaims)
// Accept invitation
async function handleAccept(invitationId: string) {
async function handleAcceptInvitation(invitationId: string) {
processingId = invitationId
try {
await acceptMutation.mutateAsync(invitationId)
@ -40,7 +55,7 @@
}
// Reject invitation
async function handleReject(invitationId: string) {
async function handleRejectInvitation(invitationId: string) {
processingId = invitationId
try {
await rejectMutation.mutateAsync(invitationId)
@ -51,6 +66,38 @@
}
}
// Accept phantom claim
async function handleAcceptPhantomClaim(phantom: PhantomPlayer) {
if (!phantom.crew) return
processingId = phantom.id
try {
await confirmClaimMutation.mutateAsync({
crewId: phantom.crew.id,
phantomId: phantom.id
})
processingId = null
} catch (error) {
console.error('Failed to accept phantom claim:', error)
processingId = null
}
}
// Decline phantom claim
async function handleDeclinePhantomClaim(phantom: PhantomPlayer) {
if (!phantom.crew) return
processingId = phantom.id
try {
await declineClaimMutation.mutateAsync({
crewId: phantom.crew.id,
phantomId: phantom.id
})
processingId = null
} catch (error) {
console.error('Failed to decline phantom claim:', error)
processingId = null
}
}
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, {
@ -67,78 +114,139 @@
</script>
<Dialog bind:open>
<ModalHeader title="Crew Invitations" description="Accept or decline pending invitations" />
<ModalHeader title="Notifications" description="Pending actions for your account" />
<ModalBody>
{#if isLoading}
<div class="loading-state">
<Icon name="loader-2" size={24} />
<p>Loading invitations...</p>
<p>Loading notifications...</p>
</div>
{:else if invitations.length === 0}
{:else if !hasNotifications}
<div class="empty-state">
<Icon name="mail" size={32} />
<p>No pending invitations</p>
<p class="hint">You'll see crew invitations here when you receive them.</p>
<Icon name="bell" size={32} />
<p>No pending notifications</p>
<p class="hint">You'll see crew invitations and phantom assignments here.</p>
</div>
{:else}
<div class="invitations-list">
{#each invitations as invitation}
{@const expired = isExpired(invitation.expiresAt)}
{@const crew = invitation.crew}
{@const invitedBy = invitation.invitedBy}
{@const isProcessing = processingId === invitation.id}
<!-- Phantom Claims Section -->
{#if hasPhantomClaims}
<div class="section">
<h3 class="section-title">Phantom Assignments</h3>
<p class="section-description">
Accept to inherit the phantom's GW scores and join date
</p>
<div class="notifications-list">
{#each phantomClaims as phantom}
{@const crew = phantom.crew}
{@const isProcessing = processingId === phantom.id}
{#if crew}
<div class="invitation-card" class:expired>
<div class="invitation-content">
<div class="crew-info">
<div class="crew-name-row">
<span class="crew-name">{crew.name}</span>
{#if crew.gamertag}
<span class="gamertag">[{crew.gamertag}]</span>
{/if}
{#if crew}
<div class="notification-card">
<div class="notification-content">
<div class="notification-info">
<div class="notification-title-row">
<span class="notification-title">{phantom.name}</span>
</div>
<span class="notification-subtitle">
From crew {crew.name}{crew.gamertag ? ` [${crew.gamertag}]` : ''}
</span>
{#if phantom.joinedAt}
<span class="notification-meta">
Joined {formatDate(phantom.joinedAt)}
</span>
{/if}
</div>
</div>
{#if invitedBy}
<span class="invited-by">
Invited by {invitedBy.username}
</span>
{/if}
</div>
{#if expired}
<div class="expired-badge">Expired</div>
{:else}
<div class="expires-info">
Expires {formatDate(invitation.expiresAt)}
<div class="notification-actions">
<Button
variant="secondary"
size="small"
onclick={() => handleDeclinePhantomClaim(phantom)}
disabled={isProcessing}
>
{isProcessing && declineClaimMutation.isPending ? 'Declining...' : 'Decline'}
</Button>
<Button
variant="primary"
size="small"
onclick={() => handleAcceptPhantomClaim(phantom)}
disabled={isProcessing}
>
{isProcessing && confirmClaimMutation.isPending ? 'Accepting...' : 'Accept'}
</Button>
</div>
{/if}
</div>
{#if !expired}
<div class="invitation-actions">
<Button
variant="secondary"
size="small"
onclick={() => handleReject(invitation.id)}
disabled={isProcessing}
>
{isProcessing && rejectMutation.isPending ? 'Declining...' : 'Decline'}
</Button>
<Button
variant="primary"
size="small"
onclick={() => handleAccept(invitation.id)}
disabled={isProcessing}
>
{isProcessing && acceptMutation.isPending ? 'Joining...' : 'Accept'}
</Button>
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/each}
</div>
</div>
{/if}
<!-- Crew Invitations Section -->
{#if hasInvitations}
<div class="section">
<h3 class="section-title">Crew Invitations</h3>
<div class="notifications-list">
{#each invitations as invitation}
{@const expired = isExpired(invitation.expiresAt)}
{@const crew = invitation.crew}
{@const invitedBy = invitation.invitedBy}
{@const isProcessing = processingId === invitation.id}
{#if crew}
<div class="notification-card" class:expired>
<div class="notification-content">
<div class="notification-info">
<div class="notification-title-row">
<span class="notification-title">{crew.name}</span>
{#if crew.gamertag}
<span class="gamertag">[{crew.gamertag}]</span>
{/if}
</div>
{#if invitedBy}
<span class="notification-subtitle">
Invited by {invitedBy.username}
</span>
{/if}
</div>
{#if expired}
<div class="expired-badge">Expired</div>
{:else}
<div class="expires-info">
Expires {formatDate(invitation.expiresAt)}
</div>
{/if}
</div>
{#if !expired}
<div class="notification-actions">
<Button
variant="secondary"
size="small"
onclick={() => handleRejectInvitation(invitation.id)}
disabled={isProcessing}
>
{isProcessing && rejectMutation.isPending ? 'Declining...' : 'Decline'}
</Button>
<Button
variant="primary"
size="small"
onclick={() => handleAcceptInvitation(invitation.id)}
disabled={isProcessing}
>
{isProcessing && acceptMutation.isPending ? 'Joining...' : 'Accept'}
</Button>
</div>
{/if}
</div>
{/if}
{/each}
</div>
</div>
{/if}
{/if}
</ModalBody>
</Dialog>
@ -190,13 +298,34 @@
}
}
.invitations-list {
.section {
&:not(:first-child) {
margin-top: spacing.$unit-3x;
padding-top: spacing.$unit-3x;
border-top: 1px solid var(--border-color);
}
}
.section-title {
margin: 0 0 spacing.$unit-half 0;
font-size: typography.$font-regular;
font-weight: typography.$medium;
color: var(--text-primary);
}
.section-description {
margin: 0 0 spacing.$unit-2x 0;
font-size: typography.$font-small;
color: var(--text-secondary);
}
.notifications-list {
display: flex;
flex-direction: column;
gap: spacing.$unit;
}
.invitation-card {
.notification-card {
background: var(--surface-secondary, #f9fafb);
border: 1px solid var(--border-color);
border-radius: 8px;
@ -207,7 +336,7 @@
}
}
.invitation-content {
.notification-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
@ -215,19 +344,19 @@
margin-bottom: spacing.$unit;
}
.crew-info {
.notification-info {
display: flex;
flex-direction: column;
gap: spacing.$unit-quarter;
}
.crew-name-row {
.notification-title-row {
display: flex;
align-items: baseline;
gap: spacing.$unit-half;
}
.crew-name {
.notification-title {
font-weight: typography.$medium;
color: var(--text-primary);
}
@ -237,11 +366,16 @@
font-size: typography.$font-small;
}
.invited-by {
.notification-subtitle {
font-size: typography.$font-small;
color: var(--text-secondary);
}
.notification-meta {
font-size: typography.$font-tiny;
color: var(--text-tertiary);
}
.expires-info {
font-size: typography.$font-tiny;
color: var(--text-secondary);
@ -257,7 +391,7 @@
font-weight: typography.$medium;
}
.invitation-actions {
.notification-actions {
display: flex;
justify-content: flex-end;
gap: spacing.$unit;

View file

@ -8,7 +8,8 @@
import {
useRemoveMember,
useUpdateMembership,
useDeletePhantom
useDeletePhantom,
useDeclinePhantomClaim
} from '$lib/api/mutations/crew.mutations'
import { crewAdapter } from '$lib/api/adapters/crew.adapter'
import { crewStore } from '$lib/stores/crew.store.svelte'
@ -83,6 +84,7 @@
const removeMemberMutation = useRemoveMember()
const updateMembershipMutation = useUpdateMembership()
const deletePhantomMutation = useDeletePhantom()
const declinePhantomClaimMutation = useDeclinePhantomClaim()
// Filter options - Pending only shown to officers
const filterOptions = $derived.by(() => {
@ -266,12 +268,26 @@
assignPhantomDialogOpen = true
}
// Confirm claim
// Confirm claim (opens modal)
function openConfirmClaimDialog(phantom: PhantomPlayer) {
phantomToClaim = phantom
confirmClaimDialogOpen = true
}
// Decline claim (direct action, no confirmation needed)
async function handleDeclineClaim(phantom: PhantomPlayer) {
if (!crewStore.crew) return
try {
await declinePhantomClaimMutation.mutateAsync({
crewId: crewStore.crew.id,
phantomId: phantom.id
})
} catch (error) {
console.error('Failed to decline phantom claim:', error)
}
}
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, {
@ -417,7 +433,8 @@
onEdit={() => openEditPhantomDialog(phantom)}
onDelete={() => openDeletePhantomDialog(phantom)}
onAssign={() => openAssignPhantomDialog(phantom)}
onConfirmClaim={() => openConfirmClaimDialog(phantom)}
onAccept={() => openConfirmClaimDialog(phantom)}
onDecline={() => handleDeclineClaim(phantom)}
/>
{/each}
</ul>
@ -438,7 +455,8 @@
onEdit={() => openEditPhantomDialog(phantom)}
onDelete={() => openDeletePhantomDialog(phantom)}
onAssign={() => openAssignPhantomDialog(phantom)}
onConfirmClaim={() => openConfirmClaimDialog(phantom)}
onAccept={() => openConfirmClaimDialog(phantom)}
onDecline={() => handleDeclineClaim(phantom)}
/>
{/each}
</ul>