- ✅
- ✅ - ✅ - ✅
This commit is contained in:
+27
-32
@@ -2,30 +2,12 @@
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { paymentService } from "@/server/services/payment.service";
|
||||
import { bookingService } from "@/server/services/booking.service";
|
||||
import { refundService } from "@/server/services/refund.service";
|
||||
import { absoluteUrl } from "@/lib/site";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function markParticipantPaidAction(tripId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
|
||||
try {
|
||||
await tripService.markParticipantPayment(tripId, session.user.id);
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/profile");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export type StartMidtransResponse =
|
||||
| { error: string }
|
||||
| {
|
||||
@@ -33,6 +15,7 @@ export type StartMidtransResponse =
|
||||
snapToken: string;
|
||||
snapJsUrl: string;
|
||||
clientKey: string;
|
||||
orderId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -58,39 +41,51 @@ export async function startMidtransPaymentAction(
|
||||
|
||||
const result = await paymentService.startMidtransPayment(
|
||||
booking.id,
|
||||
session.user.id
|
||||
session.user.id,
|
||||
{ finishUrl: absoluteUrl(`/trips/${tripId}/payment`) }
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
snapToken: result.snapToken,
|
||||
snapJsUrl: result.snapJsUrl,
|
||||
clientKey: result.clientKey,
|
||||
orderId: result.orderId,
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmParticipantPaymentAction(
|
||||
tripId: string,
|
||||
participantId: string
|
||||
) {
|
||||
/**
|
||||
* Tarik status terkini dari Midtrans untuk satu order, lalu sinkron ke DB.
|
||||
* Dipakai oleh payment page saat user kembali dari Snap (redirect bawa
|
||||
* `?order_id=...`), dan oleh `MidtransPayButton` di callback `onSuccess`/
|
||||
* `onPending`/`onClose` agar UI ter-update tanpa menunggu webhook.
|
||||
*/
|
||||
export async function reconcileMidtransPaymentAction(orderId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
if (!orderId || typeof orderId !== "string") {
|
||||
return { error: "order_id tidak valid" };
|
||||
}
|
||||
|
||||
try {
|
||||
await tripService.confirmParticipantPayment(
|
||||
tripId,
|
||||
participantId,
|
||||
const result = await paymentService.reconcileFromGateway(
|
||||
orderId,
|
||||
session.user.id
|
||||
);
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/profile");
|
||||
return { success: true };
|
||||
if (!result.ok) {
|
||||
if (result.reason === "forbidden") {
|
||||
return { error: "Order ini bukan milikmu" };
|
||||
}
|
||||
if (result.reason === "not_found") {
|
||||
return { error: "Order tidak ditemukan" };
|
||||
}
|
||||
return { error: "Status pembayaran tidak cocok dengan tagihan" };
|
||||
}
|
||||
return { success: true as const, status: result.status };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markParticipantPaidAction } from "@/features/booking/actions";
|
||||
|
||||
interface MarkPaidButtonProps {
|
||||
tripId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MarkPaidButton({ tripId, disabled }: MarkPaidButtonProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const result = await markParticipantPaidAction(tripId);
|
||||
setLoading(false);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && (
|
||||
<div className="mb-3 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={loading || disabled}
|
||||
className="w-full rounded-xl bg-primary-600 py-3 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 ? "Memproses..." : "Saya sudah bayar"}
|
||||
</button>
|
||||
<p className="mt-2 text-center text-[11px] text-neutral-500">
|
||||
Tekan setelah kamu transfer ke rekening di atas. Organizer akan cek &
|
||||
konfirmasi pembayaran kamu.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startMidtransPaymentAction } from "@/features/booking/actions";
|
||||
import {
|
||||
reconcileMidtransPaymentAction,
|
||||
startMidtransPaymentAction,
|
||||
} from "@/features/booking/actions";
|
||||
|
||||
interface SnapCallbacks {
|
||||
onSuccess?: (result: unknown) => void;
|
||||
@@ -86,23 +89,25 @@ export function MidtransPayButton({ tripId }: MidtransPayButtonProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderId = result.orderId;
|
||||
// Tarik status terkini dari Midtrans server-side, lalu refresh halaman.
|
||||
// Tidak menunggu webhook supaya UI ter-update saat webhook belum sampai
|
||||
// (mis. di localhost) atau redirect flow di mana popup tidak dipakai.
|
||||
async function reconcileAndRefresh() {
|
||||
await reconcileMidtransPaymentAction(orderId);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
window.snap.pay(result.snapToken, {
|
||||
onSuccess: () => {
|
||||
// Webhook server akan tetap jadi sumber kebenaran. Refresh page untuk pull state baru.
|
||||
router.refresh();
|
||||
},
|
||||
onPending: () => router.refresh(),
|
||||
onSuccess: reconcileAndRefresh,
|
||||
onPending: reconcileAndRefresh,
|
||||
onError: () => {
|
||||
setError(
|
||||
"Pembayaran gagal diproses. Coba lagi atau pakai metode lain."
|
||||
);
|
||||
router.refresh();
|
||||
},
|
||||
onClose: () => {
|
||||
// User menutup popup tanpa menyelesaikan. Refresh saja, kalau status berubah
|
||||
// (mis. user sudah bayar VA) callback dari Midtrans akan datang ke webhook.
|
||||
router.refresh();
|
||||
void reconcileAndRefresh();
|
||||
},
|
||||
onClose: reconcileAndRefresh,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { confirmParticipantPaymentAction } from "@/features/booking/actions";
|
||||
|
||||
export interface PaymentPendingParticipant {
|
||||
id: string;
|
||||
user: { name: string; image: string | null };
|
||||
/** PENDING atau CONFIRMED (join) — keduanya bisa sudah tandai bayar */
|
||||
joinStatus: "PENDING" | "CONFIRMED";
|
||||
}
|
||||
|
||||
interface OrganizerPaymentQueueProps {
|
||||
tripId: string;
|
||||
items: PaymentPendingParticipant[];
|
||||
}
|
||||
|
||||
export function OrganizerPaymentQueue({
|
||||
tripId,
|
||||
items,
|
||||
}: OrganizerPaymentQueueProps) {
|
||||
const router = useRouter();
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function confirm(participantId: string) {
|
||||
setLoadingId(participantId);
|
||||
setError("");
|
||||
const result = await confirmParticipantPaymentAction(tripId, participantId);
|
||||
setLoadingId(null);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-primary-200 bg-primary-50/60 p-4 sm:p-5">
|
||||
<h2 className="text-sm font-bold text-primary-950 sm:text-base">
|
||||
Konfirmasi pembayaran ({items.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-primary-900/85 sm:text-sm">
|
||||
Peserta sudah menandai pembayaran. Cek rekening atau bukti transfer,
|
||||
lalu konfirmasi.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="mt-3 rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700 sm:text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ul className="mt-4 space-y-3">
|
||||
{items.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex flex-col gap-3 rounded-xl border border-primary-100 bg-white/95 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{p.user.image ? (
|
||||
<Image
|
||||
src={p.user.image}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
|
||||
{p.user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-neutral-800">
|
||||
{p.user.name}
|
||||
</p>
|
||||
<p className="text-xs text-primary-800/90">
|
||||
Menunggu konfirmasi pembayaran
|
||||
{p.joinStatus === "PENDING" && (
|
||||
<span className="text-neutral-500">
|
||||
{" "}
|
||||
· belum disetujui ikut trip
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingId !== null}
|
||||
onClick={() => confirm(p.id)}
|
||||
className="shrink-0 rounded-lg bg-primary-600 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-primary-700 disabled:opacity-50 sm:text-sm"
|
||||
>
|
||||
{loadingId === p.id ? "Memproses…" : "Konfirmasi pembayaran"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,20 +20,17 @@ export function ProfileTripRow({
|
||||
rightSlot,
|
||||
}: ProfileTripRowProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 transition-colors hover:border-primary-200 hover:bg-primary-50/40 sm:px-4 sm:py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 transition-colors hover:border-primary-200 hover:bg-primary-50/40 sm:px-4 sm:py-3">
|
||||
<Link href={href} className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-neutral-800">{title}</p>
|
||||
<p className="truncate text-xs text-neutral-500">{destination}</p>
|
||||
<p className="mt-0.5 text-[11px] text-neutral-400 sm:text-xs">
|
||||
{formatTripCalendarDateRangeLong(date, endDate)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
{rightSlot && (
|
||||
<div className="shrink-0 text-right text-xs font-medium">{rightSlot}</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 "Saya sudah bayar" */
|
||||
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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user