diff --git a/Dockerfile b/Dockerfile
index 4854636..1cd5bc4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,7 +14,8 @@ RUN apt-get update && apt-get install -y \
libzip-dev \
default-mysql-client \
netcat-openbsd \
- grep
+ grep \
+ cron
# 清理 apt 快取以減小鏡像大小
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
@@ -48,6 +49,12 @@ COPY docker/php/local.ini /usr/local/etc/php/conf.d/local.ini
COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+# 加入 Laravel Scheduler cron job
+RUN echo "* * * * * www-data php /var/www/artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1" \
+ > /etc/cron.d/laravel-scheduler \
+ && chmod 0644 /etc/cron.d/laravel-scheduler \
+ && crontab /etc/cron.d/laravel-scheduler
+
# 設置容器啟動時執行的入口點
# 這將在 CMD 指令之前執行
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
diff --git a/app/Console/Commands/CompleteFinishedBookings.php b/app/Console/Commands/CompleteFinishedBookings.php
new file mode 100644
index 0000000..3ae414b
--- /dev/null
+++ b/app/Console/Commands/CompleteFinishedBookings.php
@@ -0,0 +1,24 @@
+value)
+ ->whereHas('schedule', fn($q) => $q->whereDate('scheduled_date', '<', now()->toDateString()))
+ ->update(['status' => BookingStatus::Completed->value]);
+
+ Log::info("CompleteFinishedBookings: {$count} completed");
+ $this->info("CompleteFinishedBookings: {$count} bookings completed.");
+ }
+}
diff --git a/app/Console/Commands/ExpirePendingBookings.php b/app/Console/Commands/ExpirePendingBookings.php
new file mode 100644
index 0000000..649a564
--- /dev/null
+++ b/app/Console/Commands/ExpirePendingBookings.php
@@ -0,0 +1,24 @@
+value)
+ ->where('created_at', '<=', now()->subHours(48))
+ ->update(['status' => BookingStatus::Expired->value]);
+
+ Log::info("ExpirePendingBookings: {$count} expired");
+ $this->info("ExpirePendingBookings: {$count} bookings expired.");
+ }
+}
diff --git a/app/Enums/BookingStatus.php b/app/Enums/BookingStatus.php
new file mode 100644
index 0000000..5bf4ebd
--- /dev/null
+++ b/app/Enums/BookingStatus.php
@@ -0,0 +1,14 @@
+where('member_id', $request->user()->id)
+ ->orderByDesc('created_at')
+ ->get()
+ ->map(fn($b) => $this->formatBooking($b));
+
+ return response()->json(['status' => true, 'data' => $bookings]);
+ }
+
+ public function show(Request $request, int $id)
+ {
+ $booking = Booking::with(['schedule.divingOffer'])->findOrFail($id);
+ if ($booking->member_id !== $request->user()->id) {
+ return response()->json(['status' => false, 'message' => '無權查看此預約'], 403);
+ }
+
+ return response()->json(['status' => true, 'data' => $this->formatBooking($booking)]);
+ }
+
+ public function store(Request $request)
+ {
+ $data = $request->validate([
+ 'schedule_id' => 'required|integer|exists:course_schedules,id',
+ 'participants' => 'required|integer|min:1',
+ 'notes' => 'nullable|string|max:500',
+ ]);
+
+ $schedule = CourseSchedule::with('divingOffer')->findOrFail($data['schedule_id']);
+
+ // Layer 1:快速失敗
+ if ($schedule->status !== ScheduleStatus::Open) {
+ return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422);
+ }
+ if ($data['participants'] > $schedule->remainingSpots()) {
+ return response()->json(['status' => false, 'message' => '人數超過剩餘名額'], 422);
+ }
+
+ $memberId = $request->user()->id;
+
+ try {
+ $booking = DB::transaction(function () use ($data, $schedule, $memberId) {
+ // Layer 2:lockForUpdate 後二次驗證
+ $schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
+ if ($data['participants'] > $schedule->remainingSpots()) {
+ throw new \RuntimeException('名額不足,請重新選擇');
+ }
+
+ // 重複預約檢查
+ $duplicate = Booking::where('member_id', $memberId)
+ ->where('schedule_id', $schedule->id)
+ ->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
+ ->exists();
+ if ($duplicate) {
+ throw new \RuntimeException('您已預約此時段');
+ }
+
+ return Booking::create([
+ 'schedule_id' => $schedule->id,
+ 'member_id' => $memberId,
+ 'participants' => $data['participants'],
+ 'total_price' => $schedule->divingOffer->price * $data['participants'],
+ 'status' => BookingStatus::Pending,
+ 'notes' => $data['notes'] ?? null,
+ ]);
+ });
+ } catch (\RuntimeException $e) {
+ return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
+ }
+
+ return response()->json([
+ 'status' => true,
+ 'message' => '預約已送出,等待教練確認',
+ 'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])),
+ ], 201);
+ }
+
+ public function destroy(Request $request, int $id)
+ {
+ $booking = Booking::with('schedule')->findOrFail($id);
+ if ($booking->member_id !== $request->user()->id) {
+ return response()->json(['status' => false, 'message' => '無權操作此預約'], 403);
+ }
+
+ $canCancelFrom = [BookingStatus::Pending, BookingStatus::Confirmed];
+ if (!in_array($booking->status, $canCancelFrom)) {
+ return response()->json(['status' => false, 'message' => '此預約狀態無法取消'], 422);
+ }
+
+ // 24h 截止驗證
+ $schedule = $booking->schedule;
+ $courseStart = Carbon::parse($schedule->scheduled_date->toDateString() . ' ' . $schedule->start_time);
+ if (now()->diffInHours($courseStart, false) < 24) {
+ return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消,請聯繫教練'], 422);
+ }
+
+ DB::transaction(function () use ($booking, $schedule) {
+ $wasConfirmed = $booking->status === BookingStatus::Confirmed;
+ $booking->update(['status' => BookingStatus::MemberCancelled]);
+
+ if ($wasConfirmed) {
+ $schedule = $booking->schedule()->lockForUpdate()->first();
+ $schedule->decrement('current_participants', $booking->participants);
+ $schedule->refresh();
+
+ if ($schedule->current_participants < $schedule->max_participants
+ && $schedule->status === ScheduleStatus::Full) {
+ $schedule->update(['status' => ScheduleStatus::Open]);
+ }
+ }
+ });
+
+ return response()->json(['status' => true, 'message' => '預約已取消']);
+ }
+
+ private function formatBooking(Booking $b): array
+ {
+ $offer = $b->schedule?->divingOffer;
+ return [
+ 'id' => $b->id,
+ 'offer_id' => $offer?->id,
+ 'offer_title' => $offer?->title,
+ 'offer_location' => $offer?->location,
+ 'offer_region' => $offer?->region,
+ 'offer_price' => $offer?->price,
+ 'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
+ 'start_time' => $b->schedule?->start_time,
+ 'participants' => $b->participants,
+ 'total_price' => $b->total_price,
+ 'status' => $b->status->value,
+ 'notes' => $b->notes,
+ 'created_at' => $b->created_at?->toISOString(),
+ ];
+ }
+}
diff --git a/app/Http/Controllers/API/ProviderBookingController.php b/app/Http/Controllers/API/ProviderBookingController.php
new file mode 100644
index 0000000..1adef81
--- /dev/null
+++ b/app/Http/Controllers/API/ProviderBookingController.php
@@ -0,0 +1,120 @@
+user();
+ $bookings = Booking::with(['member', 'schedule.divingOffer'])
+ ->whereHas('schedule', fn($q) => $q->where('provider_id', $provider->id))
+ ->orderByDesc('created_at')
+ ->get()
+ ->map(fn($b) => $this->formatBooking($b));
+
+ return response()->json(['status' => true, 'data' => $bookings]);
+ }
+
+ public function confirm(Request $request, int $id)
+ {
+ $booking = Booking::with('schedule')->findOrFail($id);
+ $this->authorizeProvider($request, $booking);
+
+ if (!$booking->canTransitionTo(BookingStatus::Confirmed)) {
+ return response()->json(['status' => false, 'message' => '當前狀態無法確認'], 422);
+ }
+
+ try {
+ DB::transaction(function () use ($booking) {
+ $schedule = $booking->schedule()->lockForUpdate()->first();
+ $remaining = $schedule->max_participants - $schedule->current_participants;
+
+ if ($booking->participants > $remaining) {
+ throw new \RuntimeException('名額不足,無法確認此預約');
+ }
+
+ $booking->update(['status' => BookingStatus::Confirmed]);
+ $schedule->increment('current_participants', $booking->participants);
+ $schedule->refresh();
+
+ if ($schedule->current_participants >= $schedule->max_participants) {
+ $schedule->update(['status' => ScheduleStatus::Full]);
+ }
+ });
+ } catch (\RuntimeException $e) {
+ return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
+ }
+
+ return response()->json(['status' => true, 'message' => '預約已確認', 'data' => $this->formatBooking($booking->fresh(['member', 'schedule.divingOffer']))]);
+ }
+
+ public function reject(Request $request, int $id)
+ {
+ $booking = Booking::with('schedule')->findOrFail($id);
+ $this->authorizeProvider($request, $booking);
+
+ if (!$booking->canTransitionTo(BookingStatus::Rejected)) {
+ return response()->json(['status' => false, 'message' => '當前狀態無法拒絕'], 422);
+ }
+
+ $booking->update(['status' => BookingStatus::Rejected]);
+
+ return response()->json(['status' => true, 'message' => '預約已拒絕']);
+ }
+
+ public function cancel(Request $request, int $id)
+ {
+ $booking = Booking::with('schedule')->findOrFail($id);
+ $this->authorizeProvider($request, $booking);
+
+ if (!$booking->canTransitionTo(BookingStatus::ProviderCancelled)) {
+ return response()->json(['status' => false, 'message' => '當前狀態無法取消'], 422);
+ }
+
+ DB::transaction(function () use ($booking) {
+ $schedule = $booking->schedule()->lockForUpdate()->first();
+ $booking->update(['status' => BookingStatus::ProviderCancelled]);
+ $schedule->decrement('current_participants', $booking->participants);
+ $schedule->refresh();
+
+ if ($schedule->current_participants < $schedule->max_participants
+ && $schedule->status === ScheduleStatus::Full) {
+ $schedule->update(['status' => ScheduleStatus::Open]);
+ }
+ });
+
+ return response()->json(['status' => true, 'message' => '預約已取消']);
+ }
+
+ private function authorizeProvider(Request $request, Booking $booking): void
+ {
+ if ($booking->schedule->provider_id !== $request->user()->id) {
+ abort(403, '無權操作此預約');
+ }
+ }
+
+ private function formatBooking(Booking $b): array
+ {
+ return [
+ 'id' => $b->id,
+ 'member_name' => $b->member?->name,
+ 'member_email' => $b->member?->email,
+ 'offer_title' => $b->schedule?->divingOffer?->title,
+ 'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
+ 'start_time' => $b->schedule?->start_time,
+ 'participants' => $b->participants,
+ 'total_price' => $b->total_price,
+ 'status' => $b->status->value,
+ 'notes' => $b->notes,
+ 'created_at' => $b->created_at?->toISOString(),
+ ];
+ }
+}
diff --git a/app/Http/Controllers/API/ScheduleController.php b/app/Http/Controllers/API/ScheduleController.php
new file mode 100644
index 0000000..c6142ea
--- /dev/null
+++ b/app/Http/Controllers/API/ScheduleController.php
@@ -0,0 +1,131 @@
+user();
+ $schedules = CourseSchedule::with('divingOffer')
+ ->where('provider_id', $provider->id)
+ ->orderBy('scheduled_date')
+ ->orderBy('start_time')
+ ->get()
+ ->map(fn($s) => $this->formatSchedule($s));
+
+ return response()->json(['status' => true, 'data' => $schedules]);
+ }
+
+ public function store(Request $request)
+ {
+ $data = $request->validate([
+ 'diving_offer_id' => 'required|integer|exists:diving_offers,id',
+ 'scheduled_date' => 'required|date|after_or_equal:today',
+ 'start_time' => 'required|date_format:H:i',
+ 'max_participants' => 'required|integer|min:1',
+ ]);
+
+ $offer = DivingOffer::findOrFail($data['diving_offer_id']);
+ if ($offer->provider_id !== $request->user()->id) {
+ return response()->json(['status' => false, 'message' => '無權操作此課程'], 403);
+ }
+
+ $schedule = CourseSchedule::create([
+ 'diving_offer_id' => $data['diving_offer_id'],
+ 'provider_id' => $request->user()->id,
+ 'scheduled_date' => $data['scheduled_date'],
+ 'start_time' => $data['start_time'],
+ 'max_participants' => $data['max_participants'],
+ 'current_participants'=> 0,
+ 'status' => ScheduleStatus::Open,
+ ]);
+
+ return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule)], 201);
+ }
+
+ public function update(Request $request, int $id)
+ {
+ $schedule = CourseSchedule::findOrFail($id);
+ if ($schedule->provider_id !== $request->user()->id) {
+ return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
+ }
+
+ $data = $request->validate([
+ 'start_time' => 'sometimes|date_format:H:i',
+ 'max_participants' => 'sometimes|integer|min:1',
+ ]);
+
+ if (isset($data['max_participants']) && $data['max_participants'] < $schedule->current_participants) {
+ return response()->json([
+ 'status' => false,
+ 'message' => '人數上限不可低於目前已確認人數(' . $schedule->current_participants . ')',
+ ], 422);
+ }
+
+ $schedule->update($data);
+
+ return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule->fresh())]);
+ }
+
+ public function destroy(Request $request, int $id)
+ {
+ $schedule = CourseSchedule::findOrFail($id);
+ if ($schedule->provider_id !== $request->user()->id) {
+ return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
+ }
+
+ DB::transaction(function () use ($schedule) {
+ $schedule->update(['status' => ScheduleStatus::Cancelled]);
+ $schedule->bookings()
+ ->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
+ ->update(['status' => BookingStatus::ProviderCancelled->value]);
+ });
+
+ return response()->json(['status' => true, 'message' => '時段已取消']);
+ }
+
+ public function publicList(int $offerId)
+ {
+ $offer = DivingOffer::findOrFail($offerId);
+ $schedules = CourseSchedule::where('diving_offer_id', $offer->id)
+ ->where('status', ScheduleStatus::Open->value)
+ ->whereDate('scheduled_date', '>=', now()->toDateString())
+ ->orderBy('scheduled_date')
+ ->orderBy('start_time')
+ ->get()
+ ->map(fn($s) => [
+ 'id' => $s->id,
+ 'scheduled_date' => $s->scheduled_date->toDateString(),
+ 'start_time' => $s->start_time,
+ 'max_participants' => $s->max_participants,
+ 'remaining_spots' => $s->remainingSpots(),
+ 'status' => $s->status->value,
+ ]);
+
+ return response()->json(['status' => true, 'data' => $schedules]);
+ }
+
+ private function formatSchedule(CourseSchedule $s): array
+ {
+ return [
+ 'id' => $s->id,
+ 'diving_offer_id' => $s->diving_offer_id,
+ 'offer_title' => $s->divingOffer?->title,
+ 'scheduled_date' => $s->scheduled_date?->toDateString(),
+ 'start_time' => $s->start_time,
+ 'max_participants' => $s->max_participants,
+ 'current_participants' => $s->current_participants,
+ 'remaining_spots' => $s->remainingSpots(),
+ 'status' => $s->status->value,
+ ];
+ }
+}
diff --git a/app/Models/Booking.php b/app/Models/Booking.php
new file mode 100644
index 0000000..f6644ed
--- /dev/null
+++ b/app/Models/Booking.php
@@ -0,0 +1,51 @@
+ 'integer',
+ 'total_price' => 'integer',
+ 'status' => BookingStatus::class,
+ ];
+
+ const VALID_TRANSITIONS = [
+ 'pending' => ['confirmed', 'rejected', 'expired', 'member_cancelled'],
+ 'confirmed' => ['completed', 'member_cancelled', 'provider_cancelled'],
+ 'completed' => [],
+ 'rejected' => [],
+ 'expired' => [],
+ 'member_cancelled' => [],
+ 'provider_cancelled' => [],
+ ];
+
+ public function canTransitionTo(BookingStatus $newStatus): bool
+ {
+ $current = $this->status->value;
+ $allowed = self::VALID_TRANSITIONS[$current] ?? [];
+ return in_array($newStatus->value, $allowed);
+ }
+
+ public function schedule()
+ {
+ return $this->belongsTo(CourseSchedule::class, 'schedule_id');
+ }
+
+ public function member()
+ {
+ return $this->belongsTo(User::class, 'member_id');
+ }
+}
diff --git a/app/Models/CourseSchedule.php b/app/Models/CourseSchedule.php
new file mode 100644
index 0000000..2d39181
--- /dev/null
+++ b/app/Models/CourseSchedule.php
@@ -0,0 +1,54 @@
+ 'date',
+ 'max_participants' => 'integer',
+ 'current_participants' => 'integer',
+ 'status' => ScheduleStatus::class,
+ ];
+
+ public function divingOffer()
+ {
+ return $this->belongsTo(DivingOffer::class);
+ }
+
+ public function provider()
+ {
+ return $this->belongsTo(User::class, 'provider_id');
+ }
+
+ public function bookings()
+ {
+ return $this->hasMany(Booking::class, 'schedule_id');
+ }
+
+ public function remainingSpots(): int
+ {
+ return max(0, $this->max_participants - $this->current_participants);
+ }
+
+ protected function startTime(): Attribute
+ {
+ return Attribute::make(
+ get: fn($value) => $value ? substr($value, 0, 5) : $value,
+ );
+ }
+}
diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php
index d5fbe6d..e10047f 100644
--- a/app/Models/DivingOffer.php
+++ b/app/Models/DivingOffer.php
@@ -30,4 +30,9 @@ class DivingOffer extends Model
'price' => 'integer',
'reviews'=> 'integer',
];
+
+ public function schedules()
+ {
+ return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
+ }
}
diff --git a/database/migrations/2026_05_11_144005_create_course_schedules_table.php b/database/migrations/2026_05_11_144005_create_course_schedules_table.php
new file mode 100644
index 0000000..92e5e6c
--- /dev/null
+++ b/database/migrations/2026_05_11_144005_create_course_schedules_table.php
@@ -0,0 +1,37 @@
+id();
+ $table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
+ $table->foreignId('provider_id')->constrained('users')->cascadeOnDelete();
+ $table->date('scheduled_date');
+ $table->time('start_time');
+ $table->unsignedInteger('max_participants');
+ $table->unsignedInteger('current_participants')->default(0);
+ $table->string('status')->default('open');
+ $table->timestamps();
+
+ $table->index(['diving_offer_id', 'status', 'scheduled_date'], 'idx_offer_status_date');
+ $table->index('provider_id', 'idx_provider_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('course_schedules');
+ }
+};
diff --git a/database/migrations/2026_05_11_144028_create_bookings_table.php b/database/migrations/2026_05_11_144028_create_bookings_table.php
new file mode 100644
index 0000000..48e5495
--- /dev/null
+++ b/database/migrations/2026_05_11_144028_create_bookings_table.php
@@ -0,0 +1,38 @@
+id();
+ $table->foreignId('schedule_id')->constrained('course_schedules')->cascadeOnDelete();
+ $table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
+ $table->unsignedInteger('participants')->default(1);
+ $table->unsignedInteger('total_price');
+ $table->string('status')->default('pending');
+ $table->text('notes')->nullable();
+ $table->timestamps();
+
+ $table->index(['member_id', 'status'], 'idx_member_status');
+ $table->index(['schedule_id', 'status'], 'idx_schedule_status');
+ $table->index(['status', 'created_at'], 'idx_status_created_at');
+ $table->index(['status', 'schedule_id'], 'idx_status_sched');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('bookings');
+ }
+};
diff --git a/docker/php/docker-entrypoint.sh b/docker/php/docker-entrypoint.sh
index 26d2eac..e2ffe9e 100644
--- a/docker/php/docker-entrypoint.sh
+++ b/docker/php/docker-entrypoint.sh
@@ -110,5 +110,9 @@ fi
echo "✅ CFDivePlatform 初始化完成!"
+# 啟動 cron daemon(Laravel Scheduler)
+echo "⏰ 啟動 Laravel Scheduler cron..."
+service cron start || cron || true
+
# 執行傳入的命令
exec "$@"
diff --git a/frontend/src/api/bookingApi.js b/frontend/src/api/bookingApi.js
new file mode 100644
index 0000000..bdd29e7
--- /dev/null
+++ b/frontend/src/api/bookingApi.js
@@ -0,0 +1,17 @@
+import api from './axios'
+
+export function getMyBookings() {
+ return api.get('/member/bookings')
+}
+
+export function getBooking(id) {
+ return api.get(`/member/bookings/${id}`)
+}
+
+export function createBooking(payload) {
+ return api.post('/member/bookings', payload)
+}
+
+export function cancelBooking(id) {
+ return api.delete(`/member/bookings/${id}`)
+}
diff --git a/frontend/src/api/coachBookingApi.js b/frontend/src/api/coachBookingApi.js
new file mode 100644
index 0000000..39a2ab4
--- /dev/null
+++ b/frontend/src/api/coachBookingApi.js
@@ -0,0 +1,17 @@
+import coachApi from './coachAxios'
+
+export function getProviderBookings() {
+ return coachApi.get('/provider/bookings')
+}
+
+export function confirmBooking(id) {
+ return coachApi.put(`/provider/bookings/${id}/confirm`)
+}
+
+export function rejectBooking(id) {
+ return coachApi.put(`/provider/bookings/${id}/reject`)
+}
+
+export function cancelBooking(id) {
+ return coachApi.put(`/provider/bookings/${id}/cancel`)
+}
diff --git a/frontend/src/api/coachScheduleApi.js b/frontend/src/api/coachScheduleApi.js
new file mode 100644
index 0000000..202d96d
--- /dev/null
+++ b/frontend/src/api/coachScheduleApi.js
@@ -0,0 +1,17 @@
+import coachApi from './coachAxios'
+
+export function getSchedules() {
+ return coachApi.get('/provider/schedules')
+}
+
+export function createSchedule(payload) {
+ return coachApi.post('/provider/schedules', payload)
+}
+
+export function updateSchedule(id, payload) {
+ return coachApi.put(`/provider/schedules/${id}`, payload)
+}
+
+export function deleteSchedule(id) {
+ return coachApi.delete(`/provider/schedules/${id}`)
+}
diff --git a/frontend/src/api/scheduleApi.js b/frontend/src/api/scheduleApi.js
new file mode 100644
index 0000000..d4155d9
--- /dev/null
+++ b/frontend/src/api/scheduleApi.js
@@ -0,0 +1,10 @@
+import axios from 'axios'
+
+const publicApi = axios.create({
+ baseURL: import.meta.env.VITE_API_URL + '/api',
+ headers: { Accept: 'application/json' },
+})
+
+export function getSchedulesByOffer(offerId) {
+ return publicApi.get(`/diving-offers/${offerId}/schedules`)
+}
diff --git a/frontend/src/components/CoachNavBar.vue b/frontend/src/components/CoachNavBar.vue
index 6dc72c6..1b866a4 100644
--- a/frontend/src/components/CoachNavBar.vue
+++ b/frontend/src/components/CoachNavBar.vue
@@ -18,8 +18,10 @@ async function handleLogout() {
🤿 Coach Portal
- 我的課程
- 個人資料
+ 我的課程
+ 時段管理
+ 預約管理
+ 個人資料
diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue
index 1cdfab0..4287af1 100644
--- a/frontend/src/components/NavBar.vue
+++ b/frontend/src/components/NavBar.vue
@@ -25,6 +25,7 @@ async function handleLogout() {
👤 {{ auth.user?.name }}
+
我的預約
個人資料
-
+
課程費用
NT$ {{ offer.price.toLocaleString() }}
-
+
+
+
+
+
+
可預約時段
+
+
+ ⚠️
+ 送出預約後需等待教練確認,確認後才算預約成功。
+
+
+
目前沒有開放時段
+
+
+
+
+
+
+
+
+
+
+
+
+
✓ 預約已送出!請等待教練確認。前往 我的預約 查看。
+
{{ booking.error }}
+
+
+ 請先 登入 才能預約
+
+
+
diff --git a/frontend/src/views/MyBookingsView.vue b/frontend/src/views/MyBookingsView.vue
new file mode 100644
index 0000000..0893fa1
--- /dev/null
+++ b/frontend/src/views/MyBookingsView.vue
@@ -0,0 +1,166 @@
+
+
+
+
+ 我的預約
+
+ 載入中...
+ {{ error }}
+
+ 目前沒有預約記錄。瀏覽課程
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ b.status === 'pending' ? '⏳' : b.status === 'confirmed' ? '✅' : 'ℹ️' }}
+ {{ STATUS_LABEL[b.status].hint }}
+
+
+
+
+
+
課程名稱
+
{{ b.offer_title }}
+
+
+
地點
+
{{ b.offer_location || '—' }}
+ ・{{ b.offer_region }}
+
+
+
+
上課日期
+
{{ b.scheduled_date }} {{ b.start_time }}
+
+
+
預約人數
+
{{ b.participants }} 人
+
+
+
課程單價
+
NT$ {{ b.offer_price?.toLocaleString() }}
+
+
+
總金額
+
NT$ {{ b.total_price?.toLocaleString() }}
+
+
+
+
預約時間
+
{{ b.created_at ? new Date(b.created_at).toLocaleString('zh-TW') : '—' }}
+
+
+
+
+
+
+ 查看課程介紹 →
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue
index 4dcecf1..53c32ac 100644
--- a/frontend/src/views/RegisterView.vue
+++ b/frontend/src/views/RegisterView.vue
@@ -72,6 +72,7 @@ async function submit() {
minlength="8"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-ocean-400"
/>
+
至少 8 個字元
@@ -80,7 +81,9 @@ async function submit() {
type="password"
required
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-ocean-400"
+ :class="confirm && confirm !== password ? 'border-red-400' : ''"
/>
+
密碼不一致