/* iLPF v2 — Login + MFA variants Three side-by-side designs sharing the same step machine. Step 1: ID + kata laluan Step 2: SMS OTP (6 or 8 digit) — switches to backup-code sub-mode Step 3: Disahkan · redirecting */ const { useState, useEffect, useRef, useCallback } = React; /* ---------- icons (inline SVG) -------------------------------------- */ const Ic = { shield: (p) => ( ), phone: (p) => ( ), eye: (p) => ( ), eyeOff: (p) => ( ), check: (p) => ( ), arrowR: (p) => ( ), arrowL: (p) => ( ), key: (p) => ( ), alert: (p) => ( ), }; /* ---------- shared step machine ------------------------------------- */ function useLoginFlow(otpLen, onSubmitCreds, onSubmitOtp) { const [step, setStep] = useState(1); const [mode, setMode] = useState("sms"); // 'sms' | 'backup' const [userId, setUserId] = useState(""); const [password, setPassword] = useState(""); const [showPass, setShowPass] = useState(false); const [credBusy, setCredBusy] = useState(false); const [credErr, setCredErr] = useState(""); const otpRef = useRef(Array(otpLen).fill("")); const [otp, setOtpState] = useState(() => Array(otpLen).fill("")); const setOtp = useCallback((v) => { otpRef.current = v; setOtpState(v); }, []); const [otpBusy, setOtpBusy] = useState(false); const [otpErr, setOtpErr] = useState(""); const [resendIn, setResendIn] = useState(32); const [backup, setBackup] = useState(["", "", "", ""]); const [backupBusy, setBackupBusy] = useState(false); const [backupErr, setBackupErr] = useState(""); // Reset OTP array when length changes useEffect(() => { const fresh = Array(otpLen).fill(""); otpRef.current = fresh; setOtpState(fresh); }, [otpLen]); // Resend countdown useEffect(() => { if (step !== 2 || mode !== "sms") return; if (resendIn <= 0) return; const t = setTimeout(() => setResendIn((s) => s - 1), 1000); return () => clearTimeout(t); }, [step, mode, resendIn]); const submitCreds = useCallback( (e) => { e && e.preventDefault(); setCredErr(""); setCredBusy(true); if (onSubmitCreds) { onSubmitCreds({ userId, password }, ({ ok, error }) => { setCredBusy(false); if (ok) { setStep(2); setMode("sms"); setResendIn(32); } else setCredErr(error || "E-mel atau kata laluan tidak sah."); }); } else { setTimeout(() => { setCredBusy(false); setStep(2); setMode("sms"); setResendIn(32); }, 700); } }, [userId, password, onSubmitCreds] ); const submitOtp = useCallback(() => { setOtpBusy(true); setOtpErr(""); if (onSubmitOtp) { onSubmitOtp({ code: otpRef.current.join("") }, ({ ok, error, redirect }) => { setOtpBusy(false); if (ok) { setStep(3); if (redirect) setTimeout(() => { window.location.href = redirect; }, 1200); } else { setOtpErr(error || "Kod OTP tidak sah."); } }); } else { setTimeout(() => { setOtpBusy(false); setStep(3); }, 900); } }, [onSubmitOtp]); const submitBackup = useCallback(() => { setBackupBusy(true); setBackupErr(""); setTimeout(() => { setBackupBusy(false); setStep(3); }, 900); }, []); const resend = () => { setResendIn(32); setOtp(Array(otpLen).fill("")); setOtpErr(""); }; const restart = () => { setStep(1); setMode("sms"); setOtp(Array(otpLen).fill("")); setBackup(["", "", "", ""]); setOtpErr(""); setBackupErr(""); setCredErr(""); setResendIn(32); }; return { step, setStep, mode, setMode, userId, setUserId, password, setPassword, showPass, setShowPass, credBusy, credErr, submitCreds, otp, setOtp, otpBusy, otpErr, submitOtp, resendIn, resend, backup, setBackup, backupBusy, backupErr, submitBackup, restart, }; } /* ---------- OTP grid component -------------------------------------- */ function OtpGrid({ value, onChange, onComplete, dark, errored, len }) { const refs = useRef([]); const set = (i, v) => { const next = [...value]; next[i] = v; onChange(next); if (v && i < len - 1) refs.current[i + 1]?.focus(); if (next.every((d) => d !== "") && next.join("").length === len) { onComplete && onComplete(next.join("")); } }; const handleKey = (i, e) => { if (e.key === "Backspace" && !value[i] && i > 0) { refs.current[i - 1]?.focus(); } else if (e.key === "ArrowLeft" && i > 0) { refs.current[i - 1]?.focus(); } else if (e.key === "ArrowRight" && i < len - 1) { refs.current[i + 1]?.focus(); } }; const handlePaste = (e) => { const txt = (e.clipboardData.getData("text") || "") .replace(/\D/g, "") .slice(0, len); if (!txt) return; e.preventDefault(); const next = Array(len).fill(""); for (let i = 0; i < txt.length; i++) next[i] = txt[i]; onChange(next); const focusAt = Math.min(txt.length, len - 1); refs.current[focusAt]?.focus(); if (txt.length === len) onComplete && onComplete(txt); }; return (
{Array.from({ length: len }).map((_, i) => ( (refs.current[i] = el)} type="text" inputMode="numeric" maxLength={1} className={`otp__cell ${dark ? "otp__cell--dark" : ""} ${value[i] ? "otp__cell--filled" : ""} ${errored ? "otp__cell--err" : ""}`} value={value[i]} onChange={(e) => set(i, e.target.value.replace(/\D/g, "").slice(-1))} onKeyDown={(e) => handleKey(i, e)} onPaste={handlePaste} autoFocus={i === 0} /> ))}
); } /* ---------- Backup-code 4×4 grid ------------------------------------ */ function BackupGrid({ value, onChange, dark }) { const refs = useRef([]); const set = (gi, v) => { const cleaned = v.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 4); const next = [...value]; next[gi] = cleaned; onChange(next); if (cleaned.length === 4 && gi < 3) refs.current[gi + 1]?.focus(); }; const handlePaste = (e) => { const txt = (e.clipboardData.getData("text") || "") .toUpperCase() .replace(/[^A-Z0-9]/g, "") .slice(0, 16); if (!txt) return; e.preventDefault(); const next = ["", "", "", ""]; for (let i = 0; i < 4; i++) next[i] = txt.slice(i * 4, i * 4 + 4); onChange(next); const lastIdx = Math.min(3, Math.floor((txt.length - 1) / 4)); refs.current[lastIdx]?.focus(); }; return (
{[0, 1, 2, 3].map((gi) => ( (refs.current[gi] = el)} type="text" className="backup__cell" value={value[gi]} onChange={(e) => set(gi, e.target.value)} onPaste={handlePaste} placeholder="····" autoFocus={gi === 0} /> {gi < 3 && } ))}
); } /* ---------- Resend pill --------------------------------------------- */ function ResendPill({ resendIn, onResend, dark }) { return (
Tidak menerima SMS? {resendIn > 0 ? ( Hantar semula dalam 0:{String(resendIn).padStart(2, "0")} ) : ( )}
); } /* ==================================================================== VARIANT A — Centered card (classic, calm) ==================================================================== */ function LoginA({ otpLen, phone, onSubmitCreds }) { const f = useLoginFlow(otpLen, onSubmitCreds); return (
Portal Rasmi PPF · KDN
BM / EN · OKU · Hubungi
iLPF
Ruang ALPF
{f.step === 1 && (

Log masuk.

Pengesahan dua langkah aktif untuk semua ALPF.

f.setUserId(e.target.value)} autoFocus />
f.setPassword(e.target.value)} />
Disahkan SMS pada langkah seterusnya Lupa kata laluan?
{f.credErr && (
{f.credErr}
)}
)} {f.step === 2 && (
Kata laluan 2 SMS OTP

Sahkan identiti.

{f.mode === "sms" ? `Kod ${otpLen}-digit dihantar ke nombor berdaftar anda.` : "Masukkan kod sandaran 16-aksara dari pendaftaran anda."}

{f.mode === "sms" ? ( <>
Dihantar ke {phone}
{f.otpErr && (
{f.otpErr}
)} ) : ( <> {f.backupErr && (
{f.backupErr}
)} )}
)} {f.step === 3 && (

Disahkan.

Selamat datang kembali. Membuka ruang kerja anda.

Mengalihkan ke /app/utama
)}
© KDN · PPF 2026 v2.0.0-alpf
); } /* ==================================================================== VARIANT B — Split editorial ==================================================================== */ function LoginB({ otpLen, phone, onSubmitCreds, onSubmitOtp }) { const f = useLoginFlow(otpLen, onSubmitCreds, onSubmitOtp); return (
iLPF
RUANG ALPF · v2

Dibina untuk memutuskan. Direka untuk menyampaikan.

Ruang kerja Ahli Lembaga Penapisan Filem. Tenang. Padat. Decisif. Setiap tugasan, satu klik daripada selesai.

Diluluskan.
Dihantar.
Selesai.
Pejabat Penapisan Filem · KDN Sesi disulitkan TLS 1.3
{f.step === 1 && (
Langkah 1 daripada 2

Log masuk.

Masukkan butiran rasmi anda untuk meneruskan.

f.setUserId(e.target.value)} autoFocus />
f.setPassword(e.target.value)} />
SMS OTP seterusnya Lupa kata laluan?
{f.credErr &&
{f.credErr}
}
)} {f.step === 2 && (
Langkah 2 daripada 2 · MFA

Sahkan dengan SMS.

{f.mode === "sms" ? `Kami menghantar kod ${otpLen}-digit ke nombor berdaftar anda. Kod sah selama 5 minit.` : "Masukkan kod sandaran 16-aksara dari pendaftaran anda."}

{f.mode === "sms" ? ( <>
Dihantar ke {phone}
{f.otpErr &&
{f.otpErr}
} ) : ( <> {f.backupErr &&
{f.backupErr}
} )}
)} {f.step === 3 && (

Disahkan.

Selamat kembali. Membuka ruang kerja anda.

Masuk ruang kerja Mengalihkan ke /app/utama
)}
); } /* ==================================================================== VARIANT C — Pine terminal (dark, senior-analyst feel) ==================================================================== */ function LoginC({ otpLen, phone, onSubmitCreds }) { const f = useLoginFlow(otpLen, onSubmitCreds); const stamp = new Date().toISOString().slice(0, 19).replace("T", " "); return (
PORTAL.RASMI / PPF · KDN SESI · TLS 1.3 · MY-WEST-1
AUTH · SECURE
iLPF
{stamp} UTC
{f.step === 1 && (
1 Kata laluan 2 SMS OTP

Log masuk.

Ruang kerja ALPF. Akses terhad kepada anggota Lembaga Penapisan Filem yang dilantik.

f.setUserId(e.target.value)} autoFocus />
f.setPassword(e.target.value)} />
Pengesahan SMS aktif Lupa kata laluan?
{f.credErr &&
{f.credErr}
}
)} {f.step === 2 && (
Kata laluan 2 SMS OTP

Sahkan identiti.

{f.mode === "sms" ? `Kod ${otpLen}-digit dihantar. Sah 5 minit.` : "Kod sandaran 16-aksara. Sekali guna sahaja."}

{f.mode === "sms" ? ( <>
Dihantar ke {phone}
{f.otpErr &&
{f.otpErr}
} ) : ( <> {f.backupErr &&
{f.backupErr}
} )}
)} {f.step === 3 && (

Disahkan.

Sesi aktif. Membuka ruang kerja anda.

ROUTE /app/utama
)}
© KDN · PPF 2026 v2.0.0-alpf · sha 7b3c2f
); } Object.assign(window, { LoginA, LoginB, LoginC });