233 lines
9.0 KiB
Vue
233 lines
9.0 KiB
Vue
<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>
|