add payment, trust badge, handle race condition, fix booking schema

This commit is contained in:
arifal
2026-04-20 23:57:31 +07:00
parent ba5f64ae0e
commit fcdca34460
33 changed files with 1781 additions and 138 deletions
+67 -1
View File
@@ -18,6 +18,10 @@ export async function createTripAction(formData: FormData) {
description: formData.get("description") as string,
mountain: formData.get("mountain") 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,
endDate: (formData.get("endDate") as string) || undefined,
maxParticipants: formData.get("maxParticipants") as string,
@@ -50,8 +54,20 @@ export async function createTripAction(formData: FormData) {
}
try {
const {
meetingPoint,
itinerary,
whatsIncluded,
whatsExcluded,
...tripCore
} = result.data;
const trip = await tripService.createTrip({
...result.data,
...tripCore,
meetingPoint,
itinerary,
whatsIncluded,
whatsExcluded,
date,
endDate,
organizerId: session.user.id,
@@ -101,3 +117,53 @@ export async function cancelJoinAction(tripId: string) {
return { error: (err as Error).message };
}
}
export async function confirmParticipantAction(
tripId: string,
participantId: string
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
try {
await tripService.confirmParticipant(
tripId,
participantId,
session.user.id
);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
export async function rejectParticipantAction(
tripId: string,
participantId: string
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
try {
await tripService.rejectParticipant(
tripId,
participantId,
session.user.id
);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
+70 -1
View File
@@ -4,15 +4,23 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { joinTripAction, cancelJoinAction } from "@/features/trip/actions";
import { markParticipantPaidAction } from "@/features/booking/actions";
interface JoinTripButtonProps {
tripId: string;
isLoggedIn: boolean;
isOrganizer: boolean;
isJoined: boolean;
/** Status partisipasi user saat isJoined (bukan organizer) */
participationStatus?: "PENDING" | "CONFIRMED" | null;
/** Status pembayaran manual (peserta) */
participantPayment?: {
markedPaidAt: string | Date | null;
paymentConfirmedAt: string | Date | null;
} | null;
isFull: boolean;
tripStatus: string;
/** Tanggal berangkat sudah lewat (hari kalender UTC) */
/** Tanggal berangkat trip sudah lewat */
isDeparturePast?: boolean;
}
@@ -21,6 +29,8 @@ export function JoinTripButton({
isLoggedIn,
isOrganizer,
isJoined,
participationStatus,
participantPayment,
isFull,
tripStatus,
isDeparturePast,
@@ -98,6 +108,29 @@ export function JoinTripButton({
}
}
async function handleMarkPaid() {
setLoading(true);
setError("");
const result = await markParticipantPaidAction(tripId);
setLoading(false);
if (result.error) {
setError(result.error);
} else {
router.refresh();
}
}
const pay = participantPayment;
const showMarkPaid =
isJoined &&
pay &&
!pay.paymentConfirmedAt &&
!pay.markedPaidAt &&
!isDeparturePast;
const waitingPaymentConfirm =
isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt;
const paymentDone = isJoined && pay && pay.paymentConfirmedAt;
return (
<div>
{error && (
@@ -105,6 +138,42 @@ export function JoinTripButton({
{error}
</div>
)}
{isJoined && participationStatus === "PENDING" && (
<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">
Permintaan ikut trip kamu{" "}
<span className="font-semibold">menunggu persetujuan organizer</span>.
Kamu bisa membatalkan kapan saja sebelum disetujui.
</div>
)}
{isJoined && participationStatus === "CONFIRMED" && (
<div className="mb-3 rounded-xl border border-secondary-200 bg-secondary-50 px-4 py-3 text-sm font-medium text-secondary-900">
Kamu sudah{" "}
<span className="font-semibold">terkonfirmasi</span> sebagai peserta
trip ini.
</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.
</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>.
</div>
)}
{showMarkPaid && (
<button
type="button"
onClick={handleMarkPaid}
disabled={loading}
className="mb-3 w-full rounded-xl border-2 border-primary-500 bg-white py-3 text-sm font-bold text-primary-700 transition-colors hover:bg-primary-50 disabled:opacity-50"
>
{loading ? "Memproses..." : "Saya sudah bayar"}
</button>
)}
{isJoined ? (
<button
onClick={handleCancel}
@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import {
confirmParticipantAction,
rejectParticipantAction,
} from "@/features/trip/actions";
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 {
tripId: string;
pending: PendingJoinRequest[];
}
export function OrganizerJoinRequests({
tripId,
pending,
}: OrganizerJoinRequestsProps) {
const router = useRouter();
const [loadingId, setLoadingId] = useState<string | null>(null);
const [error, setError] = useState("");
async function run(
participantId: string,
action: "confirm" | "reject"
) {
setLoadingId(participantId);
setError("");
const result =
action === "confirm"
? await confirmParticipantAction(tripId, participantId)
: await rejectParticipantAction(tripId, participantId);
setLoadingId(null);
if (result.error) {
setError(result.error);
return;
}
router.refresh();
}
return (
<div className="rounded-xl border border-amber-200 bg-amber-50/70 p-4 sm:p-5">
<h2 className="text-sm font-bold text-amber-950 sm:text-base">
Permintaan join ({pending.length})
</h2>
<p className="mt-1 text-xs text-amber-900/80 sm:text-sm">
Setujui atau tolak siapa yang boleh ikut trip ini.
</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">
{pending.map((p) => (
<li
key={p.id}
className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-white/90 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>
<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>
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
disabled={loadingId !== null}
onClick={() => run(p.id, "reject")}
className="rounded-lg border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold text-neutral-600 hover:bg-neutral-50 disabled:opacity-50 sm:text-sm"
>
{loadingId === p.id ? "…" : "Tolak"}
</button>
<button
type="button"
disabled={loadingId !== null}
onClick={() => run(p.id, "confirm")}
className="rounded-lg bg-primary-600 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-primary-700 disabled:opacity-50 sm:text-sm"
>
{loadingId === p.id ? "Memproses…" : "Setujui"}
</button>
</div>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,79 @@
import Image from "next/image";
import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerTrustPanelProps {
name: string;
image: string | null;
trust: OrganizerTrust;
}
export function OrganizerTrustPanel({
name,
image,
trust,
}: OrganizerTrustPanelProps) {
return (
<div className="rounded-xl border border-neutral-200 bg-linear-to-br from-white to-neutral-50 p-4 sm:p-5">
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
Organizer & kepercayaan
</h2>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex items-center gap-3">
{image ? (
<Image
src={image}
alt=""
width={48}
height={48}
className="h-12 w-12 shrink-0 rounded-full object-cover ring-2 ring-white shadow"
/>
) : (
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary-600 text-lg font-bold text-white shadow">
{name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-bold text-neutral-900 sm:text-base">
{name}
</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{trust.isVerified && (
<span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-blue-800 sm:text-xs">
Verified
</span>
)}
{trust.isTripLeader && (
<span className="inline-flex items-center rounded-full bg-secondary-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-secondary-900 sm:text-xs">
Trip leader
</span>
)}
</div>
</div>
</div>
<div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0">
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Trip dibuat
</p>
<p className="text-lg font-bold text-neutral-800">
{trust.tripsCreated}
</p>
</div>
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Rating organizer
</p>
<p className="text-lg font-bold text-amber-700">
{trust.avgRating != null ? `${trust.avgRating}` : "—"}
</p>
{trust.reviewCount > 0 && (
<p className="text-[10px] text-neutral-400">
dari {trust.reviewCount} ulasan trip
</p>
)}
</div>
</div>
</div>
</div>
);
}
+3 -2
View File
@@ -1,6 +1,7 @@
import Image from "next/image";
import Link from "next/link";
import { formatRupiah, formatDateRange } from "@/lib/utils";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
interface TripCardProps {
id: string;
@@ -80,7 +81,7 @@ export function TripCard({
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">📅</span>{" "}
{formatDateRange(date, endDate)}
{formatTripCalendarDateRangeLong(date, endDate)}
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">👤</span>{" "}
+4 -7
View File
@@ -4,6 +4,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { formatLocalCalendarYmd } from "@/lib/trip-dates";
export function TripFilter() {
const router = useRouter();
@@ -35,8 +36,8 @@ export function TripFilter() {
e.preventDefault();
const params = new URLSearchParams();
if (query.trim()) params.set("q", query.trim());
if (startDate) params.set("from", startDate.toISOString().split("T")[0]);
if (endDate) params.set("to", endDate.toISOString().split("T")[0]);
if (startDate) params.set("from", formatLocalCalendarYmd(startDate));
if (endDate) params.set("to", formatLocalCalendarYmd(endDate));
const qs = params.toString();
router.push(`/trips${qs ? `?${qs}` : ""}`);
@@ -90,12 +91,8 @@ export function TripFilter() {
{/* Date range */}
<div className="sm:w-64">
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
Rentang tanggal (UTC)
Tanggal
</label>
<p className="mb-1 text-[10px] leading-snug text-neutral-400 sm:text-xs">
Menampilkan trip yang jadwalnya overlap rentang ini: multi hari pakai
tanggal pulang; satu hari pakai tanggal berangkat saja.
</p>
<div className="relative">
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg
@@ -0,0 +1,72 @@
interface TripProgramBlockProps {
meetingPoint: string | null;
itinerary: string | null;
whatsIncluded: string | null;
whatsExcluded: string | null;
}
export function TripProgramBlock({
meetingPoint,
itinerary,
whatsIncluded,
whatsExcluded,
}: TripProgramBlockProps) {
const hasAny =
meetingPoint || itinerary || whatsIncluded || whatsExcluded;
if (!hasAny) return 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">
Detail perjalanan
</h2>
{meetingPoint && (
<div>
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
Meeting point
</h3>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
{meetingPoint}
</p>
</div>
)}
{itinerary && (
<div>
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
Itinerary
</h3>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
{itinerary}
</p>
</div>
)}
{(whatsIncluded || whatsExcluded) && (
<div className="grid gap-4 sm:grid-cols-2">
{whatsIncluded && (
<div className="rounded-lg border border-secondary-200 bg-white p-3">
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-wide text-secondary-800 sm:text-xs">
Termasuk
</h3>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
{whatsIncluded}
</p>
</div>
)}
{whatsExcluded && (
<div className="rounded-lg border border-neutral-200 bg-white p-3">
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-wide text-neutral-600 sm:text-xs">
Tidak termasuk
</h3>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
{whatsExcluded}
</p>
</div>
)}
</div>
)}
</div>
);
}
+56
View File
@@ -84,6 +84,62 @@ export const createTripSchema = z
LIMITS.MAX_PRICE_IDR,
`Harga maksimal Rp ${LIMITS.MAX_PRICE_IDR.toLocaleString("id-ID")}`
),
meetingPoint: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.max(
LIMITS.MAX_MEETING_POINT_LENGTH,
`Meeting point maksimal ${LIMITS.MAX_MEETING_POINT_LENGTH} karakter`
)
.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;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.max(
LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH,
`Bagian 'Termasuk' maksimal ${LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH} karakter`
)
.optional()
),
whatsExcluded: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.max(
LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH,
`Bagian 'Tidak termasuk' maksimal ${LIMITS.MAX_TRIP_BULLET_SECTION_LENGTH} karakter`
)
.optional()
),
})
.superRefine((data, ctx) => {
const dep = tripStoredInstantFromYmd(data.date);