diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4fa0b10..034834d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -213,6 +213,29 @@ Database --- +## Phase 3+ (SeTrip saat ini — booking, detail, trust) + +Alur data mengikuti pola yang sama: **UI (`app/`) → server actions (`features/*/actions.ts`) → service (`server/services`) → repository (`server/repositories`)**. + +### Booking & pembayaran manual (`features/booking/`) + +- **Peserta:** tombol *Saya sudah bayar* menulis `TripParticipant.markedPaidAt` (komitmen transfer manual). +- **Organizer:** panel *Konfirmasi pembayaran* memanggil service yang mengisi `paymentConfirmedAt`. +- Tetap **tanpa payment gateway**; bukti transfer bisa di luar app (WA) sesuai kebutuhan. + +### Detail trip kuat (`Trip` + halaman detail) + +- Field terstruktur: `meetingPoint`, `itinerary`, `whatsIncluded`, `whatsExcluded` (teks bebas / bullet). +- Form buat trip: `features/trip/schemas.ts` + `app/create-trip/page.tsx`. + +### Trust & organizer (`server/services/trust.service.ts`) + +- **Verified:** kolom `User.isVerified` (default false; set manual / seed / admin ke depan). +- **Trip leader:** heuristik `jumlah trip dibuat ≥ TRIP_LEADER_MIN_TRIPS` (`lib/trust.ts`). +- **Jumlah trip dibuat & rating organizer:** dihitung agregat dari DB (rating = rata-rata `TripReview` pada semua trip sang organizer). + +--- + # 🧠 Final Principle > Build fast → validate → refactor later diff --git a/README.md b/README.md index 0025b0c..d1c6062 100644 --- a/README.md +++ b/README.md @@ -9,43 +9,93 @@ Stack: [Next.js](https://nextjs.org) (App Router), NextAuth, Prisma (PostgreSQL) ### 1. Autentikasi - Pengguna baru mendaftar di `/register` (nama, email, password disimpan di database). -- Login di `/login` melalui NextAuth; sesi dipakai di server action dan di halaman client (misalnya navbar, form buat trip). +- Login di `/login` melalui NextAuth; sesi dipakai di server action dan di halaman client (navbar, form buat trip, join). Tanpa login, pengguna tetap bisa melihat daftar trip dan detail trip, tetapi tidak bisa membuat trip atau join. ### 2. Organizer: membuat trip -1. Setelah login, organizer membuka **Buat Trip** (`/create-trip`) dari navbar, halaman `/trips`, beranda, atau tombol mengambang (+). -2. Halaman form (`app/create-trip/page.tsx`) memvalidasi sesi di client; jika belum login, ditampilkan ajakan login. -3. Organizer mengisi judul, gunung, lokasi, deskripsi (opsional), rentang tanggal (DatePicker), maks peserta, harga (format Rupiah), dan URL gambar opsional (`ImageUrlInput`). -4. Submit memanggil server action `createTripAction` (`features/trip/actions.ts`): - - Memastikan ada sesi. - - Mem-parse dan memvalidasi input dengan Zod (`features/trip/schemas.ts`). - - `tripService.createTrip` menulis trip baru ke database lewat `tripRepo.create`, menghubungkan `organizerId` ke user yang login, dan menyimpan gambar jika ada. -5. Trip baru berstatus **OPEN** (default schema), lalu pengguna diarahkan ke detail trip `/trips/[id]`. +1. Setelah login, organizer membuka **Buat Trip** (`/create-trip`) dari navbar, `/trips`, beranda, atau tombol (+). +2. Form memvalidasi sesi; jika belum login, ditampilkan ajakan login. +3. Organizer mengisi judul, gunung, lokasi, deskripsi (opsional), **meeting point**, **itinerary**, **termasuk / tidak termasuk** (opsional), rentang tanggal berangkat–pulang, maks peserta, harga (Rupiah), dan URL gambar opsional. +4. Submit → `createTripAction` → validasi Zod → `tripService.createTrip` menulis `Trip` ke database (status default **OPEN**) beserta gambar jika ada. +5. Pengguna diarahkan ke detail trip `/trips/[id]`. -Organizer **tidak** bisa join trip sendiri; di detail trip tombol join diganti pesan bahwa user adalah organizer. +Organizer **tidak** bisa join trip sendiri; di detail trip ditampilkan bahwa dia adalah organizer trip ini. -### 3. Peserta: mencari trip dan join +### 3. Peserta: mencari trip -1. **Beranda** (`/`) dan **Open Trip** (`/trips`) menampilkan trip dengan status **OPEN** dan tanggal berangkat tidak di masa lalu (`tripService.getOpenTrips` + filter di repository). -2. Filter pencarian (`TripFilter`) mengirim query string; daftar trip disaring di server. -3. Dari kartu trip (`TripCard`), pengguna membuka **detail** `/trips/[id]` (`app/trips/[id]/page.tsx`): - - Peserta aktif = baris `TripParticipant` yang statusnya bukan `CANCELLED`. - - Slot tersisa dan progress bar memakai jumlah peserta aktif tersebut. -4. **Join** (`JoinTripButton` + `joinTripAction`): - - Jika belum login: tautan ke `/login`. - - Jika trip bukan `OPEN` dan user belum join: pendaftaran ditutup (kecuali user sudah terdaftar dan ingin membatalkan, mengikuti logika UI). - - `tripService.joinTrip` memeriksa: trip ada, status `OPEN`, bukan organizer, belum terdaftar aktif, kapasitas belum penuh; lalu menambah atau mengaktifkan kembali partisipasi (lihat bagian perbaikan bug di bawah). -5. Jika jumlah peserta aktif mencapai `maxParticipants`, status trip diperbarui menjadi **FULL**. -6. **Batal ikut** memanggil `cancelJoinAction` → `tripService.cancelJoin`: partisipasi ditandai `CANCELLED`; jika trip sebelumnya `FULL` dan setelah batal slot kosong lagi, status dikembalikan ke **OPEN**. +1. **Beranda** (`/`) dan **Open Trip** (`/trips`) menampilkan trip **OPEN** dengan tanggal berangkat yang masih relevan (`tripService.getOpenTrips`). +2. Filter pencarian (`TripFilter`) mengirim query string (`q`, `from`, `to`); penyaringan dilakukan di server. +3. Dari **TripCard**, pengguna membuka **detail** `/trips/[id]`: melihat info trip, kuota, panel kepercayaan organizer (jumlah trip, rating, badge jika ada), daftar peserta yang sudah disetujui, dan ulasan. -### 4. Ringkasan peran data +--- + +### 4. Alur lengkap: dari join hingga pembayaran sukses + +Alur ini menggambarkan satu peserta dari pertama kali mendaftar sampai pembayaran dianggap selesai di aplikasi (pembayaran **manual** / transfer di luar gateway). + +```text +[Peserta] [Sistem] [Organizer] + | | | + |-- buka detail trip ------>| | + | | | + |-- "Join Trip" ------------>| status partisipasi: PENDING | + | | (mengisi slot kuota trip) | + |<-- menunggu persetujuan ---| | + | |<-- lihat "Permintaan join" ------| + | | | + | |<-- "Setujui" atau "Tolak" -------| + | | Setujui -> CONFIRMED | + | | Tolak -> CANCELLED | + |<-- terkonfirmasi ikut -----| (jika disetujui) | + | | | + |-- transfer uang (WA/rek) --| (di luar app) | + | | | + |-- "Saya sudah bayar" ----->| markedPaidAt = sekarang | + |<-- menunggu konfirmasi ----| | + | | | + | |<-- "Konfirmasi pembayaran" ------| + | | paymentConfirmedAt = sekarang | + |<-- pembayaran dikonfirmasi-| | +``` + +**Langkah per langkah** + +1. **Join trip** + Di halaman detail, peserta login menekan **Join Trip Sekarang** → `joinTripAction` → `tripService.joinTrip`. + Dibuat atau diaktifkan kembali baris `TripParticipant` dengan status **`PENDING`**. Kuota trip (peserta aktif: PENDING + CONFIRMED) bertambah; trip bisa menjadi **FULL** jika slot habis. + +2. **Menunggu organizer** + Peserta melihat status bahwa permintaan **menunggu persetujuan organizer**. Dia bisa **Batal ikut** selama tanggal berangkat belum lewat (status menjadi `CANCELLED`, slot bisa longgar lagi). + +3. **Organizer menyetujui atau menolak** + Di blok **Permintaan join**, organizer menekan **Setujui** → status partisipasi menjadi **`CONFIRMED`**, atau **Tolak** → **`CANCELLED`**. + Jika ada peserta yang sudah menandai bayar sebelum disetujui, penolakan juga membersihkan data tandai bayar pada baris itu. + +4. **Peserta terkonfirmasi** + Setelah **CONFIRMED**, peserta dianggap bagian dari daftar “peserta terkonfirmasi” di halaman trip. Fitur ulasan trip setelah selesai hanya relevan untuk partisipasi terkonfirmasi (sesuai aturan di service). + +5. **Menandai sudah membayar** + Peserta mentransfer sesuai instruksi organizer (di luar app). Di app dia menekan **Saya sudah bayar** → kolom **`markedPaidAt`** diisi. Tombol ini tidak muncul jika pembayaran sudah dikonfirmasi atau tanggal trip sudah lewat. + Aksi ini **atomik** (aman dari double klik). + +6. **Organizer mengonfirmasi pembayaran** + Di blok **Konfirmasi pembayaran**, organizer mengecek mutasi/bukti lalu menekan **Konfirmasi pembayaran** untuk peserta bersangkutan → kolom **`paymentConfirmedAt`** diisi. + Ini **idempoten** (konfirmasi ganda tidak mengubah hasil akhir yang salah). + +7. **Selesai (sukses payment di app)** + Peserta melihat bahwa pembayaran **sudah dikonfirmasi organizer**. Di sini alur “commit” peserta + uang dalam konteks SeTrip dianggap lengkap dari sisi data aplikasi. + +**Catatan produk:** tidak ada payment gateway; bukti transfer dan nominal final tetap komunikasi organizer–peserta (misalnya WA). + +### 5. Ringkasan peran data | Konsep | Penyimpanan | |--------|-------------| -| Trip | Model `Trip` (judul, gunung, lokasi, tanggal, kuota, harga, status, relasi ke organizer) | -| Peserta | `TripParticipant` unik per `(tripId, userId)` dengan status `CONFIRMED` / `CANCELLED` (default schema juga mengenal `PENDING`; alur UI saat ini memakai `CONFIRMED` saat join) | +| Trip | `Trip`: judul, gunung, lokasi, tanggal, kuota, harga, status trip (`OPEN` / `FULL` / …), meeting point, itinerary, termasuk/tidak termasuk, relasi ke organizer | +| Peserta | `TripParticipant` unik per `(tripId, userId)`: status **`PENDING`** / **`CONFIRMED`** / **`CANCELLED`**, serta **`markedPaidAt`** & **`paymentConfirmedAt`** untuk alur bayar manual | +| Organizer (kepercayaan) | `User.isVerified`; agregat rating & jumlah trip dibuat dihitung dari data ulasan & trip | ## Menjalankan secara lokal @@ -63,13 +113,13 @@ Buka [http://localhost:3000](http://localhost:3000). ## Perbaikan bug (yang relevan dengan join & listing) 1. **Join lagi setelah “Batal ikut”** - Satu user hanya boleh satu baris partisipasi per trip (`@@unique([tripId, userId])`). Kode lama mencoba `create` lagi setelah status `CANCELLED`, sehingga bisa gagal dengan pelanggaran unik. Sekarang jika sudah ada baris `CANCELLED`, partisipasi **diaktifkan kembali** (`CONFIRMED`) lewat `participantRepo.reactivate`, bukan insert baru. + Satu user hanya satu baris per trip (`@@unique([tripId, userId])`). Jika baris sudah `CANCELLED`, join berikutnya **mengaktifkan kembali** partisipasi ke **`PENDING`** (bukan insert baru). 2. **Jumlah peserta di kartu / daftar** - `_count.participants` di query listing sebelumnya menghitung semua baris termasuk yang `CANCELLED`, sehingga “slot tersisa” di `TripCard` bisa salah. Count sekarang hanya menghitung peserta dengan status **bukan** `CANCELLED`. + `_count.participants` hanya menghitung status **bukan** `CANCELLED`, agar slot dan label “penuh” konsisten. -3. **Segar halaman setelah join/batal/buat trip** - Setelah aksi trip, cache halaman `/trips` dan `/` ikut di-`revalidatePath` agar jumlah slot dan daftar di beranda konsisten tanpa harus refresh manual. +3. **Segar halaman setelah aksi** + `revalidatePath` dipanggil setelah join, batal, buat trip, setujui/tolak peserta, dan konfirmasi pembayaran agar daftar dan detail konsisten. ## Learn More diff --git a/app/create-trip/page.tsx b/app/create-trip/page.tsx index b0d0124..6aeccd4 100644 --- a/app/create-trip/page.tsx +++ b/app/create-trip/page.tsx @@ -73,7 +73,7 @@ export default function CreateTripPage() { setLoading(true); const formData = new FormData(e.currentTarget); - // Hari kalender lokal → YYYY-MM-DD (bukan toISOString, supaya tidak geser ke UTC) + // Tanggal dari picker → string tanggal untuk server action formData.set("date", formatLocalCalendarYmd(startDate)); if (endDate) { const startYmd = formatLocalCalendarYmd(startDate); @@ -215,10 +215,63 @@ export default function CreateTripPage() { name="description" rows={4} className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white" - placeholder="Detail trip, itinerary, meeting point, fasilitas..." + placeholder="Ringkasan trip, vibe, level kesulitan..." /> +
+ + +
+ +
+ +