- 
- 
- 
This commit is contained in:
2026-05-18 18:31:16 +07:00
parent b599d01eea
commit c4efe4453b
36 changed files with 3057 additions and 1493 deletions
+22 -4
View File
@@ -2,7 +2,11 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { createTripSchema, tripImageUrlsSchema } from "./schemas";
import {
createTripSchema,
itineraryItemsSchema,
tripImageUrlsSchema,
} from "./schemas";
import { tripService } from "@/server/services/trip.service";
import { organizerService } from "@/server/services/organizer.service";
import { revalidatePath } from "next/cache";
@@ -21,7 +25,6 @@ export async function createTripAction(formData: FormData) {
destination: formData.get("destination") as string,
location: formData.get("location") as string,
meetingPoint: formData.get("meetingPoint") as string,
itinerary: formData.get("itinerary") as string,
whatsIncluded: formData.get("whatsIncluded") as string,
whatsExcluded: formData.get("whatsExcluded") as string,
date: formData.get("date") as string,
@@ -36,6 +39,22 @@ export async function createTripAction(formData: FormData) {
return { error: result.error.issues[0].message };
}
const itineraryJson = formData.get("itineraryItems");
let itineraryItems: ReturnType<typeof itineraryItemsSchema.parse> = [];
if (typeof itineraryJson === "string" && itineraryJson.trim().length > 0) {
let parsed: unknown;
try {
parsed = JSON.parse(itineraryJson);
} catch {
return { error: "Format itinerary tidak valid" };
}
const itineraryParsed = itineraryItemsSchema.safeParse(parsed);
if (!itineraryParsed.success) {
return { error: itineraryParsed.error.issues[0].message };
}
itineraryItems = itineraryParsed.data;
}
if (result.data.price > 0) {
const approved = await organizerService.isApproved(session.user.id);
if (!approved) {
@@ -69,7 +88,6 @@ export async function createTripAction(formData: FormData) {
try {
const {
meetingPoint,
itinerary,
whatsIncluded,
whatsExcluded,
...tripCore
@@ -78,13 +96,13 @@ export async function createTripAction(formData: FormData) {
const trip = await tripService.createTrip({
...tripCore,
meetingPoint,
itinerary,
whatsIncluded,
whatsExcluded,
date,
endDate,
organizerId: session.user.id,
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
itineraryItems: itineraryItems.length > 0 ? itineraryItems : undefined,
});
revalidatePath("/trips");
revalidatePath("/");
File diff suppressed because it is too large Load Diff
+22 -13
View File
@@ -1,24 +1,31 @@
"use client";
import { useState } from "react";
import { LIMITS } from "@/lib/limits";
export function ImageUrlInput() {
const [urls, setUrls] = useState<string[]>([""]);
interface ImageUrlInputProps {
value: string[];
onChange: (urls: string[]) => void;
}
export function ImageUrlInput({ value, onChange }: ImageUrlInputProps) {
const urls = value.length > 0 ? value : [""];
const max = LIMITS.MAX_IMAGE_URLS;
function addField() {
if (urls.length < 5) {
setUrls([...urls, ""]);
if (urls.length < max) {
onChange([...urls, ""]);
}
}
function removeField(index: number) {
setUrls(urls.filter((_, i) => i !== index));
const next = urls.filter((_, i) => i !== index);
onChange(next.length > 0 ? next : [""]);
}
function updateField(index: number, value: string) {
function updateField(index: number, next: string) {
const updated = [...urls];
updated[index] = value;
setUrls(updated);
updated[index] = next;
onChange(updated);
}
return (
@@ -27,13 +34,14 @@ export function ImageUrlInput() {
<span className="text-sm font-semibold text-neutral-700">
Foto Trip (URL)
</span>
<span className="text-xs text-neutral-400">{urls.length}/5</span>
<span className="text-xs text-neutral-400">
{urls.length}/{max}
</span>
</label>
<div className="space-y-2">
{urls.map((url, i) => (
<div key={i} className="flex gap-2">
<input
name="imageUrls"
type="url"
value={url}
onChange={(e) => updateField(i, e.target.value)}
@@ -48,6 +56,7 @@ export function ImageUrlInput() {
<button
type="button"
onClick={() => removeField(i)}
aria-label={`Hapus foto ${i + 1}`}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-neutral-200 text-neutral-400 hover:bg-red-50 hover:text-red-500"
>
@@ -56,7 +65,7 @@ export function ImageUrlInput() {
</div>
))}
</div>
{urls.length < 5 && (
{urls.length < max && (
<button
type="button"
onClick={addField}
@@ -66,7 +75,7 @@ export function ImageUrlInput() {
</button>
)}
<p className="mt-1.5 text-xs text-neutral-400">
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya di sini
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya di sini.
</p>
</div>
);
+22 -21
View File
@@ -5,6 +5,15 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { joinTripAction, cancelJoinAction } from "@/features/trip/actions";
type BookingStatus =
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
interface JoinTripButtonProps {
tripId: string;
isLoggedIn: boolean;
@@ -14,11 +23,8 @@ interface JoinTripButtonProps {
isFree: boolean;
/** Status partisipasi user saat isJoined (bukan organizer) */
participationStatus?: "PENDING" | "CONFIRMED" | null;
/** Status pembayaran manual (peserta). Hanya relevan untuk trip berbayar. */
participantPayment?: {
markedPaidAt: string | Date | null;
paymentConfirmedAt: string | Date | null;
} | null;
/** Status booking peserta (hanya relevan untuk trip berbayar). */
bookingStatus?: BookingStatus | null;
isFull: boolean;
tripStatus: string;
/** Tanggal berangkat trip sudah lewat */
@@ -35,7 +41,7 @@ export function JoinTripButton({
isJoined,
isFree,
participationStatus,
participantPayment,
bookingStatus,
isFull,
tripStatus,
isDeparturePast,
@@ -114,11 +120,9 @@ export function JoinTripButton({
}
}
const pay = participantPayment;
const showPaymentLink = !isFree && isJoined && !isDeparturePast;
const waitingPaymentConfirm =
!isFree && isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt;
const paymentDone = !isFree && isJoined && pay && pay.paymentConfirmedAt;
const needsPayment = !isFree && isJoined && bookingStatus === "AWAITING_PAY";
const paymentDone = !isFree && isJoined && bookingStatus === "PAID";
const showPaymentLink = (needsPayment || paymentDone) && !isDeparturePast;
return (
<div>
@@ -142,16 +146,17 @@ export function JoinTripButton({
{isFree && <span> trip gratis, tidak ada pembayaran 🎉</span>}.
</div>
)}
{waitingPaymentConfirm && (
<div className="mb-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-3 text-sm font-medium leading-relaxed text-primary-950">
Kamu sudah menandai <span className="font-semibold">sudah bayar</span>.
Tunggu organizer mengonfirmasi pembayaran.
{needsPayment && (
<div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium leading-relaxed text-amber-900">
Selesaikan pembayaran lewat{" "}
<span className="font-semibold">Midtrans</span> untuk mengamankan slot
kamu.
</div>
)}
{paymentDone && (
<div className="mb-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-900">
Pembayaran kamu sudah{" "}
<span className="font-semibold">dikonfirmasi organizer</span>.
<span className="font-semibold">terkonfirmasi</span>.
</div>
)}
{showPaymentLink && (
@@ -159,11 +164,7 @@ export function JoinTripButton({
href={`/trips/${tripId}/payment`}
className="mb-3 block w-full rounded-xl border-2 border-primary-500 bg-white py-3 text-center text-sm font-bold text-primary-700 transition-colors hover:bg-primary-50"
>
{paymentDone
? "Lihat detail pembayaran"
: pay?.markedPaidAt
? "Lihat status pembayaran"
: "Buka detail pembayaran"}
{paymentDone ? "Lihat detail pembayaran" : "Bayar sekarang"}
</Link>
)}
{isJoined ? (
@@ -11,8 +11,6 @@ import {
export interface PendingJoinRequest {
id: string;
user: { name: string; image: string | null };
/** Peserta sudah menekan &quot;Saya sudah bayar&quot; */
markedPaidAt?: string | Date | null;
}
interface OrganizerJoinRequestsProps {
@@ -83,14 +81,7 @@ export function OrganizerJoinRequests({
<p className="truncate text-sm font-semibold text-neutral-800">
{p.user.name}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-xs text-amber-800/80">Menunggu persetujuan</p>
{p.markedPaidAt ? (
<span className="rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold text-primary-800">
Sudah tandai bayar
</span>
) : null}
</div>
<p className="text-xs text-amber-800/80">Menunggu persetujuan</p>
</div>
</div>
<div className="flex shrink-0 gap-2">
@@ -1,6 +1,17 @@
import { groupItineraryByDay } from "@/lib/itinerary";
interface ItineraryItem {
day: number;
startTime: string;
endTime: string | null;
activity: string;
order: number;
}
interface TripProgramBlockProps {
meetingPoint: string | null;
itinerary: string | null;
itineraryItems: ItineraryItem[];
whatsIncluded: string | null;
whatsExcluded: string | null;
}
@@ -8,13 +19,24 @@ interface TripProgramBlockProps {
export function TripProgramBlock({
meetingPoint,
itinerary,
itineraryItems,
whatsIncluded,
whatsExcluded,
}: TripProgramBlockProps) {
const hasStructuredItinerary = itineraryItems.length > 0;
const hasLegacyItinerary = !hasStructuredItinerary && !!itinerary;
const hasAny =
meetingPoint || itinerary || whatsIncluded || whatsExcluded;
meetingPoint ||
hasStructuredItinerary ||
hasLegacyItinerary ||
whatsIncluded ||
whatsExcluded;
if (!hasAny) return null;
const grouped = hasStructuredItinerary
? groupItineraryByDay(itineraryItems)
: null;
return (
<div className="space-y-4 rounded-xl border border-neutral-200 bg-neutral-50/50 p-4 sm:p-5">
<h2 className="text-xs font-bold text-neutral-800 sm:text-sm">
@@ -32,7 +54,41 @@ export function TripProgramBlock({
</div>
)}
{itinerary && (
{grouped && (
<div>
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
Itinerary
</h3>
<div className="space-y-3">
{[...grouped.entries()].map(([day, items]) => (
<div
key={day}
className="rounded-lg border border-primary-100 bg-white p-3 sm:p-4"
>
<p className="mb-2 text-xs font-bold text-primary-800 sm:text-sm">
Hari {day}
</p>
<ol className="space-y-2">
{items.map((item) => (
<li
key={item.order}
className="flex gap-3 text-xs leading-relaxed text-neutral-700 sm:text-sm"
>
<span className="shrink-0 font-mono text-[11px] font-semibold text-primary-700 sm:text-xs">
{item.startTime}
{item.endTime ? `${item.endTime}` : ""}
</span>
<span className="min-w-0 flex-1">{item.activity}</span>
</li>
))}
</ol>
</div>
))}
</div>
</div>
)}
{hasLegacyItinerary && (
<div>
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
Itinerary
+70 -14
View File
@@ -7,6 +7,7 @@ import {
isTripDepartureDayPast,
tripStoredInstantFromYmd,
} from "@/lib/trip-dates";
import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary";
export const tripImageUrlsSchema = z
.array(
@@ -18,6 +19,75 @@ export const tripImageUrlsSchema = z
)
.max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`);
export const itineraryItemSchema = z
.object({
day: z.coerce
.number()
.int("Nomor hari tidak valid")
.min(1, "Nomor hari minimal 1")
.max(
LIMITS.MAX_ITINERARY_DAYS,
`Maksimal ${LIMITS.MAX_ITINERARY_DAYS} hari`
),
startTime: z
.string()
.trim()
.refine(isValidTimeFormat, "Format jam mulai harus HH:mm"),
endTime: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.refine(isValidTimeFormat, "Format jam selesai harus HH:mm")
.optional()
),
activity: z
.string()
.trim()
.min(1, "Deskripsi aktivitas harus diisi")
.max(
LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH,
`Aktivitas maksimal ${LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH} karakter`
),
})
.superRefine((data, ctx) => {
if (
data.endTime &&
timeToMinutes(data.endTime) < timeToMinutes(data.startTime)
) {
ctx.addIssue({
code: "custom",
message: "Jam selesai tidak boleh sebelum jam mulai",
path: ["endTime"],
});
}
});
export const itineraryItemsSchema = z
.array(itineraryItemSchema)
.max(
LIMITS.MAX_ITINERARY_ITEMS,
`Maksimal ${LIMITS.MAX_ITINERARY_ITEMS} aktivitas total`
)
.superRefine((items, ctx) => {
if (items.length === 0) return;
const days = [...new Set(items.map((i) => i.day))].sort((a, b) => a - b);
for (let i = 0; i < days.length; i++) {
if (days[i] !== i + 1) {
ctx.addIssue({
code: "custom",
message:
"Nomor hari harus berurutan mulai dari Hari 1 (tidak boleh lompat)",
path: [0, "day"],
});
return;
}
}
});
export const createTripSchema = z
.object({
category: z.enum(
@@ -105,20 +175,6 @@ export const createTripSchema = z
)
.optional()
),
itinerary: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.max(
LIMITS.MAX_TRIP_ITINERARY_LENGTH,
`Itinerary maksimal ${LIMITS.MAX_TRIP_ITINERARY_LENGTH} karakter`
)
.optional()
),
whatsIncluded: z.preprocess(
(val) => {
if (val == null) return undefined;