Compare commits

...

18 Commits

Author SHA1 Message Date
a620906209 f984d0bb82 Merge pull request 'chore:歸檔 api-performance-optimization,同步主規格' (#1) from feature/swagger-api-docs into master
Tests / PHP 8.2 (push) Failing after 2s
Tests / PHP 8.3 (push) Failing after 1s
Reviewed-on: #1
2026-05-24 17:21:43 +00:00
a620906209 4feeeff94a docs:補全所有 API 端點 Swagger 文件(73 個端點)
Tests / PHP 8.2 (pull_request) Failing after 2s
Tests / PHP 8.3 (pull_request) Failing after 2s
pull requests / uneditable (pull_request_target) Failing after 0s
- 修正 AuthApiDoc.php 路徑錯位(/register/member → /member/register 等)
  及 change-password 方法(Post→Put);補上 POST /logout、GET /user
- 新增 AuthSupplementDoc.php(Google OAuth 2 端點)
- 新增 PublicApiDoc.php(共用 Schema + 公開課程 4 端點)
- 新增 MemberApiDoc.php(預約、評價、通知共 13 端點)
- 新增 ProviderApiDoc.php(課程、圖片、時段、預約管理共 18 端點)
- 新增 AdminApiDoc.php(統計、會員/教練/課程管理共 16 端點)
- 更新 README.md
- 歸檔 swagger-api-docs-completion change,同步主規格

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 01:17:25 +08:00
a620906209 e8cd82cdee chore:新增 swagger-api-docs-completion change 規劃
proposal / design / specs / tasks 全部就緒,21 tasks 待實作

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:45:15 +08:00
a620906209 849c33e83a chore:歸檔 api-performance-optimization,同步主規格
- 移至 openspec/changes/archive/2026-05-25-api-performance-optimization/
- 新增主規格:api-cache-layer、db-index-optimization
- 更新 review-lifecycle 規格(補分頁、eager load)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:45:08 +08:00
a620906209 0455c1577e feat:實作 API 效能優化 — Redis 快取、分頁、DB 索引
Tests / PHP 8.2 (push) Failing after 1m35s
Tests / PHP 8.3 (push) Failing after 2s
- 引入 Redis(predis)快取層:Admin Stats(5分鐘)、課程列表(3分鐘,tag-based 失效)、評價分布(10分鐘)
- ReviewController::publicList 改為 paginate + eager load votes,消除 N+1
- AdminReviewController::index 加入分頁(預設 20,最大 100)
- 新增 notifications / diving_offers 效能索引 migration
- 新增 docker-compose.override.yml 本機開發 port mapping 機制(不進 git)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:16 +08:00
a620906209 6877ef30b5 fix:修正 Email 設定與規格同步
Tests / PHP 8.2 (push) Failing after 1m56s
Tests / PHP 8.3 (push) Failing after 1m17s
- .env.example:MAIL_MAILER 改為 smtp(對應 Mailpit 本地測試流程)
- notification-email spec:移除 ReviewReceived Email 觸發(僅 database channel)
- notification-email spec:Email CTA 連結改為 /my-bookings(移除 /{id})

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 23:21:16 +08:00
a620906209 03f8caf3e9 feat:實作通知系統 — 站內通知、Email 通知、Polling 機制
後端
- 新增 6 個 Notification class(預約建立/確認/拒絕/取消/完成、收到評價),database + mail 雙 channel
- 新增 NotificationController(list / unread-count / markRead / markAllRead / destroy)
- 整合通知觸發至 MemberBookingController、ProviderBookingController、CompleteFinishedBookings、ReviewController
- 新增 notifications / jobs / failed_jobs migration
- Docker Compose 加入 queue-worker、mailpit service
- DivingOffer 補上 provider() 關聯

前端
- 新增 notificationStore(Polling 30s/60s 自適應 + Page Visibility API)
- 新增 NotificationBell(未讀 Badge)、NotificationDrawer(側邊通知中心)
- main.js:auth store init 前置於 router.use(),修正 beforeEach guard 時序問題
- notificationAxios:依路徑動態選擇 member/coach token
- NotificationDrawer:改用 new URL().pathname 提取 action_url 路徑

OpenSpec
- 歸檔 notification-system change
- 同步 notification-core / notification-email / notification-triggers specs 至主規格
- 更新 booking-lifecycle / review-lifecycle spec(補充通知觸發 requirement)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 22:26:14 +08:00
a620906209 4baa4cb52b feat:實作課程圖片上傳 — 封面 + 相簿管理
後端:
- Migration:diving_offers 新增 cover_image 欄位、新增 course_images 表(含索引)
- CourseImage Model(CREATED_AT、url accessor)
- DivingOffer:cover_image_url accessor、hasMany courseImages、static::deleting() 孤兒清理
- CourseImageController:封面上傳/刪除、相簿上傳(max 3)/刪除,統一 mimes+size 驗證
- DivingOfferController:index/show 回傳加入 cover_image_url 與 images 陣列
- 修正 APP_URL 加入 port(:8080),Storage::url() 才能產生正確圖片連結

前端:
- courseImageApi.js:uploadCover/deleteCover/uploadImage/deleteImage
- CourseCard:有封面顯示 <img>,無封面顯示漸層佔位
- CourseDetailView:封面大圖 + 相簿縮圖橫列(點擊開新分頁)
- OfferFormView(編輯模式):封面預覽/更換/刪除、相簿縮圖管理(達 3 張隱藏上傳按鈕)

基礎設施:
- docker-entrypoint.sh:加入 storage:link --force
- docker-compose.yml:移除 storage-data named volume(改用 bind mount,避免 Nginx 讀不到圖片)

測試:
- CourseImageTest.php:14 個 Feature Test 全部 PASS(Storage::fake)
  涵蓋:上傳成功/格式驗證/大小驗證/所有權、刪除/無封面不報錯、
        相簿上限/sort_order 遞增、孤兒清理

OpenSpec:
- course-images change 歸檔至 archive/2026-05-12-course-images
- 新增 specs/course-image-upload 主規格(含 bind mount 持久化說明)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 03:54:45 +08:00
a620906209 81a9f84b26 feat:實作評價系統 — 匿名評價、有幫助投票、手動完成預約
後端:
- 新增 reviews / review_edits / review_votes migration(含索引)
- Review / ReviewEdit / ReviewVote Model
- ReviewController:評價 CRUD、資格驗證(completed booking)、rating 即時重算
- toggleHelpful:Member 限定、GREATEST 原子防負、DB transaction 同步
- AdminReviewController:全量列表、刪除(含重算)
- AdminBookingController:全量列表、手動標記 completed
- ProviderBookingController 新增 complete 方法(教練手動完成預約)
- DevelopmentSeeder:快速重建測試資料(admin/coach/member + offers + bookings)
- EnsureAdmin middleware 正式納入 bootstrap/app.php
- Nginx server_name 加入 cfdive.local

前端:
- 課程詳情頁加入評價區塊(星等分布、排序切換、撰寫/修改/刪除、有幫助 Toggle)
- Coach Portal 新增「課程評價」頁(只讀,依課程分組)
- Coach 預約管理加入「完成」按鈕
- Admin 新增「預約管理」頁(標記完成)、「評價管理」頁(刪除)
- Admin / Coach Navbar 新增對應連結

OpenSpec:
- review-system change 歸檔至 archive/2026-05-12-review-system
- 新增 specs/review-lifecycle 與 specs/review-voting 主規格
- review-voting spec 補充 Member 限定與 GREATEST 原子更新說明

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 02:46:54 +08:00
a620906209 975b56ca54 feat:實作預約系統 — 時段管理、預約生命週期與前端整合
後端:
- 新增 course_schedules / bookings migration(含索引)
- BookingStatus / ScheduleStatus PHP BackedEnum
- CourseSchedule / Booking Model(七狀態機 VALID_TRANSITIONS)
- ScheduleController、ProviderBookingController、MemberBookingController
- 雙層名額驗證(API 層快速失敗 + DB lockForUpdate 防超賣)
- 24h 取消截止、pending 不佔位設計
- ExpirePendingBookings(每小時)/ CompleteFinishedBookings(每日)Scheduler
- Docker cron 配置、CACHE_STORE 改為 file 修正 502

前端:
- 課程詳情頁加入時段選擇與預約流程
- 我的預約頁(展開式卡片、狀態說明、連結課程詳情)
- Coach 時段管理(上午/下午時間選擇器、新課程引導)
- Coach 預約管理(依課程分組、待確認徽章)
- Navbar 新增「我的預約」與「時段/預約管理」入口
- 密碼格式提示與即時比對

OpenSpec:
- booking-system change 歸檔至 archive/2026-05-12-booking-system
- 新增 specs/course-scheduling 與 specs/booking-lifecycle 主規格

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 00:24:51 +08:00
a620906209 ad2c05779d feat:實作 Admin Panel — 平台管理後台
後端:
- AdminStatsController:總會員/教練/課程數統計 API
- AdminUserController:會員與教練列表、詳情、啟用/停用、教練驗證(toggle 反轉語意)
- AdminOfferController:全平台課程列表與刪除
- routes/api.php:新增 /api/admin/stats、members、providers、offers 等路由

前端(frontend/):
- adminAuth store、adminAxios(第三套獨立認證)
- /admin/* 路由群組 + requiresAdmin guard
- AdminNavBar、AdminLayout
- App.vue:isCoachPage → isBackofficePage(/coach/* 和 /admin/* 皆隱藏會員 NavBar)
- LoginView、DashboardView(統計卡片)
- MembersView、ProvidersView(含驗證操作)、OffersView(含刪除確認)

OpenSpec:
- 新增 specs:admin-auth / admin-user-management / admin-offer-management / admin-stats / admin-panel-ui
- 歸檔:openspec/changes/archive/2026-05-10-admin-panel

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 04:07:13 +08:00
a620906209 da48a3652d feat:實作 Coach Portal — 教練後台課程管理
後端:
- Migration:diving_offers 新增 provider_id(nullable FK)
- Migration:users.role ENUM 加入 provider 值
- Migration:diving_offers.spot 改為 nullable
- AuthController:registerProvider business_name 改為選填
- AuthController:updateProviderProfile 補上 certifications / dive_sites / services / facilities / website / social_media
- ProviderOfferController:教練課程 CRUD(index/show/store/update/destroy),實作 provider_id 所有權不變式(404 → 403 兩步驟)

前端(frontend/):
- coachAuth store、coachAxios(獨立於 member auth)
- /coach/* 路由群組 + beforeEach guard
- CoachNavBar、CoachLayout(教練頁隱藏會員 NavBar)
- LoginView、RegisterView、DashboardView(表格 + 刪除確認)
- OfferFormView(新增/編輯共用)、ProfileView

OpenSpec:
- openspec/config.yaml 補入專案 context 與 rules
- 新增 specs:coach-offers-api / coach-portal-ui / provider-auth
- 更新 spec:diving-offers-api 加入 provider_id
- 歸檔:openspec/changes/archive/2026-05-10-coach-portal

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 03:34:14 +08:00
a620906209 550e2fc97a feat:實作 Member Portal MVP 前端與後端整合
後端:
- 新增 DivingOffer Model / DivingOfferController(列表+詳情 API,支援搜尋/篩選/分頁)
- 修正 Google OAuth callback 改為 redirect 至前端(SocialAuthController)
- 新增 config/cors.php 允許前端 origin
- .gitignore 新增 frontend/ 排除規則

前端(frontend/):
- Vue 3 + Vite + Tailwind CSS + Pinia + Vue Router
- 頁面:首頁、課程列表、課程詳情、登入、註冊、個人資料、OAuth callback
- 整合至 Docker(multi-stage build,nginx 靜態服務於 port 5173)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 01:41:28 +08:00
a620906209 725c86f434 fix:補sessionDB 2025-05-23 21:21:19 +08:00
a620906209 9de698d90e build:添加docker文件 2025-05-23 21:20:54 +08:00
a620906209 b02a18d762 test:編寫Postman API測試文件 2025-05-23 18:31:33 +08:00
a620906209 0a0d335304 docs:移除coach swagger註解 2025-05-12 02:43:37 +08:00
a620906209 6fef9e8c37 Update README.md
Swagger API Doc
2025-05-12 02:37:02 +08:00
237 changed files with 25079 additions and 213 deletions
+5 -4
View File
@@ -4,6 +4,7 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_TIMEZONE=UTC APP_TIMEZONE=UTC
APP_URL=http://localhost APP_URL=http://localhost
FRONTEND_URL=http://localhost:5173
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -36,17 +37,17 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
CACHE_STORE=database CACHE_STORE=redis
CACHE_PREFIX= CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1 REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
MAIL_MAILER=log MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1 MAIL_HOST=127.0.0.1
MAIL_PORT=2525 MAIL_PORT=2525
MAIL_USERNAME=null MAIL_USERNAME=null
+9
View File
@@ -1,4 +1,5 @@
/.phpunit.cache /.phpunit.cache
docker-compose.override.yml
/node_modules /node_modules
/public/build /public/build
/public/hot /public/hot
@@ -17,3 +18,11 @@ yarn-error.log
/.fleet /.fleet
/.idea /.idea
/.vscode /.vscode
/.claude
/.opensp
# Frontend
/frontend/node_modules
/frontend/dist
/frontend/.env
@@ -0,0 +1,501 @@
{
"info": {
"name": "CFDivePlatform API Tests",
"description": "CFDivePlatform API 測試集合 - 包含會員、服務提供者、管理員等完整測試案例",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "基礎測試",
"item": [
{
"name": "Ping Test",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"url": {
"raw": "http://localhost:8080/api/ping",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "ping"]
}
}
},
{
"name": "Test POST",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"測試用戶\",\n \"value\": \"123\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/testpost",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "testpost"]
}
}
}
]
},
{
"name": "會員認證",
"item": [
{
"name": "會員註冊",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"測試會員\",\n \"email\": \"member@test.com\",\n \"password\": \"password123\",\n \"password_confirmation\": \"password123\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/member/register",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "register"]
}
}
},
{
"name": "會員登入",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"member@test.com\",\n \"password\": \"password123\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/member/login",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "login"]
}
}
},
{
"name": "取得會員資料",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{member_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/member/profile",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "profile"]
}
}
}
]
},
{
"name": "會員專屬 API",
"item": [
{
"name": "會員登出",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{member_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/member/logout",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "logout"]
}
}
},
{
"name": "更新會員個人資料",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{member_token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"更新後的會員名稱\",\n \"phone\": \"0912345678\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/member/profile",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "profile"]
}
}
},
{
"name": "修改會員密碼",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{member_token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"current_password\": \"password123\",\n \"new_password\": \"newpassword123\",\n \"new_password_confirmation\": \"newpassword123\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/member/change-password",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "change-password"]
}
}
}
]
},
{
"name": "服務提供者認證",
"item": [
{
"name": "服務提供者註冊",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"測試教練\",\n \"email\": \"{{test_provider_email}}\",\n \"password\": \"{{test_password}}\",\n \"password_confirmation\": \"{{test_password}}\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/provider/register",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "provider", "register"]
}
}
},
{
"name": "服務提供者登入",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"{{test_provider_email}}\",\n \"password\": \"{{test_password}}\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/provider/login",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "provider", "login"]
}
}
}
]
},
{
"name": "服務提供者專屬 API",
"item": [
{
"name": "服務提供者登出",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{provider_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/provider/logout",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "provider", "logout"]
}
}
},
{
"name": "取得服務提供者資料",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{provider_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/provider/profile",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "provider", "profile"]
}
}
}
]
},
{
"name": "管理員認證",
"item": [
{
"name": "管理員註冊",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"測試管理員\",\n \"email\": \"{{test_admin_email}}\",\n \"password\": \"{{test_password}}\",\n \"password_confirmation\": \"{{test_password}}\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/admin/register",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "admin", "register"]
}
}
},
{
"name": "管理員登入",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"{{test_admin_email}}\",\n \"password\": \"{{test_password}}\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/admin/login",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "admin", "login"]
}
}
}
]
},
{
"name": "管理員專屬 API",
"item": [
{
"name": "查詢會員資料",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{admin_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/admin/check-member/1",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "admin", "check-member", "1"],
"variable": [
{
"key": "id",
"value": "1"
}
]
}
}
},
{
"name": "查詢服務提供者資料",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{admin_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/admin/check-provider/1",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "admin", "check-provider", "1"],
"variable": [
{
"key": "id",
"value": "1"
}
]
}
}
}
]
}
],
"variable": [
{
"key": "base_url",
"value": "http://localhost:8080/api"
},
{
"key": "member_token",
"value": "",
"description": "會員登入後設定"
},
{
"key": "provider_token",
"value": "",
"description": "服務提供者登入後設定"
},
{
"key": "admin_token",
"value": "",
"description": "管理員登入後設定"
},
{
"key": "test_member_email",
"value": "member@test.com"
},
{
"key": "test_provider_email",
"value": "provider@test.com"
},
{
"key": "test_admin_email",
"value": "admin@test.com"
},
{
"key": "test_password",
"value": "password123"
}
]
}
+64
View File
@@ -0,0 +1,64 @@
# 使用官方 PHP 8.2 FPM 鏡像作為基礎
FROM php:8.2-fpm
# 安裝系統依賴
# 1. 更新套件列表並安裝必要的套件
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
zip \
unzip \
libzip-dev \
default-mysql-client \
netcat-openbsd \
grep \
cron
# 清理 apt 快取以減小鏡像大小
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
# 安裝 PHP 擴展
# 這些擴展是 Laravel 和一般 PHP 開發所需的
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
# 從官方 Composer 鏡像複製 Composer 執行文件
# 這樣可以確保我們使用最新版本的 Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# 設置工作目錄
WORKDIR /var/www
# 設置目錄權限
# 將 /var/www 目錄的所有權更改為 www-data 用戶和組
RUN chown -R www-data:www-data /var/www
# 創建必要的目錄並設置權限
# Laravel 需要這些目錄來存儲日誌、緩存等
RUN mkdir -p /var/www/storage /var/www/bootstrap/cache
RUN chmod -R 775 /var/www/storage /var/www/bootstrap/cache
# 複製自定義的 PHP 配置文件
# 這個文件包含 PHP 運行時的配置選項
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"]
# 設置默認的容器命令
# 這將在 ENTRYPOINT 執行後運行
CMD ["php-fpm"]
+37 -54
View File
@@ -1,66 +1,49 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p> # CFDive Platform
<p align="center"> 潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、評價與通知等完整服務。
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel ---
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: ## 功能概覽
- [Simple, fast routing engine](https://laravel.com/docs/routing). **會員(Member**
- [Powerful dependency injection container](https://laravel.com/docs/container). - 註冊 / 登入(Email + Google OAuth
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. - 瀏覽、搜尋、篩選潛水課程
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). - 查看課程時段並送出預約
- Database agnostic [schema migrations](https://laravel.com/docs/migrations). - 對完成的課程留下評價(支援匿名、有幫助投票)
- [Robust background job processing](https://laravel.com/docs/queues). - 站內通知(Email + Polling
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications. **教練(Provider**
- 課程 CRUD(含封面 + 相簿圖片上傳)
- 課程時段管理
- 預約管理(確認 / 拒絕 / 完成 / 取消)
## Learning Laravel **管理員(Admin**
- 平台統計數據(會員數、教練數、課程數)
- 會員與教練帳號管理(啟用 / 停用 / 審核)
- 課程、預約、評價管理
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. ---
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch. ## 技術棧
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. | 層級 | 技術 |
|------|------|
| 後端 | PHP 8.x / Laravel 11 |
| 前端 | Vue 3 |
| 資料庫 | MySQL 8.0 |
| 快取 | Redispredis|
| 認證 | Laravel Sanctum + Google OAuth |
| 容器 | Docker / Docker Compose |
| API 文件 | Swagger UIl5-swagger|
## Laravel Sponsors ---
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). ## API 文件
### Premium Partners 共 73 個端點,涵蓋:
- 認證(Email + Google OAuth
- **[Vehikl](https://vehikl.com/)** - 公開課程查詢
- **[Tighten Co.](https://tighten.co)** - 會員預約 / 評價 / 通知
- **[WebReinvent](https://webreinvent.com/)** - 教練課程 / 時段 / 預約管理
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** - 管理員後台
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
- **[Cyber-Duck](https://cyber-duck.co.uk)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Jump24](https://jump24.co.uk)**
- **[Redberry](https://redberry.international/laravel/)**
- **[Active Logic](https://activelogic.com)**
- **[byte5](https://byte5.de)**
- **[OP.GG](https://op.gg)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
@@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Enums\BookingStatus;
use App\Models\Booking;
use App\Notifications\BookingCompletedNotification;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class CompleteFinishedBookings extends Command
{
protected $signature = 'app:complete-finished-bookings';
protected $description = '將課程日期已過的 confirmed 預約標記為 completed';
public function handle(): void
{
$bookings = Booking::with(['member', 'schedule.divingOffer'])
->where('status', BookingStatus::Confirmed->value)
->whereHas('schedule', fn($q) => $q->whereDate('scheduled_date', '<', now()->toDateString()))
->get();
$count = 0;
foreach ($bookings as $booking) {
$booking->update(['status' => BookingStatus::Completed]);
$count++;
try {
$booking->member->notify(new BookingCompletedNotification($booking));
} catch (\Throwable $e) {
Log::error("BookingCompletedNotification failed for booking #{$booking->id}: " . $e->getMessage());
}
}
Log::info("CompleteFinishedBookings: {$count} completed");
$this->info("CompleteFinishedBookings: {$count} bookings completed.");
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use App\Enums\BookingStatus;
use App\Models\Booking;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ExpirePendingBookings extends Command
{
protected $signature = 'app:expire-pending-bookings';
protected $description = '將超過 48 小時未確認的 pending 預約標記為 expired';
public function handle(): void
{
$count = Booking::where('status', BookingStatus::Pending->value)
->where('created_at', '<=', now()->subHours(48))
->update(['status' => BookingStatus::Expired->value]);
Log::info("ExpirePendingBookings: {$count} expired");
$this->info("ExpirePendingBookings: {$count} bookings expired.");
}
}
+504
View File
@@ -0,0 +1,504 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="Admin 統計",
* description="管理員平台統計"
* )
* @OA\Tag(
* name="Admin 會員管理",
* description="管理員的會員帳號管理"
* )
* @OA\Tag(
* name="Admin 教練管理",
* description="管理員的服務提供者帳號管理"
* )
* @OA\Tag(
* name="Admin 課程管理",
* description="管理員的課程、預約、評價管理"
* )
*/
class AdminApiDoc
{
// -----------------------------------------------------------------------
// Admin Stats
// -----------------------------------------------------------------------
/**
* 取得平台統計數據
*
* @OA\Get(
* path="/admin/stats",
* summary="取得平台統計數據",
* description="回傳會員總數、服務提供者總數、課程總數;非 admin 角色回傳 403",
* operationId="getAdminStats",
* tags={"Admin 統計"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="total_members", type="integer", example=128),
* @OA\Property(property="total_providers", type="integer", example=34),
* @OA\Property(property="total_offers", type="integer", example=87)
* )
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getAdminStats()
{
}
// -----------------------------------------------------------------------
// Admin Member Management
// -----------------------------------------------------------------------
/**
* 取得會員列表
*
* @OA\Get(
* path="/admin/members",
* summary="取得會員列表",
* description="分頁回傳所有會員帳號,含 member_profile",
* operationId="listAdminMembers",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="王小明"),
* @OA\Property(property="email", type="string", example="member@example.com"),
* @OA\Property(property="role", type="string", example="member"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="member_profile", type="object", nullable=true)
* )
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminMembers()
{
}
/**
* 取得單一會員
*
* @OA\Get(
* path="/admin/members/{id}",
* summary="取得單一會員",
* operationId="getAdminMember",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="object")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getAdminMember()
{
}
/**
* 切換會員啟用狀態
*
* @OA\Put(
* path="/admin/members/{id}/toggle-active",
* summary="切換會員啟用狀態",
* description="啟用或停用指定會員帳號",
* operationId="toggleMemberActive",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="切換成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="帳號已停用"),
* @OA\Property(property="is_active", type="boolean", example=false)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function toggleMemberActive()
{
}
/**
* 確認會員存在
*
* @OA\Get(
* path="/admin/check-member/{id}",
* summary="確認會員存在",
* description="快速確認指定 ID 的會員是否存在(角色為 member)",
* operationId="checkMember",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="會員存在",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="exists", type="boolean", example=true)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function checkMember()
{
}
// -----------------------------------------------------------------------
// Admin Provider Management
// -----------------------------------------------------------------------
/**
* 取得教練列表
*
* @OA\Get(
* path="/admin/providers",
* summary="取得教練列表",
* description="分頁回傳所有服務提供者,含 provider_profile",
* operationId="listAdminProviders",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=2),
* @OA\Property(property="name", type="string", example="林教練"),
* @OA\Property(property="email", type="string", example="coach@example.com"),
* @OA\Property(property="role", type="string", example="provider"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="is_verified", type="boolean", example=false),
* @OA\Property(property="provider_profile", type="object", nullable=true)
* )
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminProviders()
{
}
/**
* 取得單一教練
*
* @OA\Get(
* path="/admin/providers/{id}",
* summary="取得單一教練",
* operationId="getAdminProvider",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="object")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getAdminProvider()
{
}
/**
* 切換教練啟用狀態
*
* @OA\Put(
* path="/admin/providers/{id}/toggle-active",
* summary="切換教練啟用狀態",
* operationId="toggleProviderActive",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="切換成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="帳號已停用"),
* @OA\Property(property="is_active", type="boolean", example=false)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function toggleProviderActive()
{
}
/**
* 切換教練審核狀態
*
* @OA\Put(
* path="/admin/providers/{id}/toggle-verified",
* summary="切換教練審核狀態",
* description="通過或撤銷教練審核,回傳新的 is_verified 狀態",
* operationId="toggleProviderVerified",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="切換成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="教練已通過審核"),
* @OA\Property(property="is_verified", type="boolean", example=true)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function toggleProviderVerified()
{
}
/**
* 確認教練存在
*
* @OA\Get(
* path="/admin/check-provider/{id}",
* summary="確認教練存在",
* operationId="checkProvider",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="查詢成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="exists", type="boolean", example=true)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function checkProvider()
{
}
// -----------------------------------------------------------------------
// Admin Offers / Bookings / Reviews
// -----------------------------------------------------------------------
/**
* 取得所有課程(Admin
*
* @OA\Get(
* path="/admin/offers",
* summary="取得所有課程(Admin",
* description="分頁回傳全平台課程列表",
* operationId="listAdminOffers",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminOffers()
{
}
/**
* 刪除課程(Admin
*
* @OA\Delete(
* path="/admin/offers/{id}",
* summary="刪除課程(Admin",
* operationId="deleteAdminOffer",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="課程已刪除")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteAdminOffer()
{
}
/**
* 取得所有預約(Admin
*
* @OA\Get(
* path="/admin/bookings",
* summary="取得所有預約(Admin",
* description="分頁回傳全平台預約列表",
* operationId="listAdminBookings",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminBookings()
{
}
/**
* 標記預約完成(Admin
*
* @OA\Put(
* path="/admin/bookings/{id}/complete",
* summary="標記預約完成(Admin",
* operationId="completeBookingByAdmin",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已完成"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function completeBookingByAdmin()
{
}
/**
* 取得所有評價(Admin
*
* @OA\Get(
* path="/admin/reviews",
* summary="取得所有評價(Admin",
* description="分頁回傳全平台評價列表,per_page 最大 100",
* operationId="listAdminReviews",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(最大 100", @OA\Schema(type="integer", default=20, maximum=100)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Review")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminReviews()
{
}
/**
* 刪除評價(Admin
*
* @OA\Delete(
* path="/admin/reviews/{id}",
* summary="刪除評價(Admin",
* operationId="deleteAdminReview",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="評價已刪除")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteAdminReview()
{
}
}
+92 -21
View File
@@ -35,6 +35,10 @@ use OpenApi\Annotations as OA;
* name="管理員", * name="管理員",
* description="管理員相關操作" * description="管理員相關操作"
* ) * )
* @OA\Tag(
* name="認證",
* description="通用認證操作(登出、取得當前使用者)"
* )
*/ */
class AuthApiDoc class AuthApiDoc
{ {
@@ -42,7 +46,7 @@ class AuthApiDoc
* 會員註冊 * 會員註冊
* *
* @OA\Post( * @OA\Post(
* path="/register/member", * path="/member/register",
* summary="會員註冊", * summary="會員註冊",
* description="建立新的會員帳號", * description="建立新的會員帳號",
* operationId="registerMember", * operationId="registerMember",
@@ -119,7 +123,7 @@ class AuthApiDoc
* 會員登入 * 會員登入
* *
* @OA\Post( * @OA\Post(
* path="/login/member", * path="/member/login",
* summary="會員登入", * summary="會員登入",
* description="會員帳號登入系統", * description="會員帳號登入系統",
* operationId="loginMember", * operationId="loginMember",
@@ -207,7 +211,7 @@ class AuthApiDoc
* 會員登出 * 會員登出
* *
* @OA\Post( * @OA\Post(
* path="/logout/member", * path="/member/logout",
* summary="會員登出", * summary="會員登出",
* description="會員登出系統並撤銷當前令牌", * description="會員登出系統並撤銷當前令牌",
* operationId="logoutMember", * operationId="logoutMember",
@@ -241,7 +245,7 @@ class AuthApiDoc
* 取得會員個人資料 * 取得會員個人資料
* *
* @OA\Get( * @OA\Get(
* path="/profile/member", * path="/member/profile",
* summary="取得會員個人資料", * summary="取得會員個人資料",
* description="取得當前登入會員的個人資料", * description="取得當前登入會員的個人資料",
* operationId="memberProfile", * operationId="memberProfile",
@@ -303,7 +307,7 @@ class AuthApiDoc
* 更新會員個人資料 * 更新會員個人資料
* *
* @OA\Put( * @OA\Put(
* path="/profile/member", * path="/member/profile",
* summary="更新會員個人資料", * summary="更新會員個人資料",
* description="更新當前登入會員的個人資料", * description="更新當前登入會員的個人資料",
* operationId="updateMemberProfile", * operationId="updateMemberProfile",
@@ -383,8 +387,8 @@ class AuthApiDoc
/** /**
* 修改會員密碼 * 修改會員密碼
* *
* @OA\Post( * @OA\Put(
* path="/password/member", * path="/member/change-password",
* summary="修改會員密碼", * summary="修改會員密碼",
* description="修改當前登入會員的密碼", * description="修改當前登入會員的密碼",
* operationId="changeMemberPassword", * operationId="changeMemberPassword",
@@ -444,7 +448,7 @@ class AuthApiDoc
* 服務提供者註冊 * 服務提供者註冊
* *
* @OA\Post( * @OA\Post(
* path="/register/provider", * path="/provider/register",
* summary="服務提供者註冊", * summary="服務提供者註冊",
* description="建立新的服務提供者帳號", * description="建立新的服務提供者帳號",
* operationId="registerProvider", * operationId="registerProvider",
@@ -498,7 +502,7 @@ class AuthApiDoc
* 服務提供者登入 * 服務提供者登入
* *
* @OA\Post( * @OA\Post(
* path="/login/provider", * path="/provider/login",
* summary="服務提供者登入", * summary="服務提供者登入",
* description="服務提供者帳號登入系統", * description="服務提供者帳號登入系統",
* operationId="loginProvider", * operationId="loginProvider",
@@ -580,7 +584,7 @@ class AuthApiDoc
* 服務提供者登出 * 服務提供者登出
* *
* @OA\Post( * @OA\Post(
* path="/logout/provider", * path="/provider/logout",
* summary="服務提供者登出", * summary="服務提供者登出",
* description="服務提供者登出系統並撤銷當前令牌", * description="服務提供者登出系統並撤銷當前令牌",
* operationId="logoutProvider", * operationId="logoutProvider",
@@ -614,7 +618,7 @@ class AuthApiDoc
* 取得服務提供者資料 * 取得服務提供者資料
* *
* @OA\Get( * @OA\Get(
* path="/profile/provider", * path="/provider/profile",
* summary="取得服務提供者資料", * summary="取得服務提供者資料",
* description="取得當前登入服務提供者的資料", * description="取得當前登入服務提供者的資料",
* operationId="providerProfile", * operationId="providerProfile",
@@ -678,7 +682,7 @@ class AuthApiDoc
* 更新服務提供者資料 * 更新服務提供者資料
* *
* @OA\Put( * @OA\Put(
* path="/profile/provider", * path="/provider/profile",
* summary="更新服務提供者資料", * summary="更新服務提供者資料",
* description="更新當前登入服務提供者的資料", * description="更新當前登入服務提供者的資料",
* operationId="updateProviderProfile", * operationId="updateProviderProfile",
@@ -764,8 +768,8 @@ class AuthApiDoc
/** /**
* 修改服務提供者密碼 * 修改服務提供者密碼
* *
* @OA\Post( * @OA\Put(
* path="/password/provider", * path="/provider/change-password",
* summary="修改服務提供者密碼", * summary="修改服務提供者密碼",
* description="修改當前登入服務提供者的密碼", * description="修改當前登入服務提供者的密碼",
* operationId="changeProviderPassword", * operationId="changeProviderPassword",
@@ -825,7 +829,7 @@ class AuthApiDoc
* 管理員註冊 * 管理員註冊
* *
* @OA\Post( * @OA\Post(
* path="/register/admin", * path="/admin/register",
* summary="管理員註冊", * summary="管理員註冊",
* description="建立新的管理員帳號", * description="建立新的管理員帳號",
* operationId="registerAdmin", * operationId="registerAdmin",
@@ -877,7 +881,7 @@ class AuthApiDoc
* 管理員登入 * 管理員登入
* *
* @OA\Post( * @OA\Post(
* path="/login/admin", * path="/admin/login",
* summary="管理員登入", * summary="管理員登入",
* description="管理員帳號登入系統", * description="管理員帳號登入系統",
* operationId="loginAdmin", * operationId="loginAdmin",
@@ -958,7 +962,7 @@ class AuthApiDoc
* 管理員登出 * 管理員登出
* *
* @OA\Post( * @OA\Post(
* path="/logout/admin", * path="/admin/logout",
* summary="管理員登出", * summary="管理員登出",
* description="管理員登出系統並撤銷當前令牌", * description="管理員登出系統並撤銷當前令牌",
* operationId="logoutAdmin", * operationId="logoutAdmin",
@@ -992,7 +996,7 @@ class AuthApiDoc
* 取得管理員個人資料 * 取得管理員個人資料
* *
* @OA\Get( * @OA\Get(
* path="/profile/admin", * path="/admin/profile",
* summary="取得管理員個人資料", * summary="取得管理員個人資料",
* description="取得當前登入管理員的個人資料", * description="取得當前登入管理員的個人資料",
* operationId="adminProfile", * operationId="adminProfile",
@@ -1030,7 +1034,7 @@ class AuthApiDoc
* 更新管理員個人資料 * 更新管理員個人資料
* *
* @OA\Put( * @OA\Put(
* path="/profile/admin", * path="/admin/profile",
* summary="更新管理員個人資料", * summary="更新管理員個人資料",
* description="更新當前登入管理員的個人資料", * description="更新當前登入管理員的個人資料",
* operationId="updateAdminProfile", * operationId="updateAdminProfile",
@@ -1087,8 +1091,8 @@ class AuthApiDoc
/** /**
* 修改管理員密碼 * 修改管理員密碼
* *
* @OA\Post( * @OA\Put(
* path="/password/admin", * path="/admin/change-password",
* summary="修改管理員密碼", * summary="修改管理員密碼",
* description="修改當前登入管理員的密碼", * description="修改當前登入管理員的密碼",
* operationId="changeAdminPassword", * operationId="changeAdminPassword",
@@ -1143,4 +1147,71 @@ class AuthApiDoc
public function changeAdminPassword() public function changeAdminPassword()
{ {
} }
/**
* 通用登出
*
* @OA\Post(
* path="/logout",
* summary="通用登出",
* description="撤銷當前 Bearer token,適用所有角色",
* operationId="logout",
* tags={"認證"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="登出成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="登出成功")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function logout()
{
}
/**
* 取得當前使用者
*
* @OA\Get(
* path="/user",
* summary="取得當前使用者",
* description="回傳當前 Bearer token 所屬的使用者資訊",
* operationId="currentUser",
* tags={"認證"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="王小明"),
* @OA\Property(property="email", type="string", example="user@example.com"),
* @OA\Property(property="role", type="string", enum={"member","provider","admin"}, example="member"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function currentUser()
{
}
} }
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="Google OAuth",
* description="Google OAuth 2.0 社群登入"
* )
*/
class AuthSupplementDoc
{
/**
* 取得 Google OAuth 重定向 URL
*
* @OA\Get(
* path="/auth/google/redirect",
* summary="取得 Google OAuth 重定向 URL",
* description="回傳 Google OAuth 授權頁面的 redirect_url,前端應將使用者導向此 URL 以啟動 OAuth 流程",
* operationId="googleRedirect",
* tags={"Google OAuth"},
* @OA\Response(
* response=200,
* description="取得 redirect_url 成功",
* @OA\JsonContent(
* @OA\Property(property="redirect_url", type="string", format="uri", example="https://accounts.google.com/o/oauth2/auth?client_id=...")
* )
* )
* )
*/
public function googleRedirect()
{
}
/**
* Google OAuth 回調
*
* @OA\Get(
* path="/auth/google/callback",
* summary="Google OAuth 回調",
* description="Google OAuth 授權完成後的回調端點,通常由瀏覽器自動呼叫。成功後回傳 Bearer token 與使用者資訊",
* operationId="googleCallback",
* tags={"Google OAuth"},
* @OA\Parameter(
* name="code",
* in="query",
* required=true,
* description="Google 授權碼",
* @OA\Schema(type="string")
* ),
* @OA\Parameter(
* name="state",
* in="query",
* required=false,
* description="OAuth state 參數",
* @OA\Schema(type="string")
* ),
* @OA\Response(
* response=200,
* description="OAuth 登入成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="登入成功"),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="token", type="string", example="1|abcdef1234567890"),
* @OA\Property(property="token_type", type="string", example="Bearer"),
* @OA\Property(
* property="user",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="王小明"),
* @OA\Property(property="email", type="string", example="user@gmail.com"),
* @OA\Property(property="role", type="string", example="member"),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* )
* )
* ),
* @OA\Response(
* response=422,
* description="OAuth 驗證失敗",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="OAuth 驗證失敗")
* )
* )
* )
*/
public function googleCallback()
{
}
}
+418
View File
@@ -0,0 +1,418 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="會員預約",
* description="會員的預約管理"
* )
* @OA\Tag(
* name="會員評價",
* description="會員的評價管理"
* )
* @OA\Tag(
* name="通知",
* description="站內通知管理(Member / Provider 共用)"
* )
*/
class MemberApiDoc
{
// -----------------------------------------------------------------------
// Member Bookings
// -----------------------------------------------------------------------
/**
* 建立預約
*
* @OA\Post(
* path="/member/bookings",
* summary="建立預約",
* description="會員建立新的課程預約",
* operationId="createBooking",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"offer_id","schedule_id","participants"},
* @OA\Property(property="offer_id", type="integer", example=1, description="課程 ID"),
* @OA\Property(property="schedule_id", type="integer", example=2, description="時段 ID"),
* @OA\Property(property="participants", type="integer", example=2, description="參加人數"),
* @OA\Property(property="note", type="string", nullable=true, example="需要器材租借")
* )
* ),
* @OA\Response(
* response=201,
* description="預約建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約成功"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(
* response=422,
* description="驗證失敗或時段已滿",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* ),
* @OA\Response(
* response=403,
* description="無權限(非 member 角色)",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function createBooking()
{
}
/**
* 取得我的預約列表
*
* @OA\Get(
* path="/member/bookings",
* summary="取得我的預約列表",
* description="分頁回傳當前會員的所有預約",
* operationId="listMemberBookings",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listMemberBookings()
{
}
/**
* 取得單一預約
*
* @OA\Get(
* path="/member/bookings/{id}",
* summary="取得單一預約",
* operationId="getMemberBooking",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限存取", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getMemberBooking()
{
}
/**
* 取消預約
*
* @OA\Delete(
* path="/member/bookings/{id}",
* summary="取消預約",
* description="會員取消自己的預約",
* operationId="cancelBooking",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取消成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已取消")
* )
* ),
* @OA\Response(response=403, description="無權限或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function cancelBooking()
{
}
// -----------------------------------------------------------------------
// Member Reviews
// -----------------------------------------------------------------------
/**
* 建立評價
*
* @OA\Post(
* path="/member/reviews",
* summary="建立評價",
* description="會員對已完成的預約課程提交評價",
* operationId="createReview",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"booking_id","rating"},
* @OA\Property(property="booking_id", type="integer", example=5),
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
* @OA\Property(property="is_anonymous", type="boolean", example=false)
* )
* ),
* @OA\Response(
* response=201,
* description="評價建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="評價已提交"),
* @OA\Property(property="data", ref="#/components/schemas/Review")
* )
* ),
* @OA\Response(response=403, description="預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function createReview()
{
}
/**
* 更新評價
*
* @OA\Put(
* path="/member/reviews/{id}",
* summary="更新評價",
* operationId="updateReview",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=5),
* @OA\Property(property="comment", type="string", nullable=true, example="更新後的評語"),
* @OA\Property(property="is_anonymous", type="boolean", example=true)
* )
* ),
* @OA\Response(
* response=200,
* description="更新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/Review")
* )
* ),
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function updateReview()
{
}
/**
* 刪除評價
*
* @OA\Delete(
* path="/member/reviews/{id}",
* summary="刪除評價",
* operationId="deleteReview",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="評價已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteReview()
{
}
/**
* 標記評價為有幫助
*
* @OA\Post(
* path="/reviews/{id}/helpful",
* summary="標記評價為有幫助",
* description="切換投票狀態(已投票則撤回)",
* operationId="markReviewHelpful",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="操作成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="helpful_count", type="integer", example=4),
* @OA\Property(property="has_voted", type="boolean", example=true)
* )
* ),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function markReviewHelpful()
{
}
// -----------------------------------------------------------------------
// Notifications (Member + Provider 共用)
// -----------------------------------------------------------------------
/**
* 取得通知列表
*
* @OA\Get(
* path="/notifications",
* summary="取得通知列表",
* description="分頁回傳當前使用者的所有通知(Member / Provider 共用)",
* operationId="listNotifications",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="type", type="string", example="booking_confirmed"),
* @OA\Property(property="title", type="string", example="預約已確認"),
* @OA\Property(property="message", type="string", example="您的預約已由教練確認"),
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listNotifications()
{
}
/**
* 取得未讀通知數量
*
* @OA\Get(
* path="/notifications/unread-count",
* summary="取得未讀通知數量",
* operationId="getUnreadNotificationCount",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="unread_count", type="integer", example=3)
* )
* )
* )
*/
public function getUnreadNotificationCount()
{
}
/**
* 標記單一通知為已讀
*
* @OA\Patch(
* path="/notifications/{id}/read",
* summary="標記單一通知為已讀",
* operationId="markNotificationRead",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="通知已標記為已讀")
* )
* ),
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function markNotificationRead()
{
}
/**
* 全部通知標記為已讀
*
* @OA\Patch(
* path="/notifications/read-all",
* summary="全部通知標記為已讀",
* operationId="markAllNotificationsRead",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="所有通知已標記為已讀")
* )
* )
* )
*/
public function markAllNotificationsRead()
{
}
/**
* 刪除通知
*
* @OA\Delete(
* path="/notifications/{id}",
* summary="刪除通知",
* operationId="deleteNotification",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="通知已刪除")
* )
* ),
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteNotification()
{
}
}
+585
View File
@@ -0,0 +1,585 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="教練課程",
* description="服務提供者的課程管理"
* )
* @OA\Tag(
* name="教練圖片",
* description="服務提供者的課程圖片管理"
* )
* @OA\Tag(
* name="教練時段",
* description="服務提供者的課程時段管理"
* )
* @OA\Tag(
* name="教練預約",
* description="服務提供者的預約管理"
* )
*/
class ProviderApiDoc
{
// -----------------------------------------------------------------------
// Provider Offers CRUD
// -----------------------------------------------------------------------
/**
* 取得自己的課程列表
*
* @OA\Get(
* path="/provider/offers",
* summary="取得自己的課程列表",
* description="回傳當前服務提供者的所有課程(分頁)",
* operationId="listProviderOffers",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listProviderOffers()
{
}
/**
* 建立課程
*
* @OA\Post(
* path="/provider/offers",
* summary="建立課程",
* description="服務提供者建立新的潛水課程",
* operationId="createProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"title","description","region","price","max_participants"},
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
* @OA\Property(property="region", type="string", example="墾丁"),
* @OA\Property(property="price", type="number", format="float", example=8500),
* @OA\Property(property="max_participants", type="integer", example=6),
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"})
* )
* ),
* @OA\Response(
* response=201,
* description="課程建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="課程建立成功"),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function createProviderOffer()
{
}
/**
* 取得單一課程(Provider 視角)
*
* @OA\Get(
* path="/provider/offers/{id}",
* summary="取得單一課程(Provider 視角)",
* description="取得自己的指定課程,非本人課程回傳 403",
* operationId="getProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getProviderOffer()
{
}
/**
* 更新課程
*
* @OA\Put(
* path="/provider/offers/{id}",
* summary="更新課程",
* operationId="updateProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="title", type="string", example="進階開放水域課程"),
* @OA\Property(property="description", type="string", example="AOWD 課程"),
* @OA\Property(property="region", type="string", example="小琉球"),
* @OA\Property(property="price", type="number", format="float", example=12000),
* @OA\Property(property="max_participants", type="integer", example=4),
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"進階","AOWD"}),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* ),
* @OA\Response(
* response=200,
* description="更新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function updateProviderOffer()
{
}
/**
* 刪除課程
*
* @OA\Delete(
* path="/provider/offers/{id}",
* summary="刪除課程",
* operationId="deleteProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="課程已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteProviderOffer()
{
}
// -----------------------------------------------------------------------
// Provider Offer Images
// -----------------------------------------------------------------------
/**
* 上傳封面圖片
*
* @OA\Post(
* path="/provider/offers/{id}/cover",
* summary="上傳封面圖片",
* description="上傳課程封面圖片(multipart/form-data",
* operationId="uploadOfferCover",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* required={"cover_image"},
* @OA\Property(property="cover_image", type="string", format="binary", description="封面圖片(jpg/png,最大 5MB")
* )
* )
* ),
* @OA\Response(
* response=200,
* description="上傳成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="cover_image_url", type="string", example="https://example.com/covers/1.jpg")
* )
* ),
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function uploadOfferCover()
{
}
/**
* 刪除封面圖片
*
* @OA\Delete(
* path="/provider/offers/{id}/cover",
* summary="刪除封面圖片",
* operationId="deleteOfferCover",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="封面圖片已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteOfferCover()
{
}
/**
* 上傳相簿圖片
*
* @OA\Post(
* path="/provider/offers/{id}/images",
* summary="上傳相簿圖片",
* description="新增課程相簿圖片(multipart/form-data,可多張)",
* operationId="uploadOfferImages",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* required={"images[]"},
* @OA\Property(
* property="images[]",
* type="array",
* @OA\Items(type="string", format="binary"),
* description="相簿圖片(每張最大 5MB"
* )
* )
* )
* ),
* @OA\Response(
* response=200,
* description="上傳成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={"https://example.com/img/1.jpg"})
* )
* ),
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function uploadOfferImages()
{
}
/**
* 刪除相簿圖片
*
* @OA\Delete(
* path="/provider/images/{id}",
* summary="刪除相簿圖片",
* operationId="deleteOfferImage",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="圖片 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="圖片已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="圖片不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteOfferImage()
{
}
// -----------------------------------------------------------------------
// Provider Schedules
// -----------------------------------------------------------------------
/**
* 取得時段列表
*
* @OA\Get(
* path="/provider/schedules",
* summary="取得時段列表",
* description="回傳服務提供者的所有時段,可依 offer_id 篩選",
* operationId="listProviderSchedules",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="offer_id", in="query", required=false, description="課程 ID 篩選", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CourseSchedule"))
* )
* )
* )
*/
public function listProviderSchedules()
{
}
/**
* 建立時段
*
* @OA\Post(
* path="/provider/schedules",
* summary="建立時段",
* operationId="createProviderSchedule",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"offer_id","start_date","end_date","available_slots"},
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
* @OA\Property(property="available_slots", type="integer", example=4)
* )
* ),
* @OA\Response(
* response=201,
* description="時段建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
* )
* ),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function createProviderSchedule()
{
}
/**
* 更新時段
*
* @OA\Put(
* path="/provider/schedules/{id}",
* summary="更新時段",
* operationId="updateProviderSchedule",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-05"),
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-07"),
* @OA\Property(property="available_slots", type="integer", example=6),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* ),
* @OA\Response(
* response=200,
* description="更新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
* )
* ),
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function updateProviderSchedule()
{
}
/**
* 刪除時段
*
* @OA\Delete(
* path="/provider/schedules/{id}",
* summary="刪除時段",
* operationId="deleteProviderSchedule",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="時段已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteProviderSchedule()
{
}
// -----------------------------------------------------------------------
// Provider Bookings Management
// -----------------------------------------------------------------------
/**
* 取得收到的預約列表
*
* @OA\Get(
* path="/provider/bookings",
* summary="取得收到的預約列表",
* description="分頁回傳服務提供者收到的所有預約",
* operationId="listProviderBookings",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listProviderBookings()
{
}
/**
* 確認預約
*
* @OA\Put(
* path="/provider/bookings/{id}/confirm",
* summary="確認預約",
* operationId="confirmBooking",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="確認成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已確認"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許確認", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function confirmBooking()
{
}
/**
* 拒絕預約
*
* @OA\Put(
* path="/provider/bookings/{id}/reject",
* summary="拒絕預約",
* operationId="rejectBooking",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="拒絕成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已拒絕"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許拒絕", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function rejectBooking()
{
}
/**
* 標記預約完成
*
* @OA\Put(
* path="/provider/bookings/{id}/complete",
* summary="標記預約完成",
* operationId="completeBookingByProvider",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已完成"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function completeBookingByProvider()
{
}
/**
* 取消預約(Provider
*
* @OA\Put(
* path="/provider/bookings/{id}/cancel",
* summary="取消預約(Provider",
* operationId="cancelBookingByProvider",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取消成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已取消"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function cancelBookingByProvider()
{
}
}
+247
View File
@@ -0,0 +1,247 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="公開課程",
* description="無需認證的公開潛水課程查詢端點"
* )
*
* -----------------------------------------------------------------------
* 共用 Schema 定義
* -----------------------------------------------------------------------
*
* @OA\Schema(
* schema="PaginationMeta",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=5),
* @OA\Property(property="per_page", type="integer", example=15),
* @OA\Property(property="total", type="integer", example=72)
* )
*
* @OA\Schema(
* schema="ApiErrorResponse",
* type="object",
* @OA\Property(property="status", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="操作失敗"),
* @OA\Property(property="errors", type="object", nullable=true)
* )
*
* @OA\Schema(
* schema="DivingOffer",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="provider_id", type="integer", example=3),
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
* @OA\Property(property="region", type="string", example="墾丁"),
* @OA\Property(property="price", type="number", format="float", example=8500),
* @OA\Property(property="max_participants", type="integer", example=6),
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"}),
* @OA\Property(property="cover_image_url", type="string", nullable=true, example="https://example.com/image.jpg"),
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={}),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*
* @OA\Schema(
* schema="Review",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
* @OA\Property(property="is_anonymous", type="boolean", example=false),
* @OA\Property(property="helpful_count", type="integer", example=3),
* @OA\Property(property="has_voted", type="boolean", example=false),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*
* @OA\Schema(
* schema="CourseSchedule",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
* @OA\Property(property="available_slots", type="integer", example=4),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*
* @OA\Schema(
* schema="Booking",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="member_id", type="integer", example=5),
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="schedule_id", type="integer", example=2),
* @OA\Property(property="status", type="string", enum={"pending","confirmed","rejected","completed","cancelled"}, example="pending"),
* @OA\Property(property="participants", type="integer", example=2),
* @OA\Property(property="note", type="string", nullable=true, example=""),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*/
class PublicApiDoc
{
/**
* 查詢潛水課程列表
*
* @OA\Get(
* path="/diving-offers",
* summary="查詢潛水課程列表",
* description="支援關鍵字、地區、標籤篩選,回傳分頁結果",
* operationId="listDivingOffers",
* tags={"公開課程"},
* @OA\Parameter(name="q", in="query", description="關鍵字搜尋", @OA\Schema(type="string")),
* @OA\Parameter(name="region", in="query", description="地區篩選", @OA\Schema(type="string", example="墾丁")),
* @OA\Parameter(name="tag", in="query", description="標籤篩選", @OA\Schema(type="string", example="OWD")),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(預設 15,最大 50", @OA\Schema(type="integer", default=15, maximum=50)),
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Response(
* response=200,
* description="查詢成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/DivingOffer")
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listDivingOffers()
{
}
/**
* 取得單一潛水課程
*
* @OA\Get(
* path="/diving-offers/{id}",
* summary="取得單一潛水課程",
* description="回傳課程詳情,包含封面圖片與相簿",
* operationId="getDivingOffer",
* tags={"公開課程"},
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* description="課程 ID",
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(
* response=404,
* description="課程不存在",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function getDivingOffer()
{
}
/**
* 取得課程評價列表
*
* @OA\Get(
* path="/diving-offers/{id}/reviews",
* summary="取得課程評價列表",
* description="回傳評分摘要、分頁評價列表與分頁 meta",
* operationId="listOfferReviews",
* tags={"公開課程"},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Parameter(name="sort", in="query", description="排序方式(newest / helpful", @OA\Schema(type="string", enum={"newest","helpful"}, default="newest")),
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(預設 15,最大 50", @OA\Schema(type="integer", default=15, maximum=50)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(
* property="summary",
* type="object",
* @OA\Property(property="average_rating", type="number", format="float", example=4.2),
* @OA\Property(property="total_reviews", type="integer", example=24),
* @OA\Property(property="distribution", type="object",
* @OA\Property(property="1", type="integer", example=1),
* @OA\Property(property="2", type="integer", example=2),
* @OA\Property(property="3", type="integer", example=3),
* @OA\Property(property="4", type="integer", example=8),
* @OA\Property(property="5", type="integer", example=10)
* )
* ),
* @OA\Property(
* property="reviews",
* type="array",
* @OA\Items(ref="#/components/schemas/Review")
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* ),
* @OA\Response(
* response=404,
* description="課程不存在",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function listOfferReviews()
{
}
/**
* 取得課程時段列表
*
* @OA\Get(
* path="/diving-offers/{id}/schedules",
* summary="取得課程時段列表",
* description="回傳指定課程的所有可用時段",
* operationId="listOfferSchedules",
* tags={"公開課程"},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/CourseSchedule")
* )
* )
* ),
* @OA\Response(
* response=404,
* description="課程不存在",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function listOfferSchedules()
{
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
enum BookingStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Completed = 'completed';
case Rejected = 'rejected';
case Expired = 'expired';
case MemberCancelled = 'member_cancelled';
case ProviderCancelled = 'provider_cancelled';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ScheduleStatus: string
{
case Open = 'open';
case Full = 'full';
case Cancelled = 'cancelled';
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
class AdminBookingController extends Controller
{
public function index()
{
$bookings = Booking::with(['member', 'schedule.divingOffer'])
->orderByDesc('created_at')
->get()
->map(fn($b) => [
'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,
'created_at' => $b->created_at?->toISOString(),
]);
return response()->json(['status' => true, 'data' => $bookings]);
}
public function complete(int $id)
{
$booking = Booking::findOrFail($id);
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
}
$booking->update(['status' => BookingStatus::Completed]);
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
class AdminOfferController extends Controller
{
private function checkAdmin()
{
if (auth()->user()->role !== 'admin') {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
return null;
}
public function index(Request $request)
{
if ($err = $this->checkAdmin()) return $err;
$query = DivingOffer::query();
if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) {
$sub->where('title', 'like', "%{$q}%")
->orWhere('location', 'like', "%{$q}%");
});
}
$paginated = $query->latest('id')->paginate(15);
return response()->json([
'status' => true,
'data' => $paginated->items(),
'meta' => [
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
]);
}
public function destroy(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$offer = DivingOffer::find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
$offer->delete();
return response()->json(['status' => true, 'message' => '課程已刪除']);
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use App\Models\Review;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdminReviewController extends Controller
{
public function index(Request $request)
{
$perPage = min((int) $request->query('per_page', 20), 100);
$paginator = Review::with(['divingOffer', 'member'])
->orderByDesc('created_at')
->paginate($perPage);
$reviews = $paginator->getCollection()->map(fn($r) => [
'id' => $r->id,
'offer_title' => $r->divingOffer?->title,
'member_email' => $r->member?->email,
'rating' => $r->rating,
'comment' => mb_strimwidth($r->comment, 0, 50, '...'),
'is_edited' => $r->is_edited,
'helpful_count'=> $r->helpful_count,
'created_at' => $r->created_at?->toISOString(),
]);
return response()->json([
'status' => true,
'data' => $reviews,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
public function destroy(int $id)
{
$review = Review::findOrFail($id);
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $offerId) {
$review->delete();
$this->recalculateOfferRating($offerId);
});
return response()->json(['status' => true, 'message' => '評價已刪除']);
}
private function recalculateOfferRating(int $offerId): void
{
$stats = Review::where('diving_offer_id', $offerId)
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
->first();
DivingOffer::where('id', $offerId)->update([
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
'reviews' => $stats->total,
]);
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class AdminStatsController extends Controller
{
public function index()
{
if (auth()->user()->role !== 'admin') {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
$stats = Cache::remember('admin_stats', 300, fn() => [
'total_members' => User::where('role', 'member')->count(),
'total_providers' => User::where('role', 'provider')->count(),
'total_offers' => DivingOffer::count(),
]);
return response()->json(['status' => true, 'data' => $stats]);
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
class AdminUserController extends Controller
{
private function checkAdmin()
{
if (auth()->user()->role !== 'admin') {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
return null;
}
private function findUser(int $id, string $role)
{
return User::where('id', $id)->where('role', $role)->first();
}
public function members(Request $request)
{
if ($err = $this->checkAdmin()) return $err;
$query = User::where('role', 'member')->with('memberProfile');
if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) {
$sub->where('name', 'like', "%{$q}%")
->orWhere('email', 'like', "%{$q}%");
});
}
$paginated = $query->latest()->paginate(15);
return response()->json([
'status' => true,
'data' => $paginated->items(),
'meta' => [
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
]);
}
public function member(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'member');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
return response()->json(['status' => true, 'data' => $user->load('memberProfile')]);
}
public function toggleMemberActive(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'member');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
$user->is_active = !$user->is_active;
$user->save();
$msg = $user->is_active ? '帳號已啟用' : '帳號已停用';
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
}
public function providers(Request $request)
{
if ($err = $this->checkAdmin()) return $err;
$query = User::where('role', 'provider')->with('providerProfile');
if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) {
$sub->where('name', 'like', "%{$q}%")
->orWhere('email', 'like', "%{$q}%");
});
}
$paginated = $query->latest()->paginate(15);
return response()->json([
'status' => true,
'data' => $paginated->items(),
'meta' => [
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
]);
}
public function provider(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'provider');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
return response()->json(['status' => true, 'data' => $user->load('providerProfile')]);
}
public function toggleProviderActive(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'provider');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
$user->is_active = !$user->is_active;
$user->save();
$msg = $user->is_active ? '帳號已啟用' : '帳號已停用';
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
}
public function toggleProviderVerified(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'provider');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
$profile = $user->providerProfile;
$profile->is_verified = !$profile->is_verified;
$profile->save();
$msg = $profile->is_verified ? '教練已驗證' : '已取消驗證';
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]);
}
}
+25 -1
View File
@@ -474,7 +474,7 @@ class AuthController extends Controller
'email' => 'required|string|email|max:255|unique:users', 'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:6|confirmed', 'password' => 'required|string|min:6|confirmed',
'phone' => 'nullable|string|max:20', 'phone' => 'nullable|string|max:20',
'business_name' => 'required|string|max:255', 'business_name' => 'nullable|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
'contact_person' => 'nullable|string|max:100', 'contact_person' => 'nullable|string|max:100',
'contact_phone' => 'nullable|string|max:20', 'contact_phone' => 'nullable|string|max:20',
@@ -655,6 +655,12 @@ class AuthController extends Controller
'contact_email' => 'nullable|string|email|max:255', 'contact_email' => 'nullable|string|email|max:255',
'address' => 'nullable|string|max:255', 'address' => 'nullable|string|max:255',
'business_hours' => 'nullable|string|max:100', 'business_hours' => 'nullable|string|max:100',
'certifications' => 'nullable|string',
'dive_sites' => 'nullable|string',
'services' => 'nullable|string',
'facilities' => 'nullable|string',
'website' => 'nullable|string|max:255',
'social_media' => 'nullable|string|max:255',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@@ -701,6 +707,24 @@ class AuthController extends Controller
if ($request->has('business_hours')) { if ($request->has('business_hours')) {
$providerProfile->business_hours = $request->business_hours; $providerProfile->business_hours = $request->business_hours;
} }
if ($request->has('certifications')) {
$providerProfile->certifications = $request->certifications;
}
if ($request->has('dive_sites')) {
$providerProfile->dive_sites = $request->dive_sites;
}
if ($request->has('services')) {
$providerProfile->services = $request->services;
}
if ($request->has('facilities')) {
$providerProfile->facilities = $request->facilities;
}
if ($request->has('website')) {
$providerProfile->website = $request->website;
}
if ($request->has('social_media')) {
$providerProfile->social_media = $request->social_media;
}
$providerProfile->save(); $providerProfile->save();
@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\CourseImage;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class CourseImageController extends Controller
{
private function validateImage(Request $request): void
{
$request->validate([
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
]);
}
private function authorizeOffer(Request $request, DivingOffer $offer): void
{
if ($offer->provider_id !== $request->user()->id) {
abort(403, '無權操作此課程');
}
}
public function uploadCover(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$this->authorizeOffer($request, $offer);
$this->validateImage($request);
if ($offer->cover_image) {
Storage::disk('public')->delete($offer->cover_image);
}
$path = $request->file('image')->store("offers/{$offerId}/cover", 'public');
$offer->update(['cover_image' => $path]);
return response()->json([
'status' => true,
'message' => '封面已上傳',
'cover_image_url' => $offer->cover_image_url,
]);
}
public function deleteCover(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$this->authorizeOffer($request, $offer);
if ($offer->cover_image) {
Storage::disk('public')->delete($offer->cover_image);
$offer->update(['cover_image' => null]);
}
return response()->json(['status' => true, 'message' => '封面已刪除']);
}
public function uploadImage(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$this->authorizeOffer($request, $offer);
$this->validateImage($request);
if ($offer->courseImages()->count() >= 3) {
return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422);
}
$path = $request->file('image')->store("offers/{$offerId}/gallery", 'public');
$sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1;
$image = CourseImage::create([
'diving_offer_id' => $offerId,
'image_path' => $path,
'sort_order' => $sortOrder,
]);
return response()->json([
'status' => true,
'message' => '圖片已上傳',
'data' => ['id' => $image->id, 'url' => $image->url, 'sort_order' => $image->sort_order],
], 201);
}
public function deleteImage(Request $request, int $imageId)
{
$image = CourseImage::with('divingOffer')->findOrFail($imageId);
if ($image->divingOffer->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權刪除此圖片'], 403);
}
Storage::disk('public')->delete($image->image_path);
$image->delete();
return response()->json(['status' => true, 'message' => '圖片已刪除']);
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class DivingOfferController extends Controller
{
public function index(Request $request)
{
$perPage = min((int) $request->query('per_page', 12), 50);
$cacheKey = 'diving_offers_' . md5(serialize($request->all()));
$result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
$query = DivingOffer::query();
if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) {
$sub->where('title', 'like', "%{$q}%")
->orWhere('location', 'like', "%{$q}%")
->orWhere('spot', 'like', "%{$q}%");
});
}
if ($region = $request->query('region')) {
$query->where('region', $region);
}
if ($tag = $request->query('tag')) {
$query->where('tag', 'like', "%{$tag}%");
}
$paginated = $query->paginate($perPage);
return [
'items' => collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false))->values(),
'meta' => [
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
];
});
return response()->json([
'status' => true,
'data' => $result['items'],
'meta' => $result['meta'],
]);
}
public function show(int $id)
{
$offer = DivingOffer::with('courseImages')->find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
return response()->json(['status' => true, 'data' => $this->formatOffer($offer, true)]);
}
private function formatOffer(DivingOffer $offer, bool $withImages): array
{
$data = array_merge($offer->toArray(), [
'cover_image_url' => $offer->cover_image_url,
]);
if ($withImages) {
$data['images'] = $offer->courseImages->map(fn($img) => [
'id' => $img->id,
'url' => $img->url,
'sort_order' => $img->sort_order,
])->values();
}
return $data;
}
}
@@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Notifications\BookingCreatedNotification;
use App\Notifications\BookingCancelledNotification;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class MemberBookingController extends Controller
{
public function index(Request $request)
{
$bookings = Booking::with(['schedule.divingOffer'])
->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 2lockForUpdate 後二次驗證
$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);
}
try {
$booking->load('schedule.divingOffer.provider');
$provider = $booking->schedule->divingOffer->provider;
$provider->notify(new BookingCreatedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingCreatedNotification failed: ' . $e->getMessage());
}
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]);
}
}
});
try {
$booking->load('schedule.divingOffer.provider');
$provider = $booking->schedule->divingOffer->provider;
$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'));
} catch (\Throwable $e) {
Log::error('BookingCancelledNotification(member) failed: ' . $e->getMessage());
}
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(),
];
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request)
{
try {
$user = $request->user();
$unreadCount = $user->unreadNotifications()->count();
$notifications = $user->notifications()
->orderByDesc('created_at')
->paginate(20);
$items = $notifications->map(fn($n) => array_merge($n->data, [
'id' => $n->id,
'read_at' => $n->read_at?->toISOString(),
'created_at' => $n->created_at->toISOString(),
]));
return response()->json([
'status' => true,
'data' => $items,
'unread_count' => $unreadCount,
'meta' => [
'current_page' => $notifications->currentPage(),
'last_page' => $notifications->lastPage(),
'total' => $notifications->total(),
],
]);
} catch (\Throwable) {
return response()->json(['status' => true, 'data' => [], 'unread_count' => 0, 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0]]);
}
}
public function unreadCount(Request $request)
{
try {
return response()->json([
'status' => true,
'data' => ['count' => $request->user()->unreadNotifications()->count()],
]);
} catch (\Throwable) {
return response()->json(['status' => true, 'data' => ['count' => 0]]);
}
}
public function markRead(Request $request, string $id)
{
$notification = $request->user()->notifications()->findOrFail($id);
$notification->markAsRead();
return response()->json(['status' => true]);
}
public function markAllRead(Request $request)
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['status' => true]);
}
public function destroy(Request $request, string $id)
{
$notification = $request->user()->notifications()->findOrFail($id);
$notification->delete();
return response()->json(null, 204);
}
}
@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Notifications\BookingCancelledNotification;
use App\Notifications\BookingCompletedNotification;
use App\Notifications\BookingConfirmedNotification;
use App\Notifications\BookingRejectedNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ProviderBookingController extends Controller
{
public function index(Request $request)
{
$provider = $request->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);
}
try {
$booking->load('member');
$booking->member->notify(new BookingConfirmedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingConfirmedNotification failed: ' . $e->getMessage());
}
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]);
try {
$booking->load('member');
$booking->member->notify(new BookingRejectedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingRejectedNotification failed: ' . $e->getMessage());
}
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]);
}
});
try {
$booking->load('member');
$booking->member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'));
} catch (\Throwable $e) {
Log::error('BookingCancelledNotification(provider) failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '預約已取消']);
}
public function complete(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
$this->authorizeProvider($request, $booking);
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
}
$booking->update(['status' => BookingStatus::Completed]);
try {
$booking->load('member');
$booking->member->notify(new BookingCompletedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingCompletedNotification failed: ' . $e->getMessage());
}
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(),
];
}
}
@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ProviderOfferController extends Controller
{
public function index()
{
$offers = DivingOffer::where('provider_id', auth()->id())
->paginate(12);
return response()->json([
'status' => true,
'data' => $offers->items(),
'meta' => [
'total' => $offers->total(),
'per_page' => $offers->perPage(),
'current_page' => $offers->currentPage(),
'last_page' => $offers->lastPage(),
],
]);
}
public function show(int $id)
{
$offer = DivingOffer::find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
if ($offer->provider_id !== auth()->id()) {
return response()->json(['status' => false, 'message' => '無權限查看此課程'], 403);
}
return response()->json(['status' => true, 'data' => $offer]);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'location' => 'required|string|max:255',
'spot' => 'nullable|string|max:255',
'price' => 'required|integer|min:0',
'region' => 'required|string|max:100',
'tag' => 'nullable|string|max:100',
'badges' => 'nullable|array',
'badges.*' => 'string|max:50',
'description' => 'nullable|string',
]);
$validated['provider_id'] = auth()->id();
$validated['rating'] = 0;
$validated['reviews'] = 0;
$offer = DivingOffer::create($validated);
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'data' => $offer], 201);
}
public function update(Request $request, int $id)
{
$offer = DivingOffer::find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
if ($offer->provider_id !== auth()->id()) {
return response()->json(['status' => false, 'message' => '無權限修改此課程'], 403);
}
$validated = $request->validate([
'title' => 'nullable|string|max:255',
'location' => 'nullable|string|max:255',
'spot' => 'nullable|string|max:255',
'price' => 'nullable|integer|min:0',
'region' => 'nullable|string|max:100',
'tag' => 'nullable|string|max:100',
'badges' => 'nullable|array',
'badges.*' => 'string|max:50',
'description' => 'nullable|string',
]);
$offer->fill($validated)->save();
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'data' => $offer]);
}
public function destroy(int $id)
{
$offer = DivingOffer::find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
if ($offer->provider_id !== auth()->id()) {
return response()->json(['status' => false, 'message' => '無權限刪除此課程'], 403);
}
$offer->delete();
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'message' => '課程已刪除']);
}
}
@@ -0,0 +1,257 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\DivingOffer;
use App\Models\Review;
use App\Models\ReviewEdit;
use App\Models\ReviewVote;
use App\Notifications\ReviewReceivedNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ReviewController extends Controller
{
// ── 公開列表 ──────────────────────────────────────────────
public function publicList(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$user = $request->user();
$perPage = min((int) $request->query('per_page', 20), 50);
$sort = $request->query('sort', 'helpful');
$query = Review::with('votes')->where('diving_offer_id', $offer->id);
match ($sort) {
'rating' => $query->orderByDesc('rating')->orderByDesc('created_at'),
'newest' => $query->orderByDesc('created_at'),
default => $query->orderByDesc('helpful_count')->orderByDesc('created_at'),
};
$paginator = $query->paginate($perPage);
$reviews = $paginator->getCollection();
$memberId = $user?->id;
$distribution = Cache::remember("offer_review_distribution_{$offerId}", 600, function () use ($offerId) {
$distRaw = Review::where('diving_offer_id', $offerId)
->selectRaw('rating, COUNT(*) as cnt')
->groupBy('rating')
->pluck('cnt', 'rating');
return collect([1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0])->merge($distRaw);
});
$total = $paginator->total();
$average = $total > 0 ? round(
Review::where('diving_offer_id', $offerId)->avg('rating'), 1
) : 0;
$formatted = $reviews->map(function ($r) use ($user, $memberId) {
$item = [
'id' => $r->id,
'reviewer_name' => '匿名潛水者',
'rating' => $r->rating,
'comment' => $r->comment,
'helpful_count' => $r->helpful_count,
'is_edited' => $r->is_edited,
'created_at' => $r->created_at?->toISOString(),
'has_voted' => $memberId
? $r->votes->contains('member_id', $memberId)
: false,
];
if ($user) {
$item['is_mine'] = $r->member_id === $memberId;
}
return $item;
});
return response()->json([
'status' => true,
'data' => [
'summary' => [
'average' => $average,
'total' => $total,
'distribution' => $distribution,
],
'reviews' => $formatted,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
],
]);
}
// ── Member CRUD ───────────────────────────────────────────
public function store(Request $request)
{
$data = $request->validate([
'diving_offer_id' => 'required|integer|exists:diving_offers,id',
'rating' => 'required|integer|min:1|max:5',
'comment' => 'required|string|min:1',
]);
$memberId = $request->user()->id;
$offerId = $data['diving_offer_id'];
// 資格驗證:有 completed booking
$eligible = Booking::where('member_id', $memberId)
->whereHas('schedule', fn($q) => $q->where('diving_offer_id', $offerId))
->where('status', BookingStatus::Completed->value)
->exists();
if (!$eligible) {
return response()->json(['status' => false, 'message' => '須完成此課程後才能評價'], 403);
}
// 重複評價檢查
if (Review::where('member_id', $memberId)->where('diving_offer_id', $offerId)->exists()) {
return response()->json(['status' => false, 'message' => '已評價,如需修改請使用編輯功能'], 422);
}
$review = DB::transaction(function () use ($data, $memberId, $offerId) {
$review = Review::create([
'diving_offer_id' => $offerId,
'member_id' => $memberId,
'rating' => $data['rating'],
'comment' => $data['comment'],
]);
$this->recalculateOfferRating($offerId);
return $review;
});
Cache::forget("offer_review_distribution_{$offerId}");
try {
$offer = DivingOffer::with('provider')->findOrFail($offerId);
$provider = $offer->provider;
if ($provider) {
$provider->notify(new ReviewReceivedNotification($review));
}
} catch (\Throwable $e) {
Log::error('ReviewReceivedNotification failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '評價已送出', 'data' => $this->formatReview($review)], 201);
}
public function update(Request $request, int $id)
{
$review = Review::findOrFail($id);
if ($review->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權修改此評價'], 403);
}
$data = $request->validate([
'rating' => 'sometimes|integer|min:1|max:5',
'comment' => 'sometimes|string|min:1',
]);
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $data) {
ReviewEdit::updateOrCreate(
['review_id' => $review->id],
['old_rating' => $review->rating, 'old_comment' => $review->comment, 'edited_at' => now()]
);
$review->update(array_merge($data, ['is_edited' => true]));
$this->recalculateOfferRating($review->diving_offer_id);
});
Cache::forget("offer_review_distribution_{$offerId}");
return response()->json(['status' => true, 'message' => '評價已更新', 'data' => $this->formatReview($review->fresh())]);
}
public function destroy(Request $request, int $id)
{
$review = Review::findOrFail($id);
if ($review->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權刪除此評價'], 403);
}
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $offerId) {
$review->delete();
$this->recalculateOfferRating($offerId);
});
Cache::forget("offer_review_distribution_{$offerId}");
return response()->json(['status' => true, 'message' => '評價已刪除']);
}
// ── 有幫助投票 ────────────────────────────────────────────
public function toggleHelpful(Request $request, int $id)
{
if (!$request->user()->isMember()) {
return response()->json(['status' => false, 'message' => '只有會員可以投票'], 403);
}
$review = Review::findOrFail($id);
$memberId = $request->user()->id;
if ($review->member_id === $memberId) {
return response()->json(['status' => false, 'message' => '不可對自己的評價投票'], 422);
}
DB::transaction(function () use ($review, $memberId) {
$vote = ReviewVote::where('review_id', $review->id)
->where('member_id', $memberId)
->first();
if ($vote) {
$vote->delete();
DB::table('reviews')
->where('id', $review->id)
->where('helpful_count', '>', 0)
->decrement('helpful_count');
} else {
ReviewVote::create(['review_id' => $review->id, 'member_id' => $memberId, 'created_at' => now()]);
$review->increment('helpful_count');
}
});
$review->refresh();
$hasVoted = ReviewVote::where('review_id', $review->id)->where('member_id', $memberId)->exists();
return response()->json(['status' => true, 'data' => ['helpful_count' => $review->helpful_count, 'has_voted' => $hasVoted]]);
}
// ── 私有方法 ──────────────────────────────────────────────
private function recalculateOfferRating(int $offerId): void
{
$stats = Review::where('diving_offer_id', $offerId)
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
->first();
DivingOffer::where('id', $offerId)->update([
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
'reviews' => $stats->total,
]);
}
private function formatReview(Review $r): array
{
return [
'id' => $r->id,
'reviewer_name' => '匿名潛水者',
'rating' => $r->rating,
'comment' => $r->comment,
'helpful_count' => $r->helpful_count,
'is_edited' => $r->is_edited,
'created_at' => $r->created_at?->toISOString(),
];
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Http\Controllers\Controller;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ScheduleController extends Controller
{
public function index(Request $request)
{
$provider = $request->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,
];
}
}
@@ -106,23 +106,9 @@ class SocialAuthController extends Controller
// 生成 Sanctum token // 生成 Sanctum token
$token = $user->createToken('google-auth')->plainTextToken; $token = $user->createToken('google-auth')->plainTextToken;
// 載入會員資料 return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token);
$user->load('memberProfile');
return response()->json([
'status' => true,
'message' => 'Google 登入成功',
'data' => [
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
]
]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed');
'status' => false,
'message' => 'Google 登入失敗:' . $e->getMessage()
], 500);
} }
} }
} }
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user() || !$request->user()->isAdmin()) {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
return $next($request);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use App\Enums\BookingStatus;
use Illuminate\Database\Eloquent\Model;
class Booking extends Model
{
protected $fillable = [
'schedule_id',
'member_id',
'participants',
'total_price',
'status',
'notes',
];
protected $casts = [
'participants' => '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');
}
}
+1 -17
View File
@@ -4,23 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** // 該模型已棄用,請使用ProviderProfile模型
* @OA\Schema(
* schema="CoachProfile",
* title="教練個人資料",
* description="教練的詳細個人資料",
* @OA\Property(property="id", type="integer", format="int64", example=1, description="資料ID"),
* @OA\Property(property="user_id", type="integer", format="int64", example=1, description="關聯的使用者ID"),
* @OA\Property(property="bio", type="string", example="專業潛水教練,擁有10年教學經驗", description="個人簡介"),
* @OA\Property(property="expertise", type="string", example="自由潛水,水肺潛水", description="專長領域"),
* @OA\Property(property="certification", type="string", example="PADI專業潛水教練", description="證照資訊"),
* @OA\Property(property="experience", type="string", example="10年教學經驗,帶領超過500名學員", description="教學經驗"),
* @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"),
* @OA\Property(property="availability", type="string", example="週一至週五 09:00-18:00", description="可授課時間"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2023-01-01T00:00:00.000000Z", description="創建時間"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2023-01-01T00:00:00.000000Z", description="更新時間")
* )
*/
class CoachProfile extends Model class CoachProfile extends Model
{ {
/** /**
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class CourseImage extends Model
{
public $timestamps = false;
const CREATED_AT = 'created_at';
protected $fillable = [
'diving_offer_id',
'image_path',
'sort_order',
];
protected $casts = [
'sort_order' => 'integer',
'created_at' => 'datetime',
];
public function divingOffer()
{
return $this->belongsTo(DivingOffer::class);
}
public function getUrlAttribute(): string
{
return Storage::disk('public')->url($this->image_path);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Enums\ScheduleStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class CourseSchedule extends Model
{
protected $fillable = [
'diving_offer_id',
'provider_id',
'scheduled_date',
'start_time',
'max_participants',
'current_participants',
'status',
];
protected $casts = [
'scheduled_date' => '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,
);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class DivingOffer extends Model
{
public $timestamps = false;
protected $table = 'diving_offers';
protected $fillable = [
'provider_id',
'title',
'location',
'spot',
'rating',
'reviews',
'price',
'badges',
'description',
'tag',
'region',
'cover_image',
];
protected $casts = [
'badges' => 'array',
'rating' => 'float',
'price' => 'integer',
'reviews'=> 'integer',
];
protected static function booted(): void
{
static::deleting(function ($offer) {
Storage::disk('public')->deleteDirectory("offers/{$offer->id}");
});
}
public function getCoverImageUrlAttribute(): ?string
{
return $this->cover_image
? Storage::disk('public')->url($this->cover_image)
: null;
}
public function provider(): BelongsTo
{
return $this->belongsTo(User::class, 'provider_id');
}
public function schedules()
{
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
}
public function courseImages()
{
return $this->hasMany(CourseImage::class)->orderBy('sort_order');
}
public function reviews()
{
return $this->hasMany(Review::class);
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ use Illuminate\Database\Eloquent\Model;
/** /**
* @OA\Schema( * @OA\Schema(
* schema="ProviderProfile", * schema="ProviderProfile",
* title="潛水業者資料", * title="服務提供者資料",
* description="潛水業者的詳細資料", * description="服務提供者的詳細資料",
* @OA\Property(property="id", type="integer", format="int64", example=1, description="資料ID"), * @OA\Property(property="id", type="integer", format="int64", example=1, description="資料ID"),
* @OA\Property(property="user_id", type="integer", format="int64", example=1, description="關聯的使用者ID"), * @OA\Property(property="user_id", type="integer", format="int64", example=1, description="關聯的使用者ID"),
* @OA\Property(property="business_name", type="string", example="藍海潛水中心", description="業者名稱"), * @OA\Property(property="business_name", type="string", example="藍海潛水中心", description="業者名稱"),
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Review extends Model
{
protected $fillable = [
'diving_offer_id',
'member_id',
'rating',
'comment',
'helpful_count',
'is_edited',
];
protected $casts = [
'rating' => 'integer',
'helpful_count' => 'integer',
'is_edited' => 'boolean',
];
public function divingOffer()
{
return $this->belongsTo(DivingOffer::class);
}
public function member()
{
return $this->belongsTo(User::class, 'member_id');
}
public function edit()
{
return $this->hasOne(ReviewEdit::class);
}
public function votes()
{
return $this->hasMany(ReviewVote::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReviewEdit extends Model
{
public $timestamps = false;
protected $fillable = [
'review_id',
'old_rating',
'old_comment',
'edited_at',
];
protected $casts = [
'old_rating' => 'integer',
'edited_at' => 'datetime',
];
public function review()
{
return $this->belongsTo(Review::class);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReviewVote extends Model
{
public $timestamps = false;
protected $fillable = [
'review_id',
'member_id',
'created_at',
];
public function review()
{
return $this->belongsTo(Review::class);
}
public function member()
{
return $this->belongsTo(User::class, 'member_id');
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCancelledNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(
public readonly Booking $booking,
public readonly string $cancelledBy,
) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
if ($this->cancelledBy === 'member') {
return [
'type' => 'booking_cancelled',
'title' => '學員取消了預約',
'body' => "學員已取消《{$offer->title}》的預約(時段:{$date}",
'action_url' => config('app.frontend_url') . '/coach/bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
return [
'type' => 'booking_cancelled',
'title' => '教練取消了你的預約',
'body' => "教練已取消你的《{$offer->title}》預約(時段:{$date}),如有疑問請聯繫教練",
'action_url' => config('app.frontend_url') . '/my-bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
if ($this->cancelledBy === 'member') {
return (new MailMessage)
->subject('預約已取消 — CFDivePlatform')
->greeting('通知')
->line("學員已取消《{$offer->title}》的預約(時段:{$date})。")
->action('查看所有預約', config('app.frontend_url') . '/coach/bookings')
->salutation('CFDivePlatform 團隊');
}
return (new MailMessage)
->subject('你的預約已取消 — CFDivePlatform')
->greeting('通知')
->line("教練已取消你的《{$offer->title}》預約(時段:{$date})。如有疑問,請聯繫課程教練。")
->action('查看預約', config('app.frontend_url') . '/my-bookings')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCompletedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
return [
'type' => 'booking_completed',
'title' => '課程已完成,歡迎留下評價',
'body' => "你的《{$offer->title}》課程已完成,歡迎分享你的學習心得!",
'action_url' => config('app.frontend_url') . '/courses/' . $offer->id,
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$url = config('app.frontend_url') . '/courses/' . $offer->id;
return (new MailMessage)
->subject('課程完成,歡迎留下評價 — CFDivePlatform')
->greeting('恭喜完成課程!')
->line("你的《{$offer->title}》課程已完成。")
->action('前往評價', $url)
->line('你的評價對其他學員非常有幫助,感謝你的分享!')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingConfirmedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
return [
'type' => 'booking_confirmed',
'title' => '預約已確認',
'body' => "你的《{$offer->title}》課程預約已由教練確認(時段:{$date}",
'action_url' => config('app.frontend_url') . '/my-bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
$url = config('app.frontend_url') . '/my-bookings';
return (new MailMessage)
->subject('你的預約已確認 — CFDivePlatform')
->greeting('好消息!')
->line("你的《{$offer->title}》課程預約已由教練確認(時段:{$date})。")
->action('查看預約', $url)
->line('請準時出席,如需取消請至少提前 24 小時操作。')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCreatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
return [
'type' => 'booking_created',
'title' => '你有新的預約申請',
'body' => "學員申請了《{$offer->title}》的預約(時段:{$date}",
'action_url' => config('app.frontend_url') . '/coach/bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
$url = config('app.frontend_url') . '/coach/bookings';
return (new MailMessage)
->subject('你有新的預約申請 — CFDivePlatform')
->greeting('你好!')
->line("學員申請了《{$offer->title}》的預約(時段:{$date})。")
->action('查看預約', $url)
->line('請盡快確認或拒絕此預約申請。')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingRejectedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
return [
'type' => 'booking_rejected',
'title' => '預約申請未通過',
'body' => "你的《{$offer->title}》預約申請(時段:{$date})未通過,請選擇其他時段",
'action_url' => config('app.frontend_url') . '/my-bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
$url = config('app.frontend_url') . '/courses';
return (new MailMessage)
->subject('你的預約申請未通過 — CFDivePlatform')
->greeting('通知')
->line("很抱歉,你的《{$offer->title}》預約申請(時段:{$date})未通過審核。")
->action('瀏覽其他課程', $url)
->line('如有疑問,請聯繫課程教練。')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Notifications;
use App\Models\Review;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class ReviewReceivedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Review $review) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
$offer = $this->review->divingOffer;
return [
'type' => 'review_received',
'title' => '你收到了一則新評價',
'body' => "{$offer->title}》收到了 {$this->review->rating} 星評價",
'action_url' => config('app.frontend_url') . '/coach/reviews',
'related_id' => $this->review->id,
'related_type' => 'review',
];
}
}
+3 -1
View File
@@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
// $middleware->alias([
'admin' => \App\Http\Middleware\EnsureAdmin::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
// //
+2 -1
View File
@@ -10,7 +10,8 @@
"laravel/framework": "^11.0", "laravel/framework": "^11.0",
"laravel/sanctum": "^4.1", "laravel/sanctum": "^4.1",
"laravel/socialite": "^5.20", "laravel/socialite": "^5.20",
"laravel/tinker": "^2.9" "laravel/tinker": "^2.9",
"predis/predis": "^3.4"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
Generated
+67 -4
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "cd7975455a8ac2a316ac819eaed700e8", "content-hash": "c7d653b10f8ee748e5662242029651ce",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -3242,6 +3242,69 @@
], ],
"time": "2024-12-14T21:12:59+00:00" "time": "2024-12-14T21:12:59+00:00"
}, },
{
"name": "predis/predis",
"version": "v3.4.2",
"source": {
"type": "git",
"url": "https://github.com/predis/predis.git",
"reference": "2033429520d8997a7815a2485f56abe6d2d0e075"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/predis/predis/zipball/2033429520d8997a7815a2485f56abe6d2d0e075",
"reference": "2033429520d8997a7815a2485f56abe6d2d0e075",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0",
"psr/http-message": "^1.0|^2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.3",
"phpstan/phpstan": "^1.9",
"phpunit/phpcov": "^6.0 || ^8.0",
"phpunit/phpunit": "^8.0 || ~9.4.4"
},
"suggest": {
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
},
"type": "library",
"autoload": {
"psr-4": {
"Predis\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Till Krüss",
"homepage": "https://till.im",
"role": "Maintainer"
}
],
"description": "A flexible and feature-complete Redis/Valkey client for PHP.",
"homepage": "http://github.com/predis/predis",
"keywords": [
"nosql",
"predis",
"redis"
],
"support": {
"issues": "https://github.com/predis/predis/issues",
"source": "https://github.com/predis/predis/tree/v3.4.2"
},
"funding": [
{
"url": "https://github.com/sponsors/tillkruss",
"type": "github"
}
],
"time": "2026-03-09T20:33:04+00:00"
},
{ {
"name": "psr/cache", "name": "psr/cache",
"version": "3.0.0", "version": "3.0.0",
@@ -9166,12 +9229,12 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": [], "stability-flags": {},
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.2" "php": "^8.2"
}, },
"platform-dev": [], "platform-dev": {},
"plugin-api-version": "2.3.0" "plugin-api-version": "2.6.0"
} }
+2
View File
@@ -15,6 +15,8 @@ return [
'name' => env('APP_NAME', 'Laravel'), 'name' => env('APP_NAME', 'Laravel'),
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Environment | Application Environment
+38
View File
@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_origins' => [
env('FRONTEND_URL', 'http://localhost:5173'),
'http://127.0.0.1:5173',
'http://localhost:5173',
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->unsignedBigInteger('provider_id')->nullable()->after('id');
$table->foreign('provider_id')->references('id')->on('users')->onDelete('set null');
});
}
public function down(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->dropForeign(['provider_id']);
$table->dropColumn('provider_id');
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
}
public function down(): void
{
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->string('spot')->nullable()->change();
});
}
public function down(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->string('spot')->nullable(false)->change();
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('course_schedules', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bookings', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
$table->tinyInteger('rating')->unsigned();
$table->text('comment');
$table->unsignedInteger('helpful_count')->default(0);
$table->boolean('is_edited')->default(false);
$table->timestamps();
$table->unique(['member_id', 'diving_offer_id']);
$table->index(['diving_offer_id', 'helpful_count'], 'idx_reviews_helpful');
$table->index(['diving_offer_id', 'rating'], 'idx_reviews_rating');
$table->index(['diving_offer_id', 'created_at'], 'idx_reviews_newest');
$table->index(['member_id', 'diving_offer_id'], 'idx_reviews_member_offer');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reviews');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('review_edits', function (Blueprint $table) {
$table->id();
$table->foreignId('review_id')->unique()->constrained('reviews')->cascadeOnDelete();
$table->tinyInteger('old_rating')->unsigned();
$table->text('old_comment');
$table->timestamp('edited_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('review_edits');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('review_votes', function (Blueprint $table) {
$table->id();
$table->foreignId('review_id')->constrained('reviews')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->unique(['review_id', 'member_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('review_votes');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->string('cover_image', 500)->nullable()->after('description');
});
}
public function down(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->dropColumn('cover_image');
});
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('course_images', function (Blueprint $table) {
$table->id();
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
$table->string('image_path', 500);
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->index(['diving_offer_id', 'sort_order'], 'idx_course_images_offer_sort');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('course_images');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('failed_jobs');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('notifications', function (Blueprint $table) {
$table->index(['notifiable_type', 'notifiable_id', 'read_at'], 'notifications_notifiable_read_at_index');
});
Schema::table('diving_offers', function (Blueprint $table) {
$table->index('provider_id', 'diving_offers_provider_id_index');
});
}
public function down(): void
{
Schema::table('notifications', function (Blueprint $table) {
$table->dropIndex('notifications_notifiable_read_at_index');
});
Schema::table('diving_offers', function (Blueprint $table) {
$table->dropIndex('diving_offers_provider_id_index');
});
}
};
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Database\Seeders;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Models\AdminProfile;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\MemberProfile;
use App\Models\ProviderProfile;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DevelopmentSeeder extends Seeder
{
public function run(): void
{
// Admin
$admin = User::firstOrCreate(['email' => 'admin@cfdive.com'], [
'name' => '平台管理員', 'password' => Hash::make('password123'),
'role' => 'admin', 'is_active' => true,
]);
AdminProfile::firstOrCreate(['user_id' => $admin->id], ['department' => '營運']);
// Coach
$coach = User::firstOrCreate(['email' => 'coach@cfdive.com'], [
'name' => '蔡教練', 'password' => Hash::make('password123'),
'role' => 'provider', 'is_active' => true,
]);
ProviderProfile::firstOrCreate(['user_id' => $coach->id], [
'business_name' => '藍海潛水工作室',
'description' => '專業 PADI 認證教練,10 年教學經驗',
'contact_phone' => '0912345678',
'contact_email' => 'coach@cfdive.com',
'is_verified' => true,
]);
// Member
$member = User::firstOrCreate(['email' => 'member@cfdive.com'], [
'name' => '測試會員', 'password' => Hash::make('password123'),
'role' => 'member', 'is_active' => true,
]);
MemberProfile::firstOrCreate(['user_id' => $member->id], ['gender' => 'male']);
// Offers
$offer = DivingOffer::firstOrCreate(
['title' => '潛入海底 — 入門體驗', 'provider_id' => $coach->id],
[
'location' => '墾丁', 'spot' => '南灣', 'price' => 6000,
'region' => '南部', 'tag' => '初學者',
'badges' => ['PADI認證', '含裝備'],
'description' => '適合零基礎的水肺潛水入門課程,由專業教練全程陪同。',
'rating' => 0, 'reviews' => 0,
]
);
DivingOffer::firstOrCreate(
['title' => '進階深潛探索', 'provider_id' => $coach->id],
[
'location' => '小琉球', 'spot' => '美人洞', 'price' => 9800,
'region' => '南部', 'tag' => '進階',
'badges' => ['AOW認證', '含住宿'],
'description' => '探索 30 米深海,適合已有 OW 認證的潛水愛好者。',
'rating' => 0, 'reviews' => 0,
]
);
// 未來時段(開放預約)
$futureSchedule = CourseSchedule::firstOrCreate(
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->addDays(14)->toDateString()],
[
'provider_id' => $coach->id, 'start_time' => '09:00',
'max_participants' => 5, 'current_participants' => 0,
'status' => ScheduleStatus::Open,
]
);
// 過去時段(供測試 completed booking
$pastSchedule = CourseSchedule::firstOrCreate(
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->subDays(7)->toDateString()],
[
'provider_id' => $coach->id, 'start_time' => '09:00',
'max_participants' => 5, 'current_participants' => 1,
'status' => ScheduleStatus::Open,
]
);
// Pending booking(未來)
Booking::firstOrCreate(
['schedule_id' => $futureSchedule->id, 'member_id' => $member->id],
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Pending]
);
// Completed booking(可評價)
Booking::firstOrCreate(
['schedule_id' => $pastSchedule->id, 'member_id' => $member->id],
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Completed]
);
$this->command->info('✅ Seed 完成');
$this->command->info(' Admin: admin@cfdive.com / password123');
$this->command->info(' Coach: coach@cfdive.com / password123');
$this->command->info(' Member: member@cfdive.com / password123');
}
}
+165
View File
@@ -0,0 +1,165 @@
services:
# PHP-FPM 服務
app:
# 構建配置
build:
context: . # 使用當前目錄作為構建上下文
dockerfile: Dockerfile # 指定 Dockerfile 路徑
image: cfdive-platform # 構建的鏡像名稱
container_name: cfdive-app # 容器名稱
restart: unless-stopped # 自動重啟策略:除非手動停止,否則自動重啟
working_dir: /var/www/ # 工作目錄
# 卷掛載:將本地代碼掛載到容器中
volumes:
- ./:/var/www # 本地代碼目錄掛載到容器中的 /var/www
# 環境變數:設置 Laravel 數據庫連接
environment:
- DB_CONNECTION=mysql # 數據庫類型
- DB_HOST=db # 數據庫主機名
- DB_PORT=3306 # 數據庫端口
- DB_DATABASE=CFDivePlatform # 數據庫名稱
- DB_USERNAME=cfdiveuser # 數據庫用戶名
- DB_PASSWORD=cfdivepass # 數據庫密碼
# 網絡配置
networks:
- cfdive-network # 連接到自定義網絡
- proxy_net
# 依賴關係:確保 db 和 redis 服務先啟動
depends_on:
db: # 依賴 db 服務
condition: service_healthy # 等待數據庫健康檢查通過
redis: # 依賴 redis 服務
condition: service_started # 等待服務啟動
# 健康檢查配置
healthcheck:
test: ["CMD", "php", "-v"] # 檢查 PHP 版本確認服務正常
interval: 30s # 每30秒檢查一次
timeout: 10s # 超時時間10秒
retries: 3 # 重試3次
start_period: 40s # 容器啟動後40秒開始檢查
nginx:
image: nginx:alpine
container_name: cfdive-nginx
restart: unless-stopped
volumes:
- ./:/var/www
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
networks:
- cfdive-network
- proxy_net
depends_on:
app:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-https://api.hank-spack.com}
image: cfdive-frontend
container_name: cfdive-frontend
restart: unless-stopped
networks:
- cfdive-network
- proxy_net
db:
image: mysql:8.0
container_name: cfdive-db
restart: unless-stopped
environment:
MYSQL_DATABASE: CFDivePlatform
MYSQL_ROOT_PASSWORD: root
MYSQL_USER: cfdiveuser
MYSQL_PASSWORD: cfdivepass
SERVICE_TAGS: dev
SERVICE_NAME: mysql
volumes:
- mysql-data:/var/lib/mysql
networks:
- cfdive-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "cfdiveuser", "-pcfdivepass"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: cfdive-phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: db
PMA_PORT: 3306
PMA_USER: cfdiveuser
PMA_PASSWORD: cfdivepass
MYSQL_ROOT_PASSWORD: root
networks:
- cfdive-network
depends_on:
db:
condition: service_healthy
queue-worker:
image: cfdive-platform
container_name: cfdive-queue
restart: unless-stopped
working_dir: /var/www/
command: php artisan queue:work --sleep=3 --tries=3 --timeout=60
volumes:
- ./:/var/www
environment:
- DB_CONNECTION=mysql
- DB_HOST=db
- DB_PORT=3306
- DB_DATABASE=CFDivePlatform
- DB_USERNAME=cfdiveuser
- DB_PASSWORD=cfdivepass
networks:
- cfdive-network
depends_on:
app:
condition: service_healthy
mailpit:
image: axllent/mailpit
container_name: cfdive-mailpit
restart: unless-stopped
networks:
- cfdive-network
redis:
image: redis:alpine
container_name: cfdive-redis
restart: unless-stopped
networks:
- cfdive-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
networks:
cfdive-network:
driver: bridge
proxy_net:
external: true
volumes:
mysql-data:
driver: local
+58
View File
@@ -0,0 +1,58 @@
# 定義一個 HTTP 服務器塊
server {
listen 80;
server_name cfdive.local localhost;
# 默認索引文件,按順序嘗試
index index.php index.html;
# 錯誤日誌和訪問日誌的路徑
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
# 網站根目錄,指向 Laravel 的 public 目錄
root /var/www/public;
# 客戶端上傳文件大小限制
client_max_body_size 100M;
# 處理所有請求
location / {
# 嘗試以 URI 作為文件查找,然後作為目錄,最後轉發到 index.php
try_files $uri $uri/ /index.php?$query_string;
# 啟用靜態 gzip 文件服務(如果存在 .gz 文件)
gzip_static on;
}
# 處理 PHP 文件請求
location ~ \.php$ {
# 如果文件不存在,返回 404
try_files $uri =404;
# 分割路徑信息,用於處理 PATH_INFO
fastcgi_split_path_info ^(.+\.php)(/.+)$;
# 轉發 PHP 請求到 PHP-FPM 服務
fastcgi_pass app:9000;
# 設置 FastCGI 緩衝區大小
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
# 默認索引文件
fastcgi_index index.php;
# 設置 FastCGI 讀取超時時間(秒)
fastcgi_read_timeout 600;
# 包含 FastCGI 參數
include fastcgi_params;
# 設置 SCRIPT_FILENAME 參數,告訴 PHP-FPM 要執行的文件
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 設置 PATH_INFO 參數,用於獲取路徑信息
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
set -e
echo "=== CFDivePlatform 容器初始化開始 ==="
# 檢查目錄結構
if [ ! -d "/var/www/storage" ]; then
echo "創建 storage 目錄..."
mkdir -p /var/www/storage
fi
if [ ! -d "/var/www/bootstrap/cache" ]; then
echo "創建 bootstrap/cache 目錄..."
mkdir -p /var/www/bootstrap/cache
fi
# 設置權限
echo "設置目錄權限..."
chown -R www-data:www-data /var/www
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
# 等待 MySQL 服務啟動
echo "等待 MySQL 服務啟動..."
# 使用更穩定的方法檢查 MySQL 連接
MAX_TRIES=60
COUNT=0
wait_for_mysql() {
while [ $COUNT -lt $MAX_TRIES ]; do
if mysqladmin ping -h"db" -u"cfdiveuser" -p"cfdivepass" --silent 2>/dev/null; then
echo "✅ MySQL 服務已準備就緒"
return 0
fi
# 備用檢查方法
if php -r "
try {
\$pdo = new PDO('mysql:host=db;port=3306', 'cfdiveuser', 'cfdivepass');
echo 'PHP-PDO-OK';
exit(0);
} catch(Exception \$e) {
exit(1);
}
" 2>/dev/null; then
echo "✅ MySQL 連接成功 (通過 PHP PDO)"
break
fi
echo "⏳ 等待 MySQL... ($((COUNT+1))/$MAX_TRIES)"
sleep 2
COUNT=$((COUNT+1))
done
if [ $COUNT -eq $MAX_TRIES ]; then
echo "⚠️ 無法連接到 MySQL,但將繼續啟動服務"
fi
}
wait_for_mysql
# 檢查並安裝 Composer 依賴
echo "📦 檢查 Composer 依賴..."
if [ -f "composer.json" ]; then
if [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; then
echo "安裝 Composer 依賴..."
composer install --no-scripts --no-autoloader --optimize-autoloader
composer dump-autoload --optimize
else
echo "✅ Composer 依賴已是最新"
fi
fi
# 設置 Laravel 環境
if [ ! -f .env ]; then
echo "🔧 創建 .env 檔案..."
cp .env.example .env
php artisan key:generate
else
echo "✅ .env 檔案已存在"
fi
# 更新環境變數以確保正確配置
echo "🔧 更新資料庫配置..."
sed -i "s/DB_HOST=.*/DB_HOST=db/g" .env
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=cfdivepass/g" .env
sed -i "s/DB_USERNAME=.*/DB_USERNAME=cfdiveuser/g" .env
sed -i "s/DB_DATABASE=.*/DB_DATABASE=CFDivePlatform/g" .env
# 執行遷移(如果數據庫已準備好)
echo "🗄️ 執行數據庫遷移..."
if php artisan migrate:status 2>/dev/null; then
php artisan migrate --force || echo "⚠️ 遷移執行遇到問題,但繼續執行"
else
echo "⚠️ 無法檢查遷移狀態,跳過遷移"
fi
# 清除與優化 Laravel 緩存
echo "🧹 清除 Laravel 緩存..."
php artisan config:clear || true
php artisan cache:clear || true
php artisan route:clear || true
php artisan view:clear || true
# 生成 Swagger 文檔(如果可能)
if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then
echo "📖 生成 API 文檔..."
php artisan l5-swagger:generate || echo "⚠️ API 文檔生成失敗"
fi
echo "✅ CFDivePlatform 初始化完成!"
# 建立 storage symlink
echo "🔗 建立 storage symlink..."
php artisan storage:link --force || true
# 啟動 cron daemonLaravel Scheduler
echo "⏰ 啟動 Laravel Scheduler cron..."
service cron start || cron || true
# 執行傳入的命令
exec "$@"
+5
View File
@@ -0,0 +1,5 @@
upload_max_filesize=40M
post_max_size=40M
memory_limit=512M
max_execution_time=600
max_input_vars=10000
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+20
View File
@@ -0,0 +1,20 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ARG VITE_API_URL=http://localhost:8080
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cf-dive-frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}
+3855
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "cf-dive-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.16.0",
"pinia": "^3.0.4",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.19",
"vite": "^8.0.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+20
View File
@@ -0,0 +1,20 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import NavBar from './components/NavBar.vue'
import NotificationDrawer from './components/NotificationDrawer.vue'
const route = useRoute()
const isBackofficePage = computed(() =>
route.path.startsWith('/coach') || route.path.startsWith('/admin')
)
</script>
<template>
<div class="min-h-screen bg-gray-50">
<NavBar v-if="!isBackofficePage" />
<RouterView />
<NotificationDrawer />
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios'
const adminApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
adminApi.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default adminApi
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default api
+17
View File
@@ -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}`)
}
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios'
const coachApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
coachApi.interceptors.request.use((config) => {
const token = localStorage.getItem('coach_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default coachApi
+21
View File
@@ -0,0 +1,21 @@
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`)
}
export function completeBooking(id) {
return coachApi.put(`/provider/bookings/${id}/complete`)
}
+17
View File
@@ -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}`)
}
+27
View File
@@ -0,0 +1,27 @@
import coachApi from './coachAxios'
function toFormData(file) {
const fd = new FormData()
fd.append('image', file)
return fd
}
export function uploadCover(offerId, file) {
return coachApi.post(`/provider/offers/${offerId}/cover`, toFormData(file), {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export function deleteCover(offerId) {
return coachApi.delete(`/provider/offers/${offerId}/cover`)
}
export function uploadImage(offerId, file) {
return coachApi.post(`/provider/offers/${offerId}/images`, toFormData(file), {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export function deleteImage(imageId) {
return coachApi.delete(`/provider/images/${imageId}`)
}
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios'
const notificationApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
notificationApi.interceptors.request.use((config) => {
// 優先用 coach_token,因為 coach 身份通知優先;member 也可用自己的 token
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
const isCoachPage = window.location.pathname.startsWith('/coach')
const token = isCoachPage
? (localStorage.getItem('coach_token') || localStorage.getItem('token'))
: (localStorage.getItem('token') || localStorage.getItem('coach_token'))
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default notificationApi
+27
View File
@@ -0,0 +1,27 @@
import api from './axios'
import axios from 'axios'
const publicApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
export function getReviews(offerId, sort = 'helpful') {
return publicApi.get(`/diving-offers/${offerId}/reviews`, { params: { sort } })
}
export function createReview(payload) {
return api.post('/member/reviews', payload)
}
export function updateReview(id, payload) {
return api.put(`/member/reviews/${id}`, payload)
}
export function deleteReview(id) {
return api.delete(`/member/reviews/${id}`)
}
export function toggleHelpful(reviewId) {
return api.post(`/reviews/${reviewId}/helpful`)
}
+10
View File
@@ -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`)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+35
View File
@@ -0,0 +1,35 @@
<script setup>
import { useAdminAuthStore } from '../stores/adminAuth'
import { useRouter } from 'vue-router'
const adminAuth = useAdminAuthStore()
const router = useRouter()
async function handleLogout() {
await adminAuth.logout()
router.push('/admin/login')
}
</script>
<template>
<nav class="bg-slate-800 text-white shadow-md">
<div class="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<div class="flex items-center gap-6">
<span class="text-lg font-bold tracking-wide"> Admin Panel</span>
<RouterLink to="/admin/dashboard" class="text-sm hover:text-slate-300 transition">儀表板</RouterLink>
<RouterLink to="/admin/members" class="text-sm hover:text-slate-300 transition">會員管理</RouterLink>
<RouterLink to="/admin/providers" class="text-sm hover:text-slate-300 transition">教練管理</RouterLink>
<RouterLink to="/admin/offers" class="text-sm hover:text-slate-300 transition">課程管理</RouterLink>
<RouterLink to="/admin/bookings" class="text-sm hover:text-slate-300 transition">預約管理</RouterLink>
<RouterLink to="/admin/reviews" class="text-sm hover:text-slate-300 transition">評價管理</RouterLink>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="text-slate-400">{{ adminAuth.user?.name }}</span>
<button @click="handleLogout"
class="bg-slate-600 hover:bg-slate-500 px-4 py-1.5 rounded-full transition">
登出
</button>
</div>
</div>
</nav>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup>
import { useCoachAuthStore } from '../stores/coachAuth'
import { useRouter } from 'vue-router'
import NotificationBell from './NotificationBell.vue'
const coachAuth = useCoachAuthStore()
const router = useRouter()
async function handleLogout() {
await coachAuth.logout()
router.push('/coach/login')
}
</script>
<template>
<nav class="bg-gray-900 text-white shadow-md">
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
<div class="flex items-center gap-6">
<RouterLink to="/coach/dashboard" class="text-lg font-bold tracking-wide hover:text-gray-300 transition">
🤿 Coach Portal
</RouterLink>
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</RouterLink>
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
<RouterLink to="/coach/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink>
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="text-gray-400">{{ coachAuth.user?.name }}</span>
<NotificationBell />
<button
@click="handleLogout"
class="bg-gray-700 hover:bg-gray-600 px-4 py-1.5 rounded-full transition"
>
登出
</button>
</div>
</div>
</nav>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup>
defineProps({
offer: { type: Object, required: true },
})
</script>
<template>
<RouterLink
:to="`/courses/${offer.id}`"
class="bg-white rounded-2xl shadow hover:shadow-lg transition overflow-hidden flex flex-col"
>
<div class="h-40 overflow-hidden">
<img
v-if="offer.cover_image_url"
:src="offer.cover_image_url"
:alt="offer.title"
class="w-full h-full object-cover"
/>
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl">
🤿
</div>
</div>
<div class="p-4 flex flex-col gap-2 flex-1">
<div class="flex gap-2 flex-wrap">
<span
v-for="badge in (offer.badges || [])"
:key="badge"
class="text-xs bg-ocean-100 text-ocean-700 px-2 py-0.5 rounded-full"
>
{{ badge }}
</span>
<span v-if="offer.tag" class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{{ offer.tag }}
</span>
</div>
<h3 class="font-semibold text-gray-800 line-clamp-2 leading-snug">{{ offer.title }}</h3>
<p class="text-sm text-gray-500 flex items-center gap-1">
📍 {{ offer.location }}
</p>
<div class="mt-auto flex items-center justify-between pt-2 border-t border-gray-100">
<span class="text-sm text-amber-500 font-medium">
{{ offer.rating }} <span class="text-gray-400">({{ offer.reviews }})</span>
</span>
<span class="text-ocean-700 font-bold">NT$ {{ offer.price.toLocaleString() }}</span>
</div>
</div>
</RouterLink>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>

Some files were not shown because too many files have changed in this diff Show More