add crew invitation modals

- InvitationsModal: view/accept/decline pending invitations
- InviteUserModal: confirm invite from profile page
- ScoutUserModal: search users by username to invite
This commit is contained in:
Justin Edmund 2025-12-13 18:07:02 -08:00
parent 27a98274c1
commit c9f31f9059
3 changed files with 707 additions and 0 deletions

View file

@ -0,0 +1,274 @@
<svelte:options runes={true} />
<script lang="ts">
import { goto } from '$app/navigation'
import { useAcceptInvitation, useRejectInvitation } 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'
interface Props {
open: boolean
invitations: CrewInvitation[]
isLoading?: boolean
}
let { open = $bindable(false), invitations, isLoading = false }: Props = $props()
// Mutations
const acceptMutation = useAcceptInvitation()
const rejectMutation = useRejectInvitation()
// Track which invitation is being processed
let processingId = $state<string | null>(null)
// Accept invitation
async function handleAccept(invitationId: string) {
processingId = invitationId
try {
await acceptMutation.mutateAsync(invitationId)
// Successfully joined - close modal and redirect to crew
open = false
goto('/crew')
} catch (error) {
console.error('Failed to accept invitation:', error)
processingId = null
}
}
// Reject invitation
async function handleReject(invitationId: string) {
processingId = invitationId
try {
await rejectMutation.mutateAsync(invitationId)
processingId = null
} catch (error) {
console.error('Failed to reject invitation:', error)
processingId = null
}
}
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
// Check if invitation is expired
function isExpired(expiresAt: string): boolean {
return new Date(expiresAt) < new Date()
}
</script>
<Dialog bind:open>
<ModalHeader title="Crew Invitations" description="Accept or decline pending invitations" />
<ModalBody>
{#if isLoading}
<div class="loading-state">
<Icon name="loader-2" size={24} />
<p>Loading invitations...</p>
</div>
{:else if invitations.length === 0}
<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>
</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}
{#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}
</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>
{/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>
{/if}
</ModalBody>
</Dialog>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: spacing.$unit;
padding: spacing.$unit-4x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
p {
margin: 0;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: spacing.$unit;
padding: spacing.$unit-4x;
text-align: center;
color: var(--text-secondary);
:global(svg) {
opacity: 0.5;
}
p {
margin: 0;
}
.hint {
font-size: typography.$font-small;
opacity: 0.8;
}
}
.invitations-list {
display: flex;
flex-direction: column;
gap: spacing.$unit;
}
.invitation-card {
background: var(--surface-secondary, #f9fafb);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: spacing.$unit-2x;
&.expired {
opacity: 0.6;
}
}
.invitation-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: spacing.$unit-2x;
margin-bottom: spacing.$unit;
}
.crew-info {
display: flex;
flex-direction: column;
gap: spacing.$unit-quarter;
}
.crew-name-row {
display: flex;
align-items: baseline;
gap: spacing.$unit-half;
}
.crew-name {
font-weight: typography.$medium;
color: var(--text-primary);
}
.gamertag {
color: var(--text-secondary);
font-size: typography.$font-small;
}
.invited-by {
font-size: typography.$font-small;
color: var(--text-secondary);
}
.expires-info {
font-size: typography.$font-tiny;
color: var(--text-secondary);
white-space: nowrap;
}
.expired-badge {
font-size: typography.$font-tiny;
color: colors.$error;
background: colors.$error--bg--light;
padding: spacing.$unit-quarter spacing.$unit-half;
border-radius: 4px;
font-weight: typography.$medium;
}
.invitation-actions {
display: flex;
justify-content: flex-end;
gap: spacing.$unit;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,139 @@
<svelte:options runes={true} />
<script lang="ts">
import { useSendInvitation } 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 ModalFooter from '$lib/components/ui/ModalFooter.svelte'
import Button from '$lib/components/ui/Button.svelte'
interface Props {
open: boolean
userId: string
username: string
crewId: string
}
let { open = $bindable(false), userId, username, crewId }: Props = $props()
const sendMutation = useSendInvitation()
// State
let error = $state<string | null>(null)
let success = $state(false)
async function handleSend() {
error = null
try {
await sendMutation.mutateAsync({ crewId, userId })
success = true
// Close after a brief delay to show success
setTimeout(() => {
open = false
success = false
}, 1500)
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to send invitation'
}
}
function handleCancel() {
open = false
error = null
success = false
}
// Reset state when modal opens
$effect(() => {
if (open) {
error = null
success = false
}
})
</script>
<Dialog bind:open>
<ModalHeader
title="Invite to Crew"
description="Send a crew invitation to this user"
/>
<ModalBody>
{#if success}
<div class="success-message">
<p>Invitation sent to <strong>{username}</strong>!</p>
</div>
{:else}
<div class="confirmation">
<p>
Are you sure you want to invite <strong>{username}</strong> to join your crew?
</p>
<p class="note">
They will receive the invitation and can choose to accept or decline.
</p>
</div>
{#if error}
<div class="error-message">
<p>{error}</p>
</div>
{/if}
{/if}
</ModalBody>
{#if !success}
<ModalFooter>
<Button variant="secondary" onclick={handleCancel} disabled={sendMutation.isPending}>
Cancel
</Button>
<Button variant="primary" onclick={handleSend} disabled={sendMutation.isPending}>
{sendMutation.isPending ? 'Sending...' : 'Send Invitation'}
</Button>
</ModalFooter>
{/if}
</Dialog>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.confirmation {
p {
margin: 0 0 spacing.$unit;
&:last-child {
margin-bottom: 0;
}
}
.note {
font-size: typography.$font-small;
color: var(--text-secondary);
}
}
.success-message {
text-align: center;
padding: spacing.$unit-2x;
color: colors.$wind-text-20;
p {
margin: 0;
}
}
.error-message {
margin-top: spacing.$unit;
padding: spacing.$unit;
background: colors.$error--bg--light;
border-radius: 4px;
color: colors.$error;
p {
margin: 0;
font-size: typography.$font-small;
}
}
</style>

View file

@ -0,0 +1,294 @@
<svelte:options runes={true} />
<script lang="ts">
import { userAdapter, type UserInfo } from '$lib/api/adapters/user.adapter'
import { useSendInvitation } 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 ModalFooter from '$lib/components/ui/ModalFooter.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
interface Props {
open: boolean
crewId: string
}
let { open = $bindable(false), crewId }: Props = $props()
const sendMutation = useSendInvitation()
// State
let usernameInput = $state('')
let isSearching = $state(false)
let searchError = $state<string | null>(null)
let foundUser = $state<UserInfo | null>(null)
let inviteSuccess = $state(false)
let inviteError = $state<string | null>(null)
// Search for user by username
async function handleSearch() {
if (!usernameInput.trim()) return
isSearching = true
searchError = null
foundUser = null
inviteSuccess = false
inviteError = null
try {
const user = await userAdapter.getInfo(usernameInput.trim())
foundUser = user
} catch (err) {
searchError = 'User not found'
} finally {
isSearching = false
}
}
// Send invitation to the found user
async function handleInvite() {
if (!foundUser?.id) return
inviteError = null
try {
await sendMutation.mutateAsync({ crewId, userId: foundUser.id })
inviteSuccess = true
// Reset after success
setTimeout(() => {
resetState()
open = false
}, 1500)
} catch (err) {
inviteError = err instanceof Error ? err.message : 'Failed to send invitation'
}
}
function resetState() {
usernameInput = ''
foundUser = null
searchError = null
inviteSuccess = false
inviteError = null
}
function handleCancel() {
resetState()
open = false
}
// Reset state when modal opens
$effect(() => {
if (open) {
resetState()
}
})
// Handle Enter key in search input
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && usernameInput.trim() && !isSearching) {
handleSearch()
}
}
</script>
<Dialog bind:open>
<ModalHeader
title="Scout Player"
description="Search for a user to invite to your crew"
/>
<ModalBody>
{#if inviteSuccess}
<div class="success-message">
<Icon name="check-circle" size={32} />
<p>Invitation sent to <strong>{foundUser?.username}</strong>!</p>
</div>
{:else}
<!-- Search input -->
<div class="search-section">
<div class="search-input-container">
<input
type="text"
bind:value={usernameInput}
placeholder="Enter username..."
class="search-input"
onkeydown={handleKeydown}
disabled={isSearching}
/>
<Button
variant="secondary"
size="small"
onclick={handleSearch}
disabled={!usernameInput.trim() || isSearching}
>
{isSearching ? 'Searching...' : 'Search'}
</Button>
</div>
{#if searchError}
<p class="error-text">{searchError}</p>
{/if}
</div>
<!-- Found user display -->
{#if foundUser}
<div class="user-result">
<div class="user-info">
{#if foundUser.avatar?.picture}
<img
src={getAvatarSrc(foundUser.avatar.picture)}
srcset={getAvatarSrcSet(foundUser.avatar.picture)}
alt={foundUser.username}
class="user-avatar"
width="48"
height="48"
/>
{:else}
<div class="user-avatar-placeholder"></div>
{/if}
<div class="user-details">
<span class="username">{foundUser.username}</span>
</div>
</div>
{#if inviteError}
<div class="invite-error">
<p>{inviteError}</p>
</div>
{/if}
</div>
{/if}
{/if}
</ModalBody>
{#if !inviteSuccess}
<ModalFooter>
<Button variant="secondary" onclick={handleCancel}>
Cancel
</Button>
{#if foundUser}
<Button
variant="primary"
onclick={handleInvite}
disabled={sendMutation.isPending}
>
{sendMutation.isPending ? 'Sending...' : 'Send Invitation'}
</Button>
{/if}
</ModalFooter>
{/if}
</Dialog>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.search-section {
margin-bottom: spacing.$unit-2x;
}
.search-input-container {
display: flex;
gap: spacing.$unit;
}
.search-input {
flex: 1;
padding: spacing.$unit spacing.$unit-2x;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: typography.$font-regular;
background: var(--input-bg, white);
color: var(--text-primary);
&:focus {
outline: none;
border-color: var(--accent-color, #3366ff);
box-shadow: 0 0 0 2px var(--focus-ring-light, rgba(51, 102, 255, 0.1));
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&::placeholder {
color: var(--text-secondary);
}
}
.error-text {
margin: spacing.$unit 0 0;
font-size: typography.$font-small;
color: colors.$error;
}
.user-result {
background: var(--surface-secondary, #f9fafb);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: spacing.$unit-2x;
}
.user-info {
display: flex;
align-items: center;
gap: spacing.$unit-2x;
}
.user-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.user-avatar-placeholder {
width: 48px;
height: 48px;
border-radius: 50%;
background: colors.$grey-80;
}
.user-details {
display: flex;
flex-direction: column;
gap: spacing.$unit-quarter;
}
.username {
font-weight: typography.$medium;
color: var(--text-primary);
}
.invite-error {
margin-top: spacing.$unit;
padding: spacing.$unit;
background: colors.$error--bg--light;
border-radius: 4px;
p {
margin: 0;
font-size: typography.$font-small;
color: colors.$error;
}
}
.success-message {
display: flex;
flex-direction: column;
align-items: center;
gap: spacing.$unit;
padding: spacing.$unit-4x;
text-align: center;
color: colors.$wind-text-20;
p {
margin: 0;
}
}
</style>