# WeChat Mini-Program Booking Page Analysis ## mp-pilates Project (Uni-app + Vue 3) --- ## ๐Ÿ“‹ Project Structure Overview ``` packages/app/src/ โ”œโ”€โ”€ pages/ โ”‚ โ””โ”€โ”€ booking/ โ”‚ โ””โ”€โ”€ index.vue # ๐Ÿ“ Main booking page โ”œโ”€โ”€ components/ โ”‚ โ”œโ”€โ”€ DateSelector.vue # Date picker (7 days) โ”‚ โ”œโ”€โ”€ TimePeriodFilter.vue # Morning/Afternoon/Evening filter โ”‚ โ”œโ”€โ”€ SlotCard.vue # Individual time slot card โ”‚ โ””โ”€โ”€ BookingConfirmPopup.vue # Confirmation modal โ”œโ”€โ”€ stores/ โ”‚ โ”œโ”€โ”€ booking.ts # ๐Ÿ“ Booking state management โ”‚ โ””โ”€โ”€ user.ts # User/membership state โ””โ”€โ”€ utils/ โ”œโ”€โ”€ request.ts # API request utilities โ””โ”€โ”€ format.ts # Date/time formatting utilities ``` --- ## ๐ŸŽฏ API Flow ### Endpoint: `/api/time-slot/available?date=YYYY-MM-DD` **Request:** - Method: `GET` - Query params: `date` (YYYY-MM-DD format) - Authentication: Bearer token from localStorage **Response Format (from your example):** ```json { "success": true, "data": [ { "id": "string (UUID)", "date": "2026-04-05", "startTime": "09:00", "endTime": "10:00", "capacity": 1, "bookedCount": 0, "status": "OPEN", "source": "MANUAL", "templateId": null, "isBookedByMe": false, "myBookingId": null } ], "message": null } ``` **Status Values:** - `OPEN` - Available to book - `FULL` - All slots booked - `CLOSED` - Time slot closed **Source Values:** - `MANUAL` - Manually created - `TEMPLATE` - Generated from template --- ## ๐Ÿ”„ Complete Data Flow Diagram ``` User Opens Booking Page โ†“ [onMounted] Lifecycle Hook โ†“ 1. Check if logged in + fetch memberships (if needed) 2. Load today's slots: bookingStore.fetchSlots(today) โ†“ bookingStore.fetchSlots(date: string) โ†“ request.get( '/time-slot/available', { date } ) โ†“ Sets: bookingStore.slots = [TimeSlotWithBookingStatus[], ...] โ†“ Vue renders via computed: filteredSlots โ†“ User selects date OR filters by time period โ†“ Updates: selectedDate.value or selectedPeriod.value โ†“ Computed filteredSlots re-calculates โ†“ Renders SlotCard components โ†“ User taps "ๅฏ้ข„็บฆ" (Book Button) โ†“ [onBookTap(slot)] - Check login (if not โ†’ show login modal) - Check valid membership (if not โ†’ show purchase modal) - Show BookingConfirmPopup โ†“ User selects membership + confirms โ†“ [onConfirmBooking(payload)] - bookingStore.createBooking({timeSlotId, membershipId}) - POST /api/booking - Refresh slots: loadSlots(selectedDate.value) โ†“ Success/Error Toast ``` --- ## ๐Ÿ“„ File-by-File Analysis ### 1๏ธโƒฃ **pages/booking/index.vue** (Main Component) **Template Structure:** ``` .booking-page โ”œโ”€โ”€ .sticky-header (z-index: 100) โ”‚ โ”œโ”€โ”€ DateSelector (v-model="selectedDate") โ”‚ โ””โ”€โ”€ TimePeriodFilter (v-model="selectedPeriod") โ”œโ”€โ”€ scroll-view.slot-scroll โ”‚ โ”œโ”€โ”€ Loading skeleton (4 cards) - when loadingSlots โ”‚ โ”œโ”€โ”€ Empty state - when no slots โ”‚ โ””โ”€โ”€ SlotCard list - main content โ”‚ โ””โ”€โ”€ SlotCard (v-for="slot in filteredSlots") โ””โ”€โ”€ BookingConfirmPopup (conditional) ``` **Script Setup - State Variables:** ```typescript selectedDate: ref // YYYY-MM-DD format selectedPeriod: ref // 'MORNING'|'AFTERNOON'|'EVENING'|null showConfirmPopup: ref // Modal visibility pendingSlot: ref // Slot being booked refreshing: ref // Pull-to-refresh state ``` **Computed Properties:** ```typescript scrollHeight: computed(() => { // Calculates scroll area height: // windowHeight - headerHeight (220rpx) - tabbarHeight (100rpx) // Converts rpx to pixels dynamically }) filteredSlots: computed(() => { // If no period selected: return all slots // If period selected: filter by TIME_PERIODS[selectedPeriod].start/.end // Compares slot.startTime with period.start and period.end }) ``` **Key Lifecycle - onMounted():** ```typescript 1. If logged in but no memberships fetched yet: โ†’ await userStore.fetchMemberships() 2. Load today's slots: โ†’ await loadSlots(formatDate(new Date())) ``` **Event Handlers:** **onDateSelect(date: string)** โ†’ Changes selectedDate, calls loadSlots() **onPeriodChange(period)** โ†’ Updates selectedPeriod (filtering is automatic via computed) **onRefresh()** โ†’ Pull-to-refresh handler ```typescript refreshing.value = true await loadSlots(selectedDate.value) refreshing.value = false ``` **onBookTap(slot)** โ†’ Book button clicked: 1. Check login status โ†’ show login modal if needed 2. Check hasValidMembership โ†’ show purchase modal if needed 3. Set pendingSlot = slot 4. Show BookingConfirmPopup **onConfirmBooking(payload)** โ†’ User confirms booking: ```typescript await bookingStore.createBooking(payload) // payload: { timeSlotId, membershipId } await loadSlots(selectedDate.value) // Refresh ``` **onCancelTap(slot)** โ†’ Cancel booking: ```typescript if (!slot.myBookingId) return // Show confirmation modal await bookingStore.cancelBooking(slot.myBookingId) await loadSlots(selectedDate.value) // Refresh ``` **Styles:** - Page background: `#f5f3f0` (light beige) - Sticky header with box-shadow - Loading skeleton with shimmer animation - Empty state centered with image --- ### 2๏ธโƒฃ **stores/booking.ts** (State Management) **State:** ```typescript slots: ref([]) myBookings: ref([]) upcomingBookings: ref([]) loadingSlots: ref(false) loadingBookings: ref(false) ``` **Actions:** **fetchSlots(date: string)** ```typescript async function fetchSlots(date: string) { loadingSlots.value = true try { slots.value = await get( '/time-slot/available', { date } // โ† date as query param ) } catch (err) { console.error('Fetch slots failed:', err) slots.value = [] } finally { loadingSlots.value = false } } ``` โš ๏ธ **CRITICAL:** If request fails, slots.value becomes empty [] **createBooking(dto: CreateBookingDto)** ```typescript // dto: { timeSlotId: string; membershipId: string } const result = await post('/booking', dto) return result ``` **cancelBooking(bookingId: string)** ```typescript const result = await put(`/booking/${bookingId}/cancel`) return result ``` **fetchMyBookings(status?: string)** ```typescript const params = status ? { status } : {} myBookings.value = await get('/booking/my', params) ``` **fetchUpcomingBookings()** ```typescript upcomingBookings.value = await get('/booking/my/upcoming') ``` --- ### 3๏ธโƒฃ **components/SlotCard.vue** (Individual Slot) **Props:** ```typescript interface Props { slot: TimeSlotWithBookingStatus } ``` **Emits:** ```typescript book: [slot] // User wants to book cancel: [slot] // User wants to cancel ``` **Template Sections:** **1. Time & Capacity:** ```vue {{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }} {{ capacityLabel }} ``` **2. Action Buttons (4 States):** **State A: OPEN + not booked by me** ```vue ๅฏ้ข„็บฆ ``` **State B: OPEN + booked by me** ```vue ๅทฒ้ข„็บฆ ๅ–ๆถˆ ``` **State C: FULL** ```vue ๅทฒ็บฆๆปก ``` **State D: CLOSED** ```vue ๅทฒๅ…ณ้—ญ ``` **3. Booked Indicator:** ```vue ``` **Computed Properties:** **capacityLabel:** ```typescript if (status === CLOSED) return 'ๅทฒๅ…ณ้—ญ' return `${bookedCount}/${capacity} ไบบ` // e.g., "0/1 ไบบ" ``` **capacityClass:** Determines background color ``` CLOSED โ†’ cap-closed (gray) FULL โ†’ cap-full (red bg, red text) โ‰ฅ80% โ†’ cap-almost (orange bg, orange text) <80% โ†’ cap-open (green bg, green text) ``` **Styles:** - Card: white background, 20rpx border-radius, shadow - Time text: 36rpx, bold, dark - Capacity badge: 22rpx, inline-flex, colored backgrounds - Buttons: rounded pills (68rpx height, 34rpx border-radius) - Cancel text: underlined, red (#ef4444) - Booked bar: 6rpx tan bar on left edge --- ### 4๏ธโƒฃ **components/DateSelector.vue** (Date Picker) **Props:** ```typescript interface Props { modelValue: string // YYYY-MM-DD } ``` **Emits:** - `update:modelValue` - v-model update - `select` - Custom event on selection **Data:** ```typescript dateRange: computed(() => getDateRange(DATE_SELECTOR_DAYS)) // DATE_SELECTOR_DAYS = 7 // Returns array of { date, weekday, isToday } ``` **Template:** ```vue {{ item.isToday ? 'ไปŠๅคฉ' : item.weekday }} {{ getDayNumber(item.date) }} {{ getMonthNumber(item.date) }}ๆœˆ ``` **Date Display Format:** - Weekday: "ๅ‘จไธ€", "ๅ‘จไบŒ", or "ไปŠๅคฉ" - Day: Large bold number (e.g., "5") - Month: Small number (e.g., "4ๆœˆ") **Styles:** - Active state: tan background (#c9a87c), white text - Today highlight: tan-colored weekday text (even if not active) - Horizontal scroll, no scrollbar --- ### 5๏ธโƒฃ **components/TimePeriodFilter.vue** (Period Filter) **Props:** ```typescript type PeriodKey = keyof typeof TIME_PERIODS | null interface Props { modelValue: PeriodKey } ``` **Emits:** - `update:modelValue` - v-model update - `change` - Custom event **Constants:** ```typescript const TIME_PERIODS = { MORNING: { label: 'ไธŠๅˆ', start: '06:00', end: '12:00' }, AFTERNOON: { label: 'ไธ‹ๅˆ', start: '12:00', end: '18:00' }, EVENING: { label: 'ๆ™šไธŠ', start: '18:00', end: '22:00' }, } ``` **Tabs Generated:** ```typescript [ { key: null, label: 'ๅ…จ้ƒจ' }, { key: 'MORNING', label: 'ไธŠๅˆ' }, { key: 'AFTERNOON', label: 'ไธ‹ๅˆ' }, { key: 'EVENING', label: 'ๆ™šไธŠ' }, ] ``` **Template:** ```vue {{ tab.label }} ``` **Active State:** - Text color: tan (#c9a87c), weight: 600 - Bottom border: 4rpx tan underline (CSS ::after) --- ### 6๏ธโƒฃ **components/BookingConfirmPopup.vue** (Confirmation Modal) **Props:** ```typescript visible: boolean slot: TimeSlotWithBookingStatus | null memberships: MembershipWithCardType[] ``` **Emits:** - `confirm` - { timeSlotId, membershipId } - `cancel` - Popup closes - `update:visible` - Manual visibility control **Template Sections:** **1. Overlay Mask:** ```vue ``` **2. Header:** ```vue ็กฎ่ฎค้ข„็บฆ โœ• ``` **3. Info Section (read-only display):** ``` ๆ—ฅๆœŸ: 2026-04-05 ๆ—ถ้—ด: 09:00 - 10:00 ๅ‰ฉไฝ™: 1 ไธชๅ้ข ``` **4. Membership Card Selection:** **Case A: 1 membership** ```vue ๐Ÿ’ณ {{ membership.cardType.name }} ๅ‰ฉไฝ™ {{ remainingTimes }} ๆฌก โœ“ ``` (Auto-selected, pre-filled) **Case B: Multiple memberships** ```vue ``` **5. Deduction Tip:** ```vue ็กฎ่ฎคๅŽๅฐ†ไปŽใ€Œ{{ selectedMembership.cardType.name }}ใ€ๆ‰ฃ้™ค 1 ๆฌก่ฏพๆ—ถ ``` **6. Action Buttons:** ``` [ๅ–ๆถˆ] [็กฎ่ฎค้ข„็บฆ] (Outline) (Tan solid) (Disabled if no membership selected) ``` **Auto-selection Logic:** ```typescript watch([() => props.visible, () => props.memberships], ([visible, memberships]) => { if (visible && memberships.length > 0) { selectedMembershipId.value = memberships[0].id } }, { immediate: true } ) ``` **Confirm Handler:** ```typescript function handleConfirm() { emit('confirm', { timeSlotId: props.slot.id, membershipId: selectedMembershipId.value, }) } ``` **Styles:** - Modal: Fixed positioning, rgba(0,0,0,0.45) dark overlay - Panel: White background, rounded top corners, 32rpx padding - Card items: 24rpx padding, border, transition on select - Buttons: 88rpx height, rounded pills (44rpx) - Cancel: Outline style, gray text - Confirm: Solid tan background, white text --- ### 7๏ธโƒฃ **stores/user.ts** (User State) **Key State:** ```typescript user: ref(null) memberships: ref([]) token: ref(uni.getStorageSync('token')) ``` **Key Computed:** ```typescript loggedIn: computed(() => !!token.value) activeMemberships: computed(() => memberships.value.filter(m => m.status === MembershipStatus.ACTIVE) ) hasValidMembership: computed(() => activeMemberships.value.length > 0) ``` **Key Actions:** ```typescript async function login() async function fetchMemberships() // GET /membership/my async function logout() ``` --- ### 8๏ธโƒฃ **utils/request.ts** (API Client) **Base URL Logic:** ```typescript const BASE_URL = (() => { const { miniProgram } = uni.getAccountInfoSync() if (miniProgram.envVersion !== 'develop') { return 'https://focus.richarjiang.com/api' } return 'http://localhost:3000/api' })() ``` **Main request() function:** ```typescript function request(options: RequestOptions): Promise { // 1. Get token from localStorage const token = uni.getStorageSync('token') // 2. Call uni.request with: // - Authorization header (Bearer token) // - Content-Type: application/json // 3. Response handling: // - 401 โ†’ Clear token, show "please login", reject // - โ‰ฅ400 โ†’ Extract error from response.message, reject // - <400 & success: true โ†’ Resolve with data // - <400 & success: false โ†’ Reject with message // 4. Network fail โ†’ Reject with errMsg } export function get(url, data?): Promise export function post(url, data?): Promise export function put(url, data?): Promise ``` **โš ๏ธ GET Request Issue:** ```typescript // In get(), data becomes the request body // But uni.request with GET should NOT have a body // Query params should be in the URL string // This might cause issues on some platforms! ``` --- ### 9๏ธโƒฃ **utils/format.ts** (Date Utilities) ```typescript formatDate(date): string // Returns YYYY-MM-DD getWeekdayLabel(date): string // Returns "ๅ‘จไธ€", "ๅ‘จไบŒ", ..., "ๅ‘จๆ—ฅ" isToday(date): boolean // Compares year/month/day getDateRange(days: number): ReadonlyArray // Returns array of: // { // date: YYYY-MM-DD, // weekday: "ๅ‘จไธ€" | "ไปŠๅคฉ" (if i===0), // isToday: boolean // } // Uses i * 86400000ms for date increment ``` --- ## ๐Ÿ” Data Types Overview ### TimeSlotWithBookingStatus (Extended from TimeSlot) ```typescript interface TimeSlotWithBookingStatus extends TimeSlot { readonly isBookedByMe: boolean // Has user already booked? readonly myBookingId: string | null // ID needed to cancel } interface TimeSlot { readonly id: string // UUID readonly date: string // YYYY-MM-DD readonly startTime: string // HH:MM readonly endTime: string // HH:MM readonly capacity: number // Max people readonly bookedCount: number // Already booked readonly status: TimeSlotStatus // OPEN|FULL|CLOSED readonly source: TimeSlotSource // TEMPLATE|MANUAL readonly templateId: string | null } ``` ### MembershipWithCardType ```typescript interface MembershipWithCardType { readonly id: string readonly cardType: CardType readonly status: MembershipStatus // ACTIVE|EXPIRED|USED_UP readonly remainingTimes: number | null readonly expireDate: string // YYYY-MM-DD } ``` ### CreateBookingDto ```typescript interface CreateBookingDto { readonly timeSlotId: string readonly membershipId: string } ``` --- ## ๐ŸŽจ Color Scheme | Element | Color | Hex | Usage | |---------|-------|-----|-------| | Primary (Accent) | Tan/Brown | #c9a87c | Buttons, active tabs, highlights | | Background | Light Beige | #f5f3f0 | Page background | | Text Primary | Dark Gray | #1a1a1a | Main headings | | Text Secondary | Medium Gray | #666/#999 | Labels, descriptions | | Text Tertiary | Light Gray | #bbb | Disabled, hints | | Success | Green | #4caf50 | Open slots (capacity label) | | Warning | Orange | #f59e0b | Almost full (capacity label) | | Error | Red | #ef4444 | Full/closed, cancel button | | Borders | Very Light Gray | #f0f0f0/#f0ece8 | Dividers, borders | --- ## โš ๏ธ Potential Issues & Problems ### 1. **GET Request Body Issue** **File:** `utils/request.ts` in `get()` function ```typescript export function get(url: string, data?: Record): Promise { return request({ url, method: 'GET', data }) // โ† data as body! } ``` **Problem:** GET requests shouldn't have a body. Query params should be in the URL. **Impact:** `/time-slot/available?date=2026-04-05` might not work on all platforms. ### 2. **Empty Slots Array on Error** **File:** `stores/booking.ts`, `fetchSlots()` ```typescript catch (err) { console.error('Fetch slots failed:', err) slots.value = [] // โ† Clears state on error! } ``` **Problem:** Network error โ†’ page shows "empty state" instead of error message. **Impact:** Users can't tell if there's an error or truly no slots available. ### 3. **No Error Handling in Main Page** **File:** `pages/booking/index.vue`, `loadSlots()` ```typescript async function loadSlots(date: string) { await bookingStore.fetchSlots(date) // โ† No error handling, no user feedback } ``` **Problem:** If fetchSlots() fails, user sees empty page with no explanation. ### 4. **Manual Date Calculation** **File:** `utils/format.ts`, `getDateRange()` ```typescript const d = new Date(now.getTime() + i * 86400000) ``` **Problem:** Doesn't account for DST transitions. Using `Date.setDate()` would be safer. ### 5. **No Loading State for Slots** **File:** `pages/booking/index.vue` ```typescript ``` **Problem:** Skeleton appears only on initial load, not when changing dates or refreshing. **Impact:** Date changes appear instant (good UX but confusing if slow network). ### 6. **Hardcoded Membership Message** **File:** `components/BookingConfirmPopup.vue` ```typescript ็กฎ่ฎคๅŽๅฐ†ไปŽใ€Œ{{ selectedMembership.cardType.name }}ใ€ๆ‰ฃ้™ค 1 ๆฌก่ฏพๆ—ถ // โ† Always says "1 ๆฌก" even if card might deduct different amounts ``` **Problem:** Doesn't show actual deduction amount if dynamic. --- ## ๐Ÿ“Š Event Flow Sequence ``` 1. PAGE LOAD (onMounted) โ”œโ”€ Check: userStore.loggedIn? โ”œโ”€ If yes & no memberships: fetchMemberships() โ””โ”€ loadSlots(today) โ””โ”€ GET /time-slot/available?date=today โ””โ”€ bookingStore.slots = [...] โ””โ”€ render SlotCard components 2. USER TAPS DATE โ”œโ”€ selectedDate.value = newDate โ””โ”€ onDateSelect(newDate) โ””โ”€ loadSlots(newDate) โ””โ”€ fetchSlots() 3. USER FILTERS PERIOD โ”œโ”€ selectedPeriod.value = MORNING|AFTERNOON|EVENING|null โ””โ”€ filteredSlots computed updates โ””โ”€ SlotCards re-render (no new API call) 4. USER PULLS TO REFRESH โ”œโ”€ onRefresh() โ””โ”€ loadSlots(selectedDate.value) 5. USER TAPS "ๅฏ้ข„็บฆ" BUTTON โ”œโ”€ onBookTap(slot) โ”œโ”€ Check login (if not โ†’ login modal) โ”œโ”€ Check membership (if not โ†’ purchase modal) โ””โ”€ Show BookingConfirmPopup โ””โ”€ Pre-select first membership 6. USER CONFIRMS BOOKING โ”œโ”€ onConfirmBooking({timeSlotId, membershipId}) โ”œโ”€ POST /booking โ”‚ โ””โ”€ bookingStore.createBooking() โ”œโ”€ Show success toast โ””โ”€ loadSlots(selectedDate.value) // Refresh โ””โ”€ Updated slot.isBookedByMe = true 7. USER TAPS "ๅ–ๆถˆ" BUTTON โ”œโ”€ onCancelTap(slot) โ”œโ”€ Confirm modal โ”œโ”€ PUT /booking/:id/cancel โ”‚ โ””โ”€ bookingStore.cancelBooking() โ”œโ”€ Show success toast โ””โ”€ loadSlots(selectedDate.value) // Refresh โ””โ”€ Updated slot.isBookedByMe = false ``` --- ## ๐Ÿงช Testing Scenarios ### โœ… Happy Path - [ ] Load page โ†’ today's slots display - [ ] Tap date โ†’ slots for that date display - [ ] Filter by period โ†’ slots filtered correctly - [ ] Tap "ๅฏ้ข„็บฆ" โ†’ popup shows with correct time/date - [ ] Select membership โ†’ deduction message updates - [ ] Confirm โ†’ booking created, slot shows "ๅทฒ้ข„็บฆ" - [ ] Pull to refresh โ†’ slots reload - [ ] Tap "ๅ–ๆถˆ" โ†’ booking cancelled, slot back to "ๅฏ้ข„็บฆ" ### โš ๏ธ Edge Cases - [ ] No slots for date โ†’ empty state appears - [ ] User not logged in โ†’ login modal shows - [ ] No valid membership โ†’ purchase modal shows - [ ] Network error โ†’ ??? (currently shows empty) - [ ] Slot changes to FULL โ†’ button becomes disabled - [ ] Slot changes to CLOSED โ†’ button becomes disabled --- ## ๐Ÿ”ง Integration Points **From Backend:** 1. โœ… GET `/time-slot/available?date=...` โ†’ Returns slots 2. โœ… POST `/booking` โ†’ Create booking 3. โœ… PUT `/booking/:id/cancel` โ†’ Cancel booking 4. โœ… GET `/membership/my` โ†’ List memberships 5. โœ… Auth via Bearer token **From Frontend:** 1. โœ… LocalStorage for token persistence 2. โœ… uni.showModal, uni.showToast for UI feedback 3. โœ… uni.getSystemInfoSync() for responsive sizing 4. โœ… uni.navigateTo() for page navigation --- ## ๐Ÿ“ฑ Responsive Layout **Design Breakpoint:** - Base: 750rpx (WeChat standard width unit) - Window height: dynamic via uni.getSystemInfoSync().windowHeight **Scroll Area Height Calculation:** ```typescript scrollHeight = windowHeight - headerHeight(220rpx) - tabbarHeight(100rpx) = windowHeight - (220 * (windowWidth / 750)) - (100 * (windowWidth / 750)) ``` **Sticky Header:** - Position: sticky (CSS) - Top: 0 - Z-index: 100 - Contains: DateSelector + TimePeriodFilter --- ## ๐ŸŽฏ Summary The booking system is well-architected with: - โœ… Clear separation of concerns (component, store, utils) - โœ… Proper type safety with TypeScript - โœ… Responsive date/time selection - โœ… Membership-based booking validation - โœ… Optimistic loading states - โœ… Accessible UI patterns But needs: - โš ๏ธ Better error handling - โš ๏ธ Fix GET request implementation - โš ๏ธ Loading state during date/period changes - โš ๏ธ Network error user feedback