[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:
parent
016cb571c4
commit
f59efc6dc7
@ -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`
|
||||
|
||||
@ -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** — 確保遵守三語系對齊、字母排序、斜線不逸出規範
|
||||
@ -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
|
||||
|
||||
161
.agents/skills/multilingual-specs/SKILL.md
Normal file
161
.agents/skills/multilingual-specs/SKILL.md
Normal 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` 確認修改範疇僅限於新增的語系,無亂序與無關的語系被刪除。
|
||||
@ -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.'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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'
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
26
app/Http/Controllers/Guest/WelcomeGiftController.php
Normal file
26
app/Http/Controllers/Guest/WelcomeGiftController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
153
app/Models/Transaction/WelcomeGift.php
Normal file
153
app/Models/Transaction/WelcomeGift.php
Normal 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;
|
||||
}
|
||||
}
|
||||
56
app/Models/Transaction/WelcomeGiftLog.php
Normal file
56
app/Models/Transaction/WelcomeGiftLog.php
Normal 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 獲取)
|
||||
*/
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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)。'
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
704
lang/en.json
704
lang/en.json
File diff suppressed because it is too large
Load Diff
721
lang/ja.json
721
lang/ja.json
File diff suppressed because it is too large
Load Diff
670
lang/zh_TW.json
670
lang/zh_TW.json
File diff suppressed because it is too large
Load Diff
913
resources/views/admin/sales/welcome-gifts/index.blade.php
Normal file
913
resources/views/admin/sales/welcome-gifts/index.blade.php
Normal 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
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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') }} © {{ date('Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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') }} © {{ date('Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
77
resources/views/guest/welcome-gift/show.blade.php
Normal file
77
resources/views/guest/welcome-gift/show.blade.php
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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');
|
||||
|
||||
242
tests/Feature/Api/V1/WelcomeGiftTest.php
Normal file
242
tests/Feature/Api/V1/WelcomeGiftTest.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user