fix ui style

This commit is contained in:
2026-05-21 11:59:02 +07:00
parent 22e66ce493
commit f84d0e3726
51 changed files with 1035 additions and 347 deletions
+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
+1 -1
View File
@@ -84,7 +84,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
+18 -3
View File
@@ -1,12 +1,19 @@
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
@@ -205,7 +212,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>
}
/>
+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}
+18 -3
View File
@@ -1,12 +1,19 @@
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
@@ -262,7 +269,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>
+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 ${
+20 -4
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
+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>
+33 -14
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";
@@ -112,8 +118,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 +144,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 +157,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 +171,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 +197,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 +229,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 +294,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>
+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>
);
@@ -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,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { retryEmailJobAction, resendEmailAction } from "@/features/email/actions";
const BTN_CLS =
@@ -85,9 +86,18 @@ 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>
+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">
@@ -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>
+24 -16
View File
@@ -2,6 +2,14 @@
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";
@@ -367,9 +375,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 +397,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 +452,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 ${
@@ -942,7 +956,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 +1046,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 +1091,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>
+8 -2
View File
@@ -2,6 +2,7 @@
import Image from "next/image";
import { useState } from "react";
import { Mountain } from "lucide-react";
interface TripImage {
id: string;
@@ -14,8 +15,13 @@ export function ImageGallery({ images }: { images: TripImage[] }) {
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>
);
}
+4 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { Plus, X } from "lucide-react";
import { LIMITS } from "@/lib/limits";
interface ImageUrlInputProps {
@@ -59,7 +60,7 @@ export function ImageUrlInput({ value, onChange }: ImageUrlInputProps) {
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"
>
<X size={16} strokeWidth={2} aria-hidden />
</button>
)}
</div>
@@ -71,7 +72,8 @@ export function ImageUrlInput({ value, onChange }: ImageUrlInputProps) {
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
<Plus size={15} strokeWidth={2} aria-hidden />
Tambah foto
</button>
)}
<p className="mt-1.5 text-xs text-neutral-400">
@@ -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 && (
@@ -79,8 +81,20 @@ export function OrganizerTrustPanel({
<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"
+13 -3
View File
@@ -15,6 +15,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",
@@ -2829,9 +2830,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6408,6 +6409,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/lucide-react": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -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",