profileShare

rasmusjy / splitapp-frontend-vue

Read-only snapshot

No repository description.

main default branch 96 files Expires Sep 13, 2026, 9:06 AM
DetailView.vue 6,357 bytes
1 <script setup lang="ts">
2 import { ref, onMounted, computed } from 'vue'
3 import { useRouter, useRoute } from 'vue-router'
4 import { useI18n } from 'vue-i18n'
5 import PollService from '@/services/PollService'
6 import type { IPoll } from '@/types/IPoll'
7 import { useToast } from '@/composables/useToast'
8
9 const router = useRouter()
10 const route = useRoute()
11 const toast = useToast()
12 const { t } = useI18n()
13
14 const tripId = route.params.tripId as string
15 const pollId = route.params.id as string
16
17 const poll = ref<IPoll | null>(null)
18 const isLoading = ref(true)
19 const error = ref<string | null>(null)
20 const actionError = ref<string | null>(null)
21
22 const isClosed = computed(() => !!poll.value?.closedAt)
23
24 const totalVotes = computed(() => {
25 if (!poll.value?.options) return 0
26 return poll.value.options.reduce((sum, o) => sum + o.voteCount, 0)
27 })
28
29 const maxVotes = computed(() => {
30 if (!poll.value?.options) return 0
31 return Math.max(...poll.value.options.map((o) => o.voteCount))
32 })
33
34 onMounted(async () => {
35 await loadPoll()
36 })
37
38 async function loadPoll() {
39 isLoading.value = true
40 error.value = null
41
42 const result = await PollService.getById(pollId)
43 if (result.data) {
44 poll.value = result.data
45 } else if (result.errors) {
46 error.value = result.errors.join(', ')
47 }
48 isLoading.value = false
49 }
50
51 async function vote(optionId: string) {
52 actionError.value = null
53 const result = await PollService.vote(pollId, optionId)
54 if (result.errors) {
55 toast.error(result.errors.join(', '))
56 } else {
57 toast.success(t('polls.detail.voteRecorded'))
58 await loadPoll()
59 }
60 }
61
62 async function closePoll() {
63 if (!confirm(t('polls.detail.confirmClose'))) return
64 actionError.value = null
65 const result = await PollService.close(pollId)
66 if (result.errors) {
67 toast.error(result.errors.join(', '))
68 } else {
69 toast.success(t('polls.detail.closed'))
70 await loadPoll()
71 }
72 }
73
74 async function deletePoll() {
75 if (!confirm(t('polls.detail.confirmDelete'))) return
76 const result = await PollService.delete(pollId)
77 if (result.errors) {
78 toast.error(result.errors.join(', '))
79 } else {
80 toast.success(t('polls.detail.deleted'))
81 router.push({ name: 'PollsIndex', params: { tripId } })
82 }
83 }
84
85 function votePercent(voteCount: number): number {
86 if (totalVotes.value === 0) return 0
87 return Math.round((voteCount / totalVotes.value) * 100)
88 }
89
90 function isWinner(voteCount: number): boolean {
91 return isClosed.value && voteCount > 0 && voteCount === maxVotes.value
92 }
93 </script>
94
95 <template>
96 <div>
97 <!-- Loading -->
98 <div v-if="isLoading" class="text-center py-5">
99 <div class="spinner-border text-secondary" role="status"></div>
100 </div>
101
102 <!-- Error -->
103 <div v-else-if="error" class="sa-card-static sa-card-accent sa-card-accent-danger">
104 <div class="sa-card-body">
105 <i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
106 </div>
107 </div>
108
109 <!-- Poll Detail -->
110 <div v-else-if="poll">
111 <!-- Question heading with status -->
112 <div class="d-flex align-items-center gap-3 mb-4">
113 <span
114 class="sa-status-dot"
115 :class="isClosed ? 'sa-status-dot-pending' : 'sa-status-dot-active sa-status-dot-pulse'"
116 ></span>
117 <h2 class="mb-0" style="flex: 1">{{ poll.question }}</h2>
118 <span
119 class="sa-badge"
120 :class="isClosed ? 'sa-badge-neutral' : 'sa-badge-success'"
121 >
122 {{ isClosed ? t('polls.status.Closed') : t('polls.status.Open') }}
123 </span>
124 </div>
125
126 <!-- Poll Options -->
127 <div class="mb-4">
128 <div
129 v-for="option in poll.options"
130 :key="option.id"
131 class="sa-poll-option"
132 :class="{
133 'sa-poll-option-voted': option.votedByCurrentUser,
134 'sa-poll-option-winner': isWinner(option.voteCount),
135 }"
136 >
137 <div class="d-flex justify-content-between align-items-center mb-2">
138 <div class="d-flex align-items-center gap-2 flex-wrap">
139 <span class="fw-bold" style="font-size: 1.02rem">{{ option.text }}</span>
140 <span v-if="option.votedByCurrentUser" class="sa-badge sa-badge-secondary">
141 <i class="bi bi-check-circle-fill"></i> {{ t('polls.detail.yourVote') }}
142 </span>
143 <span v-if="isWinner(option.voteCount)" class="sa-badge sa-badge-accent">
144 <i class="bi bi-trophy-fill"></i> {{ t('polls.detail.winner') }}
145 </span>
146 </div>
147 <div class="d-flex align-items-center gap-3">
148 <span class="text-muted" style="font-size: 0.85rem; white-space: nowrap">
149 {{ t('polls.detail.voteCount', { n: option.voteCount }, option.voteCount) }}
150 <span class="fw-bold ms-1">{{ votePercent(option.voteCount) }}%</span>
151 </span>
152 <button
153 v-if="!isClosed"
154 class="sa-btn sa-btn-sm"
155 :class="option.votedByCurrentUser ? 'sa-btn-ghost' : 'sa-btn-secondary'"
156 @click="vote(option.id)"
157 >
158 {{ option.votedByCurrentUser ? t('polls.detail.unvote') : t('polls.detail.vote') }}
159 </button>
160 </div>
161 </div>
162 <div class="sa-progress sa-progress-sm">
163 <div
164 class="sa-progress-bar"
165 :class="{ 'sa-progress-bar-primary': isWinner(option.voteCount) }"
166 :style="{ width: votePercent(option.voteCount) + '%' }"
167 ></div>
168 </div>
169 </div>
170 </div>
171
172 <!-- Actions -->
173 <div class="d-flex gap-2 flex-wrap">
174 <button
175 v-if="!isClosed"
176 class="sa-btn sa-btn-accent sa-btn-sm"
177 @click="closePoll"
178 >
179 <i class="bi bi-lock-fill"></i> {{ t('polls.detail.closePoll') }}
180 </button>
181 <button class="sa-btn sa-btn-danger sa-btn-sm" @click="deletePoll">
182 <i class="bi bi-trash3"></i> {{ t('common.delete') }}
183 </button>
184 <div class="flex-grow-1"></div>
185 <button
186 class="sa-btn sa-btn-ghost sa-btn-sm"
187 @click="router.push({ name: 'PollsIndex', params: { tripId } })"
188 >
189 <i class="bi bi-arrow-left"></i> {{ t('polls.detail.backToPolls') }}
190 </button>
191 </div>
192 </div>
193 </div>
194 </template>
195