// checkin.jsx — Gamification check-in: QR scanner, celebrate, leaderboard, badges. Global.
function parseCheckinToken(text) {
if (!text) return null;
const m = text.match(/checkin\/([0-9a-fA-F-]{36})/) ;
if (m) return m[1];
const u = text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
return u ? u[0] : null;
}
function getGeo(timeout = 6000) {
return new Promise((resolve) => {
if (!navigator.geolocation) return resolve(null);
navigator.geolocation.getCurrentPosition(
p => resolve({ lat: p.coords.latitude, lng: p.coords.longitude }),
() => resolve(null),
{ enableHighAccuracy: true, timeout: timeout, maximumAge: 30000 }
);
});
}
function loadJsQR() {
return new Promise((resolve) => {
if (window.jsQR) return resolve(window.jsQR);
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js';
s.onload = () => resolve(window.jsQR || null);
s.onerror = () => resolve(null);
document.body.appendChild(s);
});
}
// ---------- QR scanner (camera) ----------
function CheckinScanner({ onClose, onToken }) {
const videoRef = React.useRef(null);
const canvasRef = React.useRef(null);
const streamRef = React.useRef(null);
const rafRef = React.useRef(0);
const doneRef = React.useRef(false);
const [err, setErr] = React.useState('');
React.useEffect(() => {
let detector = null;
let jsqr = null;
let cancelled = false;
async function start() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false });
if (cancelled) { stream.getTracks().forEach(t => t.stop()); return; }
streamRef.current = stream;
const v = videoRef.current;
if (!v) return;
v.srcObject = stream; v.setAttribute('playsinline', 'true'); v.muted = true;
await v.play().catch(() => {});
if ('BarcodeDetector' in window) {
try { detector = new window.BarcodeDetector({ formats: ['qr_code'] }); } catch (e) { detector = null; }
}
if (!detector) jsqr = await loadJsQR();
scan();
} catch (e) {
setErr('Tidak bisa akses kamera. Izinkan kamera atau tempel link QR di aplikasi kamera HP.');
}
}
async function scan() {
if (cancelled || doneRef.current) return;
const v = videoRef.current;
if (v && v.readyState === 4) {
try {
if (detector) {
const codes = await detector.detect(v);
if (codes && codes.length) return hit(codes[0].rawValue);
} else if (jsqr) {
const cv = canvasRef.current;
cv.width = v.videoWidth; cv.height = v.videoHeight;
const ctx = cv.getContext('2d');
ctx.drawImage(v, 0, 0, cv.width, cv.height);
const img = ctx.getImageData(0, 0, cv.width, cv.height);
const res = jsqr(img.data, cv.width, cv.height, { inversionAttempts: 'dontInvert' });
if (res && res.data) return hit(res.data);
}
} catch (e) {}
}
rafRef.current = requestAnimationFrame(scan);
}
function hit(raw) {
const tok = parseCheckinToken(raw);
if (!tok) { rafRef.current = requestAnimationFrame(scan); return; }
doneRef.current = true;
stop();
onToken(tok);
}
function stop() {
cancelAnimationFrame(rafRef.current);
if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null; }
}
start();
return () => { cancelled = true; stop(); };
}, []);
return (
{/* viewfinder */}
{[['top', 'left'], ['top', 'right'], ['bottom', 'left'], ['bottom', 'right']].map(([v, h], i) => (
))}
Scan QR Check-in
{err
?
{err}
:
Arahkan kamera ke QR tempat untuk check-in & kumpulkan poin
}
);
}
// ---------- confetti ----------
function Confetti({ n = 44 }) {
const cols = ['#1B4FD8', '#FF5A5F', '#22C55E', '#F59E0B', '#7C3AED', '#0EA5E9', '#EC4899'];
const bits = React.useMemo(() => Array.from({ length: n }, (_, i) => ({
left: Math.random() * 100, delay: Math.random() * 0.5, dur: 1.6 + Math.random() * 1.4,
col: cols[i % cols.length], size: 6 + Math.random() * 7, round: Math.random() > 0.5,
})), [n]);
return (
{bits.map((b, i) => (
))}
);
}
// ---------- celebrate / result ----------
function CheckinResult({ state, result, error, placeName, onClose, onViewPlace, onRetry, onRewards }) {
const ok = state === 'success';
const pts = result ? (result.points_awarded || 0) : 0;
const badges = (result && result.new_badges) || [];
const notes = (result && result.bonus_notes) || [];
const streak = result ? (result.streak || 0) : 0;
return (
{ok &&
}
{state === 'processing' ? (
Memproses check-in...
) : ok ? (
Check-in berhasil!
{result.place_name}
+
poin
{notes.length > 0 && (
{notes.map((nt, i) => {nt})}
)}
{streak >= 2 && 🔥 Streak {streak} hari!
}
{badges.length > 0 && (
🎉 BADGE BARU
{badges.map((b, i) => (
{b.icon || '🏅'}
{b.name}
{b.descr}
))}
)}
{onRewards && (
)}
) : (
{placeName || 'Check-in belum berhasil'}
{error || 'Terjadi kesalahan. Coba lagi.'}
)}
{state !== 'processing' && (
{ok && result.place_id && onViewPlace && (
)}
{!ok && onRetry && }
)}
);
}
// ---------- check-in flow controller ----------
function CheckinFlow({ initialToken, authed, onRequireAuth, onClose, onDone, onViewPlace, onRewards }) {
const [state, setState] = React.useState(initialToken ? 'processing' : 'scan');
const [result, setResult] = React.useState(null);
const [error, setError] = React.useState('');
const [placeName, setPlaceName] = React.useState('');
const process = React.useCallback(async (token) => {
setState('processing'); setError('');
if (!authed) { onRequireAuth && onRequireAuth(); onClose(); return; }
const geo = await getGeo();
const res = await gsData.doCheckin(token, geo ? geo.lat : null, geo ? geo.lng : null);
if (res.notReady) { setError('Fitur check-in segera aktif. Coba lagi sebentar lagi ya.'); setState('error'); return; }
if (res.error) { setError(res.error); setPlaceName(res.place_name || ''); setState('error'); return; }
setResult(res.data); setState('success');
if (onDone) onDone(res.data);
}, [authed]);
React.useEffect(() => { if (initialToken) process(initialToken); }, []);
if (state === 'scan') return ;
return { setResult(null); setState('scan'); }} />;
}
// ---------- leaderboard ----------
function LeaderboardScreen({ profile, onClose }) {
const [scope, setScope] = React.useState('week');
const [rows, setRows] = React.useState(null);
const [notReady, setNotReady] = React.useState(false);
React.useEffect(() => {
setRows(null);
gsData.loadLeaderboard(scope).then(r => { if (r.notReady) { setNotReady(true); setRows([]); } else setRows(r.rows); });
}, [scope]);
const myId = profile && profile.id;
const medal = ['🥇', '🥈', '🥉'];
return (
Top Warga
🏆
{[['week', 'Minggu ini'], ['all', 'Semua waktu']].map(([k, l]) => (
))}
{rows === null ? (
{[0, 1, 2, 3, 4].map(i => )}
) : notReady ? (
) : rows.length === 0 ? (
) : (
{rows.map((r, i) => {
const me = myId && r.user_id === myId;
return (
{i < 3 ? medal[i] : (i + 1)}
{r.avatar_url ?
: (r.name || 'W')[0].toUpperCase()}
{r.name}{me ? ' (kamu)' : ''}
{r.checkins} check-in
{r.points}
);
})}
)}
);
}
// ---------- badges grid (profile tab) ----------
function BadgesGrid() {
const [data, setData] = React.useState(null);
const [notReady, setNotReady] = React.useState(false);
React.useEffect(() => { gsData.loadBadges().then(d => { if (d.notReady) { setNotReady(true); setData({ badges: [], earned: {} }); } else setData(d); }); }, []);
if (notReady) return
;
if (data === null) return {[0, 1, 2, 3].map(i => )}
;
const earnedCount = Object.keys(data.earned).length;
return (
{earnedCount} dari {data.badges.length} badge terkumpul
{data.badges.map(b => {
const got = !!data.earned[b.code];
return (
{b.icon || '🏅'}
{b.name}
{b.descr}
{got
?
Didapat
:
🔒 Terkunci
}
);
})}
);
}
Object.assign(window, { CheckinFlow, CheckinScanner, CheckinResult, LeaderboardScreen, BadgesGrid, Confetti, parseCheckinToken, getGeo });