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 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 pendingInvitationCount = $derived(pendingInvitationsQuery.data?.length ?? 0)
const pendingPhantomClaimCount = $derived(pendingPhantomClaimsQuery.data?.length ?? 0)
const totalNotificationCount = $derived(pendingInvitationCount + pendingPhantomClaimCount)
// Handle logout // Handle logout
async function handleLogout() { async function handleLogout() {
@ -267,9 +275,9 @@
height="24" height="24"
/> />
{/if} {/if}
{#if pendingInvitationCount > 0} {#if totalNotificationCount > 0}
<span class="avatar-badge"> <span class="avatar-badge">
<NotificationBadge count={pendingInvitationCount} /> <NotificationBadge count={totalNotificationCount} />
</span> </span>
{/if} {/if}
</span> </span>
@ -309,13 +317,11 @@
</DropdownItem> </DropdownItem>
<DropdownMenu.Separator class="dropdown-separator" /> <DropdownMenu.Separator class="dropdown-separator" />
{/if} {/if}
{#if isAuth && !crewStore.isInCrew} {#if isAuth && totalNotificationCount > 0}
<DropdownItem> <DropdownItem>
<button class="dropdown-button-with-badge" onclick={() => (invitationsModalOpen = true)}> <button class="dropdown-button-with-badge" onclick={() => (invitationsModalOpen = true)}>
<span>Invitations</span> <span>Notifications</span>
{#if pendingInvitationCount > 0} <NotificationBadge count={totalNotificationCount} showCount />
<NotificationBadge count={pendingInvitationCount} showCount />
{/if}
</button> </button>
</DropdownItem> </DropdownItem>
{/if} {/if}
@ -385,12 +391,13 @@
/> />
{/if} {/if}
<!-- Invitations Modal --> <!-- Notifications Modal (invitations + phantom claims) -->
{#if isAuth} {#if isAuth}
<InvitationsModal <InvitationsModal
bind:open={invitationsModalOpen} bind:open={invitationsModalOpen}
invitations={pendingInvitationsQuery.data ?? []} invitations={pendingInvitationsQuery.data ?? []}
isLoading={pendingInvitationsQuery.isLoading} phantomClaims={pendingPhantomClaimsQuery.data ?? []}
isLoading={pendingInvitationsQuery.isLoading || pendingPhantomClaimsQuery.isLoading}
/> />
{/if} {/if}

View file

@ -2,31 +2,46 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation' 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 Dialog from '$lib/components/ui/Dialog.svelte'
import ModalHeader from '$lib/components/ui/ModalHeader.svelte' import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
import ModalBody from '$lib/components/ui/ModalBody.svelte' import ModalBody from '$lib/components/ui/ModalBody.svelte'
import Button from '$lib/components/ui/Button.svelte' import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.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 { interface Props {
open: boolean open: boolean
invitations: CrewInvitation[] invitations: CrewInvitation[]
phantomClaims: PhantomPlayer[]
isLoading?: boolean 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 acceptMutation = useAcceptInvitation()
const rejectMutation = useRejectInvitation() 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) 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 // Accept invitation
async function handleAccept(invitationId: string) { async function handleAcceptInvitation(invitationId: string) {
processingId = invitationId processingId = invitationId
try { try {
await acceptMutation.mutateAsync(invitationId) await acceptMutation.mutateAsync(invitationId)
@ -40,7 +55,7 @@
} }
// Reject invitation // Reject invitation
async function handleReject(invitationId: string) { async function handleRejectInvitation(invitationId: string) {
processingId = invitationId processingId = invitationId
try { try {
await rejectMutation.mutateAsync(invitationId) 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 // Format date
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, { return new Date(dateString).toLocaleDateString(undefined, {
@ -67,78 +114,139 @@
</script> </script>
<Dialog bind:open> <Dialog bind:open>
<ModalHeader title="Crew Invitations" description="Accept or decline pending invitations" /> <ModalHeader title="Notifications" description="Pending actions for your account" />
<ModalBody> <ModalBody>
{#if isLoading} {#if isLoading}
<div class="loading-state"> <div class="loading-state">
<Icon name="loader-2" size={24} /> <Icon name="loader-2" size={24} />
<p>Loading invitations...</p> <p>Loading notifications...</p>
</div> </div>
{:else if invitations.length === 0} {:else if !hasNotifications}
<div class="empty-state"> <div class="empty-state">
<Icon name="mail" size={32} /> <Icon name="bell" size={32} />
<p>No pending invitations</p> <p>No pending notifications</p>
<p class="hint">You'll see crew invitations here when you receive them.</p> <p class="hint">You'll see crew invitations and phantom assignments here.</p>
</div> </div>
{:else} {:else}
<div class="invitations-list"> <!-- Phantom Claims Section -->
{#each invitations as invitation} {#if hasPhantomClaims}
{@const expired = isExpired(invitation.expiresAt)} <div class="section">
{@const crew = invitation.crew} <h3 class="section-title">Phantom Assignments</h3>
{@const invitedBy = invitation.invitedBy} <p class="section-description">
{@const isProcessing = processingId === invitation.id} 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} {#if crew}
<div class="invitation-card" class:expired> <div class="notification-card">
<div class="invitation-content"> <div class="notification-content">
<div class="crew-info"> <div class="notification-info">
<div class="crew-name-row"> <div class="notification-title-row">
<span class="crew-name">{crew.name}</span> <span class="notification-title">{phantom.name}</span>
{#if crew.gamertag} </div>
<span class="gamertag">[{crew.gamertag}]</span> <span class="notification-subtitle">
{/if} From crew {crew.name}{crew.gamertag ? ` [${crew.gamertag}]` : ''}
</span>
{#if phantom.joinedAt}
<span class="notification-meta">
Joined {formatDate(phantom.joinedAt)}
</span>
{/if}
</div>
</div> </div>
{#if invitedBy}
<span class="invited-by">
Invited by {invitedBy.username}
</span>
{/if}
</div>
{#if expired} <div class="notification-actions">
<div class="expired-badge">Expired</div> <Button
{:else} variant="secondary"
<div class="expires-info"> size="small"
Expires {formatDate(invitation.expiresAt)} 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> </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> </div>
{/if} {/if}
</div> {/each}
{/if} </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} {/if}
</ModalBody> </ModalBody>
</Dialog> </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; display: flex;
flex-direction: column; flex-direction: column;
gap: spacing.$unit; gap: spacing.$unit;
} }
.invitation-card { .notification-card {
background: var(--surface-secondary, #f9fafb); background: var(--surface-secondary, #f9fafb);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
@ -207,7 +336,7 @@
} }
} }
.invitation-content { .notification-content {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
@ -215,19 +344,19 @@
margin-bottom: spacing.$unit; margin-bottom: spacing.$unit;
} }
.crew-info { .notification-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: spacing.$unit-quarter; gap: spacing.$unit-quarter;
} }
.crew-name-row { .notification-title-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: spacing.$unit-half; gap: spacing.$unit-half;
} }
.crew-name { .notification-title {
font-weight: typography.$medium; font-weight: typography.$medium;
color: var(--text-primary); color: var(--text-primary);
} }
@ -237,11 +366,16 @@
font-size: typography.$font-small; font-size: typography.$font-small;
} }
.invited-by { .notification-subtitle {
font-size: typography.$font-small; font-size: typography.$font-small;
color: var(--text-secondary); color: var(--text-secondary);
} }
.notification-meta {
font-size: typography.$font-tiny;
color: var(--text-tertiary);
}
.expires-info { .expires-info {
font-size: typography.$font-tiny; font-size: typography.$font-tiny;
color: var(--text-secondary); color: var(--text-secondary);
@ -257,7 +391,7 @@
font-weight: typography.$medium; font-weight: typography.$medium;
} }
.invitation-actions { .notification-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: spacing.$unit; gap: spacing.$unit;

View file

@ -8,7 +8,8 @@
import { import {
useRemoveMember, useRemoveMember,
useUpdateMembership, useUpdateMembership,
useDeletePhantom useDeletePhantom,
useDeclinePhantomClaim
} from '$lib/api/mutations/crew.mutations' } from '$lib/api/mutations/crew.mutations'
import { crewAdapter } from '$lib/api/adapters/crew.adapter' import { crewAdapter } from '$lib/api/adapters/crew.adapter'
import { crewStore } from '$lib/stores/crew.store.svelte' import { crewStore } from '$lib/stores/crew.store.svelte'
@ -83,6 +84,7 @@
const removeMemberMutation = useRemoveMember() const removeMemberMutation = useRemoveMember()
const updateMembershipMutation = useUpdateMembership() const updateMembershipMutation = useUpdateMembership()
const deletePhantomMutation = useDeletePhantom() const deletePhantomMutation = useDeletePhantom()
const declinePhantomClaimMutation = useDeclinePhantomClaim()
// Filter options - Pending only shown to officers // Filter options - Pending only shown to officers
const filterOptions = $derived.by(() => { const filterOptions = $derived.by(() => {
@ -266,12 +268,26 @@
assignPhantomDialogOpen = true assignPhantomDialogOpen = true
} }
// Confirm claim // Confirm claim (opens modal)
function openConfirmClaimDialog(phantom: PhantomPlayer) { function openConfirmClaimDialog(phantom: PhantomPlayer) {
phantomToClaim = phantom phantomToClaim = phantom
confirmClaimDialogOpen = true 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 // Format date
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, { return new Date(dateString).toLocaleDateString(undefined, {
@ -417,7 +433,8 @@
onEdit={() => openEditPhantomDialog(phantom)} onEdit={() => openEditPhantomDialog(phantom)}
onDelete={() => openDeletePhantomDialog(phantom)} onDelete={() => openDeletePhantomDialog(phantom)}
onAssign={() => openAssignPhantomDialog(phantom)} onAssign={() => openAssignPhantomDialog(phantom)}
onConfirmClaim={() => openConfirmClaimDialog(phantom)} onAccept={() => openConfirmClaimDialog(phantom)}
onDecline={() => handleDeclineClaim(phantom)}
/> />
{/each} {/each}
</ul> </ul>
@ -438,7 +455,8 @@
onEdit={() => openEditPhantomDialog(phantom)} onEdit={() => openEditPhantomDialog(phantom)}
onDelete={() => openDeletePhantomDialog(phantom)} onDelete={() => openDeletePhantomDialog(phantom)}
onAssign={() => openAssignPhantomDialog(phantom)} onAssign={() => openAssignPhantomDialog(phantom)}
onConfirmClaim={() => openConfirmClaimDialog(phantom)} onAccept={() => openConfirmClaimDialog(phantom)}
onDecline={() => handleDeclineClaim(phantom)}
/> />
{/each} {/each}
</ul> </ul>