425 lines
13 KiB
Vue
425 lines
13 KiB
Vue
<template>
|
|
<view class="booking-page" :style="pageStyle">
|
|
<!-- ──────────── Custom nav bar ──────────── -->
|
|
<CustomNavBar title="预约课程" />
|
|
|
|
<!-- ──────────── Sticky header area ──────────── -->
|
|
<view class="sticky-header">
|
|
<!-- Date selector -->
|
|
<DateSelector v-model="selectedDate" @select="onDateSelect" />
|
|
|
|
<!-- Time period filter -->
|
|
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" />
|
|
</view>
|
|
|
|
<!-- ──────────── Slot list ──────────── -->
|
|
<scroll-view
|
|
class="slot-scroll"
|
|
scroll-y
|
|
:style="{ height: scrollHeight, paddingTop: stickyHeaderHeight }"
|
|
refresher-enabled
|
|
:refresher-triggered="refreshing"
|
|
@refresherrefresh="onRefresh"
|
|
>
|
|
<!-- Loading skeleton -->
|
|
<view v-if="bookingStore.loadingSlots && !refreshing" class="loading-wrap">
|
|
<view v-for="i in 3" :key="i" class="skeleton-card">
|
|
<view class="skeleton-time" />
|
|
<view class="skeleton-body">
|
|
<view class="skeleton-title" />
|
|
<view class="skeleton-sub" />
|
|
</view>
|
|
<view class="skeleton-btn" />
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Empty state -->
|
|
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
|
<view class="empty-icon-circle">
|
|
<text class="empty-icon-text">📅</text>
|
|
</view>
|
|
<text class="empty-text">当日暂无可约时段</text>
|
|
<text class="empty-sub">请选择其他日期或时段查看</text>
|
|
</view>
|
|
|
|
<!-- Slot cards -->
|
|
<view v-else class="slot-list">
|
|
<!-- Date summary -->
|
|
<view class="date-summary">
|
|
<text class="date-summary-text">
|
|
共 {{ filteredSlots.length }} 个可选时段
|
|
</text>
|
|
</view>
|
|
|
|
<SlotCard
|
|
v-for="item in filteredSlots"
|
|
:key="item.id"
|
|
:time-slot="item"
|
|
@book="onBookTap"
|
|
@cancel="onCancelTap"
|
|
/>
|
|
</view>
|
|
|
|
<!-- Bottom padding spacer -->
|
|
<view class="scroll-bottom-spacer" />
|
|
</scroll-view>
|
|
|
|
<!-- ──────────── Confirm popup ──────────── -->
|
|
<BookingConfirmPopup
|
|
:visible="showConfirmPopup"
|
|
:time-slot="pendingSlot"
|
|
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
|
@confirm="onConfirmBooking"
|
|
@cancel="showConfirmPopup = false"
|
|
/>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
|
import { TIME_PERIODS } from '@mp-pilates/shared'
|
|
import { useBookingStore } from '../../stores/booking'
|
|
import { useUserStore } from '../../stores/user'
|
|
import { formatDate } from '../../utils/format'
|
|
import DateSelector from '../../components/DateSelector.vue'
|
|
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
|
import SlotCard from '../../components/SlotCard.vue'
|
|
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
|
|
|
type PeriodKey = keyof typeof TIME_PERIODS | null
|
|
|
|
// ─── Stores ───────────────────────────────────────────────
|
|
const bookingStore = useBookingStore()
|
|
const userStore = useUserStore()
|
|
|
|
// ─── State ────────────────────────────────────────────────
|
|
const selectedDate = ref<string>(formatDate(new Date()))
|
|
const selectedPeriod = ref<PeriodKey>(null)
|
|
const showConfirmPopup = ref(false)
|
|
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
|
|
const refreshing = ref(false)
|
|
|
|
// ─── Layout ───────────────────────────────────────────────
|
|
// Default: statusBar ~20px + 88rpx ≈ 64px; avoid empty string on first render
|
|
const navBarHeight = ref('64px')
|
|
const scrollHeight = ref('500px')
|
|
const stickyHeaderHeight = ref('240rpx')
|
|
|
|
function updateLayout() {
|
|
const sysInfo = uni.getSystemInfoSync()
|
|
const ratio = sysInfo.windowWidth / 750
|
|
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
|
const navTitlePx = 88 * ratio
|
|
const navBarPx = Math.round(statusBarPx + navTitlePx)
|
|
navBarHeight.value = `${navBarPx}px`
|
|
|
|
// Measure sticky header: DateSelector (~160rpx) + TimePeriodFilter (~76rpx) + borders
|
|
const stickyPx = Math.round(240 * ratio)
|
|
stickyHeaderHeight.value = `${stickyPx}px`
|
|
|
|
// scrollHeight: from below nav bar to above tabbar
|
|
const tabbarPx = Math.round(100 * ratio)
|
|
scrollHeight.value = `${sysInfo.windowHeight - navBarPx - tabbarPx}px`
|
|
}
|
|
|
|
updateLayout()
|
|
|
|
// CSS variable for sticky header offset
|
|
const pageStyle = computed(() => ({
|
|
'--nav-bar-height': navBarHeight.value,
|
|
}))
|
|
|
|
// ─── Filtered slots ───────────────────────────────────────
|
|
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
|
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
|
if (!selectedPeriod.value) return [...slots]
|
|
|
|
const period = TIME_PERIODS[selectedPeriod.value]
|
|
return slots.filter((slot) => {
|
|
const t = slot.startTime
|
|
return t >= period.start && t < period.end
|
|
})
|
|
})
|
|
|
|
// ─── Data loading ─────────────────────────────────────────
|
|
async function loadSlots(date: string) {
|
|
await bookingStore.fetchSlots(date)
|
|
}
|
|
|
|
async function onRefresh() {
|
|
refreshing.value = true
|
|
await loadSlots(selectedDate.value)
|
|
refreshing.value = false
|
|
}
|
|
|
|
// ─── Event handlers ───────────────────────────────────────
|
|
function onDateSelect(date: string) {
|
|
selectedDate.value = date
|
|
loadSlots(date)
|
|
}
|
|
|
|
function onPeriodChange(_period: PeriodKey) {
|
|
// Filtering is done client-side via computed property
|
|
}
|
|
|
|
// ─── Book flow ────────────────────────────────────────────
|
|
async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
|
// 1. Ensure logged in
|
|
if (!userStore.loggedIn) {
|
|
uni.showModal({
|
|
title: '提示',
|
|
content: '请先登录后再预约课程',
|
|
confirmText: '去登录',
|
|
success: async (res) => {
|
|
if (res.confirm) {
|
|
try {
|
|
await userStore.login()
|
|
await userStore.fetchMemberships()
|
|
// Retry booking flow after login
|
|
onBookTap(slot)
|
|
} catch {
|
|
uni.showToast({ title: '登录失败', icon: 'none' })
|
|
}
|
|
}
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
// 2. Ensure has valid membership
|
|
if (!userStore.hasValidMembership) {
|
|
uni.showModal({
|
|
title: '暂无可用会员卡',
|
|
content: '您当前没有有效的会员卡,购买后即可预约课程',
|
|
confirmText: '去购买',
|
|
cancelText: '取消',
|
|
success: (res) => {
|
|
if (res.confirm) {
|
|
uni.navigateTo({ url: '/pages/store/index' })
|
|
}
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
// 3. Show confirm popup
|
|
pendingSlot.value = slot
|
|
showConfirmPopup.value = true
|
|
}
|
|
|
|
async function onConfirmBooking(payload: { timeSlotId: string; membershipId: string }) {
|
|
showConfirmPopup.value = false
|
|
|
|
uni.showLoading({ title: '预约中...' })
|
|
try {
|
|
await bookingStore.createBooking(payload)
|
|
uni.hideLoading()
|
|
uni.showToast({ title: '预约成功!', icon: 'success' })
|
|
// Refresh slots to reflect new booking status
|
|
await loadSlots(selectedDate.value)
|
|
} catch (err: unknown) {
|
|
uni.hideLoading()
|
|
const message = err instanceof Error ? err.message : '预约失败,请重试'
|
|
uni.showToast({ title: message, icon: 'none' })
|
|
}
|
|
}
|
|
|
|
async function onCancelTap(slot: TimeSlotWithBookingStatus) {
|
|
if (!slot.myBookingId) return
|
|
|
|
uni.showModal({
|
|
title: '取消预约',
|
|
content: '确定要取消这个预约吗?',
|
|
confirmText: '确定取消',
|
|
confirmColor: '#ef4444',
|
|
cancelText: '再想想',
|
|
success: async (res) => {
|
|
if (res.confirm) {
|
|
uni.showLoading({ title: '取消中...' })
|
|
try {
|
|
await bookingStore.cancelBooking(slot.myBookingId!)
|
|
uni.hideLoading()
|
|
uni.showToast({ title: '已取消预约', icon: 'success' })
|
|
await loadSlots(selectedDate.value)
|
|
} catch (err: unknown) {
|
|
uni.hideLoading()
|
|
const message = err instanceof Error ? err.message : '取消失败,请重试'
|
|
uni.showToast({ title: message, icon: 'none' })
|
|
}
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
// ─── Lifecycle ────────────────────────────────────────────
|
|
onMounted(async () => {
|
|
// Load memberships if logged in but not yet fetched
|
|
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
|
|
await userStore.fetchMemberships()
|
|
}
|
|
// Load today's slots
|
|
await loadSlots(selectedDate.value)
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.booking-page {
|
|
min-height: 100vh;
|
|
background: #f7f4f0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
--nav-bar-height: v-bind(navBarHeight);
|
|
padding-top: var(--nav-bar-height);
|
|
}
|
|
|
|
/* ── Sticky header ─────────────────────────────────── */
|
|
.sticky-header {
|
|
position: fixed;
|
|
top: var(--nav-bar-height);
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 100;
|
|
background: #fff;
|
|
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
|
|
}
|
|
|
|
/* ── Scroll container ──────────────────────────────── */
|
|
.slot-scroll {
|
|
flex: 1;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
/* ── Slot list ─────────────────────────────────────── */
|
|
.slot-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20rpx;
|
|
padding: 24rpx 24rpx 0;
|
|
}
|
|
|
|
/* ── Date summary ──────────────────────────────────── */
|
|
.date-summary {
|
|
padding: 0 8rpx 4rpx;
|
|
}
|
|
|
|
.date-summary-text {
|
|
font-size: 24rpx;
|
|
color: #999;
|
|
font-weight: 400;
|
|
}
|
|
|
|
/* ── Loading skeleton ──────────────────────────────── */
|
|
.loading-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20rpx;
|
|
padding: 28rpx 24rpx;
|
|
}
|
|
|
|
.skeleton-card {
|
|
height: 140rpx;
|
|
border-radius: 24rpx;
|
|
background: #fff;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
padding: 32rpx 28rpx 32rpx 36rpx;
|
|
gap: 24rpx;
|
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
|
|
}
|
|
|
|
.skeleton-time {
|
|
width: 80rpx;
|
|
height: 72rpx;
|
|
border-radius: 12rpx;
|
|
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
|
background-size: 400% 100%;
|
|
animation: shimmer 1.4s infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.skeleton-body {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12rpx;
|
|
}
|
|
|
|
.skeleton-title {
|
|
width: 60%;
|
|
height: 28rpx;
|
|
border-radius: 8rpx;
|
|
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
|
background-size: 400% 100%;
|
|
animation: shimmer 1.4s infinite;
|
|
}
|
|
|
|
.skeleton-sub {
|
|
width: 40%;
|
|
height: 20rpx;
|
|
border-radius: 6rpx;
|
|
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
|
background-size: 400% 100%;
|
|
animation: shimmer 1.4s infinite;
|
|
}
|
|
|
|
.skeleton-btn {
|
|
width: 140rpx;
|
|
height: 72rpx;
|
|
border-radius: 36rpx;
|
|
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
|
background-size: 400% 100%;
|
|
animation: shimmer 1.4s infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% { background-position: 100% 0; }
|
|
100% { background-position: -100% 0; }
|
|
}
|
|
|
|
/* ── Empty state ───────────────────────────────────── */
|
|
.empty-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 140rpx 40rpx;
|
|
gap: 16rpx;
|
|
}
|
|
|
|
.empty-icon-circle {
|
|
width: 140rpx;
|
|
height: 140rpx;
|
|
border-radius: 50%;
|
|
background: #f0ece8;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 16rpx;
|
|
}
|
|
|
|
.empty-icon-text {
|
|
font-size: 56rpx;
|
|
}
|
|
|
|
.empty-text {
|
|
font-size: 30rpx;
|
|
color: #666;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.empty-sub {
|
|
font-size: 26rpx;
|
|
color: #bbb;
|
|
}
|
|
|
|
/* ── Bottom spacer ─────────────────────────────────── */
|
|
.scroll-bottom-spacer {
|
|
height: 48rpx;
|
|
}
|
|
</style>
|