Files
mp-pilates/packages/app/src/components/BookingConfirmPopup.vue

233 lines
9.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- Overlay mask -->
<view v-if="visible" class="popup-mask" @tap="handleMaskTap">
<!-- Popup panel stop propagation so tapping inside doesn't close -->
<view class="popup-panel" @tap.stop>
<!-- Header -->
<view class="popup-header">
<text class="popup-title">确认预约</text>
<view class="close-btn" @tap="handleCancel">
<text class="close-icon"></text>
</view>
</view>
<!-- Course info -->
<view class="info-section">
<view class="info-row">
<text class="info-label">日期</text>
<text class="info-value">{{ timeSlot?.date }}</text>
</view>
<view class="info-row">
<text class="info-label">时间</text>
<text class="info-value" v-if="timeSlot">
{{ timeSlot.startTime.slice(0, 5) }} - {{ timeSlot.endTime.slice(0, 5) }}
</text>
</view>
<view class="info-row">
<text class="info-label">剩余</text>
<text class="info-value" v-if="timeSlot">
{{ timeSlot.capacity - timeSlot.bookedCount }} 个名额
</text>
</view>
</view>
<view class="divider" />
<!-- Membership card selection -->
<view class="card-section">
<view class="section-label-row">
<text class="section-label">选择会员卡</text>
</view>
<!-- Single membership -->
<view v-if="memberships.length === 1" class="card-single">
<view class="card-item selected">
<view class="card-info">
<text class="card-name">{{ memberships[0].cardType.name }}</text>
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
剩余 {{ memberships[0].remainingTimes }}
</text>
<text class="card-remain" v-else>
有效期至 {{ memberships[0].expireDate.slice(0, 10) }}
</text>
</view>
<view class="check-mark">
<text class="check-icon"></text>
</view>
</view>
</view>
<!-- Multiple memberships picker -->
<scroll-view v-else-if="memberships.length > 1" class="card-picker-wrap" scroll-y>
<view
v-for="m in memberships"
:key="m.id"
class="card-item"
:class="{ selected: selectedMembershipId === m.id }"
@tap="selectedMembershipId = m.id"
>
<view class="card-info">
<text class="card-name">{{ m.cardType.name }}</text>
<text class="card-remain" v-if="m.remainingTimes !== null">
剩余 {{ m.remainingTimes }}
</text>
<text class="card-remain" v-else>
有效期至 {{ m.expireDate.slice(0, 10) }}
</text>
</view>
<view class="check-mark" v-if="selectedMembershipId === m.id">
<text class="check-icon"></text>
</view>
</view>
</scroll-view>
<!-- No memberships fallback (should not normally appear) -->
<view v-else class="no-card-tip">
<text class="no-card-text">暂无可用会员卡</text>
</view>
</view>
<!-- Deduction tip -->
<view class="deduction-tip" v-if="selectedMembership">
<text class="deduction-text">
{{ selectedMembership.remainingTimes === null
? `${selectedMembership.cardType.name}」有效期内不限次`
: `确认后将从「${selectedMembership.cardType.name}」扣除 1 次课时` }}
</text>
</view>
<!-- Subscribe note -->
<view class="subscribe-tip">
<text class="subscribe-tip-text">🔔 确认预约将同步订阅约课结果课前1小时提醒与取消通知</text>
</view>
<!-- Action buttons -->
<view class="action-row">
<view class="btn-outline" @tap="handleCancel">
<text class="btn-outline-text">取消</text>
</view>
<button
class="btn-confirm"
:class="{ disabled: !selectedMembershipId }"
:disabled="!selectedMembershipId || requestingSubscribe"
:loading="requestingSubscribe"
@tap="handleConfirm"
>
<text class="btn-confirm-text">确认预约</text>
</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { requestBookingCreatedSubscriptionMessage } from '../utils/wechat-subscription'
const props = defineProps<{
visible: boolean
timeSlot: TimeSlotWithBookingStatus | null
memberships: MembershipWithCardType[]
}>()
const emit = defineEmits<{
(e: 'confirm', payload: { timeSlotId: string; membershipId: string }): void
(e: 'cancel'): void
(e: 'update:visible', val: boolean): void
}>()
const selectedMembershipId = ref<string>('')
const requestingSubscribe = ref(false)
// Auto-select the first membership when popup opens or memberships list changes
watch(
[() => props.visible, () => props.memberships],
([visible, memberships]) => {
if (visible) {
selectedMembershipId.value = memberships[0]?.id ?? ''
}
},
{ immediate: true },
)
const selectedMembership = computed(() =>
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
)
async function handleConfirm() {
if (!props.timeSlot || !selectedMembershipId.value) return
if (requestingSubscribe.value) return
requestingSubscribe.value = true
try {
await requestBookingCreatedSubscriptionMessage()
} catch (err: unknown) {
console.warn('[subscribe] booking confirm failed', err)
} finally {
requestingSubscribe.value = false
}
emit('confirm', {
timeSlotId: props.timeSlot.id,
membershipId: selectedMembershipId.value,
})
}
function handleCancel() {
emit('cancel')
emit('update:visible', false)
}
function handleMaskTap() {
handleCancel()
}
</script>
<style lang="scss" scoped>
.popup-mask { position: fixed; inset: 0; background: rgba(56, 48, 42, 0.32); z-index: 1000; display: flex; align-items: flex-end; justify-content: center; }
.popup-panel { width: 100%; box-sizing: border-box; background: #fbf9f6; border-radius: 36rpx 36rpx 0 0; padding: 28rpx 32rpx calc(24rpx + env(safe-area-inset-bottom)); }
.popup-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
.popup-title { font-size: 32rpx; font-weight: 500; color: #514943; }
.close-btn { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
.close-icon { font-size: 26rpx; color: #8b817b; }
.info-section { padding: 24rpx; background: #f1e8e1; border-radius: 24rpx; display: flex; flex-direction: column; gap: 14rpx; }
.info-row { display: flex; align-items: center; gap: 20rpx; }
.info-label { font-size: 24rpx; color: #8b7b70; width: 64rpx; flex-shrink: 0; }
.info-value { font-size: 26rpx; color: #6f5c50; font-weight: 400; }
.divider { height: 24rpx; }
.card-section { display: flex; flex-direction: column; gap: 16rpx; }
.section-label { font-size: 26rpx; color: #514943; font-weight: 500; }
.card-picker-wrap { height: 250rpx; }
.card-item {
display: flex; align-items: center; padding: 22rpx 24rpx; border-radius: 24rpx;
border: 2rpx solid #eee8e3; background: #fff; gap: 20rpx; box-sizing: border-box;
&.selected { border-color: #a5b8ab; background: #f0f4ee; }
}
.card-picker-wrap .card-item { margin-bottom: 12rpx; }
.card-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.card-name { font-size: 26rpx; font-weight: 400; color: #514943; overflow-wrap: anywhere; }
.card-remain { font-size: 22rpx; color: #8b817b; }
.check-mark { width: 36rpx; height: 36rpx; border-radius: 50%; background: #6b8276; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.check-icon { font-size: 22rpx; color: #fff; }
.no-card-tip { padding: 24rpx; text-align: center; }
.no-card-text { font-size: 24rpx; color: #8b817b; }
.deduction-tip { padding: 18rpx 4rpx; }
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
.subscribe-tip { padding: 0 4rpx 14rpx; text-align: center; }
.subscribe-tip-text { font-size: 21rpx; color: #7f8a7e; }
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }
.btn-confirm {
flex: 2; height: 88rpx; padding: 0; margin: 0; border: none; line-height: 1;
border-radius: 999rpx; background: #6b8276;
display: flex; align-items: center; justify-content: center;
&::after { border: none; }
&:active { background: #597264; }
&.disabled { background: #d9dfd8; }
}
.btn-confirm-text { font-size: 28rpx; color: #fff; font-weight: 500; .disabled & { color: #677467; } }
</style>