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:
parent
ef95a294b3
commit
907b4503dd
3 changed files with 245 additions and 86 deletions
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue