diff --git a/packages/app/src/components/CustomNavBar.vue b/packages/app/src/components/CustomNavBar.vue index 5a37324..d0e46e8 100644 --- a/packages/app/src/components/CustomNavBar.vue +++ b/packages/app/src/components/CustomNavBar.vue @@ -34,8 +34,8 @@ defineProps<{ const statusBarHeight = ref(0) onMounted(() => { - const sysInfo = uni.getSystemInfoSync() - statusBarHeight.value = sysInfo.statusBarHeight ?? 20 + const windowInfo = uni.getWindowInfo() + statusBarHeight.value = windowInfo.statusBarHeight ?? 20 }) function handleBack() { diff --git a/packages/app/src/pages/profile/bookings.vue b/packages/app/src/pages/profile/bookings.vue index b9d7225..ec59a95 100644 --- a/packages/app/src/pages/profile/bookings.vue +++ b/packages/app/src/pages/profile/bookings.vue @@ -28,16 +28,25 @@ > - + + + + + + + + - ๐Ÿ“… + + 🧘 + ๆš‚ๆ— ๅณๅฐ†ไธŠ่ฏพ็š„้ข„็บฆ - ๅŽป้ข„็บฆไธ€่Š‚่ฏพๅง + ๅผ€ๅง‹้ข„็บฆไฝ ็š„ๆ™ฎๆ‹‰ๆ่ฏพ็จ‹ๅง - ๅŽป้ข„็บฆ + ็ซ‹ๅณ้ข„็บฆ @@ -50,21 +59,22 @@ > - + {{ formatDateDisplay(booking.timeSlot.date) }} - {{ booking.timeSlot.startTime.slice(0, 5) }} โ€“ {{ booking.timeSlot.endTime.slice(0, 5) }} + {{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }} ๅทฒ้ข„็บฆ - - ๐Ÿ’ณ {{ booking.membership.cardType.name }} - - + + + ไฝฟ็”จๅก็ง + {{ booking.membership.cardType.name }} + ๅ–ๆถˆ้ข„็บฆ @@ -87,12 +97,20 @@ > - + + + + + + + - ๐Ÿ“‹ + + 📋 + ๆš‚ๆ— ๅކๅฒ่ฎฐๅฝ• ๅทฒๅฎŒๆˆๆˆ–ๅ–ๆถˆ็š„่ฏพ็จ‹ๅฐ†ๆ˜พ็คบๅœจ่ฟ™้‡Œ @@ -106,19 +124,20 @@ > - + {{ formatDateDisplay(booking.timeSlot.date) }} - {{ booking.timeSlot.startTime.slice(0, 5) }} โ€“ {{ booking.timeSlot.endTime.slice(0, 5) }} + {{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }} {{ statusLabel(booking.status) }} - - ๐Ÿ’ณ {{ booking.membership.cardType.name }} + + ไฝฟ็”จๅก็ง + {{ booking.membership.cardType.name }} @@ -154,34 +173,47 @@ const activeTab = ref('upcoming') const refreshingUpcoming = ref(false) const refreshingHistory = ref(false) +// โ”€โ”€โ”€ Safe array accessor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function safeBookings(): readonly BookingWithDetails[] { + const raw = bookingStore.myBookings + return Array.isArray(raw) ? raw : [] +} + +/** Normalize date to YYYY-MM-DD โ€” handles both "2026-04-06" and "2026-04-06T00:00:00.000Z" */ +function toDateStr(date: string): string { + return date.slice(0, 10) +} + // โ”€โ”€โ”€ Filtered bookings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const today = computed(() => formatDate(new Date())) const upcomingBookings = computed(() => { - const all = bookingStore.myBookings as BookingWithDetails[] - return all + return safeBookings() .filter( - (b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today.value, + (b) => b.status === BookingStatus.CONFIRMED && toDateStr(b.timeSlot.date) >= today.value, ) .sort((a, b) => { - if (a.timeSlot.date !== b.timeSlot.date) { - return a.timeSlot.date.localeCompare(b.timeSlot.date) + const dateA = toDateStr(a.timeSlot.date) + const dateB = toDateStr(b.timeSlot.date) + if (dateA !== dateB) { + return dateA.localeCompare(dateB) } return a.timeSlot.startTime.localeCompare(b.timeSlot.startTime) }) }) const historyBookings = computed(() => { - const all = bookingStore.myBookings as BookingWithDetails[] - return all + return safeBookings() .filter( (b) => b.status !== BookingStatus.CONFIRMED || - b.timeSlot.date < today.value, + toDateStr(b.timeSlot.date) < today.value, ) .sort((a, b) => { - if (b.timeSlot.date !== a.timeSlot.date) { - return b.timeSlot.date.localeCompare(a.timeSlot.date) + const dateA = toDateStr(a.timeSlot.date) + const dateB = toDateStr(b.timeSlot.date) + if (dateB !== dateA) { + return dateB.localeCompare(dateA) } return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime) }) @@ -190,43 +222,58 @@ const historyBookings = computed(() => { const upcomingCount = computed(() => upcomingBookings.value.length) // โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const STATUS_LABELS: Record = { + [BookingStatus.CONFIRMED]: 'ๅทฒ้ข„็บฆ', + [BookingStatus.CANCELLED]: 'ๅทฒๅ–ๆถˆ', + [BookingStatus.COMPLETED]: 'ๅทฒๅฎŒๆˆ', + [BookingStatus.NO_SHOW]: 'ๆœชๅ‡บๅธญ', +} + +const STATUS_BADGE_CLASSES: Record = { + [BookingStatus.CONFIRMED]: 'badge--confirmed', + [BookingStatus.CANCELLED]: 'badge--cancelled', + [BookingStatus.COMPLETED]: 'badge--completed', + [BookingStatus.NO_SHOW]: 'badge--noshow', +} + +const STATUS_STRIPE_CLASSES: Record = { + [BookingStatus.CONFIRMED]: 'stripe--confirmed', + [BookingStatus.CANCELLED]: 'stripe--cancelled', + [BookingStatus.COMPLETED]: 'stripe--completed', + [BookingStatus.NO_SHOW]: 'stripe--noshow', +} + function statusLabel(status: BookingStatus): string { - const map: Record = { - [BookingStatus.CONFIRMED]: 'ๅทฒ้ข„็บฆ', - [BookingStatus.CANCELLED]: 'ๅทฒๅ–ๆถˆ', - [BookingStatus.COMPLETED]: 'ๅทฒๅฎŒๆˆ', - [BookingStatus.NO_SHOW]: 'ๆœชๅ‡บๅธญ', - } - return map[status] ?? status + return STATUS_LABELS[status] ?? status } function statusBadgeClass(status: BookingStatus): string { - const map: Record = { - [BookingStatus.CONFIRMED]: 'badge--confirmed', - [BookingStatus.CANCELLED]: 'badge--cancelled', - [BookingStatus.COMPLETED]: 'badge--completed', - [BookingStatus.NO_SHOW]: 'badge--noshow', - } - return map[status] ?? '' + return STATUS_BADGE_CLASSES[status] ?? '' } function stripeClass(status: BookingStatus): string { - const map: Record = { - [BookingStatus.CONFIRMED]: 'stripe--confirmed', - [BookingStatus.CANCELLED]: 'stripe--cancelled', - [BookingStatus.COMPLETED]: 'stripe--completed', - [BookingStatus.NO_SHOW]: 'stripe--noshow', - } - return map[status] ?? '' + return STATUS_STRIPE_CLASSES[status] ?? '' } function formatDateDisplay(dateStr: string): string { - // e.g. "2024-03-15" โ†’ "3ๆœˆ15ๆ—ฅ ๅ‘จไบ”" - const d = new Date(dateStr) - const month = d.getMonth() + 1 - const day = d.getDate() - const weekday = getWeekdayLabel(d) - return `${month}ๆœˆ${day}ๆ—ฅ ${weekday}` + const normalized = toDateStr(dateStr) + const todayStr = formatDate(new Date()) + const tomorrowDate = new Date() + tomorrowDate.setDate(tomorrowDate.getDate() + 1) + const tomorrowStr = formatDate(tomorrowDate) + + // Parse from normalized YYYY-MM-DD to avoid timezone shifts + const [y, m, d] = normalized.split('-').map(Number) + const localDate = new Date(y, m - 1, d) + const weekday = getWeekdayLabel(localDate) + + if (normalized === todayStr) { + return `ไปŠๅคฉ ${m}ๆœˆ${d}ๆ—ฅ` + } + if (normalized === tomorrowStr) { + return `ๆ˜Žๅคฉ ${m}ๆœˆ${d}ๆ—ฅ` + } + return `${m}ๆœˆ${d}ๆ—ฅ ${weekday}` } // โ”€โ”€โ”€ Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -279,8 +326,9 @@ async function handleCancel(booking: BookingWithDetails) { // โ”€โ”€โ”€ Lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ onMounted(() => { - const sys = uni.getSystemInfoSync() - navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px` + const windowInfo = uni.getWindowInfo() + const statusBarH = windowInfo.statusBarHeight ?? 20 + navBarHeight.value = `${statusBarH + Math.round(88 * windowInfo.windowWidth / 750)}px` bookingStore.fetchMyBookings() }) @@ -311,6 +359,11 @@ onMounted(() => { gap: 8rpx; padding: 28rpx 0; position: relative; + transition: opacity 0.2s; + + &:active { + opacity: 0.7; + } &.active { .tab-label { @@ -361,7 +414,7 @@ onMounted(() => { height: calc(100vh - 88rpx); } -/* โ”€โ”€ Loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +/* โ”€โ”€ Loading skeleton โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .loading-wrap { padding: 24rpx; display: flex; @@ -370,11 +423,37 @@ onMounted(() => { } .skeleton-card { - height: 160rpx; border-radius: 16rpx; + background: #fff; + overflow: hidden; + display: flex; + flex-direction: row; +} + +.skeleton-stripe { + width: 8rpx; + flex-shrink: 0; + background: #eee; +} + +.skeleton-body { + flex: 1; + padding: 28rpx 24rpx; + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.skeleton-line { + height: 28rpx; + border-radius: 8rpx; background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); background-size: 400% 100%; animation: shimmer 1.4s infinite; + + &--long { width: 70%; } + &--short { width: 40%; } + &--medium { width: 55%; } } @keyframes shimmer { @@ -389,11 +468,22 @@ onMounted(() => { align-items: center; justify-content: center; padding: 120rpx 40rpx; - gap: 20rpx; + gap: 16rpx; +} + +.empty-illustration { + width: 160rpx; + height: 160rpx; + border-radius: 80rpx; + background: #faf6f1; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16rpx; } .empty-icon { - font-size: 80rpx; + font-size: 72rpx; } .empty-title { @@ -408,16 +498,23 @@ onMounted(() => { } .empty-btn { - margin-top: 12rpx; - padding: 20rpx 56rpx; + margin-top: 24rpx; + padding: 22rpx 64rpx; border-radius: 44rpx; - background: #c9a87c; + background: linear-gradient(135deg, #d4b896, #c9a87c); + box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.3); + + &:active { + opacity: 0.85; + transform: scale(0.98); + } } .empty-btn-text { font-size: 30rpx; color: #fff; font-weight: 600; + letter-spacing: 2rpx; } /* โ”€โ”€ List โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ @@ -425,15 +522,15 @@ onMounted(() => { padding: 24rpx 24rpx 0; display: flex; flex-direction: column; - gap: 16rpx; + gap: 20rpx; } /* โ”€โ”€ Booking card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .booking-card { background: #fff; - border-radius: 16rpx; + border-radius: 20rpx; overflow: hidden; - box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06); + box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05); display: flex; flex-direction: row; } @@ -444,20 +541,20 @@ onMounted(() => { flex-shrink: 0; &.stripe--confirmed { background: #c9a87c; } - &.stripe--completed { background: #4caf50; } + &.stripe--completed { background: #66bb6a; } &.stripe--cancelled { background: #e0e0e0; } - &.stripe--noshow { background: #ef4444; } + &.stripe--noshow { background: #ef5350; } } .booking-content { flex: 1; - padding: 24rpx 24rpx 24rpx 20rpx; + padding: 28rpx 24rpx 24rpx 20rpx; display: flex; flex-direction: column; - gap: 12rpx; + gap: 16rpx; } -.booking-main { +.booking-header { display: flex; flex-direction: row; align-items: flex-start; @@ -471,14 +568,15 @@ onMounted(() => { } .booking-date { - font-size: 28rpx; + font-size: 30rpx; font-weight: 600; color: #1a1a1a; } .booking-time { - font-size: 24rpx; + font-size: 26rpx; color: #888; + letter-spacing: 1rpx; } /* Status badge */ @@ -487,10 +585,10 @@ onMounted(() => { border-radius: 20rpx; flex-shrink: 0; - &.badge--confirmed { background: #fff8ee; } - &.badge--completed { background: #f0faf3; } - &.badge--cancelled { background: #f5f5f5; } - &.badge--noshow { background: #fef0f0; } + &.badge--confirmed { background: rgba(201, 168, 124, 0.12); } + &.badge--completed { background: rgba(102, 187, 106, 0.12); } + &.badge--cancelled { background: rgba(0, 0, 0, 0.04); } + &.badge--noshow { background: rgba(239, 83, 80, 0.1); } } .status-text { @@ -498,34 +596,56 @@ onMounted(() => { font-weight: 600; .badge--confirmed & { color: #c9a87c; } - .badge--completed & { color: #4caf50; } + .badge--completed & { color: #66bb6a; } .badge--cancelled & { color: #bbb; } - .badge--noshow & { color: #ef4444; } + .badge--noshow & { color: #ef5350; } +} + +/* Footer row with meta + cancel */ +.booking-footer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding-top: 8rpx; + border-top: 1rpx solid #f5f5f5; } /* Meta info */ -.booking-meta { - .meta-text { - font-size: 24rpx; - color: #999; - } -} - -/* Cancel row */ -.cancel-row { +.booking-meta, +.booking-meta-row { display: flex; - justify-content: flex-end; - margin-top: 4rpx; + flex-direction: row; + align-items: center; + gap: 8rpx; } +.booking-meta-row { + padding-top: 8rpx; + border-top: 1rpx solid #f5f5f5; +} + +.meta-label { + font-size: 22rpx; + color: #bbb; +} + +.meta-value { + font-size: 24rpx; + color: #666; + font-weight: 500; +} + +/* Cancel button */ .cancel-btn { - padding: 10rpx 24rpx; + padding: 12rpx 28rpx; border-radius: 24rpx; - border: 1rpx solid #ef444430; - background: #fef0f0; + border: 1rpx solid rgba(239, 68, 68, 0.2); + background: rgba(254, 240, 240, 0.8); + transition: opacity 0.2s; &:active { - opacity: 0.75; + opacity: 0.65; } } diff --git a/packages/app/src/stores/booking.ts b/packages/app/src/stores/booking.ts index 326e305..8d2a07d 100644 --- a/packages/app/src/stores/booking.ts +++ b/packages/app/src/stores/booking.ts @@ -7,6 +7,14 @@ import type { } from '@mp-pilates/shared' import { get, post, put } from '../utils/request' +/** Server paginated responses use `data` field, not `items` from the shared type */ +interface ServerPaginatedResult { + readonly data: readonly T[] + readonly total: number + readonly page: number + readonly limit: number +} + export const useBookingStore = defineStore('booking', () => { const slots = ref([]) const myBookings = ref([]) @@ -39,10 +47,12 @@ export const useBookingStore = defineStore('booking', () => { async function fetchMyBookings(status?: string) { loadingBookings.value = true try { - const params = status ? { status } : {} - myBookings.value = await get('/booking/my', params) + const params: Record = status ? { status } : {} + const paginated = await get>('/booking/my', params) + myBookings.value = Array.isArray(paginated.data) ? paginated.data : [] } catch (err) { console.error('Fetch bookings failed:', err) + myBookings.value = [] } finally { loadingBookings.value = false } @@ -50,9 +60,11 @@ export const useBookingStore = defineStore('booking', () => { async function fetchUpcomingBookings() { try { - upcomingBookings.value = await get('/booking/my/upcoming') + const result = await get('/booking/my/upcoming') + upcomingBookings.value = Array.isArray(result) ? result : [] } catch (err) { console.error('Fetch upcoming bookings failed:', err) + upcomingBookings.value = [] } }