Files
mp-pilates/BOOKING_ARCHITECTURE_DIAGRAM.md
richarjiang b6986ba30c feat(admin): implement full day-by-day schedule editor with live preview
## Features

### Admin Schedule Page (`packages/app/src/pages/admin/schedule.vue`)
- Interactive date-based slot editor for managing daily schedules
- Real-time slot editing: start/end times, capacity adjustments
- Slot deletion with conflict warnings when bookings exist
- Add new slots with modal dialog
- Live booking status display (booked count, people names)
- Publish/Save changes with sync feedback
- Revert unsaved changes with confirmation
- Skeleton loading states and empty state handling
- Responsive design with optimized mobile UX

### Backend Enhancements
- **New DTO** (`PublishDaySlotsDto`): Structured slot publishing with validation
  - Date string validation
  - Slot array with existing slot IDs for updates
  - Time and capacity validation per slot

- **Schedule Preview API** (`getSchedulePreview`):
  - Check for existing published slots
  - Fallback to active WeekTemplates for unpublished dates
  - Unified response format with isPublished flag

- **Publish Slots API** (`publishDaySlots`):
  - Atomic transaction for consistency
  - Update existing slots with new times/capacity
  - Create new slots from template data
  - Delete unpublished slots or set to CLOSED if bookings exist
  - Prevent capacity reduction below existing bookings
  - Returns all published slots for feedback

### State Management
- Enhanced admin store with schedule state
- Support for pending/unsaved slot changes
- Optimistic UI updates with server sync

### Documentation
- Comprehensive scheduling system architecture docs
- Quick reference for admin workflows
- Flow diagrams and state transitions
- Implementation guide for future maintenance

## Breaking Changes
None

## Testing Recommendations
- Create slots for future dates via schedule editor
- Verify booking prevention for locked/full slots
- Test capacity adjustments with existing bookings
- Confirm template-based schedule generation
- Verify transaction rollback on publish failures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:18:49 +08:00

33 KiB

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<T>(options): Promise<T>                            │ │  │
│  │  │  • get<T>(url, data?): Promise<T>                             │ │  │
│  │  │  • post<T>(url, data?): Promise<T>                            │ │  │
│  │  │  • put<T>(url, data?): Promise<T>                             │ │  │
│  │  │                                                                 │ │  │
│  │  │ 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<TimeSlotWithBookingStatus[]>   │
│     { success: true, data: [...], message: null }      │
│                                                           │
│ POST /booking                                            │
│   ↓ Body: { timeSlotId, membershipId }                  │
│   ↓ Returns ApiResponse<BookingWithDetails>             │
│     { success: true, data: {...}, message: null }       │
│                                                           │
│ PUT /booking/:id/cancel                                 │
│   ↓ Returns ApiResponse<BookingWithDetails>             │
│     { 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 <token>'
  }
})
     ↓
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