Files
star-cloud/resources/views/admin/ads/index.blade.php
sky121113 54d62c5378
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m7s
[FEAT] 實作機台廣告管理模組與多語系支援
1. 新增廣告管理列表與機台配置介面,包含多語系 (zh_TW, en, ja) 與完整 CRUD
2. 實作基於 Alpine 的廣告素材預覽輪播功能
3. 優化廣告素材下拉選單,強制綁定所屬公司以達成多租戶資料隔離
4. 重構廣告配置中廣告影片的縮圖渲染邏輯,移除 <video> 標籤以大幅提升頁面載入速度與節省頻寬
5. 放寬個人檔案頭像上傳限制,支援 WebP 格式
2026-03-31 13:30:41 +08:00

721 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@extends('layouts.admin')
@php
$routeName = request()->route()->getName();
$baseRoute = 'admin.data-config.advertisements';
@endphp
@section('content')
<div class="space-y-2 pb-20"
x-data="adManager"
data-ads="{{ json_encode($advertisements->items()) }}"
data-machines="{{ json_encode($machines) }}"
data-all-ads="{{ json_encode($allAds) }}"
data-active-tab="{{ $tab }}"
data-urls='{
"store": "{{ route($baseRoute . ".store") }}",
"update": "{{ route($baseRoute . ".update", ":id") }}",
"delete": "{{ route($baseRoute . ".destroy", ":id") }}",
"getMachineAds": "{{ route($baseRoute . ".machine.get", ":id") }}",
"assign": "{{ route($baseRoute . ".assign") }}",
"reorder": "{{ route($baseRoute . ".assignments.reorder") }}",
"removeAssignment": "{{ route($baseRoute . ".assignment.remove", ":id") }}"
}'>
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Advertisement Management') }}</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
{{ __('Manage ad materials and machine playback settings') }}
</p>
</div>
<div class="flex items-center gap-3" x-show="activeTab === 'list'">
<button @click="openAddModal()" class="btn-luxury-primary">
<svg class="size-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 Advertisement') }}</span>
</button>
</div>
</div>
<!-- Tabs Navigation (Pills Style match Machine List) -->
<div class="flex items-center gap-1 p-1.5 bg-slate-100 dark:bg-slate-900/50 rounded-2xl w-fit border border-slate-200/50 dark:border-slate-800/50" aria-label="Tabs">
<button type="button"
@click="activeTab = 'list'"
:class="activeTab === 'list' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
{{ __('Advertisement List') }}
</button>
<button type="button"
@click="activeTab = 'machine'"
:class="activeTab === 'machine' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
{{ __('Machine Advertisement Settings') }}
</button>
</div>
<!-- Tab Contents -->
<div class="mt-6">
<!-- List Tab -->
<div x-show="activeTab === 'list'" class="luxury-card rounded-3xl p-8 animate-luxury-in" x-cloak>
<div class="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">{{ __('Preview') }}</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') }}</th>
@if(auth()->user()->isSystemAdmin())
<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-left">{{ __('Company Name') }}</th>
@endif
<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">{{ __('Type') }}</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">{{ __('Duration') }}</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">{{ __('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-50 dark:divide-slate-800/80">
@forelse($advertisements as $ad)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-4">
<div @click="openPreview(@js($ad))"
class="w-16 h-9 rounded-lg bg-slate-100 dark:bg-slate-800 overflow-hidden shadow-sm border border-slate-200 dark:border-white/5 cursor-pointer hover:scale-105 hover:shadow-cyan-500/20 transition-all duration-300">
@if($ad->type === 'image')
<img src="{{ $ad->url }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full flex items-center justify-center bg-slate-900 group-hover:bg-cyan-900 transition-colors">
<svg class="size-4 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5.14v14l11-7-11-7z"/></svg>
</div>
@endif
</div>
</td>
<td @click="openPreview(@js($ad))"
class="px-6 py-4 whitespace-nowrap text-sm font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors cursor-pointer">
{{ $ad->name }}
</td>
@if(auth()->user()->isSystemAdmin())
<td class="px-6 py-4 whitespace-nowrap text-sm font-bold text-slate-600 dark:text-slate-300">
{{ $ad->company->name ?? __('System Default') }}
</td>
@endif
<td class="px-6 py-4 text-center">
<span class="text-[10px] font-black uppercase tracking-widest px-2 py-0.5 rounded-full {{ $ad->type === 'video' ? 'bg-indigo-500/10 text-indigo-500 border border-indigo-500/20' : 'bg-cyan-500/10 text-cyan-500 border border-cyan-500/20' }}">
{{ __($ad->type) }}
</span>
</td>
<td class="px-6 py-4 text-center whitespace-nowrap text-sm font-black text-slate-700 dark:text-slate-200">
{{ $ad->duration }}s
</td>
<td class="px-6 py-4 text-center">
@if($ad->is_active)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Active') }}</span>
@else
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
@endif
</td>
<td class="px-6 py-4 text-right">
<div class="flex justify-end items-center gap-2">
<button @click="openEditModal(@js($ad))" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>
</button>
<button @click="confirmDelete(@js($ad->id))" class="p-2.5 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">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><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 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="{{ auth()->user()->isSystemAdmin() ? 7 : 6 }}" class="px-6 py-20 text-center text-slate-400 italic">{{ __('No advertisements found.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-8">
{{ $advertisements->links('vendor.pagination.luxury') }}
</div>
</div>
<!-- Machine View Tab -->
<div x-show="activeTab === 'machine'" class="space-y-6" x-cloak>
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<!-- Machine Filter -->
<div class="max-w-md mx-auto mb-10">
<label class="block text-sm font-black text-slate-700 dark:text-slate-200 mb-3 text-center uppercase tracking-widest">{{ __('Please select a machine first') }}</label>
<x-searchable-select
name="machine_selector"
:options="$machines->map(fn($m) => (object)['id' => $m->id, 'name' => $m->name . ' (' . $m->serial_no . ')'])"
placeholder="{{ __('Search Machine...') }}"
@change="selectMachine($event.target.value)"
/>
</div>
<div x-show="selectedMachineId" class="animate-luxury-in">
<!-- Positions Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
@foreach(['vending', 'visit_gift', 'standby'] as $pos)
<div class="space-y-4">
<div class="flex items-center justify-between px-2">
<h3 class="text-sm font-black text-slate-800 dark:text-white uppercase tracking-widest flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-cyan-500"></span>
{{ __($pos) }}
</h3>
<div class="flex items-center gap-2">
<button x-cloak x-show="machineAds['{{ $pos }}'] && machineAds['{{ $pos }}'].length > 0"
@click="startSequencePreview('{{ $pos }}')"
class="p-1.5 px-3 text-[10px] font-black bg-slate-800 dark:bg-slate-700 text-white rounded-lg hover:bg-slate-700 transition-colors uppercase flex items-center gap-1">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ __('Preview') }}
</button>
<button @click="openAssignModal('{{ $pos }}')" class="p-1.5 px-3 text-[10px] font-black bg-cyan-500 text-white rounded-lg hover:bg-cyan-600 transition-colors uppercase shadow-sm shadow-cyan-500/20 flex items-center gap-1">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4.5v15m7.5-7.5h-15" /></svg>
{{ __('Ad Settings') }}
</button>
</div>
</div>
<div class="luxury-card p-4 bg-slate-50/50 dark:bg-slate-900/40 border border-slate-100 dark:border-white/5 space-y-3 min-h-[150px]">
<template x-if="!machineAds['{{ $pos }}'] || machineAds['{{ $pos }}'].length === 0">
<div class="flex flex-col items-center justify-center h-full py-10">
<svg class="size-6 text-slate-300 dark:text-slate-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v6m-3-3h6m-9-3a9 9 0 1118 0 9 9 0 01-18 0z"/></svg>
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500">{{ __('No assignments') }}</span>
</div>
</template>
<template x-for="(assign, index) in machineAds['{{ $pos }}']" :key="assign.id">
<div class="flex items-center gap-4 bg-white dark:bg-slate-800 p-3 rounded-xl border border-slate-100 dark:border-white/5 shadow-sm group hover:border-cyan-500/30 transition-all">
<!-- Sort Controls -->
<div class="flex flex-col gap-1 shrink-0 bg-slate-50 dark:bg-slate-900 rounded-lg p-1 border border-slate-100 dark:border-white/5">
<button @click.prevent="moveUp('{{ $pos }}', index)" :disabled="index === 0" class="p-1 text-slate-400 hover:text-cyan-500 disabled:opacity-30 disabled:hover:text-slate-400 transition-colors">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" /></svg>
</button>
<button @click.prevent="moveDown('{{ $pos }}', index)" :disabled="index === machineAds['{{ $pos }}'].length - 1" class="p-1 text-slate-400 hover:text-cyan-500 disabled:opacity-30 disabled:hover:text-slate-400 transition-colors">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
</button>
</div>
<button @click="openPreview(assign.advertisement)" class="w-12 h-12 rounded-lg bg-slate-100 dark:bg-slate-900 border border-white/5 overflow-hidden shrink-0 hover:border-cyan-500/50 hover:shadow-[0_0_15px_rgba(6,182,212,0.3)] transition-all relative group/thumb">
<div class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity z-10">
<svg class="size-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
</div>
<template x-if="assign.advertisement.type === 'image'">
<img :src="assign.advertisement.url" class="w-full h-full object-cover">
</template>
<template x-if="assign.advertisement.type === 'video'">
<div class="w-full h-full flex items-center justify-center bg-slate-900 group-hover/thumb:bg-cyan-900 transition-colors">
<svg class="size-4 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5.14v14l11-7-11-7z"/></svg>
</div>
</template>
</button>
<div class="flex-1 min-w-0 flex flex-col justify-center cursor-pointer group-hover:text-cyan-500 transition-colors" @click="openPreview(assign.advertisement)">
<p class="text-xs font-black text-slate-700 dark:text-white truncate transition-colors" x-text="assign.advertisement.name"></p>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter mt-0.5" x-text="assign.advertisement.duration + 's'"></p>
</div>
<button @click="removeAssignment(assign.id)" class="p-1.5 text-slate-300 hover:text-rose-500 transition-colors">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</template>
</div>
</div>
@endforeach
</div>
</div>
<div x-show="!selectedMachineId" class="py-20 text-center text-slate-400 italic">
{{ __('Please select a machine to view and manage its advertisements.') }}
</div>
</div>
</div>
</div>
<!-- Modals -->
@include('admin.ads.partials.ad-modal')
@include('admin.ads.partials.assign-modal')
<!-- Preview Modal -->
<div x-show="isPreviewOpen"
class="fixed inset-0 z-[120] flex items-center justify-center p-4 sm:p-6"
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"
@keydown.escape.window="isPreviewOpen = false">
<div class="fixed inset-0 bg-slate-950/90 backdrop-blur-xl" @click="isPreviewOpen = false"></div>
<div class="relative max-w-5xl w-full max-h-[90vh] flex flex-col items-center justify-center animate-luxury-in">
<!-- Close Button -->
<button @click="isPreviewOpen = false"
class="absolute -top-12 right-0 p-2 text-white/50 hover:text-white transition-colors">
<svg class="size-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<!-- Content Area -->
<div class="w-full bg-slate-900/40 rounded-[2rem] border border-white/5 overflow-hidden shadow-2xl flex items-center justify-center">
<template x-if="isPreviewOpen && previewAd.type === 'image'">
<img :src="previewAd.url" class="max-w-full max-h-[80vh] object-contain shadow-2xl">
</template>
<template x-if="isPreviewOpen && previewAd.type === 'video'">
<video :src="previewAd.url" controls autoplay class="max-w-full max-h-[80vh] shadow-2xl"></video>
</template>
</div>
<!-- Footer Info -->
<div class="mt-4 text-center">
<h4 class="text-white font-black text-lg uppercase tracking-widest" x-text="previewAd.name"></h4>
<p class="text-white/40 text-[10px] font-bold uppercase tracking-[0.2em] mt-1" x-text="previewAd.type + ' | ' + previewAd.duration + 's'"></p>
</div>
</div>
</div>
<!-- Sequence Preview Modal -->
<div x-show="isSequencePreviewOpen"
class="fixed inset-0 z-[120] flex items-center justify-center p-4 sm:p-6"
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"
@keydown.escape.window="stopSequencePreview()">
<div class="fixed inset-0 bg-slate-950/95 backdrop-blur-md" @click="stopSequencePreview()"></div>
<div class="relative w-full max-w-5xl h-[85vh] flex flex-col items-center justify-center animate-luxury-in" x-show="currentSequenceAd">
<!-- Close Button -->
<button @click="stopSequencePreview()"
class="absolute -top-12 right-0 p-2 text-white/50 hover:text-white transition-colors">
<svg class="size-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<!-- Media Container -->
<div class="relative w-full h-full bg-slate-900 rounded-[2rem] border border-white/5 overflow-hidden shadow-2xl flex items-center justify-center">
<template x-if="currentSequenceAd && currentSequenceAd.advertisement.type === 'image'">
<img :src="currentSequenceAd.advertisement.url"
class="max-w-full max-h-full object-contain animate-luxury-in"
:key="'img-'+currentSequenceAd.id">
</template>
<template x-if="currentSequenceAd && currentSequenceAd.advertisement.type === 'video'">
<video :src="currentSequenceAd.advertisement.url"
autoplay muted playsinline
class="max-w-full max-h-full object-contain animate-luxury-in"
:key="'vid-'+currentSequenceAd.id"></video>
</template>
<!-- Progress Bar -->
<div class="absolute bottom-0 left-0 h-1.5 bg-cyan-500 transition-all duration-100 ease-linear"
:style="'width: ' + sequenceProgress + '%'"></div>
</div>
<!-- Header Info -->
<div class="absolute top-6 left-6 right-6 flex items-center justify-between z-10 w-[calc(100%-3rem)]">
<div class="bg-black/60 backdrop-blur-md rounded-xl px-4 py-2 border border-white/10 flex items-center gap-3">
<span class="text-white font-black tracking-widest text-sm" x-text="currentSequenceAd?.advertisement.name"></span>
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
<span class="text-cyan-400 font-black tracking-widest text-xs uppercase" x-text="(currentSequenceIndex + 1) + ' / ' + sequenceAds.length"></span>
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
<span class="text-white/80 font-bold tracking-widest text-xs tabular-nums" x-text="Math.ceil(sequenceRemainingTime) + 's'"></span>
</div>
<div class="flex items-center gap-2">
<button @click.stop="prevSequenceAd()" class="p-2.5 bg-black/60 backdrop-blur-md rounded-xl border border-white/10 text-white hover:bg-white/20 transition-all group">
<svg class="size-5 group-hover:-translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7"/></svg>
</button>
<!-- Play/Pause -->
<button @click.stop="toggleSequencePlay()" class="p-3 bg-cyan-500 backdrop-blur-md rounded-xl border border-cyan-400/30 text-white hover:bg-cyan-400 transition-all shadow-[0_0_15px_rgba(6,182,212,0.4)]">
<svg x-show="!isSequencePaused" class="size-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
<svg x-show="isSequencePaused" class="size-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button @click.stop="nextSequenceAd()" class="p-2.5 bg-black/60 backdrop-blur-md rounded-xl border border-white/10 text-white hover:bg-white/20 transition-all group">
<svg class="size-5 group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/></svg>
</button>
</div>
</div>
</div>
</div>
<x-delete-confirm-modal
:title="__('Delete Advertisement Confirmation')"
:message="__('Are you sure you want to delete this advertisement? This will also remove all assignments to machines.')"
/>
</div>
@endsection
@section('scripts')
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('adManager', () => ({
activeTab: 'list',
selectedMachineId: null,
machines: [],
allAds: [],
machineAds: {
vending: [],
visit_gift: [],
standby: []
},
urls: {},
// Ad CRUD Modal
isAdModalOpen: false,
isDeleteConfirmOpen: false,
deleteFormAction: '',
// Preview
isPreviewOpen: false,
previewAd: { url: '', type: '', name: '', duration: 15 },
adFormMode: 'add',
fileName: '',
adForm: {
id: null,
name: '',
type: 'image',
duration: 15,
is_active: true
},
// Assign Modal
isAssignModalOpen: false,
assignForm: {
machine_id: null,
advertisement_id: '',
position: ''
},
// Sequence Preview
isSequencePreviewOpen: false,
sequenceAds: [],
currentSequenceIndex: 0,
sequenceInterval: null,
sequenceRemainingTime: 0,
sequenceProgress: 0,
isSequencePaused: false,
get currentSequenceAd() {
return this.sequenceAds[this.currentSequenceIndex] || null;
},
startSequencePreview(pos) {
if (!this.machineAds[pos] || this.machineAds[pos].length === 0) return;
this.sequenceAds = this.machineAds[pos];
this.currentSequenceIndex = 0;
this.isSequencePreviewOpen = true;
this.isSequencePaused = false;
this.playSequenceAd();
},
stopSequencePreview() {
this.isSequencePreviewOpen = false;
this.clearSequenceTimers();
},
clearSequenceTimers() {
if (this.sequenceInterval) clearInterval(this.sequenceInterval);
},
playSequenceAd() {
this.clearSequenceTimers();
if (this.isSequencePaused) return;
const currentAd = this.currentSequenceAd?.advertisement;
if (!currentAd) return;
this.sequenceRemainingTime = currentAd.duration;
this.sequenceProgress = 0;
this.sequenceInterval = setInterval(() => {
if (this.isSequencePaused) return;
this.sequenceRemainingTime -= 0.1;
this.sequenceProgress = ((currentAd.duration - this.sequenceRemainingTime) / currentAd.duration) * 100;
if (this.sequenceRemainingTime <= 0) {
this.nextSequenceAd();
}
}, 100);
},
toggleSequencePlay() {
this.isSequencePaused = !this.isSequencePaused;
},
nextSequenceAd() {
this.currentSequenceIndex++;
if (this.currentSequenceIndex >= this.sequenceAds.length) {
this.currentSequenceIndex = 0; // Loop back
}
this.playSequenceAd();
},
prevSequenceAd() {
this.currentSequenceIndex--;
if (this.currentSequenceIndex < 0) {
this.currentSequenceIndex = this.sequenceAds.length - 1;
}
this.playSequenceAd();
},
openPreview(ad) {
this.previewAd = { ...ad };
this.isPreviewOpen = true;
},
// Sort Reordering logic
moveUp(position, index) {
if (index > 0) {
const list = this.machineAds[position];
const temp = list[index];
list[index] = list[index - 1];
list[index - 1] = temp;
this.syncSortOrder(position);
}
},
moveDown(position, index) {
const list = this.machineAds[position];
if (index < list.length - 1) {
const temp = list[index];
list[index] = list[index + 1];
list[index + 1] = temp;
this.syncSortOrder(position);
}
},
async syncSortOrder(position) {
const list = this.machineAds[position];
const assignmentIds = list.map(item => item.id);
try {
const response = await fetch(this.urls.reorder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ assignment_ids: assignmentIds })
});
const result = await response.json();
if (result.success) {
window.showToast?.(result.message, 'success');
} else {
window.showToast?.(result.message || 'Error', 'error');
this.fetchMachineAds(); // 如果更新失敗,重取恢復畫面原本樣子
}
} catch (e) {
console.error('Failed to update sort order', e);
window.showToast?.('System Error', 'error');
}
},
handleFileChange(e) {
const file = e.target.files[0];
if (file) {
this.fileName = file.name;
}
},
async submitAssignment() {
try {
const response = await fetch(this.urls.assign, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify(this.assignForm)
});
const result = await response.json();
if (result.success) {
this.isAssignModalOpen = false;
this.fetchMachineAds();
window.showToast?.(result.message, 'success');
} else {
window.showToast?.(result.message || 'Error', 'error');
}
} catch (e) {
console.error('Failed to assign ad', e);
}
},
init() {
this.urls = JSON.parse(this.$el.dataset.urls);
this.machines = JSON.parse(this.$el.dataset.machines || '[]');
this.allAds = JSON.parse(this.$el.dataset.allAds || '[]');
this.activeTab = this.$el.dataset.activeTab || 'list';
// Sync custom selects when modals open
this.$watch('isAdModalOpen', value => {
if (value) {
this.$nextTick(() => {
window.HSSelect.getInstance('#ad_type_select')?.setValue(this.adForm.type);
window.HSSelect.getInstance('#ad_duration_select')?.setValue(this.adForm.duration.toString());
if (document.querySelector('#ad_company_select')) {
window.HSSelect.getInstance('#ad_company_select')?.setValue(this.adForm.company_id || '');
}
});
}
});
},
selectMachine(id) {
if (!id || id === ' ') {
this.selectedMachineId = null;
return;
}
this.selectedMachineId = id;
this.fetchMachineAds();
},
async fetchMachineAds() {
const url = this.urls.getMachineAds.replace(':id', this.selectedMachineId);
try {
const response = await fetch(url);
const result = await response.json();
if (result.success) {
this.machineAds = {
vending: result.data.vending || [],
visit_gift: result.data.visit_gift || [],
standby: result.data.standby || []
};
}
} catch (e) {
console.error('Failed to fetch machine ads', e);
}
},
openAddModal() {
this.adFormMode = 'add';
this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true };
this.isAdModalOpen = true;
},
openEditModal(ad) {
this.adFormMode = 'edit';
this.adForm = { ...ad };
this.isAdModalOpen = true;
},
openAssignModal(pos) {
this.assignForm = {
machine_id: this.selectedMachineId,
advertisement_id: '',
position: pos,
sort_order: this.machineAds[pos]?.length || 0
};
this.updateAssignSelect();
this.isAssignModalOpen = true;
},
updateAssignSelect() {
const machine = this.machines.find(m => m.id == this.selectedMachineId);
const companyId = machine ? machine.company_id : null;
// 篩選出同公司的素材(或是系統層級的共通素材如果 company_id 為 null
// 若沒有特別設定,通常 null 為系統共用
const filteredAds = this.allAds.filter(ad => ad.company_id == companyId || ad.company_id == null);
const wrapper = document.getElementById('assign_ad_select_wrapper');
if (!wrapper) return;
wrapper.innerHTML = '';
const selectEl = document.createElement('select');
selectEl.name = 'advertisement_id';
selectEl.id = 'assign_ad_select_' + Date.now();
selectEl.className = 'hidden';
const configStr = JSON.stringify({
"placeholder": "{{ __('Please select a material') }}",
"hasSearch": true,
"searchPlaceholder": "{{ __('Search...') }}",
"isHidePlaceholder": false,
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"toggleClasses": "hs-select-toggle luxury-select-toggle",
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[100] animate-luxury-in",
"optionClasses": "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
"optionTemplate": '<div class="flex items-center justify-between w-full"><span data-title></span><span class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></span></div>'
});
selectEl.setAttribute('data-hs-select', configStr);
if (filteredAds.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = "{{ __('No materials available') }}";
opt.disabled = true;
selectEl.appendChild(opt);
} else {
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = "{{ __('Please select a material') }}";
selectEl.appendChild(emptyOpt);
filteredAds.forEach(ad => {
const opt = document.createElement('option');
opt.value = ad.id;
opt.textContent = `${ad.name} (${ad.type === 'video' ? "{{ __('video') }}" : "{{ __('image') }}"}, ${ad.duration}s)`;
opt.setAttribute('data-title', opt.textContent);
if (ad.id === this.assignForm.advertisement_id) opt.selected = true;
selectEl.appendChild(opt);
});
}
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', (e) => {
this.assignForm.advertisement_id = e.target.value;
});
this.$nextTick(() => {
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit(['select']);
}
});
},
async removeAssignment(id) {
if (!confirm("{{ __('Are you sure you want to remove this assignment?') }}")) return;
const url = this.urls.removeAssignment.replace(':id', id);
try {
const response = await fetch(url, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
const result = await response.json();
if (result.success) {
this.fetchMachineAds();
window.showToast?.(result.message, 'success');
}
} catch (e) {
console.error('Failed to remove assignment', e);
}
},
confirmDelete(id) {
this.deleteFormAction = this.urls.delete.replace(':id', id);
this.isDeleteConfirmOpen = true;
}
}));
});
</script>
@endsection