add user profile, profile vibe and trip vibe and social signal

This commit is contained in:
2026-05-08 19:20:27 +07:00
parent 3228ef712f
commit 7f419638b5
39 changed files with 1361 additions and 192 deletions
+90
View File
@@ -0,0 +1,90 @@
# Setrip — Social Repositioning Roadmap
Status implementasi reposisi dari "open trip pendakian" → "platform untuk menemukan teman aktivitas & trip bareng".
> **Prinsip pembeda:** Setrip bukan trip-marketplace. Trip adalah kendaraan untuk koneksi sosial (stranger → teman → circle). Setiap fitur dievaluasi: apakah memperkuat **find a companion**, atau hanya **book a trip**? Kalau cuma yang kedua → tolak.
---
## Phase A — Quick wins (sinyal sosial dari data yang sudah ada) ✅
Selesai. `tsc --noEmit` lulus. Migration `20260508120000_add_profile_vibe` belum di-apply ke DB.
| # | Item | Status | File |
|---|---|---|---|
| A1 | Hapus ikon `🏔️` hardcoded di trip detail → ikon + label kategori dinamis | ✅ | [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) |
| A2 | Banner onboarding profil di layout (muncul kalau `UserProfile` kosong) | ✅ | [components/shared/profile-nudge-banner.tsx](components/shared/profile-nudge-banner.tsx), [app/layout.tsx](app/layout.tsx) |
| A3 | Confirmed-peserta dirombak: chip nama → kartu (avatar + kota + 3 tag minat) | ✅ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts), [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) |
| A4 | Field `vibe` (CHILL/BALANCED/HARDCORE) di `UserProfile` + UI editor + badge di profil publik | ✅ | [prisma/schema.prisma](prisma/schema.prisma), [prisma/migrations/20260508120000_add_profile_vibe/migration.sql](prisma/migrations/20260508120000_add_profile_vibe/migration.sql), [lib/vibe.ts](lib/vibe.ts), [features/profile/schemas.ts](features/profile/schemas.ts), [features/profile/actions.ts](features/profile/actions.ts), [server/repositories/profile.repo.ts](server/repositories/profile.repo.ts), [server/repositories/user.repo.ts](server/repositories/user.repo.ts), [server/services/profile.service.ts](server/services/profile.service.ts), [features/profile/components/profile-editor.tsx](features/profile/components/profile-editor.tsx), [app/profile/page.tsx](app/profile/page.tsx), [app/u/[id]/page.tsx](app/u/[id]/page.tsx) |
**Tindakan manual:** jalankan `npx prisma migrate deploy` (atau `dev`) untuk apply migration `20260507185257_add_user_profile` + `20260508120000_add_profile_vibe`.
---
## Phase B — Discovery (people-first, bukan price-first) ✅
Selesai. `tsc --noEmit` lulus. Migration `20260508130000_add_trip_vibe` belum di-apply ke DB.
**Keputusan desain:** `Trip` reuse enum `Vibe` yang sama dengan `UserProfile` (alih-alih bikin `pace`/`level` baru) supaya matching peserta↔trip langsung selaras tanpa mapping.
| # | Item | Status | File |
|---|---|---|---|
| B1 | Halaman `/people` — daftar user dengan profil terisi | ✅ | [app/people/page.tsx](app/people/page.tsx), [server/repositories/user.repo.ts](server/repositories/user.repo.ts) (`findPeople`), [server/services/profile.service.ts](server/services/profile.service.ts) |
| B2 | Filter kota, interests, vibe di `/people` | ✅ | [features/profile/components/people-filter.tsx](features/profile/components/people-filter.tsx), [features/profile/components/user-card.tsx](features/profile/components/user-card.tsx) |
| B3 | Field `vibe` di `Trip` + tampil di trip detail & TripCard + filter di `/trips` | ✅ | [prisma/schema.prisma](prisma/schema.prisma), [prisma/migrations/20260508130000_add_trip_vibe/migration.sql](prisma/migrations/20260508130000_add_trip_vibe/migration.sql), [features/trip/schemas.ts](features/trip/schemas.ts), [features/trip/actions.ts](features/trip/actions.ts), [server/services/trip.service.ts](server/services/trip.service.ts), [features/trip/components/create-trip-form.tsx](features/trip/components/create-trip-form.tsx), [features/trip/components/trip-card.tsx](features/trip/components/trip-card.tsx), [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) |
| B4 | TripCard: 3 avatar peserta confirmed + counter `+N` | ✅ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) (include participants di `findOpen`), [features/trip/components/trip-card.tsx](features/trip/components/trip-card.tsx), [app/page.tsx](app/page.tsx), [app/trips/page.tsx](app/trips/page.tsx) |
| B5 | TripCard: badge "✨ X peserta sama minat" untuk user login | ✅ | [features/trip/components/trip-card.tsx](features/trip/components/trip-card.tsx) (compute overlap), homepage & `/trips` (fetch viewer interests) |
| B6 | Filter ukuran grup (Small ≤10 / Medium 1120 / Large 21+) | ✅ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) (`GroupSize` filter), [features/trip/components/trip-filter.tsx](features/trip/components/trip-filter.tsx), [app/trips/page.tsx](app/trips/page.tsx) |
| B7 | Section "Budget Friendly" → "Lagi Ramai" (social proof) | ✅ | [app/page.tsx](app/page.tsx) — sort by `participantCount desc`, framing "kamu nggak bakal jalan sendirian" |
| B+ | Link `/people` di navbar (desktop + mobile) | ✅ | [components/shared/navbar.tsx](components/shared/navbar.tsx) |
**Tindakan manual:** jalankan `npx prisma migrate deploy` untuk apply migration `20260508130000_add_trip_vibe` (selain 2 migration Phase A yang masih pending).
---
## Phase C — Interaksi & continuity (separate, lebih besar) ⏳
Belum mulai. Setiap item bisa jadi PR terpisah karena perlu schema baru + UI substansial.
| # | Item | Status | Catatan |
|---|---|---|---|
| C1 | Model `TripMessage` — Q&A publik per trip (sebelum berangkat) | ⏳ pending | Schema + actions + UI di trip detail. Calon peserta tanya organizer tanpa keluar app. |
| C2 | Group chat untuk peserta CONFIRMED (post-confirmation) | ⏳ pending | Bisa pakai tabel `TripMessage` yang sama dengan flag `audience` (PUBLIC/CONFIRMED_ONLY). |
| C3 | Model `Connection` (follow / circle) antar user | ⏳ pending | Foundation untuk "from strangers → circle". Halaman "Circle saya". |
| C4 | Notifikasi: organizer punya pending join, peserta dapat balasan Q&A, dst | ⏳ pending | Bisa email dulu, in-app belakangan. |
| C5 | Post-trip continuity: tombol "follow buddies dari trip ini" + album foto bareng | ⏳ pending | Momen konversi stranger → circle terbesar saat ini terbuang. |
| C6 | Review user (bukan cuma trip) — reputasi peserta (no-show? kooperatif?) | ⏳ pending | Lengkapi trust layer. Anti-scam. |
| C7 | Onboarding flow wajib post-register (bukan banner) — minta minimal 3 interests + city + vibe sebelum bisa join trip | ⏳ pending | Banner Phase A2 cuma soft nudge. Hard-gate saat user pertama kali pencet "Join". |
| C8 | Referral / invite-with-link | ⏳ pending | Loop pertumbuhan komunitas. |
---
## ❌ Anti-list (yang harus DITOLAK kalau muncul)
Fitur-fitur ini akan menarik Setrip ke arena OTA (Traveloka/Klook) yang tidak bisa dimenangkan:
- Booking hotel / tiket pesawat
- Tour massal tanpa interaksi (>30 orang, bus pariwisata)
- Mass listing dari travel agent (B2B aggregator)
- Filter & sort harga yang lebih agresif (price-low-to-high, dll.) — perkuat framing harga-dulu
- Affiliate/komisi dari pihak ketiga yang bukan organizer terverifikasi
- SEO-driven mass content untuk destinasi (artikel "10 Gunung Terbaik di Jawa") tanpa angle social
- Integrasi pembayaran kompleks (split-bill, escrow rumit) sebelum chat dasar (C1) ada — prioritas terbalik
Kalau muncul request ke arah ini, tanya: "ini meningkatkan kemungkinan dua orang asing kenalan, atau cuma memudahkan transaksi?" Kalau jawabannya yang kedua → tolak / tunda.
---
## Konteks positioning (referensi cepat)
**Untuk siapa:** orang yang ingin pergi tapi tidak punya teman, ingin kenalan baru lewat aktivitas bareng.
**Bukan untuk:** orang yang sudah punya grup dan tinggal cari paket trip termurah.
**Categories yang valid** (semua harus punya: organizer, group kecil, interaksi sosial):
- Core: hiking, camping
- Natural expansion: snorkeling, diving, island hopping
- Social activity: city trip, kulineran, konser bareng
- Semi-professional: workshop, kelas outdoor, retreat
**Tagline:** "Pergi bareng, bukan sendiri" / "From strangers to travel buddies".
+1 -1
View File
@@ -25,7 +25,7 @@ export type User = Prisma.UserModel
/**
* Model UserProfile
* Profil sosial publik. Berisi info yang user pilih untuk dibagikan ke peserta lain
* (bio, kota, minat). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification.
* (bio, kota, minat, vibe). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification.
*/
export type UserProfile = Prisma.UserProfileModel
/**
+1 -1
View File
@@ -49,7 +49,7 @@ export type User = Prisma.UserModel
/**
* Model UserProfile
* Profil sosial publik. Berisi info yang user pilih untuk dibagikan ke peserta lain
* (bio, kota, minat). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification.
* (bio, kota, minat, vibe). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification.
*/
export type UserProfile = Prisma.UserProfileModel
/**
+34
View File
@@ -148,6 +148,23 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type EnumVibeNullableFilter<$PrismaModel = never> = {
equals?: $Enums.Vibe | Prisma.EnumVibeFieldRefInput<$PrismaModel> | null
in?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel> | $Enums.Vibe | null
}
export type EnumVibeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.Vibe | Prisma.EnumVibeFieldRefInput<$PrismaModel> | null
in?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumVibeNullableWithAggregatesFilter<$PrismaModel> | $Enums.Vibe | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel>
}
export type IntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
@@ -417,6 +434,23 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedEnumVibeNullableFilter<$PrismaModel = never> = {
equals?: $Enums.Vibe | Prisma.EnumVibeFieldRefInput<$PrismaModel> | null
in?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel> | $Enums.Vibe | null
}
export type NestedEnumVibeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.Vibe | Prisma.EnumVibeFieldRefInput<$PrismaModel> | null
in?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.Vibe[] | Prisma.ListEnumVibeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumVibeNullableWithAggregatesFilter<$PrismaModel> | $Enums.Vibe | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumVibeNullableFilter<$PrismaModel>
}
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
+9
View File
@@ -9,6 +9,15 @@
* 🟢 You can import this file directly.
*/
export const Vibe = {
CHILL: 'CHILL',
BALANCED: 'BALANCED',
HARDCORE: 'HARDCORE'
} as const
export type Vibe = (typeof Vibe)[keyof typeof Vibe]
export const VerificationStatus = {
PENDING: 'PENDING',
APPROVED: 'APPROVED',
File diff suppressed because one or more lines are too long
@@ -1065,6 +1065,7 @@ export const UserProfileScalarFieldEnum = {
city: 'city',
interests: 'interests',
instagram: 'instagram',
vibe: 'vibe',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -1130,6 +1131,7 @@ export const TripScalarFieldEnum = {
endDate: 'endDate',
maxParticipants: 'maxParticipants',
price: 'price',
vibe: 'vibe',
status: 'status',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
@@ -1241,6 +1243,20 @@ export type BooleanFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel,
/**
* Reference to a field of type 'Vibe'
*/
export type EnumVibeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Vibe'>
/**
* Reference to a field of type 'Vibe[]'
*/
export type ListEnumVibeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Vibe[]'>
/**
* Reference to a field of type 'Int'
*/
@@ -100,6 +100,7 @@ export const UserProfileScalarFieldEnum = {
city: 'city',
interests: 'interests',
instagram: 'instagram',
vibe: 'vibe',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -165,6 +166,7 @@ export const TripScalarFieldEnum = {
endDate: 'endDate',
maxParticipants: 'maxParticipants',
price: 'price',
vibe: 'vibe',
status: 'status',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
+51 -1
View File
@@ -51,6 +51,7 @@ export type TripMinAggregateOutputType = {
endDate: Date | null
maxParticipants: number | null
price: number | null
vibe: $Enums.Vibe | null
status: $Enums.TripStatus | null
createdAt: Date | null
updatedAt: Date | null
@@ -72,6 +73,7 @@ export type TripMaxAggregateOutputType = {
endDate: Date | null
maxParticipants: number | null
price: number | null
vibe: $Enums.Vibe | null
status: $Enums.TripStatus | null
createdAt: Date | null
updatedAt: Date | null
@@ -93,6 +95,7 @@ export type TripCountAggregateOutputType = {
endDate: number
maxParticipants: number
price: number
vibe: number
status: number
createdAt: number
updatedAt: number
@@ -126,6 +129,7 @@ export type TripMinAggregateInputType = {
endDate?: true
maxParticipants?: true
price?: true
vibe?: true
status?: true
createdAt?: true
updatedAt?: true
@@ -147,6 +151,7 @@ export type TripMaxAggregateInputType = {
endDate?: true
maxParticipants?: true
price?: true
vibe?: true
status?: true
createdAt?: true
updatedAt?: true
@@ -168,6 +173,7 @@ export type TripCountAggregateInputType = {
endDate?: true
maxParticipants?: true
price?: true
vibe?: true
status?: true
createdAt?: true
updatedAt?: true
@@ -276,6 +282,7 @@ export type TripGroupByOutputType = {
endDate: Date | null
maxParticipants: number
price: number
vibe: $Enums.Vibe | null
status: $Enums.TripStatus
createdAt: Date
updatedAt: Date
@@ -320,6 +327,7 @@ export type TripWhereInput = {
endDate?: Prisma.DateTimeNullableFilter<"Trip"> | Date | string | null
maxParticipants?: Prisma.IntFilter<"Trip"> | number
price?: Prisma.IntFilter<"Trip"> | number
vibe?: Prisma.EnumVibeNullableFilter<"Trip"> | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFilter<"Trip"> | $Enums.TripStatus
createdAt?: Prisma.DateTimeFilter<"Trip"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Trip"> | Date | string
@@ -345,6 +353,7 @@ export type TripOrderByWithRelationInput = {
endDate?: Prisma.SortOrderInput | Prisma.SortOrder
maxParticipants?: Prisma.SortOrder
price?: Prisma.SortOrder
vibe?: Prisma.SortOrderInput | Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -373,6 +382,7 @@ export type TripWhereUniqueInput = Prisma.AtLeast<{
endDate?: Prisma.DateTimeNullableFilter<"Trip"> | Date | string | null
maxParticipants?: Prisma.IntFilter<"Trip"> | number
price?: Prisma.IntFilter<"Trip"> | number
vibe?: Prisma.EnumVibeNullableFilter<"Trip"> | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFilter<"Trip"> | $Enums.TripStatus
createdAt?: Prisma.DateTimeFilter<"Trip"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Trip"> | Date | string
@@ -398,6 +408,7 @@ export type TripOrderByWithAggregationInput = {
endDate?: Prisma.SortOrderInput | Prisma.SortOrder
maxParticipants?: Prisma.SortOrder
price?: Prisma.SortOrder
vibe?: Prisma.SortOrderInput | Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -427,6 +438,7 @@ export type TripScalarWhereWithAggregatesInput = {
endDate?: Prisma.DateTimeNullableWithAggregatesFilter<"Trip"> | Date | string | null
maxParticipants?: Prisma.IntWithAggregatesFilter<"Trip"> | number
price?: Prisma.IntWithAggregatesFilter<"Trip"> | number
vibe?: Prisma.EnumVibeNullableWithAggregatesFilter<"Trip"> | $Enums.Vibe | null
status?: Prisma.EnumTripStatusWithAggregatesFilter<"Trip"> | $Enums.TripStatus
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Trip"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Trip"> | Date | string
@@ -448,6 +460,7 @@ export type TripCreateInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -472,6 +485,7 @@ export type TripUncheckedCreateInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -496,6 +510,7 @@ export type TripUpdateInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -520,6 +535,7 @@ export type TripUncheckedUpdateInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -544,6 +560,7 @@ export type TripCreateManyInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -565,6 +582,7 @@ export type TripUpdateManyMutationInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -585,6 +603,7 @@ export type TripUncheckedUpdateManyInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -616,6 +635,7 @@ export type TripCountOrderByAggregateInput = {
endDate?: Prisma.SortOrder
maxParticipants?: Prisma.SortOrder
price?: Prisma.SortOrder
vibe?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -642,6 +662,7 @@ export type TripMaxOrderByAggregateInput = {
endDate?: Prisma.SortOrder
maxParticipants?: Prisma.SortOrder
price?: Prisma.SortOrder
vibe?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -663,6 +684,7 @@ export type TripMinOrderByAggregateInput = {
endDate?: Prisma.SortOrder
maxParticipants?: Prisma.SortOrder
price?: Prisma.SortOrder
vibe?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -794,6 +816,7 @@ export type TripCreateWithoutOrganizerInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -817,6 +840,7 @@ export type TripUncheckedCreateWithoutOrganizerInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -869,6 +893,7 @@ export type TripScalarWhereInput = {
endDate?: Prisma.DateTimeNullableFilter<"Trip"> | Date | string | null
maxParticipants?: Prisma.IntFilter<"Trip"> | number
price?: Prisma.IntFilter<"Trip"> | number
vibe?: Prisma.EnumVibeNullableFilter<"Trip"> | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFilter<"Trip"> | $Enums.TripStatus
createdAt?: Prisma.DateTimeFilter<"Trip"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Trip"> | Date | string
@@ -890,6 +915,7 @@ export type TripCreateWithoutReviewsInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -913,6 +939,7 @@ export type TripUncheckedCreateWithoutReviewsInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -952,6 +979,7 @@ export type TripUpdateWithoutReviewsInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -975,6 +1003,7 @@ export type TripUncheckedUpdateWithoutReviewsInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -998,6 +1027,7 @@ export type TripCreateWithoutImagesInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -1021,6 +1051,7 @@ export type TripUncheckedCreateWithoutImagesInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -1060,6 +1091,7 @@ export type TripUpdateWithoutImagesInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1083,6 +1115,7 @@ export type TripUncheckedUpdateWithoutImagesInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1106,6 +1139,7 @@ export type TripCreateWithoutParticipantsInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -1129,6 +1163,7 @@ export type TripUncheckedCreateWithoutParticipantsInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -1168,6 +1203,7 @@ export type TripUpdateWithoutParticipantsInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1191,6 +1227,7 @@ export type TripUncheckedUpdateWithoutParticipantsInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1214,6 +1251,7 @@ export type TripCreateManyOrganizerInput = {
endDate?: Date | string | null
maxParticipants: number
price: number
vibe?: $Enums.Vibe | null
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -1234,6 +1272,7 @@ export type TripUpdateWithoutOrganizerInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1257,6 +1296,7 @@ export type TripUncheckedUpdateWithoutOrganizerInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1280,6 +1320,7 @@ export type TripUncheckedUpdateManyWithoutOrganizerInput = {
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1349,6 +1390,7 @@ export type TripSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
endDate?: boolean
maxParticipants?: boolean
price?: boolean
vibe?: boolean
status?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -1375,6 +1417,7 @@ export type TripSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
endDate?: boolean
maxParticipants?: boolean
price?: boolean
vibe?: boolean
status?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -1397,6 +1440,7 @@ export type TripSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
endDate?: boolean
maxParticipants?: boolean
price?: boolean
vibe?: boolean
status?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -1419,13 +1463,14 @@ export type TripSelectScalar = {
endDate?: boolean
maxParticipants?: boolean
price?: boolean
vibe?: boolean
status?: boolean
createdAt?: boolean
updatedAt?: boolean
organizerId?: boolean
}
export type TripOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "title" | "description" | "category" | "destination" | "location" | "meetingPoint" | "itinerary" | "whatsIncluded" | "whatsExcluded" | "date" | "endDate" | "maxParticipants" | "price" | "status" | "createdAt" | "updatedAt" | "organizerId", ExtArgs["result"]["trip"]>
export type TripOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "title" | "description" | "category" | "destination" | "location" | "meetingPoint" | "itinerary" | "whatsIncluded" | "whatsExcluded" | "date" | "endDate" | "maxParticipants" | "price" | "vibe" | "status" | "createdAt" | "updatedAt" | "organizerId", ExtArgs["result"]["trip"]>
export type TripInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
organizer?: boolean | Prisma.UserDefaultArgs<ExtArgs>
participants?: boolean | Prisma.Trip$participantsArgs<ExtArgs>
@@ -1481,6 +1526,10 @@ export type $TripPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
endDate: Date | null
maxParticipants: number
price: number
/**
* Ritme/energi trip — dipakai untuk matching dengan vibe user.
*/
vibe: $Enums.Vibe | null
status: $Enums.TripStatus
createdAt: Date
updatedAt: Date
@@ -1926,6 +1975,7 @@ export interface TripFieldRefs {
readonly endDate: Prisma.FieldRef<"Trip", 'DateTime'>
readonly maxParticipants: Prisma.FieldRef<"Trip", 'Int'>
readonly price: Prisma.FieldRef<"Trip", 'Int'>
readonly vibe: Prisma.FieldRef<"Trip", 'Vibe'>
readonly status: Prisma.FieldRef<"Trip", 'TripStatus'>
readonly createdAt: Prisma.FieldRef<"Trip", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"Trip", 'DateTime'>
+41 -2
View File
@@ -15,7 +15,7 @@ import type * as Prisma from "../internal/prismaNamespace"
/**
* Model UserProfile
* Profil sosial publik. Berisi info yang user pilih untuk dibagikan ke peserta lain
* (bio, kota, minat). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification.
* (bio, kota, minat, vibe). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification.
*/
export type UserProfileModel = runtime.Types.Result.DefaultSelection<Prisma.$UserProfilePayload>
@@ -31,6 +31,7 @@ export type UserProfileMinAggregateOutputType = {
bio: string | null
city: string | null
instagram: string | null
vibe: $Enums.Vibe | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -41,6 +42,7 @@ export type UserProfileMaxAggregateOutputType = {
bio: string | null
city: string | null
instagram: string | null
vibe: $Enums.Vibe | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -52,6 +54,7 @@ export type UserProfileCountAggregateOutputType = {
city: number
interests: number
instagram: number
vibe: number
createdAt: number
updatedAt: number
_all: number
@@ -64,6 +67,7 @@ export type UserProfileMinAggregateInputType = {
bio?: true
city?: true
instagram?: true
vibe?: true
createdAt?: true
updatedAt?: true
}
@@ -74,6 +78,7 @@ export type UserProfileMaxAggregateInputType = {
bio?: true
city?: true
instagram?: true
vibe?: true
createdAt?: true
updatedAt?: true
}
@@ -85,6 +90,7 @@ export type UserProfileCountAggregateInputType = {
city?: true
interests?: true
instagram?: true
vibe?: true
createdAt?: true
updatedAt?: true
_all?: true
@@ -169,6 +175,7 @@ export type UserProfileGroupByOutputType = {
city: string | null
interests: string[]
instagram: string | null
vibe: $Enums.Vibe | null
createdAt: Date
updatedAt: Date
_count: UserProfileCountAggregateOutputType | null
@@ -201,6 +208,7 @@ export type UserProfileWhereInput = {
city?: Prisma.StringNullableFilter<"UserProfile"> | string | null
interests?: Prisma.StringNullableListFilter<"UserProfile">
instagram?: Prisma.StringNullableFilter<"UserProfile"> | string | null
vibe?: Prisma.EnumVibeNullableFilter<"UserProfile"> | $Enums.Vibe | null
createdAt?: Prisma.DateTimeFilter<"UserProfile"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"UserProfile"> | Date | string
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
@@ -213,6 +221,7 @@ export type UserProfileOrderByWithRelationInput = {
city?: Prisma.SortOrderInput | Prisma.SortOrder
interests?: Prisma.SortOrder
instagram?: Prisma.SortOrderInput | Prisma.SortOrder
vibe?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
user?: Prisma.UserOrderByWithRelationInput
@@ -228,6 +237,7 @@ export type UserProfileWhereUniqueInput = Prisma.AtLeast<{
city?: Prisma.StringNullableFilter<"UserProfile"> | string | null
interests?: Prisma.StringNullableListFilter<"UserProfile">
instagram?: Prisma.StringNullableFilter<"UserProfile"> | string | null
vibe?: Prisma.EnumVibeNullableFilter<"UserProfile"> | $Enums.Vibe | null
createdAt?: Prisma.DateTimeFilter<"UserProfile"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"UserProfile"> | Date | string
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
@@ -240,6 +250,7 @@ export type UserProfileOrderByWithAggregationInput = {
city?: Prisma.SortOrderInput | Prisma.SortOrder
interests?: Prisma.SortOrder
instagram?: Prisma.SortOrderInput | Prisma.SortOrder
vibe?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
_count?: Prisma.UserProfileCountOrderByAggregateInput
@@ -257,6 +268,7 @@ export type UserProfileScalarWhereWithAggregatesInput = {
city?: Prisma.StringNullableWithAggregatesFilter<"UserProfile"> | string | null
interests?: Prisma.StringNullableListFilter<"UserProfile">
instagram?: Prisma.StringNullableWithAggregatesFilter<"UserProfile"> | string | null
vibe?: Prisma.EnumVibeNullableWithAggregatesFilter<"UserProfile"> | $Enums.Vibe | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"UserProfile"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"UserProfile"> | Date | string
}
@@ -267,6 +279,7 @@ export type UserProfileCreateInput = {
city?: string | null
interests?: Prisma.UserProfileCreateinterestsInput | string[]
instagram?: string | null
vibe?: $Enums.Vibe | null
createdAt?: Date | string
updatedAt?: Date | string
user: Prisma.UserCreateNestedOneWithoutProfileInput
@@ -279,6 +292,7 @@ export type UserProfileUncheckedCreateInput = {
city?: string | null
interests?: Prisma.UserProfileCreateinterestsInput | string[]
instagram?: string | null
vibe?: $Enums.Vibe | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -289,6 +303,7 @@ export type UserProfileUpdateInput = {
city?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
interests?: Prisma.UserProfileUpdateinterestsInput | string[]
instagram?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
user?: Prisma.UserUpdateOneRequiredWithoutProfileNestedInput
@@ -301,6 +316,7 @@ export type UserProfileUncheckedUpdateInput = {
city?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
interests?: Prisma.UserProfileUpdateinterestsInput | string[]
instagram?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -312,6 +328,7 @@ export type UserProfileCreateManyInput = {
city?: string | null
interests?: Prisma.UserProfileCreateinterestsInput | string[]
instagram?: string | null
vibe?: $Enums.Vibe | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -322,6 +339,7 @@ export type UserProfileUpdateManyMutationInput = {
city?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
interests?: Prisma.UserProfileUpdateinterestsInput | string[]
instagram?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -333,6 +351,7 @@ export type UserProfileUncheckedUpdateManyInput = {
city?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
interests?: Prisma.UserProfileUpdateinterestsInput | string[]
instagram?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -357,6 +376,7 @@ export type UserProfileCountOrderByAggregateInput = {
city?: Prisma.SortOrder
interests?: Prisma.SortOrder
instagram?: Prisma.SortOrder
vibe?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -367,6 +387,7 @@ export type UserProfileMaxOrderByAggregateInput = {
bio?: Prisma.SortOrder
city?: Prisma.SortOrder
instagram?: Prisma.SortOrder
vibe?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -377,6 +398,7 @@ export type UserProfileMinOrderByAggregateInput = {
bio?: Prisma.SortOrder
city?: Prisma.SortOrder
instagram?: Prisma.SortOrder
vibe?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -422,12 +444,17 @@ export type UserProfileUpdateinterestsInput = {
push?: string | string[]
}
export type NullableEnumVibeFieldUpdateOperationsInput = {
set?: $Enums.Vibe | null
}
export type UserProfileCreateWithoutUserInput = {
id?: string
bio?: string | null
city?: string | null
interests?: Prisma.UserProfileCreateinterestsInput | string[]
instagram?: string | null
vibe?: $Enums.Vibe | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -438,6 +465,7 @@ export type UserProfileUncheckedCreateWithoutUserInput = {
city?: string | null
interests?: Prisma.UserProfileCreateinterestsInput | string[]
instagram?: string | null
vibe?: $Enums.Vibe | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -464,6 +492,7 @@ export type UserProfileUpdateWithoutUserInput = {
city?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
interests?: Prisma.UserProfileUpdateinterestsInput | string[]
instagram?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -474,6 +503,7 @@ export type UserProfileUncheckedUpdateWithoutUserInput = {
city?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
interests?: Prisma.UserProfileUpdateinterestsInput | string[]
instagram?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
vibe?: Prisma.NullableEnumVibeFieldUpdateOperationsInput | $Enums.Vibe | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -487,6 +517,7 @@ export type UserProfileSelect<ExtArgs extends runtime.Types.Extensions.InternalA
city?: boolean
interests?: boolean
instagram?: boolean
vibe?: boolean
createdAt?: boolean
updatedAt?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
@@ -499,6 +530,7 @@ export type UserProfileSelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
city?: boolean
interests?: boolean
instagram?: boolean
vibe?: boolean
createdAt?: boolean
updatedAt?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
@@ -511,6 +543,7 @@ export type UserProfileSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
city?: boolean
interests?: boolean
instagram?: boolean
vibe?: boolean
createdAt?: boolean
updatedAt?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
@@ -523,11 +556,12 @@ export type UserProfileSelectScalar = {
city?: boolean
interests?: boolean
instagram?: boolean
vibe?: boolean
createdAt?: boolean
updatedAt?: boolean
}
export type UserProfileOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "userId" | "bio" | "city" | "interests" | "instagram" | "createdAt" | "updatedAt", ExtArgs["result"]["userProfile"]>
export type UserProfileOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "userId" | "bio" | "city" | "interests" | "instagram" | "vibe" | "createdAt" | "updatedAt", ExtArgs["result"]["userProfile"]>
export type UserProfileInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
}
@@ -562,6 +596,10 @@ export type $UserProfilePayload<ExtArgs extends runtime.Types.Extensions.Interna
* Username Instagram (tanpa @, opsional)
*/
instagram: string | null
/**
* Gaya jalan / energi user — dipakai untuk matching teman dengan ritme serupa.
*/
vibe: $Enums.Vibe | null
createdAt: Date
updatedAt: Date
}, ExtArgs["result"]["userProfile"]>
@@ -994,6 +1032,7 @@ export interface UserProfileFieldRefs {
readonly city: Prisma.FieldRef<"UserProfile", 'String'>
readonly interests: Prisma.FieldRef<"UserProfile", 'String[]'>
readonly instagram: Prisma.FieldRef<"UserProfile", 'String'>
readonly vibe: Prisma.FieldRef<"UserProfile", 'Vibe'>
readonly createdAt: Prisma.FieldRef<"UserProfile", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"UserProfile", 'DateTime'>
}
+2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { SessionProvider } from "@/components/providers/session-provider";
import { Navbar } from "@/components/shared/navbar";
import { ProfileNudgeBanner } from "@/components/shared/profile-nudge-banner";
import { siteConfig, siteUrl } from "@/lib/site";
import "./globals.css";
@@ -80,6 +81,7 @@ export default function RootLayout({
<body className="flex min-h-full flex-col bg-neutral-50">
<SessionProvider>
<Navbar />
<ProfileNudgeBanner />
<main className="flex-1">{children}</main>
</SessionProvider>
</body>
+41 -9
View File
@@ -1,11 +1,25 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
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";
type OpenTrip = Awaited<ReturnType<typeof tripService.getOpenTrips>>[number];
function mapParticipants(trip: OpenTrip) {
return trip.participants.map((p) => ({
id: p.id,
name: p.user.name,
image: p.user.image,
interests: p.user.profile?.interests ?? [],
}));
}
export const metadata: Metadata = {
title: "Cari Teman Trip & Aktivitas — Pergi Bareng, Bukan Sendiri",
description: `${siteConfig.slogan} ${siteConfig.description}`,
@@ -18,7 +32,14 @@ export const metadata: Metadata = {
};
export default async function HomePage() {
const trips = await tripService.getOpenTrips();
const session = await getServerSession(authOptions);
const [trips, viewerProfile] = await Promise.all([
tripService.getOpenTrips(),
session?.user?.id
? profileRepo.findByUserId(session.user.id)
: Promise.resolve(null),
]);
const viewerInterests = viewerProfile?.interests ?? [];
const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
@@ -35,8 +56,10 @@ export default async function HomePage() {
const shownIds = new Set([...upcomingIds, ...latestTrips.map((t) => t.id)]);
const budgetTrips = trips
.filter((t) => !shownIds.has(t.id) && t.price <= 300000)
// Section sosial: trip yang paling ramai joiner-nya (social proof, bukan price proof).
const buzzingTrips = trips
.filter((t) => !shownIds.has(t.id) && t._count.participants > 0)
.sort((a, b) => b._count.participants - a._count.participants)
.slice(0, 3);
const orgJsonLd = {
@@ -191,6 +214,7 @@ export default async function HomePage() {
id={trip.id}
title={trip.title}
category={trip.category}
vibe={trip.vibe}
destination={trip.destination}
location={trip.location}
date={trip.date}
@@ -204,6 +228,8 @@ export default async function HomePage() {
isVerifiedOrganizer={
trip.organizer.organizerVerification?.status === "APPROVED"
}
participants={mapParticipants(trip)}
viewerInterests={viewerInterests}
priority={i === 0}
/>
))}
@@ -261,6 +287,7 @@ export default async function HomePage() {
id={trip.id}
title={trip.title}
category={trip.category}
vibe={trip.vibe}
destination={trip.destination}
location={trip.location}
date={trip.date}
@@ -274,35 +301,38 @@ export default async function HomePage() {
isVerifiedOrganizer={
trip.organizer.organizerVerification?.status === "APPROVED"
}
participants={mapParticipants(trip)}
viewerInterests={viewerInterests}
/>
))}
</div>
)}
</section>
{/* Budget Friendly */}
{budgetTrips.length > 0 && (
{/* 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">
Budget Friendly
Lagi Ramai
</h2>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Trip di bawah Rp 300.000
Banyak yang sudah gabung kamu nggak bakal jalan sendirian
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{budgetTrips.map((trip) => (
{buzzingTrips.map((trip) => (
<TripCard
key={trip.id}
id={trip.id}
title={trip.title}
category={trip.category}
vibe={trip.vibe}
destination={trip.destination}
location={trip.location}
date={trip.date}
@@ -316,6 +346,8 @@ export default async function HomePage() {
isVerifiedOrganizer={
trip.organizer.organizerVerification?.status === "APPROVED"
}
participants={mapParticipants(trip)}
viewerInterests={viewerInterests}
/>
))}
</div>
+104
View File
@@ -0,0 +1,104 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { profileService } from "@/server/services/profile.service";
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";
interface PeoplePageProps {
searchParams: Promise<{
city?: string;
interest?: string;
vibe?: string;
}>;
}
export async function generateMetadata({
searchParams,
}: PeoplePageProps): Promise<Metadata> {
const { city, interest, vibe: vibeParam } = await searchParams;
const vibe = isVibe(vibeParam) ? vibeParam : undefined;
const parts: string[] = [];
if (vibe) parts.push(`Vibe ${vibeLabel(vibe).toLowerCase()}`);
if (city) parts.push(`di ${city}`);
if (interest) parts.push(`#${interest.toLowerCase()}`);
const title = parts.length
? `Cari Teman ${parts.join(" ")}`
: "Cari Teman Aktivitas — Profil Anggota";
const description = `Telusuri profil anggota ${siteConfig.name} berdasarkan minat, kota, dan vibe. Temukan calon teman trip dengan ritme yang cocok sebelum gabung bareng.`;
return {
title,
description,
alternates: { canonical: "/people" },
openGraph: { title, description, url: "/people" },
};
}
export default async function PeoplePage({ searchParams }: PeoplePageProps) {
const params = await searchParams;
const vibe = isVibe(params.vibe) ? params.vibe : undefined;
const filters = {
city: params.city?.trim() || undefined,
interest: params.interest?.trim().toLowerCase() || undefined,
vibe,
};
const hasFilters = Boolean(filters.city || filters.interest || filters.vibe);
const people = await profileService.findPeople(filters);
return (
<div className="mx-auto max-w-6xl px-4 py-6 sm:py-8">
<div className="mb-5 flex flex-col gap-2 sm:mb-6">
<h1 className="text-xl font-bold text-neutral-800 sm:text-2xl">
Cari Teman Aktivitas
</h1>
<p className="text-sm text-neutral-500">
{hasFilters
? `${people.length} orang ditemukan dengan filter di atas`
: `${people.length} anggota dengan profil sosial — kenali dulu sebelum gabung trip`}
</p>
</div>
<div className="mb-6">
<Suspense fallback={null}>
<PeopleFilter />
</Suspense>
</div>
{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>
<p className="mb-1 text-base font-bold text-neutral-800 sm:text-lg">
{hasFilters
? "Belum ada anggota yang cocok"
: "Belum ada anggota dengan profil terisi"}
</p>
<p className="text-sm text-neutral-500">
{hasFilters
? "Coba longgarkan filter — kota, minat, atau vibe."
: "Setelah anggota lain mengisi profil, mereka akan muncul di sini."}
</p>
</div>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{people.map((u) => (
<li key={u.id}>
<UserCard
id={u.id}
name={u.name}
image={u.image}
isVerifiedOrganizer={
u.organizerVerification?.status === "APPROVED"
}
profile={u.profile}
/>
</li>
))}
</ul>
)}
</div>
);
}
+2
View File
@@ -95,6 +95,7 @@ export default async function ProfilePage() {
city: ownProfile.city,
interests: ownProfile.interests,
instagram: ownProfile.instagram,
vibe: ownProfile.vibe,
}
: null
}
@@ -169,6 +170,7 @@ export default async function ProfilePage() {
id={trip.id}
title={trip.title}
category={trip.category}
vibe={trip.vibe}
destination={trip.destination}
location={trip.location}
date={trip.date}
+70 -10
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import Link from "next/link";
import Image from "next/image";
import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
import { trustService } from "@/server/services/trust.service";
@@ -15,6 +16,8 @@ import { TripProgramBlock } from "@/features/trip/components/trip-program-block"
import { OrganizerPaymentQueue } from "@/features/booking/components/organizer-payment-queue";
import { ImageGallery } from "@/features/trip/components/image-gallery";
import { TripReviewSection } from "@/features/review/components/trip-review-section";
import { categoryMeta } from "@/lib/activity-category";
import { vibeMeta } from "@/lib/vibe";
import {
isPastTripLastDayForReview,
isTripDepartureDayPast,
@@ -127,6 +130,8 @@ export default async function TripDetailPage({
(p) => p.markedPaidAt && !p.paymentConfirmedAt
);
const catMeta = categoryMeta(trip.category);
const tripUrl = absoluteUrl(`/trips/${trip.id}`);
const eventStatus =
trip.status === "OPEN"
@@ -240,8 +245,21 @@ export default async function TripDetailPage({
<h1 className="text-lg font-bold text-neutral-800 sm:text-xl">
{trip.title}
</h1>
<p className="mt-0.5 flex items-center gap-1.5 text-sm text-neutral-500">
🏔 {trip.destination}
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-sm text-neutral-500">
<span aria-hidden>{catMeta.icon}</span>
<span className="rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-neutral-600">
{catMeta.label}
</span>
{trip.vibe && (
<span
className="inline-flex items-center gap-0.5 rounded-full bg-secondary-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-secondary-700"
title={vibeMeta(trip.vibe).description}
>
<span aria-hidden>{vibeMeta(trip.vibe).icon}</span>
<span>{vibeMeta(trip.vibe).label}</span>
</span>
)}
<span className="truncate">{trip.destination}</span>
</p>
</div>
<span
@@ -448,9 +466,12 @@ export default async function TripDetailPage({
{/* Peserta yang sudah disetujui organizer (publik) */}
<div>
<h2 className="mb-3 text-xs font-bold text-neutral-700 sm:text-sm">
<h2 className="mb-1 text-xs font-bold text-neutral-700 sm:text-sm">
Peserta terkonfirmasi ({confirmedCount})
</h2>
<p className="mb-3 text-[11px] text-neutral-500 sm:text-xs">
Kenalan dulu sebelum berangkat klik kartu untuk lihat profil.
</p>
{confirmedCount === 0 ? (
<p className="text-xs text-neutral-400 sm:text-sm">
Belum ada peserta yang dikonfirmasi.{" "}
@@ -459,24 +480,63 @@ export default async function TripDetailPage({
: "Jadilah yang pertama mendaftar! 🎒"}
</p>
) : (
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{confirmedParticipants.map((p) => (
<ul className="grid gap-2 sm:grid-cols-2">
{confirmedParticipants.map((p) => {
const interests = p.user.profile?.interests ?? [];
const city = p.user.profile?.city;
return (
<li key={p.id}>
<Link
key={p.id}
href={`/u/${p.user.id}`}
className="flex items-center gap-1.5 rounded-full bg-neutral-100 px-2.5 py-1 transition-colors hover:bg-primary-100 sm:gap-2 sm:px-3 sm:py-1.5"
className="flex items-start gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 transition-colors hover:border-primary-300 hover:bg-primary-50/30"
>
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary-600 text-[9px] font-bold text-white sm:h-6 sm:w-6 sm:text-[10px]">
{p.user.image ? (
<Image
src={p.user.image}
alt=""
width={40}
height={40}
className="h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
{p.user.name.charAt(0).toUpperCase()}
</div>
<span className="text-xs font-medium text-neutral-700 sm:text-sm">
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-800">
{p.user.name}
</p>
{city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {city}
</p>
)}
{interests.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{interests.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded-full bg-secondary-50 px-1.5 py-0.5 text-[10px] font-medium text-secondary-700"
>
#{tag}
</span>
</Link>
))}
{interests.length > 3 && (
<span className="text-[10px] text-neutral-400">
+{interests.length - 3}
</span>
)}
</div>
)}
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
</div>
+33 -2
View File
@@ -1,11 +1,21 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
import { profileRepo } from "@/server/repositories/profile.repo";
import { TripCard } from "@/features/trip/components/trip-card";
import { TripFilter } from "@/features/trip/components/trip-filter";
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";
const GROUP_SIZES: GroupSize[] = ["SMALL", "MEDIUM", "LARGE"];
function isGroupSize(value: unknown): value is GroupSize {
return typeof value === "string" && (GROUP_SIZES as string[]).includes(value);
}
interface TripsPageProps {
searchParams: Promise<{
@@ -13,6 +23,8 @@ interface TripsPageProps {
from?: string;
to?: string;
category?: string;
vibe?: string;
groupSize?: string;
}>;
}
@@ -44,19 +56,30 @@ export async function generateMetadata({
export default async function TripsPage({ searchParams }: TripsPageProps) {
const params = await searchParams;
const category = isActivityCategory(params.category) ? params.category : undefined;
const hasFilters = Boolean(params.q || params.from || params.to || category);
const vibe = isVibe(params.vibe) ? params.vibe : undefined;
const groupSize = isGroupSize(params.groupSize) ? params.groupSize : undefined;
const hasFilters = Boolean(
params.q || params.from || params.to || category || vibe || groupSize
);
const filters = {
q: params.q,
from: params.from,
to: params.to,
category,
vibe,
groupSize,
};
const [trips, allTrips] = await Promise.all([
const session = await getServerSession(authOptions);
const [trips, allTrips, viewerProfile] = await Promise.all([
tripService.getOpenTrips(filters),
hasFilters ? tripService.getOpenTrips() : null,
session?.user?.id
? profileRepo.findByUserId(session.user.id)
: Promise.resolve(null),
]);
const totalCount = hasFilters ? allTrips!.length : trips.length;
const viewerInterests = viewerProfile?.interests ?? [];
return (
<div className="mx-auto max-w-6xl px-4 py-6 sm:py-8">
@@ -120,6 +143,7 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
id={trip.id}
title={trip.title}
category={trip.category}
vibe={trip.vibe}
destination={trip.destination}
location={trip.location}
date={trip.date}
@@ -133,6 +157,13 @@ export default async function TripsPage({ searchParams }: TripsPageProps) {
isVerifiedOrganizer={
trip.organizer.organizerVerification?.status === "APPROVED"
}
participants={trip.participants.map((p) => ({
id: p.id,
name: p.user.name,
image: p.user.image,
interests: p.user.profile?.interests ?? [],
}))}
viewerInterests={viewerInterests}
/>
))}
</div>
+14
View File
@@ -6,6 +6,7 @@ import { profileService } from "@/server/services/profile.service";
import { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
import { siteConfig } from "@/lib/site";
import { vibeMeta } from "@/lib/vibe";
interface PageProps {
params: Promise<{ id: string }>;
@@ -94,6 +95,18 @@ export default async function PublicProfilePage({ params }: PageProps) {
</p>
)}
{profile?.vibe && (
<div className="mt-3">
<span
className="inline-flex items-center gap-1.5 rounded-full bg-primary-50 px-2.5 py-1 text-xs font-semibold text-primary-700"
title={vibeMeta(profile.vibe).description}
>
<span aria-hidden>{vibeMeta(profile.vibe).icon}</span>
<span>Vibe: {vibeMeta(profile.vibe).label}</span>
</span>
</div>
)}
{profile?.interests && profile.interests.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{profile.interests.map((tag) => (
@@ -164,6 +177,7 @@ export default async function PublicProfilePage({ params }: PageProps) {
id={trip.id}
title={trip.title}
category={trip.category}
vibe={trip.vibe}
destination={trip.destination}
location={trip.location}
date={trip.date}
+13
View File
@@ -35,6 +35,12 @@ export function Navbar() {
>
Open Trip
</Link>
<Link
href="/people"
className="rounded-lg px-3 py-1.5 text-sm font-medium text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-800"
>
Cari Teman
</Link>
{session?.user ? (
<>
@@ -125,6 +131,13 @@ export function Navbar() {
>
Open Trip
</Link>
<Link
href="/people"
onClick={() => setMenuOpen(false)}
className="rounded-lg px-3 py-2.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
>
Cari Teman
</Link>
{session?.user ? (
<>
@@ -0,0 +1,41 @@
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { profileRepo } from "@/server/repositories/profile.repo";
/**
* Server component banner: muncul di atas semua halaman ketika user sudah login
* tapi profil sosialnya kosong. Menjaga janji "kenalan dulu, gabung kemudian"
* dengan mendorong user mengisi minat/kota sebelum join trip.
*/
export async function ProfileNudgeBanner() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) return null;
const profile = await profileRepo.findByUserId(session.user.id);
const hasMeaningfulProfile =
!!profile &&
(!!profile.bio?.trim() ||
!!profile.city?.trim() ||
profile.interests.length > 0);
if (hasMeaningfulProfile) return null;
return (
<div className="border-b border-amber-200 bg-amber-50">
<div className="mx-auto flex max-w-6xl flex-col items-start gap-2 px-4 py-2.5 text-xs sm:flex-row sm:items-center sm:justify-between sm:text-sm">
<p className="text-amber-900">
<span className="font-semibold">Lengkapi profil sosial kamu</span>
bio, kota, dan minat. Calon teman trip akan lebih mudah kenal kamu
sebelum gabung bareng.
</p>
<Link
href="/profile"
className="shrink-0 rounded-lg bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-amber-700"
>
Isi profil sekarang
</Link>
</div>
</div>
);
}
-136
View File
@@ -1,136 +0,0 @@
# Deploy Setrip dengan PM2
Panduan ini untuk menjalankan aplikasi **Next.js** (setrip) di server Linux/VPS menggunakan **PM2**. Pastikan **PostgreSQL** sudah tersedia dan URL-nya sesuai dengan variabel lingkungan aplikasi.
## Prasyarat
- Node.js **20.x** (disarankan, selaras dengan `@types/node` di proyek)
- npm atau pnpm/yarn (contoh di bawah memakai **npm**)
- PM2 terpasang global: `npm install -g pm2`
- Basis data PostgreSQL dan file `.env` di server (lihat bagian Lingkungan)
## File PM2
Konfigurasi PM2 ada di root repositori: **`ecosystem.config.js`** (nama ini disengaja).
### Jangan `pm2 start ecosystem.js` kecuali itu skrip Node
Jika Anda menjalankan `pm2 start ecosystem.js` pada file yang isinya hanya `module.exports = { apps: [...] }`, PM2 menganggapnya **skrip aplikasi biasa** dan menjalankannya dengan `node ecosystem.js`. Akibatnya:
- Nama proses di daftar PM2 jadi **`ecosystem`** (bukan `setrip`).
- Next.js **tidak** dijalankan lewat entri `apps` Anda.
Gunakan selalu:
```bash
pm2 start ecosystem.config.js --env production
```
Isi file menjalankan biner Next (`next start`) setelah build, mode **fork**, satu proses, **PORT** **3090**. Ubah `PORT` di file tersebut jika kebijakan port berubah.
### Berapa port yang dibutuhkan?
Untuk **trafik HTTP/HTTPS ke aplikasi Next.js**, cukup **satu port** yang didengarkan oleh `next start` — di setup ini **3090** (atau satu port lain yang Anda set).
**PostgreSQL** memakai port tersendiri (biasanya **5432**) di mesin tempat database berjalan. Itu bukan “port kedua untuk publik” dari aplikasi web: koneksi DB terjadi dari server aplikasi ke database (localhost atau jaringan internal). Di firewall publik Anda biasanya hanya membuka **80/443** (reverse proxy) atau **3090** jika diakses langsung tanpa proxy.
## Langkah deploy (pertama kali)
1. **Clone** repositori ke server (misalnya `/var/www/setrip`).
2. **Masuk** ke folder proyek dan pasang dependensi produksi:
```bash
cd /var/www/setrip
npm ci
```
3. **Lingkungan** — salin atau buat `.env` / `.env.production` di server (jangan commit rahasia ke git). Minimal sesuai kebutuhan aplikasi Anda, contoh:
- `DATABASE_URL` — koneksi PostgreSQL
- `NEXTAUTH_SECRET` — string acak yang kuat
- `NEXTAUTH_URL` — URL publik aplikasi (harus cocok dengan yang dibuka browser), misalnya `https://domain-anda.com` atau `http://host:3090` jika tanpa HTTPS dan akses langsung ke port tersebut
4. **Prisma** — generate client dan terapkan migrasi (jika memakai migrasi):
```bash
npx prisma generate
npx prisma migrate deploy
```
5. **Build** Next.js:
```bash
npm run build
```
6. **Mulai** dengan PM2:
```bash
pm2 start ecosystem.config.js --env production
```
Tanpa `--env production` tetap jalan; variabel default memakai blok `env` di file.
7. **Simpan** daftar proses agar bangkit lagi setelah reboot:
```bash
pm2 save
pm2 startup
```
Ikuti perintah yang dikeluarkan PM2 (biasanya menyalin satu baris `sudo env ...`).
## Perintah PM2 yang sering dipakai
| Perintah | Keterangan |
|----------|------------|
| `pm2 status` | Status semua aplikasi |
| `pm2 logs setrip` | Log aplikasi bernama `setrip` |
| `pm2 reload setrip` | Reload tanpa downtime (berguna setelah deploy baru) |
| `pm2 restart setrip` | Restart proses |
| `pm2 stop setrip` | Menghentikan aplikasi |
| `pm2 delete setrip` | Menghapus aplikasi dari daftar PM2 |
## Deploy ulang (update kode)
Di server, setelah `git pull` (atau salin artefak baru):
```bash
cd /var/www/setrip
npm ci
npx prisma generate
npx prisma migrate deploy
npm run build
pm2 reload setrip
```
Jika nama aplikasi di PM2 berbeda, ganti `setrip` dengan nama di `ecosystem.config.js` (`name`).
### Hapus proses PM2 yang salah (nama `ecosystem`)
Jika Anda pernah menjalankan `pm2 start ecosystem.js` dan muncul proses bernama `ecosystem`:
```bash
pm2 stop ecosystem
pm2 delete ecosystem
```
Atau pakai id dari `pm2 status` (contoh id `9`):
```bash
pm2 stop 9
pm2 delete 9
```
Lalu mulai lagi dengan `pm2 start ecosystem.config.js --env production` dan `pm2 save`.
## Reverse proxy (opsional)
Agar bisa HTTPS dan port 80/443, letakkan **Nginx** (atau Caddy) di depan aplikasi yang mendengarkan di `127.0.0.1:3090`. Pastikan `NEXTAUTH_URL` memakai skema dan host yang sama dengan yang diakses pengguna.
## Pemecahan masalah
- **502 / tidak terhubung** — cek `pm2 logs setrip`, pastikan PostgreSQL dapat dijangkau dari server, dan `PORT` tidak bentrok dengan layanan lain.
- **Error Prisma** — pastikan `npx prisma generate` dijalankan setelah `npm ci` di setiap deploy, dan `DATABASE_URL` benar.
- **NextAuth** — `NEXTAUTH_URL` harus persis URL publik (termasuk `https://`).
+1
View File
@@ -22,6 +22,7 @@ export async function updateProfileAction(formData: FormData) {
city: formData.get("city"),
instagram: formData.get("instagram"),
interests,
vibe: formData.get("vibe"),
};
const parsed = updateProfileSchema.safeParse(raw);
@@ -0,0 +1,153 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { VIBES, vibeMeta, isVibe } from "@/lib/vibe";
import type { Vibe } from "@/app/generated/prisma/enums";
export function PeopleFilter() {
const router = useRouter();
const searchParams = useSearchParams();
const initialVibe = searchParams.get("vibe");
const [vibe, setVibe] = useState<Vibe | null>(
isVibe(initialVibe) ? initialVibe : null
);
const [city, setCity] = useState(searchParams.get("city") ?? "");
const [interest, setInterest] = useState(searchParams.get("interest") ?? "");
function buildParams(overrides?: { vibe?: Vibe | null }) {
const params = new URLSearchParams();
const nextVibe = overrides && "vibe" in overrides ? overrides.vibe : vibe;
if (nextVibe) params.set("vibe", nextVibe);
if (city.trim()) params.set("city", city.trim());
if (interest.trim()) params.set("interest", interest.trim().toLowerCase());
return params;
}
function pushFilters(params: URLSearchParams) {
const qs = params.toString();
router.push(`/people${qs ? `?${qs}` : ""}`);
}
function handleSelectVibe(next: Vibe | null) {
setVibe(next);
pushFilters(buildParams({ vibe: next }));
}
function handleSearch(e: React.FormEvent) {
e.preventDefault();
pushFilters(buildParams());
}
function handleReset() {
setVibe(null);
setCity("");
setInterest("");
router.push("/people");
}
const hasFilters = vibe || city.trim() || interest.trim();
return (
<form
onSubmit={handleSearch}
className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5"
>
<div className="mb-4">
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
Vibe
</label>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => handleSelectVibe(null)}
aria-pressed={vibe === null}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
vibe === null
? "border-primary-600 bg-primary-600 text-white"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
Semua
</button>
{VIBES.map((v) => {
const m = vibeMeta(v);
const active = vibe === v;
return (
<button
key={v}
type="button"
onClick={() => handleSelectVibe(v)}
aria-pressed={active}
title={m.description}
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
active
? "border-primary-600 bg-primary-600 text-white"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
<span aria-hidden>{m.icon}</span>
<span>{m.label}</span>
</button>
);
})}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1">
<label
htmlFor="people-city"
className="mb-1.5 block text-xs font-medium text-neutral-500"
>
Kota
</label>
<input
id="people-city"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Bandung, Jakarta, ..."
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"
/>
</div>
<div className="flex-1">
<label
htmlFor="people-interest"
className="mb-1.5 block text-xs font-medium text-neutral-500"
>
Minat
</label>
<input
id="people-interest"
type="text"
value={interest}
onChange={(e) => setInterest(e.target.value)}
placeholder="hiking, fotografi, yoga..."
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"
/>
</div>
<div className="flex gap-2 sm:shrink-0">
<button
type="submit"
className="flex-1 rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white shadow-md shadow-primary-600/20 transition-colors hover:bg-primary-700 sm:flex-none"
>
Cari
</button>
{hasFilters && (
<button
type="button"
onClick={handleReset}
className="rounded-xl border border-neutral-200 px-3 py-2.5 text-sm font-medium text-neutral-500 transition-colors hover:bg-neutral-50 hover:text-neutral-700"
>
Reset
</button>
)}
</div>
</div>
</form>
);
}
@@ -4,6 +4,8 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateProfileAction } from "@/features/profile/actions";
import { LIMITS } from "@/lib/limits";
import { VIBES, vibeMeta } from "@/lib/vibe";
import type { Vibe } from "@/app/generated/prisma/enums";
interface ProfileEditorProps {
userId: string;
@@ -12,6 +14,7 @@ interface ProfileEditorProps {
city: string | null;
interests: string[];
instagram: string | null;
vibe: Vibe | null;
} | null;
}
@@ -27,6 +30,7 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
const [instagram, setInstagram] = useState(initial?.instagram ?? "");
const [interests, setInterests] = useState<string[]>(initial?.interests ?? []);
const [interestDraft, setInterestDraft] = useState("");
const [vibe, setVibe] = useState<Vibe | null>(initial?.vibe ?? null);
function addInterest() {
const v = interestDraft.trim().toLowerCase();
@@ -65,6 +69,7 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
if (bio.trim()) formData.set("bio", bio.trim());
if (city.trim()) formData.set("city", city.trim());
if (instagram.trim()) formData.set("instagram", instagram.trim());
if (vibe) formData.set("vibe", vibe);
interests.forEach((t) => formData.append("interests", t));
const result = await updateProfileAction(formData);
@@ -257,6 +262,56 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
Vibe jalanmu{" "}
<span className="text-xs font-normal text-neutral-400">(opsional)</span>
</label>
<p className="mb-2 text-[11px] text-neutral-500">
Bantu calon teman trip nyambung dengan ritme kamu.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setVibe(null)}
aria-pressed={vibe === null}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
vibe === null
? "border-neutral-700 bg-neutral-800 text-white"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
Belum diisi
</button>
{VIBES.map((v) => {
const m = vibeMeta(v);
const active = vibe === v;
return (
<button
key={v}
type="button"
onClick={() => setVibe(v)}
aria-pressed={active}
title={m.description}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
active
? "border-primary-600 bg-primary-600 text-white"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
<span aria-hidden>{m.icon}</span>
<span>{m.label}</span>
</button>
);
})}
</div>
{vibe && (
<p className="mt-2 text-[11px] italic text-neutral-500">
{vibeMeta(vibe).description}
</p>
)}
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
+102
View File
@@ -0,0 +1,102 @@
import Image from "next/image";
import Link from "next/link";
import { vibeMeta } from "@/lib/vibe";
import type { Vibe } from "@/app/generated/prisma/enums";
interface UserCardProps {
id: string;
name: string;
image: string | null;
isVerifiedOrganizer: boolean;
profile: {
bio: string | null;
city: string | null;
interests: string[];
vibe: Vibe | null;
} | null;
}
export function UserCard({
id,
name,
image,
isVerifiedOrganizer,
profile,
}: UserCardProps) {
const interests = profile?.interests ?? [];
return (
<Link
href={`/u/${id}`}
className="group flex h-full flex-col rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-md"
>
<div className="flex items-start gap-3">
{image ? (
<Image
src={image}
alt=""
width={56}
height={56}
className="h-14 w-14 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-primary-600 text-lg font-bold text-white">
{name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-neutral-800 group-hover:text-primary-700">
{name}
</p>
{profile?.city && (
<p className="truncate text-[11px] text-neutral-500 sm:text-xs">
📍 {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"
title="Organizer terverifikasi"
>
Organizer
</span>
)}
{profile?.vibe && (
<span
className="inline-flex items-center gap-0.5 rounded-full bg-secondary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-secondary-800"
title={vibeMeta(profile.vibe).description}
>
<span aria-hidden>{vibeMeta(profile.vibe).icon}</span>
<span>{vibeMeta(profile.vibe).label}</span>
</span>
)}
</div>
</div>
</div>
{profile?.bio && (
<p className="mt-3 line-clamp-2 text-xs leading-relaxed text-neutral-600">
{profile.bio}
</p>
)}
{interests.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{interests.slice(0, 5).map((tag) => (
<span
key={tag}
className="rounded-full bg-secondary-50 px-2 py-0.5 text-[10px] font-medium text-secondary-700"
>
#{tag}
</span>
))}
{interests.length > 5 && (
<span className="text-[10px] text-neutral-400">
+{interests.length - 5}
</span>
)}
</div>
)}
</Link>
);
}
+9
View File
@@ -1,5 +1,6 @@
import { z } from "zod/v4";
import { LIMITS } from "@/lib/limits";
import { VIBES } from "@/lib/vibe";
const optionalTrimmed = (max: number, label: string) =>
z.preprocess(
@@ -52,6 +53,14 @@ export const updateProfileSchema = z.object({
`Maksimal ${LIMITS.MAX_PROFILE_INTERESTS_COUNT} minat`
)
.default([]),
vibe: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z.enum([...VIBES]).optional()
),
});
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
+1
View File
@@ -28,6 +28,7 @@ export async function createTripAction(formData: FormData) {
endDate: (formData.get("endDate") as string) || undefined,
maxParticipants: formData.get("maxParticipants") as string,
price: formData.get("price") as string,
vibe: formData.get("vibe"),
};
const result = createTripSchema.safeParse(raw);
+55 -1
View File
@@ -11,7 +11,8 @@ import {
ACTIVITY_CATEGORIES,
categoryMeta,
} from "@/lib/activity-category";
import type { ActivityCategory } from "@/app/generated/prisma/enums";
import { VIBES, vibeMeta } from "@/lib/vibe";
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
function formatRupiahInput(value: string): string {
const num = value.replace(/\D/g, "");
@@ -32,6 +33,7 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
const [loading, setLoading] = useState(false);
const [category, setCategory] = useState<ActivityCategory>("HIKING");
const [vibe, setVibe] = useState<Vibe | null>(null);
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);
const [priceDisplay, setPriceDisplay] = useState("");
@@ -62,6 +64,7 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
}
}
formData.set("price", parseRupiahInput(priceDisplay));
if (vibe) formData.set("vibe", vibe);
const result = await createTripAction(formData);
@@ -124,6 +127,57 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
<input type="hidden" name="category" value={category} />
</div>
{/* Vibe Chips */}
<div className="rounded-xl bg-secondary-50 p-4">
<label className="mb-1 block text-sm font-bold text-secondary-900">
Vibe Trip{" "}
<span className="text-xs font-normal text-secondary-700">(opsional)</span>
</label>
<p className="mb-2 text-[11px] text-secondary-700/80">
Bantu calon peserta menilai apakah ritmenya cocok dengan mereka.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setVibe(null)}
aria-pressed={vibe === null}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
vibe === null
? "border-neutral-700 bg-neutral-800 text-white"
: "border-secondary-200 bg-white text-secondary-800 hover:bg-secondary-100"
}`}
>
Belum diisi
</button>
{VIBES.map((v) => {
const m = vibeMeta(v);
const active = v === vibe;
return (
<button
key={v}
type="button"
onClick={() => setVibe(v)}
aria-pressed={active}
title={m.description}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
active
? "border-secondary-600 bg-secondary-600 text-white"
: "border-secondary-200 bg-white text-secondary-800 hover:bg-secondary-100"
}`}
>
<span aria-hidden>{m.icon}</span>
<span>{m.label}</span>
</button>
);
})}
</div>
{vibe && (
<p className="mt-2 text-[11px] italic text-secondary-700/80">
{vibeMeta(vibe).description}
</p>
)}
</div>
<div>
<label htmlFor="title" className="mb-1.5 block text-sm font-semibold text-neutral-700">
Judul Trip
+84 -2
View File
@@ -3,12 +3,21 @@ import Link from "next/link";
import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { categoryMeta } from "@/lib/activity-category";
import type { ActivityCategory } from "@/app/generated/prisma/enums";
import { vibeMeta } from "@/lib/vibe";
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
interface TripCardParticipant {
id: string;
name: string;
image: string | null;
interests: string[];
}
interface TripCardProps {
id: string;
title: string;
category: ActivityCategory;
vibe?: Vibe | null;
destination: string;
location: string;
date: Date | string;
@@ -21,12 +30,17 @@ interface TripCardProps {
coverImage?: string | null;
priority?: boolean;
isVerifiedOrganizer?: boolean;
/** Daftar peserta CONFIRMED (subset, untuk preview avatar). Optional. */
participants?: TripCardParticipant[];
/** Interests user yang sedang melihat — untuk hitung overlap. Optional. */
viewerInterests?: string[];
}
export function TripCard({
id,
title,
category,
vibe,
destination,
location,
date,
@@ -39,10 +53,25 @@ export function TripCard({
coverImage,
priority,
isVerifiedOrganizer,
participants,
viewerInterests,
}: TripCardProps) {
const spotsLeft = maxParticipants - participantCount;
const isSmallGroup = maxParticipants <= 10;
const meta = categoryMeta(category);
const vMeta = vibe ? vibeMeta(vibe) : null;
const previewParticipants = participants?.slice(0, 3) ?? [];
const moreCount =
participants && participants.length > 3 ? participants.length - 3 : 0;
let overlapCount = 0;
if (viewerInterests && viewerInterests.length > 0 && participants) {
const viewerSet = new Set(viewerInterests.map((i) => i.toLowerCase()));
overlapCount = participants.filter((p) =>
p.interests.some((tag) => viewerSet.has(tag.toLowerCase()))
).length;
}
return (
<Link href={`/trips/${id}`} className="group block">
@@ -63,13 +92,24 @@ export function TripCard({
<span className="text-4xl">{meta.icon}</span>
</div>
)}
<div className="absolute left-3 top-3 flex flex-wrap gap-1">
<span
className="absolute left-3 top-3 inline-flex items-center gap-1 rounded-full bg-white/90 px-2 py-0.5 text-[11px] font-semibold text-neutral-700 shadow-sm backdrop-blur-sm"
className="inline-flex items-center gap-1 rounded-full bg-white/90 px-2 py-0.5 text-[11px] font-semibold text-neutral-700 shadow-sm backdrop-blur-sm"
title={`Kategori: ${meta.label}`}
>
<span>{meta.icon}</span>
<span>{meta.label}</span>
</span>
{vMeta && (
<span
className="inline-flex items-center gap-1 rounded-full bg-secondary-600/90 px-2 py-0.5 text-[11px] font-semibold text-white shadow-sm backdrop-blur-sm"
title={`Vibe: ${vMeta.label}${vMeta.description}`}
>
<span aria-hidden>{vMeta.icon}</span>
<span>{vMeta.label}</span>
</span>
)}
</div>
<span
className={`absolute right-3 top-3 rounded-full px-2.5 py-0.5 text-xs font-bold backdrop-blur-sm ${
status === "OPEN"
@@ -120,6 +160,48 @@ export function TripCard({
</div>
</div>
{(previewParticipants.length > 0 || overlapCount > 0) && (
<div className="mt-3 flex items-center gap-2 border-t border-neutral-100 pt-3">
{previewParticipants.length > 0 && (
<div className="flex -space-x-2">
{previewParticipants.map((p) =>
p.image ? (
<Image
key={p.id}
src={p.image}
alt=""
width={24}
height={24}
className="h-6 w-6 rounded-full border-2 border-white object-cover"
/>
) : (
<div
key={p.id}
className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-white bg-primary-600 text-[10px] font-bold text-white"
title={p.name}
>
{p.name.charAt(0).toUpperCase()}
</div>
)
)}
{moreCount > 0 && (
<div className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-white bg-neutral-200 text-[10px] font-bold text-neutral-600">
+{moreCount}
</div>
)}
</div>
)}
{overlapCount > 0 && (
<span
className="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
</span>
)}
</div>
)}
<div className="mt-3 flex items-center justify-between border-t border-neutral-100 pt-3">
<span className="text-lg font-bold text-primary-600">
{formatRupiah(price)}
+131 -3
View File
@@ -10,16 +10,38 @@ import {
categoryMeta,
isActivityCategory,
} from "@/lib/activity-category";
import type { ActivityCategory } from "@/app/generated/prisma/enums";
import { VIBES, vibeMeta, isVibe } from "@/lib/vibe";
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
type GroupSize = "SMALL" | "MEDIUM" | "LARGE";
const GROUP_SIZES: { value: GroupSize; label: string; hint: string }[] = [
{ value: "SMALL", label: "Small", hint: "≤10 — paling akrab" },
{ value: "MEDIUM", label: "Medium", hint: "1120" },
{ value: "LARGE", label: "Large", hint: "21+" },
];
function isGroupSize(value: unknown): value is GroupSize {
return (
typeof value === "string" &&
GROUP_SIZES.some((g) => g.value === value)
);
}
export function TripFilter() {
const router = useRouter();
const searchParams = useSearchParams();
const initialCategory = searchParams.get("category");
const initialVibe = searchParams.get("vibe");
const initialGroup = searchParams.get("groupSize");
const [category, setCategory] = useState<ActivityCategory | null>(
isActivityCategory(initialCategory) ? initialCategory : null
);
const [vibe, setVibe] = useState<Vibe | null>(
isVibe(initialVibe) ? initialVibe : null
);
const [groupSize, setGroupSize] = useState<GroupSize | null>(
isGroupSize(initialGroup) ? initialGroup : null
);
const [query, setQuery] = useState(searchParams.get("q") ?? "");
const [startDate, setStartDate] = useState<Date | null>(
searchParams.get("from") ? new Date(searchParams.get("from")!) : null
@@ -28,11 +50,20 @@ export function TripFilter() {
searchParams.get("to") ? new Date(searchParams.get("to")!) : null
);
function buildParams(overrides?: { category?: ActivityCategory | null }) {
function buildParams(overrides?: {
category?: ActivityCategory | null;
vibe?: Vibe | null;
groupSize?: GroupSize | null;
}) {
const params = new URLSearchParams();
const nextCategory =
overrides && "category" in overrides ? overrides.category : category;
const nextVibe = overrides && "vibe" in overrides ? overrides.vibe : vibe;
const nextGroup =
overrides && "groupSize" in overrides ? overrides.groupSize : groupSize;
if (nextCategory) params.set("category", nextCategory);
if (nextVibe) params.set("vibe", nextVibe);
if (nextGroup) params.set("groupSize", nextGroup);
if (query.trim()) params.set("q", query.trim());
if (startDate) params.set("from", formatLocalCalendarYmd(startDate));
if (endDate) params.set("to", formatLocalCalendarYmd(endDate));
@@ -49,6 +80,16 @@ export function TripFilter() {
pushFilters(buildParams({ category: next }));
}
function handleSelectVibe(next: Vibe | null) {
setVibe(next);
pushFilters(buildParams({ vibe: next }));
}
function handleSelectGroupSize(next: GroupSize | null) {
setGroupSize(next);
pushFilters(buildParams({ groupSize: next }));
}
function handleDateChange(dates: [Date | null, Date | null]) {
const [start, end] = dates;
setStartDate(start);
@@ -57,6 +98,8 @@ export function TripFilter() {
if (!start && !end) {
const params = new URLSearchParams();
if (category) params.set("category", category);
if (vibe) params.set("vibe", vibe);
if (groupSize) params.set("groupSize", groupSize);
if (query.trim()) params.set("q", query.trim());
pushFilters(params);
}
@@ -69,13 +112,15 @@ export function TripFilter() {
function handleReset() {
setCategory(null);
setVibe(null);
setGroupSize(null);
setQuery("");
setStartDate(null);
setEndDate(null);
router.push("/trips");
}
const hasFilters = category || query || startDate || endDate;
const hasFilters = category || vibe || groupSize || query || startDate || endDate;
return (
<form
@@ -123,6 +168,89 @@ export function TripFilter() {
</div>
</div>
{/* Vibe & Ukuran grup */}
<div className="mb-4 grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
Vibe
</label>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => handleSelectVibe(null)}
aria-pressed={vibe === null}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
vibe === null
? "border-secondary-600 bg-secondary-600 text-white"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
Semua
</button>
{VIBES.map((v) => {
const m = vibeMeta(v);
const active = vibe === v;
return (
<button
key={v}
type="button"
onClick={() => handleSelectVibe(v)}
aria-pressed={active}
title={m.description}
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
active
? "border-secondary-600 bg-secondary-600 text-white"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
<span aria-hidden>{m.icon}</span>
<span>{m.label}</span>
</button>
);
})}
</div>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
Ukuran grup
</label>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => handleSelectGroupSize(null)}
aria-pressed={groupSize === null}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
groupSize === null
? "border-primary-600 bg-primary-600 text-white"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
Semua
</button>
{GROUP_SIZES.map((g) => {
const active = groupSize === g.value;
return (
<button
key={g.value}
type="button"
onClick={() => handleSelectGroupSize(g.value)}
aria-pressed={active}
title={g.hint}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
active
? "border-primary-600 bg-primary-600 text-white"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
{g.label}
</button>
);
})}
</div>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-3">
{/* Search input */}
<div className="flex-1">
+9
View File
@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { LIMITS } from "@/lib/limits";
import { ACTIVITY_CATEGORIES } from "@/lib/activity-category";
import { VIBES } from "@/lib/vibe";
import type { ActivityCategory } from "@/app/generated/prisma/enums";
import {
isTripDepartureDayPast,
@@ -146,6 +147,14 @@ export const createTripSchema = z
)
.optional()
),
vibe: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z.enum([...VIBES]).optional()
),
})
.superRefine((data, ctx) => {
const dep = tripStoredInstantFromYmd(data.date);
+41
View File
@@ -0,0 +1,41 @@
import type { Vibe } from "@/app/generated/prisma/enums";
export const VIBES = ["CHILL", "BALANCED", "HARDCORE"] as const satisfies readonly Vibe[];
interface VibeMeta {
label: string;
icon: string;
description: string;
}
const META: Record<Vibe, VibeMeta> = {
CHILL: {
label: "Chill",
icon: "😌",
description: "Santai, nikmati prosesnya, no rush.",
},
BALANCED: {
label: "Balanced",
icon: "🙂",
description: "Seimbang — ada effort, ada healing.",
},
HARDCORE: {
label: "Hardcore",
icon: "🔥",
description: "Push limit, target tercapai, full energy.",
},
};
export function vibeMeta(vibe: Vibe): VibeMeta {
return META[vibe];
}
export function vibeLabel(vibe: Vibe): string {
return META[vibe].label;
}
export function isVibe(value: unknown): value is Vibe {
return (
typeof value === "string" && (VIBES as readonly string[]).includes(value)
);
}
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "Vibe" AS ENUM ('CHILL', 'BALANCED', 'HARDCORE');
-- AlterTable
ALTER TABLE "UserProfile" ADD COLUMN "vibe" "Vibe";
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Trip" ADD COLUMN "vibe" "Vibe";
-- CreateIndex
CREATE INDEX "Trip_vibe_status_date_idx" ON "Trip"("vibe", "status", "date");
+12 -1
View File
@@ -35,7 +35,7 @@ model User {
}
/// Profil sosial publik. Berisi info yang user pilih untuk dibagikan ke peserta lain
/// (bio, kota, minat). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification.
/// (bio, kota, minat, vibe). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification.
model UserProfile {
id String @id @default(cuid())
userId String @unique
@@ -49,11 +49,19 @@ model UserProfile {
interests String[] @default([])
/// Username Instagram (tanpa @, opsional)
instagram String?
/// Gaya jalan / energi user — dipakai untuk matching teman dengan ritme serupa.
vibe Vibe?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum Vibe {
CHILL
BALANCED
HARDCORE
}
/// Tabel link akun OAuth pihak ketiga (Google, dst). Diisi oleh PrismaAdapter NextAuth.
/// Session tidak pakai DB — kita pakai JWT, jadi Session/VerificationToken tidak perlu.
model Account {
@@ -136,6 +144,8 @@ model Trip {
endDate DateTime?
maxParticipants Int
price Int
/// Ritme/energi trip — dipakai untuk matching dengan vibe user.
vibe Vibe?
status TripStatus @default(OPEN)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -148,6 +158,7 @@ model Trip {
reviews TripReview[]
@@index([category, status, date])
@@index([vibe, status, date])
}
model TripReview {
+4
View File
@@ -1,10 +1,12 @@
import { prisma } from "@/lib/prisma";
import type { Vibe } from "@/app/generated/prisma/enums";
interface UpsertProfileInput {
bio?: string;
city?: string;
instagram?: string;
interests: string[];
vibe?: Vibe;
}
export const profileRepo = {
@@ -21,12 +23,14 @@ export const profileRepo = {
city: data.city,
instagram: data.instagram,
interests: data.interests,
vibe: data.vibe,
},
update: {
bio: data.bio,
city: data.city,
instagram: data.instagram,
interests: data.interests,
vibe: data.vibe,
},
});
},
+43 -2
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/prisma";
import { Prisma } from "@/app/generated/prisma/client";
import type { ActivityCategory } from "@/app/generated/prisma/enums";
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
import {
utcStartOfDay,
utcDayStartFromYmd,
@@ -8,11 +8,15 @@ import {
maxUtcDate,
} from "@/lib/trip-dates";
export type GroupSize = "SMALL" | "MEDIUM" | "LARGE";
export interface TripFilters {
q?: string;
from?: string;
to?: string;
category?: ActivityCategory;
vibe?: Vibe;
groupSize?: GroupSize;
}
export const tripRepo = {
@@ -47,6 +51,18 @@ export const tripRepo = {
andParts.push({ category: filters.category });
}
if (filters?.vibe) {
andParts.push({ vibe: filters.vibe });
}
if (filters?.groupSize === "SMALL") {
andParts.push({ maxParticipants: { lte: 10 } });
} else if (filters?.groupSize === "MEDIUM") {
andParts.push({ maxParticipants: { gte: 11, lte: 20 } });
} else if (filters?.groupSize === "LARGE") {
andParts.push({ maxParticipants: { gte: 21 } });
}
if (!filters?.from && !filters?.to) {
andParts.push({ date: { gte: todayStart } });
} else {
@@ -104,6 +120,22 @@ export const tripRepo = {
},
},
images: { orderBy: { order: "asc" }, take: 1 },
participants: {
where: { status: "CONFIRMED" },
take: 10,
orderBy: { createdAt: "asc" },
select: {
id: true,
user: {
select: {
id: true,
name: true,
image: true,
profile: { select: { interests: true } },
},
},
},
},
_count: {
select: {
participants: { where: { status: { not: "CANCELLED" } } },
@@ -129,7 +161,16 @@ export const tripRepo = {
},
images: { orderBy: { order: "asc" } },
participants: {
include: { user: { select: { id: true, name: true, image: true } } },
include: {
user: {
select: {
id: true,
name: true,
image: true,
profile: { select: { city: true, interests: true } },
},
},
},
},
reviews: {
orderBy: { createdAt: "desc" },
+58
View File
@@ -1,5 +1,12 @@
import { prisma } from "@/lib/prisma";
import { Prisma } from "@/app/generated/prisma/client";
import type { Vibe } from "@/app/generated/prisma/enums";
export interface PeopleFilters {
city?: string;
interest?: string;
vibe?: Vibe;
}
export const userRepo = {
async findByEmail(email: string) {
@@ -42,6 +49,7 @@ export const userRepo = {
city: true,
interests: true,
instagram: true,
vibe: true,
},
},
organizerVerification: { select: { status: true } },
@@ -49,6 +57,56 @@ export const userRepo = {
});
},
/**
* Discovery /people: ambil user yang punya profil sosial terisi (minimal salah
* satu dari bio/city/interests/vibe). Filter optional by city/interest/vibe.
* Tidak ekspos email/KYC.
*/
async findPeople(filters?: PeopleFilters, limit = 60) {
const profileWhere: Prisma.UserProfileWhereInput = {
OR: [
{ bio: { not: null } },
{ city: { not: null } },
{ vibe: { not: null } },
{ interests: { isEmpty: false } },
],
};
if (filters?.city) {
profileWhere.city = {
contains: filters.city,
mode: "insensitive",
};
}
if (filters?.interest) {
profileWhere.interests = { has: filters.interest.toLowerCase() };
}
if (filters?.vibe) {
profileWhere.vibe = filters.vibe;
}
return prisma.user.findMany({
where: { profile: { is: profileWhere } },
select: {
id: true,
name: true,
image: true,
createdAt: true,
profile: {
select: {
bio: true,
city: true,
interests: true,
vibe: true,
},
},
organizerVerification: { select: { status: true } },
},
orderBy: { createdAt: "desc" },
take: limit,
});
},
async create(data: Prisma.UserCreateInput) {
return prisma.user.create({ data });
},
+6 -1
View File
@@ -1,4 +1,4 @@
import { userRepo } from "@/server/repositories/user.repo";
import { userRepo, type PeopleFilters } from "@/server/repositories/user.repo";
import { tripRepo } from "@/server/repositories/trip.repo";
import { participantRepo } from "@/server/repositories/participant.repo";
import { organizerRepo } from "@/server/repositories/organizer.repo";
@@ -11,12 +11,17 @@ export const profileService = {
return profileRepo.findByUserId(userId);
},
async findPeople(filters?: PeopleFilters) {
return userRepo.findPeople(filters);
},
async updateProfile(userId: string, input: UpdateProfileInput) {
return profileRepo.upsertByUserId(userId, {
bio: input.bio,
city: input.city,
instagram: input.instagram,
interests: input.interests,
vibe: input.vibe,
});
},
+3 -1
View File
@@ -1,5 +1,5 @@
import { Prisma } from "@/app/generated/prisma/client";
import type { ActivityCategory } from "@/app/generated/prisma/enums";
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo";
import { participantRepo } from "@/server/repositories/participant.repo";
@@ -31,6 +31,7 @@ interface CreateTripInput {
endDate?: Date;
maxParticipants: number;
price: number;
vibe?: Vibe;
organizerId: string;
imageUrls?: string[];
}
@@ -82,6 +83,7 @@ export const tripService = {
endDate: input.endDate,
maxParticipants: input.maxParticipants,
price: input.price,
vibe: input.vibe,
organizer: { connect: { id: input.organizerId } },
images,
} satisfies Prisma.TripCreateInput;