add payment, trust badge, handle race condition, fix booking schema
This commit is contained in:
@@ -18,6 +18,10 @@ export async function createTripAction(formData: FormData) {
|
||||
description: formData.get("description") as string,
|
||||
mountain: formData.get("mountain") as string,
|
||||
location: formData.get("location") as string,
|
||||
meetingPoint: formData.get("meetingPoint") as string,
|
||||
itinerary: formData.get("itinerary") as string,
|
||||
whatsIncluded: formData.get("whatsIncluded") as string,
|
||||
whatsExcluded: formData.get("whatsExcluded") as string,
|
||||
date: formData.get("date") as string,
|
||||
endDate: (formData.get("endDate") as string) || undefined,
|
||||
maxParticipants: formData.get("maxParticipants") as string,
|
||||
@@ -50,8 +54,20 @@ export async function createTripAction(formData: FormData) {
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
meetingPoint,
|
||||
itinerary,
|
||||
whatsIncluded,
|
||||
whatsExcluded,
|
||||
...tripCore
|
||||
} = result.data;
|
||||
|
||||
const trip = await tripService.createTrip({
|
||||
...result.data,
|
||||
...tripCore,
|
||||
meetingPoint,
|
||||
itinerary,
|
||||
whatsIncluded,
|
||||
whatsExcluded,
|
||||
date,
|
||||
endDate,
|
||||
organizerId: session.user.id,
|
||||
@@ -101,3 +117,53 @@ export async function cancelJoinAction(tripId: string) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmParticipantAction(
|
||||
tripId: string,
|
||||
participantId: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
|
||||
try {
|
||||
await tripService.confirmParticipant(
|
||||
tripId,
|
||||
participantId,
|
||||
session.user.id
|
||||
);
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/profile");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectParticipantAction(
|
||||
tripId: string,
|
||||
participantId: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
|
||||
try {
|
||||
await tripService.rejectParticipant(
|
||||
tripId,
|
||||
participantId,
|
||||
session.user.id
|
||||
);
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/profile");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,23 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { joinTripAction, cancelJoinAction } from "@/features/trip/actions";
|
||||
import { markParticipantPaidAction } from "@/features/booking/actions";
|
||||
|
||||
interface JoinTripButtonProps {
|
||||
tripId: string;
|
||||
isLoggedIn: boolean;
|
||||
isOrganizer: boolean;
|
||||
isJoined: boolean;
|
||||
/** Status partisipasi user saat isJoined (bukan organizer) */
|
||||
participationStatus?: "PENDING" | "CONFIRMED" | null;
|
||||
/** Status pembayaran manual (peserta) */
|
||||
participantPayment?: {
|
||||
markedPaidAt: string | Date | null;
|
||||
paymentConfirmedAt: string | Date | null;
|
||||
} | null;
|
||||
isFull: boolean;
|
||||
tripStatus: string;
|
||||
/** Tanggal berangkat sudah lewat (hari kalender UTC) */
|
||||
/** Tanggal berangkat trip sudah lewat */
|
||||
isDeparturePast?: boolean;
|
||||
}
|
||||
|
||||
@@ -21,6 +29,8 @@ export function JoinTripButton({
|
||||
isLoggedIn,
|
||||
isOrganizer,
|
||||
isJoined,
|
||||
participationStatus,
|
||||
participantPayment,
|
||||
isFull,
|
||||
tripStatus,
|
||||
isDeparturePast,
|
||||
@@ -98,6 +108,29 @@ export function JoinTripButton({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkPaid() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const result = await markParticipantPaidAction(tripId);
|
||||
setLoading(false);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const pay = participantPayment;
|
||||
const showMarkPaid =
|
||||
isJoined &&
|
||||
pay &&
|
||||
!pay.paymentConfirmedAt &&
|
||||
!pay.markedPaidAt &&
|
||||
!isDeparturePast;
|
||||
const waitingPaymentConfirm =
|
||||
isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt;
|
||||
const paymentDone = isJoined && pay && pay.paymentConfirmedAt;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && (
|
||||
@@ -105,6 +138,42 @@ export function JoinTripButton({
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{isJoined && participationStatus === "PENDING" && (
|
||||
<div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium leading-relaxed text-amber-900">
|
||||
Permintaan ikut trip kamu{" "}
|
||||
<span className="font-semibold">menunggu persetujuan organizer</span>.
|
||||
Kamu bisa membatalkan kapan saja sebelum disetujui.
|
||||
</div>
|
||||
)}
|
||||
{isJoined && participationStatus === "CONFIRMED" && (
|
||||
<div className="mb-3 rounded-xl border border-secondary-200 bg-secondary-50 px-4 py-3 text-sm font-medium text-secondary-900">
|
||||
Kamu sudah{" "}
|
||||
<span className="font-semibold">terkonfirmasi</span> sebagai peserta
|
||||
trip ini.
|
||||
</div>
|
||||
)}
|
||||
{waitingPaymentConfirm && (
|
||||
<div className="mb-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-3 text-sm font-medium leading-relaxed text-primary-950">
|
||||
Kamu sudah menandai <span className="font-semibold">sudah bayar</span>.
|
||||
Tunggu organizer mengonfirmasi pembayaran.
|
||||
</div>
|
||||
)}
|
||||
{paymentDone && (
|
||||
<div className="mb-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-900">
|
||||
Pembayaran kamu sudah{" "}
|
||||
<span className="font-semibold">dikonfirmasi organizer</span>.
|
||||
</div>
|
||||
)}
|
||||
{showMarkPaid && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMarkPaid}
|
||||
disabled={loading}
|
||||
className="mb-3 w-full rounded-xl border-2 border-primary-500 bg-white py-3 text-sm font-bold text-primary-700 transition-colors hover:bg-primary-50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Memproses..." : "Saya sudah bayar"}
|
||||
</button>
|
||||
)}
|
||||
{isJoined ? (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
confirmParticipantAction,
|
||||
rejectParticipantAction,
|
||||
} from "@/features/trip/actions";
|
||||
|
||||
export interface PendingJoinRequest {
|
||||
id: string;
|
||||
user: { name: string; image: string | null };
|
||||
/** Peserta sudah menekan "Saya sudah bayar" */
|
||||
markedPaidAt?: string | Date | null;
|
||||
}
|
||||
|
||||
interface OrganizerJoinRequestsProps {
|
||||
tripId: string;
|
||||
pending: PendingJoinRequest[];
|
||||
}
|
||||
|
||||
export function OrganizerJoinRequests({
|
||||
tripId,
|
||||
pending,
|
||||
}: OrganizerJoinRequestsProps) {
|
||||
const router = useRouter();
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function run(
|
||||
participantId: string,
|
||||
action: "confirm" | "reject"
|
||||
) {
|
||||
setLoadingId(participantId);
|
||||
setError("");
|
||||
const result =
|
||||
action === "confirm"
|
||||
? await confirmParticipantAction(tripId, participantId)
|
||||
: await rejectParticipantAction(tripId, participantId);
|
||||
setLoadingId(null);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50/70 p-4 sm:p-5">
|
||||
<h2 className="text-sm font-bold text-amber-950 sm:text-base">
|
||||
Permintaan join ({pending.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-amber-900/80 sm:text-sm">
|
||||
Setujui atau tolak siapa yang boleh ikut trip ini.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="mt-3 rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700 sm:text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ul className="mt-4 space-y-3">
|
||||
{pending.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-white/90 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{p.user.image ? (
|
||||
<Image
|
||||
src={p.user.image}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
|
||||
{p.user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-neutral-800">
|
||||
{p.user.name}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-xs text-amber-800/80">Menunggu persetujuan</p>
|
||||
{p.markedPaidAt ? (
|
||||
<span className="rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold text-primary-800">
|
||||
Sudah tandai bayar
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingId !== null}
|
||||
onClick={() => run(p.id, "reject")}
|
||||
className="rounded-lg border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold text-neutral-600 hover:bg-neutral-50 disabled:opacity-50 sm:text-sm"
|
||||
>
|
||||
{loadingId === p.id ? "…" : "Tolak"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingId !== null}
|
||||
onClick={() => run(p.id, "confirm")}
|
||||
className="rounded-lg bg-primary-600 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-primary-700 disabled:opacity-50 sm:text-sm"
|
||||
>
|
||||
{loadingId === p.id ? "Memproses…" : "Setujui"}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Image from "next/image";
|
||||
import type { OrganizerTrust } from "@/server/services/trust.service";
|
||||
|
||||
interface OrganizerTrustPanelProps {
|
||||
name: string;
|
||||
image: string | null;
|
||||
trust: OrganizerTrust;
|
||||
}
|
||||
|
||||
export function OrganizerTrustPanel({
|
||||
name,
|
||||
image,
|
||||
trust,
|
||||
}: OrganizerTrustPanelProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-neutral-200 bg-linear-to-br from-white to-neutral-50 p-4 sm:p-5">
|
||||
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
|
||||
Organizer & kepercayaan
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
className="h-12 w-12 shrink-0 rounded-full object-cover ring-2 ring-white shadow"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary-600 text-lg font-bold text-white shadow">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold text-neutral-900 sm:text-base">
|
||||
{name}
|
||||
</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>
|
||||
)}
|
||||
{trust.isTripLeader && (
|
||||
<span className="inline-flex items-center rounded-full bg-secondary-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-secondary-900 sm:text-xs">
|
||||
Trip leader
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0">
|
||||
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
|
||||
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
|
||||
Trip dibuat
|
||||
</p>
|
||||
<p className="text-lg font-bold text-neutral-800">
|
||||
{trust.tripsCreated}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
|
||||
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
|
||||
Rating organizer
|
||||
</p>
|
||||
<p className="text-lg font-bold text-amber-700">
|
||||
{trust.avgRating != null ? `${trust.avgRating} ★` : "—"}
|
||||
</p>
|
||||
{trust.reviewCount > 0 && (
|
||||
<p className="text-[10px] text-neutral-400">
|
||||
dari {trust.reviewCount} ulasan trip
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { formatRupiah, formatDateRange } from "@/lib/utils";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
|
||||
interface TripCardProps {
|
||||
id: string;
|
||||
@@ -80,7 +81,7 @@ export function TripCard({
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-secondary-500">📅</span>{" "}
|
||||
{formatDateRange(date, endDate)}
|
||||
{formatTripCalendarDateRangeLong(date, endDate)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-secondary-500">👤</span>{" "}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import { formatLocalCalendarYmd } from "@/lib/trip-dates";
|
||||
|
||||
export function TripFilter() {
|
||||
const router = useRouter();
|
||||
@@ -35,8 +36,8 @@ export function TripFilter() {
|
||||
e.preventDefault();
|
||||
const params = new URLSearchParams();
|
||||
if (query.trim()) params.set("q", query.trim());
|
||||
if (startDate) params.set("from", startDate.toISOString().split("T")[0]);
|
||||
if (endDate) params.set("to", endDate.toISOString().split("T")[0]);
|
||||
if (startDate) params.set("from", formatLocalCalendarYmd(startDate));
|
||||
if (endDate) params.set("to", formatLocalCalendarYmd(endDate));
|
||||
|
||||
const qs = params.toString();
|
||||
router.push(`/trips${qs ? `?${qs}` : ""}`);
|
||||
@@ -90,12 +91,8 @@ export function TripFilter() {
|
||||
{/* Date range */}
|
||||
<div className="sm:w-64">
|
||||
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
|
||||
Rentang tanggal (UTC)
|
||||
Tanggal
|
||||
</label>
|
||||
<p className="mb-1 text-[10px] leading-snug text-neutral-400 sm:text-xs">
|
||||
Menampilkan trip yang jadwalnya overlap rentang ini: multi hari pakai
|
||||
tanggal pulang; satu hari pakai tanggal berangkat saja.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
interface TripProgramBlockProps {
|
||||
meetingPoint: string | null;
|
||||
itinerary: string | null;
|
||||
whatsIncluded: string | null;
|
||||
whatsExcluded: string | null;
|
||||
}
|
||||
|
||||
export function TripProgramBlock({
|
||||
meetingPoint,
|
||||
itinerary,
|
||||
whatsIncluded,
|
||||
whatsExcluded,
|
||||
}: TripProgramBlockProps) {
|
||||
const hasAny =
|
||||
meetingPoint || itinerary || whatsIncluded || whatsExcluded;
|
||||
if (!hasAny) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-neutral-200 bg-neutral-50/50 p-4 sm:p-5">
|
||||
<h2 className="text-xs font-bold text-neutral-800 sm:text-sm">
|
||||
Detail perjalanan
|
||||
</h2>
|
||||
|
||||
{meetingPoint && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
|
||||
Meeting point
|
||||
</h3>
|
||||
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
|
||||
{meetingPoint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{itinerary && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
|
||||
Itinerary
|
||||
</h3>
|
||||
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
|
||||
{itinerary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(whatsIncluded || whatsExcluded) && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{whatsIncluded && (
|
||||
<div className="rounded-lg border border-secondary-200 bg-white p-3">
|
||||
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-wide text-secondary-800 sm:text-xs">
|
||||
Termasuk
|
||||
</h3>
|
||||
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
|
||||
{whatsIncluded}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{whatsExcluded && (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-3">
|
||||
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-wide text-neutral-600 sm:text-xs">
|
||||
Tidak termasuk
|
||||
</h3>
|
||||
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
|
||||
{whatsExcluded}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,62 @@ export const createTripSchema = z
|
||||
LIMITS.MAX_PRICE_IDR,
|
||||
`Harga maksimal Rp ${LIMITS.MAX_PRICE_IDR.toLocaleString("id-ID")}`
|
||||
),
|
||||
meetingPoint: z.preprocess(
|
||||
(val) => {
|
||||
if (val == null) return undefined;
|
||||
const s = String(val).trim();
|
||||
return s === "" ? undefined : s;
|
||||
},
|
||||
z
|
||||
.string()
|
||||
.max(
|
||||
LIMITS.MAX_MEETING_POINT_LENGTH,
|
||||
`Meeting point maksimal ${LIMITS.MAX_MEETING_POINT_LENGTH} karakter`
|
||||
)
|
||||
.optional()
|
||||
),
|
||||
itinerary: z.preprocess(
|
||||
(val) => {
|
||||
if (val == null) return undefined;
|
||||
const s = String(val).trim();
|
||||
return s === "" ? undefined : s;
|
||||
},
|
||||
z
|
||||
.string()
|
||||
.max(
|
||||
LIMITS.MAX_TRIP_ITINERARY_LENGTH,
|
||||
`Itinerary maksimal ${LIMITS.MAX_TRIP_ITINERARY_LENGTH} karakter`
|
||||
)
|
||||
.optional()
|
||||
),
|
||||
whatsIncluded: z.preprocess(
|
||||
(val) => {
|
||||
if (val == null) return undefined;
|
||||
const s = String(val).trim();
|
||||
return s === "" ? undefined : s;
|
||||
},
|
||||
z
|
||||
.string()
|
||||
.max(
|
||||
LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH,
|
||||
`Bagian 'Termasuk' maksimal ${LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH} karakter`
|
||||
)
|
||||
.optional()
|
||||
),
|
||||
whatsExcluded: z.preprocess(
|
||||
(val) => {
|
||||
if (val == null) return undefined;
|
||||
const s = String(val).trim();
|
||||
return s === "" ? undefined : s;
|
||||
},
|
||||
z
|
||||
.string()
|
||||
.max(
|
||||
LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH,
|
||||
`Bagian 'Tidak termasuk' maksimal ${LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH} karakter`
|
||||
)
|
||||
.optional()
|
||||
),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const dep = tripStoredInstantFromYmd(data.date);
|
||||
|
||||
Reference in New Issue
Block a user