512 lines
21 KiB
React
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>
|
|
)
|
|
}
|