feat: 整合 Preline UI 3.x 與重寫 README 為 Docker 架構
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 44s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped

- 新增 Preline UI 3.2.3 作為 UI 組件庫
- 更新 tailwind.config.js 整合 Preline
- 更新 app.js 初始化 Preline
- 完全重寫 README.md 以 Docker 容器化架構為核心
- 新增 Docker 常用指令大全
- 新增故障排除與生產部署指南
- 新增會員系統相關功能(會員、錢包、點數、會籍、禮物)
- 新增社交登入測試功能
This commit is contained in:
2026-01-13 10:17:37 +08:00
parent 55ba08c88f
commit 84ef0c24e2
49 changed files with 3593 additions and 98 deletions

View File

@@ -1,5 +1,5 @@
APP_NAME=startCloud APP_NAME=starCloud
COMPOSE_PROJECT_NAME=start-cloud COMPOSE_PROJECT_NAME=star-cloud
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
@@ -25,7 +25,7 @@ LOG_LEVEL=debug
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=mysql DB_HOST=mysql
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=start-cloud DB_DATABASE=star-cloud
DB_USERNAME=sail DB_USERNAME=sail
DB_PASSWORD=password DB_PASSWORD=password
# FORWARD_DB_PORT=3308 # FORWARD_DB_PORT=3308

414
README.md
View File

@@ -1,112 +1,392 @@
# Star Cloud 智能販賣機管理平台 # Star Cloud 智能販賣機管理平台
## 專案簡介 (Project Description) > 基於 Docker 的全方位智能販賣機後台管理系統
Star Cloud 是一個專為智能販賣機設計的後台管理系統,旨在提供全方位的機台監控、庫存管理、銷售分析與會員管理功能。透過此平台,管理者可以即時掌握機台運營狀態,優化補貨流程,並透過數據分析提升營運效益。
## 技術棧 (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 - **Framework**: Laravel 10.x
- **Language**: PHP 8.1+ - **Language**: PHP 8.5+
- **Database**: MySQL 8.0+ - **Database**: MySQL 8.0
- **Authentication**: Laravel Sanctum (API Token Authentication) - **Cache/Session**: Redis
- **Tools**: Composer - **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 - **CSS Framework**: Tailwind CSS 3.x
- **JavaScript**: Alpine.js 3.x - **JavaScript**: Alpine.js 3.x (輕量級互動框架)
- **Build Tool**: Vite 5.x - **Build Tool**: Vite 5.x
- **HTTP Client**: Axios - **HTTP Client**: Axios
## 安裝與使用說明 (Installation & Usage) ---
請依照以下步驟將專案 Clone 至本地端並開始運行: ## 快速開始
### 前置需求
### 0. 前置需求 (Prerequisites)
確保您的系統已安裝以下軟體: 確保您的系統已安裝以下軟體:
- PHP 8.1+
- Composer
- Node.js & npm
- MySQL 8.0+
若您尚未安裝 MySQLWindows 使用者可至 [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 ```bash
git clone <repository_url> git clone <repository_url>
cd star-cloud cd star-cloud
``` ```
### 2. 安裝依賴套件 (Install Dependencies) #### 2. 環境設定
安裝後端 PHP 套件 複製環境變數範例檔案
```bash
composer install
```
安裝前端 Node.js 套件:
```bash
npm install
```
### 3. 環境變數設定 (Environment Setup)
複製範例環境設定檔:
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
請開啟 `.env` 檔案,並依照您的本地環境設定資料庫連線資訊 **重要設定**`.env` 檔案
```dotenv
```env
# 應用程式設定
APP_NAME=Star Cloud
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8090
# 資料庫設定(對應 Docker Compose 服務)
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=mysql
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=star_cloud DB_DATABASE=star_cloud
DB_USERNAME=root DB_USERNAME=sail
DB_PASSWORD=your_password 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 ```bash
php artisan key:generate docker compose up -d
``` ```
### 4. 資料庫遷移 (Database Migration) > **說明**`-d` 參數表示背景執行
執行 Migration 以建立資料庫結構:
檢查容器狀態:
```bash ```bash
php artisan migrate docker compose ps
```
php artisan migrate --seed
``` ```
### 4.1 預設管理員帳號 (Default Admin Account) 預期輸出:
執行上述指令後,系統會建立一組預設管理員帳號: ```
- **Email**: `admin@star-cloud.com` NAME STATUS PORTS
- **Password**: `password` 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 ```bash
npm run dev docker compose exec laravel.test composer install
```
或編譯生產環境檔案:
```bash
npm run build
``` ```
### 6. 啟動伺服器 (Start Server) **4.2 產生應用程式金鑰**
啟動 Laravel 開發伺服器:
```bash
php artisan serve --port=8001
```
預設網址為http://localhost:8001
## 主要功能模組 ```bash
- **儀錶板 (Dashboard)**: 銷售數據概覽、機台狀態監控 docker compose exec laravel.test php artisan key:generate
- **機台管理 (Machine Management)**: 機台列表、遠端控制、日誌查詢 ```
- **商品與庫存 (Inventory)**: 商品管理、進銷存、補貨單
- **銷售管理 (Sales)**: 交易紀錄、營收報表 **4.3 執行資料庫遷移與種子**
- **權限設定 (Permissions)**: 角色與權限分配
```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 <command>
# 範例:清除快取
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
<!-- 下拉選單 -->
<div class="hs-dropdown relative inline-flex">
<button type="button" class="hs-dropdown-toggle px-4 py-2 bg-blue-600 text-white rounded-lg">
選單 <svg class="w-4 h-4 inline ml-2">...</svg>
</button>
<div class="hs-dropdown-menu hidden bg-white shadow-lg rounded-lg p-2 mt-2">
<a class="block px-3 py-2 rounded hover:bg-gray-100" href="#">選項 1</a>
<a class="block px-3 py-2 rounded hover:bg-gray-100" href="#">選項 2</a>
</div>
</div>
<!-- 模態框 -->
<button type="button" data-hs-overlay="#my-modal" class="px-4 py-2 bg-blue-600 text-white rounded-lg">
開啟模態框
</button>
<div id="my-modal" class="hs-overlay hidden">
<!-- 模態框內容 -->
</div>
```
**更多資源**
- 官方文件: 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. © Star Cloud. All Rights Reserved.
---
## 技術支援
如有問題或建議,請聯繫開發團隊或提交 Issue。

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\DepositBonusRule;
use Illuminate\Http\Request;
class DepositBonusRuleController extends Controller
{
public function index()
{
$rules = DepositBonusRule::orderBy('min_amount')->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', '儲值回饋規則已刪除');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\GiftDefinition;
use App\Models\MembershipTier;
use Illuminate\Http\Request;
class GiftDefinitionController extends Controller
{
public function index()
{
$gifts = GiftDefinition::with('tier')->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', '禮品已刪除');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\MembershipTier;
use Illuminate\Http\Request;
class MembershipTierController extends Controller
{
public function index()
{
$tiers = MembershipTier::orderBy('sort_order')->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', '會員等級已刪除');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\PointRule;
use Illuminate\Http\Request;
class PointRuleController extends Controller
{
public function index()
{
$rules = PointRule::all();
return view('admin.point-rules.index', compact('rules'));
}
public function store(Request $request)
{
$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::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', '點數規則已刪除');
}
}

View File

@@ -0,0 +1,260 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Member;
use App\Models\SocialAccount;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
class MemberController extends Controller
{
/**
* 會員註冊
*/
public function register(Request $request): JsonResponse
{
$validator = Validator::make($request->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' => '登出成功',
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Models\Member;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class MemberController extends Controller
{
/**
* Display a listing of the members.
*/
public function index()
{
$members = Member::query()
->latest()
->paginate(10);
return view('admin.members.index', [
'members' => $members,
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class SocialLoginTestController extends Controller
{
public function index()
{
return view('test.social-login');
}
public function lineCallback(Request $request)
{
// 這裡可以實作後端換發 Token 的邏輯
// 為了測試方便,我們先直接顯示回傳的 code 與 state
// 或者嘗試交換 Token 並取得 User Profile
$code = $request->input('code');
$state = $request->input('state');
$error = $request->input('error');
return view('test.social-login', [
'line_data' => [
'code' => $code,
'state' => $state,
'error' => $error
]
]);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class DepositBonusRule extends Model
{
use HasFactory;
protected $fillable = [
'name',
'min_amount',
'bonus_type',
'bonus_value',
'is_active',
'start_at',
'end_at',
];
protected $casts = [
'min_amount' => '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);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class GiftDefinition extends Model
{
use HasFactory;
protected $fillable = [
'name',
'type',
'value',
'tier_id',
'trigger',
'validity_days',
'is_active',
];
protected $casts = [
'value' => '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);
}
}

147
app/Models/Member.php Normal file
View File

@@ -0,0 +1,147 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Support\Str;
class Member extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
/**
* 資料表名稱
*/
protected $table = 'members';
/**
* 可批量賦值的屬性
*/
protected $fillable = [
'uuid',
'name',
'email',
'phone',
'password',
'birthday',
'gender',
'avatar',
'is_active',
'email_verified_at',
];
/**
* 隱藏的屬性
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* 屬性轉換
*/
protected $casts = [
'email_verified_at' => '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,
]);
}
}

56
app/Models/MemberGift.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemberGift extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'member_id',
'gift_definition_id',
'status',
'claimed_at',
'expires_at',
];
protected $casts = [
'claimed_at' => '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());
});
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemberMembership extends Model
{
use HasFactory;
protected $fillable = [
'member_id',
'tier_id',
'starts_at',
'expires_at',
'payment_id',
'auto_renew',
'status',
];
protected $casts = [
'starts_at' => '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());
});
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class MemberPoint extends Model
{
use HasFactory;
protected $fillable = [
'member_id',
'available_points',
'pending_points',
'expired_points',
'used_points',
];
protected $casts = [
'available_points' => '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');
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class MemberWallet extends Model
{
use HasFactory;
protected $fillable = [
'member_id',
'balance',
'bonus_balance',
];
protected $casts = [
'balance' => '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;
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class MembershipTier extends Model
{
use HasFactory;
protected $fillable = [
'name',
'annual_fee',
'discount_rate',
'point_multiplier',
'description',
'is_default',
'sort_order',
];
protected $casts = [
'annual_fee' => '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;
}
}

47
app/Models/PointRule.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PointRule extends Model
{
use HasFactory;
protected $fillable = [
'name',
'trigger',
'points_per_unit',
'unit_amount',
'validity_days',
'is_active',
];
protected $casts = [
'points_per_unit' => '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;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PointTransaction extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'member_id',
'type',
'points',
'balance_after',
'description',
'expires_at',
'reference_type',
'reference_id',
];
protected $casts = [
'points' => '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();
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SocialAccount extends Model
{
use HasFactory;
/**
* 資料表名稱
*/
protected $table = 'social_accounts';
/**
* 可批量賦值的屬性
*/
protected $fillable = [
'member_id',
'provider',
'provider_id',
'access_token',
'refresh_token',
'profile_data',
'token_expires_at',
];
/**
* 屬性轉換
*/
protected $casts = [
'profile_data' => 'array',
'token_expires_at' => 'datetime',
];
/**
* 隱藏的屬性
*/
protected $hidden = [
'access_token',
'refresh_token',
];
/**
* 關聯:會員
*/
public function member()
{
return $this->belongsTo(Member::class);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WalletTransaction extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'member_id',
'type',
'amount',
'balance_after',
'description',
'reference_type',
'reference_id',
];
protected $casts = [
'amount' => 'decimal:2',
'balance_after' => 'decimal:2',
'created_at' => 'datetime',
];
/**
* 所屬會員
*/
public function member(): BelongsTo
{
return $this->belongsTo(Member::class);
}
}

View File

@@ -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('members', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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('social_accounts', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,29 @@
<?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::create('member_wallets', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,34 @@
<?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::create('wallet_transactions', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,33 @@
<?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::create('deposit_bonus_rules', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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
{
/**
* 會員點數帳戶
*/
public function up(): void
{
Schema::create('member_points', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,36 @@
<?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::create('point_transactions', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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
{
/**
* 點數規則
*/
public function up(): void
{
Schema::create('point_rules', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,34 @@
<?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::create('membership_tiers', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,34 @@
<?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::create('member_memberships', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,33 @@
<?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::create('gift_definitions', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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
{
/**
* 會員禮品發放紀錄
*/
public function up(): void
{
Schema::create('member_gifts', function (Blueprint $table) {
$table->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');
}
};

198
docs/members.md Normal file
View File

@@ -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) | ✅ 完成 |

View File

@@ -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"
]
}
}
}
]
}

214
package-lock.json generated
View File

@@ -1,16 +1,18 @@
{ {
"name": "star-cloud", "name": "html",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"devDependencies": { "devDependencies": {
"@alpinejs/collapse": "^3.15.3",
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
"alpinejs": "^3.4.2", "alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"axios": "^1.6.4", "axios": "^1.6.4",
"laravel-vite-plugin": "^1.0.0", "laravel-vite-plugin": "^1.0.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"preline": "^3.2.0",
"tailwindcss": "^3.1.0", "tailwindcss": "^3.1.0",
"vite": "^5.0.0" "vite": "^5.0.0"
} }
@@ -28,6 +30,13 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -419,6 +428,34 @@
"node": ">=12" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -804,6 +841,74 @@
"win32" "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": { "node_modules/@tailwindcss/forms": {
"version": "0.5.10", "version": "0.5.10",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz",
@@ -841,6 +946,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/alpinejs": {
"version": "3.15.2", "version": "3.15.2",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.2.tgz", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.2.tgz",
@@ -872,6 +984,21 @@
"node": ">= 8" "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": { "node_modules/arg": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -992,7 +1119,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001754",
@@ -1126,6 +1252,27 @@
"node": ">=4" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1150,6 +1297,17 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "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==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "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": { "node_modules/laravel-vite-plugin": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz",
@@ -1745,6 +1916,13 @@
"node": ">=0.10.0" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1832,7 +2010,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -1976,6 +2153,21 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2177,7 +2369,6 @@
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -2274,7 +2465,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2340,13 +2530,23 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",

View File

@@ -6,12 +6,14 @@
"build": "vite build" "build": "vite build"
}, },
"devDependencies": { "devDependencies": {
"@alpinejs/collapse": "^3.15.3",
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
"alpinejs": "^3.4.2", "alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"axios": "^1.6.4", "axios": "^1.6.4",
"laravel-vite-plugin": "^1.0.0", "laravel-vite-plugin": "^1.0.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"preline": "^3.2.0",
"tailwindcss": "^3.1.0", "tailwindcss": "^3.1.0",
"vite": "^5.0.0" "vite": "^5.0.0"
} }

View File

@@ -1,7 +1,13 @@
import './bootstrap'; import './bootstrap';
import Alpine from 'alpinejs'; import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse';
Alpine.plugin(collapse);
window.Alpine = Alpine; window.Alpine = Alpine;
Alpine.start(); Alpine.start();
// 初始化 Preline UI
import 'preline';

View File

@@ -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'))
<div x-data="{ show: false }"
x-show="show"
x-cloak
x-init="setTimeout(() => { show = true; setTimeout(() => show = false, 3000) }, 50)"
x-transition:enter="transition cubic-bezier(0.34, 1.56, 0.64, 1) duration-300"
x-transition:enter-start="opacity-0 -translate-y-40"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-400"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-40"
class="fixed top-4 left-0 right-0 mx-auto w-max z-[100] bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{{ session('success') }}
</div>
@endif
<div class="container mx-auto px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h3 class="{{ $textPrimary }} text-3xl font-medium">儲值回饋設定</h3>
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">
新增規則
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full {{ $cardBg }} rounded-lg overflow-hidden">
<thead class="{{ $thBg }}">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">名稱</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">最低儲值</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">回饋類型</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">回饋值</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">狀態</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">操作</th>
</tr>
</thead>
<tbody class="divide-y {{ $borderColor }}">
@forelse($rules as $rule)
<tr>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $rule->name }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">${{ number_format($rule->min_amount) }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $rule->bonus_type == 'fixed' ? '固定金額' : '百分比' }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $rule->bonus_type == 'fixed' ? '$'.number_format($rule->bonus_value) : $rule->bonus_value.'%' }}</td>
<td class="px-6 py-4">
@if($rule->is_active)
<span class="px-2 py-1 rounded-full bg-green-100 text-green-800 text-xs">啟用</span>
@else
<span class="px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-xs">停用</span>
@endif
</td>
<td class="px-6 py-4">
<form action="{{ route('admin.deposit-bonus-rules.destroy', $rule) }}" method="POST" class="inline" onsubmit="return confirm('確定要刪除嗎?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800">刪除</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-4 text-center {{ $textSecondary }}">尚無資料</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Create Modal --}}
<div id="createModal" class="hidden fixed inset-0 z-50 overflow-y-auto" @keydown.escape.window="document.getElementById('createModal').classList.add('hidden')">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onclick="document.getElementById('createModal').classList.add('hidden')"></div>
<div class="relative {{ $cardBg }} rounded-lg shadow-xl transform transition-all sm:max-w-lg sm:w-full mx-4">
<div class="px-6 py-4 border-b {{ $borderColor }}">
<h3 class="{{ $textPrimary }} text-lg font-semibold">新增儲值回饋規則</h3>
</div>
<form action="{{ route('admin.deposit-bonus-rules.store') }}" method="POST">
@csrf
<div class="px-6 py-4 space-y-4">
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">名稱</label>
<input type="text" name="name" required class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">最低儲值金額</label>
<input type="number" name="min_amount" value="0" step="0.01" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">回饋類型</label>
<select name="bonus_type" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
<option value="fixed">固定金額</option>
<option value="percentage">百分比</option>
</select>
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">回饋值</label>
<input type="number" name="bonus_value" value="0" step="0.01" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div class="flex items-center">
<input type="checkbox" name="is_active" value="1" checked id="is_active" class="mr-2 rounded text-indigo-600 focus:ring-indigo-500">
<label for="is_active" class="{{ $textSecondary }} text-sm">啟用</label>
</div>
</div>
<div class="px-6 py-4 border-t {{ $borderColor }} flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('createModal').classList.add('hidden')" class="px-4 py-2 {{ $textSecondary }} border {{ $inputBorder }} rounded-md hover:bg-gray-100 dark:hover:bg-gray-700">取消</button>
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">建立</button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@@ -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'))
<div x-data="{ show: false }"
x-show="show"
x-cloak
x-init="setTimeout(() => { show = true; setTimeout(() => show = false, 3000) }, 50)"
x-transition:enter="transition cubic-bezier(0.34, 1.56, 0.64, 1) duration-300"
x-transition:enter-start="opacity-0 -translate-y-40"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-400"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-40"
class="fixed top-4 left-0 right-0 mx-auto w-max z-[100] bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{{ session('success') }}
</div>
@endif
<div class="container mx-auto px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h3 class="{{ $textPrimary }} text-3xl font-medium">禮品設定</h3>
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">
新增禮品
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full {{ $cardBg }} rounded-lg overflow-hidden">
<thead class="{{ $thBg }}">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">名稱</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">類型</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">數值</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">適用等級</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">觸發條件</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">狀態</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">操作</th>
</tr>
</thead>
<tbody class="divide-y {{ $borderColor }}">
@forelse($gifts as $gift)
<tr>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $gift->name }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $typeLabels[$gift->type] ?? $gift->type }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $gift->value }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $gift->tier?->name ?? '全部' }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $triggerLabels[$gift->trigger] ?? $gift->trigger }}</td>
<td class="px-6 py-4">
@if($gift->is_active)
<span class="px-2 py-1 rounded-full bg-green-100 text-green-800 text-xs">啟用</span>
@else
<span class="px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-xs">停用</span>
@endif
</td>
<td class="px-6 py-4">
<form action="{{ route('admin.gift-definitions.destroy', $gift) }}" method="POST" class="inline" onsubmit="return confirm('確定要刪除嗎?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800">刪除</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-4 text-center {{ $textSecondary }}">尚無資料</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Create Modal --}}
<div id="createModal" class="hidden fixed inset-0 z-50 overflow-y-auto" @keydown.escape.window="document.getElementById('createModal').classList.add('hidden')">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onclick="document.getElementById('createModal').classList.add('hidden')"></div>
<div class="relative {{ $cardBg }} rounded-lg shadow-xl transform transition-all sm:max-w-lg sm:w-full mx-4">
<div class="px-6 py-4 border-b {{ $borderColor }}">
<h3 class="{{ $textPrimary }} text-lg font-semibold">新增禮品</h3>
</div>
<form action="{{ route('admin.gift-definitions.store') }}" method="POST">
@csrf
<div class="px-6 py-4 space-y-4">
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">名稱</label>
<input type="text" name="name" required class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">類型</label>
<select name="type" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
@foreach($typeLabels as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">數值</label>
<input type="number" name="value" value="0" step="0.01" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">適用等級</label>
<select name="tier_id" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
<option value="">全部</option>
@foreach($tiers as $tier)
<option value="{{ $tier->id }}">{{ $tier->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">觸發條件</label>
<select name="trigger" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
@foreach($triggerLabels as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">有效天數</label>
<input type="number" name="validity_days" value="30" min="1" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div class="flex items-center">
<input type="checkbox" name="is_active" value="1" checked id="is_active" class="mr-2 rounded text-indigo-600 focus:ring-indigo-500">
<label for="is_active" class="{{ $textSecondary }} text-sm">啟用</label>
</div>
</div>
<div class="px-6 py-4 border-t {{ $borderColor }} flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('createModal').classList.add('hidden')" class="px-4 py-2 {{ $textSecondary }} border {{ $inputBorder }} rounded-md hover:bg-gray-100 dark:hover:bg-gray-700">取消</button>
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">建立</button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@@ -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
<div class="container mx-auto px-6 py-8">
<h3 class="{{ $textPrimary }} text-3xl font-medium">會員列表</h3>
<div class="mt-8">
{{-- 搜尋與篩選 (預留空間) --}}
<div class="flex flex-col mt-4">
<div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div class="align-middle inline-block min-w-full shadow overflow-hidden sm:rounded-lg border-b {{ $borderColor }}">
<table class="min-w-full">
<thead>
<tr>
<th class="px-6 py-3 border-b {{ $borderColor }} {{ $thBg }} text-left text-xs leading-4 font-medium {{ $textSecondary }} uppercase tracking-wider">
UUID
</th>
<th class="px-6 py-3 border-b {{ $borderColor }} {{ $thBg }} text-left text-xs leading-4 font-medium {{ $textSecondary }} uppercase tracking-wider">
姓名
</th>
<th class="px-6 py-3 border-b {{ $borderColor }} {{ $thBg }} text-left text-xs leading-4 font-medium {{ $textSecondary }} uppercase tracking-wider">
Email
</th>
<th class="px-6 py-3 border-b {{ $borderColor }} {{ $thBg }} text-left text-xs leading-4 font-medium {{ $textSecondary }} uppercase tracking-wider">
手機
</th>
<th class="px-6 py-3 border-b {{ $borderColor }} {{ $thBg }} text-left text-xs leading-4 font-medium {{ $textSecondary }} uppercase tracking-wider">
狀態
</th>
<th class="px-6 py-3 border-b {{ $borderColor }} {{ $thBg }} text-left text-xs leading-4 font-medium {{ $textSecondary }} uppercase tracking-wider">
註冊時間
</th>
</tr>
</thead>
<tbody class="{{ $cardBg }}">
@forelse ($members as $member)
<tr>
<td class="px-6 py-4 whitespace-no-wrap border-b {{ $borderColor }}">
<div class="text-sm leading-5 font-medium {{ $textSecondary }}">{{ $member->uuid }}</div>
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b {{ $borderColor }}">
<div class="text-sm leading-5 font-bold {{ $textPrimary }}">{{ $member->name }}</div>
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b {{ $borderColor }}">
<div class="text-sm leading-5 {{ $textPrimary }}">{{ $member->email ?? '-' }}</div>
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b {{ $borderColor }}">
<div class="text-sm leading-5 {{ $textPrimary }}">{{ $member->phone ?? '-' }}</div>
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b {{ $borderColor }}">
@if($member->is_active)
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
啟用
</span>
@else
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
停用
</span>
@endif
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b {{ $borderColor }} text-sm leading-5 {{ $textSecondary }}">
{{ $member->created_at->format('Y-m-d H:i') }}
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-4 whitespace-no-wrap border-b {{ $borderColor }} text-center {{ $textSecondary }}">
尚無會員資料
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-4">
{{ $members->links() }}
</div>
</div>
</div>
@endsection

View File

@@ -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'))
<div x-data="{ show: false }"
x-show="show"
x-cloak
x-init="setTimeout(() => { show = true; setTimeout(() => show = false, 3000) }, 50)"
x-transition:enter="transition cubic-bezier(0.34, 1.56, 0.64, 1) duration-300"
x-transition:enter-start="opacity-0 -translate-y-40"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-400"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-40"
class="fixed top-4 left-0 right-0 mx-auto w-max z-[100] bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{{ session('success') }}
</div>
@endif
<div class="container mx-auto px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h3 class="{{ $textPrimary }} text-3xl font-medium">會員等級設定</h3>
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">
新增等級
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full {{ $cardBg }} rounded-lg overflow-hidden">
<thead class="{{ $thBg }}">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">名稱</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">年費</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">折扣</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">點數倍率</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">預設</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">操作</th>
</tr>
</thead>
<tbody class="divide-y {{ $borderColor }}">
@forelse($tiers as $tier)
<tr>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $tier->name }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $tier->annual_fee == 0 ? '免費' : '$'.number_format($tier->annual_fee) }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $tier->discount_rate * 100 }}%</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $tier->point_multiplier }}x</td>
<td class="px-6 py-4">
@if($tier->is_default)
<span class="px-2 py-1 rounded-full bg-green-100 text-green-800 text-xs">預設</span>
@endif
</td>
<td class="px-6 py-4">
<form action="{{ route('admin.membership-tiers.destroy', $tier) }}" method="POST" class="inline" onsubmit="return confirm('確定要刪除嗎?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800">刪除</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-4 text-center {{ $textSecondary }}">尚無資料</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Create Modal --}}
<div id="createModal" class="hidden fixed inset-0 z-50 overflow-y-auto" x-data @keydown.escape.window="document.getElementById('createModal').classList.add('hidden')">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
{{-- 背景遮罩 --}}
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onclick="document.getElementById('createModal').classList.add('hidden')"></div>
{{-- Modal 內容 --}}
<div class="relative {{ $cardBg }} rounded-lg shadow-xl transform transition-all sm:max-w-lg sm:w-full mx-4">
<div class="px-6 py-4 border-b {{ $borderColor }}">
<h3 class="{{ $textPrimary }} text-lg font-semibold">新增會員等級</h3>
</div>
<form action="{{ route('admin.membership-tiers.store') }}" method="POST">
@csrf
<div class="px-6 py-4 space-y-4">
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">名稱</label>
<input type="text" name="name" required class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500 focus:border-transparent">
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">年費</label>
<input type="number" name="annual_fee" value="0" step="0.01" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">折扣比例 (0.95 = 95)</label>
<input type="number" name="discount_rate" value="1.00" step="0.01" min="0" max="1" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">點數倍率</label>
<input type="number" name="point_multiplier" value="1.00" step="0.01" min="0" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div class="flex items-center">
<input type="checkbox" name="is_default" value="1" id="is_default" class="mr-2 rounded text-indigo-600 focus:ring-indigo-500">
<label for="is_default" class="{{ $textSecondary }} text-sm">設為預設等級</label>
</div>
</div>
<div class="px-6 py-4 border-t {{ $borderColor }} flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('createModal').classList.add('hidden')" class="px-4 py-2 {{ $textSecondary }} border {{ $inputBorder }} rounded-md hover:bg-gray-100 dark:hover:bg-gray-700">取消</button>
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">建立</button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@@ -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'))
<div x-data="{ show: false }"
x-show="show"
x-cloak
x-init="setTimeout(() => { show = true; setTimeout(() => show = false, 3000) }, 50)"
x-transition:enter="transition cubic-bezier(0.34, 1.56, 0.64, 1) duration-300"
x-transition:enter-start="opacity-0 -translate-y-40"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-400"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-40"
class="fixed top-4 left-0 right-0 mx-auto w-max z-[100] bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{{ session('success') }}
</div>
@endif
<div class="container mx-auto px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h3 class="{{ $textPrimary }} text-3xl font-medium">點數規則設定</h3>
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">
新增規則
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full {{ $cardBg }} rounded-lg overflow-hidden">
<thead class="{{ $thBg }}">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">名稱</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">觸發條件</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">每單位點數</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">單位金額</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">有效天數</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">狀態</th>
<th class="px-6 py-3 text-left text-xs font-medium {{ $textSecondary }} uppercase">操作</th>
</tr>
</thead>
<tbody class="divide-y {{ $borderColor }}">
@forelse($rules as $rule)
<tr>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $rule->name }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $triggerLabels[$rule->trigger] ?? $rule->trigger }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $rule->points_per_unit }} </td>
<td class="px-6 py-4 {{ $textPrimary }}">${{ number_format($rule->unit_amount) }}</td>
<td class="px-6 py-4 {{ $textPrimary }}">{{ $rule->validity_days }} </td>
<td class="px-6 py-4">
@if($rule->is_active)
<span class="px-2 py-1 rounded-full bg-green-100 text-green-800 text-xs">啟用</span>
@else
<span class="px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-xs">停用</span>
@endif
</td>
<td class="px-6 py-4">
<form action="{{ route('admin.point-rules.destroy', $rule) }}" method="POST" class="inline" onsubmit="return confirm('確定要刪除嗎?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800">刪除</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-4 text-center {{ $textSecondary }}">尚無資料</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Create Modal --}}
<div id="createModal" class="hidden fixed inset-0 z-50 overflow-y-auto" @keydown.escape.window="document.getElementById('createModal').classList.add('hidden')">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onclick="document.getElementById('createModal').classList.add('hidden')"></div>
<div class="relative {{ $cardBg }} rounded-lg shadow-xl transform transition-all sm:max-w-lg sm:w-full mx-4">
<div class="px-6 py-4 border-b {{ $borderColor }}">
<h3 class="{{ $textPrimary }} text-lg font-semibold">新增點數規則</h3>
</div>
<form action="{{ route('admin.point-rules.store') }}" method="POST">
@csrf
<div class="px-6 py-4 space-y-4">
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">名稱</label>
<input type="text" name="name" required class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">觸發條件</label>
<select name="trigger" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
@foreach($triggerLabels as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">每單位獲得點數</label>
<input type="number" name="points_per_unit" value="1" min="1" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">單位金額</label>
<input type="number" name="unit_amount" value="100" step="0.01" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="{{ $textSecondary }} text-sm block mb-1">有效天數</label>
<input type="number" name="validity_days" value="365" min="1" class="w-full px-3 py-2 {{ $inputBg }} {{ $inputBorder }} border rounded-md {{ $textPrimary }} focus:ring-2 focus:ring-indigo-500">
</div>
<div class="flex items-center">
<input type="checkbox" name="is_active" value="1" checked id="is_active" class="mr-2 rounded text-indigo-600 focus:ring-indigo-500">
<label for="is_active" class="{{ $textSecondary }} text-sm">啟用</label>
</div>
</div>
<div class="px-6 py-4 border-t {{ $borderColor }} flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('createModal').classList.add('hidden')" class="px-4 py-2 {{ $textSecondary }} border {{ $inputBorder }} rounded-md hover:bg-gray-100 dark:hover:bg-gray-700">取消</button>
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">建立</button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@@ -80,14 +80,9 @@
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
<aside class="fixed inset-y-0 left-0 z-50 w-64 {{ $currentTheme['sidebar'] }} border-r lg:translate-x-0 lg:static lg:inset-0 overflow-y-auto" <aside class="fixed inset-y-0 left-0 z-50 w-64 {{ $currentTheme['sidebar'] }} border-r overflow-y-auto transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0"
:class="{'translate-x-0': sidebarOpen, '-translate-x-full': !sidebarOpen}" :class="{'translate-x-0': sidebarOpen, '-translate-x-full': !sidebarOpen}"
x-transition:enter="transition-transform ease-in-out duration-300" x-cloak>
x-transition:enter-start="-translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition-transform ease-in-out duration-300"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="-translate-x-full">
<div class="flex items-center justify-center h-16 {{ $currentTheme['header'] }} border-b"> <div class="flex items-center justify-center h-16 {{ $currentTheme['header'] }} border-b">
<span class="text-2xl font-bold text-{{ $currentTheme['accent'] }}-500">Star Cloud</span> <span class="text-2xl font-bold text-{{ $currentTheme['accent'] }}-500">Star Cloud</span>
</div> </div>

View File

@@ -24,11 +24,36 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('profile.edit') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('profile.edit') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">個人檔案</a> <a href="{{ route('profile.edit') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('profile.edit') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">個人檔案</a>
</div> </div>
</div> </div>
{{-- 會員管理 (新增) --}}
<div x-data="{
open: localStorage.getItem('menu_members') === 'true' || {{ request()->routeIs('admin.members.*') ? 'true' : 'false' }},
init() {
this.$watch('open', value => localStorage.setItem('menu_members', value))
}
}">
<button @click="open = !open" class="group flex items-center w-full px-2 py-2 text-sm font-medium rounded-md {{ $isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white' }}">
<svg class="mr-3 h-5 w-5 {{ $isLight ? 'text-gray-400 group-hover:text-gray-500' : 'text-gray-400 group-hover:text-gray-300' }}" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span class="flex-1 text-left">會員管理</span>
<svg class="ml-auto h-5 w-5 transform transition-transform duration-200" :class="{'rotate-90': open}" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.members.index') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.members.index') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">會員列表</a>
<a href="{{ route('admin.membership-tiers.index') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.membership-tiers.*') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">會員等級設定</a>
<a href="{{ route('admin.deposit-bonus-rules.index') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.deposit-bonus-rules.*') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">儲值回饋設定</a>
<a href="{{ route('admin.point-rules.index') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.point-rules.*') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">點數規則設定</a>
<a href="{{ route('admin.gift-definitions.index') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.gift-definitions.*') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">禮品設定</a>
</div>
</div>
{{-- 3. 機台管理 --}} {{-- 3. 機台管理 --}}
<div x-data="{ <div x-data="{
open: localStorage.getItem('menu_machines') === 'true' || {{ request()->routeIs('admin.machines.*') ? 'true' : 'false' }}, open: localStorage.getItem('menu_machines') === 'true' || {{ request()->routeIs('admin.machines.*') ? 'true' : 'false' }},
@@ -45,7 +70,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.machines.logs') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.machines.logs') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台日誌</a> <a href="{{ route('admin.machines.logs') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.machines.logs') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台日誌</a>
<a href="{{ route('admin.machines.index') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.machines.index') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台列表</a> <a href="{{ route('admin.machines.index') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.machines.index') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台列表</a>
<a href="{{ route('admin.machines.permissions') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.machines.permissions') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台權限</a> <a href="{{ route('admin.machines.permissions') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.machines.permissions') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-700 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台權限</a>
@@ -71,7 +96,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.app.ui-elements') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.app.ui-elements') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">UI元素設定</a> <a href="{{ route('admin.app.ui-elements') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.app.ui-elements') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">UI元素設定</a>
<a href="{{ route('admin.app.helper') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.app.helper') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">小幫手設定</a> <a href="{{ route('admin.app.helper') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.app.helper') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">小幫手設定</a>
<a href="{{ route('admin.app.questionnaire') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.app.questionnaire') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">問卷設定</a> <a href="{{ route('admin.app.questionnaire') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.app.questionnaire') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">問卷設定</a>
@@ -96,7 +121,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.warehouses.index') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.warehouses.index') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">倉庫列表(全部)</a> <a href="{{ route('admin.warehouses.index') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.warehouses.index') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">倉庫列表(全部)</a>
<a href="{{ route('admin.warehouses.personal') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.warehouses.personal') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">倉庫列表(個人)</a> <a href="{{ route('admin.warehouses.personal') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.warehouses.personal') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">倉庫列表(個人)</a>
<a href="{{ route('admin.warehouses.stock-management') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.warehouses.stock-management') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">庫存管理單</a> <a href="{{ route('admin.warehouses.stock-management') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.warehouses.stock-management') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">庫存管理單</a>
@@ -127,7 +152,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.sales.index') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.sales.index') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">銷售&金流紀錄</a> <a href="{{ route('admin.sales.index') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.sales.index') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">銷售&金流紀錄</a>
<a href="{{ route('admin.sales.pickup-codes') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.sales.pickup-codes') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">取貨碼設定</a> <a href="{{ route('admin.sales.pickup-codes') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.sales.pickup-codes') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">取貨碼設定</a>
<a href="{{ route('admin.sales.orders') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.sales.orders') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">購買單</a> <a href="{{ route('admin.sales.orders') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.sales.orders') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">購買單</a>
@@ -153,7 +178,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.analysis.change-stock') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.analysis.change-stock') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">零錢庫存分析</a> <a href="{{ route('admin.analysis.change-stock') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.analysis.change-stock') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">零錢庫存分析</a>
<a href="{{ route('admin.analysis.machine-reports') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.analysis.machine-reports') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台報表分析</a> <a href="{{ route('admin.analysis.machine-reports') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.analysis.machine-reports') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台報表分析</a>
<a href="{{ route('admin.analysis.product-reports') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.analysis.product-reports') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">商品報表分析</a> <a href="{{ route('admin.analysis.product-reports') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.analysis.product-reports') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">商品報表分析</a>
@@ -177,7 +202,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.audit.purchases') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.audit.purchases') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">採購單稽核</a> <a href="{{ route('admin.audit.purchases') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.audit.purchases') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">採購單稽核</a>
<a href="{{ route('admin.audit.transfers') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.audit.transfers') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">調撥單稽核</a> <a href="{{ route('admin.audit.transfers') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.audit.transfers') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">調撥單稽核</a>
<a href="{{ route('admin.audit.replenishments') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.audit.replenishments') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">補貨單稽核</a> <a href="{{ route('admin.audit.replenishments') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.audit.replenishments') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">補貨單稽核</a>
@@ -201,7 +226,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.data-config.products') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.data-config.products') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">商品管理</a> <a href="{{ route('admin.data-config.products') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.data-config.products') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">商品管理</a>
<a href="{{ route('admin.data-config.advertisements') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.data-config.advertisements') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">廣告管理</a> <a href="{{ route('admin.data-config.advertisements') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.data-config.advertisements') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">廣告管理</a>
<a href="{{ route('admin.data-config.admin-products') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.data-config.admin-products') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">管理者可賣商品</a> <a href="{{ route('admin.data-config.admin-products') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.data-config.admin-products') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">管理者可賣商品</a>
@@ -229,7 +254,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.remote.stock') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.remote.stock') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台庫存</a> <a href="{{ route('admin.remote.stock') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.remote.stock') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台庫存</a>
<a href="{{ route('admin.remote.restart') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.remote.restart') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台重啟</a> <a href="{{ route('admin.remote.restart') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.remote.restart') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">機台重啟</a>
<a href="{{ route('admin.remote.restart-card-reader') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.remote.restart-card-reader') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">卡機重啟</a> <a href="{{ route('admin.remote.restart-card-reader') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.remote.restart-card-reader') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">卡機重啟</a>
@@ -256,7 +281,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.line.members') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.line.members') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line會員管理</a> <a href="{{ route('admin.line.members') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.line.members') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line會員管理</a>
<a href="{{ route('admin.line.machines') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.line.machines') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line機台管理</a> <a href="{{ route('admin.line.machines') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.line.machines') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line機台管理</a>
<a href="{{ route('admin.line.products') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.line.products') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line商城商品</a> <a href="{{ route('admin.line.products') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.line.products') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line商城商品</a>
@@ -282,7 +307,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.reservation.members') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.reservation.members') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line會員管理</a> <a href="{{ route('admin.reservation.members') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.reservation.members') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line會員管理</a>
<a href="{{ route('admin.reservation.stores') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.reservation.stores') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line店家管理</a> <a href="{{ route('admin.reservation.stores') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.reservation.stores') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line店家管理</a>
<a href="{{ route('admin.reservation.time-slots') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.reservation.time-slots') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line時段組合</a> <a href="{{ route('admin.reservation.time-slots') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.reservation.time-slots') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Line時段組合</a>
@@ -309,7 +334,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.special-permission.clear-stock') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.special-permission.clear-stock') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">庫存清空</a> <a href="{{ route('admin.special-permission.clear-stock') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.special-permission.clear-stock') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">庫存清空</a>
<a href="{{ route('admin.special-permission.apk-versions') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.special-permission.apk-versions') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">APK版本管理</a> <a href="{{ route('admin.special-permission.apk-versions') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.special-permission.apk-versions') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">APK版本管理</a>
<a href="{{ route('admin.special-permission.discord-notifications') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.special-permission.discord-notifications') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Discord通知設定</a> <a href="{{ route('admin.special-permission.discord-notifications') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.special-permission.discord-notifications') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">Discord通知設定</a>
@@ -332,7 +357,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div x-show="open" x-collapse class="mt-1 space-y-1"> <div x-show="open" x-collapse.duration.500ms class="mt-1 space-y-1">
<a href="{{ route('admin.permission.app-features') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.permission.app-features') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">APP功能管理</a> <a href="{{ route('admin.permission.app-features') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.permission.app-features') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">APP功能管理</a>
<a href="{{ route('admin.permission.data-config') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.permission.data-config') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">資料設定</a> <a href="{{ route('admin.permission.data-config') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.permission.data-config') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">資料設定</a>
<a href="{{ route('admin.permission.sales') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.permission.sales') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">銷售管理</a> <a href="{{ route('admin.permission.sales') }}" @click="if (window.innerWidth < 1024) sidebarOpen = false" class="group flex items-center pl-14 pr-2 py-2 text-sm rounded-md {{ request()->routeIs('admin.permission.sales') ? 'bg-'.$currentTheme['accent'].'-600 text-white' : ($isLight ? 'text-gray-600 hover:bg-gray-100' : 'text-gray-300 hover:bg-gray-700 hover:text-white') }}" style="padding-left: 3.5rem;">銷售管理</a>

View File

@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>社群登入測試</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/1.6.2/axios.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center p-6">
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-2xl">
<h1 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-4">Star Cloud 社群登入實測</h1>
@if(isset($line_data))
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded text-sm break-all">
<h3 class="font-bold text-green-700 mb-2">Line Callback Data</h3>
<pre>{{ json_encode($line_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
<p class="mt-2 text-gray-600">請將上方 code 透過後端 API 交換 access_token再呼叫 /social-login API。</p>
</div>
@endif
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Google Section -->
<div class="bg-gray-50 p-4 rounded border">
<h2 class="font-semibold text-lg mb-4 text-blue-600">Google Login</h2>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Client ID</label>
<input type="text" id="google-client-id" class="w-full p-2 border rounded text-sm" placeholder="YOUR_GOOGLE_CLIENT_ID">
</div>
<div id="g_id_onload"
data-context="signin"
data-ux_mode="popup"
data-callback="handleGoogleCredentialResponse"
data-auto_prompt="false">
</div>
<div class="g_id_signin"
data-type="standard"
data-shape="rectangular"
data-theme="outline"
data-text="signin_with"
data-size="large"
data-logo_alignment="left">
</div>
<button onclick="initGoogle()" class="mt-4 w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 text-sm">
初始化 Google 按鈕
</button>
</div>
<!-- Line Section -->
<div class="bg-gray-50 p-4 rounded border">
<h2 class="font-semibold text-lg mb-4 text-green-600">Line Login</h2>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Channel ID</label>
<input type="text" id="line-channel-id" class="w-full p-2 border rounded text-sm" placeholder="1234567890">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Redirect URI</label>
<input type="text" id="line-redirect-uri" class="w-full p-2 border rounded text-sm" value="{{ url('/test/line/callback') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">State (Random)</label>
<input type="text" id="line-state" class="w-full p-2 border rounded text-sm" value="{{ Str::random(10) }}" readonly>
</div>
</div>
<button onclick="startLineLogin()" class="mt-6 w-full bg-green-500 text-white py-2 rounded hover:bg-green-600 font-bold">
Log in with Line
</button>
</div>
</div>
<!-- API Result -->
<div class="mt-8 border-t pt-6">
<h2 class="font-semibold text-lg mb-4 text-gray-800">API 執行結果 (/api/members/social-login)</h2>
<div id="api-result" class="bg-gray-900 text-green-400 p-4 rounded font-mono text-sm h-64 overflow-y-auto">
Waiting for action...
</div>
</div>
</div>
<script>
// Google Initialization
function initGoogle() {
const clientId = document.getElementById('google-client-id').value;
if (!clientId) {
alert('請輸入 Google Client ID');
return;
}
const wrapper = document.getElementById('g_id_onload');
wrapper.setAttribute('data-client_id', clientId);
// Re-render button if SDK already loaded
if (window.google) {
google.accounts.id.initialize({
client_id: clientId,
callback: handleGoogleCredentialResponse
});
google.accounts.id.renderButton(
document.querySelector(".g_id_signin"),
{ theme: "outline", size: "large" }
);
}
}
function handleGoogleCredentialResponse(response) {
console.log("Google JWT ID Token: " + response.credential);
logResult("收到 Google ID Token...");
// 解析 JWT (簡單解碼,正式環境應由後端驗證)
const payload = parseJwt(response.credential);
logResult("解析 JWT:\n" + JSON.stringify(payload, null, 2));
// 呼叫後端 API
callSocialLoginApi({
provider: 'google',
provider_id: payload.sub,
email: payload.email,
name: payload.name,
avatar: payload.picture,
access_token: response.credential // 這裡暫傳 id_token
});
}
// Line Login Logic
function startLineLogin() {
const channelId = document.getElementById('line-channel-id').value;
const redirectUri = document.getElementById('line-redirect-uri').value;
const state = document.getElementById('line-state').value;
if (!channelId) {
alert('請輸入 Line Channel ID');
return;
}
const url = `https://access.line.me/oauth2/v2.1/authorize?response_type=code&client_id=${channelId}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}&scope=profile%20openid%20email`;
window.location.href = url;
}
// API Call
async function callSocialLoginApi(data) {
logResult("呼叫 API: /api/members/social-login...");
try {
const response = await axios.post('/api/members/social-login', data);
logResult("API 回傳成功:\n" + JSON.stringify(response.data, null, 2));
} catch (error) {
logResult("API 錯誤:\n" + JSON.stringify(error.response ? error.response.data : error.message, null, 2));
}
}
// Utilities
function parseJwt (token) {
var base64Url = token.split('.')[1];
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
var jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
}
function logResult(msg) {
const el = document.getElementById('api-result');
el.innerText = msg + "\n\n" + el.innerText;
}
</script>
</body>
</html>

View File

@@ -2,6 +2,7 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\MemberController;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -17,3 +18,23 @@ use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->get('/user', function (Request $request) { Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user(); return $request->user();
}); });
/*
|--------------------------------------------------------------------------
| 會員 API Routes
|--------------------------------------------------------------------------
*/
// 公開路由(無需認證)
Route::prefix('members')->group(function () {
Route::post('/register', [MemberController::class, 'register']);
Route::post('/login', [MemberController::class, 'login']);
Route::post('/social-login', [MemberController::class, 'socialLogin']);
});
// 需認證路由
Route::prefix('members')->middleware('auth:sanctum')->group(function () {
Route::get('/profile', [MemberController::class, 'profile']);
Route::put('/profile', [MemberController::class, 'updateProfile']);
Route::post('/logout', [MemberController::class, 'logout']);
});

View File

@@ -26,6 +26,13 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
// 1. 儀表板 // 1. 儀表板
Route::get('/dashboard', [App\Http\Controllers\Admin\DashboardController::class, 'index'])->name('dashboard'); Route::get('/dashboard', [App\Http\Controllers\Admin\DashboardController::class, 'index'])->name('dashboard');
// 2. 會員管理
Route::resource('members', App\Http\Controllers\MemberController::class)->only(['index']);
Route::resource('membership-tiers', App\Http\Controllers\Admin\MembershipTierController::class)->except(['show', 'create', 'edit']);
Route::resource('deposit-bonus-rules', App\Http\Controllers\Admin\DepositBonusRuleController::class)->except(['show', 'create', 'edit']);
Route::resource('point-rules', App\Http\Controllers\Admin\PointRuleController::class)->except(['show', 'create', 'edit']);
Route::resource('gift-definitions', App\Http\Controllers\Admin\GiftDefinitionController::class)->except(['show', 'create', 'edit']);
// 3. 機台管理 // 3. 機台管理
Route::prefix('machines')->name('machines.')->group(function () { Route::prefix('machines')->name('machines.')->group(function () {
Route::get('/logs', [App\Http\Controllers\Admin\MachineController::class, 'logs'])->name('logs'); Route::get('/logs', [App\Http\Controllers\Admin\MachineController::class, 'logs'])->name('logs');
@@ -165,3 +172,9 @@ Route::middleware('auth')->group(function () {
}); });
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';
// 測試路由 (需非正式環境或有特別權限控管)
Route::prefix('test')->name('test.')->group(function () {
Route::get('/social-login', [App\Http\Controllers\SocialLoginTestController::class, 'index'])->name('social-login');
Route::get('/line/callback', [App\Http\Controllers\SocialLoginTestController::class, 'lineCallback'])->name('line.callback');
});

View File

@@ -8,6 +8,7 @@ export default {
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
'./storage/framework/views/*.php', './storage/framework/views/*.php',
'./resources/views/**/*.blade.php', './resources/views/**/*.blade.php',
'./node_modules/preline/preline.js',
], ],
safelist: [ safelist: [