kyc user and upload partial update encrypt nik and picture
This commit is contained in:
@@ -9,6 +9,7 @@ export async function registerAction(formData: FormData) {
|
||||
email: formData.get("email") as string,
|
||||
password: formData.get("password") as string,
|
||||
confirmPassword: formData.get("confirmPassword") as string,
|
||||
acceptedTermsAndPrivacy: formData.get("acceptedTermsAndPrivacy") === "on",
|
||||
};
|
||||
|
||||
const result = registerSchema.safeParse(raw);
|
||||
@@ -21,6 +22,7 @@ export async function registerAction(formData: FormData) {
|
||||
name: result.data.name,
|
||||
email: result.data.email,
|
||||
password: result.data.password,
|
||||
acceptedTermsAndPrivacy: result.data.acceptedTermsAndPrivacy,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
|
||||
@@ -29,6 +29,9 @@ export const registerSchema = z.object({
|
||||
.min(6, "Password minimal 6 karakter")
|
||||
.max(LIMITS.MAX_PASSWORD_LENGTH, "Password terlalu panjang (maks. 72 karakter)"),
|
||||
confirmPassword: z.string(),
|
||||
acceptedTermsAndPrivacy: z.literal(true, {
|
||||
error: "Kamu harus menyetujui Syarat & Ketentuan dan Kebijakan Privasi",
|
||||
}),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Password tidak cocok",
|
||||
path: ["confirmPassword"],
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdminEmail } from "@/lib/admin";
|
||||
import { organizerService } from "@/server/services/organizer.service";
|
||||
import { submitVerificationSchema, reviewVerificationSchema } from "./schemas";
|
||||
|
||||
export async function submitVerificationAction(formData: FormData) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
|
||||
const raw = {
|
||||
fullName: formData.get("fullName") as string,
|
||||
nik: formData.get("nik") as string,
|
||||
birthDate: formData.get("birthDate") as string,
|
||||
address: formData.get("address") as string,
|
||||
ktpImageUrl: formData.get("ktpImageUrl") as string,
|
||||
selfieUrl: formData.get("selfieUrl") as string,
|
||||
bankName: formData.get("bankName") as string,
|
||||
bankAccountNumber: formData.get("bankAccountNumber") as string,
|
||||
bankAccountName: formData.get("bankAccountName") as string,
|
||||
};
|
||||
|
||||
const result = submitVerificationSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
return { error: result.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
await organizerService.submitVerification(session.user.id, {
|
||||
...result.data,
|
||||
birthDate: new Date(result.data.birthDate),
|
||||
});
|
||||
revalidatePath("/verify");
|
||||
revalidatePath("/profile");
|
||||
revalidatePath("/admin/verifications");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function reviewVerificationAction(formData: FormData) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || !isAdminEmail(session.user.email)) {
|
||||
return { error: "Tidak memiliki akses admin" };
|
||||
}
|
||||
|
||||
const raw = {
|
||||
verificationId: formData.get("verificationId") as string,
|
||||
decision: formData.get("decision") as string,
|
||||
rejectionReason: (formData.get("rejectionReason") as string) || undefined,
|
||||
};
|
||||
|
||||
const result = reviewVerificationSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
return { error: result.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
await organizerService.reviewVerification({
|
||||
verificationId: result.data.verificationId,
|
||||
decision: result.data.decision,
|
||||
rejectionReason: result.data.rejectionReason,
|
||||
reviewerId: session.user.id,
|
||||
});
|
||||
revalidatePath("/admin/verifications");
|
||||
revalidatePath("/verify");
|
||||
revalidatePath("/profile");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { reviewVerificationAction } from "@/features/organizer/actions";
|
||||
|
||||
type Verification = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
nik: string;
|
||||
birthDate: Date;
|
||||
address: string;
|
||||
ktpImageUrl: string;
|
||||
selfieUrl: string;
|
||||
bankName: string;
|
||||
bankAccountNumber: string;
|
||||
bankAccountName: string;
|
||||
status: "PENDING" | "APPROVED" | "REJECTED";
|
||||
rejectionReason: string | null;
|
||||
reviewedAt: Date | null;
|
||||
createdAt: Date;
|
||||
user: { id: string; name: string; email: string };
|
||||
reviewedBy: { id: string; name: string; email: string } | null;
|
||||
};
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return new Date(d).toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function ReviewCard({ verification }: { verification: Verification }) {
|
||||
const router = useRouter();
|
||||
const [showReject, setShowReject] = useState(false);
|
||||
const [rejectionReason, setRejectionReason] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function decide(decision: "APPROVED" | "REJECTED") {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const fd = new FormData();
|
||||
fd.set("verificationId", verification.id);
|
||||
fd.set("decision", decision);
|
||||
if (decision === "REJECTED") fd.set("rejectionReason", rejectionReason);
|
||||
const result = await reviewVerificationAction(fd);
|
||||
setLoading(false);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setShowReject(false);
|
||||
setRejectionReason("");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<header className="mb-4 flex flex-wrap items-start justify-between gap-3 border-b border-neutral-100 pb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-neutral-900">
|
||||
{verification.user.name}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{verification.user.email} · diajukan {formatDate(verification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill status={verification.status} />
|
||||
</header>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Nama Lengkap" value={verification.fullName} />
|
||||
<Field label="NIK" value={verification.nik} mono />
|
||||
<Field
|
||||
label="Tanggal Lahir"
|
||||
value={formatDate(verification.birthDate)}
|
||||
/>
|
||||
<Field
|
||||
label="Bank"
|
||||
value={`${verification.bankName} · ${verification.bankAccountNumber}`}
|
||||
/>
|
||||
<Field
|
||||
label="Pemilik Rekening"
|
||||
value={verification.bankAccountName}
|
||||
/>
|
||||
<Field label="Alamat" value={verification.address} fullWidth />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<ImagePreview label="Foto KTP" url={verification.ktpImageUrl} />
|
||||
<ImagePreview label="Selfie + KTP" url={verification.selfieUrl} />
|
||||
</div>
|
||||
|
||||
{verification.status === "REJECTED" && verification.rejectionReason && (
|
||||
<div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700">
|
||||
<span className="font-semibold">Alasan ditolak:</span>{" "}
|
||||
{verification.rejectionReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verification.reviewedBy && verification.reviewedAt && (
|
||||
<p className="mt-4 text-xs text-neutral-500">
|
||||
Diproses oleh {verification.reviewedBy.name} pada{" "}
|
||||
{formatDate(verification.reviewedAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{verification.status === "PENDING" && (
|
||||
<div className="mt-5 border-t border-neutral-100 pt-4">
|
||||
{error && (
|
||||
<div className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!showReject ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("APPROVED")}
|
||||
disabled={loading}
|
||||
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
✅ Setujui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReject(true)}
|
||||
disabled={loading}
|
||||
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
❌ Tolak
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Alasan penolakan (akan dilihat user)"
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm focus:bg-white"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("REJECTED")}
|
||||
disabled={loading || !rejectionReason.trim()}
|
||||
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Konfirmasi Tolak
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowReject(false);
|
||||
setRejectionReason("");
|
||||
}}
|
||||
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
fullWidth,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={fullWidth ? "sm:col-span-2" : ""}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-0.5 text-sm text-neutral-800 ${mono ? "font-mono" : ""}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImagePreview({ label, url }: { label: string; url: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{label}
|
||||
</p>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100"
|
||||
>
|
||||
<div className="relative aspect-[4/3] w-full">
|
||||
<Image
|
||||
src={url}
|
||||
alt={label}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
sizes="(min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: "PENDING" | "APPROVED" | "REJECTED" }) {
|
||||
const cfg = {
|
||||
PENDING: { label: "Pending", cls: "bg-amber-50 text-amber-700 ring-amber-200" },
|
||||
APPROVED: { label: "Disetujui", cls: "bg-primary-50 text-primary-700 ring-primary-200" },
|
||||
REJECTED: { label: "Ditolak", cls: "bg-red-50 text-red-700 ring-red-200" },
|
||||
}[status];
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ${cfg.cls}`}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { submitVerificationAction } from "@/features/organizer/actions";
|
||||
|
||||
type Initial = {
|
||||
fullName: string;
|
||||
nik: string;
|
||||
birthDate: Date;
|
||||
address: string;
|
||||
ktpImageUrl: string;
|
||||
selfieUrl: string;
|
||||
bankName: string;
|
||||
bankAccountNumber: string;
|
||||
bankAccountName: string;
|
||||
} | null;
|
||||
|
||||
function toYmd(d: Date): string {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function VerifyForm({ initial }: { initial: Initial }) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
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 text-base font-bold text-neutral-900">📇 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 className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Tanggal Lahir
|
||||
</label>
|
||||
<input
|
||||
name="birthDate"
|
||||
type="date"
|
||||
required
|
||||
defaultValue={initial ? toYmd(new Date(initial.birthDate)) : ""}
|
||||
className={inputCls}
|
||||
/>
|
||||
</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 text-base font-bold text-neutral-900">🖼️ Foto</h2>
|
||||
<p className="mb-3 text-xs text-neutral-500">
|
||||
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya. Foto akan
|
||||
dilihat tim admin saat review.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
URL Foto KTP
|
||||
</label>
|
||||
<input
|
||||
name="ktpImageUrl"
|
||||
type="url"
|
||||
required
|
||||
defaultValue={initial?.ktpImageUrl ?? ""}
|
||||
className={inputCls}
|
||||
placeholder="https://i.imgur.com/xxxx.jpg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
URL Selfie dengan KTP
|
||||
</label>
|
||||
<input
|
||||
name="selfieUrl"
|
||||
type="url"
|
||||
required
|
||||
defaultValue={initial?.selfieUrl ?? ""}
|
||||
className={inputCls}
|
||||
placeholder="https://i.imgur.com/yyyy.jpg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-base font-bold text-neutral-900">🏦 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { z } from "zod/v4";
|
||||
import { LIMITS } from "@/lib/limits";
|
||||
|
||||
const ymdRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export const submitVerificationSchema = z.object({
|
||||
fullName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, "Nama lengkap minimal 2 karakter")
|
||||
.max(LIMITS.MAX_NAME_LENGTH, `Nama maksimal ${LIMITS.MAX_NAME_LENGTH} karakter`),
|
||||
nik: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{16}$/, "NIK harus 16 digit angka"),
|
||||
birthDate: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(ymdRegex, "Format tanggal lahir harus YYYY-MM-DD"),
|
||||
address: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, "Alamat minimal 5 karakter")
|
||||
.max(LIMITS.MAX_ADDRESS_LENGTH, `Alamat maksimal ${LIMITS.MAX_ADDRESS_LENGTH} karakter`),
|
||||
ktpImageUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Foto KTP wajib diisi")
|
||||
.max(LIMITS.MAX_URL_LENGTH, "URL foto KTP terlalu panjang")
|
||||
.pipe(z.url("URL foto KTP tidak valid")),
|
||||
selfieUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Foto selfie dengan KTP wajib diisi")
|
||||
.max(LIMITS.MAX_URL_LENGTH, "URL foto selfie terlalu panjang")
|
||||
.pipe(z.url("URL foto selfie tidak valid")),
|
||||
bankName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, "Nama bank minimal 2 karakter")
|
||||
.max(LIMITS.MAX_BANK_NAME_LENGTH, `Nama bank maksimal ${LIMITS.MAX_BANK_NAME_LENGTH} karakter`),
|
||||
bankAccountNumber: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[0-9-]+$/, "Nomor rekening hanya boleh angka")
|
||||
.min(5, "Nomor rekening minimal 5 digit")
|
||||
.max(LIMITS.MAX_BANK_ACCOUNT_NUMBER_LENGTH, "Nomor rekening terlalu panjang"),
|
||||
bankAccountName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, "Nama pemilik rekening minimal 2 karakter")
|
||||
.max(LIMITS.MAX_NAME_LENGTH, `Nama pemilik rekening maksimal ${LIMITS.MAX_NAME_LENGTH} karakter`),
|
||||
});
|
||||
|
||||
export const reviewVerificationSchema = z.object({
|
||||
verificationId: z.string().min(1, "Verification ID wajib"),
|
||||
decision: z.enum(["APPROVED", "REJECTED"], {
|
||||
error: "Keputusan tidak valid",
|
||||
}),
|
||||
rejectionReason: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(LIMITS.MAX_REJECTION_REASON_LENGTH, "Alasan penolakan terlalu panjang")
|
||||
.optional(),
|
||||
}).refine(
|
||||
(data) => data.decision !== "REJECTED" || (data.rejectionReason && data.rejectionReason.length > 0),
|
||||
{ message: "Alasan penolakan wajib diisi jika menolak", path: ["rejectionReason"] }
|
||||
);
|
||||
|
||||
export type SubmitVerificationInput = z.infer<typeof submitVerificationSchema>;
|
||||
export type ReviewVerificationInput = z.infer<typeof reviewVerificationSchema>;
|
||||
@@ -4,6 +4,7 @@ import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { createTripSchema, tripImageUrlsSchema } from "./schemas";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { organizerService } from "@/server/services/organizer.service";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
|
||||
|
||||
@@ -33,6 +34,16 @@ export async function createTripAction(formData: FormData) {
|
||||
return { error: result.error.issues[0].message };
|
||||
}
|
||||
|
||||
if (result.data.price > 0) {
|
||||
const approved = await organizerService.isApproved(session.user.id);
|
||||
if (!approved) {
|
||||
return {
|
||||
error:
|
||||
"Untuk membuat trip berbayar, akun kamu perlu diverifikasi. Silakan lengkapi verifikasi di /verify.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrlsRaw = formData
|
||||
.getAll("imageUrls")
|
||||
.map((v) => (v as string).trim())
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import { createTripAction } from "@/features/trip/actions";
|
||||
import { ImageUrlInput } from "@/features/trip/components/image-url-input";
|
||||
import { formatLocalCalendarYmd } from "@/lib/trip-dates";
|
||||
|
||||
const SAMPLE_MOUNTAINS = [
|
||||
{ name: "Gunung Papandayan", location: "Garut, Jawa Barat" },
|
||||
{ name: "Gunung Ciremai", location: "Kuningan, Jawa Barat" },
|
||||
{ name: "Gunung Pangrango", location: "Bogor/Cianjur, Jawa Barat" },
|
||||
{ name: "Gunung Gede", location: "Bogor/Cianjur, Jawa Barat" },
|
||||
{ name: "Gunung Tangkuban Parahu", location: "Bandung, Jawa Barat" },
|
||||
{ name: "Gunung Bukit Tunggul", location: "Bandung, Jawa Barat" },
|
||||
{ name: "Gunung Malabar", location: "Bandung, Jawa Barat" },
|
||||
{ name: "Gunung Guntur", location: "Garut, Jawa Barat" },
|
||||
];
|
||||
|
||||
function formatRupiahInput(value: string): string {
|
||||
const num = value.replace(/\D/g, "");
|
||||
return num.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
||||
}
|
||||
|
||||
function parseRupiahInput(value: string): string {
|
||||
return value.replace(/\./g, "");
|
||||
}
|
||||
|
||||
interface CreateTripFormProps {
|
||||
isVerifiedOrganizer: boolean;
|
||||
}
|
||||
|
||||
export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [startDate, setStartDate] = useState<Date | null>(null);
|
||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||
const [priceDisplay, setPriceDisplay] = useState("");
|
||||
|
||||
const priceNumber = Number(parseRupiahInput(priceDisplay) || "0");
|
||||
const isPaidTrip = priceNumber > 0;
|
||||
const blockedByVerification = isPaidTrip && !isVerifiedOrganizer;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!startDate) {
|
||||
setError("Tanggal berangkat harus diisi");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
formData.set("date", formatLocalCalendarYmd(startDate));
|
||||
if (endDate) {
|
||||
const startYmd = formatLocalCalendarYmd(startDate);
|
||||
const endYmd = formatLocalCalendarYmd(endDate);
|
||||
if (endYmd !== startYmd) {
|
||||
formData.set("endDate", endYmd);
|
||||
}
|
||||
}
|
||||
formData.set("price", parseRupiahInput(priceDisplay));
|
||||
|
||||
const result = await createTripAction(formData);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else if (result.tripId) {
|
||||
router.push(`/trips/${result.tripId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMountainSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const selected = SAMPLE_MOUNTAINS.find((m) => m.name === e.target.value);
|
||||
if (selected) {
|
||||
const form = e.target.form;
|
||||
if (form) {
|
||||
const mountainInput = form.elements.namedItem(
|
||||
"mountain"
|
||||
) as HTMLInputElement;
|
||||
const locationInput = form.elements.namedItem(
|
||||
"location"
|
||||
) as HTMLInputElement;
|
||||
mountainInput.value = selected.name;
|
||||
locationInput.value = selected.location;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateChange(dates: [Date | null, Date | null]) {
|
||||
const [start, end] = dates;
|
||||
setStartDate(start);
|
||||
setEndDate(end);
|
||||
}
|
||||
|
||||
function handlePriceChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const raw = e.target.value.replace(/\D/g, "");
|
||||
setPriceDisplay(raw ? formatRupiahInput(raw) : "");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Mountain Quick Picker */}
|
||||
<div className="rounded-xl bg-primary-50 p-4">
|
||||
<label className="mb-2 flex items-center gap-1.5 text-sm font-bold text-primary-800">
|
||||
<span>🏔️</span> Pilih Gunung Jawa Barat
|
||||
</label>
|
||||
<select
|
||||
onChange={handleMountainSelect}
|
||||
className="w-full rounded-lg border border-primary-200 bg-white px-4 py-2.5 text-sm text-neutral-800"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
Pilih gunung...
|
||||
</option>
|
||||
{SAMPLE_MOUNTAINS.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.name} — {m.location}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Judul Trip
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
required
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
|
||||
placeholder="contoh: Open Trip Papandayan Weekend"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="mountain" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Nama Gunung
|
||||
</label>
|
||||
<input
|
||||
id="mountain"
|
||||
name="mountain"
|
||||
type="text"
|
||||
required
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
|
||||
placeholder="Gunung Papandayan"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="location" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Lokasi
|
||||
</label>
|
||||
<input
|
||||
id="location"
|
||||
name="location"
|
||||
type="text"
|
||||
required
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
|
||||
placeholder="Garut, Jawa Barat"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Deskripsi
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={4}
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
|
||||
placeholder="Ringkasan trip, vibe, level kesulitan..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="meetingPoint" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Meeting point
|
||||
</label>
|
||||
<input
|
||||
id="meetingPoint"
|
||||
name="meetingPoint"
|
||||
type="text"
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
|
||||
placeholder="contoh: Alfamart Cicaheum, 05:00 WIB"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="itinerary" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Itinerary
|
||||
</label>
|
||||
<textarea
|
||||
id="itinerary"
|
||||
name="itinerary"
|
||||
rows={5}
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
|
||||
placeholder={"Hari 1: …\nHari 2: …"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="whatsIncluded" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Termasuk
|
||||
</label>
|
||||
<textarea
|
||||
id="whatsIncluded"
|
||||
name="whatsIncluded"
|
||||
rows={4}
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
|
||||
placeholder="Transport, konsumsi, tenda, …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="whatsExcluded" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Tidak termasuk
|
||||
</label>
|
||||
<textarea
|
||||
id="whatsExcluded"
|
||||
name="whatsExcluded"
|
||||
rows={4}
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
|
||||
placeholder="Tiket masuk TN, sleeping bag, …"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageUrlInput />
|
||||
|
||||
{/* Date Range & Participants & Price */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Date Range Picker */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Tanggal berangkat — pulang
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<DatePicker
|
||||
selectsRange
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onChange={handleDateChange}
|
||||
minDate={new Date()}
|
||||
placeholderText="Pilih tanggal..."
|
||||
dateFormat="dd MMM yyyy"
|
||||
isClearable
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Max Participants */}
|
||||
<div>
|
||||
<label htmlFor="maxParticipants" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Maks Peserta
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M7 8a3 3 0 100-6 3 3 0 000 6zM14.5 9a2.5 2.5 0 100-5 2.5 2.5 0 000 5zM1.615 16.428a1.224 1.224 0 01-.569-1.175 6.002 6.002 0 0111.908 0c.058.467-.172.92-.57 1.174A9.953 9.953 0 017 18a9.953 9.953 0 01-5.385-1.572zM14.5 16h-.106c.07-.297.088-.611.048-.933a7.47 7.47 0 00-1.588-3.755 4.502 4.502 0 015.874 2.636.818.818 0 01-.36.98A7.465 7.465 0 0114.5 16z" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
id="maxParticipants"
|
||||
name="maxParticipants"
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-4 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price with Rp format */}
|
||||
<div>
|
||||
<label htmlFor="priceDisplay" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Harga per Orang
|
||||
{!isVerifiedOrganizer && (
|
||||
<span className="ml-2 text-xs font-normal text-neutral-500">
|
||||
(isi 0 untuk trip gratis)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-semibold text-neutral-500">
|
||||
Rp
|
||||
</span>
|
||||
<input
|
||||
id="priceDisplay"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
required
|
||||
value={priceDisplay}
|
||||
onChange={handlePriceChange}
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-10 pr-4 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
|
||||
placeholder="150.000"
|
||||
/>
|
||||
<input type="hidden" name="price" value={parseRupiahInput(priceDisplay)} />
|
||||
</div>
|
||||
{blockedByVerification && (
|
||||
<p className="mt-2 text-xs font-medium text-amber-700">
|
||||
⚠️ Trip berbayar butuh verifikasi organizer terlebih dahulu.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || blockedByVerification}
|
||||
className="w-full rounded-xl bg-primary-600 py-3 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? "Membuat Trip..."
|
||||
: blockedByVerification
|
||||
? "Verifikasi diperlukan untuk trip berbayar"
|
||||
: "Buat Trip"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,8 +38,11 @@ export function OrganizerTrustPanel({
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{trust.isVerified && (
|
||||
<span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-blue-800 sm:text-xs">
|
||||
Verified
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800 sm:text-xs"
|
||||
title="Identitas organizer telah diverifikasi (KTP & rekening)"
|
||||
>
|
||||
✅ Verified Organizer
|
||||
</span>
|
||||
)}
|
||||
{trust.isTripLeader && (
|
||||
|
||||
Reference in New Issue
Block a user