[FEAT] 取貨碼功能強化與 UI 重構

1. 取貨碼模組新增 Slug 機制與公開憑證頁面,支援外部連結訪問。
2. 狀態篩選下拉選單重構為極簡奢華風組件 (x-searchable-select),並優化後端空值處理。
3. 機台分佈地圖新增「依型號分類」功能,提升管理便利性。
4. 新增 B660 取貨碼驗證 API 端點供機台通訊使用。
5. 更新多語言語系檔 (zh_TW.json) 確保介面一致性。
This commit is contained in:
sky121113 2026-04-29 10:39:35 +08:00
parent 78321ad693
commit 6b9b577978
15 changed files with 719 additions and 216 deletions

View File

@ -369,7 +369,8 @@ class MachineSettingController extends AdminController
{ {
$machines = Machine::whereNotNull('latitude') $machines = Machine::whereNotNull('latitude')
->whereNotNull('longitude') ->whereNotNull('longitude')
->get(['id', 'name', 'serial_no', 'address', 'location', 'latitude', 'longitude', 'status']); ->with('machineModel')
->get(['id', 'name', 'serial_no', 'address', 'location', 'latitude', 'longitude', 'status', 'machine_model_id']);
return view('admin.basic-settings.machines.distribution', compact('machines')); return view('admin.basic-settings.machines.distribution', compact('machines'));
} }

View File

@ -42,11 +42,12 @@ class SalesController extends Controller
}); });
} }
if ($request->status) { if ($request->status && trim($request->status) !== '') {
$query->where('status', $request->status); $query->where('status', trim($request->status));
} }
$pickupCodes = $query->paginate(15)->withQueryString(); $per_page = $request->input('per_page', 15);
$pickupCodes = $query->paginate($per_page)->withQueryString();
// 供新增彈窗使用的機台清單 // 供新增彈窗使用的機台清單
$machines = Machine::all(); $machines = Machine::all();

View File

@ -14,6 +14,7 @@ use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use App\Models\Transaction\PickupCode;
class MachineController extends Controller class MachineController extends Controller
{ {
@ -490,6 +491,86 @@ class MachineController extends Controller
'success' => true, 'success' => true,
'code' => 200, 'code' => 200,
'data' => [$data] // App 預期的是包含單一物件的陣列 'data' => [$data] // App 預期的是包含單一物件的陣列
}
/**
* B660: Verify Pickup Code (with Lockout)
* 驗證 4 位數取貨碼,並具備連續錯誤鎖定機制
*/
public function verifyPickupCode(Request $request)
{
$machine = $request->get('machine'); // 來自 IotAuth 中間層
$lockoutKey = "pickup_lockout:{$machine->id}";
// 檢查是否處於鎖定狀態
if (Cache::has($lockoutKey)) {
return response()->json([
'success' => false,
'message' => 'Too many failed attempts. Please try again in 1 minute.',
'code' => 429
], 429);
}
$validator = Validator::make($request->all(), [
'code' => 'required|string|size:8'
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Invalid code format',
'code' => 400
], 400);
}
$code = $request->input('code');
$pickupCode = PickupCode::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where('expires_at', '>', now())
->first();
if (!$pickupCode) {
// 追蹤失敗次數
$failKey = "pickup_fails:{$machine->id}";
$fails = Cache::increment($failKey);
// 第一次失敗,設定計數器的過期時間 (例如 10 分鐘內累計)
if ($fails === 1) {
Cache::put($failKey, 1, 600);
}
if ($fails >= 5) {
Cache::put($lockoutKey, true, 60); // 鎖定 1 分鐘
Cache::forget($failKey); // 清除錯誤計數
return response()->json([
'success' => false,
'message' => 'Too many failed attempts. Machine locked for 1 minute.',
'code' => 429
], 429);
}
return response()->json([
'success' => false,
'message' => 'Invalid code',
'code' => 404,
'remaining_attempts' => 5 - $fails
], 404);
}
// 驗證成功:清除失敗紀錄
Cache::forget("pickup_fails:{$machine->id}");
return response()->json([
'success' => true,
'message' => 'Valid code',
'code' => 200,
'data' => [
'slot_no' => $pickupCode->slot_no,
'pickup_code_id' => $pickupCode->id,
'status' => 'active'
]
]); ]);
} }
} }

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Guest;
use App\Http\Controllers\Controller;
use App\Models\Transaction\PickupCode;
use Illuminate\Http\Request;
class PickupController extends Controller
{
/**
* 顯示公開取貨憑證頁面
*/
public function show($slug)
{
$pickupCode = PickupCode::with(['machine.slots.product'])
->where(function($query) use ($slug) {
$query->where('slug', $slug)
->orWhere('code', $slug); // 相容舊版或測試
})
->where('status', 'active')
->where('expires_at', '>', now())
->firstOrFail();
return view('guest.pickup.show', compact('pickupCode'));
}
}

View File

@ -17,12 +17,31 @@ class PickupCode extends Model
'machine_id', 'machine_id',
'slot_no', 'slot_no',
'code', 'code',
'slug',
'expires_at', 'expires_at',
'used_at', 'used_at',
'status', 'status',
'created_by', 'created_by',
]; ];
/**
* 獲取公開取貨連結
*/
public function getTicketUrlAttribute()
{
return $this->slug ? route('pickup.ticket', $this->slug) : route('pickup.ticket', $this->code);
}
/**
* Boot the model.
*/
protected static function booted()
{
static::creating(function ($pickupCode) {
$pickupCode->slug = \Illuminate\Support\Str::random(16);
});
}
protected $casts = [ protected $casts = [
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'used_at' => 'datetime', 'used_at' => 'datetime',
@ -53,12 +72,12 @@ class PickupCode extends Model
} }
/** /**
* 產生唯一的取貨碼 (4) * 產生唯一的取貨碼 (8)
*/ */
public static function generateUniqueCode(int $machineId): string public static function generateUniqueCode(int $machineId): string
{ {
do { do {
$code = str_pad(rand(0, 9999), 4, '0', STR_PAD_LEFT); $code = str_pad(rand(0, 99999999), 8, '0', STR_PAD_LEFT);
$exists = self::where('machine_id', $machineId) $exists = self::where('machine_id', $machineId)
->where('code', $code) ->where('code', $code)
->where('status', 'active') ->where('status', 'active')

View File

@ -16,7 +16,7 @@ return new class extends Migration
$table->foreignId('company_id')->constrained('companies')->onDelete('cascade'); $table->foreignId('company_id')->constrained('companies')->onDelete('cascade');
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade'); $table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->string('slot_no')->comment('貨道編號'); $table->string('slot_no')->comment('貨道編號');
$table->string('code', 10)->comment('取貨碼 (通常為 4 位)'); $table->string('code', 10)->comment('取貨碼 (8 位)');
$table->timestamp('expires_at')->comment('到期時間'); $table->timestamp('expires_at')->comment('到期時間');
$table->timestamp('used_at')->nullable()->comment('使用時間'); $table->timestamp('used_at')->nullable()->comment('使用時間');
$table->string('status')->default('active')->comment('狀態: active, used, expired, cancelled'); $table->string('status')->default('active')->comment('狀態: active, used, expired, cancelled');

View File

@ -0,0 +1,28 @@
<?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('pickup_codes', function (Blueprint $table) {
$table->string('slug', 32)->nullable()->unique()->after('code');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
//
});
}
};

View File

@ -177,7 +177,6 @@
"by": "由", "by": "由",
"Calculate Replenishment": "計算補貨量", "Calculate Replenishment": "計算補貨量",
"Cancel": "取消", "Cancel": "取消",
"Cancel Code": "取消代碼",
"Cancel Order": "取消訂單", "Cancel Order": "取消訂單",
"Cancel Purchase": "取消購買", "Cancel Purchase": "取消購買",
"Cancelled": "已取消", "Cancelled": "已取消",
@ -529,7 +528,6 @@
"Functional Settings": "功能設定", "Functional Settings": "功能設定",
"Games": "互動遊戲", "Games": "互動遊戲",
"General permissions not linked to a specific menu.": "未連結到特定選單的一般權限。", "General permissions not linked to a specific menu.": "未連結到特定選單的一般權限。",
"Generate": "產生",
"Generate and manage one-time pickup codes for customers": "產生並管理客戶的一次性取貨碼", "Generate and manage one-time pickup codes for customers": "產生並管理客戶的一次性取貨碼",
"Generate New Code": "產生新代碼", "Generate New Code": "產生新代碼",
"Generate Pickup Code": "產生取貨碼", "Generate Pickup Code": "產生取貨碼",
@ -626,7 +624,8 @@
"Loading...": "載入中...", "Loading...": "載入中...",
"Location": "位置", "Location": "位置",
"Location found!": "已成功獲取座標!", "Location found!": "已成功獲取座標!",
"Location not found": "找不到該地址的座標", "Location not found": "找不到位置,您可以嘗試使用地標名稱(例如:花蓮縣政府)",
"Try using landmark names (e.g., Hualien County Government)": "您可以嘗試使用地標名稱(例如:花蓮縣政府)",
"Lock": "鎖定", "Lock": "鎖定",
"Lock Now": "立即鎖定", "Lock Now": "立即鎖定",
"Lock Page": "頁面鎖定", "Lock Page": "頁面鎖定",
@ -1002,6 +1001,12 @@
"Picked up Time": "領取時間", "Picked up Time": "領取時間",
"Machine / Slot": "機台 / 貨道", "Machine / Slot": "機台 / 貨道",
"Pickup Code": "取貨碼", "Pickup Code": "取貨碼",
"Pickup code generated: :code": "已生成取貨碼::code",
"Pickup code updated.": "取貨碼已更新",
"Pickup code cancelled.": "取貨碼已取消",
"Pass code created: :code": "通行碼已建立::code",
"Pass code updated.": "通行碼已更新",
"Pass code deleted.": "通行碼已刪除",
"Pickup Codes": "取貨碼", "Pickup Codes": "取貨碼",
"Pickup door closed": "取貨門已關閉", "Pickup door closed": "取貨門已關閉",
"Pickup door error": "取貨門運作異常", "Pickup door error": "取貨門運作異常",
@ -1170,7 +1175,6 @@
"Sales Management": "銷售管理", "Sales Management": "銷售管理",
"Sales Permissions": "銷售管理權限", "Sales Permissions": "銷售管理權限",
"Sales Records": "銷售紀錄", "Sales Records": "銷售紀錄",
"Save": "儲存變更",
"Save Changes": "儲存變更", "Save Changes": "儲存變更",
"Save Config": "儲存配置", "Save Config": "儲存配置",
"Save error:": "儲存錯誤:", "Save error:": "儲存錯誤:",
@ -1302,6 +1306,29 @@
"Start Delivery": "開始配送", "Start Delivery": "開始配送",
"Statistics": "數據統計", "Statistics": "數據統計",
"Status": "狀態", "Status": "狀態",
"Search by code, machine name or serial...": "搜尋代碼、機台名稱或序號...",
"Product / Slot": "商品 / 貨道",
"Pickup Ticket": "取貨憑證",
"Valid Until": "有效期限至",
"Valid Ticket": "有效憑證",
"Scan to pick up your product": "掃描以領取您的商品",
"Unknown Product": "未知商品",
"Ticket": "憑證",
"QR": "二維碼",
"QR CODE": "二維碼",
"Expires At": "到期時間",
"Copy Link": "複製連結",
"Download Image": "下載圖片",
"Image Downloaded": "圖片已下載",
"Link Copied": "連結已複製",
"Scan this code at the machine or share the link with the customer.": "請在機台掃描此碼,或將連結分享給客戶。",
"Close": "關閉",
"Copy": "複製",
"Select Slot": "選擇貨道",
"Validity Period": "有效期限",
"Max 24 Hours": "最長 24 小時",
"Expected Expiry Date & Time": "預計到期時間",
"Please select a machine": "請選擇機台",
"Status / Temp / Sub / Card / Scan": "狀態 / 溫度 / 下位機 / 刷卡機 / 掃碼機", "Status / Temp / Sub / Card / Scan": "狀態 / 溫度 / 下位機 / 刷卡機 / 掃碼機",
"Status Timeline": "狀態時間線", "Status Timeline": "狀態時間線",
"Status updated successfully": "狀態更新成功", "Status updated successfully": "狀態更新成功",
@ -1487,15 +1514,13 @@
"Utilization Timeline": "稼動時序", "Utilization Timeline": "稼動時序",
"Utilization, OEE and Operational Intelligence": "稼動率、OEE 與營運情報", "Utilization, OEE and Operational Intelligence": "稼動率、OEE 與營運情報",
"Utilized Time": "稼動持續時間", "Utilized Time": "稼動持續時間",
"Valid Until": "合約到期日", "Expiry Time": "到期時間",
"Validation Error": "驗證錯誤", "Validation Error": "驗證錯誤",
"Vending": "販賣頁", "Vending": "販賣頁",
"vending": "販賣頁", "vending": "販賣頁",
"Vending Page": "販賣頁", "Vending Page": "販賣頁",
"Venue Management": "場地管理", "Venue Management": "場地管理",
"Used": "已使用", "Used": "已使用",
"Validity Period (Days)": "有效期限 (天)",
"Validity Period (Hours)": "有效期限 (小時)",
"Video": "影片", "Video": "影片",
"video": "影片", "video": "影片",
"View all warehouses": "查看所有倉庫", "View all warehouses": "查看所有倉庫",
@ -1564,27 +1589,22 @@
"Tenant": "客戶", "Tenant": "客戶",
"Permission Tags": "權限標籤", "Permission Tags": "權限標籤",
"All Permissions": "全部權限", "All Permissions": "全部權限",
"All Status": "所有狀態",
"Select Slot": "選擇貨道",
"Validity Period (Hours)": "有效期限 (小時)",
"Hrs": "小時",
"No Actions": "無操作",
"Target Machine": "目標機台",
"Description / Name": "描述 / 名稱", "Description / Name": "描述 / 名稱",
"Pass Code (8 Digits)": "通行碼 (8 位數)", "Pass Code (8 Digits)": "通行碼 (8 位數)",
"Regenerate": "重新產生",
"Validity Period (Days)": "有效期限 (天)", "Validity Period (Days)": "有效期限 (天)",
"Leave empty for permanent code": "留空表示永久有效", "Leave empty for permanent code": "留空表示永久有效",
"Permanent": "永久有效", "Permanent": "永久有效",
"Add Pickup Code": "新增取貨碼", "Add Pickup Code": "新增取貨碼",
"Add Pass Code": "新增通行碼", "Add Pass Code": "新增通行碼",
"QR": "QR Code",
"Scan QR Code": "掃描 QR Code", "Scan QR Code": "掃描 QR Code",
"View QR Code": "查看 QR Code", "View QR Code": "查看 QR Code",
"Expected Expiry": "預計過期時間", "Expected Expiry": "預計過期時間",
"Max :max hours": "最多 :max 小時", "Max :max hours": "最多 :max 小時",
"Expected Expiry Date & Time": "預計過期日期與時間", "Cancel Pickup Code": "取消取貨碼",
"Validity Period": "有效期限", "Are you sure you want to cancel this pickup code? This action cannot be undone.": "確定要取消此取貨碼嗎?此操作無法復原。",
"Max 24 Hours": "最多 24 小時", "Yes, Cancel": "確認取消",
"Please select a machine": "請選擇機台" "Delete Pass Code": "刪除通行碼",
"Are you sure you want to delete this pass code?": "確定要刪除此通行碼嗎?",
"Yes, Delete": "確認刪除",
"Delete Code": "刪除代碼"
} }

View File

@ -60,27 +60,101 @@
{{ __('Machine Distribution') }} {{ __('Machine Distribution') }}
</p> </p>
</div> </div>
<!-- Model Statistics -->
@php
$groupedByModel = $machines->groupBy(fn($m) => $m->machineModel?->name ?? __('Unknown Model'));
$modelColors = [
'U4' => '#EAB308', // Yellow
'U3' => '#15803D', // Green
'U1' => '#F97316', // Orange
'U4S' => '#0D9488', // Teal
'I1' => '#854D0E', // Olive
'U4SSS' => '#EF4444', // Red
'Y1' => '#F97316', // Orange
'U4SS' => '#3B82F6', // Blue
'U4S(短)' => '#1E3A8A', // Dark Blue
'U4XXXS' => '#64748B', // Slate
'易豐主機' => '#22C55E', // Bright Green
'I10' => '#A855F7', // Purple
'N1' => '#BEF264', // Lime
'Y2' => '#DC2626', // Red
'U4_Android9_test' => '#10b981', // Emerald
];
// Unify fallback color logic with JS
$getFallbackColor = function($name) {
$hash = 0;
$len = mb_strlen($name);
for ($i = 0; $i < $len; $i++) {
$char = mb_substr($name, $i, 1);
$code = mb_ord($char);
$hash = $code + (($hash << 5) - $hash);
$hash = $hash & 0xFFFFFFFF; // Keep it 32-bit
}
return sprintf('#%06X', $hash & 0xFFFFFF);
};
@endphp
<div class="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar"> <div class="p-4 border-b border-slate-200/50 dark:border-slate-800/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-y-auto max-h-64 custom-scrollbar">
<div class="flex items-center justify-between mb-3">
<h3 class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Statistics: Model') }}</h3>
</div>
<div class="grid grid-cols-1 gap-2">
@foreach($groupedByModel as $modelName => $group)
@php $color = $modelColors[$modelName] ?? $getFallbackColor($modelName); @endphp
<div class="flex items-center justify-between group cursor-pointer" onclick="filterByModel('{{ $modelName }}')">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full shadow-sm" style="background-color: {{ $color }}"></span>
<span class="text-xs font-bold text-slate-600 dark:text-slate-300 group-hover:text-cyan-500 transition-colors">{{ $modelName }}</span>
</div>
<span class="text-[10px] font-mono text-slate-400">({{ $group->count() }})</span>
</div>
@endforeach
</div>
</div>
<!-- Search Box -->
<div class="px-4 pb-4 border-b border-slate-200/50 dark:border-slate-800/50 bg-slate-50/30 dark:bg-slate-900/30 pt-4">
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
<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.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</span>
<input type="text" id="machine-search" onkeyup="updateSearchFilter(this.value)" placeholder="{{ __('Search name or serial...') }}"
class="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl text-xs font-bold text-slate-700 dark:text-slate-200 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 transition-all shadow-sm">
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar" id="machine-list">
@foreach($machines as $machine) @foreach($machines as $machine)
<div class="p-4 rounded-2xl bg-white/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 hover:border-cyan-500/30 transition-all cursor-pointer group" @php
$modelName = $machine->machineModel?->name ?? __('Unknown Model');
$color = $modelColors[$modelName] ?? $getFallbackColor($modelName);
@endphp
<div class="machine-item p-4 rounded-2xl bg-white/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 hover:border-cyan-500/30 transition-all cursor-pointer group"
data-model="{{ $modelName }}"
data-id="{{ $machine->id }}"
onclick="focusMachine({{ $machine->latitude }}, {{ $machine->longitude }}, {{ $machine->id }})"> onclick="focusMachine({{ $machine->latitude }}, {{ $machine->longitude }}, {{ $machine->id }})">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-sm font-black text-slate-800 dark:text-white group-hover:text-cyan-500 transition-colors"> <div class="flex items-center gap-2">
{{ $machine->name }} <span class="w-2 h-2 rounded-full" style="background-color: {{ $color }}"></span>
</span> <span class="text-sm font-black text-slate-800 dark:text-white group-hover:text-cyan-500 transition-colors">
{{ $machine->name }}
</span>
</div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full {{ $machine->status === 'online' ? 'bg-emerald-500' : ($machine->status === 'offline' ? 'bg-slate-400' : 'bg-rose-500') }}"></span> <span class="w-1.5 h-1.5 rounded-full {{ $machine->status === 'online' ? 'bg-emerald-500' : ($machine->status === 'offline' ? 'bg-slate-400' : 'bg-rose-500') }}"></span>
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ __($machine->status) }}</span> <span class="text-[9px] font-bold text-slate-400 uppercase tracking-widest">{{ __($machine->status) }}</span>
</div> </div>
</div> </div>
<p class="text-xs font-medium text-slate-500 dark:text-slate-400 leading-relaxed"> <p class="text-xs font-medium text-slate-500 dark:text-slate-400 leading-relaxed truncate">
{{ $machine->address ?: $machine->location ?: __('No address information') }} {{ $machine->address ?: $machine->location ?: __('No address information') }}
</p> </p>
<div class="mt-3 flex items-center gap-2"> <div class="mt-3 flex items-center justify-between">
<span class="text-[10px] font-mono text-slate-400 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded shadow-sm"> <span class="text-[10px] font-mono text-slate-400 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded shadow-sm">
{{ $machine->serial_no }} {{ $machine->serial_no }}
</span> </span>
<span class="text-[9px] font-black text-slate-300 dark:text-slate-600 uppercase">{{ $modelName }}</span>
</div> </div>
</div> </div>
@endforeach @endforeach
@ -91,6 +165,9 @@
<span>{{ __('Total Machines') }}</span> <span>{{ __('Total Machines') }}</span>
<span class="text-slate-800 dark:text-white">{{ $machines->count() }}</span> <span class="text-slate-800 dark:text-white">{{ $machines->count() }}</span>
</div> </div>
<button onclick="resetFilter()" class="mt-4 w-full py-2 rounded-xl border border-slate-200 dark:border-slate-800 text-[10px] font-black text-slate-400 hover:text-cyan-500 hover:border-cyan-500/30 transition-all uppercase tracking-widest">
{{ __('Show All') }}
</button>
</div> </div>
</div> </div>
@ -122,6 +199,17 @@
<script> <script>
const machines = @json($machines); const machines = @json($machines);
const modelColors = @json($modelColors);
function getFallbackColor(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const color = '#' + (hash & 0x00FFFFFF).toString(16).toUpperCase().padStart(6, '0');
return color;
}
const map = L.map('map', { const map = L.map('map', {
zoomControl: false zoomControl: false
}).setView([23.973875, 120.982024], 8); // Center of Taiwan }).setView([23.973875, 120.982024], 8); // Center of Taiwan
@ -137,13 +225,16 @@
}).addTo(map); }).addTo(map);
const markers = {}; const markers = {};
const markerGroups = {};
machines.forEach(machine => { machines.forEach(machine => {
if (machine.latitude && machine.longitude) { if (machine.latitude && machine.longitude) {
const statusClass = `marker-${machine.status}`; const modelName = machine.machine_model?.name || '{{ __("Unknown Model") }}';
const color = modelColors[modelName] || getFallbackColor(modelName);
const icon = L.divIcon({ const icon = L.divIcon({
className: 'custom-marker', className: 'custom-marker',
html: `<div class="marker-dot ${statusClass}"></div>`, html: `<div class="marker-dot" style="background-color: ${color}; border-color: white;"></div>`,
iconSize: [20, 20], iconSize: [20, 20],
iconAnchor: [10, 10] iconAnchor: [10, 10]
}); });
@ -152,14 +243,20 @@
const popupContent = ` const popupContent = `
<div class="p-2 min-w-[200px]"> <div class="p-2 min-w-[200px]">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center justify-between mb-2">
<div class="w-2 h-2 rounded-full ${machine.status === 'online' ? 'bg-emerald-500' : (machine.status === 'offline' ? 'bg-slate-400' : 'bg-rose-500')}"></div> <div class="flex items-center gap-2">
<span class="text-sm font-black text-slate-800">${machine.name}</span> <div class="w-3 h-3 rounded-full" style="background-color: ${color}"></div>
<span class="text-sm font-black text-slate-800">${machine.name}</span>
</div>
<span class="text-[9px] font-bold text-slate-400 uppercase tracking-widest">${modelName}</span>
</div> </div>
<p class="text-[11px] font-medium text-slate-500 mb-2">${machine.address || machine.location || ''}</p> <p class="text-[11px] font-medium text-slate-500 mb-2">${machine.address || machine.location || ''}</p>
<div class="flex items-center justify-between border-t border-slate-100 pt-2 mt-2"> <div class="flex items-center justify-between border-t border-slate-100 pt-2 mt-2">
<span class="text-[9px] font-black text-slate-400 uppercase tracking-widest">${machine.serial_no}</span> <span class="text-[9px] font-black text-slate-400 uppercase tracking-widest">${machine.serial_no}</span>
<span class="text-[9px] font-bold text-cyan-600 uppercase tracking-widest">${machine.status.toUpperCase()}</span> <div class="flex items-center gap-1">
<div class="w-1.5 h-1.5 rounded-full ${machine.status === 'online' ? 'bg-emerald-500' : (machine.status === 'offline' ? 'bg-slate-400' : 'bg-rose-500')}"></div>
<span class="text-[9px] font-bold text-cyan-600 uppercase tracking-widest">${machine.status.toUpperCase()}</span>
</div>
</div> </div>
</div> </div>
`; `;
@ -170,6 +267,9 @@
}); });
markers[machine.id] = marker; markers[machine.id] = marker;
if (!markerGroups[modelName]) markerGroups[modelName] = [];
markerGroups[modelName].push(marker);
} }
}); });
@ -184,6 +284,62 @@
}, 1600); }, 1600);
} }
let currentModelFilter = null;
let currentSearchQuery = '';
function applyFilters() {
const visibleMarkers = [];
machines.forEach(machine => {
const modelName = machine.machine_model?.name || '{{ __("Unknown Model") }}';
const matchesModel = !currentModelFilter || modelName === currentModelFilter;
const matchesSearch = !currentSearchQuery ||
machine.name.toLowerCase().includes(currentSearchQuery) ||
machine.serial_no.toLowerCase().includes(currentSearchQuery);
const isVisible = matchesModel && matchesSearch;
if (markers[machine.id]) {
if (isVisible) {
markers[machine.id].addTo(map);
visibleMarkers.push(markers[machine.id]);
} else {
markers[machine.id].remove();
}
}
const item = document.querySelector(`.machine-item[data-id="${machine.id}"]`);
if (item) {
if (isVisible) item.classList.remove('hidden');
else item.classList.add('hidden');
}
});
// If we have visible markers and a model filter is active, adjust bounds
if (currentModelFilter && visibleMarkers.length > 0) {
const group = new L.featureGroup(visibleMarkers);
map.fitBounds(group.getBounds().pad(0.1));
}
}
function filterByModel(modelName) {
currentModelFilter = modelName;
applyFilters();
}
function updateSearchFilter(query) {
currentSearchQuery = query.toLowerCase().trim();
applyFilters();
}
function resetFilter() {
currentModelFilter = null;
currentSearchQuery = '';
document.getElementById('machine-search').value = '';
applyFilters();
map.setView([23.973875, 120.982024], 8);
}
// Adjust map on window resize // Adjust map on window resize
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
map.invalidateSize(); map.invalidateSize();

View File

@ -721,6 +721,27 @@
<input type="text" name="serial_no" class="luxury-input w-full" <input type="text" name="serial_no" class="luxury-input w-full"
placeholder="{{ __('Enter serial number') }}"> placeholder="{{ __('Enter serial number') }}">
</div> </div>
<div>
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Location') }}
</label>
<input type="text" name="location" class="luxury-input w-full"
placeholder="{{ __('Enter machine location') }}">
</div>
<div class="relative z-30">
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Model') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="machine_model_id" :placeholder="__('Select Model')">
@foreach($models as $model)
<option value="{{ $model->id }}">{{ $model->name }}</option>
@endforeach
</x-searchable-select>
</div>
<div class="relative z-20"> <div class="relative z-20">
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2"> class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
@ -734,56 +755,8 @@
@endforeach @endforeach
</x-searchable-select> </x-searchable-select>
</div> </div>
<div>
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Location') }}
</label>
<input type="text" name="location" class="luxury-input w-full"
placeholder="{{ __('Enter machine location') }}">
</div>
<div class="relative z-10">
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Model') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="machine_model_id" :placeholder="__('Select Model')">
@foreach($models as $model)
<option value="{{ $model->id }}">{{ $model->name }}</option>
@endforeach
</x-searchable-select>
</div>
<div x-data="machineGeocoding()" class="col-span-full grid grid-cols-1 md:grid-cols-4 gap-6 pt-6 border-t border-slate-100 dark:border-slate-800">
<div class="md:col-span-2">
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Address') }}</label>
<div class="flex gap-2">
<input type="text" name="address" x-model="address" class="luxury-input w-full" placeholder="{{ __('Enter full address') }}">
<button type="button" @click="geocode()" :disabled="loading" class="btn-luxury-ghost py-2 px-3 flex items-center gap-2 whitespace-nowrap min-w-[120px] justify-center">
<template x-if="!loading">
<div class="flex items-center gap-2">
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>{{ __('Resolve Coordinates') }}</span>
</div>
</template>
<template x-if="loading">
<div class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4 text-indigo-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
<span>{{ __('Searching...') }}</span>
</div>
</template>
</button>
</div>
</div>
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Latitude') }}</label>
<input type="number" step="any" name="latitude" class="luxury-input w-full" placeholder="{{ __('e.g., 25.0330') }}">
</div>
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Longitude') }}</label>
<input type="number" step="any" name="longitude" class="luxury-input w-full" placeholder="{{ __('e.g., 121.5654') }}">
</div>
</div>
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2"> class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
@ -1467,42 +1440,6 @@
@section('scripts') @section('scripts')
<script> <script>
function machineGeocoding() {
return {
address: '',
loading: false,
async geocode() {
if (!this.address) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: @js(__('Please enter address first')), type: 'error' } }));
return;
}
this.loading = true;
try {
const response = await fetch(@js(route('admin.basic-settings.machines.geocode')), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ address: this.address })
});
const data = await response.json();
if (data.success) {
document.querySelector('#create_modal input[name=latitude]').value = parseFloat(data.lat).toFixed(6);
document.querySelector('#create_modal input[name=longitude]').value = parseFloat(data.lon).toFixed(6);
window.dispatchEvent(new CustomEvent('toast', { detail: { message: @js(__('Location found!')), type: 'success' } }));
} else {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: @js(__('Location not found')), type: 'error' } }));
}
} catch (error) {
console.error('Geocoding error:', error);
window.dispatchEvent(new CustomEvent('toast', { detail: { message: @js(__('Error searching location')), type: 'error' } }));
} finally {
this.loading = false;
}
}
}
}
</script> </script>
@endsection @endsection

View File

@ -3,6 +3,8 @@
@section('content') @section('content')
<div class="space-y-4 pb-20" x-data="{ <div class="space-y-4 pb-20" x-data="{
showCreateModal: false, showCreateModal: false,
showDeleteModal: false,
deleteTargetForm: '',
selectedMachine: '', selectedMachine: '',
name: '', name: '',
expiresDays: 7, expiresDays: 7,
@ -31,6 +33,19 @@
} finally { } finally {
this.isLoadingTable = false; this.isLoadingTable = false;
} }
},
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;
} }
}"> }">
{{-- Page Header --}} {{-- Page Header --}}
@ -126,10 +141,10 @@
</td> </td>
<td class="px-6 py-6 whitespace-nowrap text-right"> <td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<form action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST" onsubmit="return confirm('{{ __('Are you sure you want to delete this pass code?') }}')"> <form id="delete-form-desktop-{{ $code->id }}" action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" class="p-2 text-rose-500 hover:bg-rose-500/10 rounded-xl transition-all duration-200" title="{{ __('Delete Code') }}"> <button type="button" @click="confirmDelete('delete-form-desktop-{{ $code->id }}')" class="p-2 text-rose-500 hover:bg-rose-500/10 rounded-xl transition-all duration-200" title="{{ __('Delete Code') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v2m3 4h.01" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v2m3 4h.01" />
</svg> </svg>
@ -185,10 +200,10 @@
{{-- Action Buttons --}} {{-- Action Buttons --}}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<form action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST" class="flex-1" onsubmit="return confirm('{{ __('Are you sure you want to delete this pass code?') }}')"> <form id="delete-form-mobile-{{ $code->id }}" action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST" class="flex-1">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-rose-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50"> <button type="button" @click="confirmDelete('delete-form-mobile-{{ $code->id }}')" class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-rose-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v2m3 4h.01" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v2m3 4h.01" />
</svg> </svg>
@ -281,5 +296,16 @@
</div> </div>
</div> </div>
</div> </div>
{{-- Confirm Delete Modal --}}
<x-confirm-modal
alpineVar="showDeleteModal"
:title="__('Delete Pass Code')"
:message="__('Are you sure you want to delete this pass code?')"
:confirmText="__('Yes, Delete')"
confirmAction="submitDelete()"
confirmColor="rose"
iconType="danger"
/>
</div> </div>
@endsection @endsection

View File

@ -26,11 +26,11 @@
:title="__('Pickup Codes')" :title="__('Pickup Codes')"
:subtitle="__('Generate and manage one-time pickup codes for customers')" :subtitle="__('Generate and manage one-time pickup codes for customers')"
> >
<button @click="showCreateModal = true" class="btn-luxury-primary"> <button @click="showCreateModal = true" class="btn-luxury-primary whitespace-nowrap flex items-center gap-2 transition-all duration-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"> <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="M12 4.5v15m7.5-7.5h-15" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" />
</svg> </svg>
<span>{{ __('Add Pickup Code') }}</span> <span class="text-sm sm:text-base">{{ __('Add Pickup Code') }}</span>
</button> </button>
</x-page-header> </x-page-header>
@ -38,7 +38,10 @@
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden"> <div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<x-luxury-spinner show="isLoadingTable" /> <x-luxury-spinner show="isLoadingTable" />
<div id="ajax-content-container" :class="{ 'opacity-30 pointer-events-none': isLoadingTable }"> <div id="ajax-content-container"
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': isLoadingTable }"
@ajax:navigate.prevent.stop="fetchPage($event.detail.url)"
@click="if ($event.target.closest('a') && $event.target.closest('a').href && ($event.target.closest('a').href.includes('page=') || $event.target.closest('a').href.includes('per_page='))) { $event.preventDefault(); fetchPage($event.target.closest('a').href); }">
<form action="{{ route('admin.sales.pickup-codes') }}" method="GET" <form action="{{ route('admin.sales.pickup-codes') }}" method="GET"
class="flex flex-col md:flex-row md:items-center gap-3 mb-8" class="flex flex-col md:flex-row md:items-center gap-3 mb-8"
@ -46,7 +49,12 @@
<div class="relative group flex-1 md:flex-none"> <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"> <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" y1="21" x2="16.65" y2="16.65"/></svg> <svg class="w-4 h-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors 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" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span> </span>
<input type="text" name="search" value="{{ request('search') }}" <input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
@ -54,23 +62,26 @@
</div> </div>
<div class="w-full md:w-48"> <div class="w-full md:w-48">
<select name="status" class="luxury-input w-full text-sm" @change="$el.closest('form').dispatchEvent(new Event('submit'))"> <x-searchable-select name="status" id="filter-status" :placeholder="__('All Status')" :selected="request('status')" :hasSearch="false" @change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="">{{ __('All Status') }}</option> <option value="active" {{ request('status') === 'active' ? 'selected' : '' }} data-title="{{ __('Active') }}">{{ __('Active') }}</option>
<option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>{{ __('Active') }}</option> <option value="used" {{ request('status') === 'used' ? 'selected' : '' }} data-title="{{ __('Used') }}">{{ __('Used') }}</option>
<option value="used" {{ request('status') === 'used' ? 'selected' : '' }}>{{ __('Used') }}</option> <option value="expired" {{ request('status') === 'expired' ? 'selected' : '' }} data-title="{{ __('Expired') }}">{{ __('Expired') }}</option>
<option value="expired" {{ request('status') === 'expired' ? 'selected' : '' }}>{{ __('Expired') }}</option> <option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }} data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
<option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }}>{{ __('Cancelled') }}</option> </x-searchable-select>
</select>
</div> </div>
<div class="flex items-center gap-2 ml-auto md:ml-0 shrink-0"> <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') }}"> <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> <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>
<button type="button" <button type="button"
@click="$el.closest('form').querySelector('input[name=search]').value=''; $el.closest('form').querySelector('select[name=status]').value=''; $el.closest('form').dispatchEvent(new Event('submit'))" @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') }}"> class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95 border border-slate-200 dark:border-slate-700" 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> <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> </button>
</div> </div>
</form> </form>
@ -89,9 +100,6 @@
<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"> <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">
{{ __('Product / Slot') }} {{ __('Product / Slot') }}
</th> </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">
{{ __('QR') }}
</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"> <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') }} {{ __('Expires At') }}
</th> </th>
@ -107,9 +115,16 @@
@forelse ($pickupCodes as $code) @forelse ($pickupCodes as $code)
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200"> <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"> <td class="px-6 py-6 whitespace-nowrap">
<span class="text-lg font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-tighter bg-cyan-50 dark:bg-cyan-500/10 px-3 py-1 rounded-lg border border-cyan-100 dark:border-cyan-500/20 shadow-sm"> @if($code->status === 'active' && $code->expires_at->isFuture())
{{ $code->code }} <span @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
</span> class="text-lg font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-tighter bg-cyan-50 dark:bg-cyan-500/10 px-3 py-1 rounded-lg 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">
{{ $code->code }}
</span>
@else
<span class="text-lg font-black font-mono text-slate-400 dark:text-slate-600 tracking-tighter bg-slate-50 dark:bg-slate-900/50 px-3 py-1 rounded-lg border border-slate-100 dark:border-slate-800/50 shadow-sm">
{{ $code->code }}
</span>
@endif
</td> </td>
<td class="px-6 py-6"> <td class="px-6 py-6">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">{{ $code->machine->name }}</div> <div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">{{ $code->machine->name }}</div>
@ -123,13 +138,6 @@
<div class="text-sm font-black text-cyan-600 dark:text-cyan-400 mb-0.5">{{ $productName }}</div> <div class="text-sm font-black text-cyan-600 dark:text-cyan-400 mb-0.5">{{ $productName }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Slot') }}: {{ $code->slot_no }}</div> <div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Slot') }}: {{ $code->slot_no }}</div>
</td> </td>
<td class="px-6 py-6 text-center">
<button @click="activeQrCode = '{{ $code->code }}'; showQrModal = true" class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-cyan-500 hover:text-white transition-all duration-300 shadow-sm border border-slate-200 dark:border-slate-700">
<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" d="M12 4v1m0 11v1m4-11h.01M17 16h.01M9 16h.01M12 12h.01M12 12v1M12 12h-1M12 12h1M9 8h.01M9 8h.01M7 8h.01M7 8h.01M7 10h.01M7 12h.01M7 14h.01M7 16h.01M17 8h.01M17 10h.01M17 12h.01M17 14h.01M17 16h.01M9 10h.01M9 12h.01M9 14h.01M9 16h.01M11 8h.01M11 10h.01M11 12h.01M11 14h.01M11 16h.01M13 8h.01M13 10h.01M13 12h.01M13 14h.01M13 16h.01M15 8h.01M15 10h.01M15 12h.01M15 14h.01M15 16h.01" />
</svg>
</button>
</td>
<td class="px-6 py-6 whitespace-nowrap"> <td class="px-6 py-6 whitespace-nowrap">
<div class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono tracking-widest">{{ $code->expires_at->format('Y-m-d H:i') }}</div> <div class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono tracking-widest">{{ $code->expires_at->format('Y-m-d H:i') }}</div>
<div class="text-xs font-bold text-slate-400 mt-0.5">{{ $code->expires_at->diffForHumans() }}</div> <div class="text-xs font-bold text-slate-400 mt-0.5">{{ $code->expires_at->diffForHumans() }}</div>
@ -143,12 +151,24 @@
<td class="px-6 py-6 whitespace-nowrap text-right"> <td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
@if($code->status === 'active' && $code->expires_at->isFuture()) @if($code->status === 'active' && $code->expires_at->isFuture())
<form action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST" onsubmit="return confirm('{{ __('Are you sure you want to cancel this code?') }}')"> {{-- View QR Code Button --}}
<button @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 dark:hover:bg-emerald-500/10 border border-transparent hover:border-emerald-500/20 transition-all inline-flex group/btn"
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>
@endif
@if($code->status === 'active' && $code->expires_at->isFuture())
<form id="cancel-form-desktop-{{ $code->id }}" action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" class="p-2 text-rose-500 hover:bg-rose-500/10 rounded-xl transition-all duration-200" title="{{ __('Cancel Code') }}"> <button type="button" @click="confirmCancel('cancel-form-desktop-{{ $code->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 Code') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <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> </svg>
</button> </button>
</form> </form>
@ -157,7 +177,7 @@
</td> </td>
</tr> </tr>
@empty @empty
<x-empty-state mode="table" :colspan="6" :message="__('No pickup codes found')" /> <x-empty-state mode="table" :colspan="5" :message="__('No pickup codes found')" />
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
@ -176,9 +196,16 @@
</svg> </svg>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<h3 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"> @if($code->status === 'active' && $code->expires_at->isFuture())
{{ $code->code }} <h3 @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
</h3> 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">
{{ $code->code }}
</h3>
@else
<h3 class="text-base font-extrabold text-slate-400 dark:text-slate-600 truncate tracking-tight">
{{ $code->code }}
</h3>
@endif
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate"> <p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $code->machine->name }} {{ $code->machine->name }}
</p> </p>
@ -209,33 +236,31 @@
</div> </div>
</div> </div>
{{-- QR Button (Mobile) --}}
<div class="mb-6">
<button @click="activeQrCode = '{{ $code->code }}'; showQrModal = true" class="w-full flex items-center justify-center gap-2 py-3 rounded-2xl bg-cyan-50 dark:bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 font-black text-xs uppercase tracking-widest border border-cyan-100 dark:border-cyan-500/20 hover:bg-cyan-500 hover:text-white transition-all duration-300">
<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="M12 4v1m0 11v1m4-11h.01M17 16h.01M9 16h.01M12 12h.01M12 12v1M12 12h-1M12 12h1M9 8h.01M9 8h.01M7 8h.01M7 8h.01M7 10h.01M7 12h.01M7 14h.01M7 16h.01M17 8h.01M17 10h.01M17 12h.01M17 14h.01M17 16h.01M9 10h.01M9 12h.01M9 14h.01M9 16h.01M11 8h.01M11 10h.01M11 12h.01M11 14h.01M11 16h.01M13 8h.01M13 10h.01M13 12h.01M13 14h.01M13 16h.01M15 8h.01M15 10h.01M15 12h.01M15 14h.01M15 16h.01" />
</svg>
{{ __('View QR Code') }}
</button>
</div>
{{-- Action Buttons --}} {{-- Action Buttons --}}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@if($code->status === 'active' && $code->expires_at->isFuture()) @if($code->status === 'active' && $code->expires_at->isFuture())
<form action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST" class="flex-1" onsubmit="return confirm('{{ __('Are you sure you want to cancel this code?') }}')"> {{-- QR Button (Mobile) --}}
<button @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->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>
@endif
@if($code->status === 'active' && $code->expires_at->isFuture())
<form id="cancel-form-mobile-{{ $code->id }}" action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST" class="flex-1">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-rose-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50"> <button type="button" @click="confirmCancel('cancel-form-mobile-{{ $code->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-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <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> </svg>
{{ __('Cancel') }} {{ __('Cancel') }}
</button> </button>
</form> </form>
@else
<div class="flex-1 py-3 text-center text-slate-400 text-xs font-bold uppercase tracking-widest bg-slate-50/50 dark:bg-slate-800/30 rounded-xl">
{{ __('No Actions') }}
</div>
@endif @endif
</div> </div>
</div> </div>
@ -354,41 +379,81 @@
</div> </div>
{{-- QR Modal --}} {{-- QR Modal --}}
<div x-show="showQrModal" <div x-show="showQrModal" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak
class="fixed inset-0 z-[60] overflow-y-auto" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter="transition ease-out duration-300" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:enter-start="opacity-0" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
x-transition:enter-end="opacity-100" <div class="flex items-center justify-center min-h-screen px-4">
x-transition:leave="transition ease-in duration-200" <div class="fixed inset-0 transition-opacity" @click="showQrModal = false">
x-transition:leave-start="opacity-100" <div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"></div>
x-transition:leave-end="opacity-0" </div>
x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 p-0"> <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="fixed inset-0 transition-opacity bg-slate-900/80 backdrop-blur-md" @click="showQrModal = false"></div> <div class="px-8 py-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<div>
<div x-show="showQrModal" 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" <h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('QR Code') }}</h3>
class="inline-block px-8 py-12 text-center align-bottom transition-all transform luxury-card rounded-[3rem] dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-sm sm:w-full overflow-hidden"> <p class="text-xs font-bold text-cyan-600 dark:text-cyan-400 mt-1 uppercase tracking-widest">
{{ __('Pickup Code') }}: <span class="font-mono" x-text="activeQrCode"></span>
<div class="mb-8"> </p>
<div class="w-20 h-20 bg-cyan-500 rounded-3xl mx-auto flex items-center justify-center shadow-lg shadow-cyan-500/30 mb-6">
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v1m0 11v1m4-11h.01M17 16h.01M9 16h.01M12 12h.01M12 12v1M12 12h-1M12 12h1M9 8h.01M9 8h.01M7 8h.01M7 8h.01M7 10h.01M7 12h.01M7 14h.01M7 16h.01M17 8h.01M17 10h.01M17 12h.01M17 14h.01M17 16h.01M9 10h.01M9 12h.01M9 14h.01M9 16h.01M11 8h.01M11 10h.01M11 12h.01M11 14h.01M11 16h.01M13 8h.01M13 10h.01M13 12h.01M13 14h.01M13 16h.01M15 8h.01M15 10h.01M15 12h.01M15 14h.01M15 16h.01" />
</svg>
</div> </div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight mb-2">{{ __('Scan QR Code') }}</h3> <button @click="showQrModal = false" class="text-slate-400 hover:text-slate-600 transition-colors">
<p class="text-sm font-bold text-slate-400 uppercase tracking-widest">{{ __('Pickup Code') }}: <span class="text-cyan-500" x-text="activeQrCode"></span></p> <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>
<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">
{{-- Copy Link Button --}}
<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 001.242 7.244" />
</svg>
<span class="text-xs font-black uppercase tracking-widest">{{ __('Copy') }}</span>
</button>
<div class="relative group p-4 bg-white dark:bg-white/5 rounded-[2.5rem] border border-slate-100 dark:border-slate-800 shadow-inner mb-8"> {{-- Download Image Button --}}
<x-qr-code data="activeQrCode" size="300" class="w-full h-auto aspect-square mx-auto rounded-3xl" /> <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>
<button @click="showQrModal = false" class="w-full btn-luxury-primary py-4 rounded-2xl text-lg">
{{ __('Close') }}
</button>
</div> </div>
</div> </div>
</div> </div>
{{-- Confirm Cancel Modal --}}
<x-confirm-modal
alpineVar="showCancelModal"
:title="__('Cancel Pickup Code')"
:message="__('Are you sure you want to cancel this pickup code? This action cannot be undone.')"
:confirmText="__('Yes, Cancel')"
confirmAction="submitCancel()"
confirmColor="rose"
iconType="danger"
/>
</div> </div>
<script> <script>
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
@ -400,9 +465,12 @@ document.addEventListener('alpine:init', () => {
maxHours: 24, maxHours: 24,
showQrModal: false, showQrModal: false,
activeQrCode: '', activeQrCode: '',
activeTicketUrl: '',
slots: [], slots: [],
loadingSlots: false, loadingSlots: false,
isLoadingTable: false, isLoadingTable: false,
showCancelModal: false,
cancelTargetForm: null,
slotSelectConfig: config, slotSelectConfig: config,
calculateExpiry() { calculateExpiry() {
@ -452,6 +520,39 @@ document.addEventListener('alpine:init', () => {
} }
}, },
copyQrLink() {
if (!this.activeTicketUrl) return;
navigator.clipboard.writeText(this.activeTicketUrl).then(() => {
window.showToast("{{ __('Link Copied') }}", 'success');
});
},
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 = `pickup-code-${this.activeQrCode}.png`;
link.click();
URL.revokeObjectURL(link.href);
});
},
confirmCancel(formId) {
this.cancelTargetForm = formId;
this.showCancelModal = true;
},
submitCancel() {
if (this.cancelTargetForm) {
const form = document.getElementById(this.cancelTargetForm);
if (form) form.submit();
}
this.showCancelModal = false;
},
updateSlotSelect() { updateSlotSelect() {
this.$nextTick(() => { this.$nextTick(() => {
const wrapper = document.getElementById('slot-select-wrapper'); const wrapper = document.getElementById('slot-select-wrapper');

View File

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<title>{{ __('Pickup Ticket') }} - {{ 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="h-full bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-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-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>
<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">
@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" />
</svg>
@endif
</div>
<div class="flex-1">
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight leading-tight mb-1">
{{ $product?->name ?? __('Unknown Product') }}
</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">{{ __('Slot') }}</span>
<span class="text-2xl font-black text-slate-800 dark:text-white font-display">{{ $pickupCode->slot_no }}</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-emerald-500/10 text-emerald-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" />
</div>
<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>
</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">
{{ $pickupCode->expires_at->format('Y/m/d H:i') }}
</p>
</div>
</div>
</div>
{{-- Footer Branding --}}
<div class="mt-12 text-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.4em] opacity-50">{{ config('app.name') }} &copy; {{ date('Y') }}</p>
</div>
</div>
</body>
</html>

View File

@ -63,6 +63,7 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
Route::post('machine/timer/B710', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncTimer']); Route::post('machine/timer/B710', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncTimer']);
Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']); Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']);
Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']); Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']);
Route::post('machine/pickup/verify/B660', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyPickupCode']);
// 廣告與貨道清單 (B005, B009, B012) // 廣告與貨道清單 (B005, B009, B012)
Route::get('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']); Route::get('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']);

View File

@ -24,6 +24,9 @@ Route::get('/', function () {
// 公開機台分布地圖 (無需登入) // 公開機台分布地圖 (無需登入)
Route::get('/machines/distribution', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'distribution'])->name('machines.distribution'); Route::get('/machines/distribution', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'distribution'])->name('machines.distribution');
// 公開取貨憑證頁面 (無需登入)
Route::get('/p/{slug}', [App\Http\Controllers\Guest\PickupController::class, 'show'])->name('pickup.ticket')->middleware('throttle:60,1');
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
return redirect()->route('admin.dashboard'); return redirect()->route('admin.dashboard');
})->middleware(['auth', 'verified'])->name('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard');