add crew UI components, store, queries, and mutations

This commit is contained in:
Justin Edmund 2025-12-04 03:03:27 -08:00
parent 32af6a7788
commit aee0690b2d
9 changed files with 1041 additions and 0 deletions

View file

@ -0,0 +1,258 @@
/**
* Crew Mutation Configurations
*
* Provides mutation configurations for crew CRUD operations
* with cache invalidation using TanStack Query v6.
*
* @module api/mutations/crew
*/
import { useQueryClient, createMutation } from '@tanstack/svelte-query'
import { crewAdapter } from '$lib/api/adapters/crew.adapter'
import { crewKeys } from '$lib/api/queries/crew.queries'
import type {
CreateCrewInput,
UpdateCrewInput,
CreatePhantomPlayerInput,
UpdatePhantomPlayerInput,
UpdateMembershipInput
} from '$lib/types/api/crew'
// ==================== Crew Mutations ====================
/**
* Create crew mutation
*/
export function useCreateCrew() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (input: CreateCrewInput) => crewAdapter.create(input),
onSuccess: (crew) => {
queryClient.setQueryData(crewKeys.myCrew(), crew)
queryClient.invalidateQueries({ queryKey: crewKeys.invitations.pending() })
}
}))
}
/**
* Update crew mutation
*/
export function useUpdateCrew() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (input: UpdateCrewInput) => crewAdapter.update(input),
onSuccess: (crew) => {
queryClient.setQueryData(crewKeys.myCrew(), crew)
}
}))
}
/**
* Leave crew mutation
*/
export function useLeaveCrew() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: () => crewAdapter.leave(),
onSuccess: () => {
queryClient.removeQueries({ queryKey: crewKeys.myCrew() })
queryClient.removeQueries({ queryKey: crewKeys.membersAll() })
}
}))
}
/**
* Transfer captain mutation
*/
export function useTransferCaptain() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ crewId, userId }: { crewId: string; userId: string }) =>
crewAdapter.transferCaptain(crewId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.myCrew() })
queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() })
}
}))
}
// ==================== Membership Mutations ====================
/**
* Update membership (promote/demote or update joined_at) mutation
*/
export function useUpdateMembership() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({
crewId,
membershipId,
input
}: {
crewId: string
membershipId: string
input: UpdateMembershipInput
}) => crewAdapter.updateMembership(crewId, membershipId, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() })
}
}))
}
/**
* Remove member mutation
*/
export function useRemoveMember() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ crewId, membershipId }: { crewId: string; membershipId: string }) =>
crewAdapter.removeMember(crewId, membershipId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() })
queryClient.invalidateQueries({ queryKey: crewKeys.myCrew() })
}
}))
}
// ==================== Invitation Mutations ====================
/**
* Send invitation mutation
*/
export function useSendInvitation() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ crewId, userId }: { crewId: string; userId: string }) =>
crewAdapter.sendInvitation(crewId, userId),
onSuccess: (_invitation, { crewId }) => {
queryClient.invalidateQueries({ queryKey: crewKeys.crewInvitations(crewId) })
}
}))
}
/**
* Accept invitation mutation
*/
export function useAcceptInvitation() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (invitationId: string) => crewAdapter.acceptInvitation(invitationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.myCrew() })
queryClient.invalidateQueries({ queryKey: crewKeys.invitations.pending() })
}
}))
}
/**
* Reject invitation mutation
*/
export function useRejectInvitation() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (invitationId: string) => crewAdapter.rejectInvitation(invitationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.invitations.pending() })
}
}))
}
// ==================== Phantom Player Mutations ====================
/**
* Create phantom player mutation
*/
export function useCreatePhantom() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ crewId, input }: { crewId: string; input: CreatePhantomPlayerInput }) =>
crewAdapter.createPhantom(crewId, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() })
}
}))
}
/**
* Update phantom player mutation
*/
export function useUpdatePhantom() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({
crewId,
phantomId,
input
}: {
crewId: string
phantomId: string
input: UpdatePhantomPlayerInput
}) => crewAdapter.updatePhantom(crewId, phantomId, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() })
}
}))
}
/**
* Delete phantom player mutation
*/
export function useDeletePhantom() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ crewId, phantomId }: { crewId: string; phantomId: string }) =>
crewAdapter.deletePhantom(crewId, phantomId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() })
}
}))
}
/**
* Assign phantom player mutation
*/
export function useAssignPhantom() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({
crewId,
phantomId,
userId
}: {
crewId: string
phantomId: string
userId: string
}) => crewAdapter.assignPhantom(crewId, phantomId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() })
}
}))
}
/**
* Confirm phantom claim mutation
*/
export function useConfirmPhantomClaim() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ crewId, phantomId }: { crewId: string; phantomId: string }) =>
crewAdapter.confirmPhantomClaim(crewId, phantomId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() })
}
}))
}

View file

@ -0,0 +1,213 @@
/**
* GW (Guild War) Mutation Configurations
*
* Provides mutation configurations for GW operations
* with cache invalidation using TanStack Query v6.
*
* @module api/mutations/gw
*/
import { useQueryClient, createMutation } from '@tanstack/svelte-query'
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
import { gwKeys } from '$lib/api/queries/gw.queries'
import type {
CreateGwEventInput,
UpdateGwEventInput,
UpdateParticipationRankingInput,
CreateCrewScoreInput,
UpdateCrewScoreInput,
CreateIndividualScoreInput,
BatchIndividualScoresInput
} from '$lib/types/api/gw'
// ==================== Event Mutations (Admin) ====================
/**
* Create GW event mutation (admin only)
*/
export function useCreateGwEvent() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (input: CreateGwEventInput) => gwAdapter.createEvent(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: gwKeys.events() })
}
}))
}
/**
* Update GW event mutation (admin only)
*/
export function useUpdateGwEvent() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ eventId, input }: { eventId: string; input: UpdateGwEventInput }) =>
gwAdapter.updateEvent(eventId, input),
onSuccess: (_data, { eventId }) => {
queryClient.invalidateQueries({ queryKey: gwKeys.events() })
queryClient.invalidateQueries({ queryKey: gwKeys.event(eventId) })
}
}))
}
// ==================== Participation Mutations ====================
/**
* Join GW event mutation
*/
export function useJoinGwEvent() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (eventId: string) => gwAdapter.joinEvent(eventId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: gwKeys.participationsAll() })
}
}))
}
/**
* Update participation ranking mutation
*/
export function useUpdateParticipationRanking() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({
participationId,
input
}: {
participationId: string
input: UpdateParticipationRankingInput
}) => gwAdapter.updateParticipationRanking(participationId, input),
onSuccess: (_data, { participationId }) => {
queryClient.invalidateQueries({ queryKey: gwKeys.participationsAll() })
queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) })
}
}))
}
// ==================== Crew Score Mutations ====================
/**
* Add crew score mutation
*/
export function useAddCrewScore() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({
participationId,
input
}: {
participationId: string
input: CreateCrewScoreInput
}) => gwAdapter.addCrewScore(participationId, input),
onSuccess: (_data, { participationId }) => {
queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) })
}
}))
}
/**
* Update crew score mutation
*/
export function useUpdateCrewScore() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({
participationId,
scoreId,
input
}: {
participationId: string
scoreId: string
input: UpdateCrewScoreInput
}) => gwAdapter.updateCrewScore(participationId, scoreId, input),
onSuccess: (_data, { participationId }) => {
queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) })
}
}))
}
// ==================== Individual Score Mutations ====================
/**
* Add individual score mutation
*/
export function useAddIndividualScore() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({
participationId,
input
}: {
participationId: string
input: CreateIndividualScoreInput
}) => gwAdapter.addIndividualScore(participationId, input),
onSuccess: (_data, { participationId }) => {
queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) })
}
}))
}
/**
* Batch add individual scores mutation
*/
export function useBatchAddIndividualScores() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({
participationId,
input
}: {
participationId: string
input: BatchIndividualScoresInput
}) => gwAdapter.batchAddIndividualScores(participationId, input),
onSuccess: (_data, { participationId }) => {
queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) })
}
}))
}
/**
* Update individual score mutation
*/
export function useUpdateIndividualScore() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({
participationId,
scoreId,
input
}: {
participationId: string
scoreId: string
input: Partial<CreateIndividualScoreInput>
}) => gwAdapter.updateIndividualScore(participationId, scoreId, input),
onSuccess: (_data, { participationId }) => {
queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) })
}
}))
}
/**
* Delete individual score mutation
*/
export function useDeleteIndividualScore() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ participationId, scoreId }: { participationId: string; scoreId: string }) =>
gwAdapter.deleteIndividualScore(participationId, scoreId),
onSuccess: (_data, { participationId }) => {
queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) })
}
}))
}

View file

@ -0,0 +1,117 @@
/**
* Crew Query Options Factory
*
* Provides type-safe, reusable query configurations for crew operations
* using TanStack Query v6 patterns.
*
* @module api/queries/crew
*/
import { queryOptions } from '@tanstack/svelte-query'
import { crewAdapter } from '$lib/api/adapters/crew.adapter'
import type { MemberFilter } from '$lib/types/api/crew'
/**
* Crew query options factory
*
* @example
* ```typescript
* import { createQuery } from '@tanstack/svelte-query'
* import { crewQueries } from '$lib/api/queries/crew.queries'
*
* // Current user's crew
* const crew = createQuery(() => crewQueries.myCrew())
*
* // Crew members
* const members = createQuery(() => crewQueries.members('active'))
*
* // Pending invitations
* const invitations = createQuery(() => crewQueries.pendingInvitations())
* ```
*/
export const crewQueries = {
/**
* Current user's crew query options
*/
myCrew: () =>
queryOptions({
queryKey: ['crew', 'my'] as const,
queryFn: () => crewAdapter.getMyCrew(),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
retry: (failureCount, error) => {
// Don't retry on 404 (no crew)
if (error && 'status' in error && error.status === 404) {
return false
}
return failureCount < 3
}
}),
/**
* Crew members query options
*
* @param filter - 'active' (default), 'retired', 'phantom', 'all'
*/
members: (filter: MemberFilter = 'active') =>
queryOptions({
queryKey: ['crew', 'members', filter] as const,
queryFn: () => crewAdapter.getMembers(filter),
staleTime: 1000 * 60 * 2, // 2 minutes
gcTime: 1000 * 60 * 15 // 15 minutes
}),
/**
* Crew's sent invitations query options
*
* @param crewId - Crew ID
*/
crewInvitations: (crewId: string) =>
queryOptions({
queryKey: ['crew', crewId, 'invitations'] as const,
queryFn: () => crewAdapter.getCrewInvitations(crewId),
enabled: !!crewId,
staleTime: 1000 * 60 * 2, // 2 minutes
gcTime: 1000 * 60 * 15 // 15 minutes
}),
/**
* Current user's pending invitations query options
*/
pendingInvitations: () =>
queryOptions({
queryKey: ['invitations', 'pending'] as const,
queryFn: () => crewAdapter.getPendingInvitations(),
staleTime: 1000 * 60 * 2, // 2 minutes
gcTime: 1000 * 60 * 15 // 15 minutes
})
}
/**
* Query key helpers for cache invalidation
*
* @example
* ```typescript
* import { useQueryClient } from '@tanstack/svelte-query'
* import { crewKeys } from '$lib/api/queries/crew.queries'
*
* const queryClient = useQueryClient()
*
* // Invalidate current crew
* queryClient.invalidateQueries({ queryKey: crewKeys.myCrew() })
*
* // Invalidate all member queries
* queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() })
* ```
*/
export const crewKeys = {
all: ['crew'] as const,
myCrew: () => [...crewKeys.all, 'my'] as const,
membersAll: () => [...crewKeys.all, 'members'] as const,
members: (filter: MemberFilter) => [...crewKeys.all, 'members', filter] as const,
crewInvitations: (crewId: string) => [...crewKeys.all, crewId, 'invitations'] as const,
invitations: {
all: ['invitations'] as const,
pending: () => ['invitations', 'pending'] as const
}
}

View file

@ -0,0 +1,109 @@
/**
* GW (Guild War / Unite and Fight) Query Options Factory
*
* Provides type-safe, reusable query configurations for GW operations
* using TanStack Query v6 patterns.
*
* @module api/queries/gw
*/
import { queryOptions } from '@tanstack/svelte-query'
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
/**
* GW query options factory
*
* @example
* ```typescript
* import { createQuery } from '@tanstack/svelte-query'
* import { gwQueries } from '$lib/api/queries/gw.queries'
*
* // All GW events
* const events = createQuery(() => gwQueries.events())
*
* // Single event
* const event = createQuery(() => gwQueries.event(eventId))
*
* // Crew's participations
* const participations = createQuery(() => gwQueries.participations())
*
* // Single participation with scores
* const participation = createQuery(() => gwQueries.participation(participationId))
* ```
*/
export const gwQueries = {
/**
* All GW events query options
*/
events: () =>
queryOptions({
queryKey: ['gw', 'events'] as const,
queryFn: () => gwAdapter.getEvents(),
staleTime: 1000 * 60 * 10, // 10 minutes - events don't change often
gcTime: 1000 * 60 * 60 // 1 hour
}),
/**
* Single GW event query options
*
* @param eventId - Event ID
*/
event: (eventId: string) =>
queryOptions({
queryKey: ['gw', 'events', eventId] as const,
queryFn: () => gwAdapter.getEvent(eventId),
enabled: !!eventId,
staleTime: 1000 * 60 * 10, // 10 minutes
gcTime: 1000 * 60 * 60 // 1 hour
}),
/**
* Crew's GW participations query options
*/
participations: () =>
queryOptions({
queryKey: ['gw', 'participations'] as const,
queryFn: () => gwAdapter.getParticipations(),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30 // 30 minutes
}),
/**
* Single participation with scores query options
*
* @param participationId - Participation ID
*/
participation: (participationId: string) =>
queryOptions({
queryKey: ['gw', 'participations', participationId] as const,
queryFn: () => gwAdapter.getParticipation(participationId),
enabled: !!participationId,
staleTime: 1000 * 60 * 2, // 2 minutes - scores change during event
gcTime: 1000 * 60 * 15 // 15 minutes
})
}
/**
* Query key helpers for cache invalidation
*
* @example
* ```typescript
* import { useQueryClient } from '@tanstack/svelte-query'
* import { gwKeys } from '$lib/api/queries/gw.queries'
*
* const queryClient = useQueryClient()
*
* // Invalidate all events
* queryClient.invalidateQueries({ queryKey: gwKeys.events() })
*
* // Invalidate a specific participation
* queryClient.invalidateQueries({ queryKey: gwKeys.participation(participationId) })
* ```
*/
export const gwKeys = {
all: ['gw'] as const,
events: () => [...gwKeys.all, 'events'] as const,
event: (eventId: string) => [...gwKeys.all, 'events', eventId] as const,
participationsAll: () => [...gwKeys.all, 'participations'] as const,
participation: (participationId: string) => [...gwKeys.all, 'participations', participationId] as const
}

View file

@ -0,0 +1,90 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
/** Main title text */
title: string
/** Optional subtitle (e.g., gamertag) */
subtitle?: string | undefined
/** Optional description text */
description?: string | undefined
/** Content rendered below title (e.g., filter tabs) */
belowTitle?: Snippet | undefined
/** Action buttons on the right */
actions?: Snippet | undefined
}
const { title, subtitle, description, belowTitle, actions }: Props = $props()
</script>
<div class="section-header">
<div class="header-info">
<div class="title-row">
<span class="header-title">{title}</span>
{#if subtitle}
<span class="header-subtitle">[{subtitle}]</span>
{/if}
</div>
{#if description}
<p class="header-description">{description}</p>
{/if}
{#if belowTitle}
{@render belowTitle()}
{/if}
</div>
{#if actions}
<div class="header-actions">
{@render actions()}
</div>
{/if}
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: spacing.$unit-2x;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.header-info {
display: flex;
flex-direction: column;
gap: spacing.$unit-half;
}
.title-row {
display: flex;
align-items: baseline;
gap: spacing.$unit;
}
.header-title {
font-size: typography.$font-regular;
font-weight: typography.$medium;
}
.header-subtitle {
font-size: typography.$font-small;
color: var(--text-secondary);
}
.header-description {
color: var(--text-secondary);
font-size: typography.$font-small;
line-height: 1.5;
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: spacing.$unit;
}
</style>

View file

@ -0,0 +1,25 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
children: Snippet
}
let { children }: Props = $props()
</script>
<div class="modal-body">
{@render children()}
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
.modal-body {
padding: spacing.$unit-2x;
overflow-y: auto;
flex: 1;
}
</style>

View file

@ -0,0 +1,27 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
children: Snippet
}
let { children }: Props = $props()
</script>
<div class="modal-footer">
{@render children()}
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
.modal-footer {
padding: spacing.$unit-2x;
padding-top: 0;
display: flex;
gap: spacing.$unit-2x;
justify-content: flex-end;
}
</style>

View file

@ -0,0 +1,54 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
title: string
description?: string
children?: Snippet
}
let { title, description, children }: Props = $props()
</script>
<div class="modal-header">
<div class="header-text">
<h2 class="title">{title}</h2>
{#if description}
<p class="description">{description}</p>
{/if}
</div>
{#if children}
{@render children()}
{/if}
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.modal-header {
padding: spacing.$unit-2x;
padding-bottom: spacing.$unit;
}
.header-text {
display: flex;
flex-direction: column;
gap: spacing.$unit-half;
}
.title {
font-size: typography.$font-large;
font-weight: typography.$bold;
margin: 0;
color: var(--text-primary);
}
.description {
font-size: typography.$font-small;
color: var(--text-secondary);
margin: 0;
}
</style>

View file

@ -0,0 +1,148 @@
/**
* Crew Store
*
* Manages the current user's crew state using Svelte 5 runes.
* This store is populated after auth and provides convenient
* accessors for crew-related permissions.
*/
import type { Crew, CrewMembership, CrewInvitation } from '$lib/types/api/crew'
class CrewStore {
// Core state
crew = $state<Crew | null>(null)
membership = $state<CrewMembership | null>(null)
pendingInvitations = $state<CrewInvitation[]>([])
isLoading = $state(false)
error = $state<string | null>(null)
// Computed properties
get isInCrew(): boolean {
return this.crew !== null
}
get isCaptain(): boolean {
return this.membership?.role === 'captain'
}
get isViceCaptain(): boolean {
return this.membership?.role === 'vice_captain'
}
get isOfficer(): boolean {
return this.isCaptain || this.isViceCaptain
}
get isMember(): boolean {
return this.membership?.role === 'member'
}
get canManageMembers(): boolean {
return this.isOfficer
}
get canEditCrew(): boolean {
return this.isOfficer
}
get canTransferCaptain(): boolean {
return this.isCaptain
}
get canLeaveCrew(): boolean {
return this.isInCrew && !this.isCaptain
}
get canManagePhantoms(): boolean {
return this.isOfficer
}
get canRecordOthersScores(): boolean {
return this.isOfficer
}
get hasPendingInvitations(): boolean {
return this.pendingInvitations.length > 0
}
get pendingInvitationCount(): number {
return this.pendingInvitations.length
}
// Actions
setCrew(crew: Crew | null, membership: CrewMembership | null = null) {
this.crew = crew
this.membership = membership
this.error = null
}
setMembership(membership: CrewMembership | null) {
this.membership = membership
}
setPendingInvitations(invitations: CrewInvitation[]) {
this.pendingInvitations = invitations
}
addPendingInvitation(invitation: CrewInvitation) {
this.pendingInvitations = [...this.pendingInvitations, invitation]
}
removePendingInvitation(invitationId: string) {
this.pendingInvitations = this.pendingInvitations.filter((inv) => inv.id !== invitationId)
}
setLoading(loading: boolean) {
this.isLoading = loading
}
setError(error: string | null) {
this.error = error
}
clear() {
this.crew = null
this.membership = null
this.pendingInvitations = []
this.isLoading = false
this.error = null
}
/**
* Check if current user can perform an action on a specific member
*/
canActOnMember(memberRole: 'member' | 'vice_captain' | 'captain'): boolean {
if (!this.isOfficer) return false
// Captain can act on anyone except themselves
if (this.isCaptain) return true
// Vice captain can only act on regular members
if (this.isViceCaptain && memberRole === 'member') return true
return false
}
/**
* Check if current user can promote a member to a specific role
*/
canPromoteTo(targetRole: 'vice_captain' | 'captain'): boolean {
// Only captain can promote to vice_captain
if (targetRole === 'vice_captain') return this.isCaptain
// No one can promote to captain (must transfer)
return false
}
/**
* Check if current user can demote a member
*/
canDemote(memberRole: 'vice_captain' | 'captain'): boolean {
// Only captain can demote vice captains
if (memberRole === 'vice_captain') return this.isCaptain
return false
}
}
export const crewStore = new CrewStore()