diff --git a/ADMIN_SCHEDULING_EXPLORATION.md b/ADMIN_SCHEDULING_EXPLORATION.md deleted file mode 100644 index e5e638b..0000000 --- a/ADMIN_SCHEDULING_EXPLORATION.md +++ /dev/null @@ -1,803 +0,0 @@ -# WeChat Mini-Program Admin Scheduling/ๆŽ’่ฏพ่ฎพ็ฝฎ - Complete Exploration Report - -**Date**: 2026-04-05 -**Project**: mp-pilates (WeChat mini-program for pilates studio bookings) - ---- - -## ๐Ÿ“‹ Executive Summary - -This is a **Pilates studio booking management system** with a comprehensive admin scheduling UI. The "ๆŽ’่ฏพ่ฎพ็ฝฎ" (Schedule Setup) feature allows admins to: -1. Define recurring weekly class templates (ๆ—ถ้—ดๆจกๆฟ) -2. Manually add time slots for specific dates -3. Close slots (ไธดๆ—ถ่ฐƒๆ•ด โ†’ ๅ…ณ้—ญๆ—ถๆฎต) -4. Batch generate slots from templates - -The architecture uses: -- **Frontend**: Vue 3 + TypeScript (WeChat mini-program with Taro/UNI framework) -- **Backend**: NestJS + Prisma ORM -- **State Management**: Pinia (Vue state management) -- **Database**: Likely PostgreSQL/MySQL with Prisma - ---- - -## ๐Ÿ—‚๏ธ File Structure - -### Frontend Admin Pages -``` -packages/app/src/pages/admin/ -โ”œโ”€โ”€ index.vue # Admin dashboard with nav grid -โ”œโ”€โ”€ week-template.vue # ๐Ÿ“… Scheduling/ๆŽ’่ฏพ่ฎพ็ฝฎ - Main feature -โ”œโ”€โ”€ slot-adjust.vue # ๐Ÿ”ง Temporary adjustments (3 tabs) -โ”œโ”€โ”€ members.vue # ๐Ÿ‘ฅ Member management -โ”œโ”€โ”€ orders.vue # ๐Ÿ“‹ Order management -โ”œโ”€โ”€ card-types.vue # ๐Ÿ’ณ Card type management -โ””โ”€โ”€ studio.vue # ๐Ÿข Studio settings -``` - -### Stores -``` -packages/app/src/stores/ -โ””โ”€โ”€ admin.ts # Pinia store with all admin API calls -``` - -### Backend API -``` -packages/server/src/ -โ”œโ”€โ”€ time-slot/ -โ”‚ โ”œโ”€โ”€ time-slot.controller.ts # Admin & member endpoints for slots -โ”‚ โ”œโ”€โ”€ time-slot.service.ts # Business logic for slots -โ”‚ โ”œโ”€โ”€ slot-generator.service.ts # Template-based slot generation -โ”‚ โ””โ”€โ”€ dto/ -โ”‚ โ”œโ”€โ”€ week-template.dto.ts # Input validation -โ”‚ โ”œโ”€โ”€ create-manual-slot.dto.ts -โ”‚ โ””โ”€โ”€ query-slots.dto.ts -โ”œโ”€โ”€ studio/ -โ”‚ โ””โ”€โ”€ studio.controller.ts # Studio config (admin endpoints) -โ””โ”€โ”€ scheduler/ # Cron scheduler for auto-generation -``` - -### Shared Types -``` -packages/shared/src/types/ -โ”œโ”€โ”€ week-template.ts # WeekTemplate interface -โ”œโ”€โ”€ time-slot.ts # TimeSlot interface -โ””โ”€โ”€ constants.ts # WEEKDAY_LABELS, SLOT_GENERATION_DAYS -``` - ---- - -## ๐Ÿ”‘ Key Components - -### 1. **Admin Dashboard (index.vue)** - -**File**: `packages/app/src/pages/admin/index.vue` - -**Features**: -- Display stats: today's bookings, total orders, total bookings -- Navigation grid to 6 admin modules: - - ๐Ÿ“… **ๆŽ’่ฏพ่ฎพ็ฝฎ** โ†’ `/pages/admin/week-template` - - ๐Ÿ”ง **ไธดๆ—ถ่ฐƒๆ•ด** โ†’ `/pages/admin/slot-adjust` - - ๐Ÿ‘ฅ **ไผšๅ‘˜็ฎก็†** โ†’ `/pages/admin/members` - - ๐Ÿ“‹ **่ฎขๅ•็ฎก็†** โ†’ `/pages/admin/orders` - - ๐Ÿ’ณ **ๅก็ง็ฎก็†** โ†’ `/pages/admin/card-types` - - ๐Ÿข **ๅทฅไฝœๅฎค่ฎพ็ฝฎ** โ†’ `/pages/admin/studio` - -**Key Functions**: -```typescript -- navigate(path): Navigates to admin pages -- loadStats(): Fetches dashboard statistics via adminStore.fetchDashboardStats() -``` - -**State**: -```typescript -const stats = ref({ todayBookings: 0, totalOrders: 0, totalBookings: 0 }) -const statsLoading = ref(false) -``` - ---- - -### 2. **Week Template Management (week-template.vue)** โœจ MAIN SCHEDULING UI - -**File**: `packages/app/src/pages/admin/week-template.vue` -**Route**: `/pages/admin/week-template` - -#### Purpose -Manage recurring weekly schedule templates. These are used to **auto-generate** time slots for future weeks. - -#### Data Structure -```typescript -interface WeekTemplate { - readonly id: string - readonly dayOfWeek: number // 1=Mon, 2=Tue, ..., 7=Sun (ISO format) - readonly startTime: string // HH:MM format - readonly endTime: string // HH:MM format - readonly capacity: number // Max bookings per slot - readonly isActive: boolean // Enable/disable template - readonly createdAt: string - readonly updatedAt: string -} -``` - -#### UI Sections -1. **Toolbar** - - Display template count: "ๅ…ฑ N ๆกๆจกๆฟ" - - "+ ๆ–ฐๅขžๆ—ถๆฎต" (Add new slot) button - -2. **Template List** (Grouped by weekday) - - Days are sorted (Monday โ†’ Sunday) - - Each day shows count: "3 ไธชๆ—ถๆฎต" - - Each template row displays: - - Time range: "09:00 โ€“ 10:00" - - Capacity: "10 ไบบ" - - Actions: - - Toggle button: "ๅฏ็”จ"/"ๅœ็”จ" (Enable/Disable) - - "็ผ–่พ‘" (Edit) - - "ๅˆ ้™ค" (Delete) - - Inactive templates are grayed out (opacity: 0.5) - -3. **Modal for Add/Edit** - - Star date picker (1-7 for weekday) - - Start time picker - - End time picker - - Capacity input (number) - - Validation: time and capacity required - - Cancel/Confirm buttons - -4. **Save Bar** (Fixed at bottom) - - Only shows when `isDirty` flag is true - - "ไฟๅญ˜ๅ…จ้ƒจๆ›ดๆ”น" button with loading state - -#### Key Functions - -```typescript -async fetchTemplates() - - Fetches all templates from backend - - Groups by dayOfWeek for display - - Clears isDirty flag - -async handleSave() - - Maps local template state to API payload - - Calls adminStore.saveWeekTemplates(payload) - - Refreshes templates after save - - Shows success/error toast - -function openAdd() - - Opens modal for creating new template - - Clears form - -function openEdit(tpl) - - Opens modal in edit mode - - Populates form with existing values - -function submitForm() - - Validates form (time and capacity required) - - Creates or updates template in memory - - Sets isDirty = true (triggers save bar) - -function toggleTemplate(tpl) - - Toggles isActive flag - - Sets isDirty = true - -function deleteTemplate(tpl) - - Shows confirmation modal - - Removes from array - - Sets isDirty = true -``` - -#### Local State Management -```typescript -const templates = ref([]) -const loading = ref(false) -const saving = ref(false) -const isDirty = ref(false) // Tracks unsaved changes -const showModal = ref(false) -const editTarget = ref(null) - -const form = ref({ - dayIdx: 0, // Selected day index (0-6) - startTime: '09:00', - endTime: '10:00', - capacityStr: '10', -}) - -const grouped = computed(() => { - // Groups templates by dayOfWeek for rendering - return Object.fromEntries( - Object.entries(map).sort(([a], [b]) => Number(a) - Number(b)) - ) -}) -``` - -#### Example: Adding a Monday 9AM-10AM class -1. User taps "+ ๆ–ฐๅขžๆ—ถๆฎต" -2. Modal opens, form is reset to defaults -3. User selects "ๅ‘จไธ€" (Monday) from picker -4. User confirms times and capacity -5. New template object is pushed to `templates` array -6. `isDirty` is set to true โ†’ save bar appears -7. User taps "ไฟๅญ˜ๅ…จ้ƒจๆ›ดๆ”น" -8. Store calls `PUT /admin/week-template` with all templates -9. Backend deletes all old templates and creates new ones -10. Frontend refetches and displays updated list - ---- - -### 3. **Slot Adjustment (slot-adjust.vue)** - Temporary Slot Management - -**File**: `packages/app/src/pages/admin/slot-adjust.vue` -**Route**: `/pages/admin/slot-adjust` - -#### Purpose -Handle temporary/manual time slot operations: -1. Add one-off slots for specific dates -2. Close available slots -3. Batch-generate slots from templates - -#### UI Structure (3 Tabs) - -##### Tab 0: "ๆ–ฐๅขžๆ—ถๆฎต" (Add Manual Slot) -- Date picker (defaults to today) -- Start/End time pickers -- Capacity input -- Submit button: "ๆ–ฐๅขžๆ—ถๆฎต" -- **Endpoint**: `POST /admin/time-slot/manual` - -```typescript -interface CreateManualSlotDto { - date: string // YYYY-MM-DD - startTime: string // HH:MM - endTime: string // HH:MM - capacity?: number // Defaults to DEFAULT_SLOT_CAPACITY (1) -} -``` - -##### Tab 1: "ๅ…ณ้—ญๆ—ถๆฎต" (Close Slots) -- Date picker (defaults to today) -- Loads all slots for selected date -- Displays slot list with: - - Time range - - Status badge (OPEN/FULL/CLOSED) - - Booked count: "X/Y" - - Close button (if not already closed) -- Confirmation modal when closing -- **Endpoint**: `PUT /admin/time-slot/:id/close` - -**Slot Status Colors**: -``` -OPEN โ†’ Green badge #27ae60 -FULL โ†’ Orange badge #e67e22 -CLOSED โ†’ Gray badge #999 -``` - -##### Tab 2: "ๆ‰น้‡็”Ÿๆˆ" (Batch Generate) -- Start date picker -- End date picker (defaults to +7 days) -- Hint: "ๅฐ†ๆ นๆฎๆŽ’่ฏพๆจกๆฟ๏ผŒ่‡ชๅŠจ็”Ÿๆˆๆ‰€้€‰ๆ—ฅๆœŸ่Œƒๅ›ดๅ†…็š„ๆ—ถๆฎต" - - *"Will auto-generate slots for selected date range based on schedule template"* -- Submit button: "ๆ‰น้‡็”Ÿๆˆ" -- **Endpoint**: `POST /admin/generate-slots` - -**How it works**: -1. Frontend sends date range to backend -2. Backend fetches all **active** WeekTemplates -3. For each day in range, finds matching templates by weekday -4. Creates TimeSlot records with `source: TEMPLATE` -5. Uses `skipDuplicates: true` to avoid re-generating existing slots - -```typescript -// Backend example: If templates include: -// - Monday: 09:00-10:00, 18:00-19:00 (2 templates) -// - Wednesday: 10:00-11:00 (1 template) -// -// And user selects 2026-04-05 to 2026-04-11: -// - Mon 04-06: 2 slots generated -// - Wed 04-08: 1 slot generated -// Total: 3 slots (if these dates fall in range) -``` - -#### Key Functions -```typescript -async submitAddSlot() - - POST /admin/time-slot/manual - - Shows success/error toast - -async loadSlotsForClose() - - Fetches slots for closeDate via adminStore.fetchSlotsByDate(date) - - Sets slotsLoading flag - -async closeSlot(slot) - - Confirmation modal - - PUT /admin/time-slot/:id/close - - Reloads slot list - -async submitGenerate() - - POST /admin/generate-slots with date range - - Shows toast with count of generated slots -``` - -#### Local State -```typescript -const activeTab = ref(0) // 0=Add, 1=Close, 2=Generate -const submitting = ref(false) -const slotsLoading = ref(false) - -// Tab 0: Add form -const addForm = ref({ - date: formatDate(new Date()), - startTime: '09:00', - endTime: '10:00', - capacityStr: '10', -}) - -// Tab 1: Close slots -const closeDate = ref(formatDate(new Date())) -const daySlots = ref([]) - -// Tab 2: Generate form -const genForm = ref({ - startDate: formatDate(new Date()), - endDate: formatDate(new Date(Date.now() + 7 * 86400000)), // +7 days -}) -``` - ---- - -### 4. **Admin Store (Pinia)** - -**File**: `packages/app/src/stores/admin.ts` - -#### API Methods Related to Scheduling - -```typescript -// โ”€โ”€ Week templates โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async fetchWeekTemplates(): Promise - // GET /admin/week-template - // Returns all templates for current studio - // Usage: Gets templates for display in week-template.vue - -async saveWeekTemplates(templates: WeekTemplateInput[]): Promise - // PUT /admin/week-template - // Body: { templates: [...] } - // Replaces ALL templates with new set (delete all, create new) - // Note: Backend uses transaction for atomicity - -// โ”€โ”€ Time slots โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async fetchSlotsByDate(date: string): Promise - // GET /admin/time-slots?date=YYYY-MM-DD - // Returns all slots for a specific date - // Used in slot-adjust.vue Tab 1 (close slots) - -async createManualSlot(dto: CreateManualSlotDto): Promise - // POST /admin/time-slot/manual - // Creates a one-off time slot - // Used in slot-adjust.vue Tab 0 - -async closeSlot(id: string): Promise - // PUT /admin/time-slot/:id/close - // Changes slot status from OPEN to CLOSED - // Used in slot-adjust.vue Tab 1 - -async generateSlots(startDate: string, endDate: string): Promise<{ count: number }> - // POST /admin/generate-slots - // Generates slots from active templates for date range - // Used in slot-adjust.vue Tab 2 - // Returns: { count: number of newly created slots } - -// โ”€โ”€ Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async fetchDashboardStats(): Promise - // GET /admin/stats - // Returns: { todayBookings, totalOrders, totalBookings } - // Used in index.vue -``` - -#### API Response Types -```typescript -interface AdminStats { - todayBookings: number - totalOrders: number - totalBookings: number -} - -interface WeekTemplate { - id: string - dayOfWeek: number // 1-7 (ISO weekday) - startTime: string // HH:MM - endTime: string // HH:MM - capacity: number - isActive: boolean - createdAt: string - updatedAt: string -} - -interface TimeSlot { - id: string - date: string // YYYY-MM-DD - startTime: string - endTime: string - capacity: number - bookedCount: number - status: TimeSlotStatus // OPEN | FULL | CLOSED - source: TimeSlotSource // TEMPLATE | MANUAL - templateId: string | null - createdAt: string - updatedAt: string -} -``` - ---- - -## ๐Ÿ”Œ Backend Architecture - -### Time Slot Controller -**File**: `packages/server/src/time-slot/time-slot.controller.ts` - -#### Member Endpoints (Public) -``` -GET /time-slot/available?date=YYYY-MM-DD - - Get available slots for a date - - Include booking status for current user - -GET /time-slot/:id - - Get specific slot details -``` - -#### Admin Endpoints (Requires JWT + ADMIN role) -``` -GET /admin/week-template - - Returns all WeekTemplates - - Ordered by: dayOfWeek ASC, startTime ASC - -PUT /admin/week-template - - Request body: { templates: [...] } - - Replaces all templates (transaction-based) - - Validation: dayOfWeek 1-7, startTime/endTime strings - -POST /admin/time-slot/manual - - Request body: { date, startTime, endTime, capacity? } - - Creates manual slot with source=MANUAL - - Capacity defaults to DEFAULT_SLOT_CAPACITY - -PUT /admin/time-slot/:id/close - - Changes slot status to CLOSED - - Returns updated slot - -POST /admin/generate-slots - - Generates slots from active templates - - Fetches templates where isActive=true - - Creates slots for next SLOT_GENERATION_DAYS (14 days by default) - - Uses skipDuplicates to make re-runs safe - - Returns: { count: number } -``` - -### Time Slot Service -**File**: `packages/server/src/time-slot/time-slot.service.ts` - -Key methods: -```typescript -async getWeekTemplates(): Promise - // Returns all templates sorted by day/time - -async replaceWeekTemplates(items: Array<{...}>): Promise - // Transaction-based replacement: - // 1. Delete all existing templates - // 2. Create new ones from items array - // 3. Return count of created - -async createManualSlot(dto): Promise - // Creates slot with source=MANUAL, status=OPEN - -async closeSlot(id: string): Promise - // Updates status to CLOSED -``` - -### Slot Generator Service -**File**: `packages/server/src/time-slot/slot-generator.service.ts` - -Key method: -```typescript -async generateSlots(daysAhead: number = 14): Promise - // 1. Fetches all WeekTemplates where isActive=true - // 2. For each of next N days: - // - Calculate ISO weekday (1=Mon, 7=Sun) - // - Find matching templates by dayOfWeek - // - Create TimeSlot records with source=TEMPLATE, templateId=id - // 3. Uses createMany with skipDuplicates=true - // 4. Returns count of newly created slots - // - // Key: Converts JS getDay() (0=Sun) to ISO weekday (1=Mon, 7=Sun) - -async cleanupExpiredSlots(): Promise - // Called by scheduler - // Closes all OPEN slots with date < today - -async checkExpiredMemberships(): Promise - // Called by scheduler - // Expires memberships past end date or with 0 sessions left - -async completeBookings(): Promise - // Called by scheduler - // Marks CONFIRMED bookings as COMPLETED if slot date passed -``` - ---- - -## ๐Ÿ“Š Data Flow: "ๆŽ’่ฏพ่ฎพ็ฝฎ" User Journey - -### Scenario: Admin sets up class schedule for next week - -1. **Admin opens dashboard** โ†’ `index.vue` - - Taps "ๆŽ’่ฏพ่ฎพ็ฝฎ" nav item - -2. **Admin navigates to Week Template page** โ†’ `week-template.vue` - - `onMounted()` โ†’ `fetchTemplates()` - - Frontend: `GET /admin/week-template` - - Shows existing templates grouped by day - - Example display: - ``` - ๅ‘จไธ€ - 09:00-10:00 10ไบบ [ๅฏ็”จ] [็ผ–่พ‘] [ๅˆ ้™ค] - 18:00-19:00 8ไบบ [ๅฏ็”จ] [็ผ–่พ‘] [ๅˆ ้™ค] - ๅ‘จไธ‰ - 10:00-11:00 12ไบบ [ๅฏ็”จ] [็ผ–่พ‘] [ๅˆ ้™ค] - ``` - -3. **Admin adds a new class** โ†’ Click "+ ๆ–ฐๅขžๆ—ถๆฎต" - - Modal opens - - Select day, time, capacity - - Click "็กฎ่ฎค" - - Template added to local `templates` array - - **Save bar appears** at bottom - -4. **Admin edits existing template** โ†’ Click "็ผ–่พ‘" - - Modal opens with existing values - - Modify time/capacity - - Click "็กฎ่ฎค" - - Updated in local array - - Save bar shows if changed - -5. **Admin disables a template** โ†’ Click "ๅœ็”จ" - - `isActive` flipped to false - - Template grayed out - - Save bar shows - -6. **Admin saves all changes** โ†’ Click "ไฟๅญ˜ๅ…จ้ƒจๆ›ดๆ”น" - - Loading state - - Frontend: `PUT /admin/week-template` with all templates - - Backend transaction: - ``` - BEGIN TRANSACTION - DELETE FROM week_template - INSERT INTO week_template (day_of_week, start_time, end_time, capacity, is_active) VALUES (...) - COMMIT TRANSACTION - ``` - - Success toast - - Frontend refetches templates - - Save bar disappears - -7. **Backend scheduler auto-generates slots** - - Nightly cron (scheduler module) - - Calls `SlotGeneratorService.generateSlots(14)` - - Queries active WeekTemplates - - For each day in next 14 days: - - Checks what templates apply (by ISO weekday) - - Creates TimeSlot records - - Uses `skipDuplicates` to avoid duplicates on re-run - - Example output: - ``` - date: 2026-04-06 (Monday) - 09:00-10:00 source=TEMPLATE templateId=abc123 - 18:00-19:00 source=TEMPLATE templateId=def456 - date: 2026-04-08 (Wednesday) - 10:00-11:00 source=TEMPLATE templateId=ghi789 - ``` - -8. **Members can see and book the generated slots** - - Frontend: `GET /time-slot/available?date=2026-04-06` - - Members choose a slot and confirm booking - ---- - -## ๐Ÿ“… Constants & Utilities - -### Shared Constants -**File**: `packages/shared/src/constants.ts` - -```typescript -export const SLOT_GENERATION_DAYS = 14 - // Number of days ahead to generate slots for - -export const DEFAULT_SLOT_CAPACITY = 1 - // Default capacity if not specified (for private lessons) - -export const WEEKDAY_LABELS = ['', 'ๅ‘จไธ€', 'ๅ‘จไบŒ', 'ๅ‘จไธ‰', 'ๅ‘จๅ››', 'ๅ‘จไบ”', 'ๅ‘จๅ…ญ', 'ๅ‘จๆ—ฅ'] - // Index 0 is unused, 1-7 map to weekdays - // Used in dropdowns and display - -export const DEFAULT_CANCEL_HOURS_LIMIT = 2 - // Hours before slot to allow free cancellation - -export 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' } -} - -export const DATE_SELECTOR_DAYS = 7 -``` - -### Format Utilities -**File**: `packages/app/src/utils/format.ts` - -```typescript -formatDate(date: Date | string): string - // Converts to YYYY-MM-DD format - // Used for date pickers and API calls - -getWeekdayLabel(date: Date | string): string - // Returns Chinese weekday (ๅ‘จไธ€-ๅ‘จๆ—ฅ) - -isToday(date: Date | string): boolean - // Checks if date is today - -getDateRange(days: number): Array<{ date, weekday, isToday }> - // Generates future N days' dates -``` - -### Request Utility -**File**: `packages/app/src/utils/request.ts` - -```typescript -function request(options: RequestOptions): Promise - // Makes HTTP request with JWT auth - // Auto-refreshes token on 401 - -function get(url: string, data?: Record): Promise -function post(url: string, data?: Record): Promise -function put(url: string, data?: Record): Promise -function del(url: string, data?: Record): Promise - -// Base URL logic: -// - Production: https://focus.richarjiang.com/api -// - Development: http://localhost:3000/api -``` - ---- - -## ๐Ÿ” Permission Model - -**Role**: `UserRole.ADMIN` - -### Protected Endpoints -All `/admin/*` endpoints require: -1. Valid JWT token -2. Header: `Authorization: Bearer ` -3. User role must be `ADMIN` - -Protected by: -- `@UseGuards(JwtAuthGuard, RolesGuard)` -- `@Roles(UserRole.ADMIN)` - -### Auth Flow -1. Admin logs in via auth module -2. JWT token returned, stored in `uni.setStorageSync('token')` -3. All requests include token in Authorization header -4. If 401 response: clear token, show login prompt -5. If 4xx/5xx: show error toast - ---- - -## ๐Ÿ› Current Implementation Notes - -### Implemented Features โœ… -- [x] Week template CRUD (Create, Read, Update via replace) -- [x] Manual slot creation -- [x] Close individual slots -- [x] Batch slot generation from templates -- [x] UI for all three slot adjustment tabs -- [x] Local state change tracking (isDirty) -- [x] Modal form for adding/editing templates -- [x] Grouping templates by weekday -- [x] Status badges for slots (OPEN/FULL/CLOSED) - -### Missing/Stub Features โš ๏ธ -- [ ] `fetchDashboardStats()` API endpoint appears to be stubbed - - `index.vue` calls it but endpoint not found in backend - - May need to implement in studio or payment controller -- [ ] No client-side validation errors displayed on API failures -- [ ] No confirmation before overwriting all templates -- [ ] No undo/restore from past template versions - -### Edge Cases to Watch ๐Ÿ” -1. **Timezone handling**: All dates are treated as UTC midnight - - Slot generation uses `setUTCHours(0,0,0,0)` - - Frontend format displays as YYYY-MM-DD (local string) - -2. **Duplicate slot prevention**: - - Backend uses `skipDuplicates: true` in createMany - - Assumes date + startTime + endTime forms unique key - -3. **Template replacement is atomic**: - - All templates deleted, all new ones created in transaction - - If one row fails, entire operation rolls back - -4. **ISO weekday vs JS getDay()**: - - Shared code uses ISO: 1=Mon, 7=Sun - - Frontend picker displays Chinese labels - - Backend slot-generator converts JS getDay() to ISO - ---- - -## ๐Ÿ“ฑ UI Design Patterns - -### Colors & Styling -- **Primary**: `#1a1a2e` (dark navy) -- **Accent**: `#c9a87c` (gold) -- **Success**: `#27ae60` (green) -- **Warning**: `#e67e22` (orange) -- **Danger**: `#c0392b` (red) -- **Background**: `#f5f3f0` (light beige) - -### Component Patterns -1. **Skeleton loaders**: Shimmer animation for loading states -2. **Save bar**: Fixed bottom bar shows only when changes exist -3. **Toggle buttons**: Color indicates state (on=green, off=orange) -4. **Modals**: Bottom-sheet style with backdrop -5. **Pickers**: WeChat native pickers for date/time -6. **Badges**: Color-coded status indicators - ---- - -## ๐Ÿš€ Deployment & Configuration - -### Frontend -- WeChat mini-program environment -- Base URL logic in `packages/app/src/utils/request.ts`: - ```typescript - // Production - https://focus.richarjiang.com/api - - // Development - http://localhost:3000/api - ``` - -### Backend -- NestJS server on port 3000 -- Prisma ORM with database -- JWT authentication -- Role-based access control (RBAC) - ---- - -## ๐Ÿ“š Related Files Summary - -| File | Purpose | Type | -|------|---------|------| -| `admin/index.vue` | Admin dashboard | Component | -| `admin/week-template.vue` | Schedule templates | Component โญ | -| `admin/slot-adjust.vue` | Manual slot ops | Component | -| `stores/admin.ts` | Admin API calls | Store | -| `time-slot.service.ts` | Slot business logic | Service | -| `slot-generator.service.ts` | Template-based generation | Service | -| `time-slot.controller.ts` | API endpoints | Controller | -| `week-template.ts` | Type definitions | Type | -| `constants.ts` | Shared constants | Config | -| `format.ts` | Date/time utilities | Utility | - ---- - -## ๐ŸŽฏ Key Takeaways - -1. **"ๆŽ’่ฏพ่ฎพ็ฝฎ"** is the master schedule template management page -2. **Templates are ISO-weekday based** (1=Monday, 7=Sunday) -3. **Slot generation is automated** via backend scheduler, triggered by: - - Nightly cron job - - Or manual POST to `/admin/generate-slots` endpoint -4. **Save pattern**: Local changes tracked, one "save all" API call with full template array -5. **Timezone**: All operations use UTC midnight as boundaries -6. **Atomicity**: Backend uses Prisma transactions for template replacement -7. **Permissions**: All admin endpoints protected by JWT + ADMIN role guard - diff --git a/BOOKING_ARCHITECTURE_DIAGRAM.md b/BOOKING_ARCHITECTURE_DIAGRAM.md deleted file mode 100644 index 80f779c..0000000 --- a/BOOKING_ARCHITECTURE_DIAGRAM.md +++ /dev/null @@ -1,552 +0,0 @@ -# Booking Page - Architecture Diagram - -## ๐Ÿ›๏ธ Complete System Architecture - -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ WECHAT MINI-PROGRAM โ”‚ -โ”‚ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ FRONTEND (Vue 3 + Uni-app) โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ pages/booking/index.vue (Main Page Component) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ State: โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข selectedDate: string โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข selectedPeriod: PeriodKey | null โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข showConfirmPopup: boolean โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข pendingSlot: TimeSlotWithBookingStatus | null โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข refreshing: boolean โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Computed: โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข scrollHeight (responsive) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข filteredSlots (depends on period) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Lifecycle: โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข onMounted() โ†’ Load memberships + today's slots โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Event Handlers: โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข onDateSelect() โ†’ loadSlots(newDate) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข onPeriodChange() โ†’ Auto-filter via computed โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข onRefresh() โ†’ Reload slots โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข onBookTap() โ†’ Auth check โ†’ Show popup โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข onConfirmBooking() โ†’ Create booking โ†’ Refresh โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข onCancelTap() โ†’ Cancel booking โ†’ Refresh โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ”‚ โ†“ โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Child Components (All reactive & event-driven) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ DateSelector.vue โ”‚ โ”‚ TimePeriod...vue โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ [Today] [5] [4] โ”‚ โ”‚ ๅ…จ้ƒจ ไธŠๅˆ ไธ‹ๅˆ... โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ Props: modelValueโ”‚ โ”‚ Props: modelValueโ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ Emit: @select โ”‚ โ”‚ Emit: @change โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ†“ โ†“ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ (Updates selectedDate) (Updates selectedPeriod) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ†“ โ†“ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ (Triggers loadSlots) (Recomputes filteredSlots) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ SlotCard.vue (Rendered via v-for over filteredSlots) โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ [09:00-10:00] [0/1 ไบบ] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ [ๅฏ้ข„็บฆ] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ Props: slot (TimeSlotWithBookingStatus) โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ Emit: @book | @cancel โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ Computed: โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ€ข capacityLabel ("0/1 ไบบ" | "ๅทฒๅ…ณ้—ญ") โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ€ข capacityClass (cap-open | cap-almost | ...) โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ Button States (4 conditions): โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ 1. OPEN + not booked โ†’ "ๅฏ้ข„็บฆ" โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ 2. OPEN + booked โ†’ "ๅทฒ้ข„็บฆ" + "ๅ–ๆถˆ" โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ 3. FULL โ†’ "ๅทฒ็บฆๆปก" โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ 4. CLOSED โ†’ "ๅทฒๅ…ณ้—ญ" โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ†“ โ†“ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ (onBookTap) (onCancelTap) โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ BookingConfirmPopup.vue (Modal) โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ Props: โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ€ข visible: boolean โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ€ข slot: TimeSlotWithBookingStatus โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ€ข memberships: MembershipWithCardType[] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ State: โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ€ข selectedMembershipId (auto-selected on show) โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ Display: โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ ็กฎ่ฎค้ข„็บฆ โœ• โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ ๆ—ฅๆœŸ: 2026-04-05 โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ ๆ—ถ้—ด: 09:00 - 10:00 โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ ๅ‰ฉไฝ™: 1 ไธชๅ้ข โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ ๐Ÿ’ณ ็งๆ•™่ฏพ็จ‹ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ ๅ‰ฉไฝ™ 10 ๆฌก โœ“ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ [ๅ–ๆถˆ] [็กฎ่ฎค้ข„็บฆ] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ†“ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ Emit: @confirm({timeSlotId, membershipId}) โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ or @cancel โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ”‚ โ†“ โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Pinia Stores (Reactive State Management) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ stores/booking.ts: โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ State: โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข slots: TimeSlotWithBookingStatus[] โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข myBookings: BookingWithDetails[] โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข upcomingBookings: BookingWithDetails[] โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข loadingSlots: boolean โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข loadingBookings: boolean โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Actions: โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข fetchSlots(date) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข createBooking(dto) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข cancelBooking(bookingId) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข fetchMyBookings() โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข fetchUpcomingBookings() โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ stores/user.ts: โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ State: โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข user: UserProfileResponse | null โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข memberships: MembershipWithCardType[] โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข token: string (from localStorage) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Computed: โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข loggedIn: !!token โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข hasValidMembership: activeMemberships.length > 0 โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข activeMemberships: memberships filtered by ACTIVE โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Actions: โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข login() โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข fetchMemberships() โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข fetchProfile() โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข logout() โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ”‚ โ†“ โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Utils & Helpers โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ utils/request.ts (HTTP Client): โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข request(options): Promise โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข get(url, data?): Promise โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข post(url, data?): Promise โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข put(url, data?): Promise โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ utils/format.ts (Date Utilities): โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข formatDate(date): string โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข getWeekdayLabel(date): string โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข isToday(date): boolean โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ€ข getDateRange(days): DateInfo[] โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ†• โ”‚ -โ”‚ HTTP Requests โ”‚ -โ”‚ (Bearer Token in Header) โ”‚ -โ”‚ โ†“ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ BACKEND API โ”‚ โ”‚ -โ”‚ โ”‚ (packages/server/src/time-slot, booking, membership modules) โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ GET /api/time-slot/available?date=YYYY-MM-DD โ”‚ โ”‚ -โ”‚ โ”‚ โ†’ TimeSlotWithBookingStatus[] โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ POST /api/booking โ”‚ โ”‚ -โ”‚ โ”‚ Body: { timeSlotId, membershipId } โ”‚ โ”‚ -โ”‚ โ”‚ โ†’ BookingWithDetails โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ PUT /api/booking/:bookingId/cancel โ”‚ โ”‚ -โ”‚ โ”‚ โ†’ BookingWithDetails (status: CANCELLED) โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ GET /api/membership/my โ”‚ โ”‚ -โ”‚ โ”‚ โ†’ MembershipWithCardType[] โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ†• โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ DATABASE โ”‚ โ”‚ -โ”‚ โ”‚ (TimeSlot, Booking, Membership, User tables) โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - ---- - -## ๐Ÿ“Š Data Flow Lifecycle - -``` -โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— -โ•‘ BOOKING PAGE LIFECYCLE โ•‘ -โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - -1. PAGE LOAD - โ”Œโ”€ onMounted() - โ”‚ โ”œโ”€ IF loggedIn AND no memberships - โ”‚ โ”‚ โ””โ”€ userStore.fetchMemberships() - โ”‚ โ”‚ GET /membership/my - โ”‚ โ”‚ โ†’ memberships array - โ”‚ โ”‚ - โ”‚ โ””โ”€ loadSlots(today) - โ”‚ โ†’ bookingStore.fetchSlots(date) - โ”‚ GET /time-slot/available?date=YYYY-MM-DD - โ”‚ โ†’ slots array - โ”‚ โ†’ Render SlotCard components - โ”‚ - โ””โ”€ READY โœ“ - -2. USER SELECTS DATE - โ”œโ”€ onDateSelect(newDate) - โ”œโ”€ selectedDate.value = newDate - โ””โ”€ loadSlots(newDate) - โ†’ bookingStore.fetchSlots(newDate) - โ†’ slots array (for new date) - โ†’ SlotCard components re-render - -3. USER SELECTS TIME PERIOD - โ”œโ”€ onPeriodChange(period) - โ”œโ”€ selectedPeriod.value = period - โ””โ”€ filteredSlots computed updates automatically - โ†’ Vue watches TIME_PERIODS[period] - โ†’ Filters slots by startTime - โ†’ SlotCard components re-render (subset) - -4. USER PULLS TO REFRESH - โ”œโ”€ onRefresh() - โ”œโ”€ refreshing.value = true - โ”œโ”€ loadSlots(selectedDate.value) - โ”‚ โ†’ bookingStore.fetchSlots() - โ”‚ โ†’ slots array (refreshed) - โ””โ”€ refreshing.value = false - -5. USER TAPS "ๅฏ้ข„็บฆ" (Book) - โ”œโ”€ onBookTap(slot) - โ”‚ - โ”œโ”€ CHECK: loggedIn? - โ”‚ โ”œโ”€ NO โ†’ Show login modal - โ”‚ โ”‚ User clicks confirm - โ”‚ โ”‚ โ†’ userStore.login() - โ”‚ โ”‚ POST /auth/wxLogin - โ”‚ โ”‚ โ†’ token + user - โ”‚ โ”‚ โ†’ userStore.fetchMemberships() - โ”‚ โ”‚ GET /membership/my - โ”‚ โ”‚ โ†’ memberships array - โ”‚ โ”‚ โ†’ RETRY onBookTap(slot) - โ”‚ โ”‚ - โ”‚ โ””โ”€ YES โ†’ Continue - โ”‚ - โ”œโ”€ CHECK: hasValidMembership? - โ”‚ โ”œโ”€ NO โ†’ Show purchase modal - โ”‚ โ”‚ User clicks confirm - โ”‚ โ”‚ โ†’ uni.navigateTo('/pages/store/index') - โ”‚ โ”‚ - โ”‚ โ””โ”€ YES โ†’ Continue - โ”‚ - โ”œโ”€ pendingSlot.value = slot - โ”œโ”€ showConfirmPopup.value = true - โ”‚ - โ””โ”€ POPUP SHOWN โœ“ - โ”œโ”€ selectedMembershipId auto-selected (first one) - โ”œโ”€ Watch on popup visibility + memberships - โ”‚ โ†’ Auto-select first membership when shown - โ”‚ - โ””โ”€ User sees: - โ€ข Slot date/time - โ€ข Membership card options - โ€ข Deduction message - -6. USER CONFIRMS BOOKING - โ”œโ”€ onConfirmBooking({timeSlotId, membershipId}) - โ”œโ”€ showConfirmPopup.value = false - โ”œโ”€ uni.showLoading('้ข„็บฆไธญ...') - โ”‚ - โ”œโ”€ bookingStore.createBooking(payload) - โ”‚ โ””โ”€ POST /booking - โ”‚ Body: { timeSlotId, membershipId } - โ”‚ โ†’ BookingWithDetails - โ”‚ - โ”œโ”€ uni.hideLoading() - โ”œโ”€ uni.showToast('้ข„็บฆๆˆๅŠŸ๏ผ') - โ”‚ - โ”œโ”€ loadSlots(selectedDate.value) // REFRESH - โ”‚ โ†’ bookingStore.fetchSlots() - โ”‚ GET /time-slot/available?date= - โ”‚ โ†’ slots array (UPDATED) - โ”‚ โ€ข slot.isBookedByMe = true - โ”‚ โ€ข slot.myBookingId = bookingId - โ”‚ โ€ข Button now shows "ๅทฒ้ข„็บฆ" - โ”‚ - โ””โ”€ BOOKING COMPLETE โœ“ - -7. USER TAPS "ๅ–ๆถˆ" (Cancel) - โ”œโ”€ onCancelTap(slot) - โ”œโ”€ Show confirmation modal - โ”œโ”€ User confirms - โ”‚ - โ”œโ”€ uni.showLoading('ๅ–ๆถˆไธญ...') - โ”‚ - โ”œโ”€ bookingStore.cancelBooking(slot.myBookingId) - โ”‚ โ””โ”€ PUT /booking/:id/cancel - โ”‚ โ†’ BookingWithDetails (status: CANCELLED) - โ”‚ - โ”œโ”€ uni.hideLoading() - โ”œโ”€ uni.showToast('ๅทฒๅ–ๆถˆ้ข„็บฆ') - โ”‚ - โ”œโ”€ loadSlots(selectedDate.value) // REFRESH - โ”‚ โ†’ bookingStore.fetchSlots() - โ”‚ GET /time-slot/available?date= - โ”‚ โ†’ slots array (UPDATED) - โ”‚ โ€ข slot.isBookedByMe = false - โ”‚ โ€ข slot.myBookingId = null - โ”‚ โ€ข Button now shows "ๅฏ้ข„็บฆ" - โ”‚ - โ””โ”€ CANCELLATION COMPLETE โœ“ -``` - ---- - -## ๐Ÿ”„ State Synchronization - -``` -Component โ†โ†’ Pinia Store โ†โ†’ API โ†โ†’ Database - -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Component (Vue Template) โ”‚ -โ”‚ โ”‚ -โ”‚ {{ bookingStore.slots }} โ† Reactive binding โ”‚ -โ”‚ {{ filteredSlots }} โ† Computed from slots โ”‚ -โ”‚ {{ userStore.hasValidMembership }} โ† Computed from store โ”‚ -โ”‚ โ”‚ -โ”‚ @click="onBookTap(slot)" โ† User action โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ†‘ โ†“ - โ”‚ Read โ”‚ Mutate - โ”‚ โ†“ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Pinia Store State โ”‚ -โ”‚ โ”‚ -โ”‚ slots: TimeSlotWithBookingStatus[] โ”‚ -โ”‚ โ†“ Recomputed when: โ”‚ -โ”‚ - fetchSlots() returns data โ”‚ -โ”‚ - createBooking() succeeds โ”‚ -โ”‚ - cancelBooking() succeeds โ”‚ -โ”‚ โ”‚ -โ”‚ memberships: MembershipWithCardType[] โ”‚ -โ”‚ โ†“ Set when: โ”‚ -โ”‚ - fetchMemberships() returns data โ”‚ -โ”‚ โ”‚ -โ”‚ loadingSlots: boolean โ”‚ -โ”‚ โ†“ Set to: โ”‚ -โ”‚ - true on fetchSlots() start โ”‚ -โ”‚ - false on fetchSlots() end โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ†‘ โ†“ - โ”‚ Response โ”‚ Request - โ”‚ โ†“ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ API Layer (utils/request.ts) โ”‚ -โ”‚ โ”‚ -โ”‚ GET /time-slot/available?date=2026-04-05 โ”‚ -โ”‚ โ†“ Returns ApiResponse โ”‚ -โ”‚ { success: true, data: [...], message: null } โ”‚ -โ”‚ โ”‚ -โ”‚ POST /booking โ”‚ -โ”‚ โ†“ Body: { timeSlotId, membershipId } โ”‚ -โ”‚ โ†“ Returns ApiResponse โ”‚ -โ”‚ { success: true, data: {...}, message: null } โ”‚ -โ”‚ โ”‚ -โ”‚ PUT /booking/:id/cancel โ”‚ -โ”‚ โ†“ Returns ApiResponse โ”‚ -โ”‚ { success: true, data: {...}, message: null } โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ†‘ โ†“ - โ”‚ SELECT/UPDATE โ”‚ INSERT/UPDATE - โ”‚ โ†“ -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Database โ”‚ -โ”‚ โ”‚ -โ”‚ TimeSlot Table โ”‚ -โ”‚ id, date, startTime, endTime, capacity, โ”‚ -โ”‚ bookedCount, status, source, templateId โ”‚ -โ”‚ โ”‚ -โ”‚ Booking Table โ”‚ -โ”‚ id, userId, timeSlotId, membershipId, โ”‚ -โ”‚ status (CONFIRMED/CANCELLED/...), bookedAt โ”‚ -โ”‚ โ”‚ -โ”‚ Membership Table โ”‚ -โ”‚ id, userId, cardTypeId, status, remainingTimes, โ”‚ -โ”‚ expireDate, createdAt, updatedAt โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - ---- - -## ๐ŸŽฏ Component Communication - -``` -Root: pages/booking/index.vue -โ”‚ -โ”œโ”€ PROPS DOWN โ”€โ”€โ†’ DateSelector.vue -โ”‚ โ””โ”€ modelValue: string (YYYY-MM-DD) -โ”‚ -โ”œโ”€ PROPS DOWN โ”€โ”€โ†’ TimePeriodFilter.vue -โ”‚ โ””โ”€ modelValue: PeriodKey | null -โ”‚ -โ”œโ”€ PROPS DOWN โ”€โ”€โ†’ SlotCard.vue (v-for) -โ”‚ โ””โ”€ slot: TimeSlotWithBookingStatus -โ”‚ -โ”œโ”€ PROPS DOWN โ”€โ”€โ†’ BookingConfirmPopup.vue -โ”‚ โ”œโ”€ visible: boolean -โ”‚ โ”œโ”€ slot: TimeSlotWithBookingStatus | null -โ”‚ โ””โ”€ memberships: MembershipWithCardType[] -โ”‚ -โ”œโ”€ EVENTS UP โ†โ”€โ”€ DateSelector.vue -โ”‚ โ”œโ”€ @select(date) โ†’ onDateSelect() -โ”‚ โ””โ”€ @update:modelValue(date) -โ”‚ -โ”œโ”€ EVENTS UP โ†โ”€โ”€ TimePeriodFilter.vue -โ”‚ โ”œโ”€ @change(period) โ†’ onPeriodChange() -โ”‚ โ””โ”€ @update:modelValue(period) -โ”‚ -โ”œโ”€ EVENTS UP โ†โ”€โ”€ SlotCard.vue -โ”‚ โ”œโ”€ @book(slot) โ†’ onBookTap() -โ”‚ โ””โ”€ @cancel(slot) โ†’ onCancelTap() -โ”‚ -โ””โ”€ EVENTS UP โ†โ”€โ”€ BookingConfirmPopup.vue - โ”œโ”€ @confirm({timeSlotId, membershipId}) โ†’ onConfirmBooking() - โ””โ”€ @cancel โ†’ showConfirmPopup = false -``` - ---- - -## ๐Ÿงฌ Reactive Dependency Chain - -``` -LocalStorage (token) - โ†“ -userStore.token - โ†“ -userStore.loggedIn (computed) - โ†“ -pages/booking โ†’ Check login status - โ†“ -userStore.memberships - โ†“ -userStore.activeMemberships (computed, filtered by ACTIVE) - โ†“ -userStore.hasValidMembership (computed) - โ†“ -pages/booking โ†’ Show/hide booking button & membership popup - โ†“ -BookingConfirmPopup โ† receives activeMemberships as props - โ†“ -selectedMembershipId (auto-selected on popup show) - - -bookingStore.slots (array) - โ†“ -pages/booking.selectedPeriod - โ†“ -pages/booking.filteredSlots (computed, filtered by TIME_PERIODS) - โ†“ -v-for โ†’ SlotCard components render - โ†“ -Each SlotCard โ†’ capacityLabel (computed) - โ†’ capacityClass (computed) - โ†’ Button state determined - - -bookingStore.loadingSlots (boolean) - โ†“ -pages/booking template - โ†“ -v-if โ†’ Show skeleton | Show slots | Show empty state -``` - ---- - -## ๐Ÿ“‹ API Request/Response Chain - -``` -USER TAPS DATE - โ†“ -pages/booking/onDateSelect() - โ†“ -loadSlots(date) - โ†“ -bookingStore.fetchSlots(date) - โ†“ -get('/time-slot/available', { date }) - โ†“ -utils/request.get() - โ†“ -uni.request({ - url: 'http://localhost:3000/api/time-slot/available', - method: 'GET', - data: { date: '2026-04-05' }, - header: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' - } -}) - โ†“ -BACKEND: GET /api/time-slot/available?date=2026-04-05 - (Queries database for TimeSlot records matching date) - (Fetches current user's bookings for those slots) - (Enriches response with isBookedByMe, myBookingId) - โ†“ -Response: { - "success": true, - "data": [ - { - "id": "...", - "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 -} - โ†“ -request.ts success callback - โ”œโ”€ Check: statusCode < 400 โœ“ - โ”œโ”€ Check: body.success === true โœ“ - โ”œโ”€ Extract: body.data (TimeSlotWithBookingStatus[]) - โ””โ”€ Resolve promise with data - โ†“ -bookingStore.fetchSlots() try block - โ”œโ”€ slots.value = data - โ””โ”€ loadingSlots.value = false - โ†“ -Component template reactivity - โ”œโ”€ Re-render with new slots - โ”œโ”€ Compute filteredSlots - โ””โ”€ Render SlotCard components -``` - diff --git a/BOOKING_PAGE_ANALYSIS.md b/BOOKING_PAGE_ANALYSIS.md deleted file mode 100644 index c70366e..0000000 --- a/BOOKING_PAGE_ANALYSIS.md +++ /dev/null @@ -1,894 +0,0 @@ -# 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 - diff --git a/BOOKING_README.md b/BOOKING_README.md deleted file mode 100644 index e4ca48b..0000000 --- a/BOOKING_README.md +++ /dev/null @@ -1,395 +0,0 @@ -# Booking Page Documentation - -## ๐Ÿ“š Overview - -This folder contains comprehensive documentation for the WeChat Mini-Program booking system in the mp-pilates project (Uni-app + Vue 3). - -### ๐Ÿ“„ Documentation Files - -1. **BOOKING_PAGE_ANALYSIS.md** โญ START HERE - - Complete file-by-file breakdown of all components - - Data flow diagrams - - API contract documentation - - Color scheme and styling details - - Potential issues and problems - -2. **COMPONENT_HIERARCHY.md** - - Visual component tree structure - - State management flow (Pinia stores) - - API sequence diagrams - - State machine for slot cards - - Data transformations - -3. **QUICK_REFERENCE.md** - - Code snippets for quick lookup - - Debugging tips and console commands - - Common issues and solutions - - Debugging checklist - - API examples - ---- - -## ๐ŸŽฏ Quick Navigation - -### I want to understand... - -**...the overall flow** -โ†’ Read: BOOKING_PAGE_ANALYSIS.md โ†’ "Complete Data Flow Diagram" section - -**...how the UI is structured** -โ†’ Read: COMPONENT_HIERARCHY.md โ†’ "Component Tree" + "UI Layout Breakdown" - -**...where specific code is** -โ†’ Read: QUICK_REFERENCE.md โ†’ "Finding Specific Things" - -**...how to debug an issue** -โ†’ Read: QUICK_REFERENCE.md โ†’ "Common Issues & Solutions" - -**...the API contracts** -โ†’ Read: QUICK_REFERENCE.md โ†’ "API Contract Summary" - -**...the store state** -โ†’ Read: COMPONENT_HIERARCHY.md โ†’ "State Management Flow" - ---- - -## ๐Ÿ—๏ธ Project Structure - -``` -packages/app/src/ -โ”œโ”€โ”€ pages/ -โ”‚ โ””โ”€โ”€ booking/ -โ”‚ โ””โ”€โ”€ index.vue # Main booking page (311 lines) -โ”œโ”€โ”€ components/ -โ”‚ โ”œโ”€โ”€ DateSelector.vue # Date picker (50 lines) -โ”‚ โ”œโ”€โ”€ TimePeriodFilter.vue # Time period filter (50 lines) -โ”‚ โ”œโ”€โ”€ SlotCard.vue # Individual slot card (230 lines) -โ”‚ โ””โ”€โ”€ BookingConfirmPopup.vue # Booking confirmation modal (430 lines) -โ”œโ”€โ”€ stores/ -โ”‚ โ”œโ”€โ”€ booking.ts # Booking state (72 lines) -โ”‚ โ””โ”€โ”€ user.ts # User/membership state (110 lines) -โ””โ”€โ”€ utils/ - โ”œโ”€โ”€ request.ts # API request utilities (80 lines) - โ””โ”€โ”€ format.ts # Date formatting utilities (50 lines) - -packages/shared/src/ -โ”œโ”€โ”€ types/ -โ”‚ โ”œโ”€โ”€ time-slot.ts # TimeSlot types -โ”‚ โ”œโ”€โ”€ api.ts # API response types -โ”‚ โ””โ”€โ”€ booking.ts # Booking types -โ”œโ”€โ”€ constants.ts # TIME_PERIODS, etc -โ””โ”€โ”€ enums.ts # Enums (TimeSlotStatus, etc) -``` - ---- - -## ๐Ÿ”„ Data Flow at a Glance - -``` -Page Load - โ†“ -[Check login + load memberships] - โ†“ -Store: fetchSlots(today) - โ†“ -API: GET /time-slot/available?date=TODAY - โ†“ -State: bookingStore.slots = [TimeSlotWithBookingStatus[], ...] - โ†“ -Computed: filteredSlots (optionally filtered by period) - โ†“ -Render: SlotCard components - โ†“ -User interaction: - - Tap date โ†’ loadSlots(newDate) - - Filter period โ†’ filteredSlots re-computed - - Book slot โ†’ onBookTap() โ†’ popup - - Confirm โ†’ createBooking() โ†’ refresh slots - - Cancel โ†’ cancelBooking() โ†’ refresh slots -``` - ---- - -## ๐ŸŽญ Key Components - -### 1. pages/booking/index.vue -**Role:** Main page that orchestrates everything -**State:** selectedDate, selectedPeriod, showConfirmPopup, pendingSlot -**Stores:** bookingStore, userStore -**Key computed:** scrollHeight, filteredSlots - -### 2. components/SlotCard.vue -**Role:** Displays individual time slot -**Props:** slot (TimeSlotWithBookingStatus) -**Emits:** book, cancel -**States:** 4 button states based on status + isBookedByMe - -### 3. components/DateSelector.vue -**Role:** Horizontal date picker -**Props:** modelValue (YYYY-MM-DD) -**Data:** dateRange (7 days from today) -**Display:** Shows weekday, day number, month - -### 4. components/TimePeriodFilter.vue -**Role:** Horizontal tab filter -**Props:** modelValue (MORNING|AFTERNOON|EVENING|null) -**Constants:** TIME_PERIODS from shared - -### 5. components/BookingConfirmPopup.vue -**Role:** Modal for confirming booking -**Props:** visible, slot, memberships -**State:** selectedMembershipId (auto-selected on show) -**Logic:** Auto-select first membership when popup opens - -### 6. stores/booking.ts -**Actions:** -- fetchSlots(date) โ†’ GET /time-slot/available?date= -- createBooking(dto) โ†’ POST /booking -- cancelBooking(bookingId) โ†’ PUT /booking/:id/cancel -- fetchMyBookings(status?) โ†’ GET /booking/my -- fetchUpcomingBookings() โ†’ GET /booking/my/upcoming - -### 7. stores/user.ts -**Computed:** -- loggedIn: !!token.value -- hasValidMembership: activeMemberships.length > 0 -- activeMemberships: memberships filtered by ACTIVE status - ---- - -## ๐Ÿ“Š State Types - -### TimeSlotWithBookingStatus -```typescript -{ - id: string // UUID - date: "2026-04-05" // YYYY-MM-DD - startTime: "09:00" // HH:MM - endTime: "10:00" // HH:MM - capacity: 1 // Max slots - bookedCount: 0 // Currently booked - status: "OPEN" | "FULL" | "CLOSED" - source: "MANUAL" | "TEMPLATE" - templateId: null - isBookedByMe: boolean // User has booked this - myBookingId: string | null // Booking ID (for cancel) -} -``` - -### MembershipWithCardType -```typescript -{ - id: string - cardType: { name: string, ... } - status: "ACTIVE" | "EXPIRED" | "USED_UP" - remainingTimes: number | null - expireDate: "2026-12-31" -} -``` - ---- - -## ๐ŸŽจ Visual States - -### Slot Card Button States - -| Condition | Button | Color | Action | -|-----------|--------|-------|--------| -| OPEN, not booked | "ๅฏ้ข„็บฆ" | Tan (#c9a87c) | Show popup | -| OPEN, booked by me | "ๅทฒ้ข„็บฆ" + "ๅ–ๆถˆ" link | Tan + Red | Show cancel confirm | -| FULL | "ๅทฒ็บฆๆปก" | Gray (#f0f0f0) | Disabled | -| CLOSED | "ๅทฒๅ…ณ้—ญ" | Gray (#f0f0f0) | Disabled | - -### Capacity Badge Colors - -| Condition | Background | Text | Meaning | -|-----------|------------|------|---------| -| <80% booked | #f0faf3 | #4caf50 | Green - Plenty of spots | -| โ‰ฅ80% booked | #fff8ed | #f59e0b | Orange - Almost full | -| FULL | #fef0f0 | #ef4444 | Red - No spots | -| CLOSED | #f5f5f5 | #999 | Gray - Unavailable | - ---- - -## ๐Ÿ” Authentication - -- Token stored in localStorage -- Automatically included in request headers -- 401 response โ†’ Clear token + show "please login" toast -- onBookTap checks loggedIn โ†’ shows login modal if needed -- onBookTap checks hasValidMembership โ†’ shows purchase modal if needed - ---- - -## ๐Ÿ“ก API Endpoints - -### GET /time-slot/available?date=YYYY-MM-DD -``` -Query: date (required, YYYY-MM-DD format) -Returns: TimeSlotWithBookingStatus[] -Auth: Bearer token required -``` - -### POST /booking -``` -Body: { timeSlotId, membershipId } -Returns: BookingWithDetails -Auth: Bearer token required -``` - -### PUT /booking/:bookingId/cancel -``` -Path: bookingId -Returns: BookingWithDetails (with status: CANCELLED) -Auth: Bearer token required -``` - -### GET /membership/my -``` -Returns: MembershipWithCardType[] -Auth: Bearer token required -``` - ---- - -## โš ๏ธ Known Issues - -### 1. GET Request Body Issue -- File: `utils/request.ts`, `get()` function -- Problem: Data passed as body instead of query params -- Impact: Might not work on all platforms - -### 2. Error Handling -- File: `stores/booking.ts`, `fetchSlots()` -- Problem: Network error โ†’ empty array instead of error message -- Impact: Users can't tell if error or truly no slots - -### 3. Loading State -- File: `pages/booking/index.vue` -- Problem: Skeleton only appears on initial load -- Impact: Date changes appear instant (confusing on slow network) - -### 4. Date Math -- File: `utils/format.ts`, `getDateRange()` -- Problem: Uses ms arithmetic (86400000ms per day) -- Impact: Doesn't account for DST transitions - ---- - -## ๐Ÿงช Testing Checklist - -### Happy Path -- [ ] Load page โ†’ today's slots display -- [ ] Tap date โ†’ slots change for that date -- [ ] Filter by period โ†’ slots filtered correctly -- [ ] Tap "ๅฏ้ข„็บฆ" โ†’ popup shows -- [ ] Confirm booking โ†’ slot shows "ๅทฒ้ข„็บฆ" -- [ ] Tap "ๅ–ๆถˆ" โ†’ booking cancelled, slot resets -- [ ] Pull to refresh โ†’ slots reload - -### Edge Cases -- [ ] No slots for date โ†’ empty state appears -- [ ] Not logged in โ†’ login modal on book tap -- [ ] No valid membership โ†’ purchase modal on book tap -- [ ] Network error โ†’ ??? (currently shows empty) -- [ ] Slot becomes FULL โ†’ button updates to disabled -- [ ] Multiple memberships โ†’ can select different card - ---- - -## ๐Ÿ“ File Sizes - -| File | Lines | Purpose | -|------|-------|---------| -| pages/booking/index.vue | 311 | Main page orchestration | -| components/BookingConfirmPopup.vue | 430 | Booking modal | -| components/SlotCard.vue | 230 | Slot display | -| stores/booking.ts | 72 | Booking state | -| utils/request.ts | 80 | API client | -| components/DateSelector.vue | 50 | Date picker | -| components/TimePeriodFilter.vue | 50 | Period filter | -| utils/format.ts | 50 | Date utilities | - ---- - -## ๐ŸŽ“ Learning Path - -**Level 1: Overview** -1. Read this file -2. Look at BOOKING_PAGE_ANALYSIS.md โ†’ "Complete Data Flow Diagram" - -**Level 2: Components** -1. Read COMPONENT_HIERARCHY.md โ†’ "Component Tree" -2. Read BOOKING_PAGE_ANALYSIS.md โ†’ "File-by-File Analysis" - -**Level 3: Implementation** -1. Read QUICK_REFERENCE.md โ†’ "Where Slots Come From" -2. Read actual source files in order: - - stores/booking.ts - - pages/booking/index.vue - - components/SlotCard.vue - - components/BookingConfirmPopup.vue - -**Level 4: Debugging** -1. Read QUICK_REFERENCE.md โ†’ "Debugging Tips" -2. Read QUICK_REFERENCE.md โ†’ "Common Issues & Solutions" - -**Level 5: Deep Dive** -1. Read COMPONENT_HIERARCHY.md โ†’ "State Management Flow" -2. Read COMPONENT_HIERARCHY.md โ†’ "API Calls Sequence" -3. Study utils/request.ts for request handling - ---- - -## ๐Ÿ”— Related Documentation - -- Backend: `/packages/server/src/time-slot/` -- Shared types: `/packages/shared/src/types/` -- Auth: `/packages/app/src/utils/auth.ts` -- User store: `/packages/app/src/stores/user.ts` - ---- - -## ๐Ÿ“ž Quick Answers - -**Q: Why doesn't the page load?** -A: Check 1) Is API returning data? 2) Is token valid? 3) Check console for errors - -**Q: Why doesn't filtering work?** -A: Check 1) Is selectedPeriod.value being set? 2) Is slot.startTime correct format? - -**Q: Why doesn't the booking button work?** -A: Check 1) Is slot.status === OPEN? 2) Is isBookedByMe === false? 3) Is user logged in? - -**Q: How do I add error handling?** -A: See QUICK_REFERENCE.md โ†’ "Issue 1: Slots not loading" โ†’ Solution - -**Q: How do I test the booking flow?** -A: See "Testing Checklist" section above - ---- - -## ๐Ÿš€ Common Tasks - -### Add loading indicator during date change -โ†’ Use bookingStore.loadingSlots in template - -### Show error message for API failures -โ†’ Add error state to bookingStore, show in template - -### Change colors/styling -โ†’ Edit style blocks in .vue files (see color scheme in BOOKING_PAGE_ANALYSIS.md) - -### Modify time period ranges -โ†’ Edit TIME_PERIODS in packages/shared/src/constants.ts - -### Change initial date or time range -โ†’ Edit pages/booking/index.vue onMounted() or DATE_SELECTOR_DAYS constant - -### Add/remove date selector days -โ†’ Edit DATE_SELECTOR_DAYS in packages/shared/src/constants.ts - ---- - -Generated: 2026-04-05 -Last Updated: BOOKING_PAGE_ANALYSIS.md diff --git a/BUG_FIX_COMPLETION_INDEX.md b/BUG_FIX_COMPLETION_INDEX.md deleted file mode 100644 index 6e9461f..0000000 --- a/BUG_FIX_COMPLETION_INDEX.md +++ /dev/null @@ -1,218 +0,0 @@ -# Card Types Bug Fix - Completion Index - -## Quick Links - -**Bug Fix Commit**: [a85270e](https://github.com/richarjiang/mp-pilates/commit/a85270e) - -**Files Modified**: -- `packages/app/src/pages/admin/card-types.vue` - Added `.stop` modifiers to 3 action buttons - -**Documentation Files**: -- `CARD_TYPES_BUG_FIX.md` - Complete bug explanation and fix details -- `MODAL_EVENT_HANDLING_AUDIT.md` - Audit of all application modals -- `CARD_TYPES_ANALYSIS.md` - Deep technical analysis -- `CARD_TYPES_QUICK_REFERENCE.md` - Quick lookup guide -- `EXPLORATION_SUMMARY.md` - Full system overview - ---- - -## The Bug in 30 Seconds - -**Problem**: Edit modal closes immediately after opening - -**Cause**: Vue event propagation - tap events bubble from action buttons to modal-mask's close handler - -**Solution**: Add `.stop` modifier to prevent event bubbling - -**Impact**: Users can now edit card types successfully - ---- - -## What Was Changed - -### File: packages/app/src/pages/admin/card-types.vue - -Three lines modified: - -```diff -- -+ - -- -+ - -- -+ -``` - ---- - -## Why It Works - -The `.stop` modifier calls `event.stopPropagation()`, which prevents the tap event from bubbling to parent elements. This prevents the modal-mask's close handler from being triggered. - -**Event flow with fix**: -1. User taps action button โœ“ -2. Event handler executes (edit/toggle/delete) โœ“ -3. Event propagation is stopped โœ— (no bubbling) -4. Modal-mask close handler is NOT triggered โœ“ -5. Modal stays open โœ“ - ---- - -## Testing Instructions - -### Quick Test -1. Go to Admin โ†’ Card Types -2. Click any [็ผ–่พ‘] (Edit) button -3. Modal should open and stay open -4. Edit a field and click [็กฎ่ฎค] (Confirm) -5. Changes should save - -### Full Test Suite -See `CARD_TYPES_BUG_FIX.md` for complete testing checklist - ---- - -## Documentation Overview - -### Bug Fix Documentation -- **CARD_TYPES_BUG_FIX.md** - Complete fix documentation with testing instructions -- **MODAL_EVENT_HANDLING_AUDIT.md** - Audit of all modals + preventive measures - -### Feature Documentation -- **CARD_TYPES_ANALYSIS.md** - Deep dive into card types system -- **CARD_TYPES_QUICK_REFERENCE.md** - Quick lookup guide -- **EXPLORATION_SUMMARY.md** - Full system overview -- **CARD_TYPES_INDEX.md** - Master index - -### Diagrams -- **CARD_TYPES_FLOW_DIAGRAM.txt** - ASCII art workflows - ---- - -## Key Findings from Audit - -โœ… **card-types.vue** - FIXED (event propagation issue resolved) -โœ… **week-template.vue** - SAFE (separate DOM structure) -โœ… **members.vue** - SAFE (single tap handler pattern) -โœ… **BookingConfirmPopup.vue** - SAFE (dedicated component) - -**Conclusion**: No other files have the same issue. - ---- - -## Commit Information - -``` -Hash: a85270e -Author: richarjiang -Date: Sun Apr 5 12:53:03 2026 +0800 -Message: fix(admin): prevent edit modal from closing immediately on tap - - Fix the card types management edit modal that was closing - immediately after opening due to event propagation. Added - .stop modifier to all action button tap handlers (edit, toggle, - delete) to prevent bubbling to parent modal-mask element. - - - Changed @tap="openEdit(ct)" to @tap.stop="openEdit(ct)" - - Changed @tap="toggleActive(ct)" to @tap.stop="toggleActive(ct)" - - Changed @tap="confirmDelete(ct)" to @tap.stop="confirmDelete(ct)" - - This fixes the bug where the edit modal would open and close in - the same event cycle, making it impossible to edit card types. -``` - ---- - -## Files Changed Summary - -| File | Changes | Lines | Type | -|------|---------|-------|------| -| card-types.vue | `.stop` modifiers added | 3 | Fix | -| CARD_TYPES_BUG_FIX.md | New documentation | 132 | Doc | -| MODAL_EVENT_HANDLING_AUDIT.md | New audit report | 200+ | Doc | - -**Total**: 2 files modified/created - ---- - -## Next Steps - -### Immediate (Before Merge) -1. โœ… Code changes applied -2. โœ… Commit created -3. โœ… Documentation completed -4. โ–ก Manual testing required -5. โ–ก Code review approval needed - -### For Deployment -1. Test the fix manually -2. Review commit in GitHub -3. Get team approval -4. Merge to main branch -5. Deploy to staging -6. Deploy to production - -### For Prevention -1. Review `MODAL_EVENT_HANDLING_AUDIT.md` guidelines -2. Apply best practices to new code -3. Add E2E tests for modal interactions -4. Consider ESLint rules for modal event handling - ---- - -## Technical Deep Dive - -### Problem Pattern - -This is a classic Vue event propagation issue that occurs when: -1. List items have action buttons -2. Tap handlers on buttons trigger state changes -3. Modal appears as overlay -4. Modal-mask has a tap handler to close -5. Event bubbles from button โ†’ card โ†’ list โ†’ modal-mask - -### Solution Pattern - -The fix is to add `.stop` modifier to any event handler that triggers state changes that render overlays: - -```vue - - - - - -``` - -### Why This Is Safe - -- `.stop` only prevents propagation, not default behavior -- Event still executes on the clicked element -- All three buttons work independently -- No side effects or unexpected behavior -- Follows Vue best practices - ---- - -## References - -- **Vue Event Modifiers**: https://vuejs.org/guide/essentials/event-handling.html#event-modifiers -- **Event Propagation**: https://developer.mozilla.org/en-US/docs/Web/API/Event/stopPropagation -- **Uni-app Events**: https://uniapp.dcloud.io/api/ui/intersection-observer - ---- - -## Support & Questions - -For questions about this fix: -1. Read `CARD_TYPES_BUG_FIX.md` for detailed explanation -2. Check `MODAL_EVENT_HANDLING_AUDIT.md` for similar patterns -3. Review the commit diff for exact changes -4. Consult Vue 3 event handling documentation - ---- - -**Status**: โœ… COMPLETE - Ready for testing and deployment - -**Last Updated**: 2026-04-05 diff --git a/CARD_TYPES_ANALYSIS.md b/CARD_TYPES_ANALYSIS.md deleted file mode 100644 index 033213f..0000000 --- a/CARD_TYPES_ANALYSIS.md +++ /dev/null @@ -1,548 +0,0 @@ -# Card Types Management Feature - Comprehensive Analysis - -## Project Structure -- **Frontend**: `packages/app` (Vue 3 + Uni-app mini-program) -- **Backend**: `packages/server` (NestJS) -- **Shared**: `packages/shared` (types, enums, DTOs) - ---- - -## 1. DATABASE SCHEMA (Prisma) - -### CardType Model -**File**: `packages/server/prisma/schema.prisma` (lines 73-91) - -```prisma -model CardType { - id String @id @default(uuid()) - name String - type CardTypeCategory // TIMES | DURATION | TRIAL - totalTimes Int? // For TIMES/TRIAL cards - durationDays Int // How many days card is valid - price Decimal(10, 0) // Current price (in cents internally) - originalPrice Decimal?(10, 0) // Optional strikethrough price - description String? - isActive Boolean @default(true) // For ไธŠๆžถ/ไธ‹ๆžถ - sortOrder Int @default(0) // Display order - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - memberships Membership[] - orders Order[] -} -``` - -### Card Type Category Enum -**File**: `packages/server/prisma/schema.prisma` (lines 17-21) - -```prisma -enum CardTypeCategory { - TIMES // Time-based card (e.g., 10 classes) - DURATION // Month card (e.g., 30 days) - TRIAL // Trial card -} -``` - -**Shared Enum**: `packages/shared/src/enums.ts` (lines 8-12) -```typescript -export enum CardTypeCategory { - TIMES = 'TIMES', - DURATION = 'DURATION', - TRIAL = 'TRIAL', -} -``` - ---- - -## 2. SHARED TYPES & DTOs - -### CardType Interface -**File**: `packages/shared/src/types/card-type.ts` - -```typescript -export interface CardType { - readonly id: string - readonly name: string - readonly type: CardTypeCategory // TIMES | DURATION | TRIAL - readonly totalTimes: number | null // null for DURATION cards - readonly durationDays: number - readonly price: number // In cents, e.g., 98000 = ยฅ980 - readonly originalPrice: number | null - readonly description: string | null - readonly isActive: boolean // true = ้”€ๅ”ฎไธญ, false = ๅทฒไธ‹ๆžถ - readonly sortOrder: number - readonly createdAt: string - readonly updatedAt: string -} -``` - -### CreateCardTypeDto -```typescript -export interface CreateCardTypeDto { - readonly name: string - readonly type: CardTypeCategory - readonly totalTimes?: number - readonly durationDays: number - readonly price: number - readonly originalPrice?: number - readonly description?: string - readonly sortOrder?: number -} -``` - -### UpdateCardTypeDto -```typescript -export interface UpdateCardTypeDto { - readonly name?: string - readonly totalTimes?: number - readonly durationDays?: number - readonly price?: number - readonly originalPrice?: number - readonly description?: string - readonly isActive?: boolean // For toggling ไธŠๆžถ/ไธ‹ๆžถ - readonly sortOrder?: number -} -``` - ---- - -## 3. SERVER-SIDE IMPLEMENTATION - -### Membership Controller -**File**: `packages/server/src/membership/membership.controller.ts` - -**Endpoints**: -```typescript -// Public (no auth) -GET /membership/card-types โ†’ getActiveCardTypes() - -// Admin only (JWT + RolesGuard) -GET /admin/card-types โ†’ getAllCardTypes() -POST /admin/card-types โ†’ createCardType(dto) -PUT /admin/card-types/:id โ†’ updateCardType(id, dto) -DELETE /admin/card-types/:id โ†’ deleteCardType(id) -``` - -### Membership Service -**File**: `packages/server/src/membership/membership.service.ts` - -#### getActiveCardTypes() -- Returns only cards where `isActive: true` -- Sorted by `sortOrder` (ascending) -- Used by regular users/public - -#### getAllCardTypes() -- Returns all cards (including inactive) -- Sorted by `sortOrder` -- Admin-only - -#### createCardType(dto: CreateCardTypeDto) -- Creates a new card type -- Sets `isActive: true` by default -- `totalTimes` and `description` are optional (default to null) - -#### updateCardType(id: string, dto: UpdateCardTypeDto) -- Updates card (all fields optional) -- **Can toggle `isActive`** for ไธŠๆžถ/ไธ‹ๆžถ -- Can update name, price, duration, etc. - -#### deleteCardType(id: string) -- **Soft delete**: doesn't remove from DB -- Sets `isActive: false` instead -- Updates the record - ---- - -## 4. FRONTEND ADMIN PAGE - -### Card-Types Page -**File**: `packages/app/src/pages/admin/card-types.vue` - -#### Layout Structure: -1. **Toolbar** (top) - - Shows count: "ๅ…ฑ X ไธชๅก็ง" - - "+ ๆ–ฐๅขžๅก็ง" button โ†’ `openAdd()` - -2. **Card List** (scrollable) - - Each card shows: - - Header band (colored by type: ๆฌกๅก/ๆœˆๅก/ไฝ“้ชŒๅก) - - Status tag (้”€ๅ”ฎไธญ or ๅทฒไธ‹ๆžถ) - - Card name, price, description - - Meta info: times, duration, sort order - - Three action buttons: ็ผ–่พ‘, ไธ‹ๆžถ/ไธŠๆžถ, ๅˆ ้™ค - -3. **Modal/Popup** (add/edit form) - - Title: "ๆ–ฐๅขžๅก็ง" or "็ผ–่พ‘ๅก็ง" - - Input fields: - * ๅก็งๅ็งฐ (name) - * ็ฑปๅž‹ (picker: ๆฌกๅก, ๆœˆๅก, ไฝ“้ชŒๅก) - * ็Žฐไปท (price, digit input) - * ๅŽŸไปท (originalPrice, optional) - * ๆฌกๆ•ฐ (totalTimes, optional, required for ๆฌกๅก) - * ๆœ‰ๆ•ˆๅคฉๆ•ฐ (durationDays, required) - * ๆŽ’ๅบๅ€ผ (sortOrder, defaults to 0) - * ๆ่ฟฐ (description, optional textarea) - - Cancel and Confirm buttons - -#### Key Ref Variables: -```typescript -const cardTypes = ref([]) -const loading = ref(false) -const showModal = ref(false) -const submitting = ref(false) -const editTarget = ref(null) - -const form = ref({ - name: '', - typeIdx: 0, // Index into typeOptions - priceStr: '', // String, parsed to number - originalPriceStr: '', - totalTimesStr: '', - durationDaysStr: '90', // Default 90 days - sortOrderStr: '0', // Default 0 - description: '', -}) -``` - -#### Functions: - -**fetchCardTypes()** -- Calls `adminStore.fetchCardTypes()` -- Sets loading state -- Updates `cardTypes` ref - -**openAdd()** -- Sets `editTarget = null` -- Resets `form` to initial state -- Sets `showModal = true` -- โ†’ **Opens new card form** - -**openEdit(ct: CardType)** -- Sets `editTarget = ct` -- Populates `form` from card data -- Finds `typeIdx` from typeOptions -- Sets `showModal = true` -- โ†’ **Opens edit form with card data** - -**closeModal()** -- Sets `showModal = false` -- Clears `editTarget` - -**submitForm()** -- Validates: name (required), price (required, > 0), durationDays (required, >= 1) -- Parses string inputs to numbers -- Builds payload object -- If `editTarget` exists: calls `adminStore.updateCardType()` -- Else: calls `adminStore.createCardType()` -- Shows success toast and refetches list -- Catches errors and shows error toast - -**toggleActive(ct: CardType)** -- Calls `adminStore.updateCardType(ct.id, { isActive: !ct.isActive })` -- Refetches list -- โ†’ **ไธŠๆžถ/ไธ‹ๆžถ button action** - -**confirmDelete(ct: CardType)** -- Shows confirmation modal: "ๅˆ ้™คๅก็งใ€ŒXใ€๏ผŸๆญคๆ“ไฝœไธๅฏๆขๅคใ€‚" -- If confirmed: calls `adminStore.deleteCardType(ct.id)` -- Soft deletes (sets isActive: false) -- Shows success toast -- Refetches list - -#### Helper Functions: - -**typeLabel(ct: CardType): string** -- Maps enum to Chinese: TIMES โ†’ 'ๆฌกๅก', DURATION โ†’ 'ๆœˆๅก', TRIAL โ†’ 'ไฝ“้ชŒๅก' - -**headerClass(ct: CardType): string** -- Returns CSS class for colored header banner - ---- - -## 5. ADMIN STORE (Pinia) - -**File**: `packages/app/src/stores/admin.ts` - -```typescript -export const useAdminStore = defineStore('admin', () => { - // โ”€โ”€โ”€ Card types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const cardTypes = ref([]) - - async function fetchCardTypes(): Promise { - const data = await get('/admin/card-types') - cardTypes.value = [...data].sort((a, b) => a.sortOrder - b.sortOrder) - return cardTypes.value - } - - async function createCardType(dto: CreateCardTypeDto): Promise { - const data = await post('/admin/card-types', dto) - await fetchCardTypes() // Refetch to get updated list - return data - } - - async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise { - const data = await put(`/admin/card-types/${id}`, dto) - await fetchCardTypes() // Refetch to get updated list - return data - } - - async function deleteCardType(id: string): Promise { - await del(`/admin/card-types/${id}`) - await fetchCardTypes() // Refetch to get updated list - } - - return { - cardTypes, - fetchCardTypes, - createCardType, - updateCardType, - deleteCardType, - // ... other admin functions - } -}) -``` - ---- - -## 6. WORKFLOW FLOWS - -### Adding a New Card Type -1. User taps "+ ๆ–ฐๅขžๅก็ง" button -2. `openAdd()` is called - - `editTarget = null` - - `form` reset to defaults - - `showModal = true` -3. Modal appears with empty form -4. User fills in form fields -5. User taps "็กฎ่ฎค" button -6. `submitForm()` validates, builds payload, calls `adminStore.createCardType(payload)` -7. Backend creates new CardType (with `isActive: true` by default) -8. Admin store refetches list -9. Page updates with new card -10. Modal closes automatically - -### Editing a Card Type -1. User taps "็ผ–่พ‘" button on a card -2. `openEdit(ct)` is called - - `editTarget = ct` - - `form` populated from card data - - `showModal = true` -3. Modal appears with prefilled form -4. User modifies fields -5. User taps "็กฎ่ฎค" button -6. `submitForm()` validates, builds payload, calls `adminStore.updateCardType(id, payload)` -7. Backend updates CardType -8. Admin store refetches list -9. Page updates with new data -10. Modal closes automatically - -### Toggling Active Status (ไธŠๆžถ/ไธ‹ๆžถ) -1. User taps "ไธ‹ๆžถ" or "ไธŠๆžถ" button -2. `toggleActive(ct)` is called - - Calls `adminStore.updateCardType(ct.id, { isActive: !ct.isActive })` -3. Backend updates `isActive` field -4. Admin store refetches list -5. Page re-renders: - - If `isActive: false`: card becomes semi-transparent (opacity: 0.6) - - Status tag changes from "้”€ๅ”ฎไธญ" to "ๅทฒไธ‹ๆžถ" - - Button text changes - -### Deleting a Card Type -1. User taps "ๅˆ ้™ค" button -2. `confirmDelete(ct)` is called - - Shows confirmation dialog -3. User confirms deletion -4. `adminStore.deleteCardType(ct.id)` called -5. Backend does soft delete: sets `isActive: false` -6. Admin store refetches list -7. Page updates (card marked as inactive) - ---- - -## 7. API COMMUNICATION - -### Request Utility -**File**: `packages/app/src/utils/request.ts` - -```typescript -const BASE_URL = 'http://localhost:3000/api' // or production URL - -// Helper functions -async function get(url: string, data?: Record): Promise -async function post(url: string, data?: Record): Promise -async function put(url: string, data?: Record): Promise -async function del(url: string, data?: Record): Promise -``` - -**Response Format**: -```typescript -interface ApiResponse { - success: boolean - data: T | null - message: string | null -} -``` - -All admin endpoints require: -- JWT Bearer token (from storage) -- User role must be ADMIN - ---- - -## 8. PRICE HANDLING - -**Important**: Prices are stored as integers (cents) in DB and API -- ยฅ980 is stored as `98000` cents -- Frontend displays formatted: `ยฅ980.00` - -**Formatting**: -```typescript -export function formatPrice(cents: number): string { - return (cents / 100).toFixed(2) // 98000 โ†’ "980.00" -} -``` - -**In Page**: `ยฅ{{ formatPrice(ct.price) }}` - ---- - -## 9. CARD TYPE CATEGORIES - -### TIMES Card (ๆฌกๅก) -- Used for class count-based purchases -- Example: "10ๆฌก่ฏพๅฅ—้ค" -- **Required fields**: `totalTimes` (e.g., 10) -- Optional fields: `originalPrice`, `description` -- Color: Dark blue gradient (`#1a1a2e` to `#2d2d5e`) - -### DURATION Card (ๆœˆๅก) -- Used for time-period-based purchases -- Example: "30ๅคฉๅก" -- **Required fields**: `durationDays` -- `totalTimes` is optional/not used -- Color: Purple gradient (`#6c3483` to `#9b59b6`) - -### TRIAL Card (ไฝ“้ชŒๅก) -- Used for trial/sample purchases -- Color: Gold/tan gradient (`#7d6608` to `#c9a87c`) - ---- - -## 10. FIELD REQUIREMENTS & VALIDATION - -| Field | Create | Update | Type | Validation | -|-------|--------|--------|------|-----------| -| name | โœ“ Required | Optional | string | Trimmed, non-empty | -| type | โœ“ Required | Optional | enum | TIMES \| DURATION \| TRIAL | -| totalTimes | Optional | Optional | integer | Min: 1 | -| durationDays | โœ“ Required | Optional | integer | Min: 1 | -| price | โœ“ Required | Optional | number | Min: 0 | -| originalPrice | Optional | Optional | number | Min: 0 | -| description | Optional | Optional | string | Max: 200 chars | -| sortOrder | Optional | Optional | integer | Min: 0, default: 0 | -| isActive | N/A | Optional | boolean | default: true on create | - ---- - -## 11. POTENTIAL ISSUES & BUG: Edit Popup Closes Immediately - -### Issue Description -When user taps "็ผ–่พ‘" button, the edit modal popup closes immediately instead of staying open. - -### Root Cause Analysis - -Looking at the template structure (lines 85-195 of card-types.vue): - -```vue - - - - - -``` - -**The problem**: -1. User taps "็ผ–่พ‘" button on a card (line 67) -2. `openEdit(ct)` sets `showModal = true` -3. Modal appears -4. BUT: The tap event likely **bubbles** or there's a **race condition** -5. The click that triggered `openEdit()` might also trigger `closeModal()` - -### Potential Causes: - -1. **Event Propagation Issue**: - - The edit button tap might bubble to parent elements - - The modal-mask has `@tap.self="closeModal"` - - If the modal appears in the same frame, the tap event might close it - -2. **Modal Rendering Timing**: - - If modal renders synchronously in the same event tick - - The tap event (which hasn't finished propagating) might hit the modal-mask - -3. **Vue/Uni-app Quirk**: - - Some mini-program frameworks have event timing issues - - The `.self` modifier might not work as expected with rapid re-renders - -### Solution Approaches: - -1. **Add click guard**: Prevent tap on edit button from propagating - ```vue - - ``` - -2. **Add delay for modal rendering**: Let Vue finish the current cycle - ```typescript - function openEdit(ct: CardType) { - editTarget.value = ct - form.value = { ... } - // Delay modal show to next tick - nextTick(() => { - showModal.value = true - }) - } - ``` - -3. **Track modal state change**: Ignore tap events for a brief moment after modal opens - ```typescript - const modalJustOpened = ref(false) - - function openEdit(ct: CardType) { - editTarget.value = ct - form.value = { ... } - showModal.value = true - modalJustOpened.value = true - setTimeout(() => { - modalJustOpened.value = false - }, 100) - } - - function closeModal() { - if (!modalJustOpened.value) { - showModal.value = false - editTarget.value = null - } - } - ``` - -4. **Restructure modal trigger**: - - Separate the button from the modal in the DOM - - Or use a completely different event model - ---- - -## SUMMARY OF ALL FILES REVIEWED - -1. โœ… Frontend page: `packages/app/src/pages/admin/card-types.vue` (607 lines) -2. โœ… Admin store: `packages/app/src/stores/admin.ts` (198 lines) -3. โœ… Shared types: `packages/shared/src/types/card-type.ts` (39 lines) -4. โœ… Server controller: `packages/server/src/membership/membership.controller.ts` (68 lines) -5. โœ… Server service: `packages/server/src/membership/membership.service.ts` (173 lines) -6. โœ… Create DTO: `packages/server/src/membership/dto/create-card-type.dto.ts` (45 lines) -7. โœ… Update DTO: `packages/server/src/membership/dto/update-card-type.dto.ts` (49 lines) -8. โœ… Prisma schema: `packages/server/prisma/schema.prisma` (205 lines) -9. โœ… Shared enums: `packages/shared/src/enums.ts` (47 lines) -10. โœ… Format utils: `packages/app/src/utils/format.ts` (46 lines) -11. โœ… Request utils: `packages/app/src/utils/request.ts` (80 lines) -12. โœ… Membership types: `packages/shared/src/types/membership.ts` (19 lines) -13. โœ… API types: `packages/shared/src/types/api.ts` (20 lines) - diff --git a/CARD_TYPES_BUG_FIX.md b/CARD_TYPES_BUG_FIX.md deleted file mode 100644 index a974c87..0000000 --- a/CARD_TYPES_BUG_FIX.md +++ /dev/null @@ -1,132 +0,0 @@ -# Card Types Edit Modal Bug Fix - -## Bug Description - -When a user taps the **[็ผ–่พ‘]** (Edit) button in the card types admin page, the edit modal opens briefly but **closes immediately** in the same event cycle. This makes it impossible to edit card types. - -### Root Cause - -The bug was caused by Vue event propagation/bubbling: - -1. User taps edit button โ†’ `@tap="openEdit(ct)"` fires -2. `openEdit()` sets `showModal.value = true` -3. Modal is rendered and displayed -4. The tap event **bubbles up** to the parent modal-mask element -5. Modal-mask has `@tap.self="closeModal"` which immediately closes the modal -6. Result: Modal opens and closes in the same event tick - -### Code Location - -File: `packages/app/src/pages/admin/card-types.vue` - -**Before (buggy):** -```vue - - - ็ผ–่พ‘ - - - - - ... - - - - - ... - - - - - ... - -``` - -## Solution Applied - -Added the `.stop` modifier to all action button tap handlers to **prevent event propagation** to parent elements: - -```vue - - - ็ผ–่พ‘ - - - - - ... - - - - - ... - -``` - -## Why This Works - -The `.stop` modifier is equivalent to calling `event.stopPropagation()`. It prevents the tap event from bubbling up the DOM tree, so: - -1. User taps edit button โ†’ `@tap.stop="openEdit(ct)"` fires -2. Event propagation is **stopped** - event does NOT bubble to modal-mask -3. `openEdit()` sets `showModal.value = true` -4. Modal renders and stays open โœ“ - -## Technical Details - -### Vue Event Modifiers Used - -- **`.stop`** - Calls `event.stopPropagation()` to prevent event bubbling - -### Affected Operations - -Three actions were fixed: -1. **Edit** (็ผ–่พ‘) - Opens form to edit selected card type -2. **Toggle** (ไธŠๆžถ/ไธ‹ๆžถ) - Toggles active status (on/off shelf) -3. **Delete** (ๅˆ ้™ค) - Opens confirmation dialog for deletion - -## Testing Instructions - -To verify the fix works: - -1. Navigate to Admin โ†’ Card Types Management -2. Click the **[็ผ–่พ‘]** button on any card -3. Verify the edit modal opens and **stays open** -4. Edit form fields and confirm the changes save correctly -5. Test the toggle button (ไธŠๆžถ/ไธ‹ๆžถ) - should toggle without closing modal -6. Test the delete button - should show confirmation dialog - -## Code Changes Summary - -| File | Line | Change | Type | -|------|------|--------|------| -| card-types.vue | 67 | `@tap="openEdit(ct)"` โ†’ `@tap.stop="openEdit(ct)"` | Fix | -| card-types.vue | 73 | `@tap="toggleActive(ct)"` โ†’ `@tap.stop="toggleActive(ct)"` | Fix | -| card-types.vue | 77 | `@tap="confirmDelete(ct)"` โ†’ `@tap.stop="confirmDelete(ct)"` | Fix | - -Total changes: **3 lines modified** - -## Impact Assessment - -- **Severity**: High - Feature completely broken, users cannot edit card types -- **Risk**: Very Low - Simple modifier addition, no logic changes -- **Testing**: Quick manual test needed -- **Performance**: No impact -- **Breaking Changes**: None -- **Backward Compatibility**: Fully compatible - -## Related Documentation - -See the following files for comprehensive feature documentation: -- `CARD_TYPES_ANALYSIS.md` - Deep dive into the feature -- `CARD_TYPES_QUICK_REFERENCE.md` - Quick lookup guide -- `EXPLORATION_SUMMARY.md` - Full system overview -- `CARD_TYPES_INDEX.md` - Master index with all references - -## Next Steps - -1. โœ… Apply the fix (COMPLETED) -2. Test the feature manually -3. Verify all three action buttons work correctly -4. Consider adding automated E2E tests for card type management -5. Review other modals for similar event propagation issues diff --git a/CARD_TYPES_FLOW_DIAGRAM.txt b/CARD_TYPES_FLOW_DIAGRAM.txt deleted file mode 100644 index 81c75bf..0000000 --- a/CARD_TYPES_FLOW_DIAGRAM.txt +++ /dev/null @@ -1,228 +0,0 @@ -โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— -โ•‘ CARD TYPES MANAGEMENT - COMPLETE FLOW DIAGRAM โ•‘ -โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ DATABASE TIER (Prisma/MySQL) โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ CardType Model โ”‚ โ”‚ CardTypeCategory Enum โ”‚ โ”‚ -โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ -โ”‚ โ”‚ id (UUID) โ”‚ โ”‚ TIMES (classes) โ”‚ โ”‚ -โ”‚ โ”‚ name (String) โ”‚ โ”‚ DURATION (months) โ”‚ โ”‚ -โ”‚ โ”‚ type (Enum) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ TRIAL (trial) โ”‚ โ”‚ -โ”‚ โ”‚ totalTimes (Int?) โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ durationDays (Int) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ price (Decimal) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ โ”‚ originalPrice (Decimal?)โ”‚ โ”‚ -โ”‚ โ”‚ description (String?) โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ isActive (Boolean) โ”‚โ”€โ”€โ”€โ”€โ†’โ”‚ Soft Delete Strategy โ”‚ โ”‚ -โ”‚ โ”‚ sortOrder (Int) โ”‚ โ”‚ DELETE = isActive=false โ”‚ -โ”‚ โ”‚ createdAt/updatedAt โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ -โ”‚ Relationships: โ† Membership (many), Order (many) โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ API TIER (NestJS Backend) - packages/server/src/membership/ โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ โ”‚ -โ”‚ MembershipController MembershipService โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ GET /membership/... โ”‚ โ”‚ getActiveCardTypes() โ”‚ โ”‚ -โ”‚ โ”‚ GET /admin/card-types โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ†’ โ”‚ getAllCardTypes() โ”‚ โ”‚ -โ”‚ โ”‚ POST /admin/... โ”‚ โ”‚ createCardType(dto) โ”‚ โ”‚ -โ”‚ โ”‚ PUT /admin/.../id โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ updateCardType(id, dto) โ”‚ โ”‚ -โ”‚ โ”‚ DELETE /admin/.../id โ”‚ โ”‚ โ”‚ deleteCardType(id) โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ†“ โ”‚ โ†“ โ”‚ -โ”‚ Validators: โ””โ†’ PrismaService (DB calls) โ”‚ -โ”‚ - JwtAuthGuard (token required) โ”‚ -โ”‚ - RolesGuard (ADMIN role only) โ”‚ -โ”‚ โ”‚ -โ”‚ Request DTOs: Response Types: โ”‚ -โ”‚ โ”Œโ”€CreateCardTypeDtoโ”€โ”€โ”€โ” โ”Œโ”€โ”€CardTypeโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ name โœ“ โ”‚ โ”‚ id โ”‚ โ”‚ -โ”‚ โ”‚ type โœ“ โ”‚ โ”‚ name โ”‚ โ”‚ -โ”‚ โ”‚ durationDays โœ“ โ”‚ โ”‚ type โ”‚ โ”‚ -โ”‚ โ”‚ price โœ“ โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ†’ โ”‚ totalTimes โ”‚ โ”‚ -โ”‚ โ”‚ totalTimes? โ”‚ โ”‚ durationDays โ”‚ โ”‚ -โ”‚ โ”‚ originalPrice? โ”‚ โ”‚ price โ”‚ โ”‚ -โ”‚ โ”‚ description? โ”‚ โ”‚ originalPrice โ”‚ โ”‚ -โ”‚ โ”‚ sortOrder? โ”‚ โ”‚ isActive โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ sortOrder โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”Œโ”€UpdateCardTypeDtoโ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ (all fields optional) Includes isActive toggle! โ”‚ -โ”‚ โ”‚ name? โ”‚ -โ”‚ โ”‚ type? โ”‚ -โ”‚ โ”‚ price? โ”‚ -โ”‚ โ”‚ isActive? โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ†’ ไธŠๆžถ/ไธ‹ๆžถ functionality โ”‚ -โ”‚ โ”‚ ... etc ... โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ SHARED TYPES TIER - packages/shared/src/types/ โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ โ”‚ -โ”‚ TypeScript Interfaces & Enums โ”‚ -โ”‚ โ”œโ”€โ”€ CardType (read-only interface) โ”‚ -โ”‚ โ”œโ”€โ”€ CreateCardTypeDto โ”‚ -โ”‚ โ”œโ”€โ”€ UpdateCardTypeDto โ”‚ -โ”‚ โ””โ”€โ”€ CardTypeCategory Enum: TIMES | DURATION | TRIAL โ”‚ -โ”‚ โ”‚ -โ”‚ Shared across Frontend & Backend โ”‚ -โ”‚ โœ“ Type safety โ”‚ -โ”‚ โœ“ Request/Response validation โ”‚ -โ”‚ โœ“ Documentation โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ FRONTEND TIER (Vue 3 + Uni-app) - packages/app/src/ โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ card-types.vue - Admin Management Page โ”‚ โ”‚ -โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€ Toolbar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ "ๅ…ฑ X ไธชๅก็ง" [๏ผ‹ ๆ–ฐๅขžๅก็ง] โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ†“ tap โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ openAdd() โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€ Card List โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ for each cardType: โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ [Header band - colored by type]โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ Card Name, ยฅPrice โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ Duration, Times, Description โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ [็ผ–่พ‘] [ไธ‹ๆžถ] [ๅˆ ้™ค] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ†“ โ†“ โ†“ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ open toggle delete โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ Edit() Active() Confirm() โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€ Modal/Popup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ @tap.self="closeModal" on mask โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ v-if="showModal" โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ ๆ–ฐๅขžๅก็ง / ็ผ–่พ‘ๅก็ง โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ ๅก็งๅ็งฐ [input] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ ็ฑปๅž‹ [picker] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ ็Žฐไปท [digit] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ ๅŽŸไปท [digit] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ ๆฌกๆ•ฐ [number] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ ๆœ‰ๆ•ˆๅคฉๆ•ฐ [number] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ ๆŽ’ๅบๅ€ผ [number] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ ๆ่ฟฐ [textarea] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ [ๅ–ๆถˆ] [็กฎ่ฎค] โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ†“ tap โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ submitForm() โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ closeModal() โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ editTarget = nullโ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ Reactive State: โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”œโ”€ cardTypes: [] โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”œโ”€ showModal: false โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”œโ”€ editTarget: null โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”œโ”€ form: { โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ name, typeIdx, โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ priceStr, ... โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ } โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€ submitting: false โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ admin.ts (Pinia Store) โ”‚ โ”‚ โ”‚ -โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ โ”‚ -โ”‚ โ”‚ cardTypes: CardType[] โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ fetchCardTypes() โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”œโ”€ GET /admin/card-types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ โ”œโ”€ return sorted list โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€ update state โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ createCardType(dto) โ”‚ โ”‚ -โ”‚ โ”‚ โ”œโ”€ POST /admin/card-types โ”€โ”€โ”€โ”€โ”€โ†’ Backend โ”‚ -โ”‚ โ”‚ โ””โ”€ refetch list โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ updateCardType(id, dto) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ -โ”‚ โ”‚ โ”œโ”€ PUT /admin/card-types/:id โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€ refetch list โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ deleteCardType(id) โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ”œโ”€ DELETE /admin/card-types/:id โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€ refetch list โ”‚ โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ -โ”‚ utils/request.ts โ”‚ โ”‚ -โ”‚ โ”œโ”€ get() โ”‚ โ”‚ -โ”‚ โ”œโ”€ post() โ”‚ โ”‚ -โ”‚ โ”œโ”€ put() โ”‚ โ”‚ -โ”‚ โ””โ”€ del() โ”‚ โ”‚ -โ”‚ All with JWT Bearer token โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - -โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— -โ•‘ CRITICAL BUG: Edit Popup Closes Immediately โ•‘ -โ• โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•ฃ -โ•‘ โ•‘ -โ•‘ SYMPTOM: When tapping [็ผ–่พ‘], modal appears then instantly closes โ•‘ -โ•‘ โ•‘ -โ•‘ ROOT CAUSE: Event propagation issue โ•‘ -โ•‘ 1. User taps [็ผ–่พ‘] button โ•‘ -โ•‘ 2. openEdit() sets showModal = true โ•‘ -โ•‘ 3. Modal renders with @tap.self="closeModal" โ•‘ -โ•‘ 4. Tap event might propagate to modal-mask in same tick โ•‘ -โ•‘ 5. closeModal() fires immediately โ•‘ -โ•‘ 6. Modal closes โ•‘ -โ•‘ โ•‘ -โ•‘ SOLUTIONS: โ•‘ -โ•‘ โ•‘ -โ•‘ Option 1: Stop propagation (RECOMMENDED - SIMPLE) โ•‘ -โ•‘ @tap.stop="openEdit(ct)" โ•‘ -โ•‘ โ•‘ -โ•‘ Option 2: Use nextTick() for modal rendering โ•‘ -โ•‘ function openEdit(ct: CardType) { โ•‘ -โ•‘ editTarget.value = ct โ•‘ -โ•‘ form.value = { ... } โ•‘ -โ•‘ nextTick(() => { โ•‘ -โ•‘ showModal.value = true // Defer to next frame โ•‘ -โ•‘ }) โ•‘ -โ•‘ } โ•‘ -โ•‘ โ•‘ -โ•‘ Option 3: State guard with timeout โ•‘ -โ•‘ const modalJustOpened = ref(false) โ•‘ -โ•‘ โ•‘ -โ•‘ function openEdit(ct: CardType) { โ•‘ -โ•‘ editTarget.value = ct โ•‘ -โ•‘ form.value = { ... } โ•‘ -โ•‘ showModal.value = true โ•‘ -โ•‘ modalJustOpened.value = true โ•‘ -โ•‘ setTimeout(() => { modalJustOpened.value = false }, 100) โ•‘ -โ•‘ } โ•‘ -โ•‘ โ•‘ -โ•‘ function closeModal() { โ•‘ -โ•‘ if (!modalJustOpened.value) { โ•‘ -โ•‘ showModal.value = false โ•‘ -โ•‘ editTarget.value = null โ•‘ -โ•‘ } โ•‘ -โ•‘ } โ•‘ -โ•‘ โ•‘ -โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• diff --git a/CARD_TYPES_INDEX.md b/CARD_TYPES_INDEX.md deleted file mode 100644 index ad64be1..0000000 --- a/CARD_TYPES_INDEX.md +++ /dev/null @@ -1,244 +0,0 @@ -# ๅก็ง็ฎก็† (Card Types Management) - Documentation Index - -**Exploration Date**: April 5, 2026 -**Total Files Analyzed**: 13 source files (~1,800 lines) -**Documentation Created**: 4 comprehensive guides (1,546 lines) - ---- - -## ๐Ÿ“– Documentation Files - -### 1. **EXPLORATION_SUMMARY.md** โญ START HERE -**Best for**: Quick overview of the entire system and key findings - -- What was explored (13 files, 1,800 lines) -- Documentation generated -- Key findings summary -- File inventory -- Complete workflows -- Bug identification -- Next steps -- Statistics - -**Read time**: 15-20 minutes -**Size**: 12 KB, 428 lines - ---- - -### 2. **CARD_TYPES_QUICK_REFERENCE.md** ๐Ÿ“‹ FOR LOOKUP -**Best for**: Quick lookup when working on the code - -- File quick links with line numbers -- Key data model (CardType entity) -- API endpoints -- DTOs & validation rules -- UI components structure -- Form fields list -- Operations guide (Add, Edit, Toggle, Delete) -- React refs & state -- Admin store methods -- **Bug explanation with 3 solutions** โšก -- Price handling notes -- Testing checklist -- Card type categories - -**Read time**: 10 minutes -**Size**: 10 KB, 342 lines - ---- - -### 3. **CARD_TYPES_ANALYSIS.md** ๐Ÿ“š FOR DEEP DIVE -**Best for**: Understanding every detail of the system - -**11 Sections**: -1. Project structure -2. Database schema (Prisma) -3. Shared types & DTOs -4. Server-side implementation -5. Frontend admin page -6. Admin store (Pinia) -7. Workflow flows -8. API communication -9. Price handling -10. Card type categories -11. **Detailed bug analysis** with root cause - -**Read time**: 30-40 minutes -**Size**: 16 KB, 548 lines - ---- - -### 4. **CARD_TYPES_FLOW_DIAGRAM.txt** ๐ŸŽจ FOR VISUALIZATION -**Best for**: Understanding data flow and architecture visually - -- Database tier diagram -- API tier diagram -- Shared types tier -- Frontend tier (page structure, store, state) -- Complete operation flows (Add, Edit, Toggle, Delete) -- **Bug analysis with solutions** - -**Read time**: 20 minutes -**Size**: 24 KB, 228 lines (ASCII art) - ---- - -## ๐ŸŽฏ How to Use This Documentation - -### Scenario 1: "I need to understand the whole system" -1. Start with **EXPLORATION_SUMMARY.md** (overview) -2. Look at **CARD_TYPES_FLOW_DIAGRAM.txt** (visual) -3. Dive into **CARD_TYPES_ANALYSIS.md** (details) - -### Scenario 2: "I need to find something specific" -โ†’ Use **CARD_TYPES_QUICK_REFERENCE.md** (index & lookup) - -### Scenario 3: "I need to fix the edit modal bug" -โ†’ Jump to **CARD_TYPES_QUICK_REFERENCE.md** โ†’ Section "THE BUG" or -โ†’ Read **CARD_TYPES_ANALYSIS.md** โ†’ Section 11 "Detailed bug analysis" - -### Scenario 4: "I need to see how data flows" -โ†’ Check **CARD_TYPES_FLOW_DIAGRAM.txt** - -### Scenario 5: "I'm new to this project" -โ†’ Read in order: -1. EXPLORATION_SUMMARY.md -2. CARD_TYPES_FLOW_DIAGRAM.txt -3. CARD_TYPES_QUICK_REFERENCE.md (bookmark for later) -4. CARD_TYPES_ANALYSIS.md (as needed for details) - ---- - -## ๐Ÿ” Quick File Locations - -### Frontend -- Admin page: `packages/app/src/pages/admin/card-types.vue` (607 lines) -- Pinia store: `packages/app/src/stores/admin.ts` (198 lines) - -### Backend -- Controller: `packages/server/src/membership/membership.controller.ts` (68 lines) -- Service: `packages/server/src/membership/membership.service.ts` (173 lines) -- Create DTO: `packages/server/src/membership/dto/create-card-type.dto.ts` (45 lines) -- Update DTO: `packages/server/src/membership/dto/update-card-type.dto.ts` (49 lines) - -### Database -- Prisma schema: `packages/server/prisma/schema.prisma` (205 lines) - -### Shared Types -- Card types: `packages/shared/src/types/card-type.ts` (39 lines) -- Enums: `packages/shared/src/enums.ts` (47 lines) - ---- - -## โšก The Critical Bug - -**What**: Edit modal closes immediately when user taps [็ผ–่พ‘] button - -**Why**: Event propagation issue - tap event bubbles to modal-mask's @tap.self - -**Where to Fix**: Line 67 of `packages/app/src/pages/admin/card-types.vue` - -**Simple Fix**: Change `@tap="openEdit(ct)"` to `@tap.stop="openEdit(ct)"` - -**See Also**: -- CARD_TYPES_QUICK_REFERENCE.md โ†’ "THE BUG" section -- CARD_TYPES_ANALYSIS.md โ†’ Section 11 -- CARD_TYPES_FLOW_DIAGRAM.txt โ†’ Bottom (3 solutions shown) - ---- - -## ๐Ÿ“Š Key Statistics - -| Aspect | Count | -|--------|-------| -| Source files analyzed | 13 | -| Total lines of code | ~1,800 | -| API endpoints | 5 | -| Card type categories | 3 (TIMES, DURATION, TRIAL) | -| Core operations | 4 (Create, Read, Update, Delete) | -| Documentation files | 4 | -| Documentation lines | 1,546 | -| Bugs identified | 1 | -| Bug severity | High (UX-breaking) | - ---- - -## ๐ŸŽจ Card Type Categories - -1. **ๆฌกๅก (TIMES)**: Class count-based (e.g., 10 classes) - Dark blue -2. **ๆœˆๅก (DURATION)**: Time period-based (e.g., 30 days) - Purple -3. **ไฝ“้ชŒๅก (TRIAL)**: Trial cards - Gold/tan - ---- - -## ๐Ÿ” Auth & Security - -- Admin endpoints require JWT Bearer token -- Admin endpoints require ADMIN role -- Public endpoint (GET /membership/card-types) returns only active cards - ---- - -## ๐Ÿ’พ Database Details - -**CardType Model**: -- Soft delete (set isActive=false, not removed from DB) -- Relationships: Membership (many), Order (many) -- Indexed on: isActive, sortOrder - ---- - -## ๐Ÿ“ API Endpoints - -| Method | Endpoint | Auth | Purpose | -|--------|----------|------|---------| -| GET | /membership/card-types | None | Get active cards (public) | -| GET | /admin/card-types | JWT+Admin | Get all cards (admin) | -| POST | /admin/card-types | JWT+Admin | Create card | -| PUT | /admin/card-types/:id | JWT+Admin | Update card (can toggle isActive) | -| DELETE | /admin/card-types/:id | JWT+Admin | Soft delete card | - ---- - -## ๐Ÿงช Testing Checklist - -- [ ] Create new card with all types -- [ ] Edit existing card -- [ ] Toggle card status (ไธŠๆžถ/ไธ‹ๆžถ) -- [ ] Delete card (soft delete works) -- [ ] List updates after each operation -- [ ] Modal closes after submit -- [ ] **FIX**: Edit modal stays open (not closes immediately) - ---- - -## ๐Ÿš€ Next Steps - -1. **Quick start**: Read EXPLORATION_SUMMARY.md (15 min) -2. **Deep dive**: Read CARD_TYPES_ANALYSIS.md (30 min) -3. **Reference**: Bookmark CARD_TYPES_QUICK_REFERENCE.md -4. **Implement bug fix** (5 min) -5. **Test thoroughly** (15 min) - ---- - -## ๐Ÿ’ก Price Handling - -**Critical**: Prices are stored as integers (cents) -- ยฅ980 = 98000 cents -- Display: formatPrice(98000) = "980.00" - ---- - -## ๐Ÿ“š Related Documentation - -- `ADMIN_SCHEDULING_EXPLORATION.md` - Scheduling feature -- `BOOKING_ARCHITECTURE_DIAGRAM.md` - Booking system -- `BOOKING_PAGE_ANALYSIS.md` - Booking pages -- `SCHEDULING_QUICK_REFERENCE.md` - Scheduling reference - ---- - -**Generated**: 2026-04-05 -**Ready to**: Implement features, fix bugs, deploy updates - diff --git a/CARD_TYPES_QUICK_REFERENCE.md b/CARD_TYPES_QUICK_REFERENCE.md deleted file mode 100644 index ba82595..0000000 --- a/CARD_TYPES_QUICK_REFERENCE.md +++ /dev/null @@ -1,342 +0,0 @@ -# Card Types Management - Quick Reference Guide - -## ๐Ÿ“ File Quick Links - -| Purpose | File Path | Lines | -|---------|-----------|-------| -| **Frontend** | | | -| Admin page | `packages/app/src/pages/admin/card-types.vue` | 607 | -| Store (Pinia) | `packages/app/src/stores/admin.ts` | 198 | -| Request utils | `packages/app/src/utils/request.ts` | 80 | -| Format utils | `packages/app/src/utils/format.ts` | 46 | -| **Backend** | | | -| Controller | `packages/server/src/membership/membership.controller.ts` | 68 | -| Service | `packages/server/src/membership/membership.service.ts` | 173 | -| Create DTO | `packages/server/src/membership/dto/create-card-type.dto.ts` | 45 | -| Update DTO | `packages/server/src/membership/dto/update-card-type.dto.ts` | 49 | -| **Database** | | | -| Prisma schema | `packages/server/prisma/schema.prisma` | 205 | -| **Shared** | | | -| Card types | `packages/shared/src/types/card-type.ts` | 39 | -| Enums | `packages/shared/src/enums.ts` | 47 | -| API types | `packages/shared/src/types/api.ts` | 20 | -| Membership types | `packages/shared/src/types/membership.ts` | 19 | - ---- - -## ๐ŸŽฏ Key Data Model - -### CardType Entity -```typescript -{ - id: string (UUID) - name: string // e.g., "10ๆฌก่ฏพๅฅ—้ค" - type: 'TIMES' | 'DURATION' | 'TRIAL' - totalTimes: number | null // For TIMES/TRIAL cards - durationDays: number // How many days valid - price: number (cents) // ยฅ980 = 98000 - originalPrice: number | null // Strikethrough price - description: string | null - isActive: boolean // ไธŠๆžถ(true) / ไธ‹ๆžถ(false) - sortOrder: number // Display order (ascending) - createdAt: DateTime - updatedAt: DateTime -} -``` - ---- - -## ๐Ÿ”„ API Endpoints - -### Public (No Auth) -``` -GET /membership/card-types Returns active cards only -``` - -### Admin Only (JWT + ADMIN Role) -``` -GET /admin/card-types Get all cards (including inactive) -POST /admin/card-types Create new card -PUT /admin/card-types/:id Update card (can toggle isActive) -DELETE /admin/card-types/:id Soft delete (sets isActive=false) -``` - ---- - -## ๐Ÿ“ DTOs & Validation - -### CreateCardTypeDto -| Field | Required | Type | Validation | -|-------|----------|------|-----------| -| name | โœ“ | string | Must be non-empty | -| type | โœ“ | enum | TIMES \| DURATION \| TRIAL | -| durationDays | โœ“ | int | Min: 1 | -| price | โœ“ | number | Min: 0 | -| totalTimes | - | int | Min: 1 (optional) | -| originalPrice | - | number | Min: 0 (optional) | -| description | - | string | Max: 200 (optional) | -| sortOrder | - | int | Min: 0 (optional, default: 0) | - -### UpdateCardTypeDto -- All fields optional (partial update) -- Can toggle `isActive` for ไธŠๆžถ/ไธ‹ๆžถ - ---- - -## ๐ŸŽจ UI Components - -### Page Structure -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Toolbar: "ๅ…ฑ X ไธชๅก็ง" [๏ผ‹ ๆ–ฐๅขžๅก็ง] โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ Card List โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ [Colored Header Band] โ”‚ โ”‚ -โ”‚ โ”‚ Card Name, ยฅPrice, Duration, etc โ”‚ โ”‚ -โ”‚ โ”‚ [็ผ–่พ‘] [ไธŠๆžถ/ไธ‹ๆžถ] [ๅˆ ้™ค] โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ ... more cards ... โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ Modal (Add/Edit Form) โ”‚ -โ”‚ - Title: ๆ–ฐๅขžๅก็ง / ็ผ–่พ‘ๅก็ง โ”‚ -โ”‚ - Input fields โ”‚ -โ”‚ - [ๅ–ๆถˆ] [็กฎ่ฎค] buttons โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -### Header Colors by Type -- **ๆฌกๅก (TIMES)**: Dark blue `linear-gradient(90deg, #1a1a2e, #2d2d5e)` -- **ๆœˆๅก (DURATION)**: Purple `linear-gradient(90deg, #6c3483, #9b59b6)` -- **ไฝ“้ชŒๅก (TRIAL)**: Gold/tan `linear-gradient(90deg, #7d6608, #c9a87c)` - ---- - -## ๐Ÿ“‹ Form Fields in Modal - -``` -ๅก็งๅ็งฐ text input -็ฑปๅž‹ picker (ๆฌกๅก, ๆœˆๅก, ไฝ“้ชŒๅก) -็Žฐไปท๏ผˆๅ…ƒ๏ผ‰ digit input -ๅŽŸไปท๏ผˆๅ…ƒ๏ผ‰ digit input (optional) -ๆฌกๆ•ฐ number input (optional) -ๆœ‰ๆ•ˆๅคฉๆ•ฐ number input (required, default: 90) -ๆŽ’ๅบๅ€ผ number input (default: 0) -ๆ่ฟฐ textarea (optional) -``` - ---- - -## ๐Ÿ”„ Operations - -### ADD New Card Type -1. Tap [๏ผ‹ ๆ–ฐๅขžๅก็ง] -2. Modal opens with empty form -3. Fill fields (name, type, price, duration required) -4. Tap [็กฎ่ฎค] -5. Backend creates card (isActive=true by default) -6. Modal closes, list updates - -### EDIT Card Type -1. Tap [็ผ–่พ‘] on a card -2. Modal opens with prefilled form -3. Modify desired fields -4. Tap [็กฎ่ฎค] -5. Backend updates card -6. Modal closes, list updates - -### TOGGLE Status (ไธŠๆžถ/ไธ‹ๆžถ) -1. Tap [ไธŠๆžถ] or [ไธ‹ๆžถ] -2. Backend updates `isActive` toggle -3. List re-renders - - Card becomes transparent if `isActive=false` - - Status tag and button text change - -### DELETE Card Type -1. Tap [ๅˆ ้™ค] -2. Confirmation dialog appears -3. If confirmed: backend soft-deletes (isActive=false) -4. List updates - ---- - -## โš™๏ธ React Refs & State - -```typescript -const cardTypes = ref([]) // Current list -const loading = ref(false) // Loading spinner -const showModal = ref(false) // Modal visibility -const submitting = ref(false) // Form submission state -const editTarget = ref(null) // Card being edited (null=add) - -const form = ref({ - name: '', - typeIdx: 0, // Index into typeOptions array - priceStr: '', // String (parsed to number on submit) - originalPriceStr: '', - totalTimesStr: '', - durationDaysStr: '90', // Default 90 days - sortOrderStr: '0', // Default 0 - description: '', -}) -``` - ---- - -## ๐Ÿ’พ Admin Store Methods - -```typescript -// Fetch all cards (including inactive) -await adminStore.fetchCardTypes(): Promise - -// Create new card -await adminStore.createCardType(dto: CreateCardTypeDto): Promise - -// Update card (all fields optional) -// Can toggle isActive, change price, name, etc. -await adminStore.updateCardType(id: string, dto: UpdateCardTypeDto): Promise - -// Delete card (soft delete: sets isActive=false) -await adminStore.deleteCardType(id: string): Promise -``` - -**Note**: All mutations refetch the list automatically - ---- - -## ๐Ÿ› THE BUG: Edit Modal Closes Immediately - -### Symptom -When user taps [็ผ–่พ‘], the edit modal opens then immediately closes. - -### Root Cause -Event propagation issue: -1. User taps [็ผ–่พ‘] button -2. `openEdit()` runs and sets `showModal = true` -3. Modal renders in same event tick -4. Tap event propagates to `modal-mask` which has `@tap.self="closeModal"` -5. Modal closes instantly - -### Current Code (Buggy) -```vue - - ็ผ–่พ‘ - - - - - - -``` - -### Solutions (Pick One) - -**Option 1: Stop Propagation (RECOMMENDED)** -```vue - - - -``` - -**Option 2: Use nextTick()** -```typescript -import { nextTick } from 'vue' - -function openEdit(ct: CardType) { - editTarget.value = ct - form.value = { ... populate ... } - nextTick(() => { - showModal.value = true // Render in next frame - }) -} -``` - -**Option 3: Guard with State** -```typescript -const modalJustOpened = ref(false) - -function openEdit(ct: CardType) { - editTarget.value = ct - form.value = { ... } - showModal.value = true - modalJustOpened.value = true - setTimeout(() => { modalJustOpened.value = false }, 100) -} - -function closeModal() { - if (!modalJustOpened.value) { // Ignore if just opened - showModal.value = false - editTarget.value = null - } -} -``` - -**Recommendation**: Use **Option 1** (@tap.stop) - it's simplest and most idiomatic. - ---- - -## ๐Ÿ’ก Price Handling - -**Important**: Prices are stored as **integers (cents)** in DB and API -- Frontend sends: `{ price: 98000 }` for ยฅ980 -- Display: `formatPrice(98000)` โ†’ `"980.00"` - -```typescript -// Utility function -export function formatPrice(cents: number): string { - return (cents / 100).toFixed(2) -} - -// Usage in template -ยฅ{{ formatPrice(ct.price) }} -``` - ---- - -## ๐Ÿงช Testing Checklist - -- [ ] Can create new card with all field types -- [ ] Can edit existing card and see changes -- [ ] Can toggle card status (ไธŠๆžถ/ไธ‹ๆžถ) -- [ ] Card becomes transparent when inactive -- [ ] Can delete card (shows confirmation) -- [ ] List updates after each operation -- [ ] Price displayed with 2 decimal places -- [ ] Modal closes after successful submit -- [ ] Modal can be closed by tapping outside (on mask) -- [ ] Modal can be closed by tapping Cancel button -- [ ] **BUG FIX**: Edit modal stays open and doesn't close immediately - ---- - -## ๐Ÿ“Š Card Type Categories - -| Type | Chinese | Use Case | Example | Color | Required Fields | -|------|---------|----------|---------|-------|-----------------| -| TIMES | ๆฌกๅก | Classes count | 10ๆฌก่ฏพๅฅ—้ค | Dark blue | totalTimes | -| DURATION | ๆœˆๅก | Time period | 30ๅคฉๅก | Purple | durationDays | -| TRIAL | ไฝ“้ชŒๅก | Trial | ไฝ“้ชŒๅก | Gold/tan | durationDays | - ---- - -## ๐Ÿ”— Related Features - -### Memberships (User Side) -- User can purchase cards (creates Order) -- Payment successful creates Membership record -- Membership tracks remaining times or expiry date -- Used when user books a class - -### Public Card Display -- Users see only `isActive=true` cards on shop page -- Sorted by `sortOrder` -- Can purchase cards - ---- - -## ๐Ÿ“š Documentation Files - -- `CARD_TYPES_ANALYSIS.md` - Complete technical analysis -- `CARD_TYPES_FLOW_DIAGRAM.txt` - Visual flow diagrams -- `CARD_TYPES_QUICK_REFERENCE.md` - This file (quick lookup) - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 4416ed3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,150 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## ้กน็›ฎๆฆ‚่ฟฐ - -ๆ™ฎๆ‹‰ๆๅทฅไฝœๅฎค้ข„็บฆไธŽไผšๅ‘˜็ฎก็†็š„ๅพฎไฟกๅฐ็จ‹ๅบใ€‚TypeScript monorepo๏ผŒๅŒ…ๅซไธ‰ไธชๅŒ…๏ผš - -- **packages/server** โ€” NestJS ๅŽ็ซฏ๏ผˆREST APIใ€Prisma ORMใ€PostgreSQL๏ผ‰ -- **packages/app** โ€” Vue 3 + Pinia ๅ‰็ซฏ๏ผŒๅŸบไบŽ Uni-app๏ผˆ็›ฎๆ ‡ๅนณๅฐ mp-weixin๏ผ‰ -- **packages/shared** โ€” ๅ‰ๅŽ็ซฏๅ…ฑ็”จ็š„ TypeScript ็ฑปๅž‹ใ€ๆžšไธพๅ’Œๅธธ้‡ - -## ๅธธ็”จๅ‘ฝไปค - -### ๅผ€ๅ‘ -```bash -pnpm dev:server # NestJS watch ๆจกๅผ (localhost:3000) -pnpm dev:app # ๅพฎไฟกๅฐ็จ‹ๅบๅผ€ๅ‘ๆœๅŠกๅ™จ -pnpm build:shared # ๅฟ…้กปๅ…ˆๆž„ๅปบ shared๏ผŒๅ†ๆž„ๅปบ server/app -``` - -### ๆต‹่ฏ•๏ผˆไป… server๏ผ‰ -```bash -cd packages/server -pnpm test # ่ฟ่กŒๅ…จ้ƒจๆต‹่ฏ• -pnpm test -- auth.service.spec # ่ฟ่กŒๅ•ไธชๆต‹่ฏ•ๆ–‡ไปถ -pnpm test:watch # watch ๆจกๅผ -pnpm test:cov # ่ฆ†็›–็އๆŠฅๅ‘Š -``` - -Jest ้…็ฝฎๅ†…่”ๅœจ `packages/server/package.json`ใ€‚ๆต‹่ฏ•ๆ–‡ไปถไฝไบŽ `__tests__/` ๅญ็›ฎๅฝ•๏ผˆๅฆ‚ `src/auth/__tests__/auth.service.spec.ts`๏ผ‰๏ผŒๅŒน้…ๆจกๅผ๏ผš`*.spec.ts`ใ€‚ - -### ๆ•ฐๆฎๅบ“ -```bash -cd packages/server -pnpm prisma:generate # schema ๅ˜ๆ›ดๅŽ้‡ๆ–ฐ็”Ÿๆˆ Prisma Client -pnpm prisma:migrate # ่ฟ่กŒ่ฟ็งป๏ผˆไบคไบ’ๅผ๏ผ‰ -pnpm prisma:seed # ๅกซๅ……็งๅญๆ•ฐๆฎ -``` - -### ไปฃ็ ๆฃ€ๆŸฅ -```bash -pnpm lint # ๆ‰€ๆœ‰ๅŒ…็š„ ESLint ๆฃ€ๆŸฅ -``` - -## ๆžถๆž„ - -### ๆ•ฐๆฎๆต -``` -ๅพฎไฟกๅฐ็จ‹ๅบ โ†’ Uni-app (Vue 3) โ†’ REST API (NestJS) โ†’ Prisma โ†’ PostgreSQL - โ†• โ†• - Pinia stores @nestjs/schedule (ๅฎšๆ—ถไปปๅŠก) -``` - -### ๅŽ็ซฏๆจกๅ—็ป“ๆž„ -ๆฏไธชๅŠŸ่ƒฝๆ˜ฏไธ€ไธช NestJS ๆจกๅ—๏ผŒ้ตๅพช controller โ†’ service โ†’ Prisma ๆจกๅผใ€‚ๆ ธๅฟƒๆจกๅ—๏ผš -- **auth** โ€” ๅพฎไฟก OAuth ็™ปๅฝ•๏ผˆcode2Session๏ผ‰ใ€JWT ไปค็‰Œใ€ๆ‰‹ๆœบๅท็ป‘ๅฎš -- **booking** โ€” ๅˆ›ๅปบ/ๅ–ๆถˆ้ข„็บฆ๏ผŒๅซไผšๅ‘˜ๅก้ชŒ่ฏๅ’Œๅฎน้‡ๆฃ€ๆŸฅ -- **time-slot** โ€” ่ฏพ็จ‹ๆ—ถๆฎต็ฎก็†๏ผ›`SlotGeneratorService` ๆ นๆฎ `WeekTemplate` ่‡ชๅŠจ็”Ÿๆˆ -- **membership** โ€” ๅŸบไบŽๅก็š„ไผšๅ‘˜ๅˆถ๏ผˆTIMES ๆฌกๅกใ€DURATION ๆ—ถๆ•ˆๅกใ€TRIAL ไฝ“้ชŒๅก๏ผ‰ -- **payment** โ€” ๅพฎไฟกๆ”ฏไป˜้›†ๆˆ๏ผŒ็”จไบŽ่ดญๅก -- **scheduler** โ€” ๅฎšๆ—ถไปปๅŠก๏ผš02:00 ่‡ชๅŠจ็”Ÿๆˆๆ—ถๆฎต๏ผŒ02:30 ๆธ…็†่ฟ‡ๆœŸๆ—ถๆฎต - -### ๅ‰็ซฏ็ป“ๆž„ -- **pages/** โ€” ๆŒ‰่ทฏ็”ฑ็ป„็ป‡็š„้กต้ข๏ผˆhomeใ€bookingใ€cardใ€profileใ€admin๏ผ‰ -- **stores/** โ€” Pinia ็Šถๆ€็ฎก็†๏ผˆuserใ€bookingใ€studioใ€admin๏ผ‰ -- **utils/request.ts** โ€” ๅฐ่ฃ… `uni.request` ็š„ HTTP ๅฎขๆˆท็ซฏ๏ผŒ่‡ชๅŠจๆบๅธฆ JWT -- **utils/auth.ts** โ€” ๅพฎไฟก็™ปๅฝ•ๆต็จ‹๏ผšuni.login โ†’ ๆœๅŠก็ซฏ /auth/login โ†’ ๅญ˜ๅ‚จ token - -### Shared ๅŒ… -ๆ‰€ๆœ‰ API ็ฑปๅž‹ใ€DTOใ€ๆžšไธพๅ’ŒไธšๅŠกๅธธ้‡ๅฎšไน‰ๅœจ `packages/shared/src/`๏ผŒๅ‰ๅŽ็ซฏ้€š่ฟ‡ `@mp-pilates/shared` ๅผ•็”จใ€‚่ทฏๅพ„ๅˆซๅ้…็ฝฎๅœจ `tsconfig.base.json` ๅ’Œ Jest ็š„ `moduleNameMapper` ไธญใ€‚ - -### ๆ•ฐๆฎๅบ“ Schema -Prisma schema ไฝไบŽ `packages/server/prisma/schema.prisma`๏ผŒๅ…ณ้”ฎ็บฆๅฎš๏ผš -- Model ็”จ PascalCase๏ผŒ่กจๅ็”จ snake_case๏ผˆ`@@map`๏ผ‰ -- ๅญ—ๆฎต็”จ camelCase๏ผŒๅˆ—ๅ็”จ snake_case๏ผˆ`@map`๏ผ‰ -- ๆ‰€ๆœ‰ ID ไธบ UUID -- ้‡‘้ขๅญ—ๆฎตไฝฟ็”จ `Decimal(10, 0)` -- ๅ…ณ้”ฎๅ”ฏไธ€็บฆๆŸ๏ผš`TimeSlot` ็š„ `@@unique([date, startTime, endTime])`๏ผŒ`Booking` ็š„ `@@unique([userId, timeSlotId])` - -### ๆ ธๅฟƒไธšๅŠก่ง„ๅˆ™ -- ้ข„็บฆ้œ€่ฆๆœ‰ๆ•ˆ็š„ไผšๅ‘˜ๅก๏ผˆๅ‰ฉไฝ™ๆฌกๆ•ฐๆˆ–ๆœ‰ๆ•ˆๆœŸๅ†…๏ผ‰ -- ๅ–ๆถˆ้ข„็บฆ้œ€ๅœจ่ฏพ็จ‹ๅผ€ๅง‹ๅ‰ `cancelHoursLimit` ๅฐๆ—ถ๏ผˆ้ป˜่ฎค 2 ๅฐๆ—ถ๏ผŒๅฏๅœจ StudioConfig ไธญ้…็ฝฎ๏ผ‰ -- ๆ—ถๆฎตๆ นๆฎ WeekTemplate ่‡ชๅŠจ็”Ÿๆˆๆœชๆฅ 14 ๅคฉ็š„่ฏพ็จ‹ -- ้ป˜่ฎคๆ—ถๆฎตๅฎน้‡ไธบ 1๏ผˆ็งๆ•™่ฏพ๏ผ‰ - -## ็Žฏๅขƒ้…็ฝฎ - -้œ€่ฆ Node 20+๏ผˆ.nvmrc๏ผ‰ใ€pnpm 8+ใ€PostgreSQLใ€‚ๅคๅˆถ `packages/server/.env.example` ไธบ `.env.local`๏ผŒ้œ€้…็ฝฎ DATABASE_URLใ€JWT_SECRET ๅŠๅพฎไฟก็›ธๅ…ณๅ‡ญ่ฏ๏ผˆAPPIDใ€SECRETใ€MCH_IDใ€MCH_KEYใ€่ฏไนฆ่ทฏๅพ„๏ผ‰ใ€‚ - -## ๅผ€ๅ‘็บฆๅฎš - -- **API ๅ‰็ผ€**๏ผšๆ‰€ๆœ‰่ทฏ็”ฑๅœจ `/api` ไธ‹๏ผˆsetGlobalPrefix๏ผ‰ -- **ๅ‚ๆ•ฐๆ ก้ชŒ**๏ผšๅ…จๅฑ€ ValidationPipe๏ผŒๅฏ็”จ whitelist + forbidNonWhitelisted + transform -- **้‰ดๆƒๅฎˆๅซ**๏ผšๅ—ไฟๆŠค่ทฏ็”ฑไฝฟ็”จ `@UseGuards(JwtAuthGuard)`๏ผŒ้€š่ฟ‡ `@Req()` ไปŽ JWT ่ฝฝ่ทๆๅ–็”จๆˆท -- **่ง’่‰ฒ**๏ผšMEMBER ๅ’Œ ADMIN๏ผ›็ฎก็†ๅ‘˜่ทฏ็”ฑไฝฟ็”จ่‡ชๅฎšไน‰่ง’่‰ฒๅฎˆๅซ -- **ๅผ‚ๅธธๅค„็†**๏ผšไฝฟ็”จ NestJS ๅ†…็ฝฎๅผ‚ๅธธ๏ผˆBadRequestExceptionใ€NotFoundException ็ญ‰๏ผ‰ -- **ๅˆ†้กต**๏ผš็ปŸไธ€ไฝฟ็”จ `PaginatedResponse`๏ผŒๅŒ…ๅซ dataใ€totalใ€pageใ€limit -- **pnpm**๏ผšไฝฟ็”จ `shamefully-hoist=true`๏ผˆ.npmrc๏ผ‰๏ผŒไธบ Uni-app ๅ…ผๅฎนๆ‰€้œ€ - -## ๅ‰็ซฏๆ ทๅผ่ง„่Œƒ - -### ไธป้ข˜่‰ฒๅ˜้‡๏ผˆๅฟ…็”จ๏ผ‰ - -ๆ‰€ๆœ‰่‰ฒๅ€ผๅฟ…้กปไฝฟ็”จ `packages/app/src/uni.scss` ไธญๅฎšไน‰็š„ SCSS ๅ˜้‡๏ผŒ็ฆๆญขๅœจ Vue/Scss ๆ–‡ไปถไธญ็กฌ็ผ–็ ่‰ฒๅ€ผใ€‚ - -**ไธป้ข˜่‰ฒ็ณป๏ผš** - -```scss -$primary-color: #a9bfcc; /* ไธป่‰ฒ-ๆŸ”้›พ่“็ฐ */ -$primary-dark: #7ba5be; /* ไธป่‰ฒ-ๆทฑ่“็ฐ */ -$primary-light: #c8d8e4; /* ไธป่‰ฒ-ๆต…่“็ฐ */ -$primary-bg: #f0f6f9; /* ้กต้ข่ƒŒๆ™ฏ-ๅ†ท็™ฝ่“ */ -$primary-border: #d8eaf4; /* ่พนๆก†-ๆทก่“็ฐ */ -$primary-selected-bg: #EFF6F9; /* ้€‰ไธญๆ€่ƒŒๆ™ฏ */ -``` - -**้€š็”จ่ฏญไน‰ๅ˜้‡๏ผˆๅทฒๅŒๆญฅไธป้ข˜่‰ฒ๏ผ‰๏ผš** - -| ๅ˜้‡ | ๅ€ผ | ็”จ้€” | -|------|----|------| -| `$accent-color` | `#7ba5be` | ๅผบ่ฐƒ่‰ฒ | -| `$warning-color` | `#e8a87c` | ่ญฆๅ‘Š่‰ฒ | -| `$brand-light` | `#c8d8e4` | ๅ“็‰Œๆต…่‰ฒ | -| `$border-color` | `rgba(180,160,130,0.2)` | ่พนๆก†๏ผˆไธญๆ€ง๏ผ‰ | -| `$text-primary` | `#4A4035` | ไธปๆ–‡ๅญ—๏ผˆๆทฑๆฃ•็ฐ๏ผ‰ | -| `$text-secondary` | `#7A6A5A` | ๆฌกๆ–‡ๅญ— | -| `$text-hint` | `#A09080` | ๅผฑๆ็คบๆ–‡ๅญ— | - -### ๅ˜้‡ๆ›ฟๆข่ง„ๅˆ™ - -| ๆ—ง็กฌ็ผ–็  | ๆ›ฟๆขไธบ | -|---------|--------| -| `#c9a87c`๏ผˆๆ—งๆš–ๆฃ•้‡‘๏ผ‰ | `$primary-dark` | -| `#d4b896`๏ผˆๆ—งๆต…ๆฃ•้‡‘๏ผ‰ | `$primary-color` | -| `#C4956A`๏ผˆๆ—ง่ญฆๅ‘Šๆฉ™ๆฃ•๏ผ‰ | `$warning-color` | -| `#B08050`๏ผˆๆ—งๆทฑๆฃ•๏ผ‰ | `$accent-color` | -| `#7d6608`๏ผˆๆ—งๆทฑๆš–็ปฟ๏ผ‰ | `#5a7a8a`๏ผˆๅ†ท้’็ฐ๏ผ‰ | -| `#e8c88a`ใ€`#b49868`๏ผˆๆ—งๆš–ๆธๅ˜๏ผ‰ | `$primary-color` / `$primary-dark` | - -### CSS ๅ˜้‡่ง„่Œƒ - -็ป„ไปถๅ†…้ƒจ็š„ๅคšๅค„ๅ…ฑ็”จ้ขœ่‰ฒ๏ผˆๅฆ‚้˜ดๅฝฑใ€้ฎ็ฝฉ๏ผ‰่‹ฅๆ— ๆณ•็”จ SCSS ๅ˜้‡๏ผŒ้œ€็”จ `rgba($primary-dark, 0.x)` ๅฝขๅผๅŠจๆ€ๆž„้€ ๏ผŒไธๅฏ็›ดๆŽฅๅ†™ๆญปๅๅ…ญ่ฟ›ๅˆถๅ€ผใ€‚ - -### ๆ–ฐๅขž้กต้ข/็ป„ไปถ - -ๆ–ฐๅขž้กต้ขๆˆ–็ป„ไปถๆ—ถ๏ผš -1. ไผ˜ๅ…ˆๆŸฅ้˜… `uni.scss` ๅทฒๆœ‰ๅ˜้‡ -2. ่‹ฅ้œ€่ฆๆ–ฐๅขž่ฏญไน‰ๅŒ–ๅ˜้‡๏ผŒๅ…ˆๆ›ดๆ–ฐ `uni.scss`๏ผŒๅ†ๅœจ็ป„ไปถไธญๅผ•็”จ -3. ็ฆๆญขๅœจ ` diff --git a/packages/app/src/components/StudioInfo.vue b/packages/app/src/components/StudioInfo.vue index 7b1111f..6528483 100644 --- a/packages/app/src/components/StudioInfo.vue +++ b/packages/app/src/components/StudioInfo.vue @@ -8,17 +8,17 @@ - + - ๐Ÿ“ + {{ studioInfo?.address || 'ๆทฑๅœณๅธ‚ๅฎๅฎ‰ๅŒบ่ฅฟไนก่ก—้“่ดขๅฏŒๆธฏ D ๅบง 1203D' }} - - ๐Ÿ“ž - + @@ -39,23 +39,20 @@ function previewPhoto(index: number) { } function handleAddressTap() { - if (!props.studioInfo) return + const latitude = props.studioInfo?.latitude ?? 22.567048 + const longitude = props.studioInfo?.longitude ?? 113.867227 + const address = props.studioInfo?.address || 'ๆทฑๅœณๅธ‚ๅฎๅฎ‰ๅŒบ่ฅฟไนก่ก—้“่ดขๅฏŒๆธฏ D ๅบง 1203D' + const name = props.studioInfo?.name || 'Focus Core' - const { latitude, longitude, address, name } = props.studioInfo - - if (latitude && longitude) { - uni.openLocation({ - latitude, - longitude, - name: name || 'Focus Core', - address, - fail() { - copyAddress() - }, - }) - } else { - copyAddress() - } + uni.openLocation({ + latitude, + longitude, + name, + address, + fail() { + copyAddress() + }, + }) } function copyAddress() { @@ -68,17 +65,6 @@ function copyAddress() { }, }) } - -function handlePhoneTap() { - const phone = props.studioInfo?.phone - if (!phone) return - uni.makePhoneCall({ - phoneNumber: phone, - fail() { - uni.showToast({ title: 'ๆ‹จๅทๅคฑ่ดฅ', icon: 'none' }) - }, - }) -} diff --git a/packages/app/src/pages/card/detail.vue b/packages/app/src/pages/card/detail.vue index caccfa1..ebf5522 100644 --- a/packages/app/src/pages/card/detail.vue +++ b/packages/app/src/pages/card/detail.vue @@ -37,25 +37,54 @@ class="card-row" @tap="goToDetail(c.id)" > - - - {{ truncate(c.name, 8) }} - ยฅ{{ formatPrice(c.price) }} - - - - {{ c.name }} - ๆœ‰ๆ•ˆๆœŸ:{{ c.durationDays }} ๅคฉ - - ยฅ{{ formatPrice(c.price) }} + + + + + + + + + {{ getCardTypeLabel(c.type) }} + + {{ c.name }} + + ยฅ + {{ formatPrice(c.price) }} + - ๅŽŸไปท:ยฅ{{ formatPrice(c.originalPrice) }} + ยฅ{{ formatPrice(c.originalPrice) }} + + + + + {{ c.name }} + ๆœ‰ๆ•ˆๆœŸ {{ c.durationDays }} ๅคฉ + + + + {{ c.totalTimes }} + ่ฏพๆ—ถ + + + ยฅ{{ formatPrice(c.price) }} + + ยฅ{{ formatPrice(c.originalPrice) }} + + + + + + โ€บ @@ -172,7 +201,7 @@ import { ref, computed, onMounted } from 'vue' import type { CardType, CreateOrderResponse } from '@mp-pilates/shared' import { CardTypeCategory } from '@mp-pilates/shared' import { get, post } from '../../utils/request' -import { formatPrice } from '../../utils/format' +import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format' import { getSystemLayout } from '../../utils/system' import { useUserStore } from '../../stores/user' import CustomNavBar from '../../components/CustomNavBar.vue' @@ -257,16 +286,6 @@ function goToDetail(id: string) { uni.navigateTo({ url: `/pages/card/detail?id=${id}` }) } -function thumbClass(card: CardType): string { - if (card.type === CardTypeCategory.TRIAL) return 'thumb--trial' - if (card.type === CardTypeCategory.DURATION) return 'thumb--duration' - return 'thumb--times' -} - -function truncate(str: string, maxLen: number): string { - return str.length > maxLen ? str.slice(0, maxLen) + 'โ€ฆ' : str -} - // โ”€โ”€โ”€ Buy flow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function handleBuy() { if (buying.value || !card.value) return @@ -710,101 +729,335 @@ onMounted(() => { .card-row { display: flex; align-items: center; - gap: 24rpx; - padding: 24rpx; + gap: 20rpx; + padding: 20rpx; background: #fff; border-radius: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05); } -.card-thumb { - width: 200rpx; - height: 140rpx; - border-radius: 12rpx; +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + CARD COVER โ€” Horizontal premium card design + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ +.card-cover { + width: 240rpx; + height: 130rpx; + border-radius: 16rpx; overflow: hidden; flex-shrink: 0; position: relative; -} - -.thumb-fallback { - width: 100%; - height: 100%; display: flex; - flex-direction: column; + flex-direction: row; align-items: center; justify-content: center; - gap: 8rpx; - padding: 12rpx; + + &::before { + content: ''; + position: absolute; + top: -20rpx; + left: -20rpx; + right: -20rpx; + bottom: -20rpx; + background: inherit; + filter: blur(24rpx) brightness(0.8); + z-index: 0; + opacity: 0.4; + } } -.thumb--times .thumb-fallback { - background: linear-gradient(135deg, #3a3a3a, #555); +.cover-accent-bar { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 6rpx; + background: rgba(255, 255, 255, 0.4); + z-index: 1; } -.thumb--duration .thumb-fallback { - background: linear-gradient(135deg, #6c3483, #9b59b6); +.cover-deco { + position: absolute; + border-radius: 50%; + z-index: 0; + pointer-events: none; + + &--tl { + width: 60rpx; + height: 60rpx; + top: -16rpx; + right: 20rpx; + background: rgba(255, 255, 255, 0.1); + } + + &--br { + width: 80rpx; + height: 80rpx; + bottom: -24rpx; + left: -16rpx; + background: rgba(255, 255, 255, 0.07); + } } -.thumb--trial .thumb-fallback { - background: linear-gradient(135deg, #5a7a8a, $primary-dark); +.cover-icon { + width: 52rpx; + height: 52rpx; + position: relative; + z-index: 2; + flex-shrink: 0; + margin-left: 20rpx; } -.thumb-name { - font-size: 22rpx; +.cover-icon--TIMES { + &::before { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 36rpx; + height: 24rpx; + border: 2rpx solid rgba(255, 255, 255, 0.85); + border-radius: 5rpx; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.12); + } + &::after { + content: ''; + position: absolute; + bottom: 10rpx; + left: 50%; + transform: translateX(-50%); + width: 36rpx; + height: 24rpx; + border: 2rpx solid rgba(255, 255, 255, 1); + border-radius: 5rpx; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.2); + } +} + +.cover-icon--DURATION { + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 36rpx; + height: 30rpx; + border: 2rpx solid rgba(255, 255, 255, 0.9); + border-radius: 5rpx; + box-sizing: border-box; + } + &::after { + content: ''; + position: absolute; + top: 9rpx; + left: 50%; + transform: translateX(-50%); + width: 24rpx; + height: 0; + border-top: 2rpx solid rgba(255, 255, 255, 1); + box-shadow: + -6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9), + 6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9); + } +} + +.cover-icon--TRIAL { + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 16rpx; + height: 16rpx; + border: 2rpx solid rgba(255, 255, 255, 1); + border-radius: 50%; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.25); + } + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 2rpx; + height: 42rpx; + background: rgba(255, 255, 255, 0.8); + box-shadow: + 0 -12rpx 0 0 rgba(255, 255, 255, 0.8), + 0 12rpx 0 0 rgba(255, 255, 255, 0.8), + -12rpx 0 0 0 rgba(255, 255, 255, 0.8), + 12rpx 0 0 0 rgba(255, 255, 255, 0.8), + -8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8), + 8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8), + -8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8), + 8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8); + } +} + +.cover--times { + background: linear-gradient(135deg, #1e2340 0%, #2d2d5e 50%, #3a3a7a 100%); +} + +.cover--duration { + background: linear-gradient(135deg, #4a1a6b 0%, #6c3483 50%, #8e4aaf 100%); +} + +.cover--trial { + background: linear-gradient(135deg, #14527a 0%, #1a6fa0 50%, #48a9a6 100%); +} + +.cover-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + padding: 0 16rpx 0 12rpx; + gap: 4rpx; + z-index: 2; +} + +.cover-badge { + padding: 3rpx 10rpx; + border-radius: 8rpx; + background: rgba(255, 255, 255, 0.18); + border: 1rpx solid rgba(255, 255, 255, 0.28); +} + +.cover-badge-text { + font-size: 16rpx; + color: rgba(255, 255, 255, 0.9); font-weight: 600; - color: #ffffff; - text-align: center; - line-height: 1.3; - word-break: break-all; } -.thumb-price { +.cover-name { font-size: 24rpx; font-weight: 700; color: #ffffff; -} - -.card-info { - flex: 1; - min-width: 0; -} - -.card-name { - display: block; - font-size: 30rpx; - font-weight: 600; - color: #222; - margin-bottom: 8rpx; + letter-spacing: 0.5rpx; + line-height: 1.2; + max-width: 130rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cover-price-row { + display: flex; + align-items: baseline; + gap: 2rpx; +} + +.cover-currency { + font-size: 18rpx; + font-weight: 600; + color: rgba(255, 255, 255, 0.85); +} + +.cover-price { + font-size: 28rpx; + font-weight: 800; + color: #ffffff; + line-height: 1; +} + +.cover-original { + font-size: 16rpx; + color: rgba(255, 255, 255, 0.5); + text-decoration: line-through; +} + +/* โ”€โ”€ Card info โ€” matches card-cover height โ”€โ”€ */ +.card-info { + flex: 1; + min-width: 0; + height: 130rpx; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.info-top { + display: flex; + flex-direction: column; + justify-content: center; + gap: 4rpx; +} + +.info-bottom { + display: flex; + align-items: baseline; + gap: 16rpx; +} + +.card-name { + font-size: 30rpx; + font-weight: 700; + color: $text-primary; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: 0.5rpx; + line-height: 1.2; +} + .card-validity { - display: block; - font-size: 24rpx; - color: #999; - margin-bottom: 12rpx; + font-size: 23rpx; + color: $text-secondary; + line-height: 1.2; +} + +.card-times { + display: flex; + align-items: baseline; + gap: 4rpx; +} + +.card-times-value { + font-size: 34rpx; + font-weight: 800; + color: $brand-color; + line-height: 1; +} + +.card-times-unit { + font-size: 20rpx; + color: $text-secondary; + font-weight: 500; } .price-row { display: flex; align-items: baseline; - gap: 8rpx; + gap: 6rpx; } .price-current { - font-size: 40rpx; + font-size: 32rpx; font-weight: 800; - color: #e53935; + color: $brand-color; + line-height: 1; } .price-original { - font-size: 22rpx; - color: #bbb; + font-size: 20rpx; + color: $text-hint; text-decoration: line-through; } +.card-arrow { + font-size: 44rpx; + color: $text-hint; + flex-shrink: 0; + transform: scaleX(0.5); + transform-origin: center; +} + /* โ”€โ”€ Empty state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .empty-state { padding: 160rpx 40rpx; diff --git a/packages/app/src/pages/home/index.vue b/packages/app/src/pages/home/index.vue index 5109d02..c82b146 100644 --- a/packages/app/src/pages/home/index.vue +++ b/packages/app/src/pages/home/index.vue @@ -1,12 +1,11 @@