round 4
This commit is contained in:
202
web/vue-app/src/components/notification/NotificationList.vue
Normal file
202
web/vue-app/src/components/notification/NotificationList.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { getCachedImageUrl } from '../../common/imageCache'
|
||||
import type { PendingReward, Event, ChildRewardRequestEventPayload } from '@/common/models'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
|
||||
const emit = defineEmits(['item-clicked'])
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const notifications = ref<PendingReward[]>([])
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await fetch('/api/pending-rewards')
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const rewards: PendingReward[] = data.rewards || []
|
||||
|
||||
// Fetch images for child and reward
|
||||
await Promise.all(
|
||||
rewards.map(async (item) => {
|
||||
if (item.child_image_id) {
|
||||
try {
|
||||
item.child_image_url = await getCachedImageUrl(item.child_image_id)
|
||||
} catch (e) {
|
||||
item.child_image_url = null
|
||||
}
|
||||
}
|
||||
if (item.reward_image_id) {
|
||||
try {
|
||||
item.reward_image_url = await getCachedImageUrl(item.reward_image_id)
|
||||
} catch (e) {
|
||||
item.reward_image_url = null
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
notifications.value = rewards
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch notifications'
|
||||
notifications.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardRequest(event: Event) {
|
||||
const payload = event.payload as ChildRewardRequestEventPayload
|
||||
const childId = payload.child_id
|
||||
const rewardId = payload.reward_id
|
||||
// Todo: Have event carry more info to avoid full refresh
|
||||
fetchNotifications()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
eventBus.on('child_reward_request', handleRewardRequest)
|
||||
await fetchNotifications()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_reward_request', handleRewardRequest)
|
||||
})
|
||||
|
||||
function handleItemClick(item: PendingReward) {
|
||||
emit('item-clicked', item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="loading" class="loading">Loading notifications...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="notifications.length === 0" class="empty">No Notifications</div>
|
||||
<div v-else class="notification-listbox">
|
||||
<div v-for="(item, idx) in notifications" :key="item.id">
|
||||
<div class="notification-list-item" @click="handleItemClick(item)">
|
||||
<div class="child-info">
|
||||
<img
|
||||
v-if="item.child_image_url"
|
||||
:src="item.child_image_url"
|
||||
alt="Child"
|
||||
class="child-image"
|
||||
/>
|
||||
<span class="child-name">{{ item.child_name }}</span>
|
||||
</div>
|
||||
<span class="requested-text">requested</span>
|
||||
<div class="reward-info">
|
||||
<span class="reward-name">{{ item.reward_name }}</span>
|
||||
<img
|
||||
v-if="item.reward_image_url"
|
||||
:src="item.reward_image_url"
|
||||
alt="Reward"
|
||||
class="reward-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="idx < notifications.length - 1" class="notification-separator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-listbox {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
max-width: 480px;
|
||||
max-height: calc(100vh - 4.5rem);
|
||||
overflow-y: auto;
|
||||
margin: 0.2rem 0 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
background: #fff5;
|
||||
padding: 0.2rem 0.2rem 0.2rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.notification-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px outset #ef4444;
|
||||
border-radius: 8px;
|
||||
padding: 0.2rem 1rem;
|
||||
background: #f8fafc;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
transition: border 0.18s;
|
||||
margin-bottom: 0.2rem;
|
||||
margin-left: 0.2rem;
|
||||
margin-right: 0.2rem;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
||||
box-sizing: border-box;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.child-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.child-image {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: #eee;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.child-name {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
.reward-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.reward-name {
|
||||
font-weight: 600;
|
||||
color: #ef4444;
|
||||
}
|
||||
.reward-image {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: #eee;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
margin: 2rem 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: #fdfdfd;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.error {
|
||||
color: #ef4444; /* Red-500 for errors */
|
||||
background: #fff1f2; /* Red-50 for error background */
|
||||
}
|
||||
.notification-list-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.notification-separator {
|
||||
height: 0px;
|
||||
background: #0000;
|
||||
margin: 0rem 0.2rem;
|
||||
border-radius: 0px;
|
||||
}
|
||||
.requested-text {
|
||||
margin: 0 0.7rem;
|
||||
font-weight: 500;
|
||||
color: #444;
|
||||
font-size: 1.05rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user