c4efe4453b
- ✅ - ✅ - ✅
1233 lines
40 KiB
TypeScript
1233 lines
40 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, 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";
|
||
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>
|
||
<label className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||
Mulai
|
||
</label>
|
||
<input
|
||
type="time"
|
||
value={item.startTime}
|
||
onChange={(e) =>
|
||
updateItem(
|
||
dayIdx,
|
||
itemIdx,
|
||
"startTime",
|
||
e.target.value
|
||
)
|
||
}
|
||
className="w-30 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-0.5 block text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||
Selesai
|
||
</label>
|
||
<input
|
||
type="time"
|
||
value={item.endTime}
|
||
onChange={(e) =>
|
||
updateItem(
|
||
dayIdx,
|
||
itemIdx,
|
||
"endTime",
|
||
e.target.value
|
||
)
|
||
}
|
||
className="w-30 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 focus:border-primary-400"
|
||
/>
|
||
</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>
|
||
<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={(dates) => {
|
||
const [s, e] = dates as [Date | null, Date | null];
|
||
onDateChange(s, e);
|
||
}}
|
||
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>
|
||
|
||
<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>
|
||
);
|
||
}
|