diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4eed313..45694aa 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,10 @@ "permissions": { "allow": [ "WebFetch(domain:unsplash.com)", - "Bash(npx prisma *)" + "Bash(npx prisma *)", + "Bash(Get-ChildItem -Path \"c:\\\\development\\\\DIOS\\\\weekly-project\\\\setrip\" -Force)", + "Bash(Select-Object Name, PSIsContainer)", + "Bash(npx tsc *)" ] } } diff --git a/PAYMENT_ROADMAP.md b/PAYMENT_ROADMAP.md deleted file mode 100644 index 7bd398f..0000000 --- a/PAYMENT_ROADMAP.md +++ /dev/null @@ -1,265 +0,0 @@ -# Setrip — Payment Roadmap - -Rencana implementasi flow pembayaran: handle trip gratis, halaman detail payment, dan integrasi Midtrans. - -> **Prinsip:** Sumber kebenaran pembayaran = record di tabel khusus, bukan timestamp di `TripParticipant`. Manual transfer & Midtrans = dua provider dari pipeline yang sama. Trip gratis = first-class case (bukan paid trip dengan amount 0). - ---- - -## Audit state sekarang (baseline) - -- Field `TripParticipant.markedPaidAt` + `paymentConfirmedAt` — dual-timestamp manual. -- Flow: user klik "Saya sudah bayar" → organizer klik "Konfirmasi pembayaran". Tidak ada record transaksi terpisah, tidak ada bukti transfer, tidak ada amount snapshot. -- Tidak ada halaman `/trips/[id]/payment`. Tombol bayar inline di trip detail. -- **Bug**: trip gratis (`price === 0`) tetap melewati flow yang sama — peserta tetap harus klik "Saya sudah bayar" dan organizer tetap harus konfirmasi. - -File terkait: [server/services/trip.service.ts](server/services/trip.service.ts) (`markParticipantPayment`, `confirmParticipantPayment`), [features/booking/actions.ts](features/booking/actions.ts), [features/booking/components/organizer-payment-queue.tsx](features/booking/components/organizer-payment-queue.tsx), [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx), [server/repositories/participant.repo.ts](server/repositories/participant.repo.ts). - ---- - -## PR A — Free trip handling + halaman detail payment (manual flow) ✅ - -Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration. - -**Keputusan akses control:** halaman `/trips/[id]/payment` boleh diakses peserta apapun yang aktif (PENDING/CONFIRMED). Peserta PENDING bisa lihat nominal + rekening untuk persiapan, tapi diberi notice "tunggu approve dulu sebelum transfer". Organizer trip-nya sendiri di-redirect ke trip detail. - -| # | Item | Status | File | -|---|---|---|---| -| A1 | Helper `lib/trip-pricing.ts` dengan `isFreeTrip` & `isPaidTrip` | ✅ | [lib/trip-pricing.ts](lib/trip-pricing.ts) | -| A2 | Service guard: `markParticipantPayment` & `confirmParticipantPayment` reject trip gratis | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) | -| A3 | UI gate di JoinTripButton: hide flow pembayaran kalau gratis | ✅ | [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx) | -| A4 | UI gate di trip detail: skip `OrganizerPaymentQueue` kalau gratis | ✅ | [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) | -| A5 | Halaman `/trips/[id]/payment` (server component dengan akses kontrol) | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) | -| A6 | Konten halaman trip gratis: banner 🎉 + status keikutsertaan + CTA | ✅ | `FreeTripSection` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) | -| A7 | Konten halaman trip berbayar: timeline + rekening organizer + tombol "Saya sudah bayar" + status | ✅ | `PaidTripSection`, `PaymentTimeline`, `BankRow` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) | -| A8 | Link dari trip detail → `/trips/[id]/payment` (replace tombol inline) | ✅ | `showPaymentLink` di [join-trip-button.tsx](features/trip/components/join-trip-button.tsx) | -| A9 | Metadata + `robots: noindex` halaman payment | ✅ | [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) — `metadata.robots: { index: false, follow: false }` | -| A+ | `MarkPaidButton` (client component) untuk action "Saya sudah bayar" | ✅ | [features/booking/components/mark-paid-button.tsx](features/booking/components/mark-paid-button.tsx) | -| A+ | `CopyButton` (client component) untuk copy nomor rekening / nominal | ✅ | [features/booking/components/copy-button.tsx](features/booking/components/copy-button.tsx) | - -**Tindakan manual:** tidak ada. Tidak ada migration di PR A. - -**Catatan edge case yang sudah dihandle:** -- Organizer trip-nya sendiri buka halaman → redirect ke trip detail. -- User belum login → redirect ke login dengan `callbackUrl`. -- User belum join / sudah cancel → tampil notice "kamu belum terdaftar". -- Trip dengan organizer yang belum APPROVED verifikasinya → tampil notice "rekening belum tersedia, hubungi organizer langsung" (tidak crash). -- Peserta PENDING di-warning "tunggu approve dulu sebelum transfer". -- Tombol "Saya sudah bayar" hanya muncul untuk CONFIRMED + belum mark + bank tersedia. - ---- - -## PR B — Refactor schema ke `Booking` + `Payment` (provider MANUAL only) ✅ - -Selesai. `tsc --noEmit` lulus. Pondasi untuk Midtrans sudah siap. - -### Schema (target) - -```prisma -model Booking { - id String @id @default(cuid()) - tripId String - trip Trip @relation(fields: [tripId], references: [id]) - userId String - user User @relation(fields: [userId], references: [id]) - participantId String @unique - participant TripParticipant @relation(fields: [participantId], references: [id]) - - /// Snapshot harga saat booking dibuat — protect dari perubahan trip.price. - amount Int - currency String @default("IDR") - status BookingStatus @default(PENDING) - - payments Payment[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([tripId, status]) - @@index([userId]) -} - -enum BookingStatus { - PENDING // join belum approve atau belum bayar - AWAITING_PAY // approved, tinggal bayar - PAID // lunas (manual confirm atau gateway settlement) - CANCELLED - REFUNDED - EXPIRED -} - -model Payment { - id String @id @default(cuid()) - bookingId String - booking Booking @relation(fields: [bookingId], references: [id]) - - provider PaymentProvider - /// order_id eksternal — untuk MANUAL referensi internal, untuk MIDTRANS dikirim ke gateway. Unik per attempt. - externalOrderId String @unique - externalTxId String? - method String? // bca_va, gopay, qris, manual_transfer, dst - amount Int - status PaymentStatus @default(PENDING) - - rawCallback Json? - snapToken String? - expiresAt DateTime? - - paidAt DateTime? - failedAt DateTime? - rejectionReason String? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([bookingId, status]) - @@index([provider, status]) -} - -enum PaymentProvider { - MANUAL - MIDTRANS -} - -enum PaymentStatus { - PENDING - AWAITING - PAID - FAILED - EXPIRED - CANCELLED - REFUNDED -} -``` - -### Tugas - -| # | Item | Status | File | -|---|---|---|---| -| B1 | Schema: tambah `Booking`, `Payment`, 3 enum (`BookingStatus`, `PaymentProvider`, `PaymentStatus`) + relasi di `User`, `Trip`, `TripParticipant` | ✅ | [prisma/schema.prisma](prisma/schema.prisma) | -| B2 | Migration `add_booking_payment` (CreateEnum + CreateTable + Index + FK) | ✅ | [prisma/migrations/20260508150000_add_booking_payment/migration.sql](prisma/migrations/20260508150000_add_booking_payment/migration.sql) | -| B3 | Backfill script TS (idempotent, skip baris yang sudah punya Booking) | ✅ | [prisma/backfill-bookings.ts](prisma/backfill-bookings.ts) | -| B4 | `bookingRepo` + `paymentRepo` | ✅ | [server/repositories/booking.repo.ts](server/repositories/booking.repo.ts), [server/repositories/payment.repo.ts](server/repositories/payment.repo.ts) | -| B5 | `bookingService` — `markPaidManual`, `confirmPaidManual`, `getByTripAndUser`, `getAwaitingManualForTrip` (idempotent, transactional dengan retry serialisasi) | ✅ | [server/services/booking.service.ts](server/services/booking.service.ts) | -| B6 | `tripService.markParticipantPayment` → delegate ke `bookingService.markPaidManual`. Tetap update `TripParticipant.markedPaidAt` untuk backcompat. | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) | -| B7 | `tripService.confirmParticipantPayment` → delegate ke `bookingService.confirmPaidManual`. Tetap update `paymentConfirmedAt`. | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) | -| B+ | `tripService.joinTrip` → upsert Booking PENDING (handle re-join dari CANCELLED) | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) | -| B+ | `tripService.confirmParticipant` → transition Booking PENDING → AWAITING_PAY (paid) atau PAID (free) | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) | -| B+ | `tripService.cancelJoin` & `rejectParticipant` → Booking → CANCELLED | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) | -| B8 | Halaman `/trips/[id]/payment` baca dari Booking + Payment (bukan timestamp lama) | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) | -| B9 | `OrganizerPaymentQueue` di trip detail dapat data dari `bookingService.getAwaitingManualForTrip` | ✅ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) | -| B10 | Deprecate `TripParticipant.markedPaidAt` + `paymentConfirmedAt` (komen `@deprecated`, tetap di-update untuk backcompat) | ✅ | [prisma/schema.prisma](prisma/schema.prisma) | -| B11 | Index optimization (`@@index([tripId, status])` di Booking, `@@index([provider, status])` di Payment, `@@index([userId])` di Booking) | ✅ | [prisma/schema.prisma](prisma/schema.prisma) | - -**Tindakan manual (urutan penting):** -1. `npx prisma migrate deploy` — apply schema migration `20260508150000_add_booking_payment`. -2. `npx tsx prisma/backfill-bookings.ts` — populate Booking + Payment dari `TripParticipant` lama. Idempotent, aman dijalankan ulang. -3. Verifikasi: jumlah Booking aktif = jumlah TripParticipant aktif setelah backfill. - ---- - -## PR C — Midtrans integration (Snap + webhook) ✅ - -Selesai. `tsc --noEmit` lulus. Belum test live ke sandbox Midtrans — perlu env diisi + tunneling kalau dev lokal. - -### Persiapan akun & env - -| Env | Keterangan | -|---|---| -| `MIDTRANS_SERVER_KEY` | Server key dari dashboard Midtrans (sandbox/production sesuai mode). Rahasia. Server-side only. | -| `NEXT_PUBLIC_MIDTRANS_CLIENT_KEY` | Client key untuk Snap.js. Aman di-expose ke frontend (NEXT_PUBLIC_). | -| `NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION` | `true` untuk production, `false` (atau kosong) untuk sandbox. NEXT_PUBLIC_ supaya client tahu URL Snap.js yang benar. | - -`MIDTRANS_NOTIFICATION_URL` **tidak** di env — diset langsung di dashboard Midtrans ke `/api/webhooks/midtrans`. - -Sudah ditambah ke [env.example](env.example). - -### Tugas - -| # | Item | Status | File | -|---|---|---|---| -| C1 | Update [env.example](env.example) + 3 env baru + komentar webhook URL | ✅ | [env.example](env.example) | -| C2 | `lib/midtrans.ts` — `createSnapTransaction`, `verifyMidtransSignature` (timing-safe compare), `MIDTRANS` config helper | ✅ | [lib/midtrans.ts](lib/midtrans.ts) | -| C3 | Status mapping `mapMidtransStatus(transaction_status, fraud_status)` → `PaymentStatus` | ✅ | [lib/midtrans.ts](lib/midtrans.ts) | -| C4 | `paymentService.startMidtransPayment(bookingId, userId)` — validate, reuse Payment AWAITING aktif (idempotent re-attempt), atau buat Payment baru + call Snap API + simpan token + expiresAt | ✅ | [server/services/payment.service.ts](server/services/payment.service.ts) | -| C5 | Halaman payment: tombol "Bayar online via Midtrans" + divider "atau" + tombol manual lama | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) | -| C6 | `MidtransPayButton` client component — load Snap.js dengan `data-client-key` dinamis, `window.snap.pay(token, callbacks)`, refresh page setelah Snap close | ✅ | [features/booking/components/midtrans-pay-button.tsx](features/booking/components/midtrans-pay-button.tsx) | -| C7 | Webhook endpoint `app/api/webhooks/midtrans/route.ts` — POST, verify signature, lookup, idempotent, return 200/401 sesuai outcome | ✅ | [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts) | -| C8 | `paymentService.handleMidtransWebhook` — verifikasi signature, amount check, transaction (`Payment` + `Booking` + `TripParticipant.paymentConfirmedAt` backcompat) | ✅ | [server/services/payment.service.ts](server/services/payment.service.ts) | -| C10 | Anti-replay: skip update kalau Payment sudah final (PAID/FAILED/EXPIRED/CANCELLED/REFUNDED) | ✅ | [payment.service.ts](server/services/payment.service.ts) | -| C11 | Simpan callback mentah ke `Payment.rawCallback` (audit & dispute), termasuk untuk callback yang di-skip | ✅ | [payment.service.ts](server/services/payment.service.ts) | -| C+ | Server action `startMidtransPaymentAction` (resolve booking dari tripId, bridge ke client) | ✅ | [features/booking/actions.ts](features/booking/actions.ts) | -| C+ | Retry handling: Payment row baru dengan `midtrans-{bookingId}-{retryN}` kalau attempt lama expired/failed; idempotent reuse kalau masih AWAITING | ✅ | [payment.service.ts](server/services/payment.service.ts) | -| C9 | Cron expire Payment lama | ⏸️ skipped | Housekeeping di-handle saat user start payment (auto-expire attempt yang lewat `expiresAt`). Cron formal bisa ditambah kalau perlu cleanup massal. | -| C12 | Test scenario sandbox (settlement, deny, expire) | ⏸️ manual | Perlu env Midtrans diisi + tunneling untuk dev lokal (ngrok/cloudflared). Tidak bisa otomatis dari sini. | -| C13 | Status badge real-time | ⏸️ partial | Page refresh setelah Snap close + halaman SSR pull state baru tiap reload. Polling otomatis belum diimplementasi. | -| C14 | Email/in-app notification setelah PAID | ⏳ pending | Diluar scope PR C — masuk Phase berikutnya. | - -### Mapping `transaction_status` Midtrans → `PaymentStatus` - -| Midtrans | Trigger lain | `PaymentStatus` | -|---|---|---| -| `capture` | `fraud_status === "accept"` | PAID | -| `capture` | `fraud_status === "challenge"` | AWAITING (review manual di dashboard Midtrans) | -| `settlement` | — | PAID | -| `pending` | — | AWAITING | -| `deny` | — | FAILED | -| `expire` | — | EXPIRED | -| `cancel` | — | CANCELLED | -| `refund` / `partial_refund` | — | REFUNDED | - -### Webhook checklist (security) - -1. Verify signature: `sha512(order_id + status_code + gross_amount + SERVER_KEY) === signature_key`. Mismatch → 401, log. -2. Cek `gross_amount` cocok dengan `payment.amount` — kalau tidak sama, log anomaly, jangan PAID. -3. Lookup `Payment.externalOrderId === order_id`. Tidak ada → 200 OK + log (jangan biarkan Midtrans retry forever). -4. Idempotent: kalau status sudah final, skip update tapi tetap return 200. -5. Pakai DB transaction untuk update Payment + Booking + TripParticipant bersamaan. -6. Selalu return 200 kalau request valid (mismatch signature → 401, sisanya → 200 + log). - -### Hardening pasca-audit (sebelum Midtrans live) ✅ - -Empat fix tambahan dari audit security/correctness: - -| Fix | Issue | Solusi | File | -|---|---|---|---| -| 1 | Webhook bisa overwrite Booking CANCELLED/REFUNDED/EXPIRED jadi PAID | Re-fetch Booking di dalam serializable transaction; kalau state konflik, Payment tetap PAID (uang masuk) tapi Booking tidak di-update + `Payment.rejectionReason` di-flag untuk manual review/refund. Webhook outcome `booking_conflict` di-log warning. | [payment.service.ts](server/services/payment.service.ts), [route.ts](app/api/webhooks/midtrans/route.ts) | -| 2 | `startMidtransPayment` lupa cek trip departure date | Tambah `isTripDepartureDayPast` guard, juga di `bookingService.markPaidManual` untuk konsistensi | [payment.service.ts](server/services/payment.service.ts), [booking.service.ts](server/services/booking.service.ts) | -| 3 | `Booking` tidak punya constraint `(tripId, userId)` unique | Tambah `@@unique([tripId, userId])` + migration `20260508160000_booking_unique_trip_user`. `findByTripAndUser` switch dari `findFirst` ke `findUnique` (lebih efisien) | [schema.prisma](prisma/schema.prisma), [migration](prisma/migrations/20260508160000_booking_unique_trip_user/migration.sql), [booking.repo.ts](server/repositories/booking.repo.ts) | -| 4 | Webhook payload tidak schema-validated | Zod `midtransWebhookSchema` (passthrough untuk forward-compat). Webhook route `safeParse` → 400 kalau shape invalid. Service signature pakai type yang inferred dari schema. | [lib/midtrans.ts](lib/midtrans.ts), [route.ts](app/api/webhooks/midtrans/route.ts), [payment.service.ts](server/services/payment.service.ts) | - -### Edge cases yang gampang lupa - -- **Quota race**: dua user bayar bersamaan untuk slot terakhir → slot di-hold saat Booking dibuat (status AWAITING_PAY masih hitung kuota via `TripParticipant.status`). Release belum otomatis saat Payment EXPIRED — kalau perlu, tambah cron (lihat C9 yang di-skip). -- **Trip dibatalkan organizer setelah peserta bayar** → `Booking.status = REFUNDED` setelah dana balik. Implementasi refund Midtrans = PR terpisah (tidak di scope PR C ini). -- **User retry pembayaran setelah gagal** → bikin Payment baru, `externalOrderId` baru (`midtrans-{bookingId}-{retryN}`). Reuse kalau masih AWAITING & belum expired. -- **Webhook duplicate**: Midtrans bisa kirim notifikasi yang sama 2-3 kali. Idempotent: skip update kalau Payment sudah final, tapi tetap simpan callback ke `rawCallback` untuk audit. -- **Sandbox vs production**: webhook URL diset di dashboard Midtrans = `/api/webhooks/midtrans`. Dev lokal perlu tunneling (ngrok / cloudflared) supaya endpoint bisa di-reach Midtrans. -- **Booking belum approved (`PENDING`) tapi user coba bayar** — `paymentService.startMidtransPayment` reject dengan pesan jelas. UI sudah hide tombol di state ini. - ---- - -## ❌ Anti-list (yang harus DITOLAK kalau muncul) - -- **Halaman payment yang berubah jadi marketplace checkout**: upsell ("trip serupa lebih murah"), pricing comparison, "harga turun" — semua menarik framing OTA. Halaman payment fokus pada satu transaksi: lunas atau belum. -- **Multi-method abstraksi prematur**: jangan bikin "PaymentProvider" generic untuk Stripe/Xendit/Doku sekaligus sebelum salah satu jalan. Mulai dari MANUAL + MIDTRANS, baru tambah kalau perlu. -- **Auto-refund logic kompleks** sebelum manual refund di dashboard Midtrans dipakai. Refund jarang, manual cukup di Phase awal. -- **Payment retry otomatis** dari sisi server. User harus eksplisit klik "bayar lagi" untuk attempt baru — supaya tidak ambigu siapa yang trigger. -- **Multi-currency** sebelum ada permintaan eksplisit. `currency` di schema sudah default IDR, tapi tidak perlu UI selector. -- **Saving credit card / tokenization** tanpa kebutuhan jelas. PCI scope naik drastis. Snap sudah handle tanpa simpan kartu di sisi kita. - ---- - -## Saran phasing - -PR berurutan. Setiap PR mandiri (siap di-deploy): - -1. **PR A** — Free trip handling + halaman payment (manual). Cepat, low-risk, no migration. **Mulai dari sini.** -2. **PR B** — Refactor ke `Booking` + `Payment`. Migration + backfill data lama. UI tetap mirip. -3. **PR C** — Tambah Midtrans Snap. Test di sandbox dulu sebelum production. - -Pertanyaan terbuka sebelum mulai: - -1. **Akses halaman payment**: hanya peserta CONFIRMED, atau juga PENDING (yang belum disetujui organizer)? Saya rekomendasi CONFIRMED only — peserta PENDING belum perlu lihat detail bayar. -2. **Snapshot amount di Booking**: kalau organizer ubah `trip.price` setelah booking dibuat, booking lama pakai harga lama atau baru? Saya rekomendasi tetap pakai snapshot lama (audit-friendly). -3. **Manual + Midtrans co-exist**: user pilih satu provider per booking, atau bisa retry dengan provider berbeda? Saya rekomendasi pilih satu — kalau gagal di Midtrans, bisa cancel dan buat Payment baru dengan provider MANUAL. diff --git a/TRUST_ROADMAP.md b/TRUST_ROADMAP.md new file mode 100644 index 0000000..d959e55 --- /dev/null +++ b/TRUST_ROADMAP.md @@ -0,0 +1,136 @@ +# Setrip — Trust & Trip Detail Roadmap + +Status implementasi yang menaikkan kepercayaan calon peserta — trip detail experience yang meyakinkan + sistem reputasi organizer yang transparan. + +> **Prinsip:** trust = fungsi dari (a) kelengkapan informasi trip dan (b) reputasi organizer yang transparan. Setiap fitur dievaluasi: apakah memberi calon peserta alasan obyektif untuk percaya? + +--- + +## Audit state sekarang (baseline) + +**Trip detail (~80% sudah ada):** +- ✅ Itinerary, include/exclude, meeting point — schema lengkap di `Trip`, ditampilkan via `TripProgramBlock`. +- ✅ Participant preview (kartu confirmed peserta dengan avatar, kota, interests). +- ✅ Slot tersisa (X / Y) dengan progress bar berwarna. +- ⚠️ Urgency message ada tapi tidak mencolok saat slot menipis. +- ⚠️ Itinerary text bebas — organizer butuh hint format supaya konsisten isi lengkap. + +**Review/trust (~40% sudah ada):** +- ✅ Model `TripReview` (rating + comment per trip+user, unique). +- ✅ Trust panel di trip detail (verified badge, trips created, avg rating, review count). +- ❌ Profil organizer publik `/u/[id]` belum tampilkan rating/review aggregate. +- ❌ Belum ada total participants served, completion rate, rating breakdown. +- ❌ Belum ada list ulasan terkumpul per organizer. +- ❌ `TripStatus.COMPLETED` enum-nya ada tapi tidak pernah di-set. + +File baseline: [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx), [server/services/trust.service.ts](server/services/trust.service.ts), [server/services/review.service.ts](server/services/review.service.ts), [app/u/[id]/page.tsx](app/u/%5Bid%5D/page.tsx). + +--- + +## PR-1 — Trip Detail Polish (UI only) ⏳ + +Cosmetic. Tidak ada migration, tidak ada perubahan service/repo. + +| # | Item | Status | File | +|---|---|---|---| +| 1.1 | Urgency badge mencolok saat `spotsLeft <= 3` ("⚡ Tinggal X spot!") di header progress bar | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) | +| 1.2 | Participant preview ringkas di blok progress ("👥 Sudah join: Andi, Rina, Budi +4") — first impression tanpa scroll | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) | +| 1.3 | Hint deskriptif + placeholder lebih konkret di field itinerary form create-trip | ⏳ | [features/trip/components/create-trip-form.tsx](features/trip/components/create-trip-form.tsx) | + +**Tindakan manual:** tidak ada. + +--- + +## PR-2 — Organizer Trust Aggregates (service + UI, tanpa migration) ✅ + +Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration. + +**Keputusan asumsi yang dipakai:** +- `tripsCompleted` ≠ `Trip.status = COMPLETED` (status itu tidak pernah di-set). Pakai `endDate < now()` (fallback `date < now()`) AND `status != CLOSED`. +- `tripsCancelled` = `Trip.status = CLOSED` (organizer batalkan trip eksplisit). +- `completionRate` butuh sample ≥ 3 (`COMPLETION_RATE_MIN_SAMPLE` di [lib/trust.ts](lib/trust.ts)) supaya tidak menyesatkan organizer baru. +- Rating breakdown di-render sebagai bar chart kecil (visual cue lebih kuat dari angka mentah). +- `OrganizerStatsPanel` di profil publik tidak di-render untuk user yang murni peserta — query Prisma juga di-skip kalau `organizedTrips.length === 0 && !isVerifiedOrganizer`. +- Trip detail: stat box "Trip dibuat" diganti jadi **"Trip selesai"** (lebih meaningful) + tambah **"Peserta dilayani"**. Total 3 stat box, masih kompak. + +| # | Item | Status | File | +|---|---|---|---| +| 2.1 | Extend `OrganizerTrust` type: `tripsCompleted`, `tripsCancelled`, `totalParticipantsServed`, `completionRate`, `ratingBreakdown` | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) | +| 2.2 | `tripsCompleted` di-derive dari `endDate < now()` (fallback `date < now()`) AND `status != CLOSED` | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) | +| 2.3 | `totalParticipantsServed` = count `TripParticipant CONFIRMED` di trip yang sudah lewat & tidak dibatalkan | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) | +| 2.4 | `completionRate` = `tripsCompleted / (tripsCompleted + tripsCancelled)`. Null bila sample < 3 | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts), [lib/trust.ts](lib/trust.ts) | +| 2.5 | `ratingBreakdown` via `prisma.tripReview.groupBy({ by: ['rating'] })` | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) | +| 2.6 | Komponen `OrganizerStatsPanel` (badges + 4 stat box + bar chart breakdown) | ✅ | [features/profile/components/organizer-stats-panel.tsx](features/profile/components/organizer-stats-panel.tsx) | +| 2.7 | Update `OrganizerTrustPanel` di trip detail — Trip selesai (+ subtitle "berjalan"), Peserta dilayani, Rating | ✅ | [features/trip/components/organizer-trust-panel.tsx](features/trip/components/organizer-trust-panel.tsx) | +| 2.8 | Render `OrganizerStatsPanel` di `/u/[id]` (skip query untuk non-organizer) | ✅ | [app/u/[id]/page.tsx](app/u/%5Bid%5D/page.tsx) | + +**Tindakan manual:** tidak ada. + +--- + +## PR-3 — Organizer Reviews Aggregator (service + UI, tanpa migration) ✅ + +Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration. + +**Keputusan asumsi yang dipakai:** +- Default limit 20 ulasan terbaru — cukup untuk MVP, tidak perlu pagination dulu. +- Komponen RSC (server component, no `"use client"`) — pure render, tidak ada interaktivitas. +- Tipe `OrganizerReviewItem` di-extract dari `Awaited>[number]` supaya schema repo = sumber kebenaran tanpa duplikasi tipe. +- Fetch trust + reviews via single `Promise.all` di `/u/[id]` (paralel, hemat 1 round-trip). +- Komponen di-skip kalau `reviews.length === 0` — biar tidak makin "kosong" di profil organizer baru. Stats panel sudah punya pesan "Belum ada ulasan". +- Rating ditampilkan sebagai bintang penuh (`★★★★☆`) bukan angka — visual cue lebih kuat untuk testimoni. +- Header section: "X terbaru dari Y ulasan" kalau di-limit, atau cuma "Y ulasan" kalau seluruh list ditampilkan. + +| # | Item | Status | File | +|---|---|---|---| +| 3.1 | `reviewService.getReviewsByOrganizer(organizerId, limit?)` + tipe `OrganizerReviewItem` | ✅ | [server/services/review.service.ts](server/services/review.service.ts) | +| 3.2 | Repo helper `findByOrganizer` (default limit 20, urut newest, include user + trip) | ✅ | [server/repositories/review.repo.ts](server/repositories/review.repo.ts) | +| 3.3 | Komponen `OrganizerReviewsList` (avatar + name + bintang + trip link + tanggal + comment) | ✅ | [features/review/components/organizer-reviews-list.tsx](features/review/components/organizer-reviews-list.tsx) | +| 3.4 | Render di `/u/[id]` di bawah `OrganizerStatsPanel`, fetch via `Promise.all` paralel | ✅ | [app/u/[id]/page.tsx](app/u/%5Bid%5D/page.tsx) | + +**Tindakan manual:** tidak ada. + +--- + +## PR-4 — Trip Completion Mechanism (opsional, butuh diskusi) ⏳ + +Saat ini `Trip.status = COMPLETED` tidak pernah di-set oleh kode mana pun. PR ini hanya perlu kalau ingin pakai `status` sebagai sumber kebenaran formal (bukan computed-from-`endDate`). + +Opsi: +- **A. Manual** — organizer klik "Tandai trip selesai" pasca-pulang. Pro: kontrol di organizer. Con: gampang lupa, `status` tidak accurate kalau organizer pasif. +- **B. Cron job** — daily job set `status = COMPLETED` untuk trip dengan `endDate < today() AND status IN ('OPEN','FULL')`. Pro: otomatis akurat. Con: butuh infra cron (belum ada di project). +- **C. Skip — biarkan computed-from-`endDate`** di service layer. Pro: paling sederhana, sejalan dengan PR-2 yang juga compute on-the-fly. Con: field `status` jadi sebagian "live" (OPEN/FULL/CLOSED murni, COMPLETED computed). + +**Rekomendasi:** **C** dulu sampai ada kebutuhan riil untuk transisi formal (mis. trigger payout organizer pasca-trip atau notif post-trip continuity di Phase C SOCIAL_ROADMAP). + +| # | Item | Status | +|---|---|---| +| 4.1 | Pilih opsi A/B/C | ⏳ | +| 4.2 | Implementasi sesuai pilihan (atau dokumentasikan keputusan kalau C) | ⏳ | + +--- + +## ❌ Anti-list (yang harus DITOLAK kalau muncul) + +- **Model `OrganizerReview` terpisah** — `TripReview` sudah cukup, 1 trip = 1 organizer. Bikin model baru = duplikasi data + sumber kebenaran ambigu. +- **Denormalisasi cache** (mis. `User.cachedAvgRating`) sebelum aggregate query terbukti lambat. Premature optimization → drift jadi tech debt cepat. +- **Auto-hapus review buruk** atau organizer "respon" review (untuk MVP). Bisa nanti — fokus dulu menampilkan data jujur. +- **"Trust score" gabungan satu angka** — kasih breakdown agar calon peserta evaluasi sendiri. Single number gampang dimanipulasi & menyesatkan. +- **Review user/peserta** (no-show, kooperatif?) — itu C6 di [SOCIAL_ROADMAP.md](SOCIAL_ROADMAP.md). Scope berbeda, jangan campur. +- **Rating dengan setengah bintang / kustom 1-10** — tetap 1-5 integer (sudah di schema). Granularitas lebih halus tidak meningkatkan trust, hanya menambah noise. + +--- + +## Saran phasing + +PR berurutan. Setiap PR mandiri (siap di-deploy): + +1. **PR-1** — Trip detail polish. Cepat, low-risk, no migration. **Mulai dari sini.** +2. **PR-2** — Trust aggregates di service + UI. Read-only, no migration. +3. **PR-3** — Reviews list per organizer di service + UI. Read-only, no migration. +4. **PR-4** — Diskusi opsi completion mechanism (atau skip kalau opsi C dipilih). + +**Pertanyaan terbuka sebelum PR-2:** +1. Apakah `tripsCompleted` count termasuk trip dengan `status = CLOSED` yang `endDate < now()`? Saran: tidak — CLOSED = dibatalkan, dipisah ke `tripsCancelled`. +2. Threshold minimum supaya `completionRate` ditampilkan? Saran: min 3 trip selesai supaya angka tidak menyesatkan (1 trip dibatalkan dari 1 trip = 0% looks bad untuk organizer baru). +3. Tampilkan rating breakdown sebagai bar chart atau hanya angka? Saran: bar chart kecil — visual cue lebih kuat untuk credibility. diff --git a/app/trips/[id]/page.tsx b/app/trips/[id]/page.tsx index 8828bfc..46273bb 100644 --- a/app/trips/[id]/page.tsx +++ b/app/trips/[id]/page.tsx @@ -344,10 +344,22 @@ export default async function TripDetailPage({ {/* Participant Progress */}
-
- - Peserta - +
+
+ + Peserta + + {spotsLeft > 0 && spotsLeft <= 3 && ( + + ⚡ Tinggal {spotsLeft} spot! + + )} + {spotsLeft <= 0 && ( + + Penuh + + )} +
{participantCount}{" "} @@ -383,6 +395,23 @@ export default async function TripDetailPage({ )}

+ {confirmedCount > 0 && ( +

+ 👥 Sudah join:{" "} + + {confirmedParticipants + .slice(0, 3) + .map((p) => p.user.name.split(" ")[0]) + .join(", ")} + + {confirmedCount > 3 && ( + + {" "} + +{confirmedCount - 3} lainnya + + )} +

+ )}
0 || isVerifiedOrganizer; + const [organizerTrust, organizerReviews] = isOrganizerProfile + ? await Promise.all([ + trustService.getOrganizerTrust(user.id), + reviewService.getReviewsByOrganizer(user.id), + ]) + : [null, []]; + return (
{/* Header */} @@ -156,6 +170,15 @@ export default async function PublicProfilePage({ params }: PageProps) {
+ {organizerTrust && } + + {organizerTrust && organizerReviews.length > 0 && ( + + )} + {/* Empty profile hint */} {!profile && (

diff --git a/features/profile/components/organizer-stats-panel.tsx b/features/profile/components/organizer-stats-panel.tsx new file mode 100644 index 0000000..5f182fe --- /dev/null +++ b/features/profile/components/organizer-stats-panel.tsx @@ -0,0 +1,161 @@ +import type { OrganizerTrust } from "@/server/services/trust.service"; + +interface OrganizerStatsPanelProps { + trust: OrganizerTrust; +} + +/** + * Panel reputasi organizer untuk halaman profil publik /u/[id]. + * Tidak render kalau user belum punya history sebagai organizer. + */ +export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) { + const { + isVerified, + isTripLeader, + tripsCreated, + tripsCompleted, + totalParticipantsServed, + completionRate, + avgRating, + reviewCount, + ratingBreakdown, + } = trust; + + if (tripsCreated === 0 && reviewCount === 0 && !isVerified) { + return null; + } + + const maxBreakdown = Math.max( + ratingBreakdown[1], + ratingBreakdown[2], + ratingBreakdown[3], + ratingBreakdown[4], + ratingBreakdown[5], + 1 + ); + + return ( +

+

+ Reputasi sebagai organizer +

+ + {(isVerified || isTripLeader) && ( +
+ {isVerified && ( + + ✅ Verified Organizer + + )} + {isTripLeader && ( + + Trip leader + + )} +
+ )} + +
+ + + + 0 + ? `${reviewCount} ulasan` + : "Belum ada ulasan" + } + tone="amber" + /> +
+ + {reviewCount > 0 && ( +
+

+ Distribusi rating +

+
+ {([5, 4, 3, 2, 1] as const).map((star) => { + const count = ratingBreakdown[star]; + const percent = (count / maxBreakdown) * 100; + return ( +
+ + {star} ★ + +
+
+
+ + {count} + +
+ ); + })} +
+
+ )} +
+ ); +} + +const TONE_CLASSES = { + primary: { bg: "bg-primary-50", value: "text-primary-700" }, + secondary: { bg: "bg-secondary-50", value: "text-secondary-700" }, + neutral: { bg: "bg-neutral-50", value: "text-neutral-800" }, + amber: { bg: "bg-amber-50", value: "text-amber-700" }, +} as const; + +interface StatProps { + label: string; + value: string; + subtitle?: string; + tone: keyof typeof TONE_CLASSES; +} + +function Stat({ label, value, subtitle, tone }: StatProps) { + const cls = TONE_CLASSES[tone]; + return ( +
+

+ {label} +

+

+ {value} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ ); +} diff --git a/features/review/components/organizer-reviews-list.tsx b/features/review/components/organizer-reviews-list.tsx new file mode 100644 index 0000000..7755885 --- /dev/null +++ b/features/review/components/organizer-reviews-list.tsx @@ -0,0 +1,107 @@ +import Link from "next/link"; +import Image from "next/image"; +import type { OrganizerReviewItem } from "@/server/services/review.service"; + +interface OrganizerReviewsListProps { + reviews: OrganizerReviewItem[]; + /// Total review keseluruhan organizer (bisa lebih besar dari `reviews.length` + /// kalau di-limit di service). Kalau tidak diberikan, fallback ke `reviews.length`. + totalCount?: number; +} + +export function OrganizerReviewsList({ + reviews, + totalCount, +}: OrganizerReviewsListProps) { + if (reviews.length === 0) { + return null; + } + + const total = totalCount ?? reviews.length; + const showingMore = total > reviews.length; + + return ( +
+
+

+ Ulasan dari peserta +

+

+ {showingMore + ? `${reviews.length} terbaru dari ${total} ulasan` + : `${total} ulasan`} +

+
+ +
    + {reviews.map((r) => ( +
  • +
    + {r.user.image ? ( + + ) : ( +
    + {r.user.name.charAt(0).toUpperCase()} +
    + )} + +
    +
    + + {r.user.name} + + + {"★".repeat(r.rating)} + + {"★".repeat(5 - r.rating)} + + +
    + +

    + via{" "} + + {r.trip.title} + + + {" "} + · {formatReviewDate(r.createdAt)} + +

    + + {r.comment && ( +

    + {r.comment} +

    + )} +
    +
    +
  • + ))} +
+
+ ); +} + +function formatReviewDate(date: Date): string { + return new Date(date).toLocaleDateString("id-ID", { + day: "numeric", + month: "short", + year: "numeric", + }); +} diff --git a/features/trip/components/create-trip-form.tsx b/features/trip/components/create-trip-form.tsx index 33816f2..1baf6d6 100644 --- a/features/trip/components/create-trip-form.tsx +++ b/features/trip/components/create-trip-form.tsx @@ -248,15 +248,21 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
-