Files
uas-poverty-mapping/src/pages/AuthPage.jsx
T
2026-06-05 15:09:45 +07:00

512 lines
21 KiB
React

import { useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import './AuthPage.css'
import Toast from '../components/Toast'
import { useToast } from '../hooks/useToast'
import { apiCall } from '../utils/api'
// ── PASSWORD STRENGTH ─────────────────────────────────────────────────────
function PasswordStrength({ value }) {
if (!value) return null
let score = 0
if (value.length >= 8) score++
if (/[A-Z]/.test(value)) score++
if (/[0-9]/.test(value)) score++
if (/[^A-Za-z0-9]/.test(value)) score++
const colors = ['#e06060', '#e0a060', '#d4b800', '#1c8a5f']
const labels = ['Sangat Lemah', 'Lemah', 'Sedang', 'Kuat']
const color = score > 0 ? colors[score - 1] : 'var(--txt-3)'
return (
<div style={{ marginTop: 8 }}>
<div className="strength-segs">
{[0,1,2,3].map(i => (
<span key={i} style={{ background: i < score ? color : 'rgba(120,120,128,0.12)' }} />
))}
</div>
{score > 0 && <div className="strength-label" style={{ color }}>{labels[score - 1]}</div>}
</div>
)
}
// ── EYE BUTTON ────────────────────────────────────────────────────────────
function EyeButton({ show, onToggle }) {
return (
<button className="input-eye" type="button" onClick={onToggle}>
{show ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"/>
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
)}
</button>
)
}
function BackArrow() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
)
}
// ── LANDING ───────────────────────────────────────────────────────────────
function LandingScreen({ active, onEnter }) {
return (
<div className={`auth-screen${active ? ' active' : ''}`} style={{ zIndex: 2 }}>
<div className="land-screen-bg">
<div className="land-bg-dots" />
<svg className="land-lines-svg" viewBox="0 0 1200 800" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<path d="M0,200 Q150,150 300,190 T600,170 Q750,150 900,195 T1200,180" fill="none" stroke="rgba(28,138,95,0.07)" strokeWidth="1.5"/>
<path d="M0,380 Q200,320 400,370 T800,340 Q1000,310 1200,355" fill="none" stroke="rgba(28,138,95,0.05)" strokeWidth="1"/>
<path d="M0,560 Q180,500 360,545 T720,510 Q900,480 1200,530" fill="none" stroke="rgba(200,75,15,0.04)" strokeWidth="0.8"/>
<circle cx="290" cy="195" r="2.5" fill="rgba(28,138,95,0.18)"/>
<circle cx="600" cy="172" r="3.5" fill="rgba(28,138,95,0.14)"/>
<circle cx="870" cy="200" r="2.5" fill="rgba(28,138,95,0.18)"/>
<circle cx="155" cy="370" r="2" fill="rgba(28,138,95,0.12)"/>
<circle cx="460" cy="345" r="3" fill="rgba(28,138,95,0.14)"/>
<circle cx="740" cy="308" r="2.5" fill="rgba(200,75,15,0.16)"/>
<circle cx="1020" cy="350" r="2" fill="rgba(28,138,95,0.12)"/>
</svg>
</div>
<div className="land-card">
<div className="land-badge"><span className="badge-dot" />Sistem Informasi Aktif</div>
<div className="land-logo">SIP<em>KEM</em></div>
<div className="land-subtitle">Sistem Peta Kemiskinan</div>
<div className="land-rule" />
<p className="land-desc">
Platform pemetaan terpadu untuk <strong>rumah ibadah</strong> dan{' '}
<strong>rumah miskin</strong> di wilayah Pontianak mendukung pendataan,
monitoring, dan pembinaan sosial berbasis lokasi.
</p>
<button className="btn-enter" onClick={onEnter}>
Masuk ke Sistem
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
<div className="land-features">
{[['🗺️','Peta Interaktif'],['🕌','Rumah Ibadah'],['🏠','Rumah Miskin'],['📊','Statistik']].map(([icon, label]) => (
<div key={label} className="feat-chip"><span>{icon}</span> {label}</div>
))}
</div>
</div>
</div>
)
}
// ── ROLE SELECTION ────────────────────────────────────────────────────────
function RoleScreen({ active, selectedRole, onSelect, onBack, onNext }) {
return (
<div className={`auth-screen${active ? ' active' : ''}`}>
<div className="auth-card">
<button className="card-back" onClick={onBack}><BackArrow /> Kembali</button>
<div className="step-dots">
<div className="step-dot active" />
<div className="step-dot" />
</div>
<div className="card-eyebrow">Langkah 1 dari 2</div>
<div className="card-title">Pilih Peran Anda</div>
<p className="card-sub">Akses sistem disesuaikan berdasarkan peran yang Anda pilih.</p>
<div className="role-grid">
{[
{
key: 'pengguna',
label: 'Pengguna',
desc: 'Melihat peta & data yang tersedia',
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1c8a5f" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
</svg>
),
},
{
key: 'pengurus',
label: 'Pengurus',
desc: 'Kelola & edit seluruh data sistem',
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#c84b0f" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L4 5v6c0 5.5 3.5 10.7 8 12 4.5-1.3 8-6.5 8-12V5l-8-3z"/>
<path d="M9 12l2 2 4-4"/>
</svg>
),
},
].map(({ key, label, desc, icon }) => (
<div
key={key}
className={`role-card ${key}${selectedRole === key ? ' selected' : ''}`}
onClick={() => onSelect(key)}
>
<div className="check-badge">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5"/>
</svg>
</div>
<div className="role-icon">{icon}</div>
<div className="role-name">{label}</div>
<div className="role-desc">{desc}</div>
</div>
))}
</div>
<button
className={`btn-primary ${selectedRole === 'pengurus' ? 'earth' : 'green'}`}
disabled={!selectedRole}
onClick={onNext}
>
<span className="btn-text">Lanjutkan</span>
<span className="spinner" />
</button>
</div>
</div>
)
}
// ── LOGIN ─────────────────────────────────────────────────────────────────
function LoginScreen({ active, role, onBack, onSuccess, showToast, onGoRegister }) {
const [email, setEmail] = useState('')
const [pass, setPass] = useState('')
const [showPass, setShowPass] = useState(false)
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState({})
const isValidEmail = v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
function validate() {
const errs = {}
if (!isValidEmail(email)) errs.email = 'Format email tidak valid.'
if (pass.length < 8) errs.pass = 'Kata sandi minimal 8 karakter.'
return errs
}
async function handleLogin() {
const errs = validate()
if (Object.keys(errs).length) { setErrors(errs); return }
setErrors({})
setLoading(true)
const res = await apiCall('login', { email, password: pass, role })
setLoading(false)
if (!res.ok) {
// Map error to the right field
const msg = res.error || 'Login gagal.'
if (msg.toLowerCase().includes('email') || msg.toLowerCase().includes('kata sandi') || msg.toLowerCase().includes('salah')) {
setErrors({ api: msg })
} else if (msg.toLowerCase().includes('pengurus') || msg.toLowerCase().includes('pengguna') || msg.toLowerCase().includes('akun')) {
setErrors({ role: msg })
} else {
setErrors({ api: msg })
}
return
}
onSuccess(res.user.role, res.user)
}
// Pengurus: no register link shown
const isPengurus = role === 'pengurus'
return (
<div className={`auth-screen${active ? ' active' : ''}`}>
<div className="auth-card">
<button className="card-back" onClick={onBack}><BackArrow /> Ganti Peran</button>
<div className="step-dots">
<div className="step-dot done" />
<div className="step-dot active" />
</div>
<div className={`role-pill ${role}`}>
{isPengurus ? (
<>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L4 5v6c0 5.5 3.5 10.7 8 12 4.5-1.3 8-6.5 8-12V5l-8-3z"/>
<path d="M9 12l2 2 4-4"/>
</svg>
Pengurus
</>
) : (
<>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
</svg>
Pengguna
</>
)}
</div>
<div className="card-eyebrow" style={{ marginTop: 8 }}>Masuk Akun</div>
<div className="card-title">Selamat Datang</div>
<p className="card-sub">
{isPengurus
? 'Masukkan kredensial akun Pengurus Anda.'
: 'Masukkan email dan kata sandi Anda untuk melanjutkan.'}
</p>
{/* Role mismatch error banner */}
{errors.role && (
<div className="error-banner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
{errors.role}
</div>
)}
<div className="form-group">
<label className="form-label">Alamat Email</label>
<input
className={`form-input${errors.email ? ' err' : ''}`}
type="email"
placeholder="nama@email.com"
value={email}
onChange={e => { setEmail(e.target.value); setErrors(p => ({ ...p, email: '', api: '' })) }}
onKeyDown={e => e.key === 'Enter' && handleLogin()}
autoComplete="email"
/>
{errors.email && <div className="form-error show">{errors.email}</div>}
</div>
<div className="form-group">
<label className="form-label">Kata Sandi</label>
<div className="input-wrap">
<input
className={`form-input${errors.pass || errors.api ? ' err' : ''}`}
type={showPass ? 'text' : 'password'}
placeholder="Min. 8 karakter"
value={pass}
onChange={e => { setPass(e.target.value); setErrors(p => ({ ...p, pass: '', api: '' })) }}
onKeyDown={e => e.key === 'Enter' && handleLogin()}
autoComplete="current-password"
/>
<EyeButton show={showPass} onToggle={() => setShowPass(s => !s)} />
</div>
{errors.pass && <div className="form-error show">{errors.pass}</div>}
{errors.api && <div className="form-error show">{errors.api}</div>}
</div>
<button
className={`btn-primary ${isPengurus ? 'earth' : 'green'}${loading ? ' loading' : ''}`}
onClick={handleLogin}
disabled={loading}
>
<span className="btn-text">Masuk</span>
<span className="spinner" />
</button>
{/* Pengurus: no register link */}
{!isPengurus && (
<div className="bottom-link">
Belum punya akun?
<button className="link-text" style={{ marginLeft: 4 }} onClick={onGoRegister}>
Daftar di sini
</button>
</div>
)}
{isPengurus && (
<div className="pengurus-note">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
Akun Pengurus hanya dapat dibuat oleh administrator sistem.
</div>
)}
</div>
</div>
)
}
// ── REGISTER (Pengguna only) ──────────────────────────────────────────────
function RegisterScreen({ active, onBack, showToast }) {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [pass, setPass] = useState('')
const [pass2, setPass2] = useState('')
const [showP, setShowP] = useState(false)
const [showP2, setShowP2] = useState(false)
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState({})
const isValidEmail = v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
function validate() {
const errs = {}
if (!name.trim()) errs.name = 'Nama tidak boleh kosong.'
if (!isValidEmail(email)) errs.email = 'Format email tidak valid.'
if (pass.length < 8) errs.pass = 'Kata sandi minimal 8 karakter.'
if (pass !== pass2 || !pass2) errs.pass2 = 'Kata sandi tidak cocok.'
return errs
}
async function handleRegister() {
const errs = validate()
if (Object.keys(errs).length) { setErrors(errs); return }
setErrors({})
setLoading(true)
const res = await apiCall('register', { nama: name.trim(), email, password: pass })
setLoading(false)
if (!res.ok) {
const msg = res.error || 'Pendaftaran gagal.'
if (msg.toLowerCase().includes('email')) {
setErrors({ email: msg })
} else {
setErrors({ api: msg })
}
return
}
showToast('✓ Akun berhasil dibuat — silakan masuk')
// Reset form
setName(''); setEmail(''); setPass(''); setPass2('')
setTimeout(() => onBack(), 1200)
}
return (
<div className={`auth-screen${active ? ' active' : ''}`}>
<div className="auth-card">
<button className="card-back" onClick={onBack}><BackArrow /> Kembali</button>
<div className="card-eyebrow">Daftar Akun</div>
<div className="card-title">Buat Akun Baru</div>
<p className="card-sub">Isi data di bawah untuk membuat akun Pengguna SIPKEM.</p>
{errors.api && (
<div className="error-banner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
{errors.api}
</div>
)}
<div className="form-group">
<label className="form-label">Nama Lengkap</label>
<input
className={`form-input${errors.name ? ' err' : ''}`}
type="text" placeholder="Nama Anda"
value={name}
onChange={e => { setName(e.target.value); setErrors(p => ({ ...p, name: '' })) }}
autoComplete="name"
/>
{errors.name && <div className="form-error show">{errors.name}</div>}
</div>
<div className="form-group">
<label className="form-label">Alamat Email</label>
<input
className={`form-input${errors.email ? ' err' : ''}`}
type="email" placeholder="nama@email.com"
value={email}
onChange={e => { setEmail(e.target.value); setErrors(p => ({ ...p, email: '' })) }}
autoComplete="email"
/>
{errors.email && <div className="form-error show">{errors.email}</div>}
</div>
<div className="form-group">
<label className="form-label">Kata Sandi</label>
<div className="input-wrap">
<input
className={`form-input${errors.pass ? ' err' : ''}`}
type={showP ? 'text' : 'password'} placeholder="Min. 8 karakter"
value={pass}
onChange={e => { setPass(e.target.value); setErrors(p => ({ ...p, pass: '' })) }}
autoComplete="new-password"
/>
<EyeButton show={showP} onToggle={() => setShowP(s => !s)} />
</div>
<PasswordStrength value={pass} />
{errors.pass && <div className="form-error show">{errors.pass}</div>}
</div>
<div className="form-group">
<label className="form-label">Ulangi Kata Sandi</label>
<div className="input-wrap">
<input
className={`form-input${errors.pass2 ? ' err' : ''}`}
type={showP2 ? 'text' : 'password'} placeholder="Ulangi kata sandi"
value={pass2}
onChange={e => { setPass2(e.target.value); setErrors(p => ({ ...p, pass2: '' })) }}
onKeyDown={e => e.key === 'Enter' && handleRegister()}
autoComplete="new-password"
/>
<EyeButton show={showP2} onToggle={() => setShowP2(s => !s)} />
</div>
{errors.pass2 && <div className="form-error show">{errors.pass2}</div>}
</div>
<button
className={`btn-primary green${loading ? ' loading' : ''}`}
onClick={handleRegister}
disabled={loading}
>
<span className="btn-text">Buat Akun</span>
<span className="spinner" />
</button>
<div className="bottom-link" style={{ marginTop: 16 }}>
Sudah punya akun?
<button className="link-text" style={{ marginLeft: 4 }} onClick={onBack}>
Masuk di sini
</button>
</div>
</div>
</div>
)
}
// ── MAIN ──────────────────────────────────────────────────────────────────
export default function AuthPage() {
const navigate = useNavigate()
const [screen, setScreen] = useState('landing')
const [role, setRole] = useState(null)
const { message, visible, showToast } = useToast()
const goTo = useCallback((s) => setScreen(s), [])
return (
<div className="auth-root">
{screen !== 'landing' && <div className="auth-bg-layer" />}
<LandingScreen active={screen === 'landing'} onEnter={() => goTo('role')} />
<RoleScreen
active={screen === 'role'}
selectedRole={role}
onSelect={setRole}
onBack={() => goTo('landing')}
onNext={() => { if (role) goTo('login') }}
/>
<LoginScreen
active={screen === 'login'}
role={role || 'pengguna'}
onBack={() => goTo('role')}
onSuccess={(r, user) => navigate('/map', { state: { role: r, user } })}
showToast={showToast}
onGoRegister={() => goTo('register')}
/>
{/* Register only accessible if role is pengguna (or role not yet set) */}
{role !== 'pengurus' && (
<RegisterScreen
active={screen === 'register'}
onBack={() => goTo('login')}
showToast={showToast}
/>
)}
<Toast message={message} visible={visible} />
</div>
)
}