Files
star-cloud/resources/views/admin/ads/index.blade.php
sky121113 ad256d3d3b
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
[FEAT] 廣告排程功能與 UI 優化
1. 新增廣告排程功能,支援設定發布時間與下架時間。
2. 整合 Flatpickr 時間選擇器,提供與機台日誌一致的極簡奢華風 UI。
3. 優化廣告列表中的數字字體,套用 font-mono 與 tabular-nums,與客戶管理模組風格同步。
4. 修正 Alpine.js 資料同步邏輯,確保編輯模式下排程時間能正確回填。
2026-04-13 11:44:26 +08:00

867 lines
50 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">{{ __('Schedule') }}</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-mono font-bold text-slate-700 dark:text-slate-200">
{{ $ad->duration }}s
</td>
<td class="px-6 py-4 text-center whitespace-nowrap">
<div class="flex flex-col items-center gap-0.5">
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('From') }}: {{ $ad->start_at?->format('Y-m-d H:i') ?? __('Immediate') }}</span>
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('To') }}: {{ $ad->end_at?->format('Y-m-d H:i') ?? __('Indefinite') }}</span>
</div>
</td>
<td class="px-6 py-4 text-center">
@php
$now = now();
$isStarted = !$ad->start_at || $ad->start_at <= $now;
$isExpired = $ad->end_at && $ad->end_at < $now;
$isPlaying = $ad->is_active && $isStarted && !$isExpired;
@endphp
<div class="flex flex-col items-center gap-1">
@if(!$ad->is_active)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-slate-500/10 text-slate-500 border border-slate-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
@elseif($isExpired)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Expired') }}</span>
@elseif(!$isStarted)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-amber-500/10 text-amber-500 border border-amber-500/20 tracking-widest uppercase">{{ __('Waiting') }}</span>
@else
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Ongoing') }}</span>
@endif
</div>
</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-[11px] font-mono font-bold text-slate-400 uppercase tracking-tight 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-mono font-bold tracking-tight 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: '',
mediaPreview: null,
adForm: {
id: null,
name: '',
type: 'image',
duration: 15,
is_active: true,
url: '',
start_at: '',
end_at: ''
},
// 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) return;
// Validation
const isVideo = file.type.startsWith('video/');
const isImage = file.type.startsWith('image/');
const maxSize = isVideo ? 50 * 1024 * 1024 : 10 * 1024 * 1024; // 50MB for video, 10MB for image
if (file.size > maxSize) {
window.showToast?.(`{{ __("File is too large") }} (${isVideo ? '50MB' : '10MB'} MAX)`, 'error');
e.target.value = '';
return;
}
this.fileName = file.name;
// Set form type based on file
if (isVideo) this.adForm.type = 'video';
else if (isImage) this.adForm.type = 'image';
// Local Preview
const reader = new FileReader();
reader.onload = (event) => {
this.mediaPreview = event.target.result;
};
reader.readAsDataURL(file);
// Update Select UI
this.$nextTick(() => {
window.HSSelect.getInstance('#ad_type_select')?.setValue(this.adForm.type);
});
},
removeMedia() {
this.fileName = '';
this.mediaPreview = null;
if (this.$refs.fileInput) this.$refs.fileInput.value = '';
// If editing, we still keep the original url in adForm.url but the UI shows "empty"
},
async submitAdForm() {
const form = this.$refs.adFormEl;
const formData = new FormData(form);
// Basic validation
if (!this.adForm.name) {
window.showToast?.('{{ __("Please enter a name") }}', 'error');
return;
}
if (this.adFormMode === 'add' && !this.fileName) {
window.showToast?.('{{ __("Please select a file") }}', 'error');
return;
}
try {
const url = this.adFormMode === 'add' ? this.urls.store : this.urls.update.replace(':id', this.adForm.id);
if (this.adFormMode === 'edit') formData.append('_method', 'PUT');
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.success) {
window.showToast?.(result.message, 'success');
this.isAdModalOpen = false;
location.reload(); // Reload to refresh the list
} else {
window.showToast?.(result.message || 'Error', 'error');
}
} catch (e) {
console.error('Failed to submit ad', e);
window.showToast?.('System Error', 'error');
}
},
formatDateForInput(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}`;
},
async submitAssignment() {
try {
const response = await fetch(this.urls.assign, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify(this.assignForm)
});
if (!response.ok) {
const errorText = await response.text();
console.error('Server error response:', errorText);
throw new Error(`Server responded with ${response.status}`);
}
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 || '');
}
// 確保 Flatpickr 實例同步顯示目前的時間值
if (this.$refs.startAtPicker?._flatpickr) {
this.$refs.startAtPicker._flatpickr.setDate(this.adForm.start_at);
}
if (this.$refs.endAtPicker?._flatpickr) {
this.$refs.endAtPicker._flatpickr.setDate(this.adForm.end_at);
}
});
}
});
},
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, url: '', start_at: '', end_at: '' };
this.fileName = '';
this.mediaPreview = null;
this.isAdModalOpen = true;
},
openEditModal(ad) {
this.adFormMode = 'edit';
this.adForm = {
...ad,
start_at: this.formatDateForInput(ad.start_at),
end_at: this.formatDateForInput(ad.end_at)
};
this.fileName = '';
this.mediaPreview = ad.url; // Use existing URL as preview
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);
// Set the initial value after appending but before autoInit
if (this.assignForm.advertisement_id) {
selectEl.value = this.assignForm.advertisement_id;
}
selectEl.addEventListener('change', (e) => {
this.assignForm.advertisement_id = e.target.value;
});
this.$nextTick(() => {
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit(['select']);
// If we have a value, ensure the Preline instance reflects it
if (this.assignForm.advertisement_id) {
setTimeout(() => {
window.HSSelect.getInstance(selectEl)?.setValue(this.assignForm.advertisement_id);
}, 50);
}
}
});
},
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