import { z } from "zod/v4"; import { LIMITS } from "@/lib/limits"; import { ACTIVITY_CATEGORIES } from "@/lib/activity-category"; import { VIBES } from "@/lib/vibe"; import type { ActivityCategory } from "@/app/generated/prisma/enums"; import { isTripDepartureDayPast, tripStoredInstantFromYmd, } from "@/lib/trip-dates"; import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary"; /** * Foto trip sekarang adalah file yang diunggah ke server sendiri, bukan URL * eksternal. Nilai yang valid hanya path terkelola `/api/trip-images/.webp` * yang dihasilkan route upload — regex ini sengaja ketat supaya URL arbitrary * (yang dulu sering tidak reachable dari server) tidak bisa lolos lagi. */ export const tripImageUrlsSchema = z .array( z .string() .trim() .regex( /^\/api\/trip-images\/[a-f0-9]{32}\.webp$/, "Foto trip tidak valid — silakan unggah ulang fotonya" ) ) .max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`); export const itineraryItemSchema = z .object({ day: z.coerce .number() .int("Nomor hari tidak valid") .min(1, "Nomor hari minimal 1") .max( LIMITS.MAX_ITINERARY_DAYS, `Maksimal ${LIMITS.MAX_ITINERARY_DAYS} hari` ), startTime: z .string() .trim() .refine(isValidTimeFormat, "Format jam mulai harus HH:mm"), endTime: z.preprocess( (val) => { if (val == null) return undefined; const s = String(val).trim(); return s === "" ? undefined : s; }, z .string() .refine(isValidTimeFormat, "Format jam selesai harus HH:mm") .optional() ), activity: z .string() .trim() .min(1, "Deskripsi aktivitas harus diisi") .max( LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH, `Aktivitas maksimal ${LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH} karakter` ), }) .superRefine((data, ctx) => { if ( data.endTime && timeToMinutes(data.endTime) < timeToMinutes(data.startTime) ) { ctx.addIssue({ code: "custom", message: "Jam selesai tidak boleh sebelum jam mulai", path: ["endTime"], }); } }); export const itineraryItemsSchema = z .array(itineraryItemSchema) .max( LIMITS.MAX_ITINERARY_ITEMS, `Maksimal ${LIMITS.MAX_ITINERARY_ITEMS} aktivitas total` ) .superRefine((items, ctx) => { if (items.length === 0) return; const days = [...new Set(items.map((i) => i.day))].sort((a, b) => a - b); for (let i = 0; i < days.length; i++) { if (days[i] !== i + 1) { ctx.addIssue({ code: "custom", message: "Nomor hari harus berurutan mulai dari Hari 1 (tidak boleh lompat)", path: [0, "day"], }); return; } } }); export const createTripSchema = z .object({ category: z.enum( ACTIVITY_CATEGORIES as [ActivityCategory, ...ActivityCategory[]], { message: "Kategori aktivitas tidak valid" } ), title: z .string() .trim() .min(3, "Judul minimal 3 karakter") .max( LIMITS.MAX_TITLE_LENGTH, `Judul maksimal ${LIMITS.MAX_TITLE_LENGTH} karakter` ), description: z.preprocess( (val) => { if (val == null) return undefined; const s = String(val).trim(); return s === "" ? undefined : s; }, z .string() .max( LIMITS.MAX_DESCRIPTION_LENGTH, `Deskripsi maksimal ${LIMITS.MAX_DESCRIPTION_LENGTH} karakter` ) .optional() ), destination: z .string() .trim() .min(2, "Destinasi harus diisi") .max( LIMITS.MAX_DESTINATION_LENGTH, `Destinasi maksimal ${LIMITS.MAX_DESTINATION_LENGTH} karakter` ), location: z .string() .trim() .min(2, "Lokasi harus diisi") .max( LIMITS.MAX_LOCATION_LENGTH, `Lokasi maksimal ${LIMITS.MAX_LOCATION_LENGTH} karakter` ), date: z .string() .refine((val) => !Number.isNaN(Date.parse(val)), "Tanggal berangkat tidak valid"), endDate: z .string() .optional() .refine( (val) => !val || !Number.isNaN(Date.parse(val)), "Tanggal pulang tidak valid" ), maxParticipants: z.coerce .number() .int("Jumlah peserta harus bilangan bulat") .min( LIMITS.MIN_PARTICIPANTS, `Minimal ${LIMITS.MIN_PARTICIPANTS} peserta` ) .max( LIMITS.MAX_PARTICIPANTS, `Maksimal ${LIMITS.MAX_PARTICIPANTS} peserta` ), price: z.coerce .number() .int("Harga harus bilangan bulat (tanpa desimal)") .min(0, "Harga tidak valid") .max( 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() ), 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() ), vibe: z.preprocess( (val) => { if (val == null) return undefined; const s = String(val).trim(); return s === "" ? undefined : s; }, z.enum([...VIBES]).optional() ), }) .superRefine((data, ctx) => { const dep = tripStoredInstantFromYmd(data.date); if (!Number.isNaN(dep.getTime()) && isTripDepartureDayPast(dep)) { ctx.addIssue({ code: "custom", message: "Tanggal berangkat tidak boleh di masa lalu", path: ["date"], }); } if (data.endDate) { const startY = data.date.slice(0, 10); const endY = data.endDate.slice(0, 10); if (endY < startY) { ctx.addIssue({ code: "custom", message: "Tanggal pulang tidak boleh sebelum tanggal berangkat", path: ["endDate"], }); } } }); export type CreateTripInput = z.infer;