profileShare

rasmusjy / splitapp-frontend-vue

Read-only snapshot

No repository description.

main default branch 96 files Expires Sep 13, 2026, 9:06 AM
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