Files
2026-05-22 14:52:22 +07:00

1208 lines
38 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
ArrowRight,
Check,
X,
CircleAlert,
Users,
} from "lucide-react";
import { DateRangeField, TimeField } from "@/components/shared/date-picker";
import { createTripAction } from "@/features/trip/actions";
import { TripImageUpload } from "@/features/trip/components/trip-image-upload";
import { formatLocalCalendarYmd } from "@/lib/trip-dates";
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
import { VIBES, vibeMeta } from "@/lib/vibe";
import { LIMITS } from "@/lib/limits";
import {
isValidTimeFormat,
timeToMinutes,
type ItineraryItemInput,
} from "@/lib/itinerary";
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
type Step = 1 | 2 | 3 | 4;
type DraftItineraryItem = {
startTime: string;
endTime: string;
activity: string;
};
type ItineraryDays = DraftItineraryItem[][];
type FormState = {
category: ActivityCategory;
vibe: Vibe | null;
title: string;
destination: string;
location: string;
description: string;
meetingPoint: string;
itineraryDays: ItineraryDays;
whatsIncluded: string;
whatsExcluded: string;
imageUrls: string[];
maxParticipants: string;
priceDisplay: string;
};
const INITIAL_STATE: FormState = {
category: "HIKING",
vibe: null,
title: "",
destination: "",
location: "",
description: "",
meetingPoint: "",
itineraryDays: [],
whatsIncluded: "",
whatsExcluded: "",
imageUrls: [],
maxParticipants: "",
priceDisplay: "",
};
function flattenItinerary(days: ItineraryDays): ItineraryItemInput[] {
const out: ItineraryItemInput[] = [];
days.forEach((dayItems, dayIdx) => {
dayItems.forEach((item) => {
if (
!item.startTime &&
!item.endTime &&
item.activity.trim().length === 0
) {
return;
}
out.push({
day: dayIdx + 1,
startTime: item.startTime,
endTime: item.endTime ? item.endTime : null,
activity: item.activity,
});
});
});
return out;
}
const STEPS: { id: Step; label: string; subtitle: string }[] = [
{ id: 1, label: "Vibe", subtitle: "Jenis aktivitas & vibe" },
{ id: 2, label: "Info", subtitle: "Judul, destinasi & lokasi" },
{ id: 3, label: "Detail", subtitle: "Itinerary & foto" },
{ id: 4, label: "Jadwal", subtitle: "Tanggal, peserta & harga" },
];
const INPUT_CLS =
"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";
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 [step, setStep] = useState<Step>(1);
const [maxStepReached, setMaxStepReached] = useState<Step>(1);
const [stepError, setStepError] = useState("");
const [submitError, setSubmitError] = useState("");
const [loading, setLoading] = useState(false);
const [state, setState] = useState<FormState>(INITIAL_STATE);
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);
const meta = categoryMeta(state.category);
const priceNumber = useMemo(
() => Number(parseRupiahInput(state.priceDisplay) || "0"),
[state.priceDisplay]
);
const isPaidTrip = priceNumber > 0;
const blockedByVerification = isPaidTrip && !isVerifiedOrganizer;
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setState((prev) => ({ ...prev, [key]: value }));
}
function validateStep(target: Step): string | null {
if (target === 1) {
if (!state.category) return "Pilih kategori aktivitas dulu";
return null;
}
if (target === 2) {
if (state.title.trim().length < 3) return "Judul minimal 3 karakter";
if (state.title.trim().length > LIMITS.MAX_TITLE_LENGTH) {
return `Judul maksimal ${LIMITS.MAX_TITLE_LENGTH} karakter`;
}
if (state.destination.trim().length < 2) {
return `${meta.destinationLabel} harus diisi`;
}
if (state.location.trim().length < 2) return "Lokasi harus diisi";
return null;
}
if (target === 3) {
// Foto divalidasi saat upload (route + komponen). Di sini cukup cek
// batas jumlah supaya tidak melampaui kapasitas.
if (state.imageUrls.length > LIMITS.MAX_IMAGE_URLS) {
return `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`;
}
for (let d = 0; d < state.itineraryDays.length; d++) {
const dayItems = state.itineraryDays[d];
for (const item of dayItems) {
const hasAnyInput =
item.startTime.trim() ||
item.endTime.trim() ||
item.activity.trim();
if (!hasAnyInput) continue;
if (!isValidTimeFormat(item.startTime)) {
return `Hari ${d + 1}: jam mulai harus HH:mm`;
}
if (item.endTime && !isValidTimeFormat(item.endTime)) {
return `Hari ${d + 1}: jam selesai harus HH:mm`;
}
if (
item.endTime &&
timeToMinutes(item.endTime) < timeToMinutes(item.startTime)
) {
return `Hari ${d + 1}: jam selesai tidak boleh sebelum jam mulai`;
}
if (item.activity.trim().length === 0) {
return `Hari ${d + 1}: deskripsi aktivitas harus diisi`;
}
if (
item.activity.trim().length > LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH
) {
return `Aktivitas maksimal ${LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH} karakter`;
}
}
}
const totalItems = flattenItinerary(state.itineraryDays).length;
if (totalItems > LIMITS.MAX_ITINERARY_ITEMS) {
return `Maksimal ${LIMITS.MAX_ITINERARY_ITEMS} aktivitas total`;
}
return null;
}
if (target === 4) {
if (!startDate) return "Tanggal berangkat harus diisi";
const max = Number(state.maxParticipants);
if (!Number.isFinite(max) || max < LIMITS.MIN_PARTICIPANTS) {
return `Minimal ${LIMITS.MIN_PARTICIPANTS} peserta`;
}
if (max > LIMITS.MAX_PARTICIPANTS) {
return `Maksimal ${LIMITS.MAX_PARTICIPANTS} peserta`;
}
if (priceNumber < 0) return "Harga tidak valid";
if (priceNumber > LIMITS.MAX_PRICE_IDR) {
return `Harga maksimal Rp ${LIMITS.MAX_PRICE_IDR.toLocaleString("id-ID")}`;
}
if (blockedByVerification) {
return "Trip berbayar butuh verifikasi organizer terlebih dahulu";
}
return null;
}
return null;
}
function goNext() {
const err = validateStep(step);
if (err) {
setStepError(err);
return;
}
setStepError("");
const next = Math.min(step + 1, STEPS.length) as Step;
setStep(next);
if (next > maxStepReached) setMaxStepReached(next);
}
function goBack() {
setStepError("");
setStep((s) => (Math.max(1, s - 1) as Step));
}
function jumpTo(target: Step) {
if (target > maxStepReached) return;
setStepError("");
setStep(target);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitError("");
for (const s of [1, 2, 3, 4] as Step[]) {
const err = validateStep(s);
if (err) {
setStep(s);
setStepError(err);
if (s > maxStepReached) setMaxStepReached(s);
return;
}
}
setLoading(true);
const fd = new FormData();
fd.set("category", state.category);
fd.set("title", state.title.trim());
fd.set("destination", state.destination.trim());
fd.set("location", state.location.trim());
fd.set("description", state.description.trim());
fd.set("meetingPoint", state.meetingPoint.trim());
fd.set("whatsIncluded", state.whatsIncluded.trim());
fd.set("whatsExcluded", state.whatsExcluded.trim());
fd.set("maxParticipants", state.maxParticipants);
fd.set("price", parseRupiahInput(state.priceDisplay) || "0");
if (state.vibe) fd.set("vibe", state.vibe);
if (startDate) fd.set("date", formatLocalCalendarYmd(startDate));
if (endDate && startDate) {
const startYmd = formatLocalCalendarYmd(startDate);
const endYmd = formatLocalCalendarYmd(endDate);
if (endYmd !== startYmd) fd.set("endDate", endYmd);
}
for (const url of state.imageUrls.map((u) => u.trim()).filter(Boolean)) {
fd.append("imageUrls", url);
}
const itineraryItems = flattenItinerary(state.itineraryDays);
if (itineraryItems.length > 0) {
fd.set("itineraryItems", JSON.stringify(itineraryItems));
}
const result = await createTripAction(fd);
setLoading(false);
if (result.error) {
setSubmitError(result.error);
return;
}
if (result.tripId) {
router.push(`/trips/${result.tripId}`);
}
}
const isLastStep = step === STEPS.length;
return (
<div className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-6">
<Stepper current={step} maxReached={maxStepReached} onJump={jumpTo} />
<form onSubmit={handleSubmit} className="mt-6">
{step === 1 && (
<StepVibe
category={state.category}
vibe={state.vibe}
onCategoryChange={(c) => update("category", c)}
onVibeChange={(v) => update("vibe", v)}
/>
)}
{step === 2 && (
<StepInfo
title={state.title}
destination={state.destination}
location={state.location}
description={state.description}
destinationLabel={meta.destinationLabel}
destinationPlaceholder={meta.destinationPlaceholder}
onChange={update}
/>
)}
{step === 3 && (
<StepDetail
meetingPoint={state.meetingPoint}
itineraryDays={state.itineraryDays}
whatsIncluded={state.whatsIncluded}
whatsExcluded={state.whatsExcluded}
imageUrls={state.imageUrls}
onChange={update}
onError={setStepError}
/>
)}
{step === 4 && (
<StepSchedule
startDate={startDate}
endDate={endDate}
maxParticipants={state.maxParticipants}
priceDisplay={state.priceDisplay}
isVerifiedOrganizer={isVerifiedOrganizer}
blockedByVerification={blockedByVerification}
onDateChange={(s, e) => {
setStartDate(s);
setEndDate(e);
}}
onMaxParticipantsChange={(v) => update("maxParticipants", v)}
onPriceChange={(v) => update("priceDisplay", v)}
summary={{
...state,
startDate,
endDate,
priceNumber,
}}
/>
)}
{(stepError || submitError) && (
<div className="mt-5 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{stepError || submitError}
</div>
)}
<div className="mt-6 flex items-center justify-between gap-3 border-t border-neutral-100 pt-5">
<button
type="button"
onClick={goBack}
disabled={step === 1 || loading}
className="inline-flex items-center gap-1 rounded-xl border border-neutral-200 bg-white px-4 py-2.5 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<ArrowLeft size={15} strokeWidth={2} aria-hidden />
Kembali
</button>
{isLastStep ? (
<button
type="submit"
disabled={loading || blockedByVerification}
className="rounded-xl bg-primary-600 px-5 py-2.5 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"
: "Buat Trip"}
</button>
) : (
<button
type="button"
onClick={goNext}
className="inline-flex items-center gap-1 rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700"
>
Lanjut
<ArrowRight size={15} strokeWidth={2} aria-hidden />
</button>
)}
</div>
</form>
</div>
);
}
function Stepper({
current,
maxReached,
onJump,
}: {
current: Step;
maxReached: Step;
onJump: (s: Step) => void;
}) {
const activeMeta = STEPS[current - 1];
return (
<div>
<ol className="flex items-center">
{STEPS.map((s, idx) => {
const isActive = s.id === current;
const isCompleted = s.id < current;
const canJump = s.id <= maxReached;
const isLast = idx === STEPS.length - 1;
const circleCls = isCompleted
? "bg-primary-600 text-white border-primary-600"
: isActive
? "bg-primary-600 text-white border-primary-600 ring-4 ring-primary-100"
: "bg-white text-neutral-400 border-neutral-200";
return (
<li
key={s.id}
className={`flex items-center ${isLast ? "" : "flex-1"}`}
>
<button
type="button"
onClick={() => canJump && onJump(s.id)}
disabled={!canJump}
aria-current={isActive ? "step" : undefined}
aria-label={`Langkah ${s.id}: ${s.label}`}
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 text-sm font-bold transition-all ${circleCls} ${
canJump
? "cursor-pointer hover:scale-105"
: "cursor-not-allowed"
}`}
>
{isCompleted ? (
<Check size={14} strokeWidth={3} aria-hidden />
) : (
s.id
)}
</button>
<span
className={`ml-2 hidden text-xs font-semibold sm:inline ${
isActive
? "text-primary-700"
: isCompleted
? "text-neutral-700"
: "text-neutral-400"
}`}
>
{s.label}
</span>
{!isLast && (
<div
className={`mx-2 h-0.5 flex-1 sm:mx-3 ${
isCompleted ? "bg-primary-600" : "bg-neutral-200"
}`}
/>
)}
</li>
);
})}
</ol>
<div className="mt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-primary-700">
Langkah {current} dari {STEPS.length}
</p>
<h2 className="mt-0.5 text-lg font-bold text-neutral-900">
{activeMeta.label}
</h2>
<p className="text-xs text-neutral-500">{activeMeta.subtitle}</p>
</div>
</div>
);
}
function StepVibe({
category,
vibe,
onCategoryChange,
onVibeChange,
}: {
category: ActivityCategory;
vibe: Vibe | null;
onCategoryChange: (c: ActivityCategory) => void;
onVibeChange: (v: Vibe | null) => void;
}) {
return (
<div className="space-y-5">
<div className="rounded-xl bg-primary-50 p-4">
<label className="mb-2 block text-sm font-bold text-primary-800">
Kategori Aktivitas
</label>
<div className="flex flex-wrap gap-2">
{ACTIVITY_CATEGORIES.map((c) => {
const m = categoryMeta(c);
const active = c === category;
return (
<button
key={c}
type="button"
onClick={() => onCategoryChange(c)}
aria-pressed={active}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
active
? "border-primary-600 bg-primary-600 text-white"
: "border-primary-200 bg-white text-primary-800 hover:bg-primary-100"
}`}
>
<span>{m.icon}</span>
<span>{m.label}</span>
</button>
);
})}
</div>
</div>
<div className="rounded-xl bg-secondary-50 p-4">
<label className="mb-1 block text-sm font-bold text-secondary-900">
Vibe Trip{" "}
<span className="text-xs font-normal text-secondary-700">
(opsional)
</span>
</label>
<p className="mb-2 text-[11px] text-secondary-700/80">
Bantu calon peserta menilai apakah ritmenya cocok dengan mereka.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => onVibeChange(null)}
aria-pressed={vibe === null}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
vibe === null
? "border-neutral-700 bg-neutral-800 text-white"
: "border-secondary-200 bg-white text-secondary-800 hover:bg-secondary-100"
}`}
>
Belum diisi
</button>
{VIBES.map((v) => {
const m = vibeMeta(v);
const active = v === vibe;
return (
<button
key={v}
type="button"
onClick={() => onVibeChange(v)}
aria-pressed={active}
title={m.description}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
active
? "border-secondary-600 bg-secondary-600 text-white"
: "border-secondary-200 bg-white text-secondary-800 hover:bg-secondary-100"
}`}
>
<span aria-hidden>{m.icon}</span>
<span>{m.label}</span>
</button>
);
})}
</div>
{vibe && (
<p className="mt-2 text-[11px] italic text-secondary-700/80">
{vibeMeta(vibe).description}
</p>
)}
</div>
</div>
);
}
function StepInfo({
title,
destination,
location,
description,
destinationLabel,
destinationPlaceholder,
onChange,
}: {
title: string;
destination: string;
location: string;
description: string;
destinationLabel: string;
destinationPlaceholder: string;
onChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
}) {
return (
<div className="space-y-5">
<div>
<label
htmlFor="title"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Judul Trip
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => onChange("title", e.target.value)}
maxLength={LIMITS.MAX_TITLE_LENGTH}
required
className={INPUT_CLS}
placeholder="contoh: Open Trip Papandayan Weekend"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="destination"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
{destinationLabel}
</label>
<input
id="destination"
type="text"
value={destination}
onChange={(e) => onChange("destination", e.target.value)}
maxLength={LIMITS.MAX_DESTINATION_LENGTH}
required
className={INPUT_CLS}
placeholder={destinationPlaceholder}
/>
</div>
<div>
<label
htmlFor="location"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Lokasi
</label>
<input
id="location"
type="text"
value={location}
onChange={(e) => onChange("location", e.target.value)}
maxLength={LIMITS.MAX_LOCATION_LENGTH}
required
className={INPUT_CLS}
placeholder="Garut, Jawa Barat"
/>
</div>
</div>
<div>
<label
htmlFor="description"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Deskripsi{" "}
<span className="text-xs font-normal text-neutral-400">
(opsional)
</span>
</label>
<textarea
id="description"
rows={4}
value={description}
onChange={(e) => onChange("description", e.target.value)}
maxLength={LIMITS.MAX_DESCRIPTION_LENGTH}
className={INPUT_CLS}
placeholder="Ringkasan trip, vibe, level kesulitan..."
/>
</div>
</div>
);
}
function StepDetail({
meetingPoint,
itineraryDays,
whatsIncluded,
whatsExcluded,
imageUrls,
onChange,
onError,
}: {
meetingPoint: string;
itineraryDays: ItineraryDays;
whatsIncluded: string;
whatsExcluded: string;
imageUrls: string[];
onChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
onError: (msg: string) => void;
}) {
return (
<div className="space-y-5">
<div>
<label
htmlFor="meetingPoint"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Meeting point{" "}
<span className="text-xs font-normal text-neutral-400">
(opsional)
</span>
</label>
<input
id="meetingPoint"
type="text"
value={meetingPoint}
onChange={(e) => onChange("meetingPoint", e.target.value)}
maxLength={LIMITS.MAX_MEETING_POINT_LENGTH}
className={INPUT_CLS}
placeholder="contoh: Alfamart Cicaheum, 05:00 WIB"
/>
</div>
<ItineraryBuilder
days={itineraryDays}
onChange={(next) => onChange("itineraryDays", next)}
/>
<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{" "}
<span className="text-xs font-normal text-neutral-400">
(opsional)
</span>
</label>
<textarea
id="whatsIncluded"
rows={4}
value={whatsIncluded}
onChange={(e) => onChange("whatsIncluded", e.target.value)}
maxLength={LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH}
className={INPUT_CLS}
placeholder="Transport, konsumsi, tenda, …"
/>
</div>
<div>
<label
htmlFor="whatsExcluded"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Tidak termasuk{" "}
<span className="text-xs font-normal text-neutral-400">
(opsional)
</span>
</label>
<textarea
id="whatsExcluded"
rows={4}
value={whatsExcluded}
onChange={(e) => onChange("whatsExcluded", e.target.value)}
maxLength={LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH}
className={INPUT_CLS}
placeholder="Tiket masuk TN, sleeping bag, …"
/>
</div>
</div>
<TripImageUpload
value={imageUrls}
onChange={(urls) => onChange("imageUrls", urls)}
onError={onError}
/>
</div>
);
}
function ItineraryBuilder({
days,
onChange,
}: {
days: ItineraryDays;
onChange: (next: ItineraryDays) => void;
}) {
const totalItems = days.reduce((sum, d) => sum + d.length, 0);
const canAddDay = days.length < LIMITS.MAX_ITINERARY_DAYS;
const canAddItem = totalItems < LIMITS.MAX_ITINERARY_ITEMS;
function addDay() {
if (!canAddDay) return;
onChange([...days, [{ startTime: "", endTime: "", activity: "" }]]);
}
function removeDay(dayIdx: number) {
onChange(days.filter((_, i) => i !== dayIdx));
}
function addItem(dayIdx: number) {
if (!canAddItem) return;
const next = days.map((d, i) =>
i === dayIdx ? [...d, { startTime: "", endTime: "", activity: "" }] : d
);
onChange(next);
}
function removeItem(dayIdx: number, itemIdx: number) {
const next = days.map((d, i) =>
i === dayIdx ? d.filter((_, j) => j !== itemIdx) : d
);
onChange(next);
}
function updateItem(
dayIdx: number,
itemIdx: number,
field: keyof DraftItineraryItem,
value: string
) {
const next = days.map((d, i) =>
i === dayIdx
? d.map((it, j) => (j === itemIdx ? { ...it, [field]: value } : it))
: d
);
onChange(next);
}
return (
<div>
<div className="mb-1.5 flex items-baseline justify-between gap-3">
<label className="block text-sm font-semibold text-neutral-700">
Itinerary per hari{" "}
<span className="text-xs font-normal text-neutral-400">
(opsional)
</span>
</label>
<span className="text-xs text-neutral-400">
{totalItems}/{LIMITS.MAX_ITINERARY_ITEMS}
</span>
</div>
<p className="mb-3 text-[11px] text-neutral-500">
Susun aktivitas per hari + jamnya supaya peserta tahu alur trip.
Itinerary jelas bikin trust naik drastis.
</p>
{days.length === 0 ? (
<div className="rounded-xl border border-dashed border-neutral-300 bg-neutral-50/60 px-4 py-6 text-center">
<p className="mb-3 text-xs text-neutral-500">
Belum ada itinerary. Tambahkan hari pertama untuk mulai.
</p>
<button
type="button"
onClick={addDay}
className="rounded-xl bg-primary-600 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-primary-700"
>
+ Tambah Hari 1
</button>
</div>
) : (
<div className="space-y-4">
{days.map((items, dayIdx) => (
<div
key={dayIdx}
className="rounded-xl border border-neutral-200 bg-white p-3 sm:p-4"
>
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-sm font-bold text-primary-800">
Hari {dayIdx + 1}
</h4>
<button
type="button"
onClick={() => removeDay(dayIdx)}
className="rounded-lg px-2 py-1 text-[11px] font-medium text-red-500 hover:bg-red-50"
>
Hapus hari
</button>
</div>
{items.length === 0 ? (
<p className="mb-2 text-[11px] text-neutral-400">
Belum ada aktivitas di hari ini.
</p>
) : (
<ul className="space-y-2">
{items.map((item, itemIdx) => (
<li
key={itemIdx}
className="rounded-lg border border-neutral-100 bg-neutral-50/60 p-2.5"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="flex shrink-0 gap-2">
<div className="w-32">
<label
htmlFor={`itin-${dayIdx}-${itemIdx}-start`}
className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Mulai
</label>
<TimeField
id={`itin-${dayIdx}-${itemIdx}-start`}
value={item.startTime}
onChange={(v) =>
updateItem(dayIdx, itemIdx, "startTime", v)
}
/>
</div>
<div className="w-32">
<label
htmlFor={`itin-${dayIdx}-${itemIdx}-end`}
className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500"
>
Selesai
</label>
<TimeField
id={`itin-${dayIdx}-${itemIdx}-end`}
value={item.endTime}
onChange={(v) =>
updateItem(dayIdx, itemIdx, "endTime", v)
}
clearable
/>
</div>
</div>
<div className="min-w-0 flex-1">
<label className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
Aktivitas
</label>
<input
type="text"
value={item.activity}
onChange={(e) =>
updateItem(
dayIdx,
itemIdx,
"activity",
e.target.value
)
}
maxLength={LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH}
placeholder="contoh: Trekking ke Pondok Salada"
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400"
/>
</div>
<button
type="button"
onClick={() => removeItem(dayIdx, itemIdx)}
aria-label="Hapus aktivitas"
className="self-end rounded-lg px-2 py-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-500 sm:self-center"
>
<X size={16} strokeWidth={2} aria-hidden />
</button>
</div>
</li>
))}
</ul>
)}
<button
type="button"
onClick={() => addItem(dayIdx)}
disabled={!canAddItem}
className="mt-3 rounded-lg border border-dashed border-primary-300 bg-primary-50/60 px-3 py-1.5 text-xs font-semibold text-primary-700 hover:bg-primary-100 disabled:cursor-not-allowed disabled:opacity-50"
>
+ Tambah aktivitas
</button>
</div>
))}
{canAddDay && (
<button
type="button"
onClick={addDay}
className="w-full rounded-xl border border-dashed border-neutral-300 bg-neutral-50/60 py-3 text-xs font-semibold text-neutral-600 hover:border-primary-400 hover:text-primary-700"
>
+ Tambah Hari {days.length + 1}
</button>
)}
</div>
)}
</div>
);
}
function StepSchedule({
startDate,
endDate,
maxParticipants,
priceDisplay,
isVerifiedOrganizer,
blockedByVerification,
onDateChange,
onMaxParticipantsChange,
onPriceChange,
summary,
}: {
startDate: Date | null;
endDate: Date | null;
maxParticipants: string;
priceDisplay: string;
isVerifiedOrganizer: boolean;
blockedByVerification: boolean;
onDateChange: (start: Date | null, end: Date | null) => void;
onMaxParticipantsChange: (v: string) => void;
onPriceChange: (v: string) => void;
summary: FormState & {
startDate: Date | null;
endDate: Date | null;
priceNumber: number;
};
}) {
function handlePriceChange(e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value.replace(/\D/g, "");
onPriceChange(raw ? formatRupiahInput(raw) : "");
}
return (
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
Tanggal berangkat pulang
</label>
<DateRangeField
startDate={startDate}
endDate={endDate}
onChange={(s, e) => onDateChange(s, e)}
minDate={new Date()}
/>
</div>
<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">
<Users size={16} strokeWidth={1.75} aria-hidden />
</span>
<input
id="maxParticipants"
type="number"
required
min={LIMITS.MIN_PARTICIPANTS}
max={LIMITS.MAX_PARTICIPANTS}
value={maxParticipants}
onChange={(e) => onMaxParticipantsChange(e.target.value)}
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>
<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"
/>
</div>
{blockedByVerification && (
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-amber-700">
<CircleAlert size={14} strokeWidth={2} aria-hidden />
Trip berbayar butuh verifikasi organizer terlebih dahulu.
</p>
)}
</div>
<ReviewSummary summary={summary} />
</div>
);
}
function ReviewSummary({
summary,
}: {
summary: FormState & {
startDate: Date | null;
endDate: Date | null;
priceNumber: number;
};
}) {
const catMeta = categoryMeta(summary.category);
const vibeM = summary.vibe ? vibeMeta(summary.vibe) : null;
const imageCount = summary.imageUrls.filter((u) => u.trim()).length;
const itineraryItemCount = flattenItinerary(summary.itineraryDays).length;
const itineraryDayCount = summary.itineraryDays.filter(
(d) => d.some((it) => it.activity.trim().length > 0)
).length;
const priceLabel =
summary.priceNumber > 0
? `Rp ${summary.priceNumber.toLocaleString("id-ID")}`
: "Gratis";
const dateLabel = (() => {
if (!summary.startDate) return "Belum dipilih";
const opts: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
year: "numeric",
};
const start = summary.startDate.toLocaleDateString("id-ID", opts);
if (
summary.endDate &&
formatLocalCalendarYmd(summary.endDate) !==
formatLocalCalendarYmd(summary.startDate)
) {
const end = summary.endDate.toLocaleDateString("id-ID", opts);
return `${start}${end}`;
}
return start;
})();
return (
<div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-4">
<p className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Ringkasan
</p>
<dl className="grid grid-cols-1 gap-x-4 gap-y-2 text-sm sm:grid-cols-2">
<SummaryRow
label="Kategori"
value={`${catMeta.icon} ${catMeta.label}`}
/>
<SummaryRow
label="Vibe"
value={vibeM ? `${vibeM.icon} ${vibeM.label}` : "—"}
/>
<SummaryRow
label="Judul"
value={summary.title.trim() || "—"}
full
/>
<SummaryRow
label={catMeta.destinationLabel}
value={summary.destination.trim() || "—"}
/>
<SummaryRow label="Lokasi" value={summary.location.trim() || "—"} />
<SummaryRow label="Tanggal" value={dateLabel} />
<SummaryRow
label="Maks peserta"
value={summary.maxParticipants || "—"}
/>
<SummaryRow label="Harga / orang" value={priceLabel} />
<SummaryRow
label="Foto"
value={imageCount > 0 ? `${imageCount} foto` : "Belum ada"}
/>
<SummaryRow
label="Itinerary"
value={
itineraryItemCount > 0
? `${itineraryDayCount} hari · ${itineraryItemCount} aktivitas`
: "Belum ada"
}
/>
</dl>
</div>
);
}
function SummaryRow({
label,
value,
full = false,
}: {
label: string;
value: string;
full?: boolean;
}) {
return (
<div className={full ? "sm:col-span-2" : undefined}>
<dt className="text-[11px] font-medium uppercase tracking-wide text-neutral-500">
{label}
</dt>
<dd className="truncate font-semibold text-neutral-800">{value}</dd>
</div>
);
}