1008 lines
46 KiB
PHP
1008 lines
46 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
<meta name="theme-color" content="#ffffff">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
|
<meta http-equiv="Pragma" content="no-cache">
|
|
<title>Face Verification — Identia</title>
|
|
<link rel="manifest" href="manifest_foto.json">
|
|
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" rel="stylesheet">
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--primary: #4747a1;
|
|
--primary-light: #e6e6f2;
|
|
--primary-glow: rgba(71, 71, 161, 0.4);
|
|
--success: #4747a1;
|
|
--success-glow: rgba(71, 71, 161, 0.4);
|
|
--warning: #f59e0b;
|
|
--danger: #ef4444;
|
|
--bg-main: #f8fafc;
|
|
--bg-glass: rgba(255, 255, 255, 0.85);
|
|
--text-main: #0f172a;
|
|
--text-muted: #64748b;
|
|
--text-light: #94a3b8;
|
|
--border-soft: rgba(99, 102, 241, 0.15);
|
|
--shadow-glass: 0 8px 32px rgba(30, 41, 59, 0.08);
|
|
}
|
|
* { margin:0; padding:0; box-sizing:border-box; user-select:none; -webkit-user-select:none; }
|
|
html, body {
|
|
height:100%; width:100%; background:var(--bg-main);
|
|
font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;
|
|
overflow:hidden; display:flex; align-items:center; justify-content:center;
|
|
-webkit-font-smoothing:antialiased;
|
|
}
|
|
|
|
/* ── PHONE FRAME ── */
|
|
.phone {
|
|
width:100%; height:100vh;
|
|
background:#1e2223; /* Behind camera */
|
|
display:flex; flex-direction:column;
|
|
position:relative; overflow:hidden;
|
|
}
|
|
|
|
/* ── HEADER ── */
|
|
.header {
|
|
display:flex; justify-content:space-between; align-items:center;
|
|
padding:20px 20px;
|
|
z-index:30; position:absolute; top:0; left:0; right:0;
|
|
background: transparent;
|
|
}
|
|
.header-left { display:flex; align-items:center; gap:12px; }
|
|
.header-logo {
|
|
width:32px; height:32px; border-radius:10px;
|
|
background: rgba(255, 255, 255, 0.2); color: #ffffff;
|
|
display:flex; align-items:center; justify-content:center;
|
|
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
}
|
|
.header-logo i { font-size:18px; }
|
|
.header-text { display:flex; flex-direction:column; justify-content:center; gap:2px; }
|
|
.sys-text { font-size:10px; font-weight:600; color:rgba(255,255,255,0.8); text-transform:uppercase; letter-spacing:1px; text-shadow:0 1px 3px rgba(0,0,0,0.5); }
|
|
.sys-sub { font-size:15px; font-weight:700; color:#ffffff; letter-spacing:-0.5px; text-shadow:0 1px 3px rgba(0,0,0,0.5); }
|
|
|
|
.header-badge {
|
|
font-size:11px; font-weight:600; color:var(--primary);
|
|
background:rgba(255, 255, 255, 0.95); padding:6px 14px; border-radius:20px;
|
|
backdrop-filter: blur(8px);
|
|
max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
}
|
|
|
|
/* ── ERROR & LOADING PAGES ── */
|
|
.page { flex:1; display:flex; flex-direction:column; }
|
|
.page-hidden { display:none !important; }
|
|
|
|
.page-validating, .page-error {
|
|
align-items:center; justify-content:center; gap:20px; color:var(--text-main);
|
|
background: var(--bg-main); z-index: 50; position: absolute; inset: 0;
|
|
}
|
|
.val-ring {
|
|
width:56px; height:56px; border-radius:50%;
|
|
border:3px solid var(--primary-light); border-top-color:var(--primary);
|
|
animation:spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
|
}
|
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
.page-validating p { font-size:14px; color:var(--text-muted); font-weight:600; }
|
|
|
|
.page-error { padding:40px 30px; text-align:center; }
|
|
.err-icon {
|
|
width:80px; height:80px; border-radius:24px;
|
|
display:flex; align-items:center; justify-content:center; margin-bottom:8px;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
|
}
|
|
.err-icon.expired { background:#fffbeb; border:1px solid #fde68a; color:var(--warning); }
|
|
.err-icon.expired i { font-size:40px; }
|
|
.err-icon.invalid { background:#fef2f2; border:1px solid #fecaca; color:var(--danger); }
|
|
.err-icon.invalid i { font-size:40px; }
|
|
.page-error h2 { font-size:22px; font-weight:800; color:var(--text-main); letter-spacing:-0.5px; }
|
|
.page-error p { font-size:14px; color:var(--text-muted); line-height:1.6; max-width:300px; margin-top: 8px; }
|
|
.btn-retry-lib {
|
|
margin-top:24px; padding:12px 32px; border-radius:12px; border:none;
|
|
background:var(--primary); color:#fff; font-family:'Inter', sans-serif;
|
|
font-size:14px; font-weight:700; cursor:pointer; transition:all 0.2s;
|
|
box-shadow: 0 8px 20px var(--primary-glow);
|
|
}
|
|
.btn-retry-lib:hover { transform:translateY(-2px); box-shadow: 0 12px 24px var(--primary-glow); }
|
|
|
|
/* ── CAMERA AREA ── */
|
|
.cam-area { flex:1; position:relative; overflow:hidden; background:#1e2223; }
|
|
.cam-area video { width:100%; height:100%; object-fit:cover; transform:scaleX(-1); }
|
|
.cam-area canvas.overlay-cv { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; }
|
|
|
|
/* Elegant Face Oval & Silhouette Mask */
|
|
.face-oval {
|
|
position:absolute; top:0; left:0; width:100%; height:100%;
|
|
pointer-events:none; z-index: 10;
|
|
}
|
|
.face-oval svg { width:100%; height:100%; }
|
|
#bgRect { fill: #ffffff; }
|
|
|
|
/* Restore the ring */
|
|
#ovalRing { stroke: #e2e8f0; stroke-width: 6; display: block; }
|
|
#ovalRingOut { stroke: none; display: block; }
|
|
|
|
/* Use pure neon colors for maximum screen brightness emission */
|
|
.face-oval.detecting #bgRect { animation: bankBgGlow 0.5s infinite; filter: brightness(1.2); }
|
|
.face-oval.success #bgRect { fill: #00FF00; animation: none; filter: brightness(1.2); }
|
|
.face-oval.error #bgRect { fill: #FF0000; animation: none; filter: brightness(1.2); }
|
|
|
|
@keyframes bankBgGlow {
|
|
0% { fill: #FF0000; }
|
|
25% { fill: #FFFF00; }
|
|
50% { fill: #00FF00; }
|
|
75% { fill: #00FFFF; }
|
|
100% { fill: #FF0000; }
|
|
}
|
|
|
|
/* Hide elements to match verification UI */
|
|
.bar-capture, .st-pill, .brightness-meter, .liveness-meter { display: none !important; }
|
|
|
|
/* ── CAPTURE & ACTION BARS ── */
|
|
.bar-capture {
|
|
position:relative; width:100%; z-index:20;
|
|
display:flex; align-items:flex-end; justify-content:center;
|
|
margin-top:-10px;
|
|
}
|
|
.shutter {
|
|
width:76px; height:76px; border-radius:50%; pointer-events:auto;
|
|
border:4px solid rgba(255,255,255,0.4); background:rgba(255,255,255,0.1); cursor:pointer;
|
|
display:flex; align-items:center; justify-content:center;
|
|
transition:all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position:relative;
|
|
backdrop-filter:blur(4px);
|
|
}
|
|
.shutter::after {
|
|
content:''; width:56px; height:56px; border-radius:50%;
|
|
background:#ffffff; transition:all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
.shutter:hover::after { transform:scale(0.95); }
|
|
.shutter:active::after { transform:scale(0.8); background:var(--primary); }
|
|
.shutter:disabled { opacity:0.6; cursor:not-allowed; pointer-events:none; }
|
|
.shutter-lbl { display:none; }
|
|
|
|
.bar-after {
|
|
position:absolute; bottom:0; left:0; right:0; z-index:40;
|
|
padding:30px 0; background: rgba(255,255,255,0.95); backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
|
|
display:flex; align-items:center; justify-content:center; gap:40px; border-top:1px solid rgba(226, 232, 240, 0.8);
|
|
border-top-left-radius:24px; border-top-right-radius:24px;
|
|
pointer-events:auto; display:none;
|
|
}
|
|
.act-btn {
|
|
display:flex; flex-direction:column; align-items:center; gap:8px;
|
|
border:none; background:none; cursor:pointer; transition:0.2s;
|
|
pointer-events:auto; outline:none; -webkit-tap-highlight-color: transparent;
|
|
}
|
|
.act-btn .ring {
|
|
width:56px; height:56px; border-radius:18px;
|
|
display:flex; align-items:center; justify-content:center; font-size:24px; transition:all 0.2s ease;
|
|
}
|
|
.act-btn span { font-size:11px; font-weight:700; color:var(--text-main); text-transform:uppercase; letter-spacing:0.5px; }
|
|
|
|
.act-btn.retry .ring { background:#f1f5f9; color:var(--text-muted); border:1px solid #e2e8f0; }
|
|
.act-btn.retry:hover .ring { background:#e2e8f0; }
|
|
|
|
.act-btn.save .ring { background:var(--success); color:#fff; }
|
|
.act-btn.save:hover .ring { opacity:0.9; }
|
|
|
|
/* ── PREVIEW ── */
|
|
.preview { flex:1; position:relative; background:#1e2223; overflow:hidden; }
|
|
.preview canvas { width:100%; height:100%; object-fit:cover; }
|
|
.preview-tag {
|
|
position:absolute; top:85px; left:50%; transform:translateX(-50%);
|
|
background:var(--success); color:#fff; padding:10px 20px; border-radius:30px;
|
|
font-size:13px; font-weight:700; display:flex; align-items:center; gap:8px;
|
|
z-index:5;
|
|
}
|
|
.preview-tag i { font-size:18px; }
|
|
.preview-watermark {
|
|
position:absolute; bottom:140px; right:16px; font-size:10px; color:rgba(255,255,255,0.8);
|
|
font-weight:600; z-index:5; text-align:right; line-height:1.5; text-shadow:0 1px 4px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.toast {
|
|
position:absolute; top:20px; left:50%; transform:translateX(-50%);
|
|
padding:12px 24px; font-size:13px; font-weight:700; border-radius:16px;
|
|
display:none; align-items:center; gap:10px; z-index:60; white-space:nowrap; animation:slideDown 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
background:var(--bg-glass); backdrop-filter:blur(10px); border:1px solid rgba(255,255,255,0.6);
|
|
}
|
|
.toast i { font-size:20px; }
|
|
.toast.ok { color:var(--success); }
|
|
.toast.err { color:var(--danger); }
|
|
@keyframes slideDown { from{opacity:0;transform:translateX(-50%) translateY(-20px);} to{opacity:1;transform:translateX(-50%) translateY(0);} }
|
|
|
|
/* ── SUCCESS PAGE - ENHANCED ── */
|
|
.page-success { align-items:center; justify-content:center; padding:40px 30px; text-align:center; color:var(--text-main); background: var(--bg-main); z-index:90; position:absolute; inset:0; }
|
|
.suc-card {
|
|
background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255, 255, 255, 0.6); border-radius: 32px; padding: 40px 30px;
|
|
box-shadow: none; display: flex; flex-direction: column; align-items: center;
|
|
max-width: 90%; animation: slideUpFade 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
position: relative; overflow: hidden;
|
|
}
|
|
@keyframes slideUpFade { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } }
|
|
|
|
.suc-ring-wrap {
|
|
width: 90px; height: 90px; border-radius: 50%; background: var(--primary-light);
|
|
display: flex; align-items: center; justify-content: center; margin-bottom: 20px;
|
|
position: relative;
|
|
}
|
|
.suc-ring-wrap::before {
|
|
content: ''; position: absolute; inset: -10px; border-radius: 50%;
|
|
border: 2px solid var(--primary-glow); animation: pulseSurround 2s infinite;
|
|
}
|
|
.suc-ring {
|
|
width: 60px; height: 60px; border-radius: 50%; background: var(--success);
|
|
display:flex; align-items:center; justify-content:center;
|
|
z-index: 2;
|
|
}
|
|
.suc-ring i { font-size: 32px; color: #fff; }
|
|
@keyframes pulseSurround { 0% { transform: scale(0.8); opacity: 1; } 100% { transform: scale(1.3); opacity: 0; } }
|
|
|
|
.page-success h2 { font-size: 24px; font-weight: 800; color: var(--success); letter-spacing: -0.5px; margin-bottom: 12px; }
|
|
.page-success p { font-size: 14px; color: var(--text-muted); line-height: 1.6; max-width: 280px; margin-bottom: 20px; }
|
|
.suc-foto {
|
|
width:120px; height:120px; border-radius:24px; object-fit:cover; border:4px solid #fff;
|
|
margin-top:10px; margin-bottom: 24px;
|
|
background: var(--primary-light);
|
|
}
|
|
.suc-checks-wrap { display: flex; flex-direction: column; gap: 12px; background: rgba(241, 245, 249, 0.6); padding: 16px 20px; border-radius: 16px; width: 100%; }
|
|
.suc-check { display:flex; align-items:flex-start; gap:12px; font-size:13px; color:var(--text-main); font-weight:600; text-align: left;}
|
|
.suc-check i { color:var(--success); font-size:18px; margin-top: -2px; }
|
|
.suc-countdown { font-size:12px; color:var(--text-muted); margin-top:24px; font-weight:600; background:rgba(255,255,255,0.8); padding: 6px 16px; border-radius: 20px; }
|
|
|
|
/* ── APP-LIKE METERS ── */
|
|
.brightness-meter, .liveness-meter {
|
|
position:absolute; top:50%; transform:translateY(-50%);
|
|
display:flex; flex-direction:column; align-items:center; gap:12px; z-index:12;
|
|
padding: 14px 10px; background: none; border-radius: 0; backdrop-filter: none;
|
|
}
|
|
.brightness-meter { right:6px; }
|
|
.liveness-meter { left:6px; }
|
|
|
|
.bm-icon, .lm-icon { font-size:16px; color:#fff; }
|
|
|
|
.bm-bar, .lm-bar {
|
|
width:6px; height:100px; background:rgba(255,255,255,0.2); position:relative; overflow:hidden;
|
|
border-radius:6px;
|
|
}
|
|
|
|
.bm-fill, .lm-fill {
|
|
position:absolute; bottom:0; left:0; width:100%; border-radius:6px;
|
|
background: #fff; transition:height 0.4s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.4s ease;
|
|
}
|
|
.bm-label, .lm-label { display:none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<?php
|
|
$tokenParam = isset($_GET['token']) ? trim($_GET['token']) : '';
|
|
?>
|
|
|
|
<div class="phone">
|
|
|
|
<?php if ($tokenParam === ''): ?>
|
|
<?php
|
|
header("Location: 404.php");
|
|
exit;
|
|
?>
|
|
<?php else: ?>
|
|
|
|
<!-- PAGE: Validating -->
|
|
<div class="page page-validating" id="pgValidating">
|
|
<div class="val-ring"></div>
|
|
<p>Validating token...</p>
|
|
</div>
|
|
|
|
<!-- PAGE: Error -->
|
|
<div class="page page-error page-hidden" id="pgError">
|
|
<div class="err-icon expired" id="errIconWrap"><i class="mdi mdi-clock-alert-outline" id="errIcon"></i></div>
|
|
<h2 id="errTitle">Link Invalid</h2>
|
|
<p id="errMsg">This link is invalid or has already been used. Please request the latest link from the Admin.</p>
|
|
<button class="btn-retry-lib" id="btnRetryLib" style="display:none;" onclick="location.reload()">
|
|
<i class="mdi mdi-refresh"></i> Try Again
|
|
</button>
|
|
</div>
|
|
|
|
<!-- PAGE: Camera -->
|
|
<div class="page page-hidden" id="pgCamera">
|
|
<div class="header" style="background: transparent; padding: 20px; align-items: flex-start;">
|
|
<div class="header-left" style="cursor: pointer;" onclick="history.back()">
|
|
<i class="mdi mdi-chevron-left" style="font-size: 28px; color: white; text-shadow: 0 1px 3px rgba(0,0,0,0.5);"></i>
|
|
</div>
|
|
<div class="header-badge" id="nameTag" style="display:none;"></div>
|
|
<div class="header-right" style="cursor: pointer;">
|
|
<i class="mdi mdi-help-circle-outline" style="font-size: 24px; color: white; text-shadow: 0 1px 3px rgba(0,0,0,0.5);"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- removed simpleText string to let stPill do it -->
|
|
|
|
<!-- Camera -->
|
|
<div class="cam-area" id="camArea">
|
|
<video id="vid" autoplay playsinline muted></video>
|
|
<canvas class="overlay-cv" id="ovCanvas"></canvas>
|
|
|
|
<div class="face-oval" id="oval">
|
|
<svg viewBox="0 0 400 800" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<mask id="silhouetteMask">
|
|
<rect width="100%" height="100%" fill="white"/>
|
|
<!-- face oval with ears shifted upwards slightly -->
|
|
<path d="M 200 200 C 110 200 70 270 70 360 C 40 350 30 460 70 450 C 90 550 140 580 200 580 C 260 580 310 550 330 450 C 370 460 360 350 330 360 C 330 270 290 200 200 200 Z" fill="black" />
|
|
</mask>
|
|
</defs>
|
|
<rect id="bgRect" width="100%" height="100%" mask="url(#silhouetteMask)"/>
|
|
|
|
<path d="M 200 200 C 110 200 70 270 70 360 C 40 350 30 460 70 450 C 90 550 140 580 200 580 C 260 580 310 550 330 450 C 370 460 360 350 330 360 C 330 270 290 200 200 200 Z" fill="none" class="ringOut" id="ovalRingOut"/>
|
|
<path d="M 200 200 C 110 200 70 270 70 360 C 40 350 30 460 70 450 C 90 550 140 580 200 580 C 260 580 310 550 330 450 C 370 460 360 350 330 360 C 330 270 290 200 200 200 Z" fill="none" class="ringIn" id="ovalRing"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<div class="corners" id="corners" style="display:none;"><div class="c"></div></div>
|
|
<div class="scanbar" id="scanbar" style="display:none;"></div>
|
|
|
|
<!-- Brightness Meter -->
|
|
<div class="brightness-meter" id="brightMeter" style="display:none;">
|
|
<i class="mdi mdi-white-balance-sunny bm-icon" id="bmIcon"></i>
|
|
<div class="bm-bar"><div class="bm-fill" id="bmFill" style="height:50%;background:#f59e0b;"></div></div>
|
|
<span class="bm-label">Light</span>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Liveness Meter -->
|
|
<div class="liveness-meter" id="liveMeter" style="display:none;">
|
|
<i class="mdi mdi-shield-check lm-icon" id="lmIcon"></i>
|
|
<div class="lm-bar"><div class="lm-fill" id="lmFill" style="height:0%;background:#f59e0b;"></div></div>
|
|
<span class="lm-label">Live</span>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div class="cam-loading" id="camLoading">
|
|
<div class="ldring"></div>
|
|
<p id="loadText">Loading camera & face detection models...</p>
|
|
<div class="load-progress"><div class="load-progress-fill" id="loadProgress"></div></div>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div class="st-pill searching" id="stPill" style="display:none;">
|
|
<i class="mdi mdi-face-recognition" id="stIcon"></i>
|
|
<span id="stText">Preparing...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview -->
|
|
<div class="preview page-hidden" id="previewWrap">
|
|
<canvas id="capCanvas"></canvas>
|
|
<div class="preview-tag"><i class="mdi mdi-check-circle"></i> Photo Captured</div>
|
|
<div class="preview-watermark" id="previewWatermark"></div>
|
|
</div>
|
|
|
|
<!-- Dashboard Wrapper for UI -->
|
|
<div class="hud-dashboard">
|
|
<!-- Instructions hidden -->
|
|
<div class="instructions" id="instrPanel" style="display:none;">
|
|
<ul class="instr-list">
|
|
<li><i class="mdi mdi-lightbulb-on"></i> Ensure bright lighting</li>
|
|
<li><i class="mdi mdi-face-man"></i> Position face in oval</li>
|
|
<li><i class="mdi mdi-account-group"></i> Only 1 face</li>
|
|
<li><i class="mdi mdi-sunglasses"></i> Remove glasses</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Bottom: Capture -->
|
|
<div class="bar-capture" id="barCapture" style="margin-bottom: 20px;">
|
|
<div style="text-align:center;">
|
|
<button class="shutter" id="btnShutter" disabled></button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bottom: After -->
|
|
<div class="bar-after" id="barAfter">
|
|
<button class="act-btn retry" id="btnRetake">
|
|
<div class="ring"><i class="mdi mdi-refresh"></i></div>
|
|
<span>Retake</span>
|
|
</button>
|
|
<button class="act-btn save" id="btnSave">
|
|
<div class="ring"><i class="mdi mdi-check"></i></div>
|
|
<span>Save</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
</div>
|
|
|
|
<!-- PAGE: Success -->
|
|
<div class="page page-success page-hidden" id="pgSuccess">
|
|
<div class="suc-card">
|
|
<div class="suc-ring-wrap">
|
|
<div class="suc-ring"><i class="mdi mdi-check-bold"></i></div>
|
|
</div>
|
|
<h2>Verification Successful!</h2>
|
|
<p>Your face photo has been securely saved.</p>
|
|
|
|
<img id="sucFoto" class="suc-foto" src="" alt="" style="display:none;">
|
|
|
|
<div class="suc-checks-wrap">
|
|
<div class="suc-check"><i class="mdi mdi-check-circle"></i> <span>Data verified and saved to server</span></div>
|
|
<div class="suc-check"><i class="mdi mdi-shield-lock"></i> <span>Protected with digital watermark scheme</span></div>
|
|
</div>
|
|
|
|
<div class="suc-countdown" id="sucCountdown">Page will close automatically...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<?php if ($tokenParam !== ''): ?>
|
|
<script src="js/face-api/face-api.min.js"></script>
|
|
|
|
<script>
|
|
(function() {
|
|
var TOKEN = '<?= htmlspecialchars($tokenParam, ENT_QUOTES); ?>';
|
|
var MODEL_URLS = [
|
|
'js/face-api/models',
|
|
'https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model'
|
|
];
|
|
|
|
var stream = null, detectLoop = null, faceOk = false, dataURL = null, occupantId = null;
|
|
var brightInterval = null, tokenCheckInterval = null, livenessScore = 0;
|
|
var prevLandmarks = null, livenessFrames = 0, sessionSaved = false;
|
|
|
|
// ── Pages ──
|
|
var pgValidating = document.getElementById('pgValidating');
|
|
var pgError = document.getElementById('pgError');
|
|
var pgCamera = document.getElementById('pgCamera');
|
|
var pgSuccess = document.getElementById('pgSuccess');
|
|
|
|
function showPage(pg) {
|
|
[pgValidating, pgError, pgCamera, pgSuccess].forEach(function(p) {
|
|
if (p) p.classList.add('page-hidden');
|
|
});
|
|
if (pg) pg.classList.remove('page-hidden');
|
|
}
|
|
|
|
// ── Camera DOM ──
|
|
var vid = document.getElementById('vid');
|
|
var ovCanvas = document.getElementById('ovCanvas');
|
|
var oval = document.getElementById('oval');
|
|
var corners = document.getElementById('corners');
|
|
var scanbar = document.getElementById('scanbar');
|
|
var camLoading = document.getElementById('camLoading');
|
|
var instrPanel = document.getElementById('instrPanel');
|
|
var stPill = document.getElementById('stPill');
|
|
var stIcon = document.getElementById('stIcon');
|
|
var stText = document.getElementById('stText');
|
|
var btnShutter = document.getElementById('btnShutter');
|
|
var camArea = document.getElementById('camArea');
|
|
var previewWrap = document.getElementById('previewWrap');
|
|
var capCanvas = document.getElementById('capCanvas');
|
|
var barCapture = document.getElementById('barCapture');
|
|
var barAfter = document.getElementById('barAfter');
|
|
var btnRetake = document.getElementById('btnRetake');
|
|
var btnSave = document.getElementById('btnSave');
|
|
var toast = document.getElementById('toast');
|
|
var bmFill = document.getElementById('bmFill');
|
|
var bmIcon = document.getElementById('bmIcon');
|
|
var lmFill = document.getElementById('lmFill');
|
|
var lmIcon = document.getElementById('lmIcon');
|
|
var loadProgress = document.getElementById('loadProgress');
|
|
var loadText = document.getElementById('loadText');
|
|
var secFace = document.getElementById('secFace');
|
|
var secLive = document.getElementById('secLive');
|
|
var secLight = document.getElementById('secLight');
|
|
|
|
function setStatus(type, txt) {
|
|
var topText = document.getElementById('simpleText');
|
|
if (topText) topText.textContent = txt;
|
|
|
|
stPill.className = 'st-pill';
|
|
if (type === 'search') { stPill.classList.add('searching'); stIcon.className = 'mdi mdi-face-recognition'; }
|
|
if (type === 'ok') { stPill.classList.add('found'); stIcon.className = 'mdi mdi-check-circle'; }
|
|
if (type === 'err') { stPill.classList.add('warn'); stIcon.className = 'mdi mdi-alert-circle'; }
|
|
stText.textContent = txt;
|
|
}
|
|
|
|
function showToast(type, msg) {
|
|
toast.className = 'toast ' + type;
|
|
toast.innerHTML = '<i class="mdi mdi-' + (type==='ok' ? 'check-circle' : 'alert-circle') + '"></i> ' + msg;
|
|
toast.style.display = 'flex';
|
|
}
|
|
|
|
function setProgress(pct) {
|
|
if (loadProgress) loadProgress.style.width = Math.min(100, pct) + '%';
|
|
}
|
|
|
|
|
|
|
|
// ── Anti-screenshot / right-click ──
|
|
document.addEventListener('contextmenu', function(e) { e.preventDefault(); });
|
|
|
|
// ── Step 1: Validate Token ──
|
|
function validateToken() {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('GET', 'validate_foto_token.php?token=' + encodeURIComponent(TOKEN), true);
|
|
xhr.onload = function() {
|
|
try {
|
|
var d = JSON.parse(xhr.responseText);
|
|
if (d.valid) {
|
|
occupantId = d.id_occupant;
|
|
document.getElementById('nameTag').textContent = d.name;
|
|
showPage(pgCamera);
|
|
initCamera();
|
|
startTokenCheck();
|
|
} else {
|
|
window.location.href = '404.php';
|
|
}
|
|
} catch(e) {
|
|
window.location.href = '404.php';
|
|
}
|
|
};
|
|
xhr.onerror = function() {
|
|
window.location.href = '404.php';
|
|
};
|
|
xhr.send();
|
|
}
|
|
|
|
// ── Periodic token validity check ──
|
|
function startTokenCheck() {
|
|
tokenCheckInterval = setInterval(function() {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('GET', 'validate_foto_token.php?token=' + encodeURIComponent(TOKEN), true);
|
|
xhr.onload = function() {
|
|
try {
|
|
var d = JSON.parse(xhr.responseText);
|
|
if (false && !sessionSaved) {
|
|
clearInterval(tokenCheckInterval);
|
|
stopEverything();
|
|
window.location.href = '404.php';
|
|
}
|
|
} catch(e) {}
|
|
};
|
|
xhr.send();
|
|
}, 30000);
|
|
}
|
|
|
|
function stopEverything() {
|
|
clearInterval(detectLoop);
|
|
clearInterval(brightInterval);
|
|
if (stream) stream.getTracks().forEach(function(t) { t.stop(); });
|
|
}
|
|
|
|
// ── Step 2: Load models + camera ──
|
|
var modelLoaded = false, landmarkLoaded = false, cameraStarted = false;
|
|
|
|
async function initCamera() {
|
|
setProgress(5);
|
|
loadText.textContent = 'Loading face detection library...';
|
|
|
|
// face-api.js loaded locally via <script> tag - check immediately
|
|
if (typeof faceapi === 'undefined') {
|
|
camLoading.style.display = 'none';
|
|
document.getElementById('errTitle').textContent = 'Failed to Load Library';
|
|
document.getElementById('errMsg').textContent = 'The face detection library could not be loaded. Check your internet connection and try again.';
|
|
document.getElementById('errIconWrap').className = 'err-icon invalid';
|
|
document.getElementById('errIcon').className = 'mdi mdi-cloud-off-outline';
|
|
var retryBtn = document.getElementById('btnRetryLib');
|
|
if (retryBtn) retryBtn.style.display = 'inline-flex';
|
|
showPage(pgError);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setProgress(20);
|
|
loadText.textContent = 'Loading face detection models...';
|
|
|
|
// Load from local models
|
|
try {
|
|
await Promise.all([
|
|
faceapi.nets.tinyFaceDetector.loadFromUri('js/face-api/models'),
|
|
faceapi.nets.faceLandmark68Net.loadFromUri('js/face-api/models')
|
|
]);
|
|
modelLoaded = true;
|
|
landmarkLoaded = true;
|
|
} catch(e) {
|
|
camLoading.style.display = 'none';
|
|
setStatus('err', 'Failed to load face detection models. Ensure models folder exists.');
|
|
return;
|
|
}
|
|
|
|
setProgress(50);
|
|
loadText.textContent = 'Starting camera...';
|
|
|
|
// Start camera
|
|
// Force Standard Definition (SD) settings for camera to improve performance
|
|
var videoConstraints = {
|
|
facingMode: 'user',
|
|
width: { ideal: 480 }
|
|
};
|
|
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false });
|
|
vid.srcObject = stream;
|
|
await new Promise(function(resolve) {
|
|
vid.onloadedmetadata = function() {
|
|
vid.play().then(function() { resolve(true); }).catch(function() { resolve(false); });
|
|
};
|
|
setTimeout(function() { resolve(false); }, 10000);
|
|
});
|
|
cameraStarted = true;
|
|
} catch(err) {
|
|
var msg = err.message;
|
|
if (err.name === 'NotAllowedError' || (err.message && err.message.includes('Permission denied'))) {
|
|
msg = "Camera access denied by your browser.\n\nPlease allow camera access (click the lock/camera icon to the left of the URL/address bar, select 'Allow' for Camera), then reload this page.";
|
|
} else if (err.name === 'NotFoundError') {
|
|
msg = "Camera not found. Make sure your device has a properly connected camera.";
|
|
} else if (err.name === 'NotReadableError') {
|
|
msg = "Camera is being used by another application. Close that application and try again.";
|
|
}
|
|
alert("Camera Access Error:\n\n" + msg);
|
|
return;
|
|
}
|
|
|
|
setProgress(100);
|
|
loadText.textContent = 'Ready!';
|
|
|
|
setTimeout(function() {
|
|
camLoading.style.display = 'none';
|
|
stPill.style.display = 'flex';
|
|
document.getElementById('brightMeter').style.display = 'flex';
|
|
document.getElementById('liveMeter').style.display = 'flex';
|
|
setStatus('search', 'Position your face...');
|
|
startDetection();
|
|
startBrightnessCheck();
|
|
}, 400);
|
|
|
|
} catch(err) {
|
|
camLoading.style.display = 'none';
|
|
setStatus('err', 'An error occurred during initialization');
|
|
}
|
|
}
|
|
|
|
function startCamera() {
|
|
// Force Standard Definition (SD) settings for camera to improve performance
|
|
var videoConstraints = {
|
|
facingMode: 'user',
|
|
width: { ideal: 480 }
|
|
};
|
|
return navigator.mediaDevices.getUserMedia({
|
|
video: videoConstraints,
|
|
audio: false
|
|
}).then(function(s) {
|
|
stream = s;
|
|
vid.srcObject = s;
|
|
return new Promise(function(resolve) {
|
|
vid.onloadedmetadata = function() {
|
|
vid.play().then(function() { resolve(true); }).catch(function() { resolve(false); });
|
|
};
|
|
setTimeout(function() { resolve(false); }, 10000);
|
|
});
|
|
}).catch(function(err) {
|
|
var msg = err.message;
|
|
if (err.name === 'NotAllowedError' || err.message.includes('Permission denied')) {
|
|
msg = "Camera access denied by your browser.\n\nPlease allow camera access (click the lock/camera icon to the left of the URL/address bar, select 'Allow' for Camera), then reload this page.";
|
|
} else if (err.name === 'NotFoundError' || err.message.includes('Requested device not found')) {
|
|
msg = "Camera not found. Make sure your device has a properly connected camera.";
|
|
} else if (err.name === 'NotReadableError' || err.message.includes('device in use')) {
|
|
msg = "Camera is being used by another application. Close that application and try again.";
|
|
} else {
|
|
msg = err.name + " - " + err.message;
|
|
}
|
|
alert("Camera Access Error:\n\n" + msg);
|
|
return false;
|
|
});
|
|
}
|
|
|
|
// ── Face Detection with Liveness ──
|
|
var isDetecting = false;
|
|
var faceState = 'idle';
|
|
var scanStartTime = 0;
|
|
|
|
function startDetection() {
|
|
var opts = new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.15 });
|
|
detectLoop = setInterval(function() {
|
|
if (vid.paused || vid.ended || isDetecting) return;
|
|
isDetecting = true;
|
|
faceapi.detectAllFaces(vid, opts).withFaceLandmarks().run().then(function(results) {
|
|
var vw = vid.videoWidth, vh = vid.videoHeight;
|
|
ovCanvas.width = vw; ovCanvas.height = vh;
|
|
var ctx = ovCanvas.getContext('2d');
|
|
ctx.clearRect(0, 0, vw, vh);
|
|
|
|
if (results.length === 1) {
|
|
var det = results[0].detection;
|
|
var box = det.box;
|
|
var landmarks = results[0].landmarks;
|
|
|
|
// Face size validation (min 15% of frame)
|
|
var faceArea = (box.width * box.height) / (vw * vh);
|
|
var faceTooSmall = faceArea < 0.04;
|
|
var faceTooBig = faceArea > 0.6;
|
|
|
|
// Face center validation
|
|
var cx = box.x + box.width / 2;
|
|
var cy = box.y + box.height / 2;
|
|
var dx = Math.abs(cx - vw / 2) / vw;
|
|
var dy = Math.abs(cy - vh / 2) / vh;
|
|
var faceNotCenter = dx > 0.2 || dy > 0.2;
|
|
|
|
if (faceTooSmall || faceTooBig || faceNotCenter) {
|
|
if (faceState !== 'showing_result') {
|
|
faceState = 'idle';
|
|
oval.className = 'face-oval';
|
|
livenessFrames = 0; livenessScore = 0;
|
|
}
|
|
} else {
|
|
// Face is good
|
|
faceOk = true;
|
|
|
|
if (faceState === 'idle') {
|
|
faceState = 'scanning';
|
|
scanStartTime = Date.now();
|
|
oval.className = 'face-oval detecting';
|
|
livenessFrames = 0; livenessScore = 0;
|
|
if (landmarks) prevLandmarks = landmarks.positions.map(function(p) { return {x: p.x, y: p.y}; });
|
|
} else if (faceState === 'scanning') {
|
|
if (landmarks && landmarkLoaded) {
|
|
var pts = landmarks.positions;
|
|
if (prevLandmarks && pts.length === prevLandmarks.length) {
|
|
var totalDiff = 0;
|
|
for (var i = 0; i < pts.length; i++) {
|
|
totalDiff += Math.abs(pts[i].x - prevLandmarks[i].x) + Math.abs(pts[i].y - prevLandmarks[i].y);
|
|
}
|
|
var avgDiff = totalDiff / pts.length;
|
|
if (avgDiff > 0.05 && avgDiff < 15) {
|
|
livenessFrames = Math.min(livenessFrames + 1, 20);
|
|
} else if (avgDiff >= 15) {
|
|
livenessFrames = Math.max(0, livenessFrames - 2);
|
|
} else {
|
|
livenessFrames = Math.min(livenessFrames + 0.5, 20);
|
|
}
|
|
}
|
|
prevLandmarks = pts.map(function(p) { return {x: p.x, y: p.y}; });
|
|
livenessScore = Math.min(100, (livenessFrames / 12) * 100);
|
|
}
|
|
|
|
if (Date.now() - scanStartTime > 1500) {
|
|
faceState = 'showing_result';
|
|
|
|
if (livenessScore >= 15) {
|
|
oval.className = 'face-oval success';
|
|
clearInterval(detectLoop);
|
|
|
|
// 3 seconds countdown
|
|
var count = 3;
|
|
var cdText = document.createElement('div');
|
|
cdText.style = 'position:absolute; top:40%; left:50%; transform:translate(-50%, -50%); font-size:120px; font-weight:900; color:#10b981; z-index:999; text-shadow:0 4px 20px rgba(0,0,0,0.5);';
|
|
cdText.innerText = count;
|
|
camArea.appendChild(cdText);
|
|
|
|
var cdInt = setInterval(function() {
|
|
count--;
|
|
if (count > 0) {
|
|
cdText.innerText = count;
|
|
} else {
|
|
clearInterval(cdInt);
|
|
cdText.remove();
|
|
faceOk = true;
|
|
capture(); // stop camera, show preview & buttons
|
|
}
|
|
}, 1000);
|
|
|
|
} else {
|
|
oval.className = 'face-oval error';
|
|
setTimeout(function() {
|
|
faceState = 'idle';
|
|
oval.className = 'face-oval';
|
|
livenessFrames = 0;
|
|
}, 1500);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (faceState !== 'showing_result') {
|
|
faceState = 'idle';
|
|
oval.className = 'face-oval';
|
|
livenessFrames = 0; livenessScore = 0;
|
|
}
|
|
}
|
|
}).catch(function(){}).finally(function() { isDetecting = false; });
|
|
}, 400);
|
|
}
|
|
|
|
// ── Brightness Check ──
|
|
function startBrightnessCheck() {
|
|
var tmpCanvas = document.createElement('canvas');
|
|
var tmpCtx = tmpCanvas.getContext('2d');
|
|
brightInterval = setInterval(function() {
|
|
if (vid.paused || vid.ended || !vid.videoWidth) return;
|
|
tmpCanvas.width = 80; tmpCanvas.height = 60;
|
|
tmpCtx.drawImage(vid, 0, 0, 80, 60);
|
|
var data = tmpCtx.getImageData(0, 0, 80, 60).data;
|
|
var sum = 0, count = data.length / 4;
|
|
for (var i = 0; i < data.length; i += 4) {
|
|
sum += (data[i] * 0.299 + data[i+1] * 0.587 + data[i+2] * 0.114);
|
|
}
|
|
var pct = Math.min(100, Math.max(5, (sum / count / 255) * 100));
|
|
bmFill.style.height = pct + '%';
|
|
if (pct < 30) {
|
|
bmFill.style.background = '#ef4444'; bmIcon.style.color = '#ef4444';
|
|
} else if (pct < 55) {
|
|
bmFill.style.background = '#f59e0b'; bmIcon.style.color = '#f59e0b';
|
|
} else {
|
|
bmFill.style.background = '#22c55e'; bmIcon.style.color = '#22c55e';
|
|
}
|
|
}, 800);
|
|
}
|
|
|
|
// ── Capture with Watermark ──
|
|
function capture() {
|
|
if (!faceOk) return;
|
|
clearInterval(detectLoop);
|
|
clearInterval(brightInterval);
|
|
var vw = vid.videoWidth, vh = vid.videoHeight;
|
|
capCanvas.width = vw; capCanvas.height = vh;
|
|
var ctx = capCanvas.getContext('2d');
|
|
ctx.save(); ctx.translate(vw, 0); ctx.scale(-1, 1);
|
|
ctx.drawImage(vid, 0, 0, vw, vh);
|
|
ctx.restore();
|
|
|
|
// Invisible watermark: embed token hash + timestamp in bottom-right pixels
|
|
var now = new Date();
|
|
var wmText = 'Identia|' + TOKEN.substring(0, 8) + '|' + now.toISOString();
|
|
ctx.font = '1px Arial';
|
|
ctx.fillStyle = 'rgba(0,0,0,0.01)';
|
|
ctx.fillText(wmText, vw - 200, vh - 2);
|
|
|
|
// Visible watermark overlay on preview
|
|
var wmEl = document.getElementById('previewWatermark');
|
|
if (wmEl) {
|
|
wmEl.innerHTML = '<i class="mdi mdi-shield-lock" style="font-size:10px;"></i> ' +
|
|
now.toLocaleDateString('id-ID') + ' ' + now.toLocaleTimeString('id-ID') +
|
|
'<br>ID: ' + TOKEN.substring(0, 8) + '...';
|
|
}
|
|
|
|
dataURL = capCanvas.toDataURL('image/png');
|
|
if (stream) stream.getTracks().forEach(function(t) { t.stop(); });
|
|
camArea.style.display = 'none';
|
|
previewWrap.classList.remove('page-hidden');
|
|
previewWrap.style.display = 'block';
|
|
barCapture.style.display = 'none';
|
|
barAfter.style.display = 'flex';
|
|
|
|
var st = document.getElementById('simpleText');
|
|
if (st) st.style.display = 'none';
|
|
var hr = document.querySelector('.header-right');
|
|
if (hr) hr.style.display = 'none';
|
|
}
|
|
|
|
// ── Retake ──
|
|
function retake() {
|
|
dataURL = null;
|
|
livenessScore = 0; livenessFrames = 0; prevLandmarks = null;
|
|
lmFill.style.height = '0%';
|
|
previewWrap.classList.add('page-hidden');
|
|
previewWrap.style.display = '';
|
|
camArea.style.display = 'block';
|
|
barAfter.style.display = 'none';
|
|
barCapture.style.display = 'flex';
|
|
|
|
var st = document.getElementById('simpleText');
|
|
if (st) st.style.display = 'block';
|
|
var hr = document.querySelector('.header-right');
|
|
if (hr) hr.style.display = 'block';
|
|
toast.style.display = 'none';
|
|
camLoading.style.display = 'flex';
|
|
loadText.textContent = 'Reloading camera...';
|
|
setProgress(50);
|
|
|
|
faceState = 'idle';
|
|
oval.className = 'face-oval';
|
|
faceOk = false;
|
|
startCamera().then(function(ok) {
|
|
if (ok) {
|
|
camLoading.style.display = 'none';
|
|
stPill.style.display = 'flex';
|
|
document.getElementById('brightMeter').style.display = 'flex';
|
|
document.getElementById('liveMeter').style.display = 'flex';
|
|
setStatus('search', 'Position your face...');
|
|
startDetection();
|
|
startBrightnessCheck();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Save ──
|
|
function save() {
|
|
if (!dataURL || sessionSaved) return;
|
|
btnSave.querySelector('.ring').innerHTML = '<i class="mdi mdi-loading mdi-spin"></i>';
|
|
btnSave.disabled = true;
|
|
|
|
var fd = new FormData();
|
|
fd.append('token', TOKEN);
|
|
fd.append('foto', dataURL);
|
|
fd.append('liveness_score', Math.round(livenessScore));
|
|
fd.append('capture_time', new Date().toISOString());
|
|
fd.append('device_info', navigator.userAgent);
|
|
fd.append('screen_res', screen.width + 'x' + screen.height);
|
|
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('POST', 'save_foto.php', true);
|
|
xhr.onload = function() {
|
|
try {
|
|
var d = JSON.parse(xhr.responseText);
|
|
if (d.status === 'success') {
|
|
sessionSaved = true;
|
|
clearInterval(tokenCheckInterval);
|
|
showPage(pgSuccess);
|
|
if (d.foto) {
|
|
document.getElementById('sucFoto').src = d.foto;
|
|
document.getElementById('sucFoto').style.display = 'block';
|
|
}
|
|
// Auto-close countdown
|
|
var count = 5;
|
|
var cdEl = document.getElementById('sucCountdown');
|
|
if (cdEl) cdEl.innerText = "This session will automatically close in " + count + " seconds...";
|
|
var cdInterval = setInterval(function() {
|
|
count--;
|
|
if (cdEl) cdEl.innerText = "This session will automatically close in " + count + " seconds...";
|
|
if (count <= 0) {
|
|
clearInterval(cdInterval);
|
|
window.close();
|
|
}
|
|
}, 1000);
|
|
} else {
|
|
showToast('err', d.message || 'Failed to save');
|
|
btnSave.disabled = false;
|
|
btnSave.querySelector('.ring').innerHTML = '<i class="mdi mdi-check-bold"></i>';
|
|
}
|
|
} catch(e) {
|
|
showToast('err', 'Invalid response');
|
|
btnSave.disabled = false;
|
|
btnSave.querySelector('.ring').innerHTML = '<i class="mdi mdi-check-bold"></i>';
|
|
}
|
|
};
|
|
xhr.onerror = function() {
|
|
showToast('err', 'Connection failed');
|
|
btnSave.disabled = false;
|
|
btnSave.querySelector('.ring').innerHTML = '<i class="mdi mdi-check-bold"></i>';
|
|
};
|
|
xhr.send(fd);
|
|
}
|
|
|
|
// ── Events ──
|
|
if (btnShutter) btnShutter.addEventListener('click', capture);
|
|
if (btnRetake) btnRetake.addEventListener('click', retake);
|
|
if (btnSave) btnSave.addEventListener('click', save);
|
|
|
|
// ── Init ──
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', validateToken);
|
|
} else {
|
|
validateToken();
|
|
}
|
|
|
|
// ── PWA Service Worker ──
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('sw_foto.js').catch(function() {});
|
|
}
|
|
|
|
// ── Smooth instruction fade ──
|
|
var ip = document.getElementById('instrPanel');
|
|
if (ip) ip.style.transition = 'opacity 0.5s ease';
|
|
})();
|
|
</script>
|
|
<?php endif; ?>
|
|
|
|
</body>
|
|
</html>
|
|
|
|
|