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:
parent
27a98274c1
commit
c9f31f9059
3 changed files with 707 additions and 0 deletions
274
src/lib/components/crew/InvitationsModal.svelte
Normal file
274
src/lib/components/crew/InvitationsModal.svelte
Normal 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>
|
||||
139
src/lib/components/crew/InviteUserModal.svelte
Normal file
139
src/lib/components/crew/InviteUserModal.svelte
Normal 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>
|
||||
294
src/lib/components/crew/ScoutUserModal.svelte
Normal file
294
src/lib/components/crew/ScoutUserModal.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue