profileShare

rasmusjy / splitapp-frontend-vue

Read-only snapshot

No repository description.

main default branch 96 files Expires Sep 13, 2026, 9:06 AM
IndexView.vue 10,018 bytes
1 <script setup lang="ts">
2 import { ref, onMounted, computed, inject, type ComputedRef } from 'vue'
3 import { useRouter, useRoute } from 'vue-router'
4 import { useI18n } from 'vue-i18n'
5 import WishlistService from '@/services/WishlistService'
6 import type { IWishlistItem } from '@/types/IWishlist'
7 import { useToast } from '@/composables/useToast'
8 import { formatCurrency } from '@/utils/formatCurrency'
9
10 const { t } = useI18n()
11
12 const _tripCurrencySymbol = inject<ComputedRef<string>>('tripCurrencySymbol')
13 const tripCurrencySymbol = computed(() => _tripCurrencySymbol?.value ?? '')
14
15 const _currentUserId = inject<ComputedRef<string | null>>('currentUserId')
16 const currentUserId = computed(() => _currentUserId?.value ?? null)
17
18 const router = useRouter()
19 const route = useRoute()
20 const toast = useToast()
21
22 const items = ref<IWishlistItem[]>([])
23 const isLoading = ref(true)
24 const error = ref<string | null>(null)
25
26 const tripId = route.params.tripId as string
27
28 const sortedItems = computed(() =>
29 [...items.value].sort((a, b) => {
30 if (a.isCompleted !== b.isCompleted) return a.isCompleted ? 1 : -1
31 return b.voteCount - a.voteCount
32 }),
33 )
34
35 onMounted(async () => {
36 await loadItems()
37 })
38
39 async function loadItems() {
40 isLoading.value = true
41 const result = await WishlistService.getByTrip(tripId)
42 if (result.data) {
43 items.value = result.data
44 } else if (result.errors) {
45 error.value = result.errors.join(', ')
46 }
47 isLoading.value = false
48 }
49
50 async function toggleVote(item: IWishlistItem) {
51 error.value = null
52 const result = await WishlistService.vote(item.id)
53 if (result.errors) {
54 toast.error(result.errors.join(', '))
55 } else {
56 await loadItems()
57 }
58 }
59
60 async function markComplete(id: string) {
61 error.value = null
62 const result = await WishlistService.complete(id)
63 if (result.errors) {
64 toast.error(result.errors.join(', '))
65 } else {
66 toast.success(t('wishlist.index.completed'))
67 await loadItems()
68 }
69 }
70
71 async function markUncomplete(id: string) {
72 error.value = null
73 const result = await WishlistService.complete(id)
74 if (result.errors) {
75 toast.error(result.errors.join(', '))
76 } else {
77 toast.success(t('wishlist.index.uncompleted'))
78 await loadItems()
79 }
80 }
81
82 async function deleteItem(id: string) {
83 if (!confirm(t('wishlist.index.confirmDelete'))) return
84 error.value = null
85 const result = await WishlistService.delete(id)
86 if (result.errors) {
87 toast.error(result.errors.join(', '))
88 } else {
89 items.value = items.value.filter((i) => i.id !== id)
90 toast.success(t('wishlist.index.deleted'))
91 }
92 }
93
94 function categoryStripeStyle(category: string): Record<string, string> {
95 switch (category) {
96 case 'Place':
97 return { height: '4px', background: 'linear-gradient(90deg, #3b82f6, #2176ae)' }
98 case 'Activity':
99 return { height: '4px', background: 'linear-gradient(90deg, #22c55e, #16a34a)' }
100 case 'Restaurant':
101 return { height: '4px', background: 'linear-gradient(90deg, #f97316, #ea580c)' }
102 default:
103 return { height: '4px', background: 'linear-gradient(90deg, var(--sa-gray-300), var(--sa-gray-400))' }
104 }
105 }
106
107 function categoryBadgeClass(category: string): string {
108 switch (category) {
109 case 'Place': return 'sa-badge-info'
110 case 'Activity': return 'sa-badge-success'
111 case 'Restaurant': return 'sa-badge-accent'
112 default: return 'sa-badge-neutral'
113 }
114 }
115
116 function priorityBadgeClass(priority: string): string {
117 return priority === 'MustDo' ? 'sa-badge-danger' : 'sa-badge-info'
118 }
119
120 function categoryLabel(category: string): string {
121 const key = `wishlist.category.${category}`
122 const translated = t(key)
123 return translated === key ? category : translated
124 }
125
126 function priorityLabel(priority: string): string {
127 const key = `wishlist.priority.${priority}`
128 const translated = t(key)
129 return translated === key ? priority : translated
130 }
131 </script>
132
133 <template>
134 <div>
135 <!-- Gradient Header -->
136 <div class="sa-gradient-header sa-gradient-header-accent d-flex justify-content-between align-items-center">
137 <div>
138 <h3 class="mb-1"><i class="bi bi-stars me-2"></i>{{ t('wishlist.index.title') }}</h3>
139 <p v-if="!isLoading && items.length > 0" class="mb-0 text-muted" style="font-size: 0.9rem">
140 {{ t('wishlist.index.itemCount', { n: items.length }, items.length) }}
141 </p>
142 </div>
143 <button
144 class="sa-btn sa-btn-pill sa-hide-mobile"
145 style="background: #fff; color: var(--sa-accent-dark)"
146 @click="router.push({ name: 'WishlistCreate', params: { tripId } })"
147 >
148 <i class="bi bi-plus-lg"></i> {{ t('wishlist.index.addItem') }}
149 </button>
150 </div>
151
152 <div v-if="error" class="alert alert-danger">{{ error }}</div>
153
154 <!-- Loading -->
155 <div v-if="isLoading" class="text-center py-5">
156 <div class="spinner-border" style="color: var(--sa-accent)" role="status"></div>
157 </div>
158
159 <!-- Empty State -->
160 <div v-else-if="sortedItems.length === 0" class="sa-empty">
161 <div class="sa-empty-icon"><i class="bi bi-stars"></i></div>
162 <div class="sa-empty-title">{{ t('wishlist.index.emptyTitle') }}</div>
163 <div class="sa-empty-text">{{ t('wishlist.index.emptyText') }}</div>
164 <button
165 class="sa-btn sa-btn-primary sa-btn-pill"
166 @click="router.push({ name: 'WishlistCreate', params: { tripId } })"
167 >
168 <i class="bi bi-plus-lg"></i> {{ t('wishlist.index.addItem') }}
169 </button>
170 </div>
171
172 <!-- Card Grid -->
173 <div v-else class="row g-4">
174 <div v-for="item in sortedItems" :key="item.id" class="col-md-6 col-lg-4">
175 <div class="sa-card d-flex flex-column h-100">
176 <!-- Category Color Stripe -->
177 <div :style="categoryStripeStyle(item.category)"></div>
178
179 <!-- Card Body -->
180 <div class="sa-card-body flex-grow-1">
181 <h5
182 class="mb-2"
183 :style="item.isCompleted ? 'text-decoration: line-through; opacity: 0.6' : ''"
184 >
185 {{ item.title }}
186 </h5>
187
188 <p
189 v-if="item.description"
190 class="mb-3"
191 style="font-size: 0.875rem; color: var(--sa-gray-500)"
192 :style="item.isCompleted ? 'opacity: 0.6' : ''"
193 >
194 {{ item.description }}
195 </p>
196
197 <!-- Badges -->
198 <div class="d-flex flex-wrap gap-1 mb-3">
199 <span class="sa-badge" :class="categoryBadgeClass(item.category)">
200 {{ categoryLabel(item.category) }}
201 </span>
202 <span class="sa-badge" :class="priorityBadgeClass(item.priority)">
203 {{ priorityLabel(item.priority) }}
204 </span>
205 <span v-if="item.estimatedCost" class="sa-badge sa-badge-neutral">
206 {{ formatCurrency(item.estimatedCost, tripCurrencySymbol) }}
207 </span>
208 </div>
209
210 <!-- Location -->
211 <div v-if="item.location" class="mb-1" style="font-size: 0.813rem; color: var(--sa-gray-500)">
212 <i class="bi bi-geo-alt me-1"></i>{{ item.location }}
213 </div>
214
215 <!-- URL -->
216 <div v-if="item.url" class="mb-2" style="font-size: 0.813rem">
217 <i class="bi bi-link-45deg me-1" style="color: var(--sa-gray-400)"></i>
218 <a :href="item.url" target="_blank" rel="noopener noreferrer" style="color: var(--sa-secondary)">
219 {{ item.url.replace(/^https?:\/\//, '').substring(0, 30) }}{{ item.url.replace(/^https?:\/\//, '').length > 30 ? '...' : '' }}
220 </a>
221 </div>
222
223 <!-- Added by -->
224 <small style="color: var(--sa-gray-400); font-size: 0.75rem">
225 {{ t('wishlist.index.addedBy', { name: item.addedByUserName || t('common.unknown') }) }}
226 </small>
227 </div>
228
229 <!-- Card Footer -->
230 <div class="sa-card-footer d-flex align-items-center justify-content-between">
231 <button
232 class="sa-vote-btn"
233 :class="{ 'sa-vote-btn-active': item.userHasVoted }"
234 @click="toggleVote(item)"
235 >
236 <i :class="item.userHasVoted ? 'bi bi-heart-fill' : 'bi bi-heart'"></i>
237 {{ item.voteCount }}
238 </button>
239 <div class="d-flex gap-1">
240 <button
241 v-if="!item.isCompleted"
242 class="sa-btn sa-btn-success sa-btn-sm"
243 @click="markComplete(item.id)"
244 >
245 {{ t('wishlist.index.complete') }}
246 </button>
247 <button
248 v-if="item.isCompleted"
249 class="sa-btn sa-btn-ghost sa-btn-sm"
250 @click="markUncomplete(item.id)"
251 >
252 {{ t('wishlist.index.undo') }}
253 </button>
254 <button
255 v-if="currentUserId === item.addedByUserId"
256 class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm"
257 :title="t('common.edit')"
258 @click="router.push({ name: 'WishlistEdit', params: { tripId, id: item.id } })"
259 >
260 <i class="bi bi-pencil"></i>
261 </button>
262 <button
263 v-if="currentUserId === item.addedByUserId"
264 class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm"
265 style="color: var(--sa-danger)"
266 :title="t('common.delete')"
267 @click="deleteItem(item.id)"
268 >
269 <i class="bi bi-trash3"></i>
270 </button>
271 </div>
272 </div>
273 </div>
274 </div>
275 </div>
276
277 <!-- Mobile FAB -->
278 <button
279 class="sa-fab"
280 style="background: linear-gradient(135deg, var(--sa-accent) 0%, #f97316 100%); box-shadow: 0 4px 16px rgba(244, 166, 35, 0.4)"
281 @click="router.push({ name: 'WishlistCreate', params: { tripId } })"
282 >
283 <i class="bi bi-plus-lg"></i>
284 </button>
285 </div>
286 </template>
287