Compare commits

..

13 Commits

Author SHA1 Message Date
arifal 5a7c832f1e 0.16.13 2026-06-02 13:53:06 +07:00
arifal 754111b359 update terms and privacy 2026-06-02 13:52:40 +07:00
arifal 88353d5d06 install 2026-05-22 15:38:08 +07:00
arifal 0e7bb07772 0.16.12 2026-05-22 15:17:59 +07:00
arifal 3268a6284e update lib 2026-05-22 15:17:40 +07:00
arifal 73406d0b86 0.16.11 2026-05-22 14:53:07 +07:00
arifal 4c449a572a fix upload image trip 2026-05-22 14:52:22 +07:00
arifal 9022f983a2 0.16.10 2026-05-21 15:31:33 +07:00
arifal 6b8f9dec5d fix warning class style 2026-05-21 15:30:53 +07:00
arifal e6a032e8e0 0.16.9 2026-05-21 12:20:49 +07:00
arifal 81a0c2c6c8 fix oauth google sign 2026-05-21 12:20:28 +07:00
arifal 03887fb1cd 0.16.8 2026-05-21 11:59:32 +07:00
arifal f84d0e3726 fix ui style 2026-05-21 11:59:02 +07:00
68 changed files with 2120 additions and 1672 deletions
+4 -1
View File
@@ -11,7 +11,10 @@
"PowerShell(npx tsc --noEmit 2>&1)",
"PowerShell(npx eslint server/services/refund.service.ts server/repositories/refund.repo.ts features/refund app/admin/refunds 2>&1)",
"PowerShell(npx eslint server lib features app 2>&1)",
"Bash(npx eslint *)"
"Bash(npx eslint *)",
"Bash(Get-ChildItem -Path \"D:\\\\development\\\\weekly-project\\\\setrip\" -Recurse -Directory -Depth 2)",
"Bash(Select-Object FullName)",
"Bash(Get-Content -Path \"D:\\\\development\\\\weekly-project\\\\setrip\\\\.env.example\" -ErrorAction SilentlyContinue)"
]
}
}
+3
View File
@@ -12,6 +12,9 @@ KYC_ENCRYPTION_KEY=
KYC_NIK_PEPPER=
# Absolute path for private KYC uploads (default: <cwd>/uploads/private)
KYC_UPLOAD_DIR=
# Absolute path for public trip image uploads (default: <cwd>/uploads/trips)
# Pakai volume persisten — file di sini harus selamat saat redeploy/restart.
TRIP_UPLOAD_DIR=
GOOGLE_CLIENT_ID="xxxxxxxx"
GOOGLE_CLIENT_SECRET="xxxxxxxx"
+2 -1
View File
@@ -36,7 +36,8 @@ yarn-error.log*
.env.development
.env.local
# private uploads (KYC: KTP / liveness). Never serve directly.
# runtime uploads KYC (encrypted, private) & trip images (public, served via
# /api/trip-images). User data, not source: keep out of git, back up separately.
/uploads/
# vercel
+65 -60
View File
@@ -1,6 +1,6 @@
# 🔒 Privacy Policy (Kebijakan Privasi) SeTrip
**Terakhir diperbarui: 2026-04-27**
**Terakhir diperbarui: 2026-06-02**
SeTrip menghargai privasi Anda. Kebijakan Privasi ini menjelaskan bagaimana kami mengumpulkan, menggunakan, dan melindungi informasi Anda saat menggunakan platform SeTrip.
@@ -10,62 +10,83 @@ Dengan menggunakan SeTrip, Anda menyetujui praktik yang dijelaskan dalam Kebijak
# 1. Informasi yang Kami Kumpulkan
Kami dapat mengumpulkan informasi berikut:
## a. Informasi Akun
- Nama
- Email
- Nomor telepon
- Password (disimpan dalam bentuk terenkripsi)
- Password (disimpan dalam bentuk hash bcrypt, tidak pernah dalam teks asli)
## b. Informasi Profil
Anda dapat mendaftar menggunakan email & password atau melalui akun Google. Jika Anda masuk dengan Google, kami menerima nama, email, foto profil, dan token akun dari Google; tidak ada password yang dibuat untuk akun tersebut.
## b. Informasi Profil (Publik)
Informasi yang Anda pilih untuk dibagikan dan ditampilkan ke pengguna lain untuk penemuan teman dan pencocokan trip:
- Foto profil
- Deskripsi diri
- Riwayat trip
- Bio / deskripsi diri
- Kota domisili
- Minat/aktivitas favorit (interests)
- Username Instagram (opsional)
- Gaya perjalanan / vibe (chill, balanced, hardcore)
- Riwayat trip yang diikuti
## c. Informasi Transaksi
## c. Verifikasi Identitas Organizer (KYC)
- Data booking trip
- Status pembayaran
- Riwayat aktivitas
Khusus bagi pengguna yang ingin menjadi organizer:
## d. Informasi Teknis
- Nama lengkap sesuai KTP
- Nomor Induk Kependudukan (NIK)
- Tanggal lahir dan alamat
- Foto KTP
- Foto liveness (memegang kertas bertuliskan "SETRIP")
- Data rekening bank (nama bank, nomor rekening, nama pemilik) untuk pencairan dana
NIK serta foto KTP dan liveness disimpan dalam bentuk terenkripsi (AES-256-GCM) pada penyimpanan privat di luar akses publik. NIK juga di-hash (HMAC) sehingga kami dapat memeriksa keunikannya tanpa membuka data aslinya.
## d. Informasi Transaksi & Pembayaran
- Data booking trip dan nominal pembayaran
- Status pembayaran dan metode pembayaran (mis. virtual account, GoPay, QRIS, transfer manual)
- ID transaksi dan catatan callback dari penyedia pembayaran (Midtrans)
- Riwayat refund dan pencairan dana (payout), termasuk data rekening tujuan
Pembayaran kartu/e-wallet diproses langsung oleh penyedia pembayaran Midtrans. Kami tidak menyimpan nomor kartu, PIN, atau kredensial pembayaran Anda.
## e. Informasi Teknis
- Alamat IP
- Browser
- Perangkat yang digunakan
- Log aktivitas
- Log aktivitas dan sistem (termasuk log pengiriman email dan log tindakan admin)
---
# 2. Cara Kami Menggunakan Informasi
Kami menggunakan informasi Anda untuk:
- Membuat dan mengelola akun
- Menghubungkan pengguna dengan organizer
- Memproses booking dan aktivitas trip
- Menghubungkan pengguna dengan organizer serta mencocokkan teman/trip berdasarkan minat dan vibe
- Memproses booking, pembayaran, escrow, refund, dan pencairan dana
- Memverifikasi identitas organizer (KYC)
- Mengirim email dan notifikasi terkait aktivitas akun dan transaksi
- Meningkatkan layanan dan pengalaman pengguna
- Mengirim notifikasi terkait aktivitas
- Mencegah penipuan dan penyalahgunaan
---
# 3. Pembagian Informasi
Kami tidak menjual data pribadi Anda.
Kami tidak menjual data pribadi Anda. Namun, kami dapat membagikan informasi dalam kondisi berikut:
Namun, kami dapat membagikan informasi dalam kondisi berikut:
## a. Dengan Organizer & Peserta Lain
## a. Dengan Organizer
- Profil publik Anda dan informasi dasar (seperti nama) dapat dibagikan kepada organizer dan peserta lain untuk keperluan trip
- Informasi dasar seperti nama dan kontak dapat dibagikan kepada organizer untuk keperluan trip
## b. Dengan Penyedia Layanan Pihak Ketiga
## b. Dengan Penyedia Layanan
- Untuk kebutuhan teknis (hosting, analytics, dll)
- Midtrans — untuk memproses pembayaran
- Resend — untuk mengirim email transaksional
- Google — saat Anda memilih masuk dengan akun Google
- Penyedia hosting dan basis data — untuk menjalankan layanan
## c. Kewajiban Hukum
@@ -75,82 +96,66 @@ Namun, kami dapat membagikan informasi dalam kondisi berikut:
# 4. Keamanan Data
Kami berusaha melindungi data Anda dengan:
- Enkripsi password
- Pembatasan akses data
- Hash password (bcrypt)
- Enkripsi data KYC sensitif (AES-256-GCM) di penyimpanan privat
- Pembatasan akses data dan pencatatan tindakan admin (audit log)
- Verifikasi tanda tangan (signature) pada callback pembayaran
- Sistem keamanan standar industri
Namun, tidak ada sistem yang 100% aman.
---
# 5. Penyimpanan Data
# 5. Penyimpanan & Retensi Data
Kami menyimpan data Anda selama:
Kami menyimpan data Anda selama akun aktif dan selama dibutuhkan untuk keperluan layanan.
- Akun Anda aktif
- Dibutuhkan untuk keperluan layanan
Data dapat dihapus atas permintaan pengguna, kecuali diwajibkan oleh hukum untuk disimpan.
Data dapat dihapus atas permintaan pengguna. Namun, catatan keuangan dan audit (pembayaran, refund, pencairan dana, log email, dan log tindakan admin) bersifat permanen (append-only) dan dapat tetap disimpan meskipun akun dihapus, sepanjang diwajibkan untuk kepatuhan hukum, akuntansi, dan penyelesaian sengketa. Data KYC disimpan selama dibutuhkan untuk verifikasi dan kewajiban hukum.
---
# 6. Hak Pengguna
Anda memiliki hak untuk:
- Mengakses data pribadi Anda
- Memperbarui informasi
- Menghapus akun
- Menarik persetujuan
Penghapusan akun tidak menghapus catatan keuangan dan audit yang wajib kami simpan sebagaimana dijelaskan pada bagian 5.
---
# 7. Cookie & Tracking
SeTrip dapat menggunakan:
- Cookie
- Teknologi pelacakan sederhana
Untuk:
- Menyimpan sesi login
- Meningkatkan pengalaman pengguna
SeTrip hanya menggunakan cookie sesi (JWT) untuk menjaga Anda tetap login. Kami **tidak** menggunakan cookie iklan maupun layanan analitik/pelacakan pihak ketiga.
---
# 8. Layanan Pihak Ketiga
SeTrip dapat menggunakan layanan pihak ketiga seperti:
SeTrip menggunakan layanan pihak ketiga berikut:
- Hosting
- Analytics
- Payment gateway (di masa depan)
- Google — autentikasi (login dengan Google)
- Midtrans — payment gateway
- Resend — pengiriman email
- Penyedia hosting dan basis data
Kami tidak bertanggung jawab atas kebijakan privasi pihak ketiga tersebut.
Kami tidak bertanggung jawab atas kebijakan privasi pihak ketiga tersebut. Silakan tinjau kebijakan privasi masing-masing penyedia.
---
# 9. Perlindungan terhadap Penipuan
Kami dapat menggunakan data untuk:
- Mendeteksi aktivitas mencurigakan
- Mencegah penipuan
- Menangguhkan akun yang melanggar
- Melindungi pengguna lain
---
# 10. Perubahan Kebijakan Privasi
SeTrip dapat memperbarui Kebijakan Privasi ini sewaktu-waktu.
Pengguna disarankan untuk:
- Membaca secara berkala
- Memahami perubahan yang berlaku
SeTrip dapat memperbarui Kebijakan Privasi ini sewaktu-waktu. Pengguna disarankan untuk membaca secara berkala dan memahami perubahan yang berlaku.
---
+26 -1
View File
@@ -94,7 +94,7 @@ Alur ini menggambarkan satu peserta dari pertama kali mendaftar sampai pembayara
### 5. Ringkasan peran data
| Konsep | Penyimpanan |
|--------|-------------|
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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) | `OrganizerVerification` (1-1 ke `User`) berisi KTP, foto liveness (memegang kertas "SETRIP"), rekening, dan status (`PENDING` / `APPROVED` / `REJECTED`); badge **Verified Organizer** muncul ketika `status === "APPROVED"` (helper `lib/trust.ts → isVerifiedOrganizer`). Agregat rating & jumlah trip dihitung dari ulasan & trip. |
@@ -128,3 +128,28 @@ Buka [http://localhost:3000](http://localhost:3000).
- [Next.js Documentation](https://nextjs.org/docs)
- [Prisma Documentation](https://www.prisma.io/docs)
# 1. Install SEMUA dep (termasuk dev) — deterministik dari lockfile.
# --include=dev memaksa dev terpasang walau NODE_ENV=production ter-export.
npm ci --include=dev
# 2. Prisma: generate client + apply migrasi
npx prisma generate
npx prisma migrate deploy
# 3. Build (butuh devDependencies)
npm run build
# 4. (Opsional) ramping-kan node_modules — buang dev SETELAH build selesai
npm prune --omit=dev
# 5. Jalankan
pm2 start ecosystem.config.js --env production
# atau restart: pm2 restart setrip --update-env
+28 -17
View File
@@ -1,6 +1,6 @@
# 📜 Terms & Conditions (Syarat & Ketentuan) SeTrip
**Terakhir diperbarui: 2026-04-27**
**Terakhir diperbarui: 2026-06-02**
Selamat datang di SeTrip. Dengan mengakses atau menggunakan platform SeTrip, Anda menyetujui untuk terikat oleh Syarat & Ketentuan berikut.
@@ -12,15 +12,16 @@ Dalam dokumen ini:
- **SeTrip**: Platform yang menghubungkan pengguna dengan penyelenggara trip.
- **Pengguna (User)**: Individu yang menggunakan aplikasi SeTrip.
- **Organizer (Penyelenggara)**: Pengguna yang membuat dan mengelola trip.
- **Organizer (Penyelenggara)**: Pengguna terverifikasi yang membuat dan mengelola trip.
- **Trip**: Kegiatan perjalanan yang dibuat oleh organizer.
- **Escrow**: Penahanan dana peserta oleh SeTrip sampai trip selesai sebelum diteruskan ke organizer.
- **Platform**: Website atau aplikasi SeTrip.
---
# 2. Peran SeTrip
SeTrip bertindak sebagai **platform perantara** yang menghubungkan pengguna dan organizer.
SeTrip bertindak sebagai **platform perantara** yang menghubungkan pengguna dan organizer serta memfasilitasi pembayaran.
SeTrip:
@@ -51,7 +52,17 @@ Dengan menggunakan SeTrip, Anda menyatakan bahwa:
---
# 5. Trip & Booking
# 5. Verifikasi Organizer (KYC)
Untuk dapat membuat dan mengelola trip, pengguna wajib melalui proses verifikasi identitas (KYC):
- Organizer mengunggah data identitas (KTP, NIK, foto liveness) dan informasi rekening bank untuk pencairan dana
- SeTrip meninjau pengajuan secara manual dan berhak menyetujui, menolak, atau meminta dokumen diunggah ulang
- Data verifikasi disimpan secara terenkripsi sesuai Kebijakan Privasi
---
# 6. Trip & Booking
- Organizer bertanggung jawab atas seluruh informasi trip
- Pengguna wajib membaca detail trip sebelum melakukan join
@@ -59,7 +70,7 @@ Dengan menggunakan SeTrip, Anda menyatakan bahwa:
---
# 6. Pembayaran & Escrow
# 7. Pembayaran & Escrow
- Pembayaran dilakukan melalui metode yang tersedia di platform (Midtrans atau transfer manual yang dikonfirmasi organizer)
- **Uang peserta ditahan oleh SeTrip (escrow)** sejak pembayaran berhasil hingga trip selesai + 3 hari, baru kemudian diteruskan ke organizer
@@ -68,7 +79,7 @@ Dengan menggunakan SeTrip, Anda menyatakan bahwa:
---
# 7. Pembatalan & Refund
# 8. Pembatalan & Refund
**Saat peserta membatalkan booking sendiri** (kebijakan default platform):
@@ -84,7 +95,7 @@ Kebijakan di atas berlaku platform-wide; organizer tidak dapat menetapkan policy
---
# 8. Tanggung Jawab Organizer
# 9. Tanggung Jawab Organizer
Organizer wajib:
@@ -94,7 +105,7 @@ Organizer wajib:
---
# 9. Risiko Perjalanan
# 10. Risiko Perjalanan
Pengguna memahami bahwa aktivitas perjalanan, terutama kegiatan outdoor, memiliki risiko termasuk namun tidak terbatas pada:
@@ -109,7 +120,7 @@ Dengan mengikuti trip, pengguna menyatakan:
---
# 10. Batasan Tanggung Jawab
# 11. Batasan Tanggung Jawab
SeTrip tidak bertanggung jawab atas:
@@ -120,7 +131,7 @@ SeTrip tidak bertanggung jawab atas:
---
# 11. Larangan Transaksi di Luar Platform
# 12. Larangan Transaksi di Luar Platform
Pengguna disarankan untuk tidak melakukan transaksi di luar platform.
@@ -132,15 +143,15 @@ SeTrip tidak bertanggung jawab atas:
---
# 12. Sistem Review
# 13. Sistem Review
- Pengguna dapat memberikan review setelah trip
- Peserta dapat memberikan review setelah trip selesai
- Review harus jujur dan tidak mengandung unsur fitnah
- SeTrip berhak menghapus review yang melanggar
---
# 13. Penangguhan & Penghentian Akun
# 14. Penangguhan & Penghentian Akun
SeTrip berhak untuk:
@@ -156,7 +167,7 @@ Jika pengguna:
---
# 14. Perubahan Layanan
# 15. Perubahan Layanan
SeTrip dapat:
@@ -168,7 +179,7 @@ Tanpa pemberitahuan sebelumnya
---
# 15. Perubahan Syarat & Ketentuan
# 16. Perubahan Syarat & Ketentuan
SeTrip dapat memperbarui Syarat & Ketentuan ini kapan saja.
@@ -179,13 +190,13 @@ Pengguna disarankan untuk:
---
# 16. Hukum yang Berlaku
# 17. Hukum yang Berlaku
Syarat & Ketentuan ini diatur oleh hukum yang berlaku di Republik Indonesia.
---
# 17. Kontak
# 18. Kontak
Jika Anda memiliki pertanyaan, silakan hubungi:
+198
View File
@@ -0,0 +1,198 @@
# 🎨 SeTrip — UI Style Guide
Panduan visual untuk membuat tampilan SeTrip terasa **natural, manusiawi, dan tidak "AI-generated"** — tanpa mengorbankan SEO.
> Prinsip utama: **clean, calm, earthy.** SeTrip itu social-companion platform ("pergi bareng, bukan sendiri"), bukan marketplace booking. UI harus terasa hangat & tenang, bukan ramai & promosi.
---
## 1. Filosofi Desain
| Hindari (kesan AI-generated) | Gunakan (kesan natural) |
| --- | --- |
| ❌ Gradient berlebihan | ✅ Background putih bersih / `neutral-50` |
| ❌ Neumorphism | ✅ Soft green / earthy tone |
| ❌ Glassmorphism ekstrem | ✅ Border tipis 1px + shadow lembut |
| ❌ Icon 3D / emoji sebagai UI icon | ✅ Stroke icon tipis (lucide-react) |
| ❌ Card mengambang dengan blur tebal | ✅ Simple rounded card, datar, jelas |
| ❌ Warna saturasi tinggi di mana-mana | ✅ 1 warna aksen, sisanya netral |
**Tiga kata kunci:** *bersih · tenang · jujur.* Kalau sebuah elemen terasa "ingin pamer", kemungkinan besar perlu disederhanakan.
---
## 2. Warna
Token warna sudah tersedia di [app/globals.css](app/globals.css) — **gunakan token, jangan hardcode hex.**
| Peran | Token | Catatan |
| --- | --- | --- |
| Aksi utama / brand | `primary-600` (#16A34A) | Hijau gunung — earthy, tidak neon |
| Hover aksi utama | `primary-700` | Hindari `primary-500` (terlalu terang) untuk hover tombol |
| Aksen sekunder | `secondary-600` (#0EA5E9) | Pakai hemat — info, link, badge vibe |
| Teks utama | `neutral-800` | |
| Teks sekunder | `neutral-500` | |
| Border | `neutral-200` | Selalu 1px |
| Background halaman | `neutral-50` | |
| Surface / card | `white` | |
### Aturan warna
- **Satu aksen per layar.** Hijau adalah bintangnya. Biru hanya bumbu.
- **Maksimal 1 area gradient per halaman**, dan harus halus (mis. hero). Sisanya warna solid.
- Surface = putih solid. Jangan pakai `bg-white/80 + backdrop-blur` untuk card biasa.
- Earthy tone tambahan diperbolehkan sebagai background section (`primary-50`, `amber-50`) tapi jangan dijadikan blok besar warna-warni.
---
## 3. Sistem Ikon — lucide-react
`lucide-react` sudah terpasang. **Stroke icon = wajah baru SeTrip.**
### Aturan ikon
- **Stroke icon, bukan filled.** Lucide default sudah stroke — jangan ganti `fill`.
- Ukuran konsisten: `16` (inline teks), `20` (tombol/list), `24` (header section).
- Ketebalan stroke seragam: `strokeWidth={1.75}` (default lucide `2` sedikit terlalu tebal untuk gaya clean ini).
- Warna ikut teks: `text-neutral-500` untuk netral, `text-primary-600` untuk aktif.
- **Jangan** beri ikon background bulat berwarna + emoji di dalamnya (pola lama). Cukup ikon polos, atau ikon di atas lingkaran `neutral-100` yang sangat soft bila perlu penekanan.
```tsx
import { Mountain } from "lucide-react";
// inline
<Mountain size={16} strokeWidth={1.75} className="text-neutral-500" />
// di tombol
<Plus size={20} strokeWidth={1.75} />
```
### Pemetaan ikon per fitur
| Fitur | Ikon lucide |
| --- | --- |
| Trip | `Mountain` |
| Group / peserta | `Users` |
| Organizer | `BadgeCheck` |
| Verified | `ShieldCheck` |
| Payment | `Wallet` |
| Meeting Point | `MapPinned` |
| Chat | `MessageCircle` |
| Review / rating | `Star` |
| Profil | `UserRound` |
Saran tambahan yang konsisten dengan set di atas:
| Konteks | Ikon lucide |
| --- | --- |
| Tanggal / jadwal | `CalendarDays` |
| Lokasi umum | `MapPin` |
| Buat trip (FAB & CTA) | `Plus` |
| Cari / filter | `Search`, `SlidersHorizontal` |
| Menu mobile | `Menu` / `X` |
| Kategori (jelajah) | `Compass` |
| Sedang ramai / populer | `Flame` atau `TrendingUp` |
| Harga | `Tag` |
> **Catatan emoji kategori:** `categoryMeta()` di [lib/activity-category.ts](lib/activity-category.ts) masih memakai emoji (🏔️🏕️🤿). Boleh dipertahankan **hanya** di konten data trip (terasa playful & manusiawi di tempat itu), tapi **elemen UI/chrome** (navbar, header section, tombol, badge status) harus pakai stroke icon.
---
## 4. Komponen
### Card
```
✅ rounded-2xl · border border-neutral-200 · bg-white
✅ hover: shadow lembut + translate-y-0.5 (sudah dipakai di TripCard — pertahankan)
❌ jangan: shadow tebal default, blur, gradient border
```
### Tombol
| Jenis | Style |
| --- | --- |
| Primer | `bg-primary-600 hover:bg-primary-700 text-white rounded-xl` |
| Sekunder | `border border-neutral-200 text-neutral-700 hover:bg-neutral-50` |
| Ghost | `text-neutral-600 hover:bg-neutral-100` |
- Shadow tombol seperlunya. `shadow-lg shadow-primary-600/25` boleh untuk **satu** CTA utama per layar, jangan semua tombol.
- `hover:scale-105` cukup untuk CTA hero saja — jangan di semua tombol (terasa "demo template").
- Sertakan ikon lucide bila memperjelas aksi (mis. `Plus` untuk "Buat Trip").
### Badge / pill
- `rounded-full`, teks kecil, warna soft (`primary-50`/`primary-700`).
- Status pakai warna semantik solid lembut, bukan transparan + blur.
### Header section
Pola lama: kotak berwarna + emoji. Pola baru:
```tsx
<div className="flex items-center gap-2.5">
<Compass size={20} strokeWidth={1.75} className="text-primary-600" />
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">Jelajah per Kategori</h2>
<p className="text-xs text-neutral-500">Hiking, diving, konser, sampai retreat</p>
</div>
</div>
```
---
## 5. Yang Perlu Dirombak di Codebase
Temuan konkret dari kode saat ini:
| Lokasi | Masalah | Aksi |
| --- | --- | --- |
| [app/(public)/page.tsx](app/(public)/page.tsx) | Header section pakai kotak warna + emoji (✨🔥🏔️🤝), badge hero pakai emoji 🤝 | Ganti ke stroke icon (`Compass`, `Flame`, `Mountain`, `Users`) |
| [app/(public)/page.tsx](app/(public)/page.tsx#L110) | Hero gradient 3 warna (`from-primary-900 via-neutral-900 to-secondary-900`) | Sederhanakan jadi overlay solid `neutral-900/80` atau gradient 2 warna halus |
| [app/(public)/page.tsx](app/(public)/page.tsx#L386) | FAB pakai teks `"+"` | Ganti `<Plus size={24} />` |
| [app/(public)/page.tsx](app/(public)/page.tsx#L153) | Stat "100% Seru" terasa filler/AI | Ganti metrik nyata (jumlah peserta, organizer terverifikasi) atau hapus |
| [components/shared/navbar.tsx](components/shared/navbar.tsx#L112) | Hamburger pakai inline SVG manual | Ganti `Menu` / `X` dari lucide |
| [components/shared/navbar.tsx](components/shared/navbar.tsx#L13) | `bg-white/90 backdrop-blur-md` | Boleh dipertahankan (tipis, wajar untuk sticky nav) — jangan ditebalkan |
| [features/trip/components/trip-card.tsx](features/trip/components/trip-card.tsx) | Avatar fallback & meta info bisa diperkuat dengan ikon stroke (`Users`, `CalendarDays`, `MapPin`) | Tambah ikon kecil di baris meta |
Prioritas: **homepage dulu** (paling sering dilihat & paling kuat kesan AI-nya), lalu navbar, lalu komponen kartu.
---
## 6. SEO — Wajib Dijaga
Perubahan visual **tidak boleh** menurunkan SEO. Aturan:
- **Ikon lucide = inline SVG**, ringan & tidak memblokir render. Aman untuk Core Web Vitals.
- **Ikon dekoratif** (hiasan di samping teks) harus `aria-hidden`. Lucide perlu di-set manual:
```tsx
<Mountain size={16} aria-hidden className="text-neutral-500" />
```
- **Ikon yang berdiri sendiri sebagai tombol** (mis. tombol menu) wajib punya label:
```tsx
<button aria-label="Buka menu"><Menu size={20} aria-hidden /></button>
```
- **Jangan ubah teks jadi gambar.** Heading, slogan, deskripsi harus tetap teks HTML.
- **Pertahankan hirarki heading:** satu `<h1>` per halaman, `<h2>` untuk section. Jangan turunkan jadi `<div>` saat merapikan visual.
- **Pertahankan metadata & JSON-LD** di [app/layout.tsx](app/layout.tsx) dan [app/(public)/page.tsx](app/(public)/page.tsx) — structured data, OpenGraph, canonical jangan disentuh saat refactor UI.
- **Komponen tetap Server Component** kalau memungkinkan. Jangan tambah `"use client"` cuma untuk render ikon — lucide jalan di server.
- **Gambar:** terus pakai `next/image` dengan `alt` deskriptif dan `priority` untuk LCP (cover hero & kartu pertama).
- **Kontras warna** minimal AA: stroke icon `neutral-500` di atas putih sudah memenuhi; jangan pakai `neutral-300` untuk ikon/teks penting.
---
## 7. Checklist Implementasi
- [ ] Ganti semua emoji di chrome UI (navbar, header section, tombol, FAB) → stroke icon lucide
- [ ] Standarkan `size` (16/20/24) & `strokeWidth={1.75}` di seluruh ikon
- [ ] Sederhanakan gradient hero homepage jadi maksimal 2 warna / overlay solid
- [ ] Ganti hamburger SVG manual di navbar → `Menu`/`X`
- [ ] Tinjau metrik "100% Seru" — ganti angka nyata atau hapus
- [ ] Pastikan ikon dekoratif `aria-hidden`, ikon-tombol punya `aria-label`
- [ ] Pastikan struktur heading `h1`/`h2` tetap utuh setelah refactor
- [ ] Jalankan Lighthouse — skor SEO & Accessibility tidak turun
- [ ] Verifikasi tidak ada `"use client"` baru yang ditambahkan hanya demi ikon
---
*Acuan token: [app/globals.css](app/globals.css) · Acuan brand: [lib/site.ts](lib/site.ts)*
+14 -6
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { Lock, Clock, CircleAlert } from "lucide-react";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service";
@@ -11,8 +12,13 @@ export default async function CreateTripPage() {
return (
<div className="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50 text-3xl">
🔒
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-50">
<Lock
size={28}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div>
<p className="mb-4 text-neutral-500">
Kamu harus login untuk membuat trip.
@@ -57,8 +63,9 @@ function VerificationBanner({
if (status === "PENDING") {
return (
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<p className="text-sm font-bold text-amber-800">
Verifikasi sedang diproses
<p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
<Clock size={15} strokeWidth={2} aria-hidden />
Verifikasi sedang diproses
</p>
<p className="mt-1 text-sm text-neutral-700">
Pengajuan verifikasi-mu masih ditinjau admin. Sementara menunggu, kamu
@@ -73,8 +80,9 @@ function VerificationBanner({
<div className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1">
<p className="text-sm font-bold text-amber-800">
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
<p className="flex items-center gap-1.5 text-sm font-bold text-amber-800">
<CircleAlert size={15} strokeWidth={2} aria-hidden />
{isRejected ? "Verifikasi ditolak" : "Belum terverifikasi"}
</p>
<p className="mt-1 text-sm text-neutral-700">
{isRejected
+13 -1
View File
@@ -1,4 +1,7 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = {
title: "Masuk",
@@ -8,6 +11,15 @@ export const metadata: Metadata = {
robots: { index: false, follow: true },
};
export default function LoginLayout({ children }: { children: React.ReactNode }) {
export default async function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
// User yang sudah login tidak boleh mengakses halaman login lagi.
const session = await getServerSession(authOptions);
if (session?.user) {
redirect(session.user.isAdmin ? "/admin" : "/");
}
return children;
}
+10 -7
View File
@@ -38,13 +38,16 @@ function LoginForm() {
if (result?.error) {
setError(result.error);
} else {
const rawCallback = searchParams.get("callbackUrl");
let next = safeInternalPath(rawCallback);
// Tanpa callbackUrl eksplisit, arahkan admin ke dashboard /admin.
if (!rawCallback) {
const callbackPath = safeInternalPath(searchParams.get("callbackUrl"));
const session = await getSession();
if (session?.user?.isAdmin) next = "/admin";
}
// Admin selalu diarahkan ke dashboard /admin setelah login — kecuali
// callbackUrl memang menuju sub-halaman admin (deep link dari /admin/...).
// callbackUrl non-admin (mis. "/" sisa dari percobaan login Google) tidak
// boleh membuat admin "nyangkut" di halaman publik.
const next =
session?.user?.isAdmin && !callbackPath.startsWith("/admin")
? "/admin"
: callbackPath;
router.push(next);
router.refresh();
}
@@ -84,7 +87,7 @@ function LoginForm() {
</div>
{/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm">
<div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
{error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
+95 -64
View File
@@ -8,6 +8,15 @@ import { profileRepo } from "@/server/repositories/profile.repo";
import { TripCard } from "@/features/trip/components/trip-card";
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
import {
Compass,
Flame,
Mountain,
Handshake,
Tent,
Plus,
type LucideIcon,
} from "lucide-react";
type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number];
@@ -44,6 +53,9 @@ export default async function HomePage() {
const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
// Social proof: total orang yang sudah gabung di seluruh open trip.
const joinerCount = trips.reduce((sum, t) => sum + t._count.participants, 0);
const upcomingTrips = trips
.filter((t) => new Date(t.date) <= nextWeek)
.slice(0, 3);
@@ -107,12 +119,17 @@ export default async function HomePage() {
className="object-cover opacity-10 brightness-150"
priority
/>
<div className="absolute inset-0 bg-linear-to-br from-primary-900/85 via-neutral-900/75 to-secondary-900/80" />
<div className="absolute inset-0 bg-linear-to-br from-neutral-900/90 to-primary-900/80" />
<div className="relative mx-auto max-w-4xl px-4 pb-10 pt-8 text-center sm:pb-14 sm:pt-12 lg:pb-16 lg:pt-14">
{/* Brand badge */}
<div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary-400/30 bg-primary-600/20 px-3 py-1 sm:mb-6 sm:gap-2 sm:px-4 sm:py-1.5">
<span className="text-xs sm:text-sm">🤝</span>
<Handshake
size={14}
strokeWidth={1.75}
aria-hidden
className="text-primary-300"
/>
<span className="text-xs font-medium text-primary-300 sm:text-sm">
Cari teman trip & aktivitas
</span>
@@ -150,8 +167,12 @@ export default async function HomePage() {
</div>
<div className="h-8 w-px bg-neutral-700 sm:h-10" />
<div>
<p className="text-xl font-bold text-white sm:text-2xl">100%</p>
<p className="text-[11px] text-neutral-400 sm:text-xs">Seru</p>
<p className="text-xl font-bold text-white sm:text-2xl">
{joinerCount}
</p>
<p className="text-[11px] text-neutral-400 sm:text-xs">
Sudah Gabung
</p>
</div>
</div>
</div>
@@ -161,19 +182,11 @@ export default async function HomePage() {
<div className="mx-auto max-w-6xl px-4 py-6 space-y-8 sm:py-8 sm:space-y-10 lg:py-10 lg:space-y-12">
{/* Jelajah per kategori */}
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Jelajah per Kategori
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Hiking, diving, konser, sampai retreat
</p>
</div>
</div>
<SectionHeading
icon={Compass}
title="Jelajah per Kategori"
subtitle="Hiking, diving, konser, sampai retreat"
/>
<div className="flex flex-wrap gap-2">
{ACTIVITY_CATEGORIES.map((c) => {
const m = categoryMeta(c);
@@ -194,19 +207,11 @@ export default async function HomePage() {
{/* Trip Terdekat */}
{upcomingTrips.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
🔥
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Trip Terdekat
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Berangkat dalam 7 hari ke depan
</p>
</div>
</div>
<SectionHeading
icon={Flame}
title="Trip Terdekat"
subtitle="Berangkat dalam 7 hari ke depan"
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{upcomingTrips.slice(0, 3).map((trip, i) => (
<TripCard
@@ -239,32 +244,29 @@ export default async function HomePage() {
{/* Open Trip */}
<section>
<div className="mb-4 flex items-center justify-between sm:mb-5">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
🏔
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Open Trip
</h2>
<p className="hidden text-xs text-neutral-500 sm:block">
Pilih trip, ketemu teman baru
</p>
</div>
</div>
<SectionHeading
icon={Mountain}
title="Open Trip"
subtitle="Pilih trip, ketemu teman baru"
action={
<Link
href="/trips"
className="rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
className="shrink-0 rounded-lg bg-secondary-50 px-2.5 py-1 text-xs font-medium text-secondary-600 hover:bg-secondary-100 sm:px-3 sm:py-1.5 sm:text-sm"
>
Lihat semua
</Link>
</div>
}
/>
{latestTrips.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
🏕
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
<Tent
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
Belum ada trip tersedia
@@ -312,19 +314,11 @@ export default async function HomePage() {
{/* Lagi Ramai — social proof, bukan price proof */}
{buzzingTrips.length > 0 && (
<section>
<div className="mb-4 flex items-center gap-3 sm:mb-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-100 text-base sm:h-9 sm:w-9 sm:text-lg">
🤝
</div>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Lagi Ramai
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Banyak yang sudah gabung kamu nggak bakal jalan sendirian
</p>
</div>
</div>
<SectionHeading
icon={Handshake}
title="Lagi Ramai"
subtitle="Banyak yang sudah gabung — kamu nggak bakal jalan sendirian"
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{buzzingTrips.map((trip) => (
<TripCard
@@ -383,11 +377,48 @@ export default async function HomePage() {
{/* ========== FAB ========== */}
<Link
href="/create-trip"
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-xl font-bold text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14 sm:text-2xl"
title="Buat Trip"
className="fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary-600 text-white shadow-xl shadow-primary-600/30 transition-all hover:scale-110 hover:bg-primary-500 active:scale-95 sm:bottom-6 sm:right-6 sm:h-14 sm:w-14"
aria-label="Buat Trip"
>
+
<Plus size={24} strokeWidth={2} aria-hidden />
</Link>
</div>
);
}
/** Heading section homepage — ikon stroke + judul, opsional aksi di kanan. */
function SectionHeading({
icon: Icon,
title,
subtitle,
action,
}: {
icon: LucideIcon;
title: string;
subtitle?: string;
action?: React.ReactNode;
}) {
return (
<div className="mb-4 flex items-center justify-between gap-3 sm:mb-5">
<div className="flex items-center gap-2.5">
<Icon
size={22}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
<div>
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
{title}
</h2>
{subtitle && (
<p className="text-[11px] text-neutral-500 sm:text-xs">
{subtitle}
</p>
)}
</div>
</div>
{action}
</div>
);
}
+8 -2
View File
@@ -5,6 +5,7 @@ import { UserCard } from "@/features/profile/components/user-card";
import { PeopleFilter } from "@/features/profile/components/people-filter";
import { isVibe, vibeLabel } from "@/lib/vibe";
import { siteConfig } from "@/lib/site";
import { Users } from "lucide-react";
interface PeoplePageProps {
searchParams: Promise<{
@@ -68,8 +69,13 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
{people.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
🔍
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
<Users
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
</div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters
+116 -43
View File
@@ -1,15 +1,22 @@
import Link from "next/link";
import { ShieldCheck, CircleCheck } from "lucide-react";
export default function PrivacyPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
🔒 Kebijakan Privasi SeTrip
<h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
<ShieldCheck
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Kebijakan Privasi SeTrip
</h1>
<p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27
Terakhir diperbarui: 2026-06-02
</p>
<p className="mt-4 text-sm leading-relaxed text-neutral-700">
SeTrip menghargai privasi Anda. Kebijakan Privasi ini menjelaskan
@@ -29,30 +36,75 @@ export default function PrivacyPage() {
<ul className="ml-5 list-disc space-y-1.5">
<li>Nama</li>
<li>Email</li>
<li>Nomor telepon</li>
<li>Password (disimpan dalam bentuk terenkripsi)</li>
<li>Password (disimpan dalam bentuk hash bcrypt, tidak pernah dalam teks asli)</li>
</ul>
<p className="mt-2">
Anda dapat mendaftar menggunakan email &amp; password atau melalui
akun Google. Jika Anda masuk dengan Google, kami menerima nama,
email, foto profil, dan token akun dari Google; tidak ada password
yang dibuat untuk akun tersebut.
</p>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">b. Informasi Profil</h3>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">
b. Informasi Profil (Publik)
</h3>
<p className="mb-2">
Informasi yang Anda pilih untuk dibagikan dan ditampilkan ke
pengguna lain untuk keperluan penemuan teman dan pencocokan trip:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Foto profil</li>
<li>Deskripsi diri</li>
<li>Riwayat trip</li>
<li>Bio / deskripsi diri</li>
<li>Kota domisili</li>
<li>Minat/aktivitas favorit (interests)</li>
<li>Username Instagram (opsional)</li>
<li>Gaya perjalanan / vibe (chill, balanced, hardcore)</li>
<li>Riwayat trip yang diikuti</li>
</ul>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">c. Informasi Transaksi</h3>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">
c. Verifikasi Identitas Organizer (KYC)
</h3>
<p className="mb-2">
Khusus bagi pengguna yang ingin menjadi organizer, kami
mengumpulkan data verifikasi identitas:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Data booking trip</li>
<li>Status pembayaran</li>
<li>Riwayat aktivitas</li>
<li>Nama lengkap sesuai KTP</li>
<li>Nomor Induk Kependudukan (NIK)</li>
<li>Tanggal lahir dan alamat</li>
<li>Foto KTP</li>
<li>Foto liveness (memegang kertas bertuliskan &ldquo;SETRIP&rdquo;)</li>
<li>Data rekening bank (nama bank, nomor rekening, nama pemilik) untuk pencairan dana</li>
</ul>
<p className="mt-2">
NIK serta foto KTP dan liveness disimpan dalam bentuk terenkripsi
(AES-256-GCM) pada penyimpanan privat di luar akses publik. NIK
juga di-hash (HMAC) sehingga kami dapat memeriksa keunikannya tanpa
membuka data aslinya.
</p>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">d. Informasi Teknis</h3>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">
d. Informasi Transaksi &amp; Pembayaran
</h3>
<ul className="ml-5 list-disc space-y-1.5">
<li>Data booking trip dan nominal pembayaran</li>
<li>Status pembayaran dan metode pembayaran (mis. virtual account, GoPay, QRIS, transfer manual)</li>
<li>ID transaksi dan catatan callback dari penyedia pembayaran (Midtrans)</li>
<li>Riwayat refund dan pencairan dana (payout), termasuk data rekening tujuan</li>
</ul>
<p className="mt-2">
Pembayaran kartu/e-wallet diproses langsung oleh penyedia
pembayaran Midtrans. Kami tidak menyimpan nomor kartu, PIN, atau
kredensial pembayaran Anda.
</p>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">e. Informasi Teknis</h3>
<ul className="ml-5 list-disc space-y-1.5">
<li>Alamat IP</li>
<li>Browser</li>
<li>Perangkat yang digunakan</li>
<li>Log aktivitas</li>
<li>Log aktivitas dan sistem (termasuk log pengiriman email dan log tindakan admin)</li>
</ul>
</section>
@@ -63,10 +115,11 @@ export default function PrivacyPage() {
<p className="mb-3">Kami menggunakan informasi Anda untuk:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Membuat dan mengelola akun</li>
<li>Menghubungkan pengguna dengan organizer</li>
<li>Memproses booking dan aktivitas trip</li>
<li>Menghubungkan pengguna dengan organizer serta mencocokkan teman/trip berdasarkan minat dan vibe</li>
<li>Memproses booking, pembayaran, escrow, refund, dan pencairan dana</li>
<li>Memverifikasi identitas organizer (KYC)</li>
<li>Mengirim email dan notifikasi terkait aktivitas akun dan transaksi</li>
<li>Meningkatkan layanan dan pengalaman pengguna</li>
<li>Mengirim notifikasi terkait aktivitas</li>
<li>Mencegah penipuan dan penyalahgunaan</li>
</ul>
</section>
@@ -78,19 +131,24 @@ export default function PrivacyPage() {
informasi dalam kondisi berikut:
</p>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">a. Dengan Organizer</h3>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">
a. Dengan Organizer &amp; Peserta Lain
</h3>
<ul className="ml-5 list-disc space-y-1.5">
<li>
Informasi dasar seperti nama dan kontak dapat dibagikan kepada
organizer untuk keperluan trip
Profil publik Anda dan informasi dasar (seperti nama) dapat
dibagikan kepada organizer dan peserta lain untuk keperluan trip
</li>
</ul>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">
b. Dengan Penyedia Layanan
b. Dengan Penyedia Layanan Pihak Ketiga
</h3>
<ul className="ml-5 list-disc space-y-1.5">
<li>Untuk kebutuhan teknis (hosting, analytics, dll)</li>
<li>Midtrans untuk memproses pembayaran</li>
<li>Resend untuk mengirim email transaksional</li>
<li>Google saat Anda memilih masuk dengan akun Google</li>
<li>Penyedia hosting dan basis data untuk menjalankan layanan</li>
</ul>
<h3 className="mb-2 mt-4 font-semibold text-neutral-800">c. Kewajiban Hukum</h3>
@@ -103,23 +161,29 @@ export default function PrivacyPage() {
<h2 className="mb-3 text-lg font-bold text-neutral-900">4. Keamanan Data</h2>
<p className="mb-3">Kami berusaha melindungi data Anda dengan:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Enkripsi password</li>
<li>Pembatasan akses data</li>
<li>Hash password (bcrypt)</li>
<li>Enkripsi data KYC sensitif (AES-256-GCM) di penyimpanan privat</li>
<li>Pembatasan akses data dan pencatatan tindakan admin (audit log)</li>
<li>Verifikasi tanda tangan (signature) pada callback pembayaran</li>
<li>Sistem keamanan standar industri</li>
</ul>
<p className="mt-3">Namun, tidak ada sistem yang 100% aman.</p>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">5. Penyimpanan Data</h2>
<h2 className="mb-3 text-lg font-bold text-neutral-900">5. Penyimpanan &amp; Retensi Data</h2>
<p className="mb-3">Kami menyimpan data Anda selama:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Akun Anda aktif</li>
<li>Dibutuhkan untuk keperluan layanan</li>
</ul>
<p className="mt-3">
Data dapat dihapus atas permintaan pengguna, kecuali diwajibkan
oleh hukum untuk disimpan.
Data dapat dihapus atas permintaan pengguna. Namun, catatan
keuangan dan audit (pembayaran, refund, pencairan dana, log email,
dan log tindakan admin) bersifat permanen (append-only) dan dapat
tetap disimpan meskipun akun dihapus, sepanjang diwajibkan untuk
kepatuhan hukum, akuntansi, dan penyelesaian sengketa. Data KYC
disimpan selama dibutuhkan untuk verifikasi dan kewajiban hukum.
</p>
</section>
@@ -132,35 +196,35 @@ export default function PrivacyPage() {
<li>Menghapus akun</li>
<li>Menarik persetujuan</li>
</ul>
<p className="mt-3">
Penghapusan akun tidak menghapus catatan keuangan dan audit yang
wajib kami simpan sebagaimana dijelaskan pada bagian 5.
</p>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">7. Cookie &amp; Tracking</h2>
<p className="mb-3">SeTrip dapat menggunakan:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Cookie</li>
<li>Teknologi pelacakan sederhana</li>
</ul>
<p className="mt-3 mb-2">Untuk:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Menyimpan sesi login</li>
<li>Meningkatkan pengalaman pengguna</li>
</ul>
<p className="mb-3">
SeTrip hanya menggunakan cookie sesi (JWT) untuk menjaga Anda tetap
login. Kami <strong>tidak</strong> menggunakan cookie iklan maupun
layanan analitik/pelacakan pihak ketiga.
</p>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
8. Layanan Pihak Ketiga
</h2>
<p className="mb-3">SeTrip dapat menggunakan layanan pihak ketiga seperti:</p>
<p className="mb-3">SeTrip menggunakan layanan pihak ketiga berikut:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Hosting</li>
<li>Analytics</li>
<li>Payment gateway (di masa depan)</li>
<li>Google autentikasi (login dengan Google)</li>
<li>Midtrans payment gateway</li>
<li>Resend pengiriman email</li>
<li>Penyedia hosting dan basis data</li>
</ul>
<p className="mt-3">
Kami tidak bertanggung jawab atas kebijakan privasi pihak ketiga
tersebut.
tersebut. Silakan tinjau kebijakan privasi masing-masing penyedia.
</p>
</section>
@@ -172,6 +236,7 @@ export default function PrivacyPage() {
<ul className="ml-5 list-disc space-y-1.5">
<li>Mendeteksi aktivitas mencurigakan</li>
<li>Mencegah penipuan</li>
<li>Menangguhkan akun yang melanggar</li>
<li>Melindungi pengguna lain</li>
</ul>
</section>
@@ -205,7 +270,15 @@ export default function PrivacyPage() {
</section>
<section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2>
<h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
<CircleCheck
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Persetujuan
</h2>
<p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p>
+8 -5
View File
@@ -10,6 +10,7 @@ import { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
import { ProfileEditor } from "@/features/profile/components/profile-editor";
import { EarningsSection } from "@/features/payout/components/earnings-section";
import { Plus, ChevronRight } from "lucide-react";
export const metadata: Metadata = {
title: "Profil Saya",
@@ -81,9 +82,10 @@ export default async function ProfilePage() {
</div>
<Link
href="/create-trip"
className="shrink-0 rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md hover:bg-primary-700"
className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md hover:bg-primary-700"
>
+ Buat trip
<Plus size={16} strokeWidth={2} aria-hidden />
Buat trip
</Link>
</div>
@@ -133,13 +135,14 @@ export default async function ProfilePage() {
endDate={t.endDate}
rightSlot={
<span
className={
className={`inline-flex items-center gap-0.5 ${
hasReview
? "text-secondary-700"
: "font-bold text-amber-800"
}
}`}
>
{hasReview ? "Ubah ulasan" : "Beri ulasan"}
{hasReview ? "Ubah ulasan" : "Beri ulasan"}
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span>
}
/>
+13 -1
View File
@@ -1,4 +1,7 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const metadata: Metadata = {
title: "Daftar Akun",
@@ -7,6 +10,15 @@ export const metadata: Metadata = {
alternates: { canonical: "/register" },
};
export default function RegisterLayout({ children }: { children: React.ReactNode }) {
export default async function RegisterLayout({
children,
}: {
children: React.ReactNode;
}) {
// User yang sudah login tidak boleh mengakses halaman daftar lagi.
const session = await getServerSession(authOptions);
if (session?.user) {
redirect(session.user.isAdmin ? "/admin" : "/");
}
return children;
}
+1 -1
View File
@@ -77,7 +77,7 @@ export default function RegisterPage() {
</div>
{/* Card */}
<div className="rounded-2xl border border-white/10 bg-white/95 p-6 shadow-2xl backdrop-blur-sm">
<div className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
{error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
+109 -31
View File
@@ -1,15 +1,22 @@
import Link from "next/link";
import { FileText, CircleCheck } from "lucide-react";
export default function TermsPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<article className="rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8 border-b border-neutral-200 pb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
📜 Syarat &amp; Ketentuan SeTrip
<h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
<FileText
size={28}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-primary-600"
/>
Syarat &amp; Ketentuan SeTrip
</h1>
<p className="mt-2 text-sm text-neutral-500">
Terakhir diperbarui: 2026-04-27
Terakhir diperbarui: 2026-06-02
</p>
<p className="mt-4 text-sm leading-relaxed text-neutral-700">
Selamat datang di SeTrip. Dengan mengakses atau menggunakan platform
@@ -31,13 +38,17 @@ export default function TermsPage() {
aplikasi SeTrip.
</li>
<li>
<strong>Organizer (Penyelenggara)</strong>: Pengguna yang membuat
dan mengelola trip.
<strong>Organizer (Penyelenggara)</strong>: Pengguna terverifikasi
yang membuat dan mengelola trip.
</li>
<li>
<strong>Trip</strong>: Kegiatan perjalanan yang dibuat oleh
organizer.
</li>
<li>
<strong>Escrow</strong>: Penahanan dana peserta oleh SeTrip
sampai trip selesai sebelum diteruskan ke organizer.
</li>
<li>
<strong>Platform</strong>: Website atau aplikasi SeTrip.
</li>
@@ -48,7 +59,8 @@ export default function TermsPage() {
<h2 className="mb-3 text-lg font-bold text-neutral-900">2. Peran SeTrip</h2>
<p className="mb-3">
SeTrip bertindak sebagai <strong>platform perantara</strong> yang
menghubungkan pengguna dan organizer. SeTrip:
menghubungkan pengguna dan organizer serta memfasilitasi
pembayaran. SeTrip:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Bukan penyelenggara trip</li>
@@ -87,7 +99,36 @@ export default function TermsPage() {
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">5. Trip &amp; Booking</h2>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
5. Verifikasi Organizer (KYC)
</h2>
<p className="mb-3">
Untuk dapat membuat dan mengelola trip, pengguna wajib melalui
proses verifikasi identitas (KYC):
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>
Organizer mengunggah data identitas (KTP, NIK, foto liveness)
dan informasi rekening bank untuk pencairan dana
</li>
<li>
SeTrip meninjau pengajuan secara manual dan berhak menyetujui,
menolak, atau meminta dokumen diunggah ulang
</li>
<li>
Data verifikasi disimpan secara terenkripsi sesuai{" "}
<Link
href="/privacy"
className="font-semibold text-primary-600 hover:text-primary-700"
>
Kebijakan Privasi
</Link>
</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">6. Trip &amp; Booking</h2>
<ul className="ml-5 list-disc space-y-1.5">
<li>Organizer bertanggung jawab atas seluruh informasi trip</li>
<li>Pengguna wajib membaca detail trip sebelum melakukan join</li>
@@ -99,40 +140,69 @@ export default function TermsPage() {
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">6. Pembayaran</h2>
<h2 className="mb-3 text-lg font-bold text-neutral-900">7. Pembayaran &amp; Escrow</h2>
<ul className="ml-5 list-disc space-y-1.5">
<li>Pembayaran dilakukan sesuai metode yang tersedia di platform</li>
<li>
Dalam fase awal, pembayaran dapat dilakukan langsung kepada
organizer
Pembayaran dilakukan melalui metode yang tersedia di platform
(Midtrans atau transfer manual yang dikonfirmasi organizer)
</li>
<li>
SeTrip tidak menjamin keamanan transaksi yang dilakukan di luar
platform
<strong>Uang peserta ditahan oleh SeTrip (escrow)</strong> sejak
pembayaran berhasil hingga trip selesai + 3 hari, baru kemudian
diteruskan ke organizer
</li>
<li>
Buffer 3 hari memberi waktu peserta dan organizer melaporkan
masalah trip sebelum dana dicairkan
</li>
<li>
Pembayaran di luar platform tidak dijamin keamanannya oleh
SeTrip kami tidak dapat memediasi sengketa untuk transaksi
off-platform
</li>
</ul>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
7. Pembatalan &amp; Refund
8. Pembatalan &amp; Refund
</h2>
<p className="mb-2 font-semibold text-neutral-800">
Saat peserta membatalkan booking sendiri (kebijakan default platform):
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Kebijakan pembatalan ditentukan oleh organizer</li>
<li>
SeTrip tidak bertanggung jawab atas refund yang tidak diberikan
oleh organizer
<strong> 7 hari</strong> sebelum tanggal berangkat refund{" "}
<strong>80%</strong> dari nominal booking
</li>
<li>
Pengguna disarankan untuk memahami kebijakan sebelum melakukan
pembayaran
<strong>36 hari</strong> sebelum tanggal berangkat refund{" "}
<strong>50%</strong> dari nominal booking
</li>
<li>
<strong>&lt; 3 hari</strong> sebelum tanggal berangkat / setelah
berangkat <strong>tidak ada refund</strong>
</li>
</ul>
<p className="mt-3 mb-2 font-semibold text-neutral-800">
Saat organizer membatalkan trip:
</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Peserta yang sudah membayar mendapat refund <strong>100%</strong></li>
</ul>
<p className="mt-3">
Pengembalian dana diproses manual oleh admin SeTrip perlu 13
hari kerja sejak refund disetujui untuk uang masuk ke rekening
Anda. Setiap pengajuan refund tercatat untuk keperluan audit.
Kebijakan ini berlaku platform-wide; organizer tidak dapat
menetapkan kebijakan yang lebih ketat tanpa persetujuan tertulis
dari SeTrip.
</p>
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
8. Tanggung Jawab Organizer
9. Tanggung Jawab Organizer
</h2>
<p className="mb-3">Organizer wajib:</p>
<ul className="ml-5 list-disc space-y-1.5">
@@ -143,7 +213,7 @@ export default function TermsPage() {
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">9. Risiko Perjalanan</h2>
<h2 className="mb-3 text-lg font-bold text-neutral-900">10. Risiko Perjalanan</h2>
<p className="mb-3">
Pengguna memahami bahwa aktivitas perjalanan, terutama kegiatan
outdoor, memiliki risiko termasuk namun tidak terbatas pada:
@@ -162,7 +232,7 @@ export default function TermsPage() {
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
10. Batasan Tanggung Jawab
11. Batasan Tanggung Jawab
</h2>
<p className="mb-3">SeTrip tidak bertanggung jawab atas:</p>
<ul className="ml-5 list-disc space-y-1.5">
@@ -175,7 +245,7 @@ export default function TermsPage() {
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
11. Larangan Transaksi di Luar Platform
12. Larangan Transaksi di Luar Platform
</h2>
<p className="mb-3">
Pengguna disarankan untuk tidak melakukan transaksi di luar
@@ -189,9 +259,9 @@ export default function TermsPage() {
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">12. Sistem Review</h2>
<h2 className="mb-3 text-lg font-bold text-neutral-900">13. Sistem Review</h2>
<ul className="ml-5 list-disc space-y-1.5">
<li>Pengguna dapat memberikan review setelah trip</li>
<li>Peserta dapat memberikan review setelah trip selesai</li>
<li>Review harus jujur dan tidak mengandung unsur fitnah</li>
<li>SeTrip berhak menghapus review yang melanggar</li>
</ul>
@@ -199,7 +269,7 @@ export default function TermsPage() {
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
13. Penangguhan &amp; Penghentian Akun
14. Penangguhan &amp; Penghentian Akun
</h2>
<p className="mb-3">SeTrip berhak untuk:</p>
<ul className="ml-5 list-disc space-y-1.5">
@@ -216,7 +286,7 @@ export default function TermsPage() {
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">14. Perubahan Layanan</h2>
<h2 className="mb-3 text-lg font-bold text-neutral-900">15. Perubahan Layanan</h2>
<p className="mb-3">SeTrip dapat:</p>
<ul className="ml-5 list-disc space-y-1.5">
<li>Mengubah fitur</li>
@@ -228,7 +298,7 @@ export default function TermsPage() {
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">
15. Perubahan Syarat &amp; Ketentuan
16. Perubahan Syarat &amp; Ketentuan
</h2>
<p className="mb-3">
SeTrip dapat memperbarui Syarat &amp; Ketentuan ini kapan saja.
@@ -241,7 +311,7 @@ export default function TermsPage() {
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">16. Hukum yang Berlaku</h2>
<h2 className="mb-3 text-lg font-bold text-neutral-900">17. Hukum yang Berlaku</h2>
<p>
Syarat &amp; Ketentuan ini diatur oleh hukum yang berlaku di
Republik Indonesia.
@@ -249,7 +319,7 @@ export default function TermsPage() {
</section>
<section>
<h2 className="mb-3 text-lg font-bold text-neutral-900">17. Kontak</h2>
<h2 className="mb-3 text-lg font-bold text-neutral-900">18. Kontak</h2>
<p>
Jika Anda memiliki pertanyaan, silakan hubungi:{" "}
<a
@@ -262,7 +332,15 @@ export default function TermsPage() {
</section>
<section className="rounded-xl bg-neutral-50 p-5">
<h2 className="mb-2 text-lg font-bold text-neutral-900"> Persetujuan</h2>
<h2 className="mb-2 flex items-center gap-1.5 text-lg font-bold text-neutral-900">
<CircleCheck
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Persetujuan
</h2>
<p className="mb-2">
Dengan menggunakan SeTrip, Anda menyatakan bahwa:
</p>
+10 -2
View File
@@ -2,7 +2,7 @@ import { ImageResponse } from "next/og";
import { tripService } from "@/server/services/trip.service";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { siteConfig } from "@/lib/site";
import { siteConfig, siteUrl } from "@/lib/site";
export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`;
export const size = { width: 1200, height: 630 };
@@ -43,7 +43,15 @@ export default async function TripOgImage({
);
}
const cover = trip.images[0]?.url;
// Satori (ImageResponse) mem-fetch gambar server-side dan butuh URL absolut.
// Foto trip baru disimpan sebagai path relatif `/api/trip-images/...` —
// prefix dengan origin. Foto lama (URL eksternal absolut) dipakai apa adanya.
const coverRaw = trip.images[0]?.url;
const cover = coverRaw
? coverRaw.startsWith("http")
? coverRaw
: `${siteUrl}${coverRaw}`
: undefined;
const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
const price = formatRupiah(trip.price);
+56 -15
View File
@@ -28,6 +28,14 @@ import {
isTripDepartureDayPast,
} from "@/lib/trip-dates";
import { previewRefund } from "@/lib/refund-policy";
import {
MapPin,
CalendarDays,
Wallet,
UserRound,
Zap,
Users,
} from "lucide-react";
export async function generateMetadata({
params,
@@ -309,8 +317,13 @@ export default async function TripDetailPage({
{/* Info Grid */}
<div className="grid grid-cols-2 gap-2 sm:gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
📍
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
<MapPin
size={18}
strokeWidth={1.75}
aria-hidden
className="text-secondary-700"
/>
</span>
<div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p>
@@ -319,8 +332,13 @@ export default async function TripDetailPage({
</div>
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
📅
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
<CalendarDays
size={18}
strokeWidth={1.75}
aria-hidden
className="text-secondary-700"
/>
</span>
<div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
@@ -331,8 +349,13 @@ export default async function TripDetailPage({
</div>
<div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 text-sm sm:h-10 sm:w-10 sm:text-lg">
💰
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 sm:h-10 sm:w-10">
<Wallet
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-700"
/>
</span>
<div className="min-w-0">
<p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p>
@@ -343,8 +366,13 @@ export default async function TripDetailPage({
</div>
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 text-sm sm:h-10 sm:w-10 sm:text-lg">
👤
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 sm:h-10 sm:w-10">
<UserRound
size={18}
strokeWidth={1.75}
aria-hidden
className="text-neutral-600"
/>
</span>
<div className="min-w-0">
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p>
@@ -372,8 +400,9 @@ export default async function TripDetailPage({
Peserta
</span>
{spotsLeft > 0 && spotsLeft <= 3 && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
Tinggal {spotsLeft} spot!
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
<Zap size={11} strokeWidth={2} aria-hidden />
Tinggal {spotsLeft} spot!
</span>
)}
{spotsLeft <= 0 && (
@@ -418,8 +447,14 @@ export default async function TripDetailPage({
)}
</p>
{confirmedCount > 0 && (
<p className="mt-2 text-[11px] text-neutral-600 sm:text-xs">
<span aria-hidden>👥</span> Sudah join:{" "}
<p className="mt-2 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-neutral-600 sm:text-xs">
<Users
size={13}
strokeWidth={1.75}
aria-hidden
className="text-neutral-400"
/>
Sudah join:{" "}
<span className="font-medium text-neutral-800">
{confirmedParticipants
.slice(0, 3)
@@ -547,7 +582,7 @@ export default async function TripDetailPage({
Belum ada peserta yang dikonfirmasi.{" "}
{pendingParticipants.length > 0
? "Cek permintaan join di atas untuk menyetujui peserta."
: "Jadilah yang pertama mendaftar! 🎒"}
: "Jadilah yang pertama mendaftar!"}
</p>
) : (
<ul className="grid gap-2 sm:grid-cols-2">
@@ -578,8 +613,14 @@ export default async function TripDetailPage({
{p.user.name}
</p>
{city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {city}
<p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
<MapPin
size={11}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{city}
</p>
)}
{interests.length > 0 && (
+69 -16
View File
@@ -11,6 +11,15 @@ import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing";
import { categoryMeta } from "@/lib/activity-category";
import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import {
ArrowLeft,
CalendarDays,
MapPin,
PartyPopper,
CircleCheck,
Clock,
Check,
} from "lucide-react";
export const metadata: Metadata = {
title: "Detail Pembayaran",
@@ -82,8 +91,15 @@ export default async function PaymentPage({ params, searchParams }: PageProps) {
<h1 className="mt-0.5 truncate text-base font-bold text-neutral-900 sm:text-lg">
{trip.title}
</h1>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
📅 {dateRange} · 📍 {trip.location}
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:text-sm">
<span className="inline-flex items-center gap-1">
<CalendarDays size={13} strokeWidth={1.75} aria-hidden />
{dateRange}
</span>
<span className="inline-flex items-center gap-1">
<MapPin size={13} strokeWidth={1.75} aria-hidden />
{trip.location}
</span>
</p>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "}
@@ -102,8 +118,12 @@ export default async function PaymentPage({ params, searchParams }: PageProps) {
return (
<div className="mx-auto max-w-2xl px-4 py-6 sm:py-10">
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 sm:text-sm">
<Link href={`/trips/${trip.id}`} className="hover:text-primary-600">
Kembali ke trip
<Link
href={`/trips/${trip.id}`}
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke trip
</Link>
</div>
@@ -170,8 +190,13 @@ function FreeTripSection({
}) {
return (
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-2xl">
🎉
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
<PartyPopper
size={26}
strokeWidth={1.75}
aria-hidden
className="text-emerald-600"
/>
</div>
<h2 className="mb-1 text-lg font-bold text-emerald-900 sm:text-xl">
Trip ini gratis
@@ -184,10 +209,28 @@ function FreeTripSection({
<p className="text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
Status keikutsertaan
</p>
<p className="text-sm font-bold text-neutral-800">
{bookingStatus === "PAID"
? "✅ Terkonfirmasi sebagai peserta"
: "⏳ Menunggu persetujuan organizer"}
<p className="flex items-center gap-1.5 text-sm font-bold text-neutral-800">
{bookingStatus === "PAID" ? (
<>
<CircleCheck
size={15}
strokeWidth={2}
aria-hidden
className="text-emerald-600"
/>
Terkonfirmasi sebagai peserta
</>
) : (
<>
<Clock
size={15}
strokeWidth={2}
aria-hidden
className="text-amber-600"
/>
Menunggu persetujuan organizer
</>
)}
</p>
</div>
@@ -250,10 +293,15 @@ function PaidTripSection({
{canPay && <MidtransPayButton tripId={tripId} />}
{isFullyPaid && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5">
<div className="flex items-start gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5">
<CircleCheck
size={16}
strokeWidth={2}
aria-hidden
className="mt-0.5 shrink-0 text-emerald-600"
/>
<p>
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip
bareng{" "}
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip bareng{" "}
<span className="font-semibold">{organizerName}</span>!
</p>
</div>
@@ -262,9 +310,10 @@ function PaidTripSection({
<div className="text-center">
<Link
href={`/trips/${tripId}`}
className="text-sm text-neutral-500 hover:text-primary-600"
className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-primary-600"
>
Kembali ke detail trip
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke detail trip
</Link>
</div>
</div>
@@ -298,7 +347,11 @@ function PaymentTimeline({
: "bg-neutral-200 text-neutral-500"
}`}
>
{s.done ? "✓" : i + 1}
{s.done ? (
<Check size={12} strokeWidth={3} aria-hidden />
) : (
i + 1
)}
</span>
<span
className={`text-sm ${
+24 -5
View File
@@ -11,6 +11,7 @@ import { siteConfig } from "@/lib/site";
import { categoryLabel, isActivityCategory } from "@/lib/activity-category";
import { isVibe } from "@/lib/vibe";
import type { GroupSize } from "@/server/repositories/trip.repo";
import { Plus, Search, Tent } from "lucide-react";
const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"];
function isGroupSize(value: unknown): value is GroupSize {
@@ -98,9 +99,10 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div>
<Link
href="/create-trip"
className="w-full rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
className="inline-flex w-full items-center justify-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md shadow-primary-600/20 hover:bg-primary-700 sm:w-auto"
>
+ Buat Trip
<Plus size={16} strokeWidth={2} aria-hidden />
Buat Trip
</Link>
</div>
@@ -113,8 +115,22 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
{trips.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-neutral-200 bg-white p-8 text-center sm:p-14">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-2xl sm:h-16 sm:w-16 sm:text-3xl">
{hasFilters ? "🔍" : "🏕️"}
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 sm:h-16 sm:w-16">
{hasFilters ? (
<Search
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
) : (
<Tent
size={26}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
)}
</div>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters
@@ -137,7 +153,7 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{trips.map((trip) => (
{trips.map((trip, index) => (
<TripCard
key={trip.id}
id={trip.id}
@@ -154,6 +170,9 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
organizerName={trip.organizer.name}
status={trip.status}
coverImage={trip.images[0]?.url}
// Baris pertama (3 kartu) di atas fold — muat segera supaya
// tidak jadi LCP yang lambat.
priority={index < 3}
isVerifiedOrganizer={
trip.organizer.organizerVerification?.status === "APPROVED"
}
+8 -5
View File
@@ -11,6 +11,7 @@ import { OrganizerStatsPanel } from "@/features/profile/components/organizer-sta
import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list";
import { siteConfig } from "@/lib/site";
import { vibeMeta } from "@/lib/vibe";
import { BadgeCheck, MapPin, AtSign } from "lucide-react";
interface PageProps {
params: Promise<{ id: string }>;
@@ -86,10 +87,11 @@ export default async function PublicProfilePage({ params }: PageProps) {
</h1>
{isVerifiedOrganizer && (
<span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)"
>
Verified Organizer
<BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span>
)}
</div>
@@ -97,7 +99,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-neutral-500">
{profile?.city && (
<span className="inline-flex items-center gap-1">
📍 {profile.city}
<MapPin size={13} strokeWidth={1.75} aria-hidden />
{profile.city}
</span>
)}
<span className="text-xs">Bergabung sejak {memberSince}</span>
@@ -141,8 +144,8 @@ export default async function PublicProfilePage({ params }: PageProps) {
rel="noopener noreferrer nofollow"
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
>
<span>📸</span>
<span>@{profile.instagram}</span>
<AtSign size={15} strokeWidth={1.75} aria-hidden />
<span>{profile.instagram}</span>
</a>
)}
</div>
+17 -7
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { Clock, RefreshCw, CircleX, ArrowLeft } from "lucide-react";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { organizerService } from "@/server/services/organizer.service";
@@ -72,8 +73,9 @@ export default async function VerifyPage() {
{verification?.status === "PENDING" && !verification.reuploadRequested && (
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-5">
<p className="mb-1 text-sm font-bold text-amber-800">
Menunggu review admin
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-800">
<Clock size={15} strokeWidth={2} aria-hidden />
Menunggu review admin
</p>
<p className="text-sm text-neutral-700">
Pengajuanmu sedang diproses. Kami akan memberitahu via email setelah selesai.
@@ -83,8 +85,9 @@ export default async function VerifyPage() {
{verification?.reuploadRequested && (
<div className="mb-6 rounded-2xl border-2 border-amber-400 bg-amber-50 p-5">
<p className="mb-1 text-sm font-bold text-amber-900">
🔄 Admin minta kamu upload ulang
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-amber-900">
<RefreshCw size={15} strokeWidth={2} aria-hidden />
Admin minta kamu upload ulang
</p>
{verification.reuploadNote && (
<p className="mb-3 text-sm text-neutral-700">
@@ -117,7 +120,10 @@ export default async function VerifyPage() {
{verification?.status === "REJECTED" && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 p-5">
<p className="mb-1 text-sm font-bold text-red-800"> Pengajuan ditolak</p>
<p className="mb-1 flex items-center gap-1.5 text-sm font-bold text-red-800">
<CircleX size={15} strokeWidth={2} aria-hidden />
Pengajuan ditolak
</p>
{verification.rejectionReason && (
<p className="text-sm text-neutral-700">
<span className="font-semibold">Alasan:</span>{" "}
@@ -135,8 +141,12 @@ export default async function VerifyPage() {
verification?.reuploadRequested)) && <VerifyForm initial={initial} />}
<p className="mt-6 text-center text-sm text-neutral-500">
<Link href="/profile" className="hover:text-primary-600">
Kembali ke profil
<Link
href="/profile"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={1.75} aria-hidden />
Kembali ke profil
</Link>
</p>
</div>
+31 -7
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, CircleAlert, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { bookingRepo } from "@/server/repositories/booking.repo";
@@ -69,8 +70,12 @@ export default async function AdminBookingDetailPage({ params }: PageProps) {
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500">
<Link href="/admin" className="hover:text-primary-600">
Dashboard
<Link
href="/admin"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Dashboard
</Link>
<Link
href={`/admin/trips/${booking.tripId}`}
@@ -87,9 +92,22 @@ export default async function AdminBookingDetailPage({ params }: PageProps) {
<h1 className="mt-0.5 text-xl font-bold text-neutral-900 sm:text-2xl">
{booking.trip.title}
</h1>
<p className="mt-1 text-sm text-neutral-500">
📅 {formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}{" "}
· 📍 {booking.trip.destination}, {booking.trip.location}
<p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
<CalendarDays
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(booking.trip.date, booking.trip.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{booking.trip.destination}, {booking.trip.location}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
@@ -300,8 +318,14 @@ function PaymentEventCard({
</p>
)}
{payment.rejectionReason && (
<p className="text-red-700">
{payment.rejectionReason}
<p className="flex items-center gap-1 text-red-700">
<CircleAlert
size={14}
strokeWidth={2}
aria-hidden
className="shrink-0"
/>
{payment.rejectionReason}
</p>
)}
</div>
+7 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { Lock } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { AdminSidebar } from "@/components/admin/admin-sidebar";
@@ -31,7 +32,12 @@ export default async function AdminLayout({
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4">
<div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-8 text-center shadow-sm">
<p className="text-2xl">🔒</p>
<Lock
size={28}
strokeWidth={1.75}
aria-hidden
className="mx-auto text-neutral-500"
/>
<h1 className="mt-2 text-base font-bold text-neutral-900">
Halaman khusus admin
</h1>
+4 -2
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { ChevronRight } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { refundRepo } from "@/server/repositories/refund.repo";
@@ -155,8 +156,9 @@ export default async function AdminDashboardPage() {
>
{s.label}
</span>
<span className="text-xs text-neutral-400 group-hover:text-primary-600">
Buka
<span className="inline-flex items-center gap-1 text-xs text-neutral-400 group-hover:text-primary-600">
Buka
<ChevronRight size={14} strokeWidth={2} aria-hidden />
</span>
</div>
<p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p>
+38 -15
View File
@@ -1,6 +1,12 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import {
ArrowUpRight,
CircleAlert,
CircleCheck,
CircleX,
} from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
@@ -56,7 +62,11 @@ async function getJobSummary(jobName: string): Promise<JobSummary> {
}
// Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler.
const TRACKED_JOBS = ["auto-complete-trips", "process-email-jobs"] as const;
const TRACKED_JOBS = [
"auto-complete-trips",
"process-email-jobs",
"cleanup-trip-images",
] as const;
function healthOf(summary: JobSummary): "ok" | "stale" | "failed" {
if (summary.lastRun?.status === "FAILED") return "failed";
@@ -112,8 +122,9 @@ export default async function AdminSystemPage() {
{hasAnyStale && (
<section className="mb-6 rounded-2xl border border-amber-300 bg-amber-50 p-4 sm:p-5">
<h2 className="mb-2 text-sm font-bold text-amber-900">
Stale State Alerts
<h2 className="mb-2 flex items-center gap-1.5 text-sm font-bold text-amber-900">
<CircleAlert size={16} strokeWidth={2} aria-hidden />
Stale State Alerts
</h2>
<ul className="space-y-1 text-xs text-amber-900">
{stale.stalePaymentsCount > 0 && (
@@ -137,9 +148,10 @@ export default async function AdminSystemPage() {
cron history di bawah.{" "}
<Link
href="/admin/payouts?tab=HELD"
className="font-semibold text-amber-700 hover:underline"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat HELD
Lihat HELD
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
@@ -149,9 +161,10 @@ export default async function AdminSystemPage() {
&gt; 7 hari belum di-process.{" "}
<Link
href="/admin/refunds?tab=APPROVED"
className="font-semibold text-amber-700 hover:underline"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat APPROVED
Lihat APPROVED
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
@@ -162,9 +175,10 @@ export default async function AdminSystemPage() {
manual.{" "}
<Link
href="/admin/emails?tab=failed"
className="font-semibold text-amber-700 hover:underline"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat email gagal
Lihat email gagal
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
@@ -187,16 +201,23 @@ export default async function AdminSystemPage() {
: "border-red-200 bg-red-50/50";
const badge =
health === "ok"
? { label: "🟢 OK", cls: "bg-emerald-100 text-emerald-800" }
? {
label: "OK",
icon: CircleCheck,
cls: "bg-emerald-100 text-emerald-800",
}
: health === "stale"
? {
label: "🟡 STALE",
label: "STALE",
icon: CircleAlert,
cls: "bg-amber-100 text-amber-800",
}
: {
label: "🔴 FAILED",
label: "FAILED",
icon: CircleX,
cls: "bg-red-100 text-red-800",
};
const BadgeIcon = badge.icon;
return (
<div
key={s.jobName}
@@ -212,8 +233,9 @@ export default async function AdminSystemPage() {
</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${badge.cls}`}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${badge.cls}`}
>
<BadgeIcon size={12} strokeWidth={2.25} aria-hidden />
{badge.label}
</span>
</div>
@@ -276,9 +298,10 @@ export default async function AdminSystemPage() {
<p className="mt-2 text-xs text-neutral-500">
<Link
href="/admin/emails"
className="font-semibold text-primary-600 hover:underline"
className="inline-flex items-center gap-1 font-semibold text-primary-600 hover:underline"
>
Buka Email Log
Buka Email Log
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</p>
</section>
+26 -7
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft, CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { tripService } from "@/server/services/trip.service";
@@ -67,8 +68,12 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/trips" className="hover:text-primary-600">
Kembali ke list trips
<Link
href="/admin/trips"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list trips
</Link>
</div>
@@ -84,9 +89,22 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
<h1 className="text-xl font-bold text-neutral-900 sm:text-2xl">
{trip.title}
</h1>
<p className="mt-1 text-sm text-neutral-500">
📅 {formatTripCalendarDateRangeLong(trip.date, trip.endDate)} ·
📍 {trip.destination}, {trip.location}
<p className="mt-1 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
<CalendarDays
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(trip.date, trip.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{trip.destination}, {trip.location}
</p>
<p className="mt-1 text-xs text-neutral-500">
Organizer:{" "}
@@ -220,8 +238,9 @@ export default async function AdminTripDetailPage({ params }: PageProps) {
{p.user.name}
</Link>
{p.user.profile?.city && (
<span className="ml-2 text-[11px] text-neutral-500">
📍 {p.user.profile.city}
<span className="ml-2 inline-flex items-center gap-1 text-[11px] text-neutral-500">
<MapPin size={12} strokeWidth={2} aria-hidden />
{p.user.profile.city}
</span>
)}
</div>
+17 -3
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { CalendarDays, MapPin } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { tripRepo } from "@/server/repositories/trip.repo";
@@ -127,9 +128,22 @@ export default async function AdminTripsPage({ searchParams }: PageProps) {
<h2 className="truncate text-base font-bold text-neutral-900 sm:text-lg">
{t.title}
</h2>
<p className="mt-1 truncate text-xs text-neutral-500 sm:text-sm">
📅 {formatTripCalendarDateRangeLong(t.date, t.endDate)}
{" · "}📍 {t.location}
<p className="mt-1 flex items-center gap-1 truncate text-xs text-neutral-500 sm:text-sm">
<CalendarDays
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{formatTripCalendarDateRangeLong(t.date, t.endDate)}
<span aria-hidden>·</span>
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0"
/>
{t.location}
</p>
<p className="mt-1 text-xs text-neutral-500 sm:text-sm">
Organizer:{" "}
+16 -8
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import Image from "next/image";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft, ArrowUpRight, Ban, Check } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { userRepo } from "@/server/repositories/user.repo";
@@ -38,8 +39,12 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/users" className="hover:text-primary-600">
Kembali ke list users
<Link
href="/admin/users"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list users
</Link>
</div>
@@ -75,8 +80,9 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
</span>
)}
{user.organizerVerification?.status === "APPROVED" && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-emerald-800">
Verified Organizer
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-emerald-800">
<Check size={12} strokeWidth={2.5} aria-hidden />
Verified Organizer
</span>
)}
</div>
@@ -122,8 +128,9 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
{user.suspended && (
<section className="mb-6 rounded-2xl border border-red-300 bg-red-50 p-4 sm:p-5">
<h2 className="text-sm font-bold text-red-900">
Akun ditangguhkan
<h2 className="flex items-center gap-1.5 text-sm font-bold text-red-900">
<Ban size={16} strokeWidth={2} aria-hidden />
Akun ditangguhkan
</h2>
<p className="mt-1 text-xs text-red-900/80">
{user.suspendedReason ?? "Tidak ada alasan tercatat."}
@@ -244,9 +251,10 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
{" · "}
<Link
href={`/admin/verifications?tab=${user.organizerVerification.status}`}
className="text-secondary-700 hover:text-secondary-900"
className="inline-flex items-center gap-1 text-secondary-700 hover:text-secondary-900"
>
Buka di /admin/verifications
Buka di /admin/verifications
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</p>
{user.organizerVerification.rejectionReason && (
+7 -4
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { Check, ChartColumn } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { userRepo } from "@/server/repositories/user.repo";
@@ -56,9 +57,10 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
</div>
<Link
href="/admin/users/stats"
className="rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
>
📊 Stats
<ChartColumn size={16} strokeWidth={2} aria-hidden />
Stats
</Link>
</header>
@@ -147,8 +149,9 @@ export default async function AdminUsersPage({ searchParams }: PageProps) {
</span>
)}
{u.organizerVerification?.status === "APPROVED" && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800">
Organizer
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800">
<Check size={12} strokeWidth={2.5} aria-hidden />
Organizer
</span>
)}
</div>
+7 -2
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { ArrowLeft } from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
@@ -97,8 +98,12 @@ export default async function AdminUserStatsPage() {
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<div className="mb-4 text-xs text-neutral-500">
<Link href="/admin/users" className="hover:text-primary-600">
Kembali ke list users
<Link
href="/admin/users"
className="inline-flex items-center gap-1 hover:text-primary-600"
>
<ArrowLeft size={14} strokeWidth={2} aria-hidden />
Kembali ke list users
</Link>
</div>
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { runCron } from "@/lib/cron-runner";
import { prisma } from "@/lib/prisma";
import {
deleteTripImage,
listTripImageNames,
tripImageMtime,
TRIP_IMAGE_URL_PREFIX,
} from "@/lib/trip-image-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** File yang lebih tua dari ini & tak direferensikan DB dianggap yatim. */
const ORPHAN_AGE_MS = 24 * 60 * 60 * 1000;
/**
* Cron — hapus file gambar trip yatim.
*
* Form create-trip multi-step mengunggah foto SEBELUM trip tersimpan; kalau
* user menutup form di tengah jalan, file menggantung di disk tanpa pernah
* jadi `TripImage`. Sweep ini menghapus file >24 jam yang tidak direferensikan
* `TripImage` mana pun. Idempotent — aman dijalankan berulang.
*
* Trigger: lihat docs/CRON_SETUP.md. Header wajib `Authorization: Bearer ${CRON_SECRET}`.
*/
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
console.error("[cron/cleanup-trip-images] CRON_SECRET tidak di-set");
return NextResponse.json(
{ error: "Server misconfigured" },
{ status: 500 }
);
}
if (req.headers.get("authorization") !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const outcome = await runCron("cleanup-trip-images", async () => {
const names = await listTripImageNames();
if (names.length === 0) return { scanned: 0, deleted: 0 };
const referenced = await prisma.tripImage.findMany({
where: { url: { startsWith: TRIP_IMAGE_URL_PREFIX } },
select: { url: true },
});
const referencedNames = new Set(
referenced.map((r) => r.url.slice(TRIP_IMAGE_URL_PREFIX.length))
);
const now = Date.now();
let deleted = 0;
for (const name of names) {
if (referencedNames.has(name)) continue;
const mtime = await tripImageMtime(name);
// File baru di-upload tapi trip belum tersimpan → beri tenggang 24 jam.
if (!mtime || now - mtime.getTime() < ORPHAN_AGE_MS) continue;
await deleteTripImage(name);
deleted++;
}
return { scanned: names.length, deleted };
});
if (!outcome.ok) {
console.error("[cron/cleanup-trip-images] gagal", outcome.error);
return NextResponse.json(
{ error: "Gagal menjalankan cleanup" },
{ status: 500 }
);
}
console.log("[cron/cleanup-trip-images] selesai", outcome.payload);
return NextResponse.json({ ok: true, ...outcome.payload });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { isValidTripImageName, readTripImage } from "@/lib/trip-image-storage";
export const runtime = "nodejs";
interface RouteCtx {
params: Promise<{ name: string }>;
}
/**
* Sajikan gambar trip dari disk lokal. Publik — gambar trip memang tampil ke
* semua pengunjung. Di-cache `immutable` selama setahun: nama file
* content-addressed (hex acak), jadi konten untuk satu nama tidak pernah
* berubah. Beban render = baca file kecil dari disk, tanpa fetch eksternal.
*/
export async function GET(_req: NextRequest, ctx: RouteCtx) {
const { name } = await ctx.params;
if (!isValidTripImageName(name)) {
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
}
let data: Buffer;
try {
data = await readTripImage(name);
} catch {
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
}
return new NextResponse(new Uint8Array(data), {
status: 200,
headers: {
"Content-Type": "image/webp",
"Content-Length": String(data.length),
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { requireActiveUser } from "@/lib/auth-guards";
import {
ALLOWED_TRIP_IMAGE_MIME,
MAX_TRIP_IMAGE_UPLOAD_BYTES,
processAndSaveTripImage,
} from "@/lib/trip-image-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Upload satu foto trip. Dipanggil dari form create-trip saat user memilih
* file — gambar langsung dikompres & disimpan, route mengembalikan URL publik
* yang nanti ikut disubmit bersama data trip.
*
* File yatim (di-upload tapi trip batal dibuat) dibersihkan cron
* `/api/cron/cleanup-trip-images`.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await requireActiveUser(session.user.id);
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message },
{ status: 403 }
);
}
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json(
{ error: "Body bukan multipart/form-data" },
{ status: 400 }
);
}
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "File wajib diisi" }, { status: 400 });
}
if (!ALLOWED_TRIP_IMAGE_MIME.has(file.type)) {
return NextResponse.json(
{ error: "Hanya menerima JPG, PNG, atau WebP" },
{ status: 415 }
);
}
if (file.size > MAX_TRIP_IMAGE_UPLOAD_BYTES) {
return NextResponse.json(
{ error: "Ukuran file maksimal 12MB" },
{ status: 413 }
);
}
try {
const buf = Buffer.from(await file.arrayBuffer());
const saved = await processAndSaveTripImage(buf);
return NextResponse.json({ url: saved.url, size: saved.size });
} catch (err) {
return NextResponse.json(
{ error: (err as Error).message || "Gagal memproses gambar" },
{ status: 400 }
);
}
}
+30 -20
View File
@@ -5,18 +5,33 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import Image from "next/image";
import { signOut } from "next-auth/react";
import {
ArrowLeft,
ArrowUpRight,
Banknote,
Compass,
IdCard,
LayoutDashboard,
Mail,
Menu,
ScrollText,
Settings,
Users,
X,
type LucideIcon,
} from "lucide-react";
import { AdminSearchBar } from "@/features/admin/components/admin-search-bar";
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
{ href: "/admin", label: "Dashboard", icon: "📊" },
{ href: "/admin/trips", label: "Trips", icon: "🧭" },
{ href: "/admin/users", label: "Users", icon: "👥" },
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
{ href: "/admin/emails", label: "Email", icon: "✉️" },
{ href: "/admin/audit-log", label: "Audit Log", icon: "📜" },
{ href: "/admin/system", label: "System", icon: "⚙️" },
const NAV_ITEMS: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/trips", label: "Trips", icon: Compass },
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/verifications", label: "Verifikasi", icon: IdCard },
{ href: "/admin/refunds", label: "Refund", icon: ArrowLeft },
{ href: "/admin/payouts", label: "Payout", icon: Banknote },
{ href: "/admin/emails", label: "Email", icon: Mail },
{ href: "/admin/audit-log", label: "Audit Log", icon: ScrollText },
{ href: "/admin/system", label: "System", icon: Settings },
];
interface AdminSidebarProps {
@@ -51,13 +66,9 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
aria-expanded={open}
>
{open ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
<X size={20} strokeWidth={2} aria-hidden />
) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
<Menu size={20} strokeWidth={2} aria-hidden />
)}
</button>
</header>
@@ -106,6 +117,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
const isActive =
pathname === item.href ||
(item.href !== "/admin" && pathname?.startsWith(item.href));
const Icon = item.icon;
return (
<li key={item.href}>
<Link
@@ -117,9 +129,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
: "text-neutral-700 hover:bg-neutral-100"
}`}
>
<span aria-hidden className="text-base">
{item.icon}
</span>
<Icon size={20} strokeWidth={1.75} aria-hidden />
<span>{item.label}</span>
</Link>
</li>
@@ -136,7 +146,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
onClick={() => setOpen(false)}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700"
>
<span aria-hidden></span>
<ArrowUpRight size={16} strokeWidth={1.75} aria-hidden />
<span>Lihat situs publik</span>
</Link>
</li>
+3 -22
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useSession, signOut } from "next-auth/react";
import { Menu, X } from "lucide-react";
export function Navbar() {
const { data: session } = useSession();
@@ -109,29 +110,9 @@ export function Navbar() {
aria-label="Toggle menu"
>
{menuOpen ? (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M5 5l10 10M15 5L5 15" />
</svg>
<X size={20} strokeWidth={1.75} aria-hidden />
) : (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
<Menu size={20} strokeWidth={1.75} aria-hidden />
)}
</button>
</div>
+8 -11
View File
@@ -1,23 +1,20 @@
import { BadgeCheck } from "lucide-react";
type Size = "sm" | "md";
export function VerifiedBadge({ size = "sm" }: { size?: Size }) {
const cls =
size === "md"
? "px-2.5 py-1 text-xs"
: "px-2 py-0.5 text-[10px]";
size === "md" ? "px-2.5 py-1 text-xs" : "px-2 py-0.5 text-[10px]";
return (
<span
className={`inline-flex items-center gap-1 rounded-full bg-primary-50 font-semibold text-primary-700 ring-1 ring-primary-200 ${cls}`}
title="Organizer terverifikasi SeTrip"
>
<svg
viewBox="0 0 16 16"
fill="currentColor"
className={size === "md" ? "h-3.5 w-3.5" : "h-3 w-3"}
aria-hidden="true"
>
<path d="M8 0l2.09 1.74L12.86 1.5l.64 2.78 2.5 1.5-1.5 2.5.5 2.86-2.78.64-1.5 2.5-2.72-.59L5.5 14.5 4 12 1.5 11.36 2 8.5.5 6 3 4.5l.64-2.78 2.77.24L8 0zm-1.07 9.4l4.6-4.6-1.06-1.06-3.54 3.54-1.41-1.42-1.06 1.06 2.47 2.48z" />
</svg>
<BadgeCheck
size={size === "md" ? 14 : 12}
strokeWidth={1.75}
aria-hidden
/>
Verified
</span>
);
+5
View File
@@ -12,6 +12,7 @@ Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar.
|---|---|---|---|---|
| 1 | `GET /api/cron/auto-complete-trips` | `0 18 * * *` | Daily 01:00 WIB (18:00 UTC) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. Setelah itu, release payout HELD yang sudah lewat `heldUntil`. |
| 2 | `GET /api/cron/process-email-jobs` | `*/5 * * * *` | Setiap 5 menit | Drain retry queue email — pick `EmailJob` status `PENDING`/`FAILED` (attempts<5), retry via Resend dengan exponential backoff. |
| 3 | `GET /api/cron/cleanup-trip-images` | `30 18 * * *` | Daily 01:30 WIB (18:30 UTC) | Hapus file gambar trip yatim — foto yang di-upload di form create-trip tapi trip-nya batal dibuat. Hanya file >24 jam yang tak direferensikan `TripImage`. |
Semua cron pakai pola yang sama: header `Authorization: Bearer ${CRON_SECRET}`, idempotent, auto-log ke `CronRun`. Tambah cron baru = tambah baris di tabel ini + tabel `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx).
@@ -67,6 +68,9 @@ Tambah baris berikut (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai
# 2. Drain email retry queue (setiap 5 menit)
*/5 * * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/process-email-jobs >> /var/log/setrip-cron.log 2>&1
# 3. Bersihkan gambar trip yatim (daily 01:30 WIB)
30 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/cleanup-trip-images >> /var/log/setrip-cron.log 2>&1
```
Verifikasi crontab tersimpan:
@@ -120,6 +124,7 @@ curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cr
|---|---|---|
| `auto-complete-trips` | `{"ok":true,"completed":0,"ids":[],"payoutsReleased":[]}` | `{"ok":true,"completed":2,"ids":["clx...","cly..."],"payoutsReleased":["..."]}` |
| `process-email-jobs` | `{"ok":true,"picked":0,"succeeded":0,"failed":0}` | `{"ok":true,"picked":5,"succeeded":5,"failed":0}` |
| `cleanup-trip-images` | `{"ok":true,"scanned":0,"deleted":0}` | `{"ok":true,"scanned":12,"deleted":3}` |
**Error response:**
- **401** — `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`.
@@ -1,3 +1,5 @@
import { Download } from "lucide-react";
interface ExportCsvLinkProps {
/** URL endpoint export, mis. `/api/admin/export/refunds`. */
href: string;
@@ -22,7 +24,7 @@ export function ExportCsvLink({
className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50"
download
>
<span aria-hidden></span>
<Download size={16} strokeWidth={2} aria-hidden />
<span>{label}</span>
</a>
);
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Lock } from "lucide-react";
import { manualOverrideVerificationAction } from "@/features/organizer/actions";
interface ManualVerifyButtonProps {
@@ -49,9 +50,10 @@ export function ManualVerifyButton({
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-xl border border-secondary-300 bg-white px-4 py-2 text-sm font-bold text-secondary-700 hover:bg-secondary-50"
className="inline-flex items-center gap-1.5 rounded-xl border border-secondary-300 bg-white px-4 py-2 text-sm font-bold text-secondary-700 hover:bg-secondary-50"
>
🔒 Manual verify (tanpa KYC)
<Lock size={18} strokeWidth={2} aria-hidden />
Manual verify (tanpa KYC)
</button>
);
}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { adminReconcileMidtransAction } from "@/features/booking/actions";
interface AdminReconcileButtonProps {
@@ -45,8 +46,9 @@ export function AdminReconcileButton({
{loading ? "Reconciling..." : "Reconcile Midtrans"}
</button>
{status && (
<span className="text-[11px] font-medium text-emerald-700">
{reconcileOutcomeLabel(status)}
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700">
<Check size={12} strokeWidth={2.5} aria-hidden />
{reconcileOutcomeLabel(status)}
</span>
)}
{error && (
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CircleAlert } from "lucide-react";
import { cancelBookingWithRefundAction } from "@/features/booking/actions";
import { formatRupiah } from "@/lib/utils";
@@ -112,9 +113,17 @@ export function CancelBookingButton({ tripId, preview }: CancelBookingButtonProp
Tier: {preview.tierLabel}
</p>
{noRefund ? (
<p className="mt-2 text-xs text-red-700">
Di luar window refund uang tidak dikembalikan. Booking akan
<p className="mt-2 flex items-start gap-1.5 text-xs text-red-700">
<CircleAlert
size={14}
strokeWidth={2}
aria-hidden
className="mt-0.5 shrink-0"
/>
<span>
Di luar window refund uang tidak dikembalikan. Booking akan
di-cancel langsung.
</span>
</p>
) : (
<p className="mt-2 text-xs text-neutral-600">
+13 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Check, Copy } from "lucide-react";
interface CopyButtonProps {
value: string;
@@ -24,9 +25,19 @@ export function CopyButton({ value, label = "Salin" }: CopyButtonProps) {
<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"
className="inline-flex items-center gap-1 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}
{copied ? (
<>
<Check size={13} strokeWidth={2.5} aria-hidden className="text-emerald-600" />
Tersalin
</>
) : (
<>
<Copy size={13} strokeWidth={1.75} aria-hidden />
{label}
</>
)}
</button>
);
}
@@ -2,7 +2,11 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { retryEmailJobAction, resendEmailAction } from "@/features/email/actions";
import { Check } from "lucide-react";
import {
retryEmailJobAction,
resendEmailAction,
} from "@/features/email/actions";
const BTN_CLS =
"rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-[11px] font-semibold text-primary-700 transition-colors hover:bg-primary-100 disabled:cursor-not-allowed disabled:opacity-50";
@@ -36,7 +40,7 @@ export function RetryEmailButton({ jobId }: { jobId: string }) {
{loading ? "Mengirim…" : "Kirim ulang"}
</button>
{error && (
<p className="mt-1 max-w-[200px] text-[10px] text-red-600">{error}</p>
<p className="mt-1 max-w-50 text-[10px] text-red-600">{error}</p>
)}
</div>
);
@@ -85,12 +89,21 @@ export function ResendEmailButton({
type="button"
onClick={handleResend}
disabled={loading || done}
className={BTN_CLS}
className={`${BTN_CLS} inline-flex items-center gap-1`}
>
{loading ? "Mengirim…" : done ? "✓ Terkirim" : "Resend"}
{loading ? (
"Mengirim…"
) : done ? (
<>
<Check size={12} strokeWidth={2.5} aria-hidden />
Terkirim
</>
) : (
"Resend"
)}
</button>
{error && (
<p className="mt-1 max-w-[200px] text-[10px] text-red-600">{error}</p>
<p className="mt-1 max-w-50 text-[10px] text-red-600">{error}</p>
)}
</div>
);
+13 -8
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CircleCheck, CircleX, RefreshCw } from "lucide-react";
import {
reopenVerificationAction,
requestReuploadAction,
@@ -180,9 +181,10 @@ export function ReviewCard({ verification }: { verification: Verification }) {
type="button"
onClick={() => setShowReopen(true)}
disabled={loading}
className="rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
>
🔄 Buka kembali ke PENDING
<RefreshCw size={18} strokeWidth={2} aria-hidden />
Buka kembali ke PENDING
</button>
) : (
<div className="space-y-2 rounded-xl border border-amber-200 bg-amber-50/60 p-3">
@@ -336,25 +338,28 @@ export function ReviewCard({ verification }: { verification: Verification }) {
type="button"
onClick={() => decide("APPROVED")}
disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
Setujui
<CircleCheck size={18} strokeWidth={2} aria-hidden />
Setujui
</button>
<button
type="button"
onClick={() => setShowReupload(true)}
disabled={loading}
className="rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-bold text-amber-700 hover:bg-amber-50 disabled:opacity-50"
>
🔄 Minta re-upload
<RefreshCw size={18} strokeWidth={2} aria-hidden />
Minta re-upload
</button>
<button
type="button"
onClick={() => setShowReject(true)}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
Tolak
<CircleX size={18} strokeWidth={2} aria-hidden />
Tolak
</button>
</div>
)}
+32 -4
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { IdCard, Image as ImageIcon, Landmark, Check } from "lucide-react";
import { submitVerificationAction } from "@/features/organizer/actions";
import { DateField } from "@/components/shared/date-picker";
@@ -79,7 +80,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
)}
<section>
<h2 className="mb-3 text-base font-bold text-neutral-900">📇 Data KTP</h2>
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<IdCard
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Data KTP
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
@@ -145,7 +154,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</section>
<section>
<h2 className="mb-3 text-base font-bold text-neutral-900">🖼 Foto</h2>
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<ImageIcon
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Foto
</h2>
<p className="mb-3 text-xs text-neutral-500">
Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh
tim admin saat review. Maks 5MB, JPG/PNG/WebP.
@@ -178,7 +195,15 @@ export function VerifyForm({ initial }: { initial: Initial }) {
</section>
<section>
<h2 className="mb-3 text-base font-bold text-neutral-900">🏦 Rekening Bank</h2>
<h2 className="mb-3 flex items-center gap-2 text-base font-bold text-neutral-900">
<Landmark
size={18}
strokeWidth={1.75}
aria-hidden
className="text-primary-600"
/>
Rekening Bank
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
@@ -312,7 +337,10 @@ function FileUpload({
/>
</label>
{value && !busy && (
<span className="text-xs text-neutral-500"> Terunggah</span>
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
<Check size={13} strokeWidth={2.5} aria-hidden />
Terunggah
</span>
)}
</div>
{previewUrl && (
@@ -3,6 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, Banknote, CircleAlert } from "lucide-react";
import { markPayoutPaidAction } from "@/features/payout/actions";
import { formatRupiah } from "@/lib/utils";
@@ -95,9 +96,10 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
</p>
<Link
href={`/admin/bookings/${payout.booking.id}`}
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
className="mt-1 inline-flex items-center gap-1 text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
>
Lihat timeline booking
<ArrowRight size={14} strokeWidth={2} aria-hidden />
Lihat timeline booking
</Link>
</div>
<StatusPill status={payout.status} />
@@ -142,9 +144,18 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
</p>
</div>
) : (
<p className="text-amber-700">
Organizer belum menyelesaikan verifikasi (KYC) tidak ada rekening
snapshot. Hubungi organizer untuk konfirmasi rekening sebelum transfer.
<p className="flex gap-1.5 text-amber-700">
<CircleAlert
size={16}
strokeWidth={1.75}
aria-hidden
className="mt-0.5 shrink-0"
/>
<span>
Organizer belum menyelesaikan verifikasi (KYC) tidak ada
rekening snapshot. Hubungi organizer untuk konfirmasi rekening
sebelum transfer.
</span>
</p>
)}
</div>
@@ -212,9 +223,10 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
type="button"
onClick={() => setOpen(true)}
disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
💸 Tandai sudah ditransfer ke organizer
<Banknote size={18} strokeWidth={2} aria-hidden />
Tandai sudah ditransfer ke organizer
</button>
)}
</div>
@@ -1,3 +1,4 @@
import { BadgeCheck, Star } from "lucide-react";
import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerStatsPanelProps {
@@ -47,7 +48,8 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)"
>
Verified Organizer
<BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span>
)}
{isTripLeader && (
@@ -83,7 +85,21 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
/>
<Stat
label="Rating"
value={avgRating != null ? `${avgRating}` : "—"}
value={
avgRating != null ? (
<span className="inline-flex items-center gap-1">
{avgRating}
<Star
size={14}
strokeWidth={2}
fill="currentColor"
aria-hidden
/>
</span>
) : (
"—"
)
}
subtitle={
reviewCount > 0
? `${reviewCount} ulasan`
@@ -107,8 +123,15 @@ export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
key={star}
className="flex items-center gap-2 text-xs"
>
<span className="w-8 shrink-0 font-medium text-neutral-600">
{star}
<span className="flex w-8 shrink-0 items-center gap-0.5 font-medium text-neutral-600">
{star}
<Star
size={11}
strokeWidth={2}
fill="currentColor"
aria-hidden
className="text-amber-500"
/>
</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-neutral-100">
<div
@@ -138,7 +161,7 @@ const TONE_CLASSES = {
interface StatProps {
label: string;
value: string;
value: React.ReactNode;
subtitle?: string;
tone: keyof typeof TONE_CLASSES;
}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowUpRight } from "lucide-react";
import { updateProfileAction } from "@/features/profile/actions";
import { LIMITS } from "@/lib/limits";
import { VIBES, vibeMeta } from "@/lib/vibe";
@@ -102,9 +103,10 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
href={`/u/${userId}`}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-neutral-50"
className="inline-flex items-center gap-1 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-neutral-50"
>
Lihat publik
Lihat publik
<ArrowUpRight size={13} strokeWidth={1.75} aria-hidden />
</a>
<button
type="button"
@@ -324,9 +326,10 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
href={`/u/${userId}`}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-neutral-200 px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
className="inline-flex items-center gap-1 rounded-xl border border-neutral-200 px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
Lihat publik
Lihat publik
<ArrowUpRight size={14} strokeWidth={1.75} aria-hidden />
</a>
</div>
</form>
+7 -4
View File
@@ -1,5 +1,6 @@
import Image from "next/image";
import Link from "next/link";
import { MapPin, BadgeCheck } from "lucide-react";
import { vibeMeta } from "@/lib/vibe";
import type { Vibe } from "@/app/generated/prisma/enums";
@@ -48,17 +49,19 @@ export function UserCard({
{name}
</p>
{profile?.city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {profile.city}
<p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
<MapPin size={11} strokeWidth={1.75} aria-hidden className="shrink-0" />
{profile.city}
</p>
)}
<div className="mt-1 flex flex-wrap gap-1">
{isVerifiedOrganizer && (
<span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800"
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800"
title="Organizer terverifikasi"
>
Organizer
<BadgeCheck size={11} strokeWidth={2} aria-hidden />
Organizer
</span>
)}
{profile?.vibe && (
@@ -1,3 +1,4 @@
import { LifeBuoy } from "lucide-react";
import { getRefundPolicyTiers } from "@/lib/refund-policy";
/**
@@ -9,7 +10,13 @@ export function RefundPolicySection() {
return (
<details className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 text-xs sm:text-sm">
<summary className="cursor-pointer select-none font-semibold text-neutral-700">
🛟 Kebijakan refund saat peserta cancel
<LifeBuoy
size={15}
strokeWidth={1.75}
aria-hidden
className="mr-1.5 inline align-text-bottom"
/>
Kebijakan refund saat peserta cancel
</summary>
<div className="mt-2 space-y-2 text-neutral-600">
<p className="text-[11px] text-neutral-500 sm:text-xs">
@@ -21,7 +28,7 @@ export function RefundPolicySection() {
{tiers.map((t) => (
<li key={t.minDaysBefore} className="flex items-baseline gap-2">
<span
className={`inline-flex min-w-[3rem] justify-center rounded-full px-2 py-0.5 text-[10px] font-bold ${
className={`inline-flex min-w-12 justify-center rounded-full px-2 py-0.5 text-[10px] font-bold ${
t.refundPercentage >= 80
? "bg-primary-100 text-primary-700"
: t.refundPercentage >= 50
@@ -3,6 +3,13 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowRight,
Banknote,
CircleAlert,
CircleCheck,
CircleX,
} from "lucide-react";
import { decideRefundAction } from "@/features/refund/actions";
import { formatRupiah } from "@/lib/utils";
@@ -129,9 +136,10 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
</p>
<Link
href={`/admin/bookings/${refund.booking.id}`}
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
className="mt-1 inline-flex items-center gap-1 text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
>
Lihat timeline payment & refund
<ArrowRight size={14} strokeWidth={2} aria-hidden />
Lihat timeline payment & refund
</Link>
</div>
<Field
@@ -211,17 +219,19 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
type="button"
onClick={() => setOpenAction("APPROVE")}
disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
Setujui
<CircleCheck size={18} strokeWidth={2} aria-hidden />
Setujui
</button>
<button
type="button"
onClick={() => setOpenAction("REJECT")}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
Tolak
<CircleX size={18} strokeWidth={2} aria-hidden />
Tolak
</button>
</>
)}
@@ -231,17 +241,19 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
type="button"
onClick={() => setOpenAction("SUCCEEDED")}
disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
💸 Tandai sudah ditransfer
<Banknote size={18} strokeWidth={2} aria-hidden />
Tandai sudah ditransfer
</button>
<button
type="button"
onClick={() => setOpenAction("FAILED")}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
Tandai gagal
<CircleAlert size={18} strokeWidth={2} aria-hidden />
Tandai gagal
</button>
</>
)}
@@ -1,5 +1,6 @@
import Link from "next/link";
import Image from "next/image";
import { Star } from "lucide-react";
import type { OrganizerReviewItem } from "@/server/services/review.service";
interface OrganizerReviewsListProps {
@@ -62,11 +63,22 @@ export function OrganizerReviewsList({
>
{r.user.name}
</Link>
<span className="text-xs font-bold text-amber-600">
{"★".repeat(r.rating)}
<span className="text-neutral-300">
{"★".repeat(5 - r.rating)}
</span>
<span
className="flex shrink-0 items-center gap-0.5"
aria-label={`Rating ${r.rating} dari 5`}
>
{[1, 2, 3, 4, 5].map((n) => (
<Star
key={n}
size={12}
strokeWidth={2}
fill="currentColor"
aria-hidden
className={
n <= r.rating ? "text-amber-500" : "text-neutral-200"
}
/>
))}
</span>
</div>
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CircleCheck } from "lucide-react";
import { adminCancelTripAction } from "@/features/trip/actions";
interface AdminCancelTripButtonProps {
@@ -42,7 +43,10 @@ export function AdminCancelTripButton({ tripId }: AdminCancelTripButtonProps) {
if (result) {
return (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">
<p className="font-bold"> Trip berhasil dibatalkan.</p>
<p className="flex items-center gap-1.5 font-bold">
<CircleCheck size={18} strokeWidth={2} aria-hidden />
Trip berhasil dibatalkan.
</p>
<ul className="mt-2 space-y-0.5 text-xs">
<li> {result.refundCount} booking PAID refund auto-dibuat</li>
<li>
+35 -30
View File
@@ -2,9 +2,17 @@
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
ArrowRight,
Check,
X,
CircleAlert,
Users,
} from "lucide-react";
import { DateRangeField, TimeField } from "@/components/shared/date-picker";
import { createTripAction } from "@/features/trip/actions";
import { ImageUrlInput } from "@/features/trip/components/image-url-input";
import { TripImageUpload } from "@/features/trip/components/trip-image-upload";
import { formatLocalCalendarYmd } from "@/lib/trip-dates";
import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category";
import { VIBES, vibeMeta } from "@/lib/vibe";
@@ -53,7 +61,7 @@ const INITIAL_STATE: FormState = {
itineraryDays: [],
whatsIncluded: "",
whatsExcluded: "",
imageUrls: [""],
imageUrls: [],
maxParticipants: "",
priceDisplay: "",
};
@@ -144,18 +152,11 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
return null;
}
if (target === 3) {
const hasInvalidUrl = state.imageUrls
.map((u) => u.trim())
.filter(Boolean)
.some((u) => {
try {
const parsed = new URL(u);
return parsed.protocol !== "http:" && parsed.protocol !== "https:";
} catch {
return true;
// Foto divalidasi saat upload (route + komponen). Di sini cukup cek
// batas jumlah supaya tidak melampaui kapasitas.
if (state.imageUrls.length > LIMITS.MAX_IMAGE_URLS) {
return `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`;
}
});
if (hasInvalidUrl) return "Ada URL foto yang tidak valid (harus http/https)";
for (let d = 0; d < state.itineraryDays.length; d++) {
const dayItems = state.itineraryDays[d];
@@ -330,6 +331,7 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
whatsExcluded={state.whatsExcluded}
imageUrls={state.imageUrls}
onChange={update}
onError={setStepError}
/>
)}
@@ -367,9 +369,10 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
type="button"
onClick={goBack}
disabled={step === 1 || loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2.5 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40"
className="inline-flex items-center gap-1 rounded-xl border border-neutral-200 bg-white px-4 py-2.5 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40"
>
Kembali
<ArrowLeft size={15} strokeWidth={2} aria-hidden />
Kembali
</button>
{isLastStep ? (
@@ -388,9 +391,10 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
<button
type="button"
onClick={goNext}
className="rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700"
className="inline-flex items-center gap-1 rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700"
>
Lanjut
Lanjut
<ArrowRight size={15} strokeWidth={2} aria-hidden />
</button>
)}
</div>
@@ -442,7 +446,11 @@ function Stepper({
: "cursor-not-allowed"
}`}
>
{isCompleted ? "✓" : s.id}
{isCompleted ? (
<Check size={14} strokeWidth={3} aria-hidden />
) : (
s.id
)}
</button>
<span
className={`ml-2 hidden text-xs font-semibold sm:inline ${
@@ -684,6 +692,7 @@ function StepDetail({
whatsExcluded,
imageUrls,
onChange,
onError,
}: {
meetingPoint: string;
itineraryDays: ItineraryDays;
@@ -691,6 +700,7 @@ function StepDetail({
whatsExcluded: string;
imageUrls: string[];
onChange: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
onError: (msg: string) => void;
}) {
return (
<div className="space-y-5">
@@ -763,9 +773,10 @@ function StepDetail({
</div>
</div>
<ImageUrlInput
<TripImageUpload
value={imageUrls}
onChange={(urls) => onChange("imageUrls", urls)}
onError={onError}
/>
</div>
);
@@ -942,7 +953,7 @@ function ItineraryBuilder({
aria-label="Hapus aktivitas"
className="self-end rounded-lg px-2 py-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-500 sm:self-center"
>
<X size={16} strokeWidth={2} aria-hidden />
</button>
</div>
</li>
@@ -1032,14 +1043,7 @@ function StepSchedule({
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path d="M7 8a3 3 0 100-6 3 3 0 000 6zM14.5 9a2.5 2.5 0 100-5 2.5 2.5 0 000 5zM1.615 16.428a1.224 1.224 0 01-.569-1.175 6.002 6.002 0 0111.908 0c.058.467-.172.92-.57 1.174A9.953 9.953 0 017 18a9.953 9.953 0 01-5.385-1.572zM14.5 16h-.106c.07-.297.088-.611.048-.933a7.47 7.47 0 00-1.588-3.755 4.502 4.502 0 015.874 2.636.818.818 0 01-.36.98A7.465 7.465 0 0114.5 16z" />
</svg>
<Users size={16} strokeWidth={1.75} aria-hidden />
</span>
<input
id="maxParticipants"
@@ -1084,8 +1088,9 @@ function StepSchedule({
/>
</div>
{blockedByVerification && (
<p className="mt-2 text-xs font-medium text-amber-700">
Trip berbayar butuh verifikasi organizer terlebih dahulu.
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-amber-700">
<CircleAlert size={14} strokeWidth={2} aria-hidden />
Trip berbayar butuh verifikasi organizer terlebih dahulu.
</p>
)}
</div>
+165 -9
View File
@@ -1,7 +1,8 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Mountain, Maximize2, X, ChevronLeft, ChevronRight } from "lucide-react";
interface TripImage {
id: string;
@@ -11,11 +12,48 @@ interface TripImage {
export function ImageGallery({ images }: { images: TripImage[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false);
const hasMultiple = images.length > 1;
function showPrev() {
setActiveIndex((i) => (i - 1 + images.length) % images.length);
}
function showNext() {
setActiveIndex((i) => (i + 1) % images.length);
}
// Saat lightbox terbuka: kunci scroll body + dukung keyboard (Esc tutup,
// panah kiri/kanan untuk ganti foto).
useEffect(() => {
if (!lightboxOpen) return;
function onKey(e: KeyboardEvent) {
// Logika prev/next di-inline (bukan panggil showPrev/showNext) supaya
// effect tidak bergantung pada fungsi yang dibuat ulang tiap render.
if (e.key === "Escape") setLightboxOpen(false);
else if (e.key === "ArrowLeft") {
setActiveIndex((i) => (i - 1 + images.length) % images.length);
} else if (e.key === "ArrowRight") {
setActiveIndex((i) => (i + 1) % images.length);
}
}
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [lightboxOpen, images.length]);
if (images.length === 0) {
return (
<div className="flex h-44 items-center justify-center bg-linear-to-br from-primary-800 to-secondary-900 sm:h-56 lg:h-72">
<span className="text-5xl sm:text-6xl">🏔</span>
<div className="flex h-44 items-center justify-center bg-neutral-100 sm:h-56 lg:h-72">
<Mountain
size={56}
strokeWidth={1.5}
aria-hidden
className="text-neutral-300"
/>
</div>
);
}
@@ -24,18 +62,25 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
return (
<div>
{/* Main Image */}
<div className="relative h-44 bg-neutral-900 sm:h-56 lg:h-72">
{/* Main Image — klik untuk lihat ukuran penuh */}
<button
type="button"
onClick={() => setLightboxOpen(true)}
aria-label="Lihat foto ukuran penuh"
className="group relative block h-44 w-full cursor-zoom-in bg-neutral-900 sm:h-56 lg:h-72"
>
<Image
src={activeImage.url}
alt={activeImage.caption || "Foto trip"}
fill
className="object-cover"
// `object-contain` — tampilkan gambar utuh tanpa terpotong; rasio
// foto bebas, sisi yang tak terisi jadi bar gelap (bg-neutral-900).
className="object-contain"
sizes="(max-width: 768px) 100vw, 768px"
priority
/>
{activeImage.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 sm:px-4 sm:pb-3 sm:pt-8">
<div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 text-left sm:px-4 sm:pb-3 sm:pt-8">
<p className="text-xs font-medium text-white sm:text-sm">
{activeImage.caption}
</p>
@@ -45,14 +90,20 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
<div className="absolute right-2 top-2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm sm:right-3 sm:top-3 sm:px-2.5 sm:py-1 sm:text-xs">
{activeIndex + 1} / {images.length}
</div>
</div>
{/* Petunjuk perbesar */}
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm transition-colors group-hover:bg-black/70 sm:left-3 sm:top-3 sm:px-2.5 sm:py-1 sm:text-xs">
<Maximize2 size={11} strokeWidth={2} aria-hidden />
Lihat penuh
</span>
</button>
{/* Thumbnails */}
{images.length > 1 && (
{hasMultiple && (
<div className="flex gap-1 overflow-x-auto bg-neutral-100 p-1.5 sm:gap-1.5 sm:p-2">
{images.map((img, i) => (
<button
key={img.id}
type="button"
onClick={() => setActiveIndex(i)}
className={`relative h-11 w-16 shrink-0 overflow-hidden rounded-md transition-all sm:h-14 sm:w-20 sm:rounded-lg ${
i === activeIndex
@@ -71,6 +122,111 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
))}
</div>
)}
{/* Lightbox — penampil foto ukuran penuh */}
{lightboxOpen && (
<div
role="dialog"
aria-modal="true"
aria-label="Penampil foto trip"
className="fixed inset-0 z-50 flex flex-col bg-black/95"
>
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-3 text-white">
<span className="text-sm font-medium tabular-nums">
{activeIndex + 1} / {images.length}
</span>
<button
type="button"
onClick={() => setLightboxOpen(false)}
aria-label="Tutup"
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 transition-colors hover:bg-white/20"
>
<X size={20} strokeWidth={2} aria-hidden />
</button>
</div>
{/* Area gambar — klik latar untuk menutup */}
<div
className="relative flex-1"
onClick={() => setLightboxOpen(false)}
>
<div
className="relative h-full w-full"
onClick={(e) => e.stopPropagation()}
>
<Image
src={activeImage.url}
alt={activeImage.caption || "Foto trip"}
fill
className="object-contain"
sizes="100vw"
quality={90}
/>
</div>
{hasMultiple && (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
showPrev();
}}
aria-label="Foto sebelumnya"
className="absolute left-2 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 sm:left-4"
>
<ChevronLeft size={24} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
showNext();
}}
aria-label="Foto berikutnya"
className="absolute right-2 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/25 sm:right-4"
>
<ChevronRight size={24} strokeWidth={2} aria-hidden />
</button>
</>
)}
</div>
{activeImage.caption && (
<p className="px-4 py-3 text-center text-sm text-white/80">
{activeImage.caption}
</p>
)}
{/* Thumbnail strip di dalam lightbox */}
{hasMultiple && (
<div className="flex justify-center gap-1.5 overflow-x-auto px-4 pb-4">
{images.map((img, i) => (
<button
key={img.id}
type="button"
onClick={() => setActiveIndex(i)}
aria-label={`Lihat foto ${i + 1}`}
className={`relative h-12 w-16 shrink-0 overflow-hidden rounded-md transition-all ${
i === activeIndex
? "ring-2 ring-primary-400 ring-offset-1 ring-offset-black"
: "opacity-50 hover:opacity-100"
}`}
>
<Image
src={img.url}
alt=""
fill
className="object-cover"
sizes="64px"
/>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -1,82 +0,0 @@
"use client";
import { LIMITS } from "@/lib/limits";
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 < max) {
onChange([...urls, ""]);
}
}
function removeField(index: number) {
const next = urls.filter((_, i) => i !== index);
onChange(next.length > 0 ? next : [""]);
}
function updateField(index: number, next: string) {
const updated = [...urls];
updated[index] = next;
onChange(updated);
}
return (
<div>
<label className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-700">
Foto Trip (URL)
</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
type="url"
value={url}
onChange={(e) => updateField(i, e.target.value)}
className="flex-1 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={
i === 0
? "URL foto utama (cover)"
: `URL foto ${i + 1} (opsional)`
}
/>
{urls.length > 1 && (
<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"
>
</button>
)}
</div>
))}
</div>
{urls.length < max && (
<button
type="button"
onClick={addField}
className="mt-2 flex items-center gap-1 rounded-lg px-2 py-1 text-sm font-medium text-secondary-600 hover:bg-secondary-50"
>
+ Tambah foto
</button>
)}
<p className="mt-1.5 text-xs text-neutral-400">
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya di sini.
</p>
</div>
);
}
@@ -143,7 +143,7 @@ export function JoinTripButton({
Kamu sudah{" "}
<span className="font-semibold">terkonfirmasi</span> sebagai peserta
trip ini
{isFree && <span> trip gratis, tidak ada pembayaran 🎉</span>}.
{isFree && <span> trip gratis, tidak ada pembayaran</span>}.
</div>
)}
{needsPayment && (
@@ -1,4 +1,5 @@
import Image from "next/image";
import { BadgeCheck, Star } from "lucide-react";
import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerTrustPanelProps {
@@ -13,7 +14,7 @@ export function OrganizerTrustPanel({
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">
<div className="rounded-xl border border-neutral-200 bg-white p-4 sm:p-5">
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
Organizer & kepercayaan
</h2>
@@ -42,7 +43,8 @@ export function OrganizerTrustPanel({
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800 sm:text-xs"
title="Identitas organizer telah diverifikasi (KTP & rekening)"
>
Verified Organizer
<BadgeCheck size={12} strokeWidth={2} aria-hidden />
Verified Organizer
</span>
)}
{trust.isTripLeader && (
@@ -54,7 +56,7 @@ export function OrganizerTrustPanel({
</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">
<div className="min-w-25 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 selesai
</p>
@@ -67,7 +69,7 @@ export function OrganizerTrustPanel({
</p>
)}
</div>
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<div className="min-w-25 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">
Peserta dilayani
</p>
@@ -75,12 +77,24 @@ export function OrganizerTrustPanel({
{trust.totalParticipantsServed}
</p>
</div>
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<div className="min-w-25 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 className="flex items-center gap-1 text-lg font-bold text-amber-700">
{trust.avgRating != null ? (
<>
{trust.avgRating}
<Star
size={15}
strokeWidth={2}
fill="currentColor"
aria-hidden
/>
</>
) : (
"—"
)}
</p>
{trust.reviewCount > 0 && (
<p className="text-[10px] text-neutral-400">
+33 -8
View File
@@ -1,5 +1,12 @@
import Image from "next/image";
import Link from "next/link";
import {
MapPin,
CalendarDays,
UserRound,
BadgeCheck,
Sparkles,
} from "lucide-react";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { categoryMeta } from "@/lib/activity-category";
@@ -132,21 +139,38 @@ export function TripCard({
<div className="mt-3 space-y-1 text-sm text-neutral-600">
<div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">📍</span> {location}
<MapPin
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span className="truncate">{location}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-secondary-500">📅</span>{" "}
{formatTripCalendarDateRangeLong(date, endDate)}
<CalendarDays
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span>{formatTripCalendarDateRangeLong(date, endDate)}</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-secondary-500">👤</span>{" "}
<UserRound
size={14}
strokeWidth={1.75}
aria-hidden
className="shrink-0 text-neutral-400"
/>
<span className="truncate">{organizerName}</span>
{isVerifiedOrganizer && (
<span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800"
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)"
>
Verified
<BadgeCheck size={11} strokeWidth={2} aria-hidden />
Verified
</span>
)}
{isSmallGroup && (
@@ -193,10 +217,11 @@ export function TripCard({
)}
{overlapCount > 0 && (
<span
className="rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-semibold text-secondary-700"
className="inline-flex items-center gap-1 rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-semibold text-secondary-700"
title="Peserta dengan minimal 1 minat sama dengan kamu"
>
{overlapCount} peserta sama minat
<Sparkles size={11} strokeWidth={2} aria-hidden />
{overlapCount} peserta sama minat
</span>
)}
</div>
+2 -12
View File
@@ -2,6 +2,7 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { Search } from "lucide-react";
import { DateRangeField } from "@/components/shared/date-picker";
import {
formatLocalCalendarYmd,
@@ -263,18 +264,7 @@ export function TripFilter() {
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clipRule="evenodd"
/>
</svg>
<Search size={16} strokeWidth={1.75} aria-hidden />
</span>
<input
type="text"
@@ -0,0 +1,175 @@
"use client";
import { useRef, useState } from "react";
import Image from "next/image";
import { ImagePlus, X, Loader2 } from "lucide-react";
import { LIMITS } from "@/lib/limits";
interface TripImageUploadProps {
/** URL gambar yang sudah terunggah (path `/api/trip-images/...`). */
value: string[];
onChange: (urls: string[]) => void;
/** Lapor error ke form (mis. file terlalu besar / gagal upload). */
onError?: (msg: string) => void;
}
const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
/** Sinkron dengan MAX_TRIP_IMAGE_UPLOAD_BYTES di lib/trip-image-storage.ts. */
const MAX_BYTES = 12 * 1024 * 1024;
/**
* Pengganti input URL foto: user memilih file dari perangkatnya, tiap file
* langsung di-upload & dikompres server-side. Form hanya menyimpan URL hasil.
*/
export function TripImageUpload({
value,
onChange,
onError,
}: TripImageUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploadingCount, setUploadingCount] = useState(0);
const max = LIMITS.MAX_IMAGE_URLS;
const usedSlots = value.length + uploadingCount;
const remaining = max - usedSlots;
async function uploadOne(file: File): Promise<string | null> {
if (!ACCEPT_MIME.split(",").includes(file.type)) {
onError?.(`"${file.name}" harus JPG, PNG, atau WebP`);
return null;
}
if (file.size > MAX_BYTES) {
onError?.(`"${file.name}" melebihi 12MB`);
return null;
}
const fd = new FormData();
fd.set("file", file);
try {
const res = await fetch("/api/upload/trip-image", {
method: "POST",
body: fd,
});
const json = await res.json();
if (!res.ok) {
onError?.(json.error ?? `Gagal mengunggah "${file.name}"`);
return null;
}
return json.url as string;
} catch {
onError?.(`Gagal mengunggah "${file.name}"`);
return null;
}
}
async function handlePick(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (files.length === 0) return;
if (remaining <= 0) {
onError?.(`Maksimal ${max} foto`);
return;
}
const picked = files.slice(0, remaining);
if (files.length > remaining) {
onError?.(`Hanya ${remaining} foto pertama diunggah (maks ${max})`);
}
setUploadingCount((c) => c + picked.length);
// `value` di-snapshot saat handler dibuat; upload sekuensial supaya urutan
// foto stabil, lalu hasil yang berhasil ditambahkan sekali di akhir.
const uploaded: string[] = [];
for (const file of picked) {
const url = await uploadOne(file);
setUploadingCount((c) => c - 1);
if (url) uploaded.push(url);
}
if (uploaded.length > 0) onChange([...value, ...uploaded]);
}
function removeAt(index: number) {
onChange(value.filter((_, i) => i !== index));
}
return (
<div>
<label className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-700">
Foto Trip
</span>
<span className="text-xs text-neutral-400">
{value.length}/{max}
</span>
</label>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{value.map((url, i) => (
<div
key={url}
className="group relative aspect-square overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100"
>
<Image
src={url}
alt={i === 0 ? "Foto cover" : `Foto ${i + 1}`}
fill
className="object-cover"
sizes="(max-width: 640px) 33vw, 160px"
/>
{i === 0 && (
<span className="absolute left-1 top-1 rounded-md bg-primary-600/90 px-1.5 py-0.5 text-[10px] font-bold text-white">
Cover
</span>
)}
<button
type="button"
onClick={() => removeAt(i)}
aria-label={`Hapus foto ${i + 1}`}
className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-black/55 text-white transition-colors hover:bg-red-600"
>
<X size={13} strokeWidth={2.5} aria-hidden />
</button>
</div>
))}
{Array.from({ length: uploadingCount }).map((_, i) => (
<div
key={`uploading-${i}`}
className="flex aspect-square items-center justify-center rounded-xl border border-dashed border-neutral-300 bg-neutral-50"
>
<Loader2
size={20}
strokeWidth={2}
aria-hidden
className="animate-spin text-neutral-400"
/>
</div>
))}
{remaining > 0 && (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex aspect-square flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-neutral-300 bg-neutral-50/60 text-neutral-500 transition-colors hover:border-primary-400 hover:text-primary-600"
>
<ImagePlus size={20} strokeWidth={1.75} aria-hidden />
<span className="text-[11px] font-semibold">Tambah</span>
</button>
)}
</div>
<input
ref={inputRef}
type="file"
accept={ACCEPT_MIME}
multiple
onChange={handlePick}
className="sr-only"
/>
<p className="mt-1.5 text-xs text-neutral-400">
Unggah langsung dari galeri/kamera JPG, PNG, atau WebP, maks 12MB per
foto. Foto pertama jadi cover. Gambar besar otomatis dikompres tanpa
mengorbankan kualitas.
</p>
</div>
);
}
+10 -2
View File
@@ -9,13 +9,21 @@ import {
} from "@/lib/trip-dates";
import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary";
/**
* Foto trip sekarang adalah file yang diunggah ke server sendiri, bukan URL
* eksternal. Nilai yang valid hanya path terkelola `/api/trip-images/<hex>.webp`
* yang dihasilkan route upload — regex ini sengaja ketat supaya URL arbitrary
* (yang dulu sering tidak reachable dari server) tidak bisa lolos lagi.
*/
export const tripImageUrlsSchema = z
.array(
z
.string()
.trim()
.max(LIMITS.MAX_URL_LENGTH, "URL terlalu panjang")
.url("Setiap URL gambar harus valid (http/https)")
.regex(
/^\/api\/trip-images\/[a-f0-9]{32}\.webp$/,
"Foto trip tidak valid — silakan unggah ulang fotonya"
)
)
.max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`);
+148
View File
@@ -0,0 +1,148 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import sharp from "sharp";
/**
* Penyimpanan gambar trip — publik, di disk lokal.
*
* Beda dengan `secure-storage.ts` (KYC): gambar trip TIDAK dienkripsi karena
* memang tampil ke semua pengunjung. Tiap gambar dikompres SEKALI saat upload
* (resize + WebP), jadi saat render server tinggal serve file statik kecil —
* tidak ada fetch URL eksternal, tidak ada masalah DNS.
*/
/** Batas ukuran file mentah dari user (sebelum kompresi). */
export const MAX_TRIP_IMAGE_UPLOAD_BYTES = 12 * 1024 * 1024;
/** MIME yang diterima saat upload. Output selalu dikonversi ke WebP. */
export const ALLOWED_TRIP_IMAGE_MIME = new Set([
"image/jpeg",
"image/png",
"image/webp",
]);
/** Prefix URL publik untuk gambar trip yang dikelola sendiri. */
export const TRIP_IMAGE_URL_PREFIX = "/api/trip-images/";
/** Sisi terpanjang hasil kompresi — cukup tajam untuk hero & cukup ringan. */
const MAX_DIMENSION = 1920;
/** Kualitas WebP — 80 = sweet spot kualitas/ukuran. */
const WEBP_QUALITY = 80;
/** Nama file valid di disk: `<32-hex>.webp`. Dipakai untuk cegah path traversal. */
const FILE_NAME_RE = /^[a-f0-9]{32}\.webp$/;
function rootDir(): string {
const fromEnv = process.env.TRIP_UPLOAD_DIR;
if (fromEnv && fromEnv.trim().length > 0) return fromEnv;
return path.join(process.cwd(), "uploads", "trips");
}
export function isValidTripImageName(name: string): boolean {
return FILE_NAME_RE.test(name);
}
/** Resolve nama file ke path absolut di dalam upload dir. Throw kalau mencurigakan. */
function resolveName(name: string): string {
if (!isValidTripImageName(name)) {
throw new Error("Nama file gambar tidak valid");
}
const dir = rootDir();
const abs = path.join(dir, name);
if (!abs.startsWith(dir + path.sep)) {
throw new Error("Nama file keluar dari direktori upload");
}
return abs;
}
export type StoredTripImage = {
/** Nama file di disk, mis. `ab12…ef.webp`. */
name: string;
/** URL publik yang disimpan ke `TripImage.url`. */
url: string;
/** Ukuran file hasil kompresi (byte). */
size: number;
};
/**
* Kompres + simpan satu gambar trip. Input mentah (JPG/PNG/WebP) di-resize agar
* muat dalam {@link MAX_DIMENSION}², dikonversi ke WebP, dan metadata (EXIF/GPS)
* dibuang. Gambar 10MB+ dari kamera HP biasanya menyusut jadi ratusan KB tanpa
* kehilangan kualitas yang terlihat.
*
* `sharp` melempar kalau buffer bukan gambar valid — itu jadi lapis validasi
* konten yang lebih kuat dari sekadar percaya `file.type`.
*/
export async function processAndSaveTripImage(
data: Buffer
): Promise<StoredTripImage> {
if (data.length === 0) throw new Error("File kosong");
if (data.length > MAX_TRIP_IMAGE_UPLOAD_BYTES) {
throw new Error("File terlalu besar");
}
let optimized: Buffer;
try {
optimized = await sharp(data)
// `.rotate()` tanpa argumen menerapkan orientasi dari EXIF lalu membuang
// metadata — foto HP tidak miring & lokasi GPS user tidak ikut tersimpan.
.rotate()
.resize({
width: MAX_DIMENSION,
height: MAX_DIMENSION,
fit: "inside",
withoutEnlargement: true,
})
.webp({ quality: WEBP_QUALITY })
.toBuffer();
} catch {
throw new Error("File bukan gambar yang valid");
}
const name = `${crypto.randomBytes(16).toString("hex")}.webp`;
const abs = resolveName(name);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, optimized, { mode: 0o644 });
return {
name,
url: `${TRIP_IMAGE_URL_PREFIX}${name}`,
size: optimized.length,
};
}
export async function readTripImage(name: string): Promise<Buffer> {
return fs.readFile(resolveName(name));
}
export async function deleteTripImage(name: string): Promise<void> {
await fs.rm(resolveName(name), { force: true });
}
/** True kalau URL menunjuk gambar trip yang dikelola sendiri (bukan URL eksternal lama). */
export function isManagedTripImageUrl(url: string): boolean {
if (!url.startsWith(TRIP_IMAGE_URL_PREFIX)) return false;
return isValidTripImageName(url.slice(TRIP_IMAGE_URL_PREFIX.length));
}
/** List semua nama file gambar yang ada di disk (untuk cron cleanup). */
export async function listTripImageNames(): Promise<string[]> {
try {
const entries = await fs.readdir(rootDir());
return entries.filter(isValidTripImageName);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
throw err;
}
}
/** mtime file. Null kalau file tidak ada. */
export async function tripImageMtime(name: string): Promise<Date | null> {
try {
const st = await fs.stat(resolveName(name));
return st.mtime;
} catch {
return null;
}
}
+2
View File
@@ -5,6 +5,8 @@ const nextConfig: NextConfig = {
dangerouslyAllowSVG: true,
// AVIF didahulukan — ~30% lebih kecil dari WebP, didukung browser modern.
formats: ["image/avif", "image/webp"],
// 75 = default (kartu/thumbnail); 90 = lightbox foto trip ukuran penuh.
qualities: [75, 90],
// Cache hasil optimasi minimal 1 hari supaya tidak re-optimize tiap request.
minimumCacheTTL: 86400,
remotePatterns: [
+14 -1050
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -1,6 +1,6 @@
{
"name": "setrip",
"version": "0.16.7",
"version": "0.16.13",
"private": true,
"scripts": {
"dev": "next dev",
@@ -20,6 +20,7 @@
"bcryptjs": "^3.0.3",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"lucide-react": "^1.16.0",
"next": "^16.2.4",
"next-auth": "^4.24.14",
"pg": "^8.20.0",
@@ -28,6 +29,7 @@
"react-datepicker": "^9.1.0",
"react-dom": "19.2.4",
"resend": "^6.12.3",
"sharp": "^0.34.5",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -42,5 +44,10 @@
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
},
"//overrides": "Patch sub-dependency transitif yang kena advisory npm audit, tanpa men-downgrade framework. Lepas saat versi induknya (next / prisma) sudah membawa versi aman sendiri.",
"overrides": {
"postcss": "^8.5.14",
"@hono/node-server": "^1.19.13"
}
}