582 lines
14 KiB
Vue
582 lines
14 KiB
Vue
<template>
|
|
<div class="roulette-demo-page container-fluid">
|
|
<header class="roulette-page-header">
|
|
<div class="roulette-page-header-inner">
|
|
<Logo :href="'/admin/'" />
|
|
<h1 class="roulette-demo-title">Sesi Cabutan Bertuah — Kehadiran Fizikal</h1>
|
|
<button type="button" class="btn btn-default btn-sm roulette-bg-music-btn"
|
|
:aria-pressed="bgMusicPlaying ? 'true' : 'false'"
|
|
:aria-label="bgMusicPlaying ? 'Pause Background Music' : 'Play Background Music'"
|
|
@click="toggleBgMusic">
|
|
{{ bgMusicPlaying ? 'Pause Background Music' : 'Play Background Music' }}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div v-if="loadError" class="alert alert-danger">
|
|
{{ loadError }}
|
|
</div>
|
|
<div v-else-if="loading" class="text-muted roulette-demo-loading">
|
|
Memuat senarai pengundi Fizikal…
|
|
</div>
|
|
<div v-else-if="!apiVoters.length" class="alert alert-info">
|
|
Tiada pengundi Fizikal direkod buat masa ini. Semak kehadiran atau tambah rekod.
|
|
</div>
|
|
|
|
<div v-if="!loading && !loadError && apiVoters.length" class="roulette-sync-bar">
|
|
<span v-if="lastSyncedAt" class="roulette-sync-time">
|
|
Senarai menunggu dikemas kini pada <strong>{{ lastSyncedFormatted }}</strong>
|
|
</span>
|
|
<span v-if="refreshing" class="roulette-sync-refreshing text-muted">Mengemas kini…</span>
|
|
<span v-if="pollError" class="text-warning roulette-sync-poll-error">{{ pollError }}</span>
|
|
<button type="button" class="btn btn-default btn-sm roulette-sync-btn" :disabled="refreshing"
|
|
@click="fetchFizikalVoters(false)">
|
|
Segerakan sekarang
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="!loading && !loadError && apiVoters.length" class="row roulette-demo-layout">
|
|
<aside class="col-md-3 col-sm-12 roulette-pending-sidebar text-left">
|
|
<div class="panel panel-default roulette-pending-panel">
|
|
<div class="panel-heading clearfix">
|
|
<span>Menunggu sertai cabutan</span>
|
|
<span class="badge pull-right">{{ pendingVoters.length }}</span>
|
|
</div>
|
|
<ul class="list-group roulette-pending-list">
|
|
<li v-if="!pendingVoters.length" class="list-group-item text-muted roulette-pending-empty">
|
|
Tiada dalam senarai menunggu — semua peserta telah diimport atau senarai kosong.
|
|
</li>
|
|
<template v-else>
|
|
<li v-for="v in pendingVoters" :key="'p-' + v.id"
|
|
class="list-group-item roulette-pending-row">
|
|
<span class="roulette-pending-name">{{ labelForVoter(v) }}</span>
|
|
<button type="button" class="btn btn-primary btn-xs roulette-pending-import"
|
|
@click="importVoter(v.id)">
|
|
Import
|
|
</button>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
<div v-if="pendingVoters.length > 1" class="panel-footer text-right">
|
|
<button type="button" class="btn btn-default btn-sm" @click="importAllPending">
|
|
Import semua
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="col-md-9 col-sm-12 roulette-main-column">
|
|
<div v-if="cabutanCount < 2" class="alert alert-info roulette-cabutan-hint">
|
|
Import sekurang-kurangnya <strong>dua</strong> peserta daripada senarai menunggu untuk memulakan
|
|
cabutan. Senarai <strong>Peserta</strong> pada roda hanya berubah apabila anda menambah peserta di
|
|
sini; auto sync mengemas kini senarai menunggu sahaja.
|
|
</div>
|
|
|
|
<roulette-elimination-wheel v-if="cabutanCount >= 2" :items="cabutanItems" :full-width="true"
|
|
persist-key="roulette_fizikal_demo" @winner="onWinner" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import RouletteEliminationWheel from './RouletteEliminationWheel.vue';
|
|
import Logo from '../common/Logo.vue';
|
|
|
|
function buildLabelsFromVoters(voters) {
|
|
if (!voters || !voters.length) {
|
|
return [];
|
|
}
|
|
var nameCount = {};
|
|
for (var i = 0; i < voters.length; i++) {
|
|
var raw = voters[i].name;
|
|
var n = (raw != null ? String(raw) : '').trim();
|
|
nameCount[n] = (nameCount[n] || 0) + 1;
|
|
}
|
|
return voters.map(function (v) {
|
|
var n = (v.name != null ? String(v.name) : '').trim();
|
|
if (!n) {
|
|
n = '#' + v.id;
|
|
}
|
|
if (nameCount[n] > 1) {
|
|
var tag = v.no_anggota != null ? String(v.no_anggota).trim() : '';
|
|
return tag ? n + ' (' + tag + ')' : n + ' (#' + v.id + ')';
|
|
}
|
|
return n;
|
|
});
|
|
}
|
|
|
|
/** Persist which voter ids were added to the cabutan (separate from wheel game state). */
|
|
var IMPORTED_IDS_STORAGE_KEY = 'roulette_fizikal_imported_ids';
|
|
|
|
/** Looping background track (`public/audio/…`). */
|
|
var BG_MUSIC_SRC = '/audio/lucky-draw-bg.mp3';
|
|
|
|
/** Stable string so we detect new/removed/changed voters from API. */
|
|
function votersSnapshotSignature(voters) {
|
|
if (!voters || !voters.length) {
|
|
return 'empty';
|
|
}
|
|
var rows = voters.map(function (v) {
|
|
return (
|
|
String(v.id) +
|
|
'\t' +
|
|
String(v.name != null ? v.name : '') +
|
|
'\t' +
|
|
String(v.no_anggota != null ? v.no_anggota : '')
|
|
);
|
|
});
|
|
rows.sort();
|
|
return rows.join('|');
|
|
}
|
|
|
|
export default {
|
|
name: 'RouletteEliminationWheelDemo',
|
|
|
|
components: {
|
|
RouletteEliminationWheel,
|
|
Logo,
|
|
},
|
|
|
|
data: function () {
|
|
return {
|
|
loading: true,
|
|
loadError: '',
|
|
/** Full list from GET …/fizikal-voters (updated by auto sync / manual refresh). */
|
|
apiVoters: [],
|
|
/** Voter ids explicitly imported into the cabutan / wheel; order = cabutan order. */
|
|
importedParticipantIds: [],
|
|
/** Last applied API snapshot — skip redundant Vue updates when unchanged. */
|
|
lastSnapshotSig: '',
|
|
refreshing: false,
|
|
lastSyncedAt: null,
|
|
pollError: '',
|
|
hasLoadedOnce: false,
|
|
pollTimerId: null,
|
|
pollStarted: false,
|
|
/** Interval in ms; set 0 to disable auto-refresh. */
|
|
pollIntervalMs: 10000,
|
|
/** True while background music is playing (user must click Main first — browser policy). */
|
|
bgMusicPlaying: false,
|
|
};
|
|
},
|
|
|
|
computed: {
|
|
lastSyncedFormatted: function () {
|
|
if (!this.lastSyncedAt) {
|
|
return '—';
|
|
}
|
|
try {
|
|
return this.lastSyncedAt.toLocaleString();
|
|
} catch (e) {
|
|
return String(this.lastSyncedAt);
|
|
}
|
|
},
|
|
/** Label string per voter id (duplicate-name disambiguation uses full API list). */
|
|
voterLabelsById: function () {
|
|
var list = this.apiVoters;
|
|
var labels = buildLabelsFromVoters(list);
|
|
var map = {};
|
|
for (var i = 0; i < list.length; i++) {
|
|
var vid = list[i].id;
|
|
var nid = typeof vid === 'number' && vid === vid ? vid : parseInt(vid, 10);
|
|
if (nid === nid) {
|
|
map[nid] = labels[i];
|
|
}
|
|
}
|
|
return map;
|
|
},
|
|
pendingVoters: function () {
|
|
var imported = this.importedParticipantIds;
|
|
return this.apiVoters.filter(function (v) {
|
|
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
|
|
return imported.indexOf(vid) < 0;
|
|
});
|
|
},
|
|
/** Wheel entries: stable voter id + label (order = import order). */
|
|
cabutanItems: function () {
|
|
var map = this.voterLabelsById;
|
|
var out = [];
|
|
for (var i = 0; i < this.importedParticipantIds.length; i++) {
|
|
var id = this.importedParticipantIds[i];
|
|
if (map[id] !== undefined) {
|
|
out.push({ id: id, label: map[id] });
|
|
}
|
|
}
|
|
return out;
|
|
},
|
|
cabutanCount: function () {
|
|
return this.cabutanItems.length;
|
|
},
|
|
},
|
|
|
|
created: function () {
|
|
this.loadImportedIdsFromStorage();
|
|
this.fetchFizikalVoters(false);
|
|
},
|
|
|
|
beforeDestroy: function () {
|
|
this.stopPolling();
|
|
this.disposeBgMusic();
|
|
},
|
|
|
|
methods: {
|
|
disposeBgMusic: function () {
|
|
this.bgMusicPlaying = false;
|
|
if (!this._bgMusicAudio) {
|
|
return;
|
|
}
|
|
try {
|
|
this._bgMusicAudio.pause();
|
|
this._bgMusicAudio.src = '';
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
this._bgMusicAudio = null;
|
|
},
|
|
|
|
ensureBgMusicAudio: function () {
|
|
if (typeof Audio === 'undefined') {
|
|
return null;
|
|
}
|
|
if (!this._bgMusicAudio) {
|
|
this._bgMusicAudio = new Audio(BG_MUSIC_SRC);
|
|
this._bgMusicAudio.loop = true;
|
|
this._bgMusicAudio.volume = 0.32;
|
|
this._bgMusicAudio.preload = 'auto';
|
|
}
|
|
return this._bgMusicAudio;
|
|
},
|
|
|
|
toggleBgMusic: function () {
|
|
var vm = this;
|
|
var a = this.ensureBgMusicAudio();
|
|
if (!a) {
|
|
return;
|
|
}
|
|
if (this.bgMusicPlaying) {
|
|
a.pause();
|
|
this.bgMusicPlaying = false;
|
|
return;
|
|
}
|
|
var p = a.play();
|
|
if (p && typeof p.then === 'function') {
|
|
p.then(function () {
|
|
vm.bgMusicPlaying = true;
|
|
}).catch(function () {
|
|
vm.bgMusicPlaying = false;
|
|
});
|
|
} else {
|
|
this.bgMusicPlaying = true;
|
|
}
|
|
},
|
|
|
|
loadImportedIdsFromStorage: function () {
|
|
if (typeof localStorage === 'undefined') {
|
|
return;
|
|
}
|
|
try {
|
|
var raw = localStorage.getItem(IMPORTED_IDS_STORAGE_KEY);
|
|
if (!raw) {
|
|
return;
|
|
}
|
|
var parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) {
|
|
return;
|
|
}
|
|
this.importedParticipantIds = parsed.filter(function (x) {
|
|
return typeof x === 'number' && x === x;
|
|
});
|
|
} catch (e) {
|
|
this.importedParticipantIds = [];
|
|
}
|
|
},
|
|
|
|
saveImportedIdsToStorage: function () {
|
|
if (typeof localStorage === 'undefined') {
|
|
return;
|
|
}
|
|
try {
|
|
localStorage.setItem(
|
|
IMPORTED_IDS_STORAGE_KEY,
|
|
JSON.stringify(this.importedParticipantIds)
|
|
);
|
|
} catch (e) {
|
|
// quota / private mode
|
|
}
|
|
},
|
|
|
|
/** Drop imported ids that no longer exist in the latest API list. */
|
|
pruneImportedIdsToApi: function (list) {
|
|
var ok = {};
|
|
for (var i = 0; i < list.length; i++) {
|
|
var vid = list[i].id;
|
|
var nid = typeof vid === 'number' && vid === vid ? vid : parseInt(vid, 10);
|
|
if (nid === nid) {
|
|
ok[nid] = true;
|
|
}
|
|
}
|
|
var next = this.importedParticipantIds.filter(function (id) {
|
|
return ok[id];
|
|
});
|
|
if (next.length !== this.importedParticipantIds.length) {
|
|
this.importedParticipantIds = next;
|
|
this.saveImportedIdsToStorage();
|
|
}
|
|
},
|
|
|
|
labelForVoter: function (v) {
|
|
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
|
|
var m = this.voterLabelsById;
|
|
return vid === vid && m[vid] != null ? m[vid] : '#' + v.id;
|
|
},
|
|
|
|
importVoter: function (id) {
|
|
var nid = typeof id === 'number' && id === id ? id : parseInt(id, 10);
|
|
if (nid !== nid) {
|
|
return;
|
|
}
|
|
if (this.importedParticipantIds.indexOf(nid) >= 0) {
|
|
return;
|
|
}
|
|
this.importedParticipantIds.push(nid);
|
|
this.saveImportedIdsToStorage();
|
|
},
|
|
|
|
importAllPending: function () {
|
|
var vm = this;
|
|
this.pendingVoters.forEach(function (v) {
|
|
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
|
|
if (vid === vid && vm.importedParticipantIds.indexOf(vid) < 0) {
|
|
vm.importedParticipantIds.push(vid);
|
|
}
|
|
});
|
|
this.saveImportedIdsToStorage();
|
|
},
|
|
|
|
stopPolling: function () {
|
|
if (this.pollTimerId !== null) {
|
|
clearInterval(this.pollTimerId);
|
|
this.pollTimerId = null;
|
|
}
|
|
this.pollStarted = false;
|
|
},
|
|
|
|
startPolling: function () {
|
|
if (this.pollIntervalMs <= 0) {
|
|
return;
|
|
}
|
|
if (this.pollStarted) {
|
|
return;
|
|
}
|
|
this.pollStarted = true;
|
|
var vm = this;
|
|
this.pollTimerId = setInterval(function () {
|
|
vm.fetchFizikalVoters(true);
|
|
}, this.pollIntervalMs);
|
|
},
|
|
|
|
/**
|
|
* @param {boolean} silent - true = background poll (no full-page loading state).
|
|
*/
|
|
fetchFizikalVoters: function (silent) {
|
|
var vm = this;
|
|
if (!silent) {
|
|
vm.loadError = '';
|
|
vm.pollError = '';
|
|
if (!vm.hasLoadedOnce) {
|
|
vm.loading = true;
|
|
} else {
|
|
vm.refreshing = true;
|
|
}
|
|
} else {
|
|
vm.pollError = '';
|
|
vm.refreshing = true;
|
|
}
|
|
|
|
return axios
|
|
.get(config.API + 'public/roulette/fizikal-voters')
|
|
.then(function (response) {
|
|
var list =
|
|
response.data && response.data.voters ? response.data.voters : [];
|
|
var sig = votersSnapshotSignature(list);
|
|
if (silent && sig === vm.lastSnapshotSig) {
|
|
vm.lastSyncedAt = new Date();
|
|
return;
|
|
}
|
|
vm.lastSnapshotSig = sig;
|
|
vm.apiVoters = list;
|
|
vm.pruneImportedIdsToApi(list);
|
|
vm.lastSyncedAt = new Date();
|
|
vm.hasLoadedOnce = true;
|
|
})
|
|
.catch(function (error) {
|
|
var msg =
|
|
error &&
|
|
error.response &&
|
|
error.response.data &&
|
|
error.response.data.message
|
|
? error.response.data.message
|
|
: 'Gagal memuat data.';
|
|
if (!silent && !vm.hasLoadedOnce) {
|
|
vm.loadError = msg;
|
|
vm.apiVoters = [];
|
|
} else {
|
|
vm.pollError =
|
|
'Tidak dapat mengemas kini: ' + (msg.length > 80 ? msg.slice(0, 80) + '…' : msg);
|
|
}
|
|
})
|
|
.finally(function () {
|
|
vm.loading = false;
|
|
vm.refreshing = false;
|
|
vm.startPolling();
|
|
});
|
|
},
|
|
|
|
onWinner: function (payload) {
|
|
if (typeof console !== 'undefined' && console.log) {
|
|
console.log('[roulette demo] winner:', payload);
|
|
}
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.roulette-demo-page {
|
|
padding-top: 2rem;
|
|
padding-bottom: 3rem;
|
|
width: 100%;
|
|
max-width: none;
|
|
}
|
|
|
|
.roulette-page-header {
|
|
display: flex;
|
|
justify-content: center;
|
|
margin-bottom: 1rem;
|
|
padding-bottom: 0.75rem;
|
|
border-bottom: 1px solid #e7e7e7;
|
|
}
|
|
|
|
.roulette-page-header-inner {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
gap: 14px 18px;
|
|
text-align: left;
|
|
}
|
|
|
|
.roulette-page-header-logo {
|
|
display: block;
|
|
height: 52px;
|
|
width: auto;
|
|
max-width: 140px;
|
|
object-fit: contain;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.roulette-demo-title {
|
|
font-size: 1.65rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
line-height: 1.3;
|
|
max-width: min(100%, 36rem);
|
|
}
|
|
|
|
.roulette-bg-music-btn {
|
|
flex-shrink: 0;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.roulette-demo-path {
|
|
margin-bottom: 1.25rem;
|
|
font-size: 0.9rem;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.roulette-poll-help {
|
|
margin-bottom: 0.75rem;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.roulette-demo-loading {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.roulette-sync-bar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 10px 16px;
|
|
margin-bottom: 14px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.roulette-sync-refreshing {
|
|
font-style: italic;
|
|
}
|
|
|
|
.roulette-sync-poll-error {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.roulette-sync-btn {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.roulette-demo-layout {
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.roulette-pending-sidebar {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
@media (min-width: 992px) {
|
|
.roulette-pending-sidebar {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.roulette-pending-panel {
|
|
position: sticky;
|
|
top: 12px;
|
|
}
|
|
}
|
|
|
|
.roulette-main-column {
|
|
min-width: 0;
|
|
}
|
|
|
|
.roulette-pending-panel {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.roulette-pending-list {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.roulette-pending-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.roulette-pending-name {
|
|
flex: 1;
|
|
min-width: 0;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.roulette-pending-import {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.roulette-cabutan-hint {
|
|
margin-bottom: 16px;
|
|
line-height: 1.45;
|
|
}
|
|
</style>
|