357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { IdCard, Image as ImageIcon, Landmark, Check } from "lucide-react";
|
|
import { submitVerificationAction } from "@/features/organizer/actions";
|
|
import { DateField } from "@/components/shared/date-picker";
|
|
|
|
type Initial = {
|
|
fullName: string;
|
|
nik: string;
|
|
birthDate: Date;
|
|
address: string;
|
|
ktpImageKey: string;
|
|
livenessKey: string;
|
|
bankName: string;
|
|
bankAccountNumber: string;
|
|
bankAccountName: string;
|
|
} | null;
|
|
|
|
type UploadKind = "ktp" | "liveness";
|
|
|
|
const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
|
|
const MAX_BYTES = 5 * 1024 * 1024;
|
|
|
|
export function VerifyForm({ initial }: { initial: Initial }) {
|
|
const router = useRouter();
|
|
const [error, setError] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? "");
|
|
const [livenessKey, setLivenessKey] = useState(initial?.livenessKey ?? "");
|
|
// `birthDate` dari DB tersimpan sebagai tengah malam UTC — baca pakai getter
|
|
// UTC supaya hari kalender yang tampil di picker tidak bergeser.
|
|
const [birthDate, setBirthDate] = useState<Date | null>(
|
|
initial
|
|
? new Date(
|
|
initial.birthDate.getUTCFullYear(),
|
|
initial.birthDate.getUTCMonth(),
|
|
initial.birthDate.getUTCDate()
|
|
)
|
|
: null
|
|
);
|
|
|
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
setError("");
|
|
if (!birthDate) {
|
|
setError("Tanggal lahir wajib diisi");
|
|
return;
|
|
}
|
|
if (!ktpKey || !livenessKey) {
|
|
setError("Foto KTP dan foto memegang kertas SETRIP wajib diunggah");
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
const formData = new FormData(e.currentTarget);
|
|
formData.set("ktpImageKey", ktpKey);
|
|
formData.set("livenessKey", livenessKey);
|
|
const result = await submitVerificationAction(formData);
|
|
setLoading(false);
|
|
if (result.error) {
|
|
setError(result.error);
|
|
return;
|
|
}
|
|
router.refresh();
|
|
}
|
|
|
|
const inputCls =
|
|
"w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 transition-colors placeholder:text-neutral-400 focus:bg-white";
|
|
|
|
return (
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="space-y-5 rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-8"
|
|
>
|
|
{error && (
|
|
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<section>
|
|
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
|
|
<IdCard
|
|
size={18}
|
|
strokeWidth={1.75}
|
|
aria-hidden
|
|
className="text-primary-600"
|
|
/>
|
|
Data KTP
|
|
</h2>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="sm:col-span-2">
|
|
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
|
Nama Lengkap (sesuai KTP)
|
|
</label>
|
|
<input
|
|
name="fullName"
|
|
type="text"
|
|
required
|
|
defaultValue={initial?.fullName ?? ""}
|
|
className={inputCls}
|
|
placeholder="Mis. Budi Santoso"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
|
NIK (16 digit)
|
|
</label>
|
|
<input
|
|
name="nik"
|
|
type="text"
|
|
inputMode="numeric"
|
|
pattern="\d{16}"
|
|
maxLength={16}
|
|
required
|
|
defaultValue={initial?.nik ?? ""}
|
|
className={inputCls}
|
|
placeholder="3201xxxxxxxxxxxx"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label
|
|
htmlFor="birthDate"
|
|
className="mb-1.5 block text-sm font-semibold text-neutral-700"
|
|
>
|
|
Tanggal Lahir
|
|
</label>
|
|
<DateField
|
|
id="birthDate"
|
|
name="birthDate"
|
|
value={birthDate}
|
|
onChange={setBirthDate}
|
|
maxDate={new Date()}
|
|
withMonthYearDropdown
|
|
required
|
|
placeholder="Pilih tanggal lahir"
|
|
/>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
|
Alamat (sesuai KTP)
|
|
</label>
|
|
<textarea
|
|
name="address"
|
|
required
|
|
rows={3}
|
|
defaultValue={initial?.address ?? ""}
|
|
className={inputCls}
|
|
placeholder="Jalan, RT/RW, kelurahan, kota, provinsi"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
|
|
<ImageIcon
|
|
size={18}
|
|
strokeWidth={1.75}
|
|
aria-hidden
|
|
className="text-primary-600"
|
|
/>
|
|
Foto
|
|
</h2>
|
|
<p className="mb-3 text-xs text-neutral-500">
|
|
Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh
|
|
tim admin saat review. Maks 5MB, JPG/PNG/WebP.
|
|
</p>
|
|
<div className="space-y-4">
|
|
<FileUpload
|
|
label="Foto KTP"
|
|
kind="ktp"
|
|
value={ktpKey}
|
|
onChange={setKtpKey}
|
|
onError={setError}
|
|
/>
|
|
<div>
|
|
<FileUpload
|
|
label="Foto kamu memegang kertas tulisan SETRIP"
|
|
kind="liveness"
|
|
value={livenessKey}
|
|
onChange={setLivenessKey}
|
|
onError={setError}
|
|
/>
|
|
<p className="mt-1.5 text-[11px] leading-relaxed text-neutral-500">
|
|
Tulis kata <span className="font-semibold">SETRIP</span> dengan
|
|
tangan di selembar kertas, lalu foto diri kamu sambil memegang
|
|
kertas itu — pastikan wajah & tulisan terlihat jelas dalam satu
|
|
foto. Foto ini bukti bahwa pengajuan benar dilakukan oleh kamu
|
|
sendiri.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
|
|
<Landmark
|
|
size={18}
|
|
strokeWidth={1.75}
|
|
aria-hidden
|
|
className="text-primary-600"
|
|
/>
|
|
Rekening Bank
|
|
</h2>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
|
Nama Bank
|
|
</label>
|
|
<input
|
|
name="bankName"
|
|
type="text"
|
|
required
|
|
defaultValue={initial?.bankName ?? ""}
|
|
className={inputCls}
|
|
placeholder="Mis. BCA, Mandiri, BRI"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
|
Nomor Rekening
|
|
</label>
|
|
<input
|
|
name="bankAccountNumber"
|
|
type="text"
|
|
inputMode="numeric"
|
|
required
|
|
defaultValue={initial?.bankAccountNumber ?? ""}
|
|
className={inputCls}
|
|
placeholder="1234567890"
|
|
/>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
|
Nama Pemilik Rekening
|
|
</label>
|
|
<input
|
|
name="bankAccountName"
|
|
type="text"
|
|
required
|
|
defaultValue={initial?.bankAccountName ?? ""}
|
|
className={inputCls}
|
|
placeholder="Sesuai buku tabungan"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="w-full rounded-xl bg-primary-600 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700 disabled:opacity-50"
|
|
>
|
|
{loading ? "Mengirim..." : "Ajukan Verifikasi"}
|
|
</button>
|
|
|
|
<p className="text-center text-xs text-neutral-500">
|
|
Data KTP & rekening hanya digunakan untuk verifikasi dan tidak akan
|
|
ditampilkan ke pengguna lain.
|
|
</p>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function FileUpload({
|
|
label,
|
|
kind,
|
|
value,
|
|
onChange,
|
|
onError,
|
|
}: {
|
|
label: string;
|
|
kind: UploadKind;
|
|
value: string;
|
|
onChange: (key: string) => void;
|
|
onError: (msg: string) => void;
|
|
}) {
|
|
const [busy, setBusy] = useState(false);
|
|
const [previewUrl, setPreviewUrl] = useState<string>("");
|
|
|
|
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
if (file.size > MAX_BYTES) {
|
|
onError(`${label} maksimal 5MB`);
|
|
e.target.value = "";
|
|
return;
|
|
}
|
|
if (!ACCEPT_MIME.split(",").includes(file.type)) {
|
|
onError(`${label} harus JPG, PNG, atau WebP`);
|
|
e.target.value = "";
|
|
return;
|
|
}
|
|
|
|
setBusy(true);
|
|
onError("");
|
|
try {
|
|
const fd = new FormData();
|
|
fd.set("kind", kind);
|
|
fd.set("file", file);
|
|
const res = await fetch("/api/upload/kyc", { method: "POST", body: fd });
|
|
const json = await res.json();
|
|
if (!res.ok) {
|
|
onError(json.error ?? `Gagal mengunggah ${label}`);
|
|
return;
|
|
}
|
|
onChange(json.key);
|
|
const obj = URL.createObjectURL(file);
|
|
setPreviewUrl((old) => {
|
|
if (old) URL.revokeObjectURL(old);
|
|
return obj;
|
|
});
|
|
} catch {
|
|
onError(`Gagal mengunggah ${label}`);
|
|
} finally {
|
|
setBusy(false);
|
|
e.target.value = "";
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
|
{label}
|
|
</label>
|
|
<div className="flex items-center gap-3">
|
|
<label className="inline-flex cursor-pointer items-center rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-100">
|
|
{busy ? "Mengunggah..." : value ? "Ganti file" : "Pilih file"}
|
|
<input
|
|
type="file"
|
|
accept={ACCEPT_MIME}
|
|
onChange={onPick}
|
|
disabled={busy}
|
|
className="sr-only"
|
|
/>
|
|
</label>
|
|
{value && !busy && (
|
|
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
|
|
<Check size={13} strokeWidth={2.5} aria-hidden />
|
|
Terunggah
|
|
</span>
|
|
)}
|
|
</div>
|
|
{previewUrl && (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={previewUrl}
|
|
alt={`${label} preview`}
|
|
className="mt-2 max-h-40 rounded-lg border border-neutral-200"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|