add user profile, profile vibe and trip vibe and social signal
This commit is contained in:
@@ -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 11–20 / 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".
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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,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,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'>
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
+79
-19
@@ -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,22 +480,61 @@ export default async function TripDetailPage({
|
||||
: "Jadilah yang pertama mendaftar! 🎒"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 sm:gap-2">
|
||||
{confirmedParticipants.map((p) => (
|
||||
<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"
|
||||
>
|
||||
<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.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-neutral-700 sm:text-sm">
|
||||
{p.user.name}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<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
|
||||
href={`/u/${p.user.id}`}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
))}
|
||||
{interests.length > 3 && (
|
||||
<span className="text-[10px] text-neutral-400">
|
||||
+{interests.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+33
-2
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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://`).
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
<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"
|
||||
title={`Kategori: ${meta.label}`}
|
||||
>
|
||||
<span>{meta.icon}</span>
|
||||
<span>{meta.label}</span>
|
||||
</span>
|
||||
<div className="absolute left-3 top-3 flex flex-wrap gap-1">
|
||||
<span
|
||||
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)}
|
||||
|
||||
@@ -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: "11–20" },
|
||||
{ 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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user