create payment roadmap pr a

This commit is contained in:
2026-05-08 20:43:14 +07:00
parent ccb3437e82
commit d4db13778a
10 changed files with 823 additions and 51 deletions
@@ -0,0 +1,32 @@
"use client";
import { useState } from "react";
interface CopyButtonProps {
value: string;
label?: string;
}
export function CopyButton({ value, label = "Salin" }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
async function handleClick() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// ignore — user can copy manually
}
}
return (
<button
type="button"
onClick={handleClick}
className="rounded-lg border border-neutral-200 bg-white px-2.5 py-1 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-50"
>
{copied ? "✓ Tersalin" : label}
</button>
);
}
@@ -0,0 +1,50 @@
"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>
);
}
+18 -14
View File
@@ -63,7 +63,8 @@ export function ReviewCard({ verification }: { verification: Verification }) {
{verification.user.name}
</h3>
<p className="text-xs text-neutral-500">
{verification.user.email} · diajukan {formatDate(verification.createdAt)}
{verification.user.email} · diajukan{" "}
{formatDate(verification.createdAt)}
</p>
</div>
<StatusPill status={verification.status} />
@@ -80,10 +81,7 @@ export function ReviewCard({ verification }: { verification: Verification }) {
label="Bank"
value={`${verification.bankName} · ${verification.bankAccountNumber}`}
/>
<Field
label="Pemilik Rekening"
value={verification.bankAccountName}
/>
<Field label="Pemilik Rekening" value={verification.bankAccountName} />
<Field label="Alamat" value={verification.address} fullWidth />
</div>
@@ -212,24 +210,30 @@ function ImagePreview({ label, src }: { label: string; src: string }) {
rel="noopener noreferrer"
className="block overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100"
>
<div className="relative aspect-[4/3] w-full">
<div className="relative aspect-4/3 w-full">
{/* Secure endpoint sends Cache-Control: private,no-store. Use plain <img> to skip Next/Image optimizer. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={label}
className="h-full w-full object-cover"
/>
<img src={src} alt={label} className="h-full w-full object-cover" />
</div>
</a>
</div>
);
}
function StatusPill({ status }: { status: "PENDING" | "APPROVED" | "REJECTED" }) {
function StatusPill({
status,
}: {
status: "PENDING" | "APPROVED" | "REJECTED";
}) {
const cfg = {
PENDING: { label: "Pending", cls: "bg-amber-50 text-amber-700 ring-amber-200" },
APPROVED: { label: "Disetujui", cls: "bg-primary-50 text-primary-700 ring-primary-200" },
PENDING: {
label: "Pending",
cls: "bg-amber-50 text-amber-700 ring-amber-200",
},
APPROVED: {
label: "Disetujui",
cls: "bg-primary-50 text-primary-700 ring-primary-200",
},
REJECTED: { label: "Ditolak", cls: "bg-red-50 text-red-700 ring-red-200" },
}[status];
return (
+19 -31
View File
@@ -4,16 +4,17 @@ 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;
/** Trip gratis (price <= 0) — sembunyikan flow pembayaran */
isFree: boolean;
/** Status partisipasi user saat isJoined (bukan organizer) */
participationStatus?: "PENDING" | "CONFIRMED" | null;
/** Status pembayaran manual (peserta) */
/** Status pembayaran manual (peserta). Hanya relevan untuk trip berbayar. */
participantPayment?: {
markedPaidAt: string | Date | null;
paymentConfirmedAt: string | Date | null;
@@ -29,6 +30,7 @@ export function JoinTripButton({
isLoggedIn,
isOrganizer,
isJoined,
isFree,
participationStatus,
participantPayment,
isFull,
@@ -108,28 +110,11 @@ 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 showPaymentLink = !isFree && isJoined && !isDeparturePast;
const waitingPaymentConfirm =
isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt;
const paymentDone = isJoined && pay && pay.paymentConfirmedAt;
!isFree && isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt;
const paymentDone = !isFree && isJoined && pay && pay.paymentConfirmedAt;
return (
<div>
@@ -149,7 +134,8 @@ export function JoinTripButton({
<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.
trip ini
{isFree && <span> trip gratis, tidak ada pembayaran 🎉</span>}.
</div>
)}
{waitingPaymentConfirm && (
@@ -164,15 +150,17 @@ export function JoinTripButton({
<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"
{showPaymentLink && (
<Link
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"
>
{loading ? "Memproses..." : "Saya sudah bayar"}
</button>
{paymentDone
? "Lihat detail pembayaran"
: pay?.markedPaidAt
? "Lihat status pembayaran"
: "Buka detail pembayaran"}
</Link>
)}
{isJoined ? (
<button