Files
mp-pilates/packages/app/src/components/BookingConfirmPopup.vue
2026-04-05 21:35:30 +08:00

430 lines
9.2 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-icon-wrap">
<text class="card-icon">💳</text>
</view>
<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 -->
<view v-else-if="memberships.length > 1" class="card-picker-wrap">
<view
v-for="m in memberships"
:key="m.id"
class="card-item"
:class="{ selected: selectedMembershipId === m.id }"
@tap="selectedMembershipId = m.id"
>
<view class="card-icon-wrap">
<text class="card-icon">💳</text>
</view>
<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>
</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.cardType.name }}扣除 1 次课时
</text>
</view>
<!-- Action buttons -->
<view class="action-row">
<view class="btn-outline" @tap="handleCancel">
<text class="btn-outline-text">取消</text>
</view>
<view
class="btn-confirm"
:class="{ disabled: !selectedMembershipId }"
@tap="handleConfirm"
>
<text class="btn-confirm-text">确认预约</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
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>('')
// Auto-select the first membership when popup opens or memberships list changes
watch(
[() => props.visible, () => props.memberships],
([visible, memberships]) => {
if (visible && memberships.length > 0) {
selectedMembershipId.value = memberships[0].id
}
},
{ immediate: true },
)
const selectedMembership = computed(() =>
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
)
function handleConfirm() {
if (!props.timeSlot || !selectedMembershipId.value) return
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;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.popup-panel {
width: 100%;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 0;
}
.popup-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.popup-title {
font-size: 34rpx;
font-weight: 700;
color: #1a1a1a;
}
.close-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
background: $primary-selected-bg;
border-radius: 50%;
}
.close-icon {
font-size: 24rpx;
color: #999;
}
/* Info rows */
.info-section {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-bottom: 28rpx;
}
.info-row {
display: flex;
flex-direction: row;
align-items: center;
}
.info-label {
font-size: 28rpx;
color: #999;
width: 80rpx;
flex-shrink: 0;
}
.info-value {
font-size: 28rpx;
color: #222;
font-weight: 500;
}
.divider {
height: 1rpx;
background: $primary-border;
margin: 8rpx 0 28rpx;
}
/* Card selection */
.card-section {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 24rpx;
}
.section-label-row {
margin-bottom: 8rpx;
}
.section-label {
font-size: 28rpx;
color: #666;
font-weight: 500;
}
.card-single,
.card-picker-wrap {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.card-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 24rpx 20rpx;
border-radius: 16rpx;
border: 2rpx solid $primary-border;
background: $primary-bg;
gap: 20rpx;
transition: border-color 0.15s, background 0.15s;
&.selected {
border-color: $primary-dark;
background: $primary-selected-bg;
}
}
.card-icon-wrap {
width: 60rpx;
height: 60rpx;
border-radius: 14rpx;
background: linear-gradient(135deg, $primary-color, $primary-dark);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-icon {
font-size: 32rpx;
}
.card-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.card-name {
font-size: 28rpx;
font-weight: 600;
color: #222;
}
.card-remain {
font-size: 22rpx;
color: #999;
}
.check-mark {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: $primary-dark;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.check-icon {
font-size: 24rpx;
color: #fff;
font-weight: 700;
}
.no-card-tip {
padding: 24rpx;
text-align: center;
}
.no-card-text {
font-size: 26rpx;
color: #bbb;
}
/* Deduction tip */
.deduction-tip {
background: $primary-selected-bg;
border-radius: 12rpx;
padding: 16rpx 20rpx;
margin-bottom: 28rpx;
}
.deduction-text {
font-size: 24rpx;
color: $primary-dark;
line-height: 1.5;
}
/* Action buttons */
.action-row {
display: flex;
flex-direction: row;
gap: 20rpx;
margin-top: 8rpx;
}
.btn-outline {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
border: 2rpx solid $primary-border;
display: flex;
align-items: center;
justify-content: center;
&:active {
background: #f5f5f5;
}
}
.btn-outline-text {
font-size: 30rpx;
color: #666;
font-weight: 500;
}
.btn-confirm {
flex: 2;
height: 88rpx;
border-radius: 44rpx;
background: linear-gradient(135deg, $primary-color, $primary-dark);
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.85;
}
&.disabled {
background: $primary-border;
}
}
.btn-confirm-text {
font-size: 30rpx;
color: #fff;
font-weight: 600;
letter-spacing: 1rpx;
.disabled & {
color: #bbb;
}
}
</style>