Dev/v1.0 (#1)
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local> Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -1,20 +1,73 @@
|
||||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import StationIntroPage from './pages/StationIntroPage/StationIntroPage.jsx';
|
||||
import ShowQueuesForTellerPage from './pages/ShowQueuesForTellerPage/ShowQueuesForTellerPage';
|
||||
import Header from './components/Header/Header.jsx';
|
||||
import CurrentTicketPage from './pages/CurrentTicketPage/CurrentTicketPage.jsx';
|
||||
import BranchDisplayPage from './pages/BranchDisplayPage/BranchDisplayPage.jsx';
|
||||
import LoginPage from './pages/LoginPage/LoginPage.jsx';
|
||||
import AuthGuard from './components/AuthGuard/AuthGuard.jsx';
|
||||
import { UserContext } from './context/UserContext.jsx';
|
||||
import { clearSession, getToken, getUserData } from './utils/session.js';
|
||||
import { SERVER_URL } from './constants.js';
|
||||
import { fetchData } from './fetching/Fetch.js';
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState(() => getUserData());
|
||||
const location = useLocation();
|
||||
const isDisplayRoute = location.pathname.startsWith('/display');
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle('display-mode', isDisplayRoute);
|
||||
return () => document.body.classList.remove('display-mode');
|
||||
}, [isDisplayRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData(`${SERVER_URL}/api/v1/auth`, 'GET').then(({ success }) => {
|
||||
if (success) {
|
||||
setUser(getUserData());
|
||||
} else {
|
||||
clearSession();
|
||||
setUser(null);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<UserContext.Provider value={{ user, setUser }}>
|
||||
{isDisplayRoute ? null : <Header />}
|
||||
<Routes>
|
||||
<Route exact path='/' element={<StationIntroPage />} />
|
||||
<Route exact path="/teller-queue/:stationId" element={<ShowQueuesForTellerPage />} />
|
||||
<Route path="/display/:stationId" element={<CurrentTicketPage/>} />
|
||||
<Route exact path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
exact
|
||||
path="/"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<StationIntroPage />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/teller-queue/:stationId"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<ShowQueuesForTellerPage />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
{/* Public display boards for waiting area screens */}
|
||||
<Route
|
||||
path="/display/branch/:tenantCode/:branchId"
|
||||
element={<BranchDisplayPage />}
|
||||
/>
|
||||
<Route path="/display/:stationId" element={<CurrentTicketPage />} />
|
||||
</Routes>
|
||||
</>
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { getUserData } from '../../utils/session.js';
|
||||
import { ROLES } from '../../constants.js';
|
||||
|
||||
const TELLER_ROLES = [
|
||||
ROLES.ROLE_USER,
|
||||
ROLES.ROLE_SUPER_ADMIN,
|
||||
ROLES.ROLE_BRANCH_ADMIN,
|
||||
];
|
||||
|
||||
export default function AuthGuard({ children }) {
|
||||
const navigate = useNavigate();
|
||||
const { user: contextUser } = useContext(UserContext);
|
||||
const [allowed, setAllowed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const user = contextUser ?? getUserData();
|
||||
const roles = Array.isArray(user?.roles) ? user.roles : [];
|
||||
const hasAccess = roles.some((role) => TELLER_ROLES.includes(role));
|
||||
|
||||
if (!user || !hasAccess) {
|
||||
setAllowed(false);
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setAllowed(true);
|
||||
}, [contextUser, navigate]);
|
||||
|
||||
if (!allowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -15,4 +15,25 @@ header.main-header {
|
||||
margin-top: 7px;
|
||||
margin-right: auto;
|
||||
margin-left: 2%;
|
||||
}
|
||||
|
||||
.header-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-right: 2%;
|
||||
}
|
||||
|
||||
.header-email {
|
||||
font-size: 14px;
|
||||
color: #334257;
|
||||
}
|
||||
|
||||
.header-logout-btn {
|
||||
border: 1px solid #334257;
|
||||
background: white;
|
||||
color: #334257;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,13 +1,34 @@
|
||||
import './Header.css';
|
||||
import { useContext } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { clearSession } from '../../utils/session.js';
|
||||
import { clearActiveStation } from '../../utils/activeStation.js';
|
||||
|
||||
export default function Header() {
|
||||
const navigate = useNavigate();
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
|
||||
function handleLogout() {
|
||||
clearSession();
|
||||
clearActiveStation();
|
||||
setUser(null);
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="main-header">
|
||||
<h2 className="header-logo" onClick={() => navigate(`/`)}>BBQMS</h2>
|
||||
<h2 className="header-logo" onClick={() => navigate(user ? '/' : '/login')}>
|
||||
MyKOPKB QMS-Teller
|
||||
</h2>
|
||||
{user ? (
|
||||
<div className="header-user">
|
||||
<span className="header-email">{user.email}</span>
|
||||
<button type="button" className="header-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,11 @@
|
||||
export const SERVER_URL = 'http://localhost:8080';
|
||||
|
||||
export const GOLD_PRICE_URL =
|
||||
import.meta.env.VITE_GOLD_PRICE_URL ??
|
||||
'https://apiujrah.erahn.com.my/api/harga_emas';
|
||||
|
||||
export const ROLES = {
|
||||
ROLE_USER: 'ROLE_USER',
|
||||
ROLE_SUPER_ADMIN: 'ROLE_SUPER_ADMIN',
|
||||
ROLE_BRANCH_ADMIN: 'ROLE_BRANCH_ADMIN',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const UserContext = createContext({
|
||||
user: null,
|
||||
setUser: () => {},
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
/*
|
||||
Koristiti ovu funkciju za fetchanje u buducnosti kad god je to moguce.
|
||||
*/
|
||||
import { getToken, setToken } from '../utils/session.js';
|
||||
|
||||
export async function fetchData(url, method, body) {
|
||||
const headers = new Headers();
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.append('Authorization', `Bearer ${ token }`);
|
||||
headers.append('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
headers.append('Content-Type', 'application/json');
|
||||
@@ -14,7 +16,7 @@ export async function fetchData(url, method, body) {
|
||||
const res = await fetch(url, {
|
||||
method: method || 'GET',
|
||||
headers: headers,
|
||||
body: body ? JSON.stringify(body) : null
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
@@ -24,12 +26,11 @@ export async function fetchData(url, method, body) {
|
||||
const data = res.ok && res.body ? await res.json() : null;
|
||||
|
||||
if (res.ok) {
|
||||
//na svaki ispravan rezultat treba da dobijemo novi token da refreshamo stari
|
||||
const newToken = res.headers.get('Auth-Token');
|
||||
if (newToken) {
|
||||
localStorage.setItem('token', newToken);
|
||||
setToken(newToken);
|
||||
}
|
||||
}
|
||||
|
||||
return { data: data, success: res.ok };
|
||||
}
|
||||
return { data, success: res.ok };
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ body {
|
||||
background-color: ghostwhite;
|
||||
}
|
||||
|
||||
body.display-mode {
|
||||
padding: 0;
|
||||
background-color: #0f172a;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* ovdje definisite konstante boje i sl. koje cete koristiti na vise mjesta */
|
||||
--blue: #334257;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
|
||||
function resolveMediaUrl(mediaUrl) {
|
||||
if (!mediaUrl) return null;
|
||||
return mediaUrl.startsWith('http') ? mediaUrl : `${SERVER_URL}${mediaUrl}`;
|
||||
}
|
||||
|
||||
export default function AdCarousel({ tenantCode }) {
|
||||
const [ads, setAds] = useState([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAds = useCallback(async () => {
|
||||
if (!tenantCode) return;
|
||||
try {
|
||||
const response = await fetchData(
|
||||
`${SERVER_URL}/api/v1/ads/${encodeURIComponent(tenantCode)}/active`,
|
||||
'GET'
|
||||
);
|
||||
if (!response.success) {
|
||||
setError('Could not load advertisements.');
|
||||
return;
|
||||
}
|
||||
const next = Array.isArray(response.data) ? response.data : [];
|
||||
setAds(next);
|
||||
setError(null);
|
||||
setIndex((current) => (next.length === 0 ? 0 : current % next.length));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Could not load advertisements.');
|
||||
}
|
||||
}, [tenantCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAds();
|
||||
const interval = setInterval(loadAds, 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadAds]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setIndex((current) => {
|
||||
if (ads.length === 0) return 0;
|
||||
return (current + 1) % ads.length;
|
||||
});
|
||||
}, [ads.length]);
|
||||
|
||||
const currentAd = ads[index] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
clearTimer();
|
||||
if (!currentAd) return undefined;
|
||||
|
||||
if (currentAd.mediaType === 'IMAGE') {
|
||||
const durationMs = Math.max(1, Number(currentAd.durationSeconds) || 10) * 1000;
|
||||
timerRef.current = setTimeout(goNext, durationMs);
|
||||
return clearTimer;
|
||||
}
|
||||
|
||||
// Videos advance on ended; fallback if play fails
|
||||
timerRef.current = setTimeout(goNext, 5 * 60 * 1000);
|
||||
return clearTimer;
|
||||
}, [currentAd, goNext, clearTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || currentAd?.mediaType !== 'VIDEO') return undefined;
|
||||
|
||||
const onEnded = () => goNext();
|
||||
video.addEventListener('ended', onEnded);
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
const playPromise = video.play();
|
||||
if (playPromise?.catch) {
|
||||
playPromise.catch(() => {
|
||||
// Autoplay blocked — advance after durationSeconds fallback
|
||||
clearTimer();
|
||||
const durationMs = Math.max(5, Number(currentAd.durationSeconds) || 15) * 1000;
|
||||
timerRef.current = setTimeout(goNext, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, [currentAd, goNext, clearTimer]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="branch-display__ad-panel">
|
||||
<h2 className="branch-display__section-title">Pengiklanan</h2>
|
||||
<p className="branch-display__empty">{error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (ads.length === 0) {
|
||||
return (
|
||||
<section className="branch-display__ad-panel">
|
||||
<h2 className="branch-display__section-title">Pengiklanan</h2>
|
||||
<p className="branch-display__empty">Tiada pengiklanan aktif.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const src = resolveMediaUrl(currentAd.mediaUrl);
|
||||
|
||||
return (
|
||||
<section className="branch-display__ad-panel">
|
||||
<div className="branch-display__ad-header">
|
||||
<h2 className="branch-display__section-title">Pengiklanan</h2>
|
||||
</div>
|
||||
<div className="branch-display__ad-stage">
|
||||
{currentAd.mediaType === 'VIDEO' ? (
|
||||
<video
|
||||
key={currentAd.id}
|
||||
ref={videoRef}
|
||||
className="branch-display__ad-media"
|
||||
src={src}
|
||||
muted
|
||||
playsInline
|
||||
autoPlay
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={currentAd.id}
|
||||
className="branch-display__ad-media"
|
||||
src={src}
|
||||
alt={currentAd.title || currentAd.fileName || 'Advertisement'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{currentAd.title ? (
|
||||
<p className="branch-display__ad-caption">{currentAd.title}</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
.branch-display {
|
||||
--bd-bg: #071a10;
|
||||
--bd-bg-soft: #0c2416;
|
||||
--bd-surface: #12301f;
|
||||
--bd-surface-raised: #183c28;
|
||||
--bd-border: #245537;
|
||||
--bd-border-strong: #2f6b45;
|
||||
--bd-text: #f3faf5;
|
||||
--bd-muted: #9bc4a8;
|
||||
--bd-muted-strong: #c5e0cf;
|
||||
--bd-accent: #f0d060;
|
||||
--bd-accent-bright: #ffe566;
|
||||
--bd-accent-deep: #d4a017;
|
||||
--bd-green: #3ecf7a;
|
||||
--bd-error-bg: #5c1a1a;
|
||||
--bd-error-text: #fecaca;
|
||||
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem 1.5rem 1rem;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(62, 207, 122, 0.12), transparent 55%),
|
||||
radial-gradient(ellipse 60% 40% at 95% 5%, rgba(240, 208, 96, 0.1), transparent 50%),
|
||||
linear-gradient(165deg, var(--bd-bg-soft) 0%, var(--bd-bg) 55%, #05140c 100%);
|
||||
color: var(--bd-text);
|
||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.branch-display__sound-enable {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
border: 1px solid var(--bd-accent-deep);
|
||||
background: linear-gradient(180deg, var(--bd-accent-bright) 0%, var(--bd-accent) 100%);
|
||||
color: #1a2e14;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 12px rgba(240, 208, 96, 0.35);
|
||||
}
|
||||
|
||||
.branch-display__header {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.branch-display__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.branch-display__logo {
|
||||
height: clamp(2.5rem, 4.5vw, 3.5rem);
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.35rem;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.branch-display__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bd-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--bd-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.branch-display__error {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--bd-error-bg);
|
||||
color: var(--bd-error-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.branch-display__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(340px, 1.05fr);
|
||||
gap: 0.85rem;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.branch-display {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: auto;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.branch-display__main {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.branch-display__section--waiting {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.branch-display__section--gold {
|
||||
flex: none;
|
||||
min-height: 16rem;
|
||||
}
|
||||
}
|
||||
|
||||
.branch-display__queue-column {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.branch-display__section {
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.branch-display__section--waiting {
|
||||
flex: 0 1 auto;
|
||||
max-height: 28%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.branch-display__section--gold {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.branch-display__section-title {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bd-muted-strong);
|
||||
}
|
||||
|
||||
.branch-display__stations {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.branch-display__station-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.65rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
|
||||
border: 1px solid var(--bd-border);
|
||||
box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.branch-display__station-name {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bd-muted);
|
||||
}
|
||||
|
||||
.branch-display__ticket-number {
|
||||
margin: 0;
|
||||
font-size: clamp(1.75rem, 3.2vw, 2.75rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--bd-accent-bright);
|
||||
text-shadow: 0 0 24px rgba(255, 229, 102, 0.25);
|
||||
}
|
||||
|
||||
.branch-display__ticket-number--idle {
|
||||
color: #4a7a5c;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.branch-display__station-service {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--bd-muted-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.branch-display__waiting-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__waiting-header .branch-display__section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.branch-display__waiting-count {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bd-accent);
|
||||
}
|
||||
|
||||
.branch-display__empty {
|
||||
margin: 0;
|
||||
color: var(--bd-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.branch-display__waiting-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.branch-display__waiting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--bd-surface);
|
||||
border: 1px solid var(--bd-border);
|
||||
border-left: 3px solid var(--bd-green);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__waiting-number {
|
||||
font-size: clamp(1.1rem, 1.8vw, 1.5rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--bd-accent);
|
||||
}
|
||||
|
||||
.branch-display__waiting-service {
|
||||
font-size: 0.85rem;
|
||||
color: var(--bd-muted-strong);
|
||||
}
|
||||
|
||||
.branch-display__ad-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 0.65rem;
|
||||
border-radius: 0.6rem;
|
||||
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
|
||||
border: 1px solid var(--bd-border);
|
||||
box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.branch-display__ad-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__ad-header .branch-display__section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.branch-display__ad-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 0.4rem;
|
||||
overflow: hidden;
|
||||
background: var(--bd-bg);
|
||||
border: 1px solid var(--bd-border);
|
||||
}
|
||||
|
||||
.branch-display__ad-media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: var(--bd-bg);
|
||||
}
|
||||
|
||||
.branch-display__ad-caption {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bd-muted-strong);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.branch-display__section--gold .branch-display__waiting-header {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.branch-display__gold-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--bd-border);
|
||||
background: var(--bd-surface);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.branch-display__gold-table-wrap::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.branch-display__gold-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.branch-display__gold-table th,
|
||||
.branch-display__gold-table td {
|
||||
padding: 0.45rem 0.65rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--bd-border);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.branch-display__gold-table th {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bd-accent);
|
||||
font-weight: 600;
|
||||
background: var(--bd-bg-soft);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.branch-display__gold-table td:nth-child(3) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: var(--bd-accent-bright);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.branch-display__gold-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.branch-display__gold-table tbody tr:hover td {
|
||||
background: rgba(62, 207, 122, 0.06);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { GOLD_PRICE_URL, SERVER_URL } from '../../constants.js';
|
||||
import {
|
||||
hasNewServingCall,
|
||||
playQueueCallSound,
|
||||
servingSnapshot,
|
||||
unlockQueueCallSound,
|
||||
} from '../../utils/queueCallSound.js';
|
||||
import AdCarousel from './AdCarousel.jsx';
|
||||
import './BranchDisplayPage.css';
|
||||
|
||||
function formatGoldPrice(value) {
|
||||
const amount = Number(value);
|
||||
if (!Number.isFinite(amount)) return value ?? '—';
|
||||
return amount.toLocaleString('en-MY', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export default function BranchDisplayPage() {
|
||||
const { tenantCode, branchId } = useParams();
|
||||
const [branchName, setBranchName] = useState('');
|
||||
const [stations, setStations] = useState([]);
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [goldPrices, setGoldPrices] = useState([]);
|
||||
const [goldUpdatedAt, setGoldUpdatedAt] = useState(null);
|
||||
const [goldError, setGoldError] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [soundReady, setSoundReady] = useState(false);
|
||||
const goldTableWrapRef = useRef(null);
|
||||
const previousServingRef = useRef(null);
|
||||
|
||||
const loadDisplay = useCallback(async () => {
|
||||
if (!tenantCode || !branchId) return;
|
||||
|
||||
try {
|
||||
const [stationsRes, queueRes, branchesRes] = await Promise.all([
|
||||
fetchData(
|
||||
`${SERVER_URL}/api/v1/stations/${encodeURIComponent(tenantCode)}/${branchId}`,
|
||||
'GET'
|
||||
),
|
||||
fetchData(
|
||||
`${SERVER_URL}/api/v1/branches/${encodeURIComponent(tenantCode)}/${branchId}/queue?activeOnly=true&sort=createdAt,asc`,
|
||||
'GET'
|
||||
),
|
||||
fetchData(
|
||||
`${SERVER_URL}/api/v1/branches/${encodeURIComponent(tenantCode)}`,
|
||||
'GET'
|
||||
),
|
||||
]);
|
||||
|
||||
if (!stationsRes.success || !queueRes.success) {
|
||||
setError('Could not load branch display.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setStations(Array.isArray(stationsRes.data) ? stationsRes.data : []);
|
||||
setTickets(Array.isArray(queueRes.data) ? queueRes.data : []);
|
||||
|
||||
if (branchesRes.success && Array.isArray(branchesRes.data)) {
|
||||
const branch = branchesRes.data.find(
|
||||
(item) => Number(item.id) === Number(branchId)
|
||||
);
|
||||
if (branch?.name) {
|
||||
setBranchName(branch.name);
|
||||
}
|
||||
} else if (queueRes.data?.[0]?.branch?.name) {
|
||||
setBranchName(queueRes.data[0].branch.name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Could not load branch display.');
|
||||
}
|
||||
}, [tenantCode, branchId]);
|
||||
|
||||
const loadGoldPrices = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(GOLD_PRICE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gold price request failed (${response.status})`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const rows = Array.isArray(data) ? data : [];
|
||||
setGoldPrices(rows);
|
||||
setGoldUpdatedAt(rows[0]?.tarikhupdate ?? null);
|
||||
setGoldError(null);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setGoldError('Could not load gold prices.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadDisplay();
|
||||
const interval = setInterval(loadDisplay, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadDisplay]);
|
||||
|
||||
useEffect(() => {
|
||||
loadGoldPrices();
|
||||
const interval = setInterval(loadGoldPrices, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadGoldPrices]);
|
||||
|
||||
useEffect(() => {
|
||||
const wrap = goldTableWrapRef.current;
|
||||
if (!wrap || goldPrices.length === 0) return undefined;
|
||||
|
||||
let rafId = 0;
|
||||
let pauseUntil = 0;
|
||||
let direction = 1;
|
||||
const speedPxPerSec = 28;
|
||||
const pauseMs = 1600;
|
||||
let lastTs = 0;
|
||||
|
||||
const step = (now) => {
|
||||
if (!lastTs) lastTs = now;
|
||||
const dt = Math.min(now - lastTs, 50);
|
||||
lastTs = now;
|
||||
|
||||
const maxScroll = wrap.scrollHeight - wrap.clientHeight;
|
||||
if (maxScroll <= 2) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
if (now < pauseUntil) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.scrollTop += (speedPxPerSec * dt * direction) / 1000;
|
||||
|
||||
if (direction > 0 && wrap.scrollTop >= maxScroll - 1) {
|
||||
wrap.scrollTop = maxScroll;
|
||||
direction = -1;
|
||||
pauseUntil = now + pauseMs;
|
||||
} else if (direction < 0 && wrap.scrollTop <= 1) {
|
||||
wrap.scrollTop = 0;
|
||||
direction = 1;
|
||||
pauseUntil = now + pauseMs;
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const startId = requestAnimationFrame(() => {
|
||||
wrap.scrollTop = 0;
|
||||
lastTs = 0;
|
||||
rafId = requestAnimationFrame(step);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(startId);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [goldPrices]);
|
||||
|
||||
const servingByStationId = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const ticket of tickets) {
|
||||
if (ticket.station?.id != null) {
|
||||
map.set(Number(ticket.station.id), ticket);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [tickets]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = servingSnapshot(tickets);
|
||||
const previous = previousServingRef.current;
|
||||
|
||||
if (hasNewServingCall(previous, next)) {
|
||||
playQueueCallSound();
|
||||
}
|
||||
|
||||
previousServingRef.current = next;
|
||||
}, [tickets]);
|
||||
|
||||
const waiting = useMemo(
|
||||
() => tickets.filter((ticket) => ticket.station == null),
|
||||
[tickets]
|
||||
);
|
||||
|
||||
const sortedStations = useMemo(
|
||||
() => [...stations].sort((a, b) => String(a.name).localeCompare(String(b.name))),
|
||||
[stations]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="branch-display"
|
||||
onClick={() => {
|
||||
if (!soundReady) {
|
||||
unlockQueueCallSound();
|
||||
setSoundReady(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!soundReady ? (
|
||||
<button
|
||||
type="button"
|
||||
className="branch-display__sound-enable"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
unlockQueueCallSound();
|
||||
setSoundReady(true);
|
||||
}}
|
||||
>
|
||||
Enable call sound
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<header className="branch-display__header">
|
||||
<div className="branch-display__brand">
|
||||
<img
|
||||
className="branch-display__logo"
|
||||
src="/logo-arrahn.jpeg"
|
||||
alt="ar-rahn"
|
||||
/>
|
||||
<h1 className="branch-display__title">
|
||||
Selamat Datang ke Cawangan {branchName}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="branch-display__eyebrow">Sistem Nombor Giliran</p>
|
||||
</header>
|
||||
|
||||
{error ? <p className="branch-display__error">{error}</p> : null}
|
||||
|
||||
<div className="branch-display__main">
|
||||
<div className="branch-display__queue-column">
|
||||
<section className="branch-display__section">
|
||||
<h2 className="branch-display__section-title">Sedang Diproses</h2>
|
||||
{sortedStations.length === 0 ? (
|
||||
<p className="branch-display__empty">Tiada Stesen di Branch ini.</p>
|
||||
) : (
|
||||
<div className="branch-display__stations">
|
||||
{sortedStations.map((station) => {
|
||||
const ticket = servingByStationId.get(Number(station.id));
|
||||
return (
|
||||
<article
|
||||
key={station.id}
|
||||
className="branch-display__station-card"
|
||||
>
|
||||
<p className="branch-display__station-name">
|
||||
{station.name}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
ticket
|
||||
? 'branch-display__ticket-number'
|
||||
: 'branch-display__ticket-number branch-display__ticket-number--idle'
|
||||
}
|
||||
>
|
||||
{ticket ? ticket.number : '—'}
|
||||
</p>
|
||||
<p className="branch-display__station-service">
|
||||
{ticket?.service?.name ?? 'Waiting for next'}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="branch-display__section branch-display__section--waiting">
|
||||
<div className="branch-display__waiting-header">
|
||||
<h2 className="branch-display__section-title">Giliran</h2>
|
||||
<p className="branch-display__waiting-count">
|
||||
{waiting.length} dalam giliran
|
||||
</p>
|
||||
</div>
|
||||
{waiting.length === 0 ? (
|
||||
<p className="branch-display__empty">Tiada nombor dalam giliran.</p>
|
||||
) : (
|
||||
<ul className="branch-display__waiting-list">
|
||||
{waiting.map((ticket) => (
|
||||
<li key={ticket.id} className="branch-display__waiting-item">
|
||||
<span className="branch-display__waiting-number">
|
||||
{ticket.number}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="branch-display__section branch-display__section--gold">
|
||||
<div className="branch-display__waiting-header">
|
||||
<h2 className="branch-display__section-title">Harga Emas</h2>
|
||||
{goldUpdatedAt ? (
|
||||
<p className="branch-display__waiting-count">
|
||||
Dikemas kini pada {goldUpdatedAt}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{goldError ? (
|
||||
<p className="branch-display__error">{goldError}</p>
|
||||
) : null}
|
||||
{!goldError && goldPrices.length === 0 ? (
|
||||
<p className="branch-display__empty">Loading harga emas…</p>
|
||||
) : null}
|
||||
{goldPrices.length > 0 ? (
|
||||
<div
|
||||
ref={goldTableWrapRef}
|
||||
className="branch-display__gold-table-wrap"
|
||||
>
|
||||
<table className="branch-display__gold-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Karat</th>
|
||||
<th>Mutu</th>
|
||||
<th>RM/g</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{goldPrices.map((row) => (
|
||||
<tr
|
||||
key={
|
||||
row.recno ??
|
||||
`${row.karat}-${row.keterangan}`
|
||||
}
|
||||
>
|
||||
<td>{row.karat}</td>
|
||||
<td>{row.keterangan}</td>
|
||||
<td>{formatGoldPrice(row.harga)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<AdCarousel tenantCode={tenantCode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#teller-login-form {
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
margin: 80px auto 40px;
|
||||
background-color: #334257;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#teller-login-form h1 {
|
||||
margin: 0;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#teller-login-form form {
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#teller-login-form .form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
#teller-login-form label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
#teller-login-form input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#teller-login-form button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background-color: #548ca8;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#teller-login-form button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#teller-login-form .error {
|
||||
margin: 0 0 12px;
|
||||
color: #b00020;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ROLES, SERVER_URL } from '../../constants.js';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { clearSession, getToken, setSession } from '../../utils/session.js';
|
||||
import './LoginPage.css';
|
||||
|
||||
const TELLER_ROLES = [
|
||||
ROLES.ROLE_USER,
|
||||
ROLES.ROLE_SUPER_ADMIN,
|
||||
ROLES.ROLE_BRANCH_ADMIN,
|
||||
];
|
||||
|
||||
function canAccessTellerApp(userData) {
|
||||
const roles = Array.isArray(userData?.roles) ? userData.roles : [];
|
||||
return roles.some((role) => TELLER_ROLES.includes(role));
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && getToken()) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
setError('Email is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
setError('Password is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const { data, success } = await fetchData(`${SERVER_URL}/api/v1/auth/login`, 'POST', {
|
||||
email: email.trim(),
|
||||
password: password.trim(),
|
||||
});
|
||||
|
||||
if (!success || !data) {
|
||||
setError('Your credentials are incorrect.');
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = data.userData ?? data;
|
||||
const token = data.token;
|
||||
|
||||
if (!canAccessTellerApp(userData)) {
|
||||
clearSession();
|
||||
setUser(null);
|
||||
setError('This account cannot access the teller app.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setError('Two-factor login is not supported in the teller app yet. Disable 2FA for this account or use an account without 2FA.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userData?.tenantCode) {
|
||||
setError('Login succeeded but tenant information is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSession({ userData, token });
|
||||
setUser(userData);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="teller-login-form">
|
||||
<h1>Teller Login</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
autoComplete="username"
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +1,203 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Button, Table } from 'react-bootstrap';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { useInterval } from '../../hooks/hooks.jsx';
|
||||
import { fetchData } from '../../fetching/Fetch.js'
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { getActiveStation, saveActiveStation } from '../../utils/activeStation.js';
|
||||
|
||||
const styles = {
|
||||
stationRow: {
|
||||
backgroundColor: "#ADD8E6",
|
||||
}
|
||||
backgroundColor: '#ADD8E6',
|
||||
},
|
||||
};
|
||||
|
||||
export default function ShowQueuesForTellerPage() {
|
||||
const url = `${ SERVER_URL }/api/v1/`;
|
||||
const [ tickets, setTickets ] = useState([]);
|
||||
const [ usedUndo, setUsedUndo ] = useState(false)
|
||||
const [ timedOut, setTimedOut ] = useState(false)
|
||||
const url = `${SERVER_URL}/api/v1/`;
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [usedUndo, setUsedUndo] = useState(false);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
const [stationLabel, setStationLabel] = useState(null);
|
||||
|
||||
const { stationId } = useParams();
|
||||
const location = useLocation();
|
||||
const { user } = useContext(UserContext);
|
||||
const tenantCode = user?.tenantCode;
|
||||
|
||||
const { stationId} = useParams()
|
||||
|
||||
useEffect(() => {
|
||||
fetchTicketsForTellerStation();
|
||||
}, [ stationId ]);
|
||||
}, [stationId]);
|
||||
|
||||
useInterval(fetchTicketsForTellerStation, 10000)
|
||||
useEffect(() => {
|
||||
const fromNav = location.state;
|
||||
if (fromNav?.station?.name) {
|
||||
const next = {
|
||||
stationId: Number(stationId),
|
||||
stationName: fromNav.station.name,
|
||||
branchId: fromNav.branch?.id ?? null,
|
||||
branchName: fromNav.branch?.name ?? null,
|
||||
};
|
||||
saveActiveStation(next);
|
||||
setStationLabel(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromSession = getActiveStation(stationId);
|
||||
if (fromSession?.stationName) {
|
||||
setStationLabel(fromSession);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenantCode) {
|
||||
setStationLabel({ stationId: Number(stationId), stationName: `Station ${stationId}` });
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData(`${url}branches/${encodeURIComponent(tenantCode)}`, 'GET')
|
||||
.then(({ data, success }) => {
|
||||
if (!success || !Array.isArray(data)) {
|
||||
setStationLabel({
|
||||
stationId: Number(stationId),
|
||||
stationName: `Station ${stationId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const branch of data) {
|
||||
const station = branch.tellerStations?.find(
|
||||
(item) => Number(item.id) === Number(stationId)
|
||||
);
|
||||
if (station) {
|
||||
const next = {
|
||||
stationId: Number(stationId),
|
||||
stationName: station.name,
|
||||
branchId: branch.id,
|
||||
branchName: branch.name,
|
||||
};
|
||||
saveActiveStation(next);
|
||||
setStationLabel(next);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStationLabel({
|
||||
stationId: Number(stationId),
|
||||
stationName: `Station ${stationId}`,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setStationLabel({
|
||||
stationId: Number(stationId),
|
||||
stationName: `Station ${stationId}`,
|
||||
});
|
||||
});
|
||||
}, [stationId, tenantCode, location.state, url]);
|
||||
|
||||
useInterval(fetchTicketsForTellerStation, 10000);
|
||||
|
||||
function fetchTicketsForTellerStation() {
|
||||
fetchData(`${ url }stations/${ stationId }/tickets`, 'GET')
|
||||
.then(response => response.data)
|
||||
fetchData(`${url}stations/${stationId}/tickets`, 'GET')
|
||||
.then((response) => response.data)
|
||||
.then(setTickets)
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function advanceQueue() {
|
||||
fetchData(`${ url }teller/advance-queue/${ stationId }`, 'POST')
|
||||
fetchData(`${url}teller/advance-queue/${stationId}`, 'POST')
|
||||
.then(fetchTicketsForTellerStation)
|
||||
.then(() => setUsedUndo(false))
|
||||
.then(timeOutTeller)
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function undoQueue() {
|
||||
fetchData(`${ url }teller/undo-queue/${ stationId }`, 'POST')
|
||||
fetchData(`${url}teller/undo-queue/${stationId}`, 'POST')
|
||||
.then(fetchTicketsForTellerStation)
|
||||
.then(() => setUsedUndo(true))
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function timeOutTeller() {
|
||||
setTimedOut(true)
|
||||
setTimeout(() => setTimedOut(false), 5000)
|
||||
setTimedOut(true);
|
||||
setTimeout(() => setTimedOut(false), 5000);
|
||||
// Can't advance more often than every 5 seconds.
|
||||
}
|
||||
|
||||
const hasAssignedTicket = tickets.some(ticket => ticket.station?.id == stationId)
|
||||
const hasAssignedTicket = tickets.some((ticket) => ticket.station?.id == stationId);
|
||||
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<h1>Queues</h1>
|
||||
<div className="d-flex gap-2 justify-content-center">
|
||||
{stationLabel ? (
|
||||
<p className="mb-3" style={{ fontSize: '1.25rem', color: '#334257' }}>
|
||||
<strong>{stationLabel.stationName}</strong>
|
||||
{stationLabel.branchName ? (
|
||||
<span className="text-muted"> · {stationLabel.branchName}</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="d-flex gap-2 justify-content-center flex-wrap">
|
||||
<Button
|
||||
variant="primary"
|
||||
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
|
||||
onClick={ advanceQueue }
|
||||
disabled={ timedOut }
|
||||
style={{ backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' }}
|
||||
onClick={advanceQueue}
|
||||
disabled={timedOut}
|
||||
>
|
||||
Advance Queue
|
||||
Nombor Giliran Seterusnya
|
||||
</Button>
|
||||
<Button variant="primary"
|
||||
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
|
||||
onClick={ undoQueue }
|
||||
disabled={ usedUndo || !hasAssignedTicket }>
|
||||
Undo
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' }}
|
||||
onClick={undoQueue}
|
||||
disabled={usedUndo || !hasAssignedTicket}
|
||||
>
|
||||
Patah balik
|
||||
</Button>
|
||||
<Button variant="primary"
|
||||
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
|
||||
onClick={ fetchTicketsForTellerStation }>
|
||||
Refresh
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' }}
|
||||
onClick={fetchTicketsForTellerStation}
|
||||
>
|
||||
Muat semula
|
||||
</Button>
|
||||
<Link to={`/display/${stationId}`} target="_blank" rel="noopener noreferrer">
|
||||
<Button
|
||||
variant="secondary"
|
||||
>
|
||||
Current Ticket
|
||||
</Button>
|
||||
<Button variant="secondary">Tiket semasa</Button>
|
||||
</Link>
|
||||
{tenantCode && stationLabel?.branchId ? (
|
||||
<Link
|
||||
to={`/display/branch/${encodeURIComponent(tenantCode)}/${stationLabel.branchId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="secondary">Paparan TV</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={ { marginTop: '20px' } }>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Table striped bordered hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket number</th>
|
||||
<th>Service</th>
|
||||
<th>Creation at</th>
|
||||
<th>Nombor tiket</th>
|
||||
<th>Perkhidmatan</th>
|
||||
<th>Dibuat pada</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickets.map((ticket, index) => {
|
||||
{tickets.map((ticket, index) => {
|
||||
const createdAt = new Date(ticket.createdAt);
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td style={ticket.station?.id == stationId ? styles.stationRow : {}}>{ticket.number}</td>
|
||||
<td
|
||||
style={
|
||||
ticket.station?.id == stationId
|
||||
? styles.stationRow
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{ticket.number}
|
||||
</td>
|
||||
<td>{ticket.service.name}</td>
|
||||
<td>{createdAt.toLocaleString()}</td>
|
||||
</tr>
|
||||
@@ -111,5 +207,5 @@ export default function ShowQueuesForTellerPage() {
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,73 +1,160 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dropdown, Button } from 'react-bootstrap';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Dropdown } from 'react-bootstrap';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { saveActiveStation } from '../../utils/activeStation.js';
|
||||
|
||||
const StationIntroPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const [branches, setBranches] = useState([]);
|
||||
const [selectedBranch, setSelectedBranch] = useState(null);
|
||||
const [stations, setStations] = useState([]);
|
||||
const [selectedStation, setSelectedStation] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loadingBranches, setLoadingBranches] = useState(false);
|
||||
const [loadingStations, setLoadingStations] = useState(false);
|
||||
|
||||
const url = `${ SERVER_URL }/api/v1/`;
|
||||
const tenantCode = user?.tenantCode;
|
||||
const url = `${SERVER_URL}/api/v1/`;
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(`${ url }branches/DFLT`, 'GET')
|
||||
.then(response => response.data)
|
||||
.then(setBranches)
|
||||
.catch(console.error)
|
||||
}, []);
|
||||
if (!tenantCode) {
|
||||
setBranches([]);
|
||||
setError('Missing tenant on your account.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingBranches(true);
|
||||
setError('');
|
||||
fetchData(`${url}branches/${encodeURIComponent(tenantCode)}`, 'GET')
|
||||
.then(({ data, success }) => {
|
||||
if (!success) {
|
||||
setBranches([]);
|
||||
setError('Could not load branches for your tenant.');
|
||||
return;
|
||||
}
|
||||
setBranches(Array.isArray(data) ? data : []);
|
||||
if (!data?.length) {
|
||||
setError(`No branches found for tenant ${tenantCode}.`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError('Could not load branches.');
|
||||
})
|
||||
.finally(() => setLoadingBranches(false));
|
||||
}, [tenantCode]);
|
||||
|
||||
function handleBranchSelect(branch) {
|
||||
setSelectedBranch(branch)
|
||||
fetchData(`${ url }stations/DFLT/${ branch.id }`, 'GET')
|
||||
.then(response => response.data)
|
||||
.then(setStations)
|
||||
.catch(console.error)
|
||||
setSelectedBranch(branch);
|
||||
setSelectedStation(null);
|
||||
setStations([]);
|
||||
setLoadingStations(true);
|
||||
setError('');
|
||||
|
||||
fetchData(`${url}stations/${encodeURIComponent(tenantCode)}/${branch.id}`, 'GET')
|
||||
.then(({ data, success }) => {
|
||||
if (!success) {
|
||||
setStations([]);
|
||||
setError('Could not load stations for this branch.');
|
||||
return;
|
||||
}
|
||||
setStations(Array.isArray(data) ? data : []);
|
||||
if (!data?.length) {
|
||||
setError('No stations found for this branch.');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError('Could not load stations.');
|
||||
})
|
||||
.finally(() => setLoadingStations(false));
|
||||
}
|
||||
|
||||
function handleStationSelect(station) {
|
||||
navigate(`/teller-queue/${ station.id }`)
|
||||
setSelectedStation(station);
|
||||
saveActiveStation({
|
||||
stationId: station.id,
|
||||
stationName: station.name,
|
||||
branchId: selectedBranch?.id,
|
||||
branchName: selectedBranch?.name,
|
||||
});
|
||||
navigate(`/teller-queue/${station.id}`, {
|
||||
state: {
|
||||
station,
|
||||
branch: selectedBranch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="border p-3" style={{ maxWidth: '500px', margin: '0 auto' }}>
|
||||
<h1 className="mb-4">Select Branch and Station</h1>
|
||||
<h1 className="mb-2">Pilih Cawangan dan Kaunter</h1>
|
||||
{tenantCode ? (
|
||||
<p className="text-muted mb-4">Syarikat: {tenantCode}</p>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-danger">{error}</p> : null}
|
||||
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle variant="primary" id="dropdown-branch" style={{ width: '100%' }}>
|
||||
{selectedBranch ? selectedBranch.name : 'Select Branch'}
|
||||
<Dropdown.Toggle
|
||||
variant="primary"
|
||||
id="dropdown-branch"
|
||||
style={{ width: '100%' }}
|
||||
disabled={loadingBranches || branches.length === 0}
|
||||
>
|
||||
{loadingBranches
|
||||
? 'Loading cawangan…'
|
||||
: selectedBranch
|
||||
? selectedBranch.name
|
||||
: 'Pilih Cawangan'}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu style={{ width: '100%' }}>
|
||||
{branches.map(branch => (
|
||||
<Dropdown.Item key={branch.id} onClick={() => handleBranchSelect(branch)}>
|
||||
{branches.map((branch) => (
|
||||
<Dropdown.Item
|
||||
key={branch.id}
|
||||
onClick={() => handleBranchSelect(branch)}
|
||||
>
|
||||
{branch.name}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
|
||||
{selectedBranch && (
|
||||
{selectedBranch ? (
|
||||
<div className="mt-3">
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle variant="primary" id="dropdown-station" style={{ width: '100%' }}>
|
||||
{selectedStation ? selectedStation.name : 'Select Station'}
|
||||
<Dropdown.Toggle
|
||||
variant="primary"
|
||||
id="dropdown-station"
|
||||
style={{ width: '100%' }}
|
||||
disabled={loadingStations || stations.length === 0}
|
||||
>
|
||||
{loadingStations
|
||||
? 'Loading kaunter…'
|
||||
: selectedStation
|
||||
? selectedStation.name
|
||||
: 'Pilih Kaunter'}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu style={{ width: '100%' }}>
|
||||
{stations.map(station => (
|
||||
<Dropdown.Item key={station.id} onClick={() => handleStationSelect(station)}>
|
||||
{stations.map((station) => (
|
||||
<Dropdown.Item
|
||||
key={station.id}
|
||||
onClick={() => handleStationSelect(station)}
|
||||
>
|
||||
{station.name}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const ACTIVE_STATION_KEY = 'qms_teller_active_station';
|
||||
|
||||
export function saveActiveStation({ stationId, stationName, branchId, branchName }) {
|
||||
sessionStorage.setItem(
|
||||
ACTIVE_STATION_KEY,
|
||||
JSON.stringify({
|
||||
stationId: Number(stationId),
|
||||
stationName,
|
||||
branchId: branchId != null ? Number(branchId) : null,
|
||||
branchName: branchName ?? null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function getActiveStation(stationId) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(ACTIVE_STATION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (stationId != null && Number(parsed.stationId) !== Number(stationId)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearActiveStation() {
|
||||
sessionStorage.removeItem(ACTIVE_STATION_KEY);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
const CALL_SOUND_URL = '/sounds/header-sound.mp3';
|
||||
|
||||
let sharedAudio = null;
|
||||
let unlocked = false;
|
||||
|
||||
function getCallAudio() {
|
||||
if (!sharedAudio) {
|
||||
sharedAudio = new Audio(CALL_SOUND_URL);
|
||||
sharedAudio.preload = 'auto';
|
||||
}
|
||||
return sharedAudio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call once after a user gesture (click/tap) so later plays are allowed.
|
||||
*/
|
||||
export function unlockQueueCallSound() {
|
||||
if (unlocked) return;
|
||||
try {
|
||||
const audio = getCallAudio();
|
||||
audio.muted = true;
|
||||
const playPromise = audio.play();
|
||||
if (playPromise?.then) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
audio.muted = false;
|
||||
unlocked = true;
|
||||
})
|
||||
.catch(() => {
|
||||
// Still mark unlocked so we keep trying on real calls.
|
||||
audio.muted = false;
|
||||
unlocked = true;
|
||||
});
|
||||
} else {
|
||||
audio.muted = false;
|
||||
unlocked = true;
|
||||
}
|
||||
} catch {
|
||||
unlocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the queue-call notification chime.
|
||||
* Safe to call repeatedly; restarts from the beginning each time.
|
||||
*/
|
||||
export function playQueueCallSound() {
|
||||
try {
|
||||
const audio = getCallAudio();
|
||||
audio.muted = false;
|
||||
audio.currentTime = 0;
|
||||
const playPromise = audio.play();
|
||||
if (playPromise?.catch) {
|
||||
playPromise.catch((error) => {
|
||||
console.warn('Could not play queue call sound:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not play queue call sound:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable map of stationId -> ticket number for currently serving tickets.
|
||||
*/
|
||||
export function servingSnapshot(tickets) {
|
||||
const map = {};
|
||||
for (const ticket of tickets) {
|
||||
if (ticket?.station?.id != null && ticket?.number != null) {
|
||||
map[String(ticket.station.id)] = String(ticket.number);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any station got a new/different ticket number vs the previous snapshot.
|
||||
*/
|
||||
export function hasNewServingCall(previous, next) {
|
||||
if (!previous) return false;
|
||||
for (const [stationId, number] of Object.entries(next)) {
|
||||
if (previous[stationId] !== number) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
const TOKEN_KEY = 'teller_token';
|
||||
const USER_DATA_KEY = 'teller_userData';
|
||||
|
||||
/** Matches backend jwt.token-validity-time (PT30M). */
|
||||
const SESSION_MAX_AGE_SECONDS = 30 * 60;
|
||||
|
||||
function getCookie(name) {
|
||||
const prefix = `${encodeURIComponent(name)}=`;
|
||||
const parts = document.cookie ? document.cookie.split('; ') : [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(prefix)) {
|
||||
return decodeURIComponent(part.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCookie(name, value, maxAgeSeconds = SESSION_MAX_AGE_SECONDS) {
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie = [
|
||||
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
||||
'Path=/',
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
'SameSite=Lax',
|
||||
secure,
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
function removeCookie(name) {
|
||||
document.cookie = `${encodeURIComponent(name)}=; Path=/; Max-Age=0; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return getCookie(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
if (token == null || token === '') {
|
||||
removeCookie(TOKEN_KEY);
|
||||
return;
|
||||
}
|
||||
setCookie(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function getUserData() {
|
||||
const raw = getCookie(USER_DATA_KEY) ?? sessionStorage.getItem(USER_DATA_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
removeCookie(USER_DATA_KEY);
|
||||
sessionStorage.removeItem(USER_DATA_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setUserData(userData) {
|
||||
if (userData == null) {
|
||||
removeCookie(USER_DATA_KEY);
|
||||
sessionStorage.removeItem(USER_DATA_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(userData);
|
||||
setCookie(USER_DATA_KEY, serialized);
|
||||
sessionStorage.setItem(USER_DATA_KEY, serialized);
|
||||
}
|
||||
|
||||
export function setSession({ token, userData } = {}) {
|
||||
if (token !== undefined) {
|
||||
setToken(token);
|
||||
}
|
||||
if (userData !== undefined) {
|
||||
setUserData(userData);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
removeCookie(TOKEN_KEY);
|
||||
removeCookie(USER_DATA_KEY);
|
||||
sessionStorage.removeItem(USER_DATA_KEY);
|
||||
}
|
||||
Reference in New Issue
Block a user