Files
setrip/features/trip/components/create-trip-form.tsx
T

1203 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { DateRangeField, TimeField } from "@/components/shared/date-picker";
import { createTripAction } from "@/features/trip/actions";
import { ImageUrlInput } from "@/features/trip/components/image-url-input";
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) {
const hasInvalidUrl = state.imageUrls
.map((u) => u.trim())
.filter(Boolean)
.some((u) => {
try {
const parsed = new URL(u);
return parsed.protocol !== "http:" && parsed.protocol !== "https:";
} catch {
return true;
}
});
if (hasInvalidUrl) return "Ada URL foto yang tidak valid (harus http/https)";
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}
/>
)}
{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="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"
>
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="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
</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 ? "✓" : 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,
}: {
meetingPoint: string;
itineraryDays: ItineraryDays;
whatsIncluded: string;
whatsExcluded: string;
imageUrls: string[];
onChange: <K extends keyof FormState>(key: K, value: FormState[K]) => 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>
<ImageUrlInput
value={imageUrls}
onChange={(urls) => onChange("imageUrls", urls)}
/>
</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"
>
</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">
<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"
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 text-xs font-medium text-amber-700">
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>
);
}