diff --git a/.env.example b/.env.example index 435165e..1256b03 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -APP_NAME=startCloud -COMPOSE_PROJECT_NAME=start-cloud +APP_NAME=starCloud +COMPOSE_PROJECT_NAME=star-cloud APP_ENV=local APP_KEY= APP_DEBUG=true @@ -25,7 +25,7 @@ LOG_LEVEL=debug DB_CONNECTION=mysql DB_HOST=mysql DB_PORT=3306 -DB_DATABASE=start-cloud +DB_DATABASE=star-cloud DB_USERNAME=sail DB_PASSWORD=password # FORWARD_DB_PORT=3308 diff --git a/README.md b/README.md index 91de073..a1ed1cf 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,392 @@ # Star Cloud 智能販賣機管理平台 -## 專案簡介 (Project Description) -Star Cloud 是一個專為智能販賣機設計的後台管理系統,旨在提供全方位的機台監控、庫存管理、銷售分析與會員管理功能。透過此平台,管理者可以即時掌握機台運營狀態,優化補貨流程,並透過數據分析提升營運效益。 +> 基於 Docker 的全方位智能販賣機後台管理系統 -## 技術棧 (Technology Stack) +Star Cloud 是一個專為智能販賣機設計的後台管理系統,提供機台監控、庫存管理、銷售分析與會員管理等完整功能。本專案採用 Docker Compose 容器化架構,實現快速部署與環境一致性。 + +--- + +## 技術架構 + +### 容器化架構 +本專案完全運行在 Docker 容器中,包含以下服務: + +| 服務 | 容器名稱 | 技術 | 用途 | 連接埠 | +|------|---------|------|------|--------| +| **應用程式** | star-cloud-laravel | Laravel 10 + PHP 8.5 | Web 應用與 API | 8090:80, 5175:5175 | +| **資料庫** | star-cloud-mysql | MySQL 8.0 | 關聯式資料庫 | 3306:3306 | +| **快取** | star-cloud-redis | Redis Alpine | 快取與 Session | 6380:6379 | + +### 後端技術棧 -### 後端 (Backend) - **Framework**: Laravel 10.x -- **Language**: PHP 8.1+ -- **Database**: MySQL 8.0+ -- **Authentication**: Laravel Sanctum (API Token Authentication) -- **Tools**: Composer +- **Language**: PHP 8.5+ +- **Database**: MySQL 8.0 +- **Cache/Session**: Redis +- **Authentication**: Laravel Sanctum (API Token) +- **Package Manager**: Composer 2.x -### 前端 (Frontend) -- **Framework**: Blade Templates (Laravel 預設樣板引擎) +### 前端技術棧 + +- **Template Engine**: Blade Templates +- **UI Library**: Preline UI 3.x (Tailwind CSS 組件庫) - **CSS Framework**: Tailwind CSS 3.x -- **JavaScript**: Alpine.js 3.x +- **JavaScript**: Alpine.js 3.x (輕量級互動框架) - **Build Tool**: Vite 5.x - **HTTP Client**: Axios -## 安裝與使用說明 (Installation & Usage) +--- -請依照以下步驟將專案 Clone 至本地端並開始運行: +## 快速開始 + +### 前置需求 -### 0. 前置需求 (Prerequisites) 確保您的系統已安裝以下軟體: -- PHP 8.1+ -- Composer -- Node.js & npm -- MySQL 8.0+ -若您尚未安裝 MySQL,Windows 使用者可至 [MySQL 官網](https://dev.mysql.com/downloads/installer/) 下載 Installer,或使用 XAMPP / Laragon 等整合環境。 +- **Docker** 20.10+ +- **Docker Compose** 2.0+ +- **Git** + +> **提示**:Windows 使用者建議安裝 [Docker Desktop](https://www.docker.com/products/docker-desktop/),Linux 使用者可參考 [官方安裝文件](https://docs.docker.com/engine/install/) + +### 安裝步驟 + +#### 1. Clone 專案 -### 1. 下載專案 (Clone Repository) ```bash git clone cd star-cloud ``` -### 2. 安裝依賴套件 (Install Dependencies) +#### 2. 環境設定 -安裝後端 PHP 套件: -```bash -composer install -``` +複製環境變數範例檔案: -安裝前端 Node.js 套件: -```bash -npm install -``` - -### 3. 環境變數設定 (Environment Setup) -複製範例環境設定檔: ```bash cp .env.example .env ``` -請開啟 `.env` 檔案,並依照您的本地環境設定資料庫連線資訊: -```dotenv +**重要設定**(`.env` 檔案): + +```env +# 應用程式設定 +APP_NAME=Star Cloud +APP_ENV=local +APP_DEBUG=true +APP_URL=http://localhost:8090 + +# 資料庫設定(對應 Docker Compose 服務) DB_CONNECTION=mysql -DB_HOST=127.0.0.1 +DB_HOST=mysql DB_PORT=3306 DB_DATABASE=star_cloud -DB_USERNAME=root -DB_PASSWORD=your_password +DB_USERNAME=sail +DB_PASSWORD=password + +# Redis 設定(對應 Docker Compose 服務) +REDIS_HOST=redis +REDIS_PASSWORD=null +REDIS_PORT=6379 + +# Vite 開發伺服器 +VITE_PORT=5175 ``` -產生應用程式金鑰 (Application Key): +#### 3. 啟動 Docker 容器 + +啟動所有服務(應用程式、資料庫、Redis): + ```bash -php artisan key:generate +docker compose up -d ``` -### 4. 資料庫遷移 (Database Migration) -執行 Migration 以建立資料庫結構: +> **說明**:`-d` 參數表示背景執行 + +檢查容器狀態: + ```bash -php artisan migrate -``` -php artisan migrate --seed +docker compose ps ``` -### 4.1 預設管理員帳號 (Default Admin Account) -執行上述指令後,系統會建立一組預設管理員帳號: -- **Email**: `admin@star-cloud.com` -- **Password**: `password` +預期輸出: +``` +NAME STATUS PORTS +star-cloud-laravel Up X minutes 0.0.0.0:8090->80/tcp, 0.0.0.0:5175->5175/tcp +star-cloud-mysql Up X minutes 0.0.0.0:3306->3306/tcp +star-cloud-redis Up X minutes 0.0.0.0:6380->6379/tcp +``` + +#### 4. 初始化應用程式 + +**4.1 安裝後端依賴** -### 5. 編譯前端資源 (Build Frontend Assets) -啟動開發模式 (Hot Module Replacement): ```bash -npm run dev -``` -或編譯生產環境檔案: -```bash -npm run build +docker compose exec laravel.test composer install ``` -### 6. 啟動伺服器 (Start Server) -啟動 Laravel 開發伺服器: -```bash -php artisan serve --port=8001 -``` -預設網址為:http://localhost:8001 +**4.2 產生應用程式金鑰** -## 主要功能模組 -- **儀錶板 (Dashboard)**: 銷售數據概覽、機台狀態監控 -- **機台管理 (Machine Management)**: 機台列表、遠端控制、日誌查詢 -- **商品與庫存 (Inventory)**: 商品管理、進銷存、補貨單 -- **銷售管理 (Sales)**: 交易紀錄、營收報表 -- **權限設定 (Permissions)**: 角色與權限分配 +```bash +docker compose exec laravel.test php artisan key:generate +``` + +**4.3 執行資料庫遷移與種子** + +```bash +docker compose exec laravel.test php artisan migrate --seed +``` + +> **預設管理員帳號**: +> - Email: `admin@star-cloud.com` +> - Password: `password` + +**4.4 安裝前端依賴** + +```bash +docker compose exec laravel.test npm install +``` + +**4.5 編譯前端資源** + +```bash +# 開發模式(支援 Hot Module Replacement) +docker compose exec laravel.test npm run dev + +# 或生產模式 +docker compose exec laravel.test npm run build +``` + +#### 5. 訪問應用程式 + +- **應用程式**: http://localhost:8090 +- **Vite Dev Server**: http://localhost:5175 --- + +## Docker 常用指令 + +### 容器管理 + +```bash +# 啟動所有服務 +docker compose up -d + +# 停止所有服務 +docker compose down + +# 重啟服務 +docker compose restart + +# 查看容器日誌 +docker compose logs -f laravel.test + +# 進入應用程式容器 +docker compose exec laravel.test bash +``` + +### Laravel 指令 + +所有 Laravel Artisan 指令需在容器內執行: + +```bash +# 執行 Artisan 指令 +docker compose exec laravel.test php artisan + +# 範例:清除快取 +docker compose exec laravel.test php artisan cache:clear + +# 範例:執行 Migration +docker compose exec laravel.test php artisan migrate + +# 範例:建立新 Controller +docker compose exec laravel.test php artisan make:controller ExampleController +``` + +### 前端開發 + +```bash +# 安裝 npm 套件 +docker compose exec laravel.test npm install + +# 開發模式(即時編譯) +docker compose exec laravel.test npm run dev + +# 生產編譯 +docker compose exec laravel.test npm run build +``` + +### 資料庫操作 + +```bash +# 進入 MySQL 容器 +docker compose exec mysql bash + +# 直接執行 SQL +docker compose exec mysql mysql -u sail -ppassword star_cloud + +# 備份資料庫 +docker compose exec mysql mysqldump -u sail -ppassword star_cloud > backup.sql + +# 還原資料庫 +docker compose exec -T mysql mysql -u sail -ppassword star_cloud < backup.sql +``` + +--- + +## 主要功能模組 + +### 核心功能 + +| 模組 | 功能描述 | +|------|---------| +| **儀錶板** | 銷售數據總覽、機台狀態即時監控、營收統計圖表 | +| **機台管理** | 機台列表、遠端控制、日誌查詢、維修管理、效期控制 | +| **倉庫管理** | 倉庫列表、庫存管理、調撥單、採購單、補貨單 | +| **商品管理** | 商品資料、分類管理、商品報表分析 | +| **銷售管理** | 交易紀錄、金流管理、促銷設定、營收報表 | +| **會員系統** | 會員管理、點數系統、來店禮、Line 整合 | +| **權限控制** | 角色管理、權限分配、功能權限設定 | +| **遠端管理** | 機台重啟、遠端出貨、遠端結帳、庫存調整 | + +--- + +## Preline UI 組件庫 + +本專案已整合 **Preline UI 3.x**,這是一個基於 Tailwind CSS 的開源 UI 組件庫,提供 50+ 預構建組件。 + +### 可用組件類別 + +- **Navigation**: 導航列、側邊欄、分頁、麵包屑、頁籤 +- **Forms**: 輸入框、選擇器、開關、檔案上傳、日期選擇器 +- **Overlays**: 模態框、抽屜、下拉選單、提示框、彈出框 +- **Data Display**: 表格、卡片、時間軸、折疊面板、徽章 +- **Feedback**: 通知、警告、載入狀態、進度條 + +### 使用範例 + +```html + +
+ + +
+ + + + +``` + +**更多資源**: +- 官方文件: https://preline.co/docs/ +- 組件範例: https://preline.co/examples.html +- GitHub: https://github.com/htmlstreamofficial/preline + +--- + +## 故障排除 + +### 容器無法啟動 + +```bash +# 檢查容器日誌 +docker compose logs + +# 重建容器 +docker compose down +docker compose up -d --build +``` + +### 連接資料庫失敗 + +確認 `.env` 中 `DB_HOST` 設定為 `mysql`(容器服務名稱),而非 `127.0.0.1`。 + +### 前端資源編譯失敗 + +```bash +# 清除 node_modules 重新安裝 +docker compose exec laravel.test rm -rf node_modules +docker compose exec laravel.test npm install +docker compose exec laravel.test npm run build +``` + +### 權限問題 + +```bash +# 修正儲存目錄權限 +docker compose exec laravel.test chmod -R 775 storage bootstrap/cache +``` + +--- + +## 部署至生產環境 + +### 1. 環境變數設定 + +將 `.env` 中的設定調整為生產環境: + +```env +APP_ENV=production +APP_DEBUG=false +APP_URL=https://your-domain.com +``` + +### 2. 編譯前端資源 + +```bash +docker compose exec laravel.test npm run build +``` + +### 3. 優化 Laravel + +```bash +docker compose exec laravel.test php artisan config:cache +docker compose exec laravel.test php artisan route:cache +docker compose exec laravel.test php artisan view:cache +``` + +### 4. 設定 HTTPS + +建議使用 Nginx Reverse Proxy + Let's Encrypt SSL 憑證。 + +--- + +## 開發團隊協作 + +### Git Workflow + +```bash +# 拉取最新程式碼 +git pull origin main + +# 重建容器(若 Docker 設定有變更) +docker compose down +docker compose up -d + +# 更新依賴 +docker compose exec laravel.test composer install +docker compose exec laravel.test npm install + +# 執行 Migration +docker compose exec laravel.test php artisan migrate +``` + +--- + +## 授權與版權 + © Star Cloud. All Rights Reserved. + +--- + +## 技術支援 + +如有問題或建議,請聯繫開發團隊或提交 Issue。 diff --git a/app/Http/Controllers/Admin/DepositBonusRuleController.php b/app/Http/Controllers/Admin/DepositBonusRuleController.php new file mode 100644 index 0000000..9e9fd3b --- /dev/null +++ b/app/Http/Controllers/Admin/DepositBonusRuleController.php @@ -0,0 +1,56 @@ +get(); + return view('admin.deposit-bonus-rules.index', compact('rules')); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'min_amount' => 'required|numeric|min:0', + 'bonus_type' => 'required|in:fixed,percentage', + 'bonus_value' => 'required|numeric|min:0', + 'is_active' => 'boolean', + 'start_at' => 'nullable|date', + 'end_at' => 'nullable|date|after:start_at', + ]); + + DepositBonusRule::create($validated); + + return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已建立'); + } + + public function update(Request $request, DepositBonusRule $depositBonusRule) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'min_amount' => 'required|numeric|min:0', + 'bonus_type' => 'required|in:fixed,percentage', + 'bonus_value' => 'required|numeric|min:0', + 'is_active' => 'boolean', + 'start_at' => 'nullable|date', + 'end_at' => 'nullable|date|after:start_at', + ]); + + $depositBonusRule->update($validated); + + return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已更新'); + } + + public function destroy(DepositBonusRule $depositBonusRule) + { + $depositBonusRule->delete(); + return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已刪除'); + } +} diff --git a/app/Http/Controllers/Admin/GiftDefinitionController.php b/app/Http/Controllers/Admin/GiftDefinitionController.php new file mode 100644 index 0000000..31ee916 --- /dev/null +++ b/app/Http/Controllers/Admin/GiftDefinitionController.php @@ -0,0 +1,58 @@ +get(); + $tiers = MembershipTier::orderBy('sort_order')->get(); + return view('admin.gift-definitions.index', compact('gifts', 'tiers')); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'type' => 'required|in:points,coupon,product,discount,cash', + 'value' => 'required|numeric|min:0', + 'tier_id' => 'nullable|exists:membership_tiers,id', + 'trigger' => 'required|in:register,birthday,annual,upgrade,manual', + 'validity_days' => 'required|integer|min:1', + 'is_active' => 'boolean', + ]); + + GiftDefinition::create($validated); + + return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已建立'); + } + + public function update(Request $request, GiftDefinition $giftDefinition) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'type' => 'required|in:points,coupon,product,discount,cash', + 'value' => 'required|numeric|min:0', + 'tier_id' => 'nullable|exists:membership_tiers,id', + 'trigger' => 'required|in:register,birthday,annual,upgrade,manual', + 'validity_days' => 'required|integer|min:1', + 'is_active' => 'boolean', + ]); + + $giftDefinition->update($validated); + + return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已更新'); + } + + public function destroy(GiftDefinition $giftDefinition) + { + $giftDefinition->delete(); + return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已刪除'); + } +} diff --git a/app/Http/Controllers/Admin/MembershipTierController.php b/app/Http/Controllers/Admin/MembershipTierController.php new file mode 100644 index 0000000..8b463db --- /dev/null +++ b/app/Http/Controllers/Admin/MembershipTierController.php @@ -0,0 +1,62 @@ +get(); + return view('admin.membership-tiers.index', compact('tiers')); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'annual_fee' => 'required|numeric|min:0', + 'discount_rate' => 'required|numeric|min:0|max:1', + 'point_multiplier' => 'required|numeric|min:0', + 'description' => 'nullable|string', + 'is_default' => 'boolean', + ]); + + if ($request->is_default) { + MembershipTier::where('is_default', true)->update(['is_default' => false]); + } + + MembershipTier::create($validated); + + return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已建立'); + } + + public function update(Request $request, MembershipTier $membershipTier) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'annual_fee' => 'required|numeric|min:0', + 'discount_rate' => 'required|numeric|min:0|max:1', + 'point_multiplier' => 'required|numeric|min:0', + 'description' => 'nullable|string', + 'is_default' => 'boolean', + ]); + + if ($request->is_default && !$membershipTier->is_default) { + MembershipTier::where('is_default', true)->update(['is_default' => false]); + } + + $membershipTier->update($validated); + + return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已更新'); + } + + public function destroy(MembershipTier $membershipTier) + { + $membershipTier->delete(); + return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已刪除'); + } +} diff --git a/app/Http/Controllers/Admin/PointRuleController.php b/app/Http/Controllers/Admin/PointRuleController.php new file mode 100644 index 0000000..4b8cf9e --- /dev/null +++ b/app/Http/Controllers/Admin/PointRuleController.php @@ -0,0 +1,54 @@ +validate([ + 'name' => 'required|string|max:255', + 'trigger' => 'required|in:purchase,deposit,register,birthday,referral', + 'points_per_unit' => 'required|integer|min:1', + 'unit_amount' => 'required|numeric|min:0', + 'validity_days' => 'required|integer|min:1', + 'is_active' => 'boolean', + ]); + + PointRule::create($validated); + + return redirect()->route('admin.point-rules.index')->with('success', '點數規則已建立'); + } + + public function update(Request $request, PointRule $pointRule) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'trigger' => 'required|in:purchase,deposit,register,birthday,referral', + 'points_per_unit' => 'required|integer|min:1', + 'unit_amount' => 'required|numeric|min:0', + 'validity_days' => 'required|integer|min:1', + 'is_active' => 'boolean', + ]); + + $pointRule->update($validated); + + return redirect()->route('admin.point-rules.index')->with('success', '點數規則已更新'); + } + + public function destroy(PointRule $pointRule) + { + $pointRule->delete(); + return redirect()->route('admin.point-rules.index')->with('success', '點數規則已刪除'); + } +} diff --git a/app/Http/Controllers/Api/MemberController.php b/app/Http/Controllers/Api/MemberController.php new file mode 100644 index 0000000..dca5958 --- /dev/null +++ b/app/Http/Controllers/Api/MemberController.php @@ -0,0 +1,260 @@ +all(), [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['nullable', 'email', 'unique:members,email'], + 'phone' => ['nullable', 'string', 'unique:members,phone'], + 'password' => ['required', Password::min(6)], + 'birthday' => ['nullable', 'date'], + 'gender' => ['nullable', 'in:male,female,other'], + ], [ + 'name.required' => '請輸入姓名', + 'email.unique' => '此 Email 已被註冊', + 'phone.unique' => '此手機號碼已被註冊', + 'password.required' => '請輸入密碼', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => '驗證失敗', + 'errors' => $validator->errors(), + ], 422); + } + + // 必須提供 email 或 phone 其中之一 + if (empty($request->email) && empty($request->phone)) { + return response()->json([ + 'success' => false, + 'message' => '請提供 Email 或手機號碼', + ], 422); + } + + $member = Member::create([ + 'name' => $request->name, + 'email' => $request->email, + 'phone' => $request->phone, + 'password' => $request->password, + 'birthday' => $request->birthday, + 'gender' => $request->gender, + ]); + + $token = $member->createToken('member-token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => '註冊成功', + 'data' => [ + 'member' => $member, + 'token' => $token, + ], + ], 201); + } + + /** + * 會員登入(Email/Phone + Password) + */ + public function login(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'account' => ['required', 'string'], + 'password' => ['required', 'string'], + ], [ + 'account.required' => '請輸入帳號', + 'password.required' => '請輸入密碼', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => '驗證失敗', + 'errors' => $validator->errors(), + ], 422); + } + + // 嘗試以 email 或 phone 查詢 + $member = Member::where('email', $request->account) + ->orWhere('phone', $request->account) + ->first(); + + if (!$member || !Hash::check($request->password, $member->password)) { + return response()->json([ + 'success' => false, + 'message' => '帳號或密碼錯誤', + ], 401); + } + + if (!$member->is_active) { + return response()->json([ + 'success' => false, + 'message' => '帳號已被停用', + ], 403); + } + + $token = $member->createToken('member-token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => '登入成功', + 'data' => [ + 'member' => $member, + 'token' => $token, + ], + ]); + } + + /** + * 社群登入 + */ + public function socialLogin(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'provider' => ['required', 'in:line,google,facebook'], + 'provider_id' => ['required', 'string'], + 'access_token' => ['nullable', 'string'], + 'name' => ['nullable', 'string'], + 'email' => ['nullable', 'email'], + 'avatar' => ['nullable', 'string'], + ], [ + 'provider.required' => '請指定登入平台', + 'provider.in' => '不支援的登入平台', + 'provider_id.required' => '缺少社群用戶 ID', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => '驗證失敗', + 'errors' => $validator->errors(), + ], 422); + } + + // 查詢是否已綁定 + $socialAccount = SocialAccount::where('provider', $request->provider) + ->where('provider_id', $request->provider_id) + ->first(); + + if ($socialAccount) { + // 已綁定,直接登入 + $member = $socialAccount->member; + + // 更新 token + $socialAccount->update([ + 'access_token' => $request->access_token, + ]); + } else { + // 未綁定,建立新會員 + $member = Member::create([ + 'name' => $request->name ?? '會員', + 'email' => $request->email, + 'avatar' => $request->avatar, + 'email_verified_at' => $request->email ? now() : null, // 社群登入自動驗證 + ]); + + // 綁定社群帳號 + $member->socialAccounts()->create([ + 'provider' => $request->provider, + 'provider_id' => $request->provider_id, + 'access_token' => $request->access_token, + 'profile_data' => $request->only(['name', 'email', 'avatar']), + ]); + } + + if (!$member->is_active) { + return response()->json([ + 'success' => false, + 'message' => '帳號已被停用', + ], 403); + } + + $token = $member->createToken('member-token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => '登入成功', + 'data' => [ + 'member' => $member, + 'token' => $token, + ], + ]); + } + + /** + * 取得個人資料 + */ + public function profile(Request $request): JsonResponse + { + $member = $request->user(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'member' => $member->load('socialAccounts'), + ], + ]); + } + + /** + * 更新個人資料 + */ + public function updateProfile(Request $request): JsonResponse + { + $member = $request->user(); + + $validator = Validator::make($request->all(), [ + 'name' => ['nullable', 'string', 'max:255'], + 'birthday' => ['nullable', 'date'], + 'gender' => ['nullable', 'in:male,female,other'], + 'avatar' => ['nullable', 'string'], + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => '驗證失敗', + 'errors' => $validator->errors(), + ], 422); + } + + $member->update($request->only(['name', 'birthday', 'gender', 'avatar'])); + + return response()->json([ + 'success' => true, + 'message' => '更新成功', + 'data' => [ + 'member' => $member, + ], + ]); + } + + /** + * 登出 + */ + public function logout(Request $request): JsonResponse + { + $request->user()->currentAccessToken()->delete(); + + return response()->json([ + 'success' => true, + 'message' => '登出成功', + ]); + } +} diff --git a/app/Http/Controllers/MemberController.php b/app/Http/Controllers/MemberController.php new file mode 100644 index 0000000..62ee1d5 --- /dev/null +++ b/app/Http/Controllers/MemberController.php @@ -0,0 +1,25 @@ +latest() + ->paginate(10); + + return view('admin.members.index', [ + 'members' => $members, + ]); + } +} diff --git a/app/Http/Controllers/SocialLoginTestController.php b/app/Http/Controllers/SocialLoginTestController.php new file mode 100644 index 0000000..474412f --- /dev/null +++ b/app/Http/Controllers/SocialLoginTestController.php @@ -0,0 +1,33 @@ +input('code'); + $state = $request->input('state'); + $error = $request->input('error'); + + return view('test.social-login', [ + 'line_data' => [ + 'code' => $code, + 'state' => $state, + 'error' => $error + ] + ]); + } +} diff --git a/app/Models/DepositBonusRule.php b/app/Models/DepositBonusRule.php new file mode 100644 index 0000000..d7cdf42 --- /dev/null +++ b/app/Models/DepositBonusRule.php @@ -0,0 +1,60 @@ + 'decimal:2', + 'bonus_value' => 'decimal:2', + 'is_active' => 'boolean', + 'start_at' => 'datetime', + 'end_at' => 'datetime', + ]; + + /** + * 取得目前有效的規則 + */ + public function scopeActive($query) + { + return $query->where('is_active', true) + ->where(function ($q) { + $q->whereNull('start_at')->orWhere('start_at', '<=', now()); + }) + ->where(function ($q) { + $q->whereNull('end_at')->orWhere('end_at', '>=', now()); + }); + } + + /** + * 計算回饋金額 + */ + public function calculateBonus(float $depositAmount): float + { + if ($depositAmount < $this->min_amount) { + return 0; + } + + if ($this->bonus_type === 'fixed') { + return $this->bonus_value; + } + + // percentage + return $depositAmount * ($this->bonus_value / 100); + } +} diff --git a/app/Models/GiftDefinition.php b/app/Models/GiftDefinition.php new file mode 100644 index 0000000..4ce9fcf --- /dev/null +++ b/app/Models/GiftDefinition.php @@ -0,0 +1,53 @@ + 'decimal:2', + 'validity_days' => 'integer', + 'is_active' => 'boolean', + ]; + + /** + * 適用等級 + */ + public function tier(): BelongsTo + { + return $this->belongsTo(MembershipTier::class, 'tier_id'); + } + + /** + * 發放紀錄 + */ + public function memberGifts(): HasMany + { + return $this->hasMany(MemberGift::class); + } + + /** + * 有效禮品 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/app/Models/Member.php b/app/Models/Member.php new file mode 100644 index 0000000..423fe47 --- /dev/null +++ b/app/Models/Member.php @@ -0,0 +1,147 @@ + 'datetime', + 'birthday' => 'date', + 'is_active' => 'boolean', + 'password' => 'hashed', + ]; + + /** + * 建立時自動產生 UUID + */ + protected static function boot() + { + parent::boot(); + + static::creating(function ($model) { + if (empty($model->uuid)) { + $model->uuid = (string) Str::uuid(); + } + }); + } + + /** + * 關聯:社群帳號 + */ + public function socialAccounts() + { + return $this->hasMany(SocialAccount::class); + } + + /** + * 關聯:錢包 + */ + public function wallet() + { + return $this->hasOne(MemberWallet::class); + } + + /** + * 關聯:點數帳戶 + */ + public function points() + { + return $this->hasOne(MemberPoint::class); + } + + /** + * 關聯:會員資格紀錄 + */ + public function memberships() + { + return $this->hasMany(MemberMembership::class); + } + + /** + * 關聯:禮品紀錄 + */ + public function gifts() + { + return $this->hasMany(MemberGift::class); + } + + /** + * 取得目前有效的會員資格 + */ + public function activeMembership() + { + return $this->hasOne(MemberMembership::class)->active()->latest('starts_at'); + } + + /** + * 檢查是否已綁定指定社群 + */ + public function hasSocialAccount(string $provider): bool + { + return $this->socialAccounts()->where('provider', $provider)->exists(); + } + + /** + * 取得或建立錢包 + */ + public function getOrCreateWallet(): MemberWallet + { + return $this->wallet ?? $this->wallet()->create([ + 'balance' => 0, + 'bonus_balance' => 0, + ]); + } + + /** + * 取得或建立點數帳戶 + */ + public function getOrCreatePoints(): MemberPoint + { + return $this->points ?? $this->points()->create([ + 'available_points' => 0, + 'pending_points' => 0, + 'expired_points' => 0, + 'used_points' => 0, + ]); + } +} diff --git a/app/Models/MemberGift.php b/app/Models/MemberGift.php new file mode 100644 index 0000000..fe3b1af --- /dev/null +++ b/app/Models/MemberGift.php @@ -0,0 +1,56 @@ + 'datetime', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + /** + * 所屬會員 + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class); + } + + /** + * 禮品定義 + */ + public function giftDefinition(): BelongsTo + { + return $this->belongsTo(GiftDefinition::class); + } + + /** + * 待領取的禮品 + */ + public function scopePending($query) + { + return $query->where('status', 'pending') + ->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } +} diff --git a/app/Models/MemberMembership.php b/app/Models/MemberMembership.php new file mode 100644 index 0000000..c367f18 --- /dev/null +++ b/app/Models/MemberMembership.php @@ -0,0 +1,65 @@ + 'datetime', + 'expires_at' => 'datetime', + 'auto_renew' => 'boolean', + ]; + + /** + * 所屬會員 + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class); + } + + /** + * 會員等級 + */ + public function tier(): BelongsTo + { + return $this->belongsTo(MembershipTier::class, 'tier_id'); + } + + /** + * 是否有效 + */ + public function getIsActiveAttribute(): bool + { + return $this->status === 'active' + && (!$this->expires_at || $this->expires_at->isFuture()); + } + + /** + * 有效會員資格 + */ + public function scopeActive($query) + { + return $query->where('status', 'active') + ->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } +} diff --git a/app/Models/MemberPoint.php b/app/Models/MemberPoint.php new file mode 100644 index 0000000..11924f0 --- /dev/null +++ b/app/Models/MemberPoint.php @@ -0,0 +1,44 @@ + 'integer', + 'pending_points' => 'integer', + 'expired_points' => 'integer', + 'used_points' => 'integer', + ]; + + /** + * 所屬會員 + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class); + } + + /** + * 點數異動紀錄 + */ + public function transactions(): HasMany + { + return $this->hasMany(PointTransaction::class, 'member_id', 'member_id'); + } +} diff --git a/app/Models/MemberWallet.php b/app/Models/MemberWallet.php new file mode 100644 index 0000000..54ca980 --- /dev/null +++ b/app/Models/MemberWallet.php @@ -0,0 +1,48 @@ + 'decimal:2', + 'bonus_balance' => 'decimal:2', + ]; + + /** + * 所屬會員 + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class); + } + + /** + * 交易紀錄 + */ + public function transactions(): HasMany + { + return $this->hasMany(WalletTransaction::class, 'member_id', 'member_id'); + } + + /** + * 總餘額 (儲值 + 回饋) + */ + public function getTotalBalanceAttribute(): float + { + return $this->balance + $this->bonus_balance; + } +} diff --git a/app/Models/MembershipTier.php b/app/Models/MembershipTier.php new file mode 100644 index 0000000..40de14f --- /dev/null +++ b/app/Models/MembershipTier.php @@ -0,0 +1,62 @@ + 'decimal:2', + 'discount_rate' => 'decimal:2', + 'point_multiplier' => 'decimal:2', + 'is_default' => 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * 此等級的會員紀錄 + */ + public function memberships(): HasMany + { + return $this->hasMany(MemberMembership::class, 'tier_id'); + } + + /** + * 此等級的禮品定義 + */ + public function giftDefinitions(): HasMany + { + return $this->hasMany(GiftDefinition::class, 'tier_id'); + } + + /** + * 取得預設等級 + */ + public static function getDefault(): ?self + { + return static::where('is_default', true)->first(); + } + + /** + * 是否為免費等級 + */ + public function getIsFreeAttribute(): bool + { + return $this->annual_fee <= 0; + } +} diff --git a/app/Models/PointRule.php b/app/Models/PointRule.php new file mode 100644 index 0000000..e8a7462 --- /dev/null +++ b/app/Models/PointRule.php @@ -0,0 +1,47 @@ + 'integer', + 'unit_amount' => 'decimal:2', + 'validity_days' => 'integer', + 'is_active' => 'boolean', + ]; + + /** + * 取得有效規則 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 根據金額計算可獲得點數 + */ + public function calculatePoints(float $amount): int + { + if ($this->unit_amount <= 0) { + return 0; + } + + return (int) floor($amount / $this->unit_amount) * $this->points_per_unit; + } +} diff --git a/app/Models/PointTransaction.php b/app/Models/PointTransaction.php new file mode 100644 index 0000000..3afb890 --- /dev/null +++ b/app/Models/PointTransaction.php @@ -0,0 +1,48 @@ + 'integer', + 'balance_after' => 'integer', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + /** + * 所屬會員 + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class); + } + + /** + * 是否已過期 + */ + public function getIsExpiredAttribute(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } +} diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php new file mode 100644 index 0000000..b27407f --- /dev/null +++ b/app/Models/SocialAccount.php @@ -0,0 +1,53 @@ + 'array', + 'token_expires_at' => 'datetime', + ]; + + /** + * 隱藏的屬性 + */ + protected $hidden = [ + 'access_token', + 'refresh_token', + ]; + + /** + * 關聯:會員 + */ + public function member() + { + return $this->belongsTo(Member::class); + } +} diff --git a/app/Models/WalletTransaction.php b/app/Models/WalletTransaction.php new file mode 100644 index 0000000..f14ce09 --- /dev/null +++ b/app/Models/WalletTransaction.php @@ -0,0 +1,38 @@ + 'decimal:2', + 'balance_after' => 'decimal:2', + 'created_at' => 'datetime', + ]; + + /** + * 所屬會員 + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class); + } +} diff --git a/database/migrations/2026_01_12_060000_create_members_table.php b/database/migrations/2026_01_12_060000_create_members_table.php new file mode 100644 index 0000000..51caca7 --- /dev/null +++ b/database/migrations/2026_01_12_060000_create_members_table.php @@ -0,0 +1,38 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('name'); + $table->string('email')->nullable()->unique(); + $table->string('phone')->nullable()->unique(); + $table->string('password')->nullable(); + $table->date('birthday')->nullable(); + $table->enum('gender', ['male', 'female', 'other'])->nullable(); + $table->string('avatar')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamp('email_verified_at')->nullable(); + $table->rememberToken(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('members'); + } +}; diff --git a/database/migrations/2026_01_12_060001_create_social_accounts_table.php b/database/migrations/2026_01_12_060001_create_social_accounts_table.php new file mode 100644 index 0000000..10e44c1 --- /dev/null +++ b/database/migrations/2026_01_12_060001_create_social_accounts_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('member_id')->constrained('members')->onDelete('cascade'); + $table->enum('provider', ['line', 'google', 'facebook']); + $table->string('provider_id'); + $table->text('access_token')->nullable(); + $table->text('refresh_token')->nullable(); + $table->json('profile_data')->nullable(); + $table->timestamp('token_expires_at')->nullable(); + $table->timestamps(); + + // 同一平台同一用戶只能綁定一次 + $table->unique(['provider', 'provider_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('social_accounts'); + } +}; diff --git a/database/migrations/2026_01_12_070000_create_member_wallets_table.php b/database/migrations/2026_01_12_070000_create_member_wallets_table.php new file mode 100644 index 0000000..993a3e6 --- /dev/null +++ b/database/migrations/2026_01_12_070000_create_member_wallets_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('member_id')->constrained('members')->onDelete('cascade'); + $table->decimal('balance', 12, 2)->default(0)->comment('錢包餘額'); + $table->decimal('bonus_balance', 12, 2)->default(0)->comment('回饋金餘額'); + $table->timestamps(); + + $table->unique('member_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('member_wallets'); + } +}; diff --git a/database/migrations/2026_01_12_070001_create_wallet_transactions_table.php b/database/migrations/2026_01_12_070001_create_wallet_transactions_table.php new file mode 100644 index 0000000..3ddebcb --- /dev/null +++ b/database/migrations/2026_01_12_070001_create_wallet_transactions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('member_id')->constrained('members')->onDelete('cascade'); + $table->enum('type', ['deposit', 'consume', 'refund', 'bonus', 'adjust'])->comment('交易類型'); + $table->decimal('amount', 12, 2)->comment('異動金額'); + $table->decimal('balance_after', 12, 2)->comment('異動後餘額'); + $table->string('description')->nullable()->comment('說明'); + $table->string('reference_type')->nullable()->comment('關聯類型'); + $table->unsignedBigInteger('reference_id')->nullable()->comment('關聯ID'); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['member_id', 'created_at']); + $table->index(['reference_type', 'reference_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('wallet_transactions'); + } +}; diff --git a/database/migrations/2026_01_12_070002_create_deposit_bonus_rules_table.php b/database/migrations/2026_01_12_070002_create_deposit_bonus_rules_table.php new file mode 100644 index 0000000..ae89415 --- /dev/null +++ b/database/migrations/2026_01_12_070002_create_deposit_bonus_rules_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name')->comment('規則名稱'); + $table->decimal('min_amount', 12, 2)->comment('最低儲值金額'); + $table->enum('bonus_type', ['fixed', 'percentage'])->comment('回饋類型'); + $table->decimal('bonus_value', 12, 2)->comment('回饋值'); + $table->boolean('is_active')->default(true); + $table->datetime('start_at')->nullable()->comment('開始時間'); + $table->datetime('end_at')->nullable()->comment('結束時間'); + $table->timestamps(); + + $table->index(['is_active', 'start_at', 'end_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('deposit_bonus_rules'); + } +}; diff --git a/database/migrations/2026_01_12_070003_create_member_points_table.php b/database/migrations/2026_01_12_070003_create_member_points_table.php new file mode 100644 index 0000000..b2f7e13 --- /dev/null +++ b/database/migrations/2026_01_12_070003_create_member_points_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('member_id')->constrained('members')->onDelete('cascade'); + $table->integer('available_points')->default(0)->comment('可用點數'); + $table->integer('pending_points')->default(0)->comment('待生效點數'); + $table->integer('expired_points')->default(0)->comment('已過期點數(統計)'); + $table->integer('used_points')->default(0)->comment('已使用點數(統計)'); + $table->timestamps(); + + $table->unique('member_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('member_points'); + } +}; diff --git a/database/migrations/2026_01_12_070004_create_point_transactions_table.php b/database/migrations/2026_01_12_070004_create_point_transactions_table.php new file mode 100644 index 0000000..c08fd9b --- /dev/null +++ b/database/migrations/2026_01_12_070004_create_point_transactions_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('member_id')->constrained('members')->onDelete('cascade'); + $table->enum('type', ['earn', 'use', 'expire', 'gift', 'adjust'])->comment('異動類型'); + $table->integer('points')->comment('異動點數'); + $table->integer('balance_after')->comment('異動後餘額'); + $table->string('description')->nullable()->comment('說明'); + $table->datetime('expires_at')->nullable()->comment('此筆點數到期日'); + $table->string('reference_type')->nullable()->comment('關聯類型'); + $table->unsignedBigInteger('reference_id')->nullable()->comment('關聯ID'); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['member_id', 'created_at']); + $table->index('expires_at'); + $table->index(['reference_type', 'reference_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('point_transactions'); + } +}; diff --git a/database/migrations/2026_01_12_070005_create_point_rules_table.php b/database/migrations/2026_01_12_070005_create_point_rules_table.php new file mode 100644 index 0000000..f08c8d3 --- /dev/null +++ b/database/migrations/2026_01_12_070005_create_point_rules_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name')->comment('規則名稱'); + $table->enum('trigger', ['purchase', 'deposit', 'register', 'birthday', 'referral'])->comment('觸發條件'); + $table->integer('points_per_unit')->default(1)->comment('每單位獲得點數'); + $table->decimal('unit_amount', 12, 2)->default(100)->comment('單位金額'); + $table->integer('validity_days')->default(365)->comment('點數有效天數'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index('is_active'); + }); + } + + public function down(): void + { + Schema::dropIfExists('point_rules'); + } +}; diff --git a/database/migrations/2026_01_12_070006_create_membership_tiers_table.php b/database/migrations/2026_01_12_070006_create_membership_tiers_table.php new file mode 100644 index 0000000..019d2f0 --- /dev/null +++ b/database/migrations/2026_01_12_070006_create_membership_tiers_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('name')->comment('等級名稱'); + $table->decimal('annual_fee', 12, 2)->default(0)->comment('年費金額'); + $table->decimal('discount_rate', 4, 2)->default(1.00)->comment('折扣比例(0.95=95折)'); + $table->decimal('point_multiplier', 4, 2)->default(1.00)->comment('點數倍率'); + $table->text('description')->nullable()->comment('說明'); + $table->boolean('is_default')->default(false)->comment('是否為預設等級'); + $table->integer('sort_order')->default(0)->comment('排序'); + $table->timestamps(); + + $table->index('is_default'); + $table->index('sort_order'); + }); + } + + public function down(): void + { + Schema::dropIfExists('membership_tiers'); + } +}; diff --git a/database/migrations/2026_01_12_070007_create_member_memberships_table.php b/database/migrations/2026_01_12_070007_create_member_memberships_table.php new file mode 100644 index 0000000..fc9d3d1 --- /dev/null +++ b/database/migrations/2026_01_12_070007_create_member_memberships_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('member_id')->constrained('members')->onDelete('cascade'); + $table->foreignId('tier_id')->constrained('membership_tiers')->onDelete('cascade'); + $table->datetime('starts_at')->comment('生效日'); + $table->datetime('expires_at')->nullable()->comment('到期日'); + $table->unsignedBigInteger('payment_id')->nullable()->comment('付款紀錄ID'); + $table->boolean('auto_renew')->default(false)->comment('是否自動續約'); + $table->enum('status', ['active', 'expired', 'cancelled'])->default('active'); + $table->timestamps(); + + $table->index(['member_id', 'status']); + $table->index('expires_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('member_memberships'); + } +}; diff --git a/database/migrations/2026_01_12_070008_create_gift_definitions_table.php b/database/migrations/2026_01_12_070008_create_gift_definitions_table.php new file mode 100644 index 0000000..7ddaf39 --- /dev/null +++ b/database/migrations/2026_01_12_070008_create_gift_definitions_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name')->comment('禮品名稱'); + $table->enum('type', ['points', 'coupon', 'product', 'discount', 'cash'])->comment('禮品類型'); + $table->decimal('value', 12, 2)->default(0)->comment('數值'); + $table->foreignId('tier_id')->nullable()->constrained('membership_tiers')->nullOnDelete()->comment('適用等級'); + $table->enum('trigger', ['register', 'birthday', 'annual', 'upgrade', 'manual'])->comment('觸發條件'); + $table->integer('validity_days')->default(30)->comment('有效天數'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['is_active', 'trigger']); + }); + } + + public function down(): void + { + Schema::dropIfExists('gift_definitions'); + } +}; diff --git a/database/migrations/2026_01_12_070009_create_member_gifts_table.php b/database/migrations/2026_01_12_070009_create_member_gifts_table.php new file mode 100644 index 0000000..0371517 --- /dev/null +++ b/database/migrations/2026_01_12_070009_create_member_gifts_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('member_id')->constrained('members')->onDelete('cascade'); + $table->foreignId('gift_definition_id')->constrained('gift_definitions')->onDelete('cascade'); + $table->enum('status', ['pending', 'claimed', 'expired'])->default('pending'); + $table->datetime('claimed_at')->nullable()->comment('領取時間'); + $table->datetime('expires_at')->nullable()->comment('有效期限'); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['member_id', 'status']); + $table->index('expires_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('member_gifts'); + } +}; diff --git a/docs/members.md b/docs/members.md new file mode 100644 index 0000000..392e7ec --- /dev/null +++ b/docs/members.md @@ -0,0 +1,198 @@ +# 會員系統(Members)功能說明 + +> 此文件記錄會員系統的設計決策與功能說明,供開發與維護時參閱。 + +--- + +## 概述 + +會員系統用於智能販賣機商城,支援消費者透過多種社群管道(Line、Google、Facebook)加入會員。 + +**重要區分**: +- `users` 表:後台管理員登入帳號 +- `members` 表:前台消費者會員帳號 + +兩者**完全獨立**,無關聯。 + +--- + +## 資料表 + +### 1. `members` - 會員資料 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `id` | bigint | 主鍵 | +| `uuid` | string | 唯一識別碼(對外使用) | +| `name` | string | 姓名 | +| `email` | string | 電子郵件(可空) | +| `phone` | string | 手機號碼(可空) | +| `password` | string | 密碼(社群登入可空) | +| `birthday` | date | 生日 | +| `gender` | enum | 性別 | +| `avatar` | string | 頭像 URL | +| `is_active` | boolean | 是否啟用 | +| `email_verified_at` | timestamp | Email 驗證時間 | + +### 2. `social_accounts` - 社群帳號 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `id` | bigint | 主鍵 | +| `member_id` | bigint | 關聯會員 | +| `provider` | enum | line / google / facebook | +| `provider_id` | string | 社群平台用戶 ID | +| `access_token` | text | 存取令牌 | +| `refresh_token` | text | 刷新令牌 | +| `profile_data` | json | 社群個人資料 | +| `token_expires_at` | timestamp | 令牌到期時間 | + +### 3. `member_wallets` - 會員錢包 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `member_id` | bigint | FK,唯一 | +| `balance` | decimal | 儲值餘額 | +| `bonus_balance` | decimal | 回饋金餘額 | + +### 4. `wallet_transactions` - 錢包交易 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `type` | enum | deposit/consume/refund/bonus/adjust | +| `amount` | decimal | 異動金額 | +| `balance_after` | decimal | 異動後餘額 | +| `reference_type/id` | | 關聯訂單或活動 | + +### 5. `deposit_bonus_rules` - 儲值回饋規則 + +設定儲值達指定金額可獲得的回饋(固定金額或百分比)。 + +### 6. `member_points` - 點數帳戶 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `available_points` | int | 可用點數 | +| `pending_points` | int | 待生效點數 | +| `expired_points` | int | 已過期(統計) | +| `used_points` | int | 已使用(統計) | + +### 7. `point_transactions` - 點數異動 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `type` | enum | earn/use/expire/gift/adjust | +| `points` | int | 異動點數 | +| `expires_at` | datetime | **此筆點數到期日** | + +> 每筆獲得點數都記錄 `expires_at`,排程任務定期處理過期。 + +### 8. `point_rules` - 點數規則 + +設定消費/儲值/註冊等行為可獲得的點數及有效天數。 + +### 9. `membership_tiers` - 會員等級 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `name` | string | 等級名稱 | +| `annual_fee` | decimal | 年費(0=免費) | +| `discount_rate` | decimal | 折扣比例 | +| `point_multiplier` | decimal | 點數倍率 | + +### 10. `member_memberships` - 會員等級紀錄 + +記錄會員的等級歸屬及有效期間。 + +### 11. `gift_definitions` - 禮品定義 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `type` | enum | points/coupon/product/discount/cash | +| `trigger` | enum | register/birthday/annual/upgrade/manual | + +### 12. `member_gifts` - 禮品發放紀錄 + +記錄發放給會員的禮品及領取狀態。 + +--- + +## ER 關係圖 + +``` +members +├── social_accounts (1:N) +├── member_wallets (1:1) +│ └── wallet_transactions (1:N) +├── member_points (1:1) +│ └── point_transactions (1:N) +├── member_memberships (1:N) +│ └── membership_tiers +└── member_gifts (1:N) + └── gift_definitions +``` + +--- + +## 登入流程 + +``` +使用者選擇社群登入 + ↓ +取得 provider + provider_id + ↓ +查詢 social_accounts + ↓ + ┌────┴────┐ +已綁定 未綁定 + ↓ ↓ +取得 member 建立新 member + social_account + ↓ ↓ + 完成登入 +``` + +--- + +## Email 驗證(可選功能) + +若需要 Email 驗證,需設定 `.env` 的 SMTP 並讓 `Member` Model 實作 `MustVerifyEmail`。 + +社群登入時自動標記 `email_verified_at`,僅對手機/密碼註冊要求驗證。 + +--- + +## API 端點 + +| Method | Endpoint | 說明 | 認證 | +|--------|----------|------|------| +| POST | `/api/members/register` | 註冊會員 | 否 | +| POST | `/api/members/login` | 登入 | 否 | +| POST | `/api/members/social-login` | 社群登入 | 否 | +| GET | `/api/members/profile` | 取得個人資料 | 是 | +| PUT | `/api/members/profile` | 更新個人資料 | 是 | +| POST | `/api/members/logout` | 登出 | 是 | + +--- + +## Postman 測試 + +匯入:`docs/postman/Star_Cloud_Members_API.postman_collection.json` + +--- + +## 社群登入實測 + +訪問 `/test/social-login` 測試 Google/Line 登入。 + +--- + +## 開發進度 + +| 日期 | 項目 | 狀態 | +|------|------|------| +| 2026-01-12 | 會員核心 (members, social_accounts) | ✅ 完成 | +| 2026-01-12 | 錢包系統 (3 表 + 3 Model) | ✅ 完成 | +| 2026-01-12 | 點數系統 (3 表 + 3 Model) | ✅ 完成 | +| 2026-01-12 | 年度會員 (2 表 + 2 Model) | ✅ 完成 | +| 2026-01-12 | 贈送機制 (2 表 + 2 Model) | ✅ 完成 | + diff --git a/docs/postman/Star_Cloud_Members_API.postman_collection.json b/docs/postman/Star_Cloud_Members_API.postman_collection.json new file mode 100644 index 0000000..9784e9a --- /dev/null +++ b/docs/postman/Star_Cloud_Members_API.postman_collection.json @@ -0,0 +1,239 @@ +{ + "info": { + "name": "Star Cloud - 會員 API", + "description": "智能販賣機商城會員系統 API 測試集合", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "base_url", + "value": "http://localhost/api", + "type": "string" + }, + { + "key": "token", + "value": "", + "type": "string" + } + ], + "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@example.com\",\n \"phone\": \"0912345678\",\n \"password\": \"password123\",\n \"birthday\": \"1990-01-01\",\n \"gender\": \"male\"\n}" + }, + "url": { + "raw": "{{base_url}}/members/register", + "host": [ + "{{base_url}}" + ], + "path": [ + "members", + "register" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 201) {", + " var jsonData = pm.response.json();", + " pm.collectionVariables.set('token', jsonData.data.token);", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "會員登入", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"account\": \"test@example.com\",\n \"password\": \"password123\"\n}" + }, + "url": { + "raw": "{{base_url}}/members/login", + "host": [ + "{{base_url}}" + ], + "path": [ + "members", + "login" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " var jsonData = pm.response.json();", + " pm.collectionVariables.set('token', jsonData.data.token);", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "社群登入", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"provider\": \"line\",\n \"provider_id\": \"U1234567890abcdef\",\n \"access_token\": \"test_access_token\",\n \"name\": \"Line 用戶\",\n \"email\": \"line@example.com\",\n \"avatar\": \"https://example.com/avatar.jpg\"\n}" + }, + "url": { + "raw": "{{base_url}}/members/social-login", + "host": [ + "{{base_url}}" + ], + "path": [ + "members", + "social-login" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " var jsonData = pm.response.json();", + " pm.collectionVariables.set('token', jsonData.data.token);", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "取得個人資料", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/members/profile", + "host": [ + "{{base_url}}" + ], + "path": [ + "members", + "profile" + ] + } + } + }, + { + "name": "更新個人資料", + "request": { + "method": "PUT", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"更新後的名字\",\n \"birthday\": \"1995-06-15\",\n \"gender\": \"female\"\n}" + }, + "url": { + "raw": "{{base_url}}/members/profile", + "host": [ + "{{base_url}}" + ], + "path": [ + "members", + "profile" + ] + } + } + }, + { + "name": "登出", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/members/logout", + "host": [ + "{{base_url}}" + ], + "path": [ + "members", + "logout" + ] + } + } + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b70f767..e172f71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { - "name": "star-cloud", + "name": "html", "lockfileVersion": 3, "requires": true, "packages": { "": { "devDependencies": { + "@alpinejs/collapse": "^3.15.3", "@tailwindcss/forms": "^0.5.2", "alpinejs": "^3.4.2", "autoprefixer": "^10.4.2", "axios": "^1.6.4", "laravel-vite-plugin": "^1.0.0", "postcss": "^8.4.31", + "preline": "^3.2.0", "tailwindcss": "^3.1.0", "vite": "^5.0.0" } @@ -28,6 +30,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@alpinejs/collapse": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@alpinejs/collapse/-/collapse-3.15.3.tgz", + "integrity": "sha512-nheS20BsFY1Eh1nyW0YNs7RMOiO/LipCTltEplbWunTcgdCeZtD7YPUim5xtbhc+0nJP4SkR7G0axRXaRf4m1g==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -419,6 +428,34 @@ "node": ">=12" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -804,6 +841,74 @@ "win32" ] }, + "node_modules/@svgdotjs/svg.draggable.js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.6.tgz", + "integrity": "sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4" + } + }, + "node_modules/@svgdotjs/svg.filter.js": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.filter.js/-/svg.filter.js-3.0.9.tgz", + "integrity": "sha512-/69XMRCDoam2HgC4ldHIaDgeQf1ViHIsa0Ld4uWgiXtZ+E24DWHe/9Ib6kbNiZ7WRIdlVokUDR1Fg0kjIpkfbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgdotjs/svg.js": "^3.2.4" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@svgdotjs/svg.js": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz", + "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Fuzzyma" + } + }, + "node_modules/@svgdotjs/svg.resize.js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.resize.js/-/svg.resize.js-2.0.5.tgz", + "integrity": "sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18" + }, + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4", + "@svgdotjs/svg.select.js": "^4.0.1" + } + }, + "node_modules/@svgdotjs/svg.select.js": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz", + "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18" + }, + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4" + } + }, + "node_modules/@swc/helpers": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.2.14.tgz", + "integrity": "sha512-wpCQMhf5p5GhNg2MmGKXzUNwxe7zRiCsmqYsamez2beP7mKPCSiu+BjZcdN95yYSzO857kr0VfQewmGpS77nqA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", @@ -841,6 +946,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@yr/monotone-cubic-spline": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", + "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", + "dev": true, + "license": "MIT" + }, "node_modules/alpinejs": { "version": "3.15.2", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.2.tgz", @@ -872,6 +984,21 @@ "node": ">= 8" } }, + "node_modules/apexcharts": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.7.0.tgz", + "integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgdotjs/svg.draggable.js": "^3.0.4", + "@svgdotjs/svg.filter.js": "^3.0.8", + "@svgdotjs/svg.js": "^3.2.4", + "@svgdotjs/svg.resize.js": "^2.0.2", + "@svgdotjs/svg.select.js": "^4.0.1", + "@yr/monotone-cubic-spline": "^1.0.3" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -992,7 +1119,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -1126,6 +1252,27 @@ "node": ">=4" } }, + "node_modules/datatables.net": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-2.3.6.tgz", + "integrity": "sha512-xQ/dCxrjfxM0XY70wSIzakkTZ6ghERwlLmAPyCnu8Sk5cyt9YvOVyOsFNOa/BZ/lM63Q3i2YSSvp/o7GXZGsbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-dt": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/datatables.net-dt/-/datatables.net-dt-2.3.6.tgz", + "integrity": "sha512-8OEUNCEfkeW+TuVUDlT1q6/XXOitgVzCdNqBivw8bK9DnaNk5F6JjT8lE2pQ4uAfoL/dTy2J+HKxTHeTh8HJlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "datatables.net": "2.3.6", + "jquery": ">=1.7" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1150,6 +1297,17 @@ "dev": true, "license": "MIT" }, + "node_modules/dropzone": { + "version": "6.0.0-beta.2", + "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-6.0.0-beta.2.tgz", + "integrity": "sha512-k44yLuFFhRk53M8zP71FaaNzJYIzr99SKmpbO/oZKNslDjNXQsBTdfLs+iONd0U0L94zzlFzRnFdqbLcs7h9fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.2.13", + "just-extend": "^5.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1575,11 +1733,24 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/just-extend": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-5.1.1.tgz", + "integrity": "sha512-b+z6yF1d4EOyDgylzQo5IminlUmzSeqR1hs/bzjBNjuGras4FXq/6TrzjxfN0j+TmI0ltJzTNlqXUMCniciwKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/laravel-vite-plugin": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz", @@ -1745,6 +1916,13 @@ "node": ">=0.10.0" } }, + "node_modules/nouislider": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/nouislider/-/nouislider-15.8.1.tgz", + "integrity": "sha512-93TweAi8kqntHJSPiSWQ1o/uZ29VWOmal9YKb6KKGGlCkugaNfAupT7o1qTHqdJvNQ7S0su5rO6qRFCjP8fxtw==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1832,7 +2010,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -1976,6 +2153,21 @@ "dev": true, "license": "MIT" }, + "node_modules/preline": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/preline/-/preline-3.2.3.tgz", + "integrity": "sha512-S13MFdC/1FWFz3S+oW1PlyZ6Alo0SZxJ9HwaZRg5IQZjcbKqCFIOXAbAhQeX0izauqWJXIQdKofhfCWBizwleQ==", + "dev": true, + "license": "Licensed under MIT and Preline UI Fair Use License", + "dependencies": { + "@floating-ui/dom": "^1.6.13", + "apexcharts": "^4.5.0", + "datatables.net-dt": "^2.2.2", + "dropzone": "^6.0.0-beta.2", + "nouislider": "^15.8.1", + "vanilla-calendar-pro": "^3.0.4" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -2177,7 +2369,6 @@ "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -2274,7 +2465,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2340,13 +2530,23 @@ "dev": true, "license": "MIT" }, + "node_modules/vanilla-calendar-pro": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vanilla-calendar-pro/-/vanilla-calendar-pro-3.1.0.tgz", + "integrity": "sha512-yXDtCaedcKz6i5OOdWGwui0C8MAmjXjj7JzKZyjDlkczSRqnhI8BDGFygqT2K+qL1uY7R2fLYlTlxA6oyFs2yg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://buymeacoffee.com/uvarov" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index 31208d1..f9add43 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,15 @@ "build": "vite build" }, "devDependencies": { + "@alpinejs/collapse": "^3.15.3", "@tailwindcss/forms": "^0.5.2", "alpinejs": "^3.4.2", "autoprefixer": "^10.4.2", "axios": "^1.6.4", "laravel-vite-plugin": "^1.0.0", "postcss": "^8.4.31", + "preline": "^3.2.0", "tailwindcss": "^3.1.0", "vite": "^5.0.0" } -} +} \ No newline at end of file diff --git a/resources/js/app.js b/resources/js/app.js index a8093be..94872e7 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,7 +1,13 @@ import './bootstrap'; import Alpine from 'alpinejs'; +import collapse from '@alpinejs/collapse'; + +Alpine.plugin(collapse); window.Alpine = Alpine; Alpine.start(); + +// 初始化 Preline UI +import 'preline'; diff --git a/resources/views/admin/deposit-bonus-rules/index.blade.php b/resources/views/admin/deposit-bonus-rules/index.blade.php new file mode 100644 index 0000000..00fa340 --- /dev/null +++ b/resources/views/admin/deposit-bonus-rules/index.blade.php @@ -0,0 +1,132 @@ +@extends('layouts.admin') + +@section('content') +@php + $theme = request()->cookie('theme', 'dark-blue'); + $isLight = in_array($theme, ['light-blue', 'light-green']); + $cardBg = $isLight ? 'bg-white' : 'bg-gray-800'; + $textPrimary = $isLight ? 'text-gray-900' : 'text-gray-200'; + $textSecondary = $isLight ? 'text-gray-600' : 'text-gray-400'; + $borderColor = $isLight ? 'border-gray-200' : 'border-gray-700'; + $thBg = $isLight ? 'bg-gray-50' : 'bg-gray-700'; + $inputBg = $isLight ? 'bg-white' : 'bg-gray-700'; + $inputBorder = $isLight ? 'border-gray-300' : 'border-gray-600'; +@endphp + +{{-- Toast 通知 --}} +@if(session('success')) +
+ + + + {{ session('success') }} +
+@endif + +
+
+

儲值回饋設定

+ +
+ +
+ + + + + + + + + + + + + @forelse($rules as $rule) + + + + + + + + + @empty + + + + @endforelse + +
名稱最低儲值回饋類型回饋值狀態操作
{{ $rule->name }}${{ number_format($rule->min_amount) }}{{ $rule->bonus_type == 'fixed' ? '固定金額' : '百分比' }}{{ $rule->bonus_type == 'fixed' ? '$'.number_format($rule->bonus_value) : $rule->bonus_value.'%' }} + @if($rule->is_active) + 啟用 + @else + 停用 + @endif + +
+ @csrf + @method('DELETE') + +
+
尚無資料
+
+
+ +{{-- Create Modal --}} + +@endsection diff --git a/resources/views/admin/gift-definitions/index.blade.php b/resources/views/admin/gift-definitions/index.blade.php new file mode 100644 index 0000000..88338b4 --- /dev/null +++ b/resources/views/admin/gift-definitions/index.blade.php @@ -0,0 +1,168 @@ +@extends('layouts.admin') + +@section('content') +@php + $theme = request()->cookie('theme', 'dark-blue'); + $isLight = in_array($theme, ['light-blue', 'light-green']); + $cardBg = $isLight ? 'bg-white' : 'bg-gray-800'; + $textPrimary = $isLight ? 'text-gray-900' : 'text-gray-200'; + $textSecondary = $isLight ? 'text-gray-600' : 'text-gray-400'; + $borderColor = $isLight ? 'border-gray-200' : 'border-gray-700'; + $thBg = $isLight ? 'bg-gray-50' : 'bg-gray-700'; + $inputBg = $isLight ? 'bg-white' : 'bg-gray-700'; + $inputBorder = $isLight ? 'border-gray-300' : 'border-gray-600'; + + $typeLabels = [ + 'points' => '點數', + 'coupon' => '優惠券', + 'product' => '商品', + 'discount' => '折扣', + 'cash' => '現金', + ]; + + $triggerLabels = [ + 'register' => '註冊', + 'birthday' => '生日', + 'annual' => '年度', + 'upgrade' => '升級', + 'manual' => '手動', + ]; +@endphp + +{{-- Toast 通知 --}} +@if(session('success')) +
+ + + + {{ session('success') }} +
+@endif + +
+
+

禮品設定

+ +
+ +
+ + + + + + + + + + + + + + @forelse($gifts as $gift) + + + + + + + + + + @empty + + + + @endforelse + +
名稱類型數值適用等級觸發條件狀態操作
{{ $gift->name }}{{ $typeLabels[$gift->type] ?? $gift->type }}{{ $gift->value }}{{ $gift->tier?->name ?? '全部' }}{{ $triggerLabels[$gift->trigger] ?? $gift->trigger }} + @if($gift->is_active) + 啟用 + @else + 停用 + @endif + +
+ @csrf + @method('DELETE') + +
+
尚無資料
+
+
+ +{{-- Create Modal --}} + +@endsection diff --git a/resources/views/admin/members/index.blade.php b/resources/views/admin/members/index.blade.php new file mode 100644 index 0000000..78018c2 --- /dev/null +++ b/resources/views/admin/members/index.blade.php @@ -0,0 +1,93 @@ +@extends('layouts.admin') + +@section('content') +@php + $theme = request()->cookie('theme', 'dark-blue'); + $isLight = in_array($theme, ['light-blue', 'light-green']); + $cardBg = $isLight ? 'bg-white' : 'bg-gray-800'; + $textPrimary = $isLight ? 'text-gray-900' : 'text-gray-200'; + $textSecondary = $isLight ? 'text-gray-600' : 'text-gray-400'; + $borderColor = $isLight ? 'border-gray-200' : 'border-gray-700'; + $thBg = $isLight ? 'bg-gray-50' : 'bg-gray-700'; +@endphp +
+

會員列表

+ +
+ {{-- 搜尋與篩選 (預留空間) --}} + +
+
+
+ + + + + + + + + + + + + @forelse ($members as $member) + + + + + + + + + @empty + + + + @endforelse + +
+ UUID + + 姓名 + + Email + + 手機 + + 狀態 + + 註冊時間 +
+
{{ $member->uuid }}
+
+
{{ $member->name }}
+
+
{{ $member->email ?? '-' }}
+
+
{{ $member->phone ?? '-' }}
+
+ @if($member->is_active) + + 啟用 + + @else + + 停用 + + @endif + + {{ $member->created_at->format('Y-m-d H:i') }} +
+ 尚無會員資料 +
+
+
+
+ +
+ {{ $members->links() }} +
+
+
+@endsection diff --git a/resources/views/admin/membership-tiers/index.blade.php b/resources/views/admin/membership-tiers/index.blade.php new file mode 100644 index 0000000..dfc9ba3 --- /dev/null +++ b/resources/views/admin/membership-tiers/index.blade.php @@ -0,0 +1,129 @@ +@extends('layouts.admin') + +@section('content') +@php + $theme = request()->cookie('theme', 'dark-blue'); + $isLight = in_array($theme, ['light-blue', 'light-green']); + $cardBg = $isLight ? 'bg-white' : 'bg-gray-800'; + $textPrimary = $isLight ? 'text-gray-900' : 'text-gray-200'; + $textSecondary = $isLight ? 'text-gray-600' : 'text-gray-400'; + $borderColor = $isLight ? 'border-gray-200' : 'border-gray-700'; + $thBg = $isLight ? 'bg-gray-50' : 'bg-gray-700'; + $inputBg = $isLight ? 'bg-white' : 'bg-gray-700'; + $inputBorder = $isLight ? 'border-gray-300' : 'border-gray-600'; +@endphp + +{{-- Toast 通知 --}} +@if(session('success')) +
+ + + + {{ session('success') }} +
+@endif + +
+
+

會員等級設定

+ +
+ +
+ + + + + + + + + + + + + @forelse($tiers as $tier) + + + + + + + + + @empty + + + + @endforelse + +
名稱年費折扣點數倍率預設操作
{{ $tier->name }}{{ $tier->annual_fee == 0 ? '免費' : '$'.number_format($tier->annual_fee) }}{{ $tier->discount_rate * 100 }}%{{ $tier->point_multiplier }}x + @if($tier->is_default) + 預設 + @endif + +
+ @csrf + @method('DELETE') + +
+
尚無資料
+
+
+ +{{-- Create Modal --}} + +@endsection diff --git a/resources/views/admin/point-rules/index.blade.php b/resources/views/admin/point-rules/index.blade.php new file mode 100644 index 0000000..5e09b36 --- /dev/null +++ b/resources/views/admin/point-rules/index.blade.php @@ -0,0 +1,147 @@ +@extends('layouts.admin') + +@section('content') +@php + $theme = request()->cookie('theme', 'dark-blue'); + $isLight = in_array($theme, ['light-blue', 'light-green']); + $cardBg = $isLight ? 'bg-white' : 'bg-gray-800'; + $textPrimary = $isLight ? 'text-gray-900' : 'text-gray-200'; + $textSecondary = $isLight ? 'text-gray-600' : 'text-gray-400'; + $borderColor = $isLight ? 'border-gray-200' : 'border-gray-700'; + $thBg = $isLight ? 'bg-gray-50' : 'bg-gray-700'; + $inputBg = $isLight ? 'bg-white' : 'bg-gray-700'; + $inputBorder = $isLight ? 'border-gray-300' : 'border-gray-600'; + + $triggerLabels = [ + 'purchase' => '消費', + 'deposit' => '儲值', + 'register' => '註冊', + 'birthday' => '生日', + 'referral' => '推薦', + ]; +@endphp + +{{-- Toast 通知 --}} +@if(session('success')) +
+ + + + {{ session('success') }} +
+@endif + +
+
+

點數規則設定

+ +
+ +
+ + + + + + + + + + + + + + @forelse($rules as $rule) + + + + + + + + + + @empty + + + + @endforelse + +
名稱觸發條件每單位點數單位金額有效天數狀態操作
{{ $rule->name }}{{ $triggerLabels[$rule->trigger] ?? $rule->trigger }}{{ $rule->points_per_unit }} 點${{ number_format($rule->unit_amount) }}{{ $rule->validity_days }} 天 + @if($rule->is_active) + 啟用 + @else + 停用 + @endif + +
+ @csrf + @method('DELETE') + +
+
尚無資料
+
+
+ +{{-- Create Modal --}} + +@endsection diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index a3e1553..9265638 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -80,14 +80,9 @@ -