[FIX] 補齊與優化來店禮、通行碼、取貨碼之多語系與介面欄位呈現

1. 移除非必要之 Log 備註:從來店禮使用記錄 (Logs) 的電腦版與行動版畫面中,徹底移除了無業務參考價值的技術備註 (Remark) 欄位,優化排版空間以符合極簡奢華風 UI 設計。
2. 補齊與對齊三語系檔案:補齊來店禮、通行碼與商品領取所缺少的多語系 Key,三個語系 JSON 檔 (zh_TW, en, ja) 的 Key 與行數已 100% 完美對齊 (共 2,255 行) 且完成升冪字母排序 (ksort)。
3. 簡化 Guest 票券大標題:簡化 Guest 端三種票券頁面 (通行碼、取貨碼、來店禮) 的大標題多語系拼接結構,改為單一乾淨的翻譯 Key 呼叫。
4. 口語化翻譯微調:將 Pickup Code 在語系檔中的中文翻譯由「領取碼」微調為更口語、直覺的「取貨碼」。
5. 優化 Guest 頁面欄位:將取貨碼頁面的「機台序號」替換為更具溫度的「機台名稱」;通行碼與來店禮頁面則徹底隱藏機台序號,優化用戶體驗。
This commit is contained in:
sky121113 2026-05-22 14:46:26 +08:00
parent 016cb571c4
commit f59efc6dc7
27 changed files with 4021 additions and 1017 deletions

View File

@ -38,7 +38,7 @@ trigger: always_on
* **樣式與組件**:全面使用 Tailwind CSS utility classes遵循 Preline UI 的 HTML 結構進行開發。**避免撰寫自訂 CSS**。
* **互動邏輯**:優先使用輕量級 **Alpine.js** (`x-data`, `x-show`, `@click`) 於 Blade 內完成,**禁用 React / Vue / Inertia.js**。
* **【警告Preline 冗餘】**Preline 官方範例常有多餘控制項。必須確保介面佈局極簡,嚴格遵守「極簡奢華風 UI 實作規範 (SKILL.md)」。
* **多語系 (I18n)**:所有 UI 可見文字必須使用 `@lang('key')``__('key')` 處理。Key 統一採用**英文原始詞彙**,並同步更新 `zh_TW.json`, `en.json`, `ja.json`
* **多語系 (I18n)**:所有 UI 可見文字必須使用 `@lang('key')``__('key')` 處理。Key 統一採用**英文原始詞彙**,並同步更新 `zh_TW.json`, `en.json`, `ja.json`必須嚴格遵守「多語系管理與自動對齊規範 (SKILL.md)」,以 `zh_TW.json` 為標準對齊三語系,所有 JSON 檔案須按字母排序 (ksort),且斜線一律使用 `JSON_UNESCAPED_SLASHES` 還原為 `/`,嚴禁逸出為 `\/`
## 5. 運行與測試機制
* **啟動環境**`./vendor/bin/sail up -d`

View File

@ -19,6 +19,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
| 介面, UI, 設計, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫, 畫面, 頁面 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` |
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
| RBAC, 權限, 角色, 租戶, Tenant, Company, Access Control, 多租戶, 權限控管 | **多租戶與權限架構實作規範** | `.agents/rules/rbac-rules.md` |
| 多語系, 翻譯, 語系檔, lang, json, zh_TW, en, ja, I18n | **多語系管理與自動對齊規範** | `.agents/skills/multilingual-specs/SKILL.md` |
---
@ -42,4 +43,8 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
必須讀取:
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
### 🔴 新增或修改語系 JSON 檔案lang/*.json或執行多語系任務時
必須讀取:
1. **multilingual-specs** — 確保遵守三語系對齊、字母排序、斜線不逸出規範

View File

@ -234,11 +234,49 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- `status`: "active"。
> [!WARNING]
> **鎖定機制**B660, B670 與 B680 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘。
> **鎖定機制**B660, B670, B680 與 B690 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘。
---
### 3.12 B027: 贈品碼/優惠券驗證 (Free Gift Verify)
### 3.12 B690: 來店禮驗證 (Welcome Gift Verify)
處理機台端的 8 位數來店禮代碼驗證。成功後回傳折扣資訊供機台端套用折扣。
- **URL**: POST /api/v1/app/machine/welcome-gift/verify/B690
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| code | String | 是 | 8 位數來店禮代碼 | "12345678" |
**Response (Success 200):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | true |
| code | Integer | 業務狀態碼 | 200 |
| data.name | String | 來店禮名稱 | 新客八五折 |
| data.code_id | Integer | 來店禮 ID (用於 MQTT 交易關聯) | 12 |
| data.discount_type | String | 折扣類型 (percentage / amount) | percentage |
| data.discount_value | Integer | 折扣趴數 (如 15 代表 15% off) 或折扣金額 | 15 |
| data.discount_label | String | 台灣中文折數標籤 (如「八五折」、「折 50 元」) | 八五折 |
| data.usage_type | String | 使用類型 (once / unlimited) | once |
| data.status | String | 狀態 | active |
**Response (Failed 404):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | false |
| message | String | 失敗原因 | Invalid or expired code |
| code | Integer | 業務狀態碼 | 404 |
| remaining_attempts | Integer | 剩餘嘗試次數 | 3 |
- **消耗機制**: 出貨完成後,由機台發送 MQTT `finalize` 訊息,並在 `payment_type` 帶入 `7` (來店禮) 完成扣減。
---
### 3.14 B027: 贈品碼/優惠券驗證 (Free Gift Verify)
用於處理行銷贈品券、0 元購活動或特定的優惠券核銷流程。
- **URL**: POST /api/v1/app/sell/free-gift/B027
@ -249,7 +287,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
---
### 3.13 B650: 會員身分驗證 (Member Verification)
### 3.15 B650: 會員身分驗證 (Member Verification)
機台端掃碼後驗證會員身分。
- **URL**: POST /api/v1/app/machine/member/verify/B650

View File

@ -0,0 +1,161 @@
# 多語系管理與自動對齊規範 (Multilingual Specs)
本技能規範定義了 Star Cloud 系統中所有多語系 JSON 檔案(位於 `lang/`)的維護標準。當專案需要新增、修改或校對多語系時,必須嚴格遵守此規範。
---
## 核心三大原則 (CRITICAL RULES)
### 1. 三語系完美對齊 (Master-Align)
- **以 `zh_TW.json` 為唯一標準**:不論任何時候新增或修改翻譯 Key都必須以 `lang/zh_TW.json` 的鍵值Keys集合為絕對標準。
- **三檔對齊**`lang/en.json` 與 `lang/ja.json` 的鍵值集合必須與 `zh_TW.json` 百分之百相同(行數、數量與 Key 的拼寫必須完全一致)。
### 2. 字母順序排序 (ksort)
- 為避免 Git 版本控制產生大範圍的亂序 Diff三個 JSON 檔案中的所有鍵值對,必須一律按 **Key 的英文字母升冪順序** 進行排序(類似 PHP 的 `ksort()`)。
### 3. 斜線不逸出與美化 (No Escaped Slashes)
- **禁止使用預設 `json_encode` 逸出斜線**JSON 檔案中的網址或路徑斜線一律使用 `/`,嚴禁產生 `\/`
- **儲存格式化要求**
在寫入語系 JSON 檔案時,必須使用以下 JSON 編碼參數:
```php
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
```
---
## 自動化對齊工具 (I18n Alignment Tool)
當處理語系變更時AI 助手應在專案的 `scratch/` 目錄中建立一個臨時 PHP 腳本(例如 `scratch/align_lang.php`),執行對齊並排序後,**立即將其刪除**。
### 🔧 臨時對齊腳本範本 (`scratch/align_lang.php`)
```php
<?php
/**
* Star Cloud - 多語系自動對齊與排序工具
* 執行指令php scratch/align_lang.php
*/
$langDir = __DIR__ . '/../lang/';
$zhPath = $langDir . 'zh_TW.json';
$enPath = $langDir . 'en.json';
$jaPath = $langDir . 'ja.json';
if (!file_exists($zhPath)) {
die("❌ 找不到 zh_TW.json 主語系檔!\n");
}
$zhData = json_decode(file_get_contents($zhPath), true);
$enData = file_exists($enPath) ? json_decode(file_get_contents($enPath), true) : [];
$jaData = file_exists($jaPath) ? json_decode(file_get_contents($jaPath), true) : [];
// 1. 精準翻譯對照字典 (用於自動補齊常見系統/機台特有詞彙)
$translationDict = [
// 通行碼/機台/權限相關
"Channel Limits Config" => [
"en" => "Channel Limits Config",
"ja" => "チャンネル制限設定"
],
"Connection error" => [
"en" => "Connection error",
"ja" => "接続エラー"
],
"Passcode settings" => [
"en" => "Passcode settings",
"ja" => "パスコード設定"
],
"Reset Passcode" => [
"en" => "Reset Passcode",
"ja" => "パスコードをリセット"
],
"Update Passcode" => [
"en" => "Update Passcode",
"ja" => "パスコードを更新"
],
"Machine Authorization" => [
"en" => "Machine Authorization",
"ja" => "デバイス授権"
],
"Status" => [
"en" => "Status",
"ja" => "ステータス"
],
"Actions" => [
"en" => "Actions",
"ja" => "操作"
]
];
$missingEnCount = 0;
$missingJaCount = 0;
$newEnData = [];
$newJaData = [];
// 2. 以 zh_TW 的 keys 為基準對齊 en 與 ja
foreach ($zhData as $key => $zhVal) {
// 處理英文
if (isset($enData[$key]) && trim($enData[$key]) !== '') {
$newEnData[$key] = $enData[$key];
} else {
// 嘗試從精準字典查找
if (isset($translationDict[$key]["en"])) {
$newEnData[$key] = $translationDict[$key]["en"];
} else {
// fallback: 使用 key 原始名稱作為英文
$newEnData[$key] = $key;
}
$missingEnCount++;
}
// 處理日文
if (isset($jaData[$key]) && trim($jaData[$key]) !== '') {
$newJaData[$key] = $jaData[$key];
} else {
// 嘗試從精準字典查找
if (isset($translationDict[$key]["ja"])) {
$newJaData[$key] = $translationDict[$key]["ja"];
} else {
// fallback: 如果 key 與英文相同,且非中文,可先 fallback 英文,後續再由 LLM 補譯
$newJaData[$key] = $key;
}
$missingJaCount++;
}
}
// 3. 字母排序 (ksort)
ksort($zhData);
ksort($newEnData);
ksort($newJaData);
// 4. 寫回檔案 (反逸出斜線 + Unicode)
$jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
file_put_contents($zhPath, json_encode($zhData, $jsonFlags) . "\n");
file_put_contents($enPath, json_encode($newEnData, $jsonFlags) . "\n");
file_put_contents($jaPath, json_encode($newJaData, $jsonFlags) . "\n");
echo "✅ 多語系處理完畢!\n";
echo "📊 統計結果:\n";
echo " - 總共鍵數: " . count($zhData) . " 個\n";
echo " - 補齊英文空缺: {$missingEnCount} 個\n";
echo " - 補齊日文空缺: {$missingJaCount} 個\n";
```
---
## 💡 AI 助手處理語系之 SOP 流程
當使用者要求修改或補齊語系時AI 必須:
1. **同步更新/新增 `zh_TW.json`**:將新的詞彙加入 `zh_TW.json` 中。
2. **利用 LLM 翻譯補足空缺**
- 針對新加入的詞彙,由 AI 運用其 LLM 能力將高品質的英文(`en`)與日文(`ja`)翻譯出來。
3. **擴充對齊腳本的暫存字典**
- 將這些新翻譯的詞彙暫時寫入臨時對齊腳本 `scratch/align_lang.php` 中的 `$translationDict` 中。
4. **執行一鍵對齊與字母排序**
- 在終端機執行 `php scratch/align_lang.php`,確保三個語系 JSON 檔案行數百分之百對齊、全部排序、斜線清空。
5. **清理暫存**
- 刪除 `scratch/align_lang.php`
6. **Git 檢查與提交**
- 執行 `git diff` 確認修改範疇僅限於新增的語系,無亂序與無關的語系被刪除。

View File

@ -13,6 +13,8 @@ use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PickupCodeLog;
use App\Models\Transaction\PassCode;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use App\Models\Machine\Machine;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
@ -596,12 +598,262 @@ class SalesController extends Controller
}
// 來店禮設定
public function storeGifts()
public function storeGifts(Request $request)
{
return view('admin.placeholder', [
$tab = $request->input('tab', 'list');
$isAjax = $request->ajax();
$data = [
'title' => '來店禮設定',
'description' => '來店優惠活動設定',
'description' => '來店禮優惠碼管理 (單次與無限制來店優惠折抵)',
'tab' => $tab,
];
// 1. 來店禮列表 (list)
if (!$isAjax || $tab === 'list') {
$query = WelcomeGift::with(['machine', 'creator'])->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhere('name', 'like', "%{$request->search}%")
->orWhereHas('machine', function ($mq) use ($request) {
$mq->where('name', 'like', "%{$request->search}%")
->orWhere('serial_no', 'like', "%{$request->search}%");
});
});
}
if ($request->status && trim($request->status) !== '') {
$status = trim($request->status);
if ($status === 'active') {
$query->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
} elseif ($status === 'expired') {
$query->where('status', 'active')
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
} else {
$query->where('status', $status);
}
}
$data['welcomeGifts'] = $query->paginate($request->input('per_page', 10), ['*'], 'list_page')->withQueryString();
$data['machines'] = Machine::all();
}
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no']);
if ($request->filled('search_log')) {
$search = $request->input('search_log');
$logQuery->where(function ($q) use ($search) {
$q->where('remark', 'like', "%{$search}%")
->orWhereHas('welcomeGift', function($pq) use ($search) {
$pq->where('code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
});
}
if ($request->filled('action')) {
$logQuery->where('action', $request->action);
}
// 日期區間篩選 (6 個月限制)
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
if (!$startDate && !$endDate) {
$logQuery->where('created_at', '>=', now()->subMonths(6));
} else {
try {
if ($startDate) {
$logQuery->where('created_at', '>=', \Carbon\Carbon::parse($startDate)->startOfMinute());
}
if ($endDate) {
$logQuery->where('created_at', '<=', \Carbon\Carbon::parse($endDate)->endOfMinute());
}
} catch (\Exception $e) { }
}
$data['logs'] = $logQuery->latest()
->paginate($request->input('per_page', 10), ['*'], 'log_page')
->withQueryString();
// 定義可用動作
$data['actions'] = [
'verify_success' => __('verify_success'),
'consume' => __('consume'),
'cancel' => __('cancel'),
];
}
if ($isAjax) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.sales.welcome-gifts.partials.tab-' . $tab, $data)->render()
]);
}
return view('admin.sales.welcome-gifts.index', $data);
}
/**
* 新增來店禮
*/
public function storeWelcomeGift(Request $request)
{
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'name' => 'required|string|max:100',
'discount_type' => 'required|in:percentage,amount',
'discount_val_input' => 'required|numeric|min:0.1', // 接受折數 (如 8.5) 或 金額 (如 50)
'usage_type' => 'required|in:once,unlimited',
'usage_limit' => 'nullable|integer|min:1',
'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:10',
]);
$machine = Machine::findOrFail($validated['machine_id']);
// 關鍵自動換算邏輯
$discountValue = 0;
if ($validated['discount_type'] === 'percentage') {
// 使用者輸入折數 (如 8 代表打八折8.5 代表打八五折)
$fold = (float) $validated['discount_val_input'];
// 換算為趴數,公式為 (10 - 折數) * 10 ➜ 例如 8.5折 ➜ (10 - 8.5) * 10 = 15% off
$discountValue = (int) round((10 - $fold) * 10);
} else {
// 金額折抵 (如 50 代表折 50 元)
$discountValue = (int) $validated['discount_val_input'];
}
$welcomeGift = WelcomeGift::create([
'machine_id' => $validated['machine_id'],
'name' => $validated['name'],
'code' => $validated['custom_code'] ?? WelcomeGift::generateUniqueCode($validated['machine_id']),
'discount_type' => $validated['discount_type'],
'discount_value' => $discountValue,
'usage_type' => $validated['usage_type'],
'usage_limit' => $validated['usage_type'] === 'once' ? ($validated['usage_limit'] ?? 1) : null,
'expires_at' => $validated['expires_at'],
'status' => 'active',
'company_id' => $machine->company_id,
'created_by' => Auth::id(),
]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'create',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift created successfully.'));
}
/**
* 更新來店禮
*/
public function updateWelcomeGift(Request $request, WelcomeGift $welcomeGift)
{
$validated = $request->validate([
'name' => 'nullable|string|max:100',
'discount_type' => 'nullable|in:percentage,amount',
'discount_val_input' => 'nullable|numeric|min:0.1',
'expires_at' => 'nullable|date',
'status' => 'nullable|in:active,disabled',
'usage_type' => 'nullable|in:once,unlimited',
'usage_limit' => 'nullable|integer|min:1',
]);
if (isset($validated['expires_at']) && empty($validated['expires_at'])) {
$validated['expires_at'] = null;
}
$oldValues = $welcomeGift->toArray();
// 整理要更新的資料
$updateData = [];
if ($request->has('name')) {
$updateData['name'] = $validated['name'];
}
if ($request->has('status')) {
$updateData['status'] = $validated['status'];
}
if ($request->has('expires_at')) {
$updateData['expires_at'] = $validated['expires_at'];
}
if ($request->has('usage_type')) {
$updateData['usage_type'] = $validated['usage_type'];
if ($validated['usage_type'] === 'unlimited') {
$updateData['usage_limit'] = null;
} elseif ($request->has('usage_limit')) {
$updateData['usage_limit'] = $validated['usage_limit'];
}
} elseif ($request->has('usage_limit') && $welcomeGift->usage_type === 'once') {
$updateData['usage_limit'] = $validated['usage_limit'];
}
// 折扣折數/金額更新
if ($request->has('discount_type') || $request->has('discount_val_input')) {
$discountType = $validated['discount_type'] ?? $welcomeGift->discount_type;
$discountValInput = $validated['discount_val_input'] ?? ($discountType === 'percentage' ? $welcomeGift->input_fold : $welcomeGift->discount_value);
$updateData['discount_type'] = $discountType;
if ($discountType === 'percentage') {
$fold = (float) $discountValInput;
$updateData['discount_value'] = (int) round((10 - $fold) * 10);
} else {
$updateData['discount_value'] = (int) $discountValInput;
}
}
$welcomeGift->update($updateData);
SystemOperationLog::create([
'company_id' => $welcomeGift->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'update',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'old_values' => $oldValues,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift updated.'));
}
/**
* 刪除來店禮 (改為停用)
*/
public function destroyWelcomeGift(WelcomeGift $welcomeGift)
{
$oldValues = $welcomeGift->toArray();
$welcomeGift->update(['status' => 'disabled']);
SystemOperationLog::create([
'company_id' => $welcomeGift->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'cancel',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'old_values' => $oldValues,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift cancelled.'));
}
/**

View File

@ -20,6 +20,8 @@ use App\Models\Transaction\PassCode;
use App\Models\StaffCard;
use App\Models\StaffCardLog;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use App\Models\System\SystemOperationLog;
use App\Services\Machine\MachineService;
use App\Services\Product\ProductCatalogService;
@ -641,4 +643,73 @@ class MachineController extends Controller
]
]);
}
/**
* B690: Verify Welcome Gift (New)
*/
public function verifyWelcomeGift(Request $request)
{
$machine = $request->get('machine');
$lockoutKey = "welcome_gift_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Too many attempts. Locked.', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
}
$code = $request->input('code');
$gift = WelcomeGift::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->first();
if (!$gift || !$gift->isValid()) {
$fails = Cache::increment("welcome_gift_fails:{$machine->id}");
if ($fails === 1) {
Cache::put("welcome_gift_fails:{$machine->id}", 1, 600);
}
if ($fails >= 5) {
Cache::put($lockoutKey, true, 60);
Cache::forget("welcome_gift_fails:{$machine->id}");
}
return response()->json(['success' => false, 'message' => 'Invalid or expired code', 'code' => 404, 'remaining_attempts' => max(0, 5 - $fails)], 404);
}
Cache::forget("welcome_gift_fails:{$machine->id}");
// 建立來店禮驗證日誌 (驗證成功)
WelcomeGiftLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'welcome_gift_id' => $gift->id,
'action' => 'verify_success',
'remark' => 'log.welcome_gift.verify_success',
'raw_data' => [
'code' => $gift->code,
'discount_type' => $gift->discount_type,
'discount_value' => $gift->discount_value
]
]);
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $gift->name,
'code_id' => $gift->id,
'discount_type' => $gift->discount_type,
'discount_value' => $gift->discount_value,
'discount_label' => $gift->discount_label, // 回傳自動算好的中文折數 (如「八折」、「折50元」)
'usage_type' => $gift->usage_type,
'status' => 'active'
]
]);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Guest;
use App\Http\Controllers\Controller;
use App\Models\Transaction\WelcomeGift;
class WelcomeGiftController extends Controller
{
/**
* 顯示公開來店禮憑證頁面
*/
public function show($slug)
{
$welcomeGift = WelcomeGift::with(['machine'])
->where('slug', $slug)
->where('status', 'active')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->firstOrFail();
return view('guest.welcome-gift.show', compact('welcomeGift'));
}
}

View File

@ -36,6 +36,7 @@ class Order extends Model
'payment_at',
'member_barcode',
'code_id',
'welcome_gift_id',
'invoice_info',
'machine_time',
'status',
@ -141,4 +142,9 @@ class Order extends Model
{
return $this->hasOne(\App\Models\StaffCardLog::class);
}
public function welcomeGift()
{
return $this->belongsTo(WelcomeGift::class);
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace App\Models\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WelcomeGift extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'name',
'code',
'slug',
'discount_type',
'discount_value',
'usage_type',
'usage_limit',
'usage_count',
'expires_at',
'status',
'created_by',
];
protected $casts = [
'expires_at' => 'datetime',
'discount_value' => 'integer',
];
protected $appends = ['discount_label', 'input_fold'];
/**
* 獲取公開來店禮連結
*/
public function getTicketUrlAttribute()
{
return $this->slug ? route('welcome-gift.ticket', $this->slug) : null;
}
protected static function booted()
{
static::creating(function ($gift) {
if (!$gift->slug) {
$gift->slug = \Illuminate\Support\Str::random(16);
}
});
}
/**
* 後台編輯時回填使用的折數 (如趴數 20 換算回 8 15 換算回 8.5 )
*/
public function getInputFoldAttribute()
{
if ($this->discount_type !== 'percentage') {
return null;
}
return (100 - $this->discount_value) / 10;
}
/**
* 自動轉換為台灣消費者習慣的折數標籤 (例如:八折、八五折、折 50 )
*/
public function getDiscountLabelAttribute(): string
{
if ($this->discount_type === 'amount') {
return "{$this->discount_value}";
}
if ($this->discount_type === 'percentage') {
$fold = $this->input_fold;
return $this->convertToChineseDiscount($fold);
}
return '';
}
private function convertToChineseDiscount($fold): string
{
$map = [
'1' => '一', '2' => '二', '3' => '三', '4' => '四', '5' => '五',
'6' => '六', '7' => '七', '8' => '八', '9' => '九', '0' => '零'
];
$foldStr = (string)$fold;
if (strpos($foldStr, '.') !== false) {
list($integer, $decimal) = explode('.', $foldStr);
$intChar = $map[$integer] ?? $integer;
$decChar = $map[substr($decimal, 0, 1)] ?? substr($decimal, 0, 1);
return "{$intChar}{$decChar}";
}
$char = $map[$foldStr] ?? $foldStr;
return "{$char}";
}
/**
* 關聯機台
*/
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
/**
* 關聯建立者
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 判斷是否可用
*/
public function isValid(): bool
{
if ($this->status !== 'active') {
return false;
}
if ($this->expires_at && $this->expires_at->isPast()) {
return false;
}
if ($this->usage_type === 'once' && $this->usage_count >= ($this->usage_limit ?? 1)) {
return false;
}
return true;
}
/**
* 產生唯一的來店禮代碼 (8)
*/
public static function generateUniqueCode(int $machineId): string
{
do {
$code = str_pad(rand(0, 99999999), 8, '0', STR_PAD_LEFT);
$exists = self::where('machine_id', $machineId)
->where('code', $code)
->where('status', 'active')
->exists();
} while ($exists);
return $code;
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Models\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WelcomeGiftLog extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'welcome_gift_id',
'order_id',
'action',
'remark',
'raw_data',
];
protected $casts = [
'raw_data' => 'array',
];
/**
* 關聯來店禮
*/
public function welcomeGift(): BelongsTo
{
return $this->belongsTo(WelcomeGift::class);
}
/**
* 關聯機台
*/
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
/**
* 關聯訂單
*/
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
/**
* 關聯操作人員(一般是系統透過訂單綁定或背景核銷,如有操作人可由關聯取得,通常從 Order 或由 SystemOperationLog 獲取)
*/
}

View File

@ -12,6 +12,8 @@ use App\Models\StaffCardLog;
use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PickupCodeLog;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use Illuminate\Support\Facades\Log;
use App\Services\Machine\MachineService;
@ -63,6 +65,7 @@ class TransactionService
'payment_response' => $data['payment_response'] ?? null,
'member_barcode' => $data['member_barcode'] ?? null,
'code_id' => $data['code_id'] ?? null,
'welcome_gift_id' => $data['welcome_gift_id'] ?? null,
'invoice_info' => $data['invoice_info'] ?? null,
'machine_time' => $data['machine_time'] ?? now(),
'payment_at' => now(),
@ -346,6 +349,38 @@ class TransactionService
}
}
// 處理來店禮 (Welcome Gift) 自動核銷
$welcomeGiftId = $data['order']['welcome_gift_id'] ?? ($data['welcome_gift_id'] ?? null);
if ($welcomeGiftId) {
$welcomeGift = WelcomeGift::where('machine_id', $order->machine_id)
->where('id', $welcomeGiftId)
->where('status', 'active')
->first();
if ($welcomeGift) {
$newCount = $welcomeGift->usage_count + 1;
$isUsedUp = ($welcomeGift->usage_type === 'once' && $newCount >= ($welcomeGift->usage_limit ?? 1));
$welcomeGift->update([
'usage_count' => $newCount,
'status' => $isUsedUp ? 'used' : 'active',
]);
WelcomeGiftLog::create([
'company_id' => $order->company_id,
'machine_id' => $order->machine_id,
'welcome_gift_id' => $welcomeGift->id,
'order_id' => $order->id,
'action' => 'consume',
'remark' => "MQTT 交易完成自動核銷,訂單: #{$order->id}",
'raw_data' => [
'code' => $welcomeGift->code,
'count' => $newCount,
'type' => $welcomeGift->usage_type
]
]);
}
}
return $order;
});

View File

@ -409,30 +409,87 @@ return [
'notes' => '驗證成功後會建立刷卡日誌。連續錯誤同樣會觸發鎖定。'
],
[
'name' => 'B027: 贈品碼/優惠券驗證 (Free Gift Verify)',
'slug' => 'b027-freebie-code',
'name' => 'B690: 來店禮驗證 (Welcome Gift Verify)',
'slug' => 'b690-welcome-gift-verify',
'method' => 'POST',
'path' => '/api/v1/app/sell/free-gift/B027',
'description' => '驗證贈品券有效性。成功後回傳對應資訊,正式消耗改由 MQTT finalize 處理。',
'path' => '/api/v1/app/machine/welcome-gift/verify/B690',
'description' => '處理機台端的 8 位數來店禮代碼驗證。成功後回傳折扣資訊(含台灣中文折數標籤),供機台端套用折扣。正式消耗改由 MQTT finalize 處理 (payment_type=7)。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'passCode' => [
'code' => [
'type' => 'string',
'description' => '贈品碼/優惠券代碼',
'example' => 'FREE888'
'required' => true,
'description' => '8 位數來店禮代碼',
'example' => '12345678'
]
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '業務狀態碼',
'example' => 200
],
'data.name' => [
'type' => 'string',
'description' => '來店禮名稱',
'example' => '新客八五折'
],
'data.code_id' => [
'type' => 'integer',
'description' => '來店禮 ID (用於 MQTT 交易關聯payment_type=7 時帶入)',
'example' => 12
],
'data.discount_type' => [
'type' => 'string',
'description' => '折扣類型percentage (趴數折扣) 或 amount (金額折扣)',
'example' => 'percentage'
],
'data.discount_value' => [
'type' => 'integer',
'description' => '折扣數值。percentage 時為趴數 (如 15 代表 15% off)amount 時為折扣金額 (如 50)',
'example' => 15
],
'data.discount_label' => [
'type' => 'string',
'description' => '台灣中文折數標籤 (如「八五折」、「折 50 元」),供 UI 直接顯示',
'example' => '八五折'
],
'data.usage_type' => [
'type' => 'string',
'description' => '使用類型once (單次) 或 unlimited (無限制)',
'example' => 'once'
],
'data.status' => [
'type' => 'string',
'description' => '來店禮狀態',
'example' => 'active'
],
],
'request' => [
'code' => '12345678'
],
'response' => [
'success' => true,
'code' => 200,
'data' => [
'res1' => 'cloud_id_123',
'message' => 'Free gift verified'
'name' => '新客八五折',
'code_id' => 12,
'discount_type' => 'percentage',
'discount_value' => 15,
'discount_label' => '八五折',
'usage_type' => 'once',
'status' => 'active'
]
],
'notes' => '消耗流程已遷移至 MQTT。'
'notes' => '安全性:連續錯誤 5 次將鎖定該機台驗證功能 1 分鐘。消耗流程已遷移至 MQTT (payment_type=7)。'
],
],
],

View File

@ -0,0 +1,55 @@
<?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('welcome_gifts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->onDelete('cascade');
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->string('name', 100)->comment('來店禮名稱');
$table->string('code', 10)->comment('來店禮代碼 (8位)');
$table->string('slug', 16)->unique()->comment('票券 Slug');
$table->string('discount_type')->comment('折扣類型: percentage (趴數), amount (金額)');
$table->integer('discount_value')->comment('折扣值 (趴數或折抵金額)');
$table->string('usage_type')->default('once')->comment('使用次數: once (單次), unlimited (無限制)');
$table->integer('usage_limit')->nullable()->default(1)->comment('使用次數上限');
$table->integer('usage_count')->default(0)->comment('已使用次數');
$table->timestamp('expires_at')->nullable()->comment('到期時間');
$table->string('status')->default('active')->comment('狀態: active, disabled, used, expired');
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->index(['company_id', 'machine_id', 'code', 'status']);
});
Schema::create('welcome_gift_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->onDelete('cascade');
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->foreignId('welcome_gift_id')->constrained('welcome_gifts')->onDelete('cascade');
$table->foreignId('order_id')->nullable()->constrained('orders')->onDelete('set null');
$table->string('action')->comment('動作: verify_success, consume, cancel');
$table->string('remark')->nullable();
$table->json('raw_data')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('welcome_gift_logs');
Schema::dropIfExists('welcome_gifts');
}
};

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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->foreignId('welcome_gift_id')
->nullable()
->after('code_id')
->constrained('welcome_gifts')
->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropForeign(['welcome_gift_id']);
$table->dropColumn('welcome_gift_id');
});
}
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,913 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-2 pb-20" x-data="{
activeTab: '{{ $tab ?? "list" }}',
tabLoading: null,
showCreateModal: false,
showEditModal: false,
showDeleteModal: false,
showQrModal: false,
deleteTargetForm: '',
activeQrCode: '',
activeTicketUrl: '',
selectedMachine: '',
giftName: '',
discountType: 'percentage',
discountValInput: '',
usageType: 'once',
usageLimit: 1,
customCode: '',
// Create validity/expiry states
expiryMode: 'permanent',
expiresDays: 7,
customExpiry: '',
editFormAction: '',
editCustomCode: '',
editGiftName: '',
editDiscountType: 'percentage',
editDiscountValInput: '',
editUsageType: 'once',
editUsageLimit: 1,
// Edit validity/expiry states
editExpiryMode: 'permanent',
editExpiresDays: 7,
editCustomExpiry: '',
openEditModal(item) {
this.editFormAction = '{{ route('admin.sales.store-gifts.update', ':id') }}'.replace(':id', item.id);
this.editCustomCode = item.code;
this.editGiftName = item.name;
this.editDiscountType = item.discount_type;
this.editDiscountValInput = item.discount_type === 'percentage' ? item.input_fold : item.discount_value;
this.editUsageType = item.usage_type;
this.editUsageLimit = item.usage_limit || 1;
if (item.expires_at) {
this.editExpiryMode = 'date';
const expDate = new Date(item.expires_at);
this.editCustomExpiry = this.formatDate(expDate);
const today = new Date();
const diffTime = expDate - today;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
this.editExpiresDays = diffDays > 0 ? diffDays : 7;
} else {
this.editExpiryMode = 'permanent';
this.editCustomExpiry = '';
this.editExpiresDays = 7;
}
this.syncSelect('edit-discount-type', this.editDiscountType);
this.syncSelect('edit-usage-type', this.editUsageType);
this.showEditModal = true;
},
generateRandomCode() {
this.customCode = Math.floor(Math.random() * 90000000 + 10000000).toString();
},
calculateDaysExpiry() {
const date = new Date();
const days = parseInt(this.expiresDays) || 0;
date.setDate(date.getDate() + days);
return this.formatDate(date);
},
getDisplayExpiry() {
if (this.expiryMode === 'permanent') {
return '{{ __("Permanent") }}';
}
if (this.customExpiry) {
return this.customExpiry.replace('T', ' ');
}
return this.calculateDaysExpiry();
},
getSubmittedExpiry() {
if (this.expiryMode === 'permanent') {
return '';
}
if (this.customExpiry) {
return this.customExpiry;
}
return this.calculateDaysExpiry();
},
calculateEditDaysExpiry() {
const date = new Date();
const days = parseInt(this.editExpiresDays) || 0;
date.setDate(date.getDate() + days);
return this.formatDate(date);
},
getDisplayEditExpiry() {
if (this.editExpiryMode === 'permanent') {
return '{{ __("Permanent") }}';
}
if (this.editCustomExpiry) {
return this.editCustomExpiry.replace('T', ' ');
}
return this.calculateEditDaysExpiry();
},
getSubmittedEditExpiry() {
if (this.editExpiryMode === 'permanent') {
return '';
}
if (this.editCustomExpiry) {
return this.editCustomExpiry;
}
return this.calculateEditDaysExpiry();
},
formatDate(date) {
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0');
},
async fetchTabData(tab, customUrl = null) {
if (this.tabLoading === tab) return;
this.tabLoading = tab;
const container = document.getElementById(`tab-${tab}-container`);
let url = customUrl;
if (!url) {
let params = new URLSearchParams();
params.set('tab', tab);
params.set('_ajax', '1');
if (container) {
const form = container.querySelector('form');
if (form) {
const formData = new FormData(form);
formData.forEach((value, key) => {
if (value.trim() !== '') params.append(key, value);
});
}
}
url = `${window.location.pathname}?${params.toString()}`;
} else {
const urlObj = new URL(url, window.location.origin);
urlObj.searchParams.set('tab', tab);
urlObj.searchParams.set('_ajax', '1');
url = urlObj.toString();
}
try {
const response = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
if (container) {
container.innerHTML = data.html;
this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit();
});
}
const historyUrl = new URL(url, window.location.origin);
historyUrl.searchParams.delete('_ajax');
window.history.pushState({}, '', historyUrl.toString());
}
} catch (e) {
console.error(e);
window.showToast?.('{{ __("Loading failed") }}', 'error');
} finally {
this.tabLoading = null;
}
},
confirmDelete(formId) {
this.deleteTargetForm = formId;
this.showDeleteModal = true;
},
submitDelete() {
if (this.deleteTargetForm) {
const form = document.getElementById(this.deleteTargetForm);
if (form) form.submit();
}
this.showDeleteModal = false;
},
copyQrCode() {
if (!this.activeQrCode) return;
navigator.clipboard.writeText(this.activeQrCode).then(() => {
window.showToast('{{ __("Code Copied") }}', 'success');
});
},
copyQrLink() {
if (!this.activeTicketUrl) return;
navigator.clipboard.writeText(this.activeTicketUrl).then(() => {
window.showToast('{{ __("Link Copied") }}', 'success');
});
},
async downloadQrCode() {
const url = '{{ route('admin.basic-settings.qr-code') }}?size=500&data=' + encodeURIComponent(this.activeQrCode);
fetch(url)
.then(response => response.blob())
.then(blob => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `welcome-gift-${this.activeQrCode}.png`;
link.click();
URL.revokeObjectURL(link.href);
});
},
syncSelect(id, value) {
this.$nextTick(() => {
const el = document.getElementById(id);
if (el) {
const valStr = (value !== undefined && value !== null && value.toString().trim() !== '') ? value.toString() : ' ';
el.value = valStr;
if (window.HSSelect) {
const inst = window.HSSelect.getInstance(el);
if (inst) inst.setValue(valStr);
}
}
});
},
validateAndSubmit(e) {
if (!this.selectedMachine) {
window.showToast('{{ __("Please select a machine") }}', 'error');
return;
}
if (!this.giftName.trim()) {
window.showToast('{{ __("Please enter a name") }}', 'error');
return;
}
if (!this.discountValInput) {
window.showToast('{{ __("Please enter discount value") }}', 'error');
return;
}
if (this.discountType === 'percentage') {
const val = parseFloat(this.discountValInput);
if (isNaN(val) || val <= 0 || val >= 10) {
window.showToast('{{ __("Discount fold must be between 0.1 and 9.9") }}', 'error');
return;
}
} else {
const val = parseInt(this.discountValInput);
if (isNaN(val) || val <= 0) {
window.showToast('{{ __("Discount amount must be a positive integer") }}', 'error');
return;
}
}
e.target.submit();
},
getDiscountHelpText(input) {
const val = parseFloat(input);
if (isNaN(val) || val <= 0 || val >= 10) {
return '';
}
const percentOff = Math.round((10 - val) * 10);
const map = {
'1': '一', '2': '二', '3': '三', '4': '四', '5': '五',
'6': '六', '7': '七', '8': '八', '9': '九', '0': '零'
};
let foldStr = val.toString();
let chineseFold = '';
if (foldStr.includes('.')) {
const parts = foldStr.split('.');
const integer = parts[0];
const decimal = parts[1];
const intChar = map[integer] || integer;
const decChar = map[decimal.charAt(0)] || decimal.charAt(0);
chineseFold = intChar + decChar + '折';
} else {
const char = map[foldStr] || foldStr;
chineseFold = char + '折';
}
return '{{ __("Discount") }}: ' + percentOff + '% off (' + chineseFold + ')';
},
adjustDiscountValue(target, delta) {
const isEdit = target === 'edit';
const type = isEdit ? this.editDiscountType : this.discountType;
const key = isEdit ? 'editDiscountValInput' : 'discountValInput';
const current = parseFloat(this[key]) || 0;
const step = type === 'percentage' ? 0.1 : 1;
let next = current + (delta * step);
if (type === 'percentage') {
next = Math.max(0.1, Math.min(9.9, next));
this[key] = (Math.round(next * 10) / 10).toString();
} else {
next = Math.max(1, Math.round(next));
this[key] = next.toString();
}
},
init() {
this.$watch('activeTab', (newTab) => {
const url = new URL(window.location.origin + window.location.pathname);
url.searchParams.set('tab', newTab);
window.history.pushState({}, '', url);
this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit();
});
});
this.$watch('showCreateModal', (val) => {
if (val) {
this.selectedMachine = '';
this.giftName = '';
this.discountType = 'percentage';
this.discountValInput = '';
this.usageType = 'once';
this.usageLimit = 1;
this.expiryMode = 'permanent';
this.expiresDays = 7;
this.customExpiry = '';
this.generateRandomCode();
this.syncSelect('modal-gift-machine', '');
this.syncSelect('create-discount-type', this.discountType);
this.syncSelect('create-usage-type', this.usageType);
}
});
}
} " @ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
{{-- Page Header --}}
<x-page-header :title="__('Welcome Gifts')"
:subtitle="__('Manage welcoming promotion codes for new guests')">
<button @click="showCreateModal = true; generateRandomCode()" class="btn-luxury-primary">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('Add Welcome Gift') }}</span>
</button>
</x-page-header>
{{-- Tabs --}}
<x-tab-nav model="activeTab">
<x-tab-nav-item value="list" :label="__('Welcome Gifts')" model="activeTab" />
<x-tab-nav-item value="logs" :label="__('Usage Logs')" model="activeTab" />
</x-tab-nav>
{{-- Main Content --}}
<div class="mt-6">
<div class="relative min-h-[400px]">
{{-- List Tab --}}
<div x-show="activeTab === 'list'" class="relative min-h-[400px]"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<x-luxury-spinner show="tabLoading === 'list'" />
<div id="tab-list-container" class="relative"
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'list' }">
@include('admin.sales.welcome-gifts.partials.tab-list')
</div>
</div>
</div>
{{-- Logs Tab --}}
<div x-show="activeTab === 'logs'" class="relative min-h-[400px]"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0" style="display: none;" x-cloak>
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<x-luxury-spinner show="tabLoading === 'logs'" />
<div id="tab-logs-container" class="relative"
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'logs' }">
@include('admin.sales.welcome-gifts.partials.tab-logs')
</div>
</div>
</div>
</div>
</div>
{{-- Create Modal --}}
<div x-show="showCreateModal" class="fixed inset-0 z-[110] overflow-y-auto"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm"
@click="showCreateModal = false"></div>
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full overflow-visible">
<div class="flex justify-between items-center mb-8">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{
__('Add Welcome Gift') }}</h3>
<button @click="showCreateModal = false"
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form action="{{ route('admin.sales.store-gifts.store') }}" method="POST" class="space-y-6"
@submit.prevent="validateAndSubmit">
@csrf
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2 relative focus-within:z-[60]">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Target Machine') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="machine_id" id="modal-gift-machine"
:placeholder="__('Please select a machine')" x-model="selectedMachine"
@change="selectedMachine = $event.target.value">
@foreach($machines as $machine)
<option value="{{ $machine->id }}"
data-title="{{ $machine->name }} ({{ $machine->serial_no }})">{{ $machine->name }}
({{ $machine->serial_no }})</option>
@endforeach
</x-searchable-select>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Welcome Gift Name') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="name" x-model="giftName" class="luxury-input w-full"
placeholder="{{ __('e.g. New Guest 15% Off Discount') }}" required>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Discount Type') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="discount_type" id="create-discount-type" :hasSearch="false"
x-model="discountType" @change="discountType = $event.target.value">
<option value="percentage" data-title="{{ __('Percentage Discount') }}">{{ __('Percentage Discount') }}</option>
<option value="amount" data-title="{{ __('Amount Discount') }}">{{ __('Amount Discount') }}</option>
</x-searchable-select>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
<span x-show="discountType === 'percentage'">{{ __('Discount Fold') }} ({{ __('e.g. 8.5 for 85折, 8 for 8折') }}) <span class="text-rose-500">*</span></span>
<span x-show="discountType === 'amount'">{{ __('Discount Amount (NTD)') }} <span class="text-rose-500">*</span></span>
</label>
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="adjustDiscountValue('create', -1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="text" inputmode="decimal" name="discount_val_input" x-model="discountValInput"
class="flex-1 min-w-0 h-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 px-3"
:placeholder="discountType === 'percentage' ? '8.5' : '50'" required>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="adjustDiscountValue('create', 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="mt-1.5 text-xs text-cyan-600 dark:text-cyan-400 font-bold" x-show="discountType === 'percentage' && discountValInput"><span x-text="getDiscountHelpText(discountValInput)"></span></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Usage Type') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="usage_type" id="create-usage-type" :hasSearch="false"
x-model="usageType" @change="usageType = $event.target.value">
<option value="once" data-title="{{ __('Once') }}">{{ __('Once') }}</option>
<option value="unlimited" data-title="{{ __('Unlimited') }}">{{ __('Unlimited') }}</option>
</x-searchable-select>
</div>
<input type="hidden" name="usage_limit" value="1">
</div>
{{-- Welcome Gift Code Section (Flexible) --}}
<div class="space-y-2">
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Welcome Gift Code (8 Digits)') }} <span class="text-rose-500">*</span>
</label>
<button type="button" @click="generateRandomCode()"
class="text-[10px] font-black text-cyan-500 hover:text-cyan-600 uppercase tracking-widest flex items-center gap-1 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ __('Regenerate') }}
</button>
</div>
<input type="text" name="custom_code" x-model="customCode"
class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/30 dark:bg-cyan-500/5 uppercase"
maxlength="12" required>
</div>
{{-- Validity Period Section --}}
<div class="space-y-4">
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Validity Period') }}</label>
<div class="flex bg-slate-100 dark:bg-slate-800 p-1 rounded-xl">
<button type="button" @click="expiryMode = 'permanent'"
class="px-3 py-1 text-[10px] font-black uppercase tracking-tighter rounded-lg transition-all"
:class="expiryMode === 'permanent' ? 'bg-white dark:bg-slate-700 text-cyan-600 shadow-sm' : 'text-slate-400'">{{ __('Permanent') }}</button>
<button type="button" @click="expiryMode = 'date'"
class="px-3 py-1 text-[10px] font-black uppercase tracking-tighter rounded-lg transition-all"
:class="expiryMode === 'date' ? 'bg-white dark:bg-slate-700 text-cyan-600 shadow-sm' : 'text-slate-400'">{{ __('Custom Expiry') }}</button>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-800/40 rounded-[2rem] p-6 border border-slate-100 dark:border-slate-800/50 shadow-sm">
<input type="hidden" name="expires_at" :value="getSubmittedExpiry()">
<div x-show="expiryMode === 'date'" x-transition class="space-y-6">
{{-- Days Stepper --}}
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3 pl-1">{{ __('Validity Period (Days)') }}</label>
<div class="flex flex-col sm:flex-row items-center gap-4">
{{-- Unified Luxury Counter --}}
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 min-w-[150px] group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="expiresDays = Math.max(1, parseInt(expiresDays || 1) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<div class="flex-1 min-w-[50px]">
<input type="text" x-model="expiresDays" readonly
class="w-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0">
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="expiresDays = parseInt(expiresDays || 1) + 1"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
{{-- Quick Select Buttons --}}
<div class="flex items-center gap-1">
<template x-for="val in [1, 3, 7, 30]">
<button type="button" @click="expiresDays = val; customExpiry = ''"
class="w-10 h-8 rounded-lg border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[10px] font-black transition-all shrink-0"
:class="expiresDays == val && !customExpiry ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val + ' ' + '{{ __('d') }}'"></span>
</button>
</template>
</div>
</div>
</div>
{{-- Custom Expiry Input --}}
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3 pl-1">{{ __('Or Choose Custom Date') }}</label>
<div class="relative group">
<input type="text" x-model="customExpiry"
x-init="const fp = flatpickr($el, {
enableTime: true,
dateFormat: 'Y-m-d H:i',
time_24hr: true,
minuteIncrement: 1,
disableMobile: true,
locale: window.flatpickrLocale
}); $watch('customExpiry', v => fp.setDate(v, false))"
class="luxury-input w-full py-3 text-center font-mono text-base bg-white dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 pl-4 pr-12 focus:border-cyan-500 transition-all"
placeholder="{{ __('Choose Date') }}">
<div class="absolute right-5 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-cyan-500 transition-colors pointer-events-none">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
</div>
</div>
</div>
</div>
{{-- Expected Expiry Card --}}
<div class="pt-6" :class="{ 'border-t border-slate-100 dark:border-slate-800/50 mt-6': expiryMode === 'date' }">
<div class="flex items-center justify-between mb-3 px-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Estimated Expiry') }}</span>
<span class="text-[10px] font-bold text-slate-400/70 tracking-wide" x-text="expiryMode === 'permanent' ? '{{ __('Permanent') }}' : '{{ __('Expiry Date Set') }}'"></span>
</div>
<div class="bg-white dark:bg-slate-900/50 rounded-2xl p-4 border border-slate-100 dark:border-slate-700/50 flex items-center justify-center gap-3">
<svg class="w-5 h-5 text-cyan-500/50 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-xl font-mono font-black text-slate-700 dark:text-slate-200 tracking-wider" x-text="getDisplayExpiry()"></span>
</div>
</div>
</div>
</div>
<div class="flex justify-end gap-x-4 pt-8">
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">
{{ __('Cancel') }}
</button>
<button type="submit" class="btn-luxury-primary px-12">
{{ __('Create') }}
</button>
</div>
</form>
</div>
</div>
</div>
{{-- Edit Modal --}}
<div x-show="showEditModal" class="fixed inset-0 z-[110] overflow-y-auto"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm"
@click="showEditModal = false"></div>
<div x-show="showEditModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-xl sm:w-full overflow-visible">
<div class="flex justify-between items-center mb-8">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{
__('Edit Welcome Gift') }}</h3>
<button @click="showEditModal = false"
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form :action="editFormAction" method="POST" class="space-y-6">
@csrf
@method('PATCH')
{{-- Readonly Welcome Gift Code Section --}}
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Welcome Gift Code') }}
</label>
<input type="text" x-model="editCustomCode" readonly
class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/10 dark:bg-cyan-500/5 cursor-not-allowed opacity-80 uppercase"
maxlength="12">
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Welcome Gift Name') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="name" x-model="editGiftName" class="luxury-input w-full"
placeholder="{{ __('e.g. New Guest 15% Off Discount') }}" required>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Discount Type') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="discount_type" id="edit-discount-type" :hasSearch="false"
x-model="editDiscountType" @change="editDiscountType = $event.target.value">
<option value="percentage" data-title="{{ __('Percentage Discount') }}">{{ __('Percentage Discount') }}</option>
<option value="amount" data-title="{{ __('Amount Discount') }}">{{ __('Amount Discount') }}</option>
</x-searchable-select>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
<span x-show="editDiscountType === 'percentage'">{{ __('Discount Fold') }} <span class="text-rose-500">*</span></span>
<span x-show="editDiscountType === 'amount'">{{ __('Discount Amount (NTD)') }} <span class="text-rose-500">*</span></span>
</label>
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="adjustDiscountValue('edit', -1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="text" inputmode="decimal" name="discount_val_input" x-model="editDiscountValInput"
class="flex-1 min-w-0 h-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 px-3"
required>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="adjustDiscountValue('edit', 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="mt-1.5 text-xs text-cyan-600 dark:text-cyan-400 font-bold" x-show="editDiscountType === 'percentage' && editDiscountValInput"><span x-text="getDiscountHelpText(editDiscountValInput)"></span></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Usage Type') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="usage_type" id="edit-usage-type" :hasSearch="false"
x-model="editUsageType" @change="editUsageType = $event.target.value">
<option value="once" data-title="{{ __('Once') }}">{{ __('Once') }}</option>
<option value="unlimited" data-title="{{ __('Unlimited') }}">{{ __('Unlimited') }}</option>
</x-searchable-select>
</div>
<input type="hidden" name="usage_limit" value="1">
</div>
{{-- Validity Period Section --}}
<div class="space-y-4">
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Validity Period') }}</label>
<div class="flex bg-slate-100 dark:bg-slate-800 p-1 rounded-xl">
<button type="button" @click="editExpiryMode = 'permanent'"
class="px-3 py-1 text-[10px] font-black uppercase tracking-tighter rounded-lg transition-all"
:class="editExpiryMode === 'permanent' ? 'bg-white dark:bg-slate-700 text-cyan-600 shadow-sm' : 'text-slate-400'">{{ __('Permanent') }}</button>
<button type="button" @click="editExpiryMode = 'date'"
class="px-3 py-1 text-[10px] font-black uppercase tracking-tighter rounded-lg transition-all"
:class="editExpiryMode === 'date' ? 'bg-white dark:bg-slate-700 text-cyan-600 shadow-sm' : 'text-slate-400'">{{ __('Custom Expiry') }}</button>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-800/40 rounded-[2rem] p-6 border border-slate-100 dark:border-slate-800/50 shadow-sm">
<input type="hidden" name="expires_at" :value="getSubmittedEditExpiry()">
<div x-show="editExpiryMode === 'date'" x-transition class="space-y-6">
{{-- Days Stepper --}}
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3 pl-1">{{ __('Validity Period (Days)') }}</label>
<div class="flex flex-col sm:flex-row items-center gap-4">
{{-- Unified Luxury Counter --}}
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 min-w-[150px] group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="editExpiresDays = Math.max(1, parseInt(editExpiresDays || 1) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<div class="flex-1 min-w-[50px]">
<input type="text" x-model="editExpiresDays" readonly
class="w-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0">
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="editExpiresDays = parseInt(editExpiresDays || 1) + 1"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
{{-- Quick Select Buttons --}}
<div class="flex items-center gap-1">
<template x-for="val in [1, 3, 7, 30]">
<button type="button" @click="editExpiresDays = val; editCustomExpiry = ''"
class="w-10 h-8 rounded-lg border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[10px] font-black transition-all shrink-0"
:class="editExpiresDays == val && !editCustomExpiry ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val + ' ' + '{{ __('d') }}'"></span>
</button>
</template>
</div>
</div>
</div>
{{-- Custom Expiry Input --}}
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3 pl-1">{{ __('Or Choose Custom Date') }}</label>
<div class="relative group">
<input type="text" x-model="editCustomExpiry"
x-init="const fp = flatpickr($el, {
enableTime: true,
dateFormat: 'Y-m-d H:i',
time_24hr: true,
minuteIncrement: 1,
disableMobile: true,
locale: window.flatpickrLocale
}); $watch('editCustomExpiry', v => fp.setDate(v, false))"
class="luxury-input w-full py-3 text-center font-mono text-base bg-white dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 pl-4 pr-12 focus:border-cyan-500 transition-all"
placeholder="{{ __('Choose Date') }}">
<div class="absolute right-5 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-cyan-500 transition-colors pointer-events-none">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
</div>
</div>
</div>
</div>
{{-- Expected Expiry Card --}}
<div class="pt-6" :class="{ 'border-t border-slate-100 dark:border-slate-800/50 mt-6': editExpiryMode === 'date' }">
<div class="flex items-center justify-between mb-3 px-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Estimated Expiry') }}</span>
<span class="text-[10px] font-bold text-slate-400/70 tracking-wide" x-text="editExpiryMode === 'permanent' ? '{{ __('Permanent') }}' : '{{ __('Expiry Date Set') }}'"></span>
</div>
<div class="bg-white dark:bg-slate-900/50 rounded-2xl p-4 border border-slate-100 dark:border-slate-700/50 flex items-center justify-center gap-3">
<svg class="w-5 h-5 text-cyan-500/50 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-xl font-mono font-black text-slate-700 dark:text-slate-200 tracking-wider" x-text="getDisplayEditExpiry()"></span>
</div>
</div>
</div>
</div>
<div class="flex gap-4 pt-4">
<button type="button" @click="showEditModal = false"
class="flex-1 px-8 py-4 rounded-2xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-[0.2em] hover:bg-slate-200 dark:hover:bg-slate-700 transition-all">
{{ __('Cancel') }}
</button>
<button type="submit"
class="flex-[2] px-8 py-4 rounded-2xl bg-cyan-500 text-white font-black text-xs uppercase tracking-[0.2em] hover:bg-cyan-600 shadow-xl shadow-cyan-500/25 transition-all active:scale-95">
{{ __('Update Welcome Gift') }}
</button>
</div>
</form>
</div>
</div>
</div>
{{-- QR Modal --}}
<div x-show="showQrModal" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4">
<div class="fixed inset-0 transition-opacity" @click="showQrModal = false">
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"></div>
</div>
<div
class="relative bg-white dark:bg-slate-900 rounded-[2.5rem] shadow-2xl border border-slate-100 dark:border-slate-800 w-full max-w-sm overflow-hidden animate-luxury-in">
<div
class="px-8 py-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('QR Code') }}
</h3>
<p class="text-xs font-bold text-cyan-600 dark:text-cyan-400 mt-1 uppercase tracking-widest">
{{ __('Welcome Gift Code') }}: <span class="font-mono" x-text="activeQrCode"></span>
</p>
</div>
<button @click="showQrModal = false" class="text-slate-400 hover:text-slate-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-10 flex flex-col items-center gap-6">
<div class="p-4 bg-white rounded-3xl shadow-xl border border-slate-100">
<x-qr-code data="activeQrCode" size="200" class="w-48 h-48" />
</div>
<div class="text-center w-full space-y-4">
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 leading-relaxed px-4">
{{ __('Scan this code at the machine or share the link with the customer.') }}
</p>
<div class="flex items-center justify-center gap-3">
<button @click="copyQrLink()"
class="flex-1 py-3 px-4 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-slate-100 dark:border-slate-700 hover:border-cyan-500/20 transition-all flex items-center justify-center gap-2 group"
title="{{ __('Copy Link') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 006.364 6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
<span class="text-xs font-black uppercase tracking-widest">{{ __('Link') }}</span>
</button>
<button @click="copyQrCode()"
class="flex-1 py-3 px-4 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-slate-100 dark:border-slate-700 hover:border-cyan-500/20 transition-all flex items-center justify-center gap-2 group"
title="{{ __('Copy Code') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span class="text-xs font-black uppercase tracking-widest">{{ __('Copy') }}</span>
</button>
<button @click="downloadQrCode()"
class="flex-1 py-3 px-4 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-emerald-500 hover:bg-emerald-500/5 dark:hover:bg-emerald-500/10 border border-slate-100 dark:border-slate-700 hover:border-emerald-500/20 transition-all flex items-center justify-center gap-2 group"
title="{{ __('Download Image') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 12l4.5 4.5m0 0l4.5-4.5M12 3v13.5" />
</svg>
<span class="text-xs font-black uppercase tracking-widest">{{ __('Save') }}</span>
</button>
</div>
</div>
</div>
<div
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-center border-t border-slate-100 dark:border-slate-800">
<button @click="showQrModal = false" class="btn-luxury-primary w-full py-4 rounded-2xl">{{
__('Close') }}</button>
</div>
</div>
</div>
</div>
{{-- Delete Modal --}}
<x-confirm-modal alpineVar="showDeleteModal" confirmAction="submitDelete()" :title="__('Confirm Deactivation')"
:message="__('Are you sure you want to deactivate this welcome gift? It will no longer be usable by guests.')"
:confirmText="__('Yes, Deactivate')" :cancelText="__('Cancel')" iconType="danger" confirmColor="rose" />
</div>
@endsection

View File

@ -0,0 +1,345 @@
<form action="{{ route('admin.sales.store-gifts') }}" method="GET"
class="flex flex-col md:flex-row md:items-center gap-3 mb-8"
@submit.prevent="fetchTabData('list')">
<div class="relative group flex-1 md:flex-none">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search by code, name or machine...') }}">
</div>
<div class="w-full md:w-48 relative focus-within:z-20">
<x-searchable-select name="status" id="filter-status" :placeholder="__('All Status')"
:selected="request('status')"
@change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="active" {{ request('status')==='active' ? 'selected' : '' }}
data-title="{{ __('Active') }}">{{ __('Active') }}</option>
<option value="expired" {{ request('status')==='expired' ? 'selected' : '' }}
data-title="{{ __('Expired') }}">{{ __('Expired') }}</option>
<option value="disabled" {{ request('status')==='disabled' ? 'selected' : '' }}
data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
</x-searchable-select>
</div>
<div class="flex items-center gap-2 ml-auto md:ml-0 shrink-0">
<button type="submit"
class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all"
title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button"
@click="$el.closest('form').querySelector('input[name=search]').value=''; syncSelect('filter-status', ''); $el.closest('form').dispatchEvent(new Event('submit'))"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all"
title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</form>
{{-- Table (Desktop) --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Welcome Gift Code') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Name / Machine') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Discount Value') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Usage Status') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Expires At') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Status') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
@forelse ($welcomeGifts as $gift)
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6 whitespace-nowrap">
@if($gift->status === 'active' && (!$gift->expires_at || $gift->expires_at->isFuture()))
<span
@click="activeQrCode = '{{ $gift->code }}'; activeTicketUrl = '{{ $gift->ticket_url }}'; showQrModal = true"
class="text-lg font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-widest bg-cyan-50 dark:bg-cyan-500/10 px-4 py-1.5 rounded-xl border border-cyan-100 dark:border-cyan-500/20 shadow-sm cursor-pointer hover:bg-cyan-100 dark:hover:bg-cyan-500/20 transition-all">
{{ $gift->code }}
</span>
@else
<span class="text-lg font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-widest bg-cyan-50 dark:bg-cyan-500/10 px-4 py-1.5 rounded-xl border border-cyan-100 dark:border-cyan-500/20 shadow-sm">
{{ $gift->code }}
</span>
@endif
</td>
<td class="px-6 py-6">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $gift->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
$gift->machine->name }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<span class="text-sm font-black text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-500/10 px-3 py-1 rounded-lg border border-indigo-100 dark:border-indigo-500/20">
{{ $gift->discount_label }}
</span>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@if($gift->usage_type === 'unlimited')
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded border border-slate-200 dark:border-slate-700">
{{ __('Unlimited') }} ({{ __('Used: :count', ['count' => $gift->usage_count]) }})
</span>
@else
@if($gift->usage_count > 0)
<span class="text-xs font-bold text-slate-400 dark:text-slate-500 bg-slate-50 dark:bg-slate-800/40 px-2 py-0.5 rounded border border-slate-200/60 dark:border-slate-700/60 line-through">
{{ __('Used') }}
</span>
@else
<span class="text-xs font-bold text-cyan-600 dark:text-cyan-400 bg-cyan-50 dark:bg-cyan-500/10 px-2 py-0.5 rounded border border-cyan-100 dark:border-cyan-500/20">
{{ __('Once') }}
</span>
@endif
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
@if($gift->expires_at)
<div class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono tracking-widest">
{{ $gift->expires_at->format('Y-m-d H:i') }}</div>
<div class="text-xs font-bold {{ $gift->expires_at->isPast() ? 'text-rose-500' : 'text-slate-400' }} mt-0.5">
{{ $gift->expires_at->isPast() ? __('Expired') : $gift->expires_at->diffForHumans() }}
</div>
@else
<span class="text-xs font-black uppercase tracking-widest text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-lg border border-emerald-500/20">
{{ __('Permanent') }}
</span>
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
$displayStatus = $gift->status === 'active' && $gift->expires_at?->isPast() ? 'expired'
: ($gift->status === 'disabled' ? 'cancelled' : $gift->status);
@endphp
<x-status-badge :status="$displayStatus" />
</td>
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
@if($gift->status === 'active' && (!$gift->expires_at || $gift->expires_at->isFuture()))
<button type="button"
@click="activeQrCode = '{{ $gift->code }}'; activeTicketUrl = '{{ $gift->ticket_url }}'; showQrModal = true"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all duration-300 border border-slate-100 dark:border-slate-800"
title="{{ __('View QR Code') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
</button>
<button type="button" @click="openEditModal({
id: {{ $gift->id }},
code: '{{ $gift->code }}',
name: '{{ addslashes($gift->name) }}',
discount_type: '{{ $gift->discount_type }}',
discount_value: {{ $gift->discount_value }},
input_fold: {{ $gift->input_fold ?? 'null' }},
usage_type: '{{ $gift->usage_type }}',
usage_limit: {{ $gift->usage_limit ?? 'null' }},
expires_at: '{{ $gift->expires_at ? $gift->expires_at->format('Y-m-d\TH:i') : '' }}'
})"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all duration-300 border border-slate-100 dark:border-slate-800"
title="{{ __('Edit') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
@endif
@if($gift->status === 'active')
<form id="delete-form-desktop-{{ $gift->id }}"
action="{{ route('admin.sales.store-gifts.destroy', $gift) }}" method="POST">
@csrf
@method('DELETE')
<button type="button"
@click="confirmDelete('delete-form-desktop-{{ $gift->id }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20"
title="{{ __('Cancel Gift') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
</button>
</form>
@endif
</div>
</td>
</tr>
@empty
<x-empty-state mode="table" :colspan="7" :message="__('No welcome gifts found')" />
@endforelse
</tbody>
</table>
</div>
{{-- Card Grid (Mobile) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse ($welcomeGifts as $gift)
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
{{-- Card Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm shrink-0">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5a2 2 0 10-2 2h2zm0 0h4" />
</svg>
</div>
<div class="min-w-0">
@if($gift->status === 'active' && (!$gift->expires_at || $gift->expires_at->isFuture()))
<h3 @click="activeQrCode = '{{ $gift->code }}'; activeTicketUrl = '{{ $gift->ticket_url }}'; showQrModal = true"
class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-pointer">
{{ $gift->code }}
</h3>
@else
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate tracking-tight">
{{ $gift->code }}
</h3>
@endif
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $gift->name }}
</p>
</div>
</div>
<div class="min-w-0">
@php
$displayStatusMobile = $gift->status === 'active' && $gift->expires_at?->isPast() ?
'expired' : ($gift->status === 'disabled' ? 'cancelled' : $gift->status);
@endphp
<x-status-badge :status="$displayStatusMobile" size="sm" />
</div>
</div>
{{-- Info Grid --}}
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Machine') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">
{{ $gift->machine->name }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Discount Value') }}</p>
<p class="text-sm font-bold text-indigo-600 dark:text-indigo-400">
{{ $gift->discount_label }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Usage Status') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
@if($gift->usage_type === 'unlimited')
{{ __('Unlimited') }} ({{ $gift->usage_count }})
@else
@if($gift->usage_count > 0)
<span class="text-slate-400 dark:text-slate-500 line-through">{{ __('Used') }}</span>
@else
<span class="text-cyan-600 dark:text-cyan-400">{{ __('Once') }}</span>
@endif
@endif
</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Expires At') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 font-mono tracking-tighter">
{{ $gift->expires_at ? $gift->expires_at->format('Y-m-d H:i') : __('Permanent') }}
</p>
</div>
</div>
{{-- Action Buttons --}}
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-3">
@if($gift->status === 'active' && (!$gift->expires_at || $gift->expires_at->isFuture()))
<button type="button"
@click="activeQrCode = '{{ $gift->code }}'; activeTicketUrl = '{{ $gift->ticket_url }}'; showQrModal = true"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest border border-slate-100 dark:border-slate-800 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
{{ __('QR Code') }}
</button>
<button type="button" @click="openEditModal({
id: {{ $gift->id }},
code: '{{ $gift->code }}',
name: '{{ addslashes($gift->name) }}',
discount_type: '{{ $gift->discount_type }}',
discount_value: {{ $gift->discount_value }},
input_fold: {{ $gift->input_fold ?? 'null' }},
usage_type: '{{ $gift->usage_type }}',
usage_limit: {{ $gift->usage_limit ?? 'null' }},
expires_at: '{{ $gift->expires_at ? $gift->expires_at->format('Y-m-d\TH:i') : '' }}'
})"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest border border-slate-100 dark:border-slate-800 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
{{ __('Edit') }}
</button>
@endif
@if($gift->status === 'active')
<form id="delete-form-mobile-{{ $gift->id }}"
action="{{ route('admin.sales.store-gifts.destroy', $gift) }}" method="POST" class="flex-1">
@csrf
@method('DELETE')
<button type="button" @click="confirmDelete('delete-form-mobile-{{ $gift->id }}')"
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-rose-500/5 text-rose-500 text-xs font-black uppercase tracking-widest border border-rose-500/20 hover:bg-rose-500 hover:text-white transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
{{ __('Cancel') }}
</button>
</form>
@endif
</div>
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No welcome gifts found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $welcomeGifts->appends(request()->query())->links('vendor.pagination.luxury') }}
</div>

View File

@ -0,0 +1,310 @@
{{-- Filter Form --}}
<form action="{{ route('admin.sales.store-gifts') }}" method="GET"
class="flex flex-wrap items-center gap-3 sm:gap-4 mb-8" @submit.prevent="fetchTabData('logs')">
<input type="hidden" name="tab" value="logs">
{{-- 搜尋關鍵字 --}}
<div class="relative group w-full sm:w-64 sm:flex-none">
<span
class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="w-4 h-4 stroke-[2.5]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" name="search_log" value="{{ request('search_log') }}"
class="py-2.5 pl-12 pr-6 block w-full luxury-input text-sm font-bold"
placeholder="{{ __('Search logs...') }}">
</div>
{{-- 類型篩選 --}}
<div class="w-full sm:w-64">
<x-searchable-select
name="action"
:selected="request('action')"
:placeholder="__('All Types')"
:hasSearch="false"
>
@foreach($actions as $key => $label)
<option value="{{ $key }}" {{ request('action') === $key ? 'selected' : '' }} data-title="{{ $label }}">
{{ $label }}
</option>
@endforeach
</x-searchable-select>
</div>
{{-- 開始時間 --}}
<div class="relative group w-full sm:w-56 sm:flex-none"
x-data="{ fp: null }"
x-init="fp = flatpickr($refs.startDate, {
disableMobile: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
defaultHour: 0,
defaultMinute: 0,
locale: 'zh_tw',
position: 'auto right',
defaultDate: '{{ request('start_date') }}'
})">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</span>
<input type="text" name="start_date" x-ref="startDate"
value="{{ request('start_date') }}"
placeholder="{{ __('Start Time') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full text-sm font-bold cursor-pointer">
<div class="absolute -top-2 left-4 px-1 bg-white dark:bg-slate-900 text-[9px] font-black text-slate-400 uppercase tracking-widest opacity-0 group-focus-within:opacity-100 transition-opacity pointer-events-none">{{ __('Start Time') }}</div>
</div>
{{-- 結束時間 --}}
<div class="relative group w-full sm:w-56 sm:flex-none"
x-data="{ fp: null }"
x-init="fp = flatpickr($refs.endDate, {
disableMobile: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
defaultHour: 23,
defaultMinute: 59,
locale: 'zh_tw',
position: 'auto right',
defaultDate: '{{ request('end_date') }}'
})">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</span>
<input type="text" name="end_date" x-ref="endDate"
value="{{ request('end_date') }}"
placeholder="{{ __('End Time') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full text-sm font-bold cursor-pointer">
<div class="absolute -top-2 left-4 px-1 bg-white dark:bg-slate-900 text-[9px] font-black text-slate-400 uppercase tracking-widest opacity-0 group-focus-within:opacity-100 transition-opacity pointer-events-none">{{ __('End Time') }}</div>
</div>
{{-- 6 個月限制提示 (僅在未搜尋時間時顯示) --}}
@if(!request('start_date') && !request('end_date'))
<div class="hidden md:flex items-center gap-2 px-4 py-2 bg-amber-50/50 dark:bg-amber-900/10 border border-amber-100/50 dark:border-amber-800/30 rounded-xl">
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-[10px] font-bold text-amber-600/80 dark:text-amber-500/80 uppercase tracking-wider">
{{ __('Showing last 6 months by default when no date range is selected') }}
</span>
</div>
@endif
{{-- 按鈕組 --}}
<div class="flex items-center gap-2 ml-auto sm:ml-0">
<button type="submit"
class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95"
title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button" @click="
$el.closest('form').querySelectorAll('input[type=text], input[type=hidden]:not([name=tab])').forEach(i => i.value = '');
$el.closest('form').querySelectorAll('select').forEach(s => {
s.value = ' ';
const instance = window.HSSelect.getInstance(s);
if (instance) instance.setValue(' ');
});
fetchTabData('logs', '{{ route('admin.sales.store-gifts') }}?tab=logs');
"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95"
title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</form>
{{-- Desktop Table Mode --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Machine / Welcome Gift') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Action') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Welcome Gift Code') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Discount') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Sales Record') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Time') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($logs as $log)
<tr
class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-5">
<div class="flex items-center gap-x-4">
<div
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor"
viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5a2 2 0 10-2 2h2zm0 0h4m-4 0H8m12 3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="flex flex-col">
<span
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
{{ $log->machine->name ?? $log->machine->serial_no ?? '-' }}
</span>
<span
class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-wide mt-0.5">
{{ $log->welcomeGift->name ?? '-' }}
</span>
</div>
</div>
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
<x-status-badge :status="$log->action" size="sm" />
</td>
<td
class="px-6 py-5 text-center font-mono font-extrabold text-slate-700 dark:text-slate-200">
<span
class="px-2 py-1 rounded-lg bg-slate-100 dark:bg-slate-800/50 border border-slate-200/50 dark:border-slate-700/50 tracking-tighter">
{{ $log->welcomeGift->code ?? '-' }}
</span>
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
<span class="text-xs font-extrabold text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 px-2 py-1 rounded-lg border border-slate-200/50 dark:border-slate-700/50">
{{ $log->welcomeGift->discount_label ?? '-' }}
</span>
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
@if($log->order_id)
<a href="{{ route('admin.sales.index', ['tab' => 'orders', 'search' => $log->order?->order_no ?? $log->order_id]) }}"
class="text-xs font-black text-cyan-600 dark:text-cyan-400 hover:underline flex items-center justify-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
{{ $log->order?->order_no ?? '#' . $log->order_id }}
</a>
@else
<span class="text-slate-300 dark:text-slate-700">-</span>
@endif
</td>
<td class="px-6 py-5 text-right whitespace-nowrap">
<span
class="text-xs font-mono font-black text-slate-400 tracking-widest">
{{ $log->created_at?->format('Y-m-d H:i:s') }}
</span>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-20 text-center">
<div class="flex flex-col items-center justify-center gap-3">
<div class="p-4 rounded-3xl bg-slate-50 dark:bg-slate-800/50">
<svg class="w-8 h-8 text-slate-300 dark:text-slate-600" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="1.5"
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5a2 2 0 10-2 2h2zm0 0h4m-4 0H8m12 3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-slate-400 font-bold uppercase tracking-widest text-xs">{{
__('No records found') }}</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Card Mode (Mobile & Tablet) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-4">
@forelse($logs as $log)
<div
class="luxury-card p-5 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
<div class="flex items-start justify-between gap-4 mb-5">
<div class="flex items-center gap-3 min-w-0">
<div
class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 shadow-sm shrink-0">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5a2 2 0 10-2 2h2zm0 0h4m-4 0H8m12 3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 transition-colors tracking-tight">
{{ $log->machine->name ?? $log->machine->serial_no ?? '-' }}</h3>
<p
class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-widest truncate mt-0.5">
{{ $log->welcomeGift->name ?? '-' }}</p>
</div>
</div>
<div class="shrink-0">
<x-status-badge :status="$log->action" size="sm" />
</div>
</div>
<div
class="grid grid-cols-2 gap-y-4 mb-4 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Welcome Gift Code') }}</p>
<p class="text-sm font-mono font-black text-slate-800 dark:text-slate-100">
{{ $log->welcomeGift->code ?? '-' }}
</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Discount') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
{{ $log->welcomeGift->discount_label ?? '-' }}
</p>
</div>
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Sales Record') }}</p>
@if($log->order_id)
<a href="{{ route('admin.sales.index', ['tab' => 'orders', 'search' => $log->order?->order_no ?? $log->order_id]) }}"
class="text-sm font-black text-cyan-600 dark:text-cyan-400 hover:underline">
{{ $log->order?->order_no ?? '#' . $log->order_id }}
</a>
@else
<p class="text-sm font-bold text-slate-300 dark:text-slate-700">-</p>
@endif
</div>
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Time') }}</p>
<p
class="text-sm font-mono font-bold text-slate-800 dark:text-slate-100 tracking-tighter">
{{ $log->created_at?->format('Y-m-d H:i:s') }}</p>
</div>
</div>
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No records found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 py-6 border-t border-slate-50 dark:border-slate-800/50">
{{ $logs->links('vendor.pagination.luxury') }}
</div>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="min-h-screen">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
@ -13,27 +13,17 @@
body { font-family: 'Outfit', sans-serif; }
</style>
</head>
<body class="h-full bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-6 antialiased">
<body class="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-start pt-8 pb-12 px-6 antialiased">
<div class="w-full max-w-sm animate-luxury-in">
{{-- Header Area --}}
<div class="text-center mb-8">
<div class="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 rounded-full shadow-sm border border-slate-100 dark:border-slate-800 mb-6">
<div class="w-2 h-2 rounded-full bg-cyan-500 animate-pulse"></div>
<span class="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">{{ __('Valid Authorization') }}</span>
</div>
<h1 class="text-4xl font-black text-slate-800 dark:text-white tracking-tight mb-2 font-display uppercase italic">{{ __('Pass') }} <span class="text-cyan-500">{{ __('Code') }}</span></h1>
<h1 class="text-4xl font-black text-slate-800 dark:text-white tracking-tight mb-2 font-display uppercase italic">{{ __('Pass Code') }}</h1>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Scan to authorize testing or maintenance') }}</p>
</div>
{{-- Ticket Card --}}
<div class="relative group">
{{-- Background Glow --}}
<div class="absolute -inset-4 bg-gradient-to-tr from-cyan-500/20 to-emerald-500/20 blur-3xl opacity-50 transition-opacity duration-500 group-hover:opacity-100"></div>
<div class="relative luxury-card rounded-[3rem] overflow-hidden border-slate-200/50 dark:border-slate-700/50 shadow-2xl bg-white dark:bg-slate-900">
{{-- Machine Section --}}
<div class="px-8 pt-10 pb-6 border-b border-dashed border-slate-100 dark:border-slate-800 relative">
{{-- Notch cutouts for ticket look --}}
<div class="absolute -left-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-r border-slate-100 dark:border-slate-800"></div>
<div class="absolute -right-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-l border-slate-100 dark:border-slate-800"></div>
@ -47,7 +37,6 @@
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight leading-tight mb-1">
{{ $passCode->machine->name }}
</h2>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ $passCode->machine->serial_no }}</p>
</div>
</div>
@ -64,7 +53,6 @@
</div>
</div>
{{-- QR Section --}}
<div class="p-10 flex flex-col items-center">
<div class="p-6 bg-white rounded-[2.5rem] shadow-xl border border-slate-100 mb-8 transform transition-transform duration-500 group-hover:scale-[1.02]">
<x-qr-code :data="$passCode->code" :dynamic="false" size="300" class="w-48 h-48 sm:w-64 sm:h-64" />
@ -76,7 +64,6 @@
</div>
</div>
{{-- Expiry Area --}}
<div class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800 text-center">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{{ __('Expiry Time') }}</p>
<p class="text-sm font-black text-slate-600 dark:text-slate-300">
@ -85,11 +72,6 @@
</div>
</div>
</div>
{{-- Footer Branding --}}
<div class="mt-12 text-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.4em] opacity-50">{{ config('app.name') }} &copy; {{ date('Y') }}</p>
</div>
</div>
</body>
</html>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="min-h-screen">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
@ -13,38 +13,29 @@
body { font-family: 'Outfit', sans-serif; }
</style>
</head>
<body class="h-full bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-6 antialiased">
<body class="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-start pt-8 pb-12 px-6 antialiased">
@php
$slot = $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first();
$product = $slot?->product;
@endphp
<div class="w-full max-w-sm animate-luxury-in">
{{-- Header Area --}}
<div class="text-center mb-8">
<div class="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 rounded-full shadow-sm border border-slate-100 dark:border-slate-800 mb-6">
<div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span class="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">{{ __('Valid Ticket') }}</span>
</div>
<h1 class="text-4xl font-black text-slate-800 dark:text-white tracking-tight mb-2 font-display uppercase italic">{{ __('Pickup') }} <span class="text-cyan-500">{{ __('Ticket') }}</span></h1>
<h1 class="text-4xl font-black text-slate-800 dark:text-white tracking-tight mb-2 font-display uppercase italic">{{ __('Pickup Code') }}</h1>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Scan to pick up your product') }}</p>
</div>
{{-- Ticket Card --}}
<div class="relative group">
{{-- Background Glow --}}
<div class="absolute -inset-4 bg-gradient-to-tr from-cyan-500/20 to-emerald-500/20 blur-3xl opacity-50 transition-opacity duration-500 group-hover:opacity-100"></div>
<div class="relative luxury-card rounded-[3rem] overflow-hidden border-slate-200/50 dark:border-slate-700/50 shadow-2xl bg-white dark:bg-slate-900">
{{-- Product Section --}}
<div class="px-8 pt-10 pb-6 border-b border-dashed border-slate-100 dark:border-slate-800 relative">
{{-- Notch cutouts for ticket look --}}
<div class="absolute -left-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-r border-slate-100 dark:border-slate-800"></div>
<div class="absolute -right-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-l border-slate-100 dark:border-slate-800"></div>
<div class="flex items-center gap-4 mb-6">
<div class="w-16 h-16 rounded-2xl bg-slate-50 dark:bg-slate-800 border border-slate-100 dark:border-slate-700 p-2 flex items-center justify-center overflow-hidden">
@php
$slot = $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first();
$product = $slot?->product;
@endphp
@if($product && $product->image_url)
<img src="{{ $product->image_url }}" class="w-full h-full object-cover">
<img src="{{ $product->image_url }}" class="w-full h-full object-cover" alt="{{ $product->name }}">
@else
<svg class="w-8 h-8 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
@ -55,6 +46,7 @@
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight leading-tight mb-1">
{{ $product?->name ?? __('Unknown Product') }}
</h2>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ $pickupCode->machine->name ?? $pickupCode->machine->serial_no }}</p>
</div>
</div>
@ -66,12 +58,11 @@
<div class="w-px h-8 bg-slate-100 dark:bg-slate-800"></div>
<div class="text-center flex-1">
<span class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Status') }}</span>
<span class="text-xs font-black px-3 py-1 rounded-full bg-emerald-500/10 text-emerald-500 uppercase tracking-tighter">{{ __('Active') }}</span>
<span class="text-xs font-black px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-500 uppercase tracking-tighter">{{ __('Active') }}</span>
</div>
</div>
</div>
{{-- QR Section --}}
<div class="p-10 flex flex-col items-center">
<div class="p-6 bg-white rounded-[2.5rem] shadow-xl border border-slate-100 mb-8 transform transition-transform duration-500 group-hover:scale-[1.02]">
<x-qr-code :data="$pickupCode->code" :dynamic="false" size="300" class="w-48 h-48 sm:w-64 sm:h-64" />
@ -79,11 +70,10 @@
<div class="text-center space-y-2 mb-2">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em]">{{ __('Pickup Code') }}</p>
<p class="text-5xl font-black text-slate-800 dark:text-white tracking-tighter font-display italic">{{ $pickupCode->code }}</p>
<p class="text-5xl font-black text-slate-800 dark:text-white tracking-tighter font-display italic leading-none">{{ $pickupCode->code }}</p>
</div>
</div>
{{-- Expiry Area --}}
<div class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800 text-center">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{{ __('Expiry Time') }}</p>
<p class="text-sm font-black text-slate-600 dark:text-slate-300">
@ -92,11 +82,6 @@
</div>
</div>
</div>
{{-- Footer Branding --}}
<div class="mt-12 text-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.4em] opacity-50">{{ config('app.name') }} &copy; {{ date('Y') }}</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="min-h-screen">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<title>{{ __('Welcome Gift') }} - {{ config('app.name') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;900&display=swap" rel="stylesheet">
<style>
[x-cloak] { display: none !important; }
body { font-family: 'Outfit', sans-serif; }
</style>
</head>
<body class="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-start pt-8 pb-12 px-6 antialiased">
<div class="w-full max-w-sm animate-luxury-in">
<div class="text-center mb-8">
<h1 class="text-4xl font-black text-slate-800 dark:text-white tracking-tight mb-2 font-display uppercase italic">{{ __('Welcome Gift') }}</h1>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Scan QR Code') }}</p>
</div>
<div class="relative group">
<div class="absolute -inset-4 bg-gradient-to-tr from-cyan-500/20 to-emerald-500/20 blur-3xl opacity-50 transition-opacity duration-500 group-hover:opacity-100"></div>
<div class="relative luxury-card rounded-[3rem] overflow-hidden border-slate-200/50 dark:border-slate-700/50 shadow-2xl bg-white dark:bg-slate-900">
<div class="px-8 pt-10 pb-6 border-b border-dashed border-slate-100 dark:border-slate-800 relative">
<div class="absolute -left-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-r border-slate-100 dark:border-slate-800"></div>
<div class="absolute -right-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-l border-slate-100 dark:border-slate-800"></div>
<div class="flex items-center gap-4 mb-6">
<div class="w-12 h-12 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-100 dark:border-slate-700 p-2 flex items-center justify-center overflow-hidden">
<svg class="w-6 h-6 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5a2 2 0 10-2 2h2zm0 0h4" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight leading-tight mb-1">
{{ $welcomeGift->machine->name }}
</h2>
</div>
</div>
<div class="flex items-center justify-between">
<div class="text-center flex-1">
<span class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Welcome Gift') }}</span>
<span class="text-sm font-black text-slate-800 dark:text-white">{{ $welcomeGift->name }}</span>
</div>
<div class="w-px h-8 bg-slate-100 dark:bg-slate-800"></div>
<div class="text-center flex-1">
<span class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Status') }}</span>
<span class="text-xs font-black px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-500 uppercase tracking-tighter">{{ __('Active') }}</span>
</div>
</div>
</div>
<div class="p-10 flex flex-col items-center">
<div class="p-6 bg-white rounded-[2.5rem] shadow-xl border border-slate-100 mb-8 transform transition-transform duration-500 group-hover:scale-[1.02]">
<x-qr-code :data="$welcomeGift->code" :dynamic="false" size="300" class="w-48 h-48 sm:w-64 sm:h-64" />
</div>
<div class="text-center space-y-2 mb-2">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em]">{{ __('Welcome Gift Code') }}</p>
<p class="text-5xl font-black text-slate-800 dark:text-white tracking-tighter font-display italic leading-none">{{ $welcomeGift->code }}</p>
</div>
</div>
<div class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800 text-center">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{{ __('Expiry Time') }}</p>
<p class="text-sm font-black text-slate-600 dark:text-slate-300">
{{ $welcomeGift->expires_at ? $welcomeGift->expires_at->format('Y/m/d H:i') : __('Permanent') }}
</p>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -210,12 +210,12 @@
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Pass Codes') }}</span>
</a></li>
@endcan
{{--
@can('menu.sales.store-gifts')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.sales.store-gifts') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.sales.store-gifts') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 12c-.07.665-.45 1.243-1.119 1.243H5.494c-.669 0-1.189-.578-1.119-1.243l-.625-12m16.5 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 7.5m17.25 0h-17.25m6 0v12m6-12v12m-9-12h12" /></svg>
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Store Gifts') }}</span>
</a></li>
--}}
@endcan
</ul>
</div>
</li>

View File

@ -67,6 +67,7 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
Route::post('machine/pickup/verify/B660', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyPickupCode']);
Route::post('machine/passcode/verify/B670', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyPassCode']);
Route::post('machine/staff/verify/B680', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyStaffCard']);
Route::post('machine/welcome-gift/verify/B690', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyWelcomeGift']);
// 廣告與貨道清單 (B005, B009, B012)
Route::get('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']);

View File

@ -27,6 +27,7 @@ Route::get('/machines/distribution', [App\Http\Controllers\Admin\BasicSettings\M
// 公開取貨憑證頁面 (無需登入)
Route::get('/p/{slug}', [App\Http\Controllers\Guest\PickupController::class, 'show'])->name('pickup.ticket')->middleware('throttle:60,1');
Route::get('/t/{slug}', [App\Http\Controllers\Guest\PassCodeController::class, 'show'])->name('pass-code.ticket')->middleware('throttle:60,1');
Route::get('/g/{slug}', [App\Http\Controllers\Guest\WelcomeGiftController::class, 'show'])->name('welcome-gift.ticket')->middleware('throttle:60,1');
Route::get('/dashboard', function () {
return redirect()->route('admin.dashboard');
@ -141,6 +142,9 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::get('/orders', [App\Http\Controllers\Admin\SalesController::class, 'orders'])->name('orders');
Route::get('/promotions', [App\Http\Controllers\Admin\SalesController::class, 'promotions'])->name('promotions');
Route::get('/store-gifts', [App\Http\Controllers\Admin\SalesController::class, 'storeGifts'])->name('store-gifts');
Route::post('/store-gifts', [App\Http\Controllers\Admin\SalesController::class, 'storeWelcomeGift'])->name('store-gifts.store');
Route::patch('/store-gifts/{welcomeGift}', [App\Http\Controllers\Admin\SalesController::class, 'updateWelcomeGift'])->name('store-gifts.update');
Route::delete('/store-gifts/{welcomeGift}', [App\Http\Controllers\Admin\SalesController::class, 'destroyWelcomeGift'])->name('store-gifts.destroy');
// 詳情路由必須放在最後,避免攔截其他具體路由
Route::get('/{order}', [App\Http\Controllers\Admin\SalesController::class, 'show'])->name('show');

View File

@ -0,0 +1,242 @@
<?php
namespace Tests\Feature\Api\V1;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\Transaction\Order;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use App\Services\Transaction\TransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class WelcomeGiftTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private Machine $machine;
private string $apiToken;
protected function setUp(): void
{
parent::setUp();
// 建立 Company
$this->company = Company::create([
'name' => 'Test Company',
]);
$this->apiToken = 'test-machine-api-token-123456';
// 建立 Machine並帶入 api_token 與 company_id
$this->machine = Machine::factory()->create([
'company_id' => $this->company->id,
'api_token' => $this->apiToken,
]);
}
/**
* 1. 測試折數與趴數雙向換算 (Model Accessors)
*/
public function test_welcome_gift_discount_conversion_logic(): void
{
// A. 百分比折抵15% off (8.5折)
$giftA = WelcomeGift::create([
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'name' => '15% off Welcome Gift',
'code' => '85000000',
'discount_type' => 'percentage',
'discount_value' => 15,
'usage_type' => 'once',
'usage_limit' => 1,
'status' => 'active',
]);
$this->assertEquals(8.5, $giftA->input_fold);
$this->assertEquals('八五折', $giftA->discount_label);
// B. 百分比折抵20% off (8折)
$giftB = WelcomeGift::create([
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'name' => '20% off Welcome Gift',
'code' => '80000000',
'discount_type' => 'percentage',
'discount_value' => 20,
'usage_type' => 'once',
'usage_limit' => 1,
'status' => 'active',
]);
$this->assertEquals(8, $giftB->input_fold);
$this->assertEquals('八折', $giftB->discount_label);
// C. 金額折抵:折 50 元
$giftC = WelcomeGift::create([
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'name' => '50 NTD off Welcome Gift',
'code' => '50000000',
'discount_type' => 'amount',
'discount_value' => 50,
'usage_type' => 'once',
'usage_limit' => 1,
'status' => 'active',
]);
$this->assertNull($giftC->input_fold);
$this->assertEquals('折 50 元', $giftC->discount_label);
}
/**
* 2. 測試 B690 API 驗證成功
*/
public function test_b690_verification_success(): void
{
$gift = WelcomeGift::create([
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'name' => '15% off Welcome Gift',
'code' => '12345678',
'discount_type' => 'percentage',
'discount_value' => 15,
'usage_type' => 'once',
'usage_limit' => 1,
'status' => 'active',
]);
$response = $this->withHeader('Authorization', "Bearer {$this->apiToken}")
->postJson('/api/v1/app/machine/welcome-gift/verify/B690', [
'code' => '12345678',
]);
$response->assertStatus(200)
->assertJson([
'success' => true,
'code' => 200,
'data' => [
'name' => '15% off Welcome Gift',
'code_id' => $gift->id,
'discount_type' => 'percentage',
'discount_value' => 15,
'discount_label' => '八五折',
'usage_type' => 'once',
'status' => 'active',
],
]);
// 驗證是否有寫入 verify_success 日誌
$this->assertDatabaseHas('welcome_gift_logs', [
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'welcome_gift_id' => $gift->id,
'action' => 'verify_success',
]);
}
/**
* 3. 測試 B690 驗證防刷與 Lockout 限流鎖定
*/
public function test_b690_lockout_mechanism(): void
{
$lockoutKey = "welcome_gift_lockout:{$this->machine->id}";
$failsKey = "welcome_gift_fails:{$this->machine->id}";
Cache::forget($lockoutKey);
Cache::forget($failsKey);
// 連續 4 次錯誤
for ($i = 0; $i < 4; $i++) {
$response = $this->withHeader('Authorization', "Bearer {$this->apiToken}")
->postJson('/api/v1/app/machine/welcome-gift/verify/B690', [
'code' => '00000000',
]);
$response->assertStatus(404)
->assertJson([
'success' => false,
'remaining_attempts' => 4 - $i,
]);
}
// 第 5 次錯誤,觸發 Lockout 60 秒鎖定
$response = $this->withHeader('Authorization', "Bearer {$this->apiToken}")
->postJson('/api/v1/app/machine/welcome-gift/verify/B690', [
'code' => '00000000',
]);
$response->assertStatus(404)
->assertJson([
'success' => false,
'remaining_attempts' => 0,
]);
// 第 6 次呼叫,因 Lockout 鎖定,直接回傳 429
$response = $this->withHeader('Authorization', "Bearer {$this->apiToken}")
->postJson('/api/v1/app/machine/welcome-gift/verify/B690', [
'code' => '00000000',
]);
$response->assertStatus(429)
->assertJson([
'success' => false,
'message' => 'Too many attempts. Locked.',
]);
}
/**
* 4. 測試交易核銷閉環 (Finalize Transaction)
*/
public function test_transaction_service_finalize_will_consume_welcome_gift(): void
{
// 建立一個單次使用限制的來店禮
$gift = WelcomeGift::create([
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'name' => '15% off Welcome Gift',
'code' => '12345678',
'discount_type' => 'percentage',
'discount_value' => 15,
'usage_type' => 'once',
'usage_limit' => 1,
'status' => 'active',
]);
$transactionService = app(TransactionService::class);
$payload = [
'serial_no' => $this->machine->serial_no,
'flow_id' => 'FLOW-123456-WELCOME-GIFT',
'order' => [
'flow_id' => 'FLOW-123456-WELCOME-GIFT',
'total_amount' => 100,
'discount_amount' => 15,
'pay_amount' => 85,
'welcome_gift_id' => $gift->id,
],
];
// 執行交易 finalize
$order = $transactionService->finalizeTransaction($payload);
$this->assertInstanceOf(Order::class, $order);
$this->assertEquals($gift->id, $order->welcome_gift_id);
// 驗證來店禮 usage_count 被加 1且狀態變為 used (因為 usage_limit 為 1)
$gift->refresh();
$this->assertEquals(1, $gift->usage_count);
$this->assertEquals('used', $gift->status);
// 驗證核銷日誌被建立
$this->assertDatabaseHas('welcome_gift_logs', [
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'welcome_gift_id' => $gift->id,
'order_id' => $order->id,
'action' => 'consume',
]);
}
}