MembersView.vue
7,023 bytes
| 1 | <script setup lang="ts"> |
|---|---|
| 2 | import { ref, onMounted, computed, inject, type ComputedRef } from 'vue' |
| 3 | import { useRoute } from 'vue-router' |
| 4 | import { useI18n } from 'vue-i18n' |
| 5 | import TripService from '@/services/TripService' |
| 6 | import InvitationService from '@/services/InvitationService' |
| 7 | import type { ITripParticipant } from '@/types/ITrip' |
| 8 | import { useToast } from '@/composables/useToast' |
| 9 | |
| 10 | const { t, d } = useI18n() |
| 11 | |
| 12 | const _isOrganizer = inject<ComputedRef<boolean>>('isOrganizer') |
| 13 | const isOrganizer = computed(() => _isOrganizer?.value ?? false) |
| 14 | |
| 15 | const sortedParticipants = computed(() => { |
| 16 | return [...participants.value].sort((a, b) => { |
| 17 | if (a.role === 'Organizer' && b.role !== 'Organizer') return -1 |
| 18 | if (a.role !== 'Organizer' && b.role === 'Organizer') return 1 |
| 19 | return 0 |
| 20 | }) |
| 21 | }) |
| 22 | |
| 23 | const route = useRoute() |
| 24 | const toast = useToast() |
| 25 | |
| 26 | const tripId = route.params.tripId as string |
| 27 | |
| 28 | const participants = ref<ITripParticipant[]>([]) |
| 29 | const isLoading = ref(true) |
| 30 | const error = ref<string | null>(null) |
| 31 | |
| 32 | const invitationToken = ref<string | null>(null) |
| 33 | const isCreatingInvite = ref(false) |
| 34 | |
| 35 | onMounted(async () => { |
| 36 | const result = await TripService.getParticipants(tripId) |
| 37 | if (result.data) { |
| 38 | participants.value = result.data |
| 39 | } else if (result.errors) { |
| 40 | error.value = result.errors.join(', ') |
| 41 | } |
| 42 | isLoading.value = false |
| 43 | }) |
| 44 | |
| 45 | async function createInvitation() { |
| 46 | invitationToken.value = null |
| 47 | isCreatingInvite.value = true |
| 48 | |
| 49 | const result = await InvitationService.create({ tripId }) |
| 50 | if (result.data) { |
| 51 | invitationToken.value = result.data.token |
| 52 | toast.success(t('members.invitationCreated')) |
| 53 | } else if (result.errors) { |
| 54 | toast.error(result.errors.join(', ')) |
| 55 | } |
| 56 | |
| 57 | isCreatingInvite.value = false |
| 58 | } |
| 59 | |
| 60 | function formatDate(dateStr: string) { |
| 61 | return d(new Date(dateStr), 'short') |
| 62 | } |
| 63 | |
| 64 | function invitationLink(): string { |
| 65 | if (!invitationToken.value) return '' |
| 66 | return `${window.location.origin}/invitations/${invitationToken.value}` |
| 67 | } |
| 68 | |
| 69 | function getInitials(name: string | null, email: string | null): string { |
| 70 | if (name) { |
| 71 | return name |
| 72 | .split(' ') |
| 73 | .map((w) => w[0]) |
| 74 | .slice(0, 2) |
| 75 | .join('') |
| 76 | .toUpperCase() |
| 77 | } |
| 78 | if (email) return email[0]!.toUpperCase() |
| 79 | return '?' |
| 80 | } |
| 81 | |
| 82 | function avatarClass(index: number): string { |
| 83 | return `sa-avatar-${(index % 8) + 1}` |
| 84 | } |
| 85 | |
| 86 | function copyLink() { |
| 87 | const link = invitationLink() |
| 88 | if (!link) return |
| 89 | navigator.clipboard.writeText(link).then(() => { |
| 90 | toast.success(t('members.copied')) |
| 91 | }).catch(() => { |
| 92 | toast.error(t('members.copyFailed')) |
| 93 | }) |
| 94 | } |
| 95 | </script> |
| 96 | |
| 97 | <template> |
| 98 | <div> |
| 99 | <!-- Gradient Header --> |
| 100 | <div class="sa-gradient-header sa-gradient-header-purple d-flex justify-content-between align-items-center"> |
| 101 | <div> |
| 102 | <h3 class="mb-1"><i class="bi bi-people-fill me-2"></i>{{ t('members.title') }}</h3> |
| 103 | <p v-if="!isLoading && participants.length > 0" class="mb-0 text-muted" style="font-size: 0.9rem"> |
| 104 | {{ t('members.count', { n: participants.length }, participants.length) }} |
| 105 | </p> |
| 106 | </div> |
| 107 | <button |
| 108 | v-if="isOrganizer" |
| 109 | class="sa-btn sa-btn-pill sa-hide-mobile" |
| 110 | style="background: #fff; color: #6366f1" |
| 111 | :class="{ 'sa-btn-loading': isCreatingInvite }" |
| 112 | :disabled="isCreatingInvite" |
| 113 | @click="createInvitation" |
| 114 | > |
| 115 | <i class="bi bi-link-45deg"></i> {{ t('members.invite') }} |
| 116 | </button> |
| 117 | </div> |
| 118 | |
| 119 | <div v-if="error" class="alert alert-danger">{{ error }}</div> |
| 120 | |
| 121 | <!-- Invitation Link --> |
| 122 | <div v-if="invitationToken" class="sa-card-static sa-card-accent sa-card-accent-secondary mb-4"> |
| 123 | <div class="sa-card-body"> |
| 124 | <div class="d-flex align-items-center mb-2"> |
| 125 | <i class="bi bi-check-circle-fill me-2" style="color: var(--sa-success); font-size: 1.1rem"></i> |
| 126 | <strong style="color: var(--sa-gray-800)">{{ t('members.invitationCreated') }}</strong> |
| 127 | </div> |
| 128 | <p class="mb-2" style="font-size: 0.875rem; color: var(--sa-gray-500)">{{ t('members.shareLink') }}</p> |
| 129 | <div class="d-flex align-items-center gap-2"> |
| 130 | <code style=" |
| 131 | flex: 1; |
| 132 | padding: 8px 12px; |
| 133 | background: var(--sa-success-light); |
| 134 | color: #15803d; |
| 135 | border-radius: var(--sa-radius-sm); |
| 136 | font-size: 0.813rem; |
| 137 | word-break: break-all; |
| 138 | ">{{ invitationLink() }}</code> |
| 139 | <button |
| 140 | class="sa-btn sa-btn-ghost sa-btn-sm sa-btn-icon" |
| 141 | :title="t('members.copyLink')" |
| 142 | @click="copyLink" |
| 143 | > |
| 144 | <i class="bi bi-clipboard"></i> |
| 145 | </button> |
| 146 | </div> |
| 147 | </div> |
| 148 | </div> |
| 149 | |
| 150 | <!-- Loading --> |
| 151 | <div v-if="isLoading" class="text-center py-5"> |
| 152 | <div class="spinner-border" style="color: #6366f1" role="status"></div> |
| 153 | </div> |
| 154 | |
| 155 | <!-- Empty State --> |
| 156 | <div v-else-if="participants.length === 0" class="sa-empty"> |
| 157 | <div class="sa-empty-icon"><i class="bi bi-people"></i></div> |
| 158 | <div class="sa-empty-title">{{ t('members.emptyTitle') }}</div> |
| 159 | <div class="sa-empty-text">{{ t('members.emptyText') }}</div> |
| 160 | <button |
| 161 | class="sa-btn sa-btn-primary sa-btn-pill" |
| 162 | :disabled="isCreatingInvite" |
| 163 | @click="createInvitation" |
| 164 | > |
| 165 | <i class="bi bi-link-45deg"></i> {{ t('members.createInvitation') }} |
| 166 | </button> |
| 167 | </div> |
| 168 | |
| 169 | <!-- Member List --> |
| 170 | <div v-else class="sa-card-static"> |
| 171 | <div |
| 172 | v-for="(member, index) in sortedParticipants" |
| 173 | :key="member.id" |
| 174 | class="d-flex align-items-center gap-3 px-4 py-3" |
| 175 | :style="index < participants.length - 1 ? 'border-bottom: 1px solid var(--sa-gray-100)' : ''" |
| 176 | > |
| 177 | <div class="sa-avatar" :class="avatarClass(index)"> |
| 178 | {{ getInitials(member.userName, member.userEmail) }} |
| 179 | </div> |
| 180 | <div class="flex-grow-1"> |
| 181 | <div class="fw-bold" style="color: var(--sa-gray-800)"> |
| 182 | {{ member.userName || member.userEmail || t('common.unknown') }} |
| 183 | </div> |
| 184 | <small v-if="member.userEmail" style="color: var(--sa-gray-500)">{{ member.userEmail }}</small> |
| 185 | </div> |
| 186 | <div class="text-end"> |
| 187 | <span |
| 188 | class="sa-badge" |
| 189 | :class="member.role === 'Organizer' ? 'sa-badge-primary' : 'sa-badge-neutral'" |
| 190 | > |
| 191 | {{ t(`members.role.${member.role === 'Organizer' ? 'Organizer' : 'Participant'}`) }} |
| 192 | </span> |
| 193 | <div class="mt-1"> |
| 194 | <small style="color: var(--sa-gray-400); font-size: 0.75rem"> |
| 195 | {{ t('members.joined', { date: formatDate(member.joinedAt) }) }} |
| 196 | </small> |
| 197 | </div> |
| 198 | </div> |
| 199 | </div> |
| 200 | </div> |
| 201 | |
| 202 | <!-- Mobile FAB --> |
| 203 | <button |
| 204 | v-if="isOrganizer" |
| 205 | class="sa-fab" |
| 206 | style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4)" |
| 207 | @click="createInvitation" |
| 208 | > |
| 209 | <i class="bi bi-link-45deg"></i> |
| 210 | </button> |
| 211 | </div> |
| 212 | </template> |
| 213 | |