create review and profile

This commit is contained in:
arifal
2026-04-20 00:25:05 +07:00
parent 7159e9108f
commit ba5f64ae0e
37 changed files with 3324 additions and 109 deletions
+65 -24
View File
@@ -1,36 +1,77 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # SeTrip
## Getting Started Aplikasi open trip pendakian yang mempertemukan **organizer** (pembuat trip) dengan **peserta** yang ingin naik gunung bareng atau mencari teman trip.
First, run the development server: Stack: [Next.js](https://nextjs.org) (App Router), NextAuth, Prisma (PostgreSQL), Tailwind CSS.
## Alur aplikasi
### 1. Autentikasi
- Pengguna baru mendaftar di `/register` (nama, email, password disimpan di database).
- Login di `/login` melalui NextAuth; sesi dipakai di server action dan di halaman client (misalnya navbar, form buat trip).
Tanpa login, pengguna tetap bisa melihat daftar trip dan detail trip, tetapi tidak bisa membuat trip atau join.
### 2. Organizer: membuat trip
1. Setelah login, organizer membuka **Buat Trip** (`/create-trip`) dari navbar, halaman `/trips`, beranda, atau tombol mengambang (+).
2. Halaman form (`app/create-trip/page.tsx`) memvalidasi sesi di client; jika belum login, ditampilkan ajakan login.
3. Organizer mengisi judul, gunung, lokasi, deskripsi (opsional), rentang tanggal (DatePicker), maks peserta, harga (format Rupiah), dan URL gambar opsional (`ImageUrlInput`).
4. Submit memanggil server action `createTripAction` (`features/trip/actions.ts`):
- Memastikan ada sesi.
- Mem-parse dan memvalidasi input dengan Zod (`features/trip/schemas.ts`).
- `tripService.createTrip` menulis trip baru ke database lewat `tripRepo.create`, menghubungkan `organizerId` ke user yang login, dan menyimpan gambar jika ada.
5. Trip baru berstatus **OPEN** (default schema), lalu pengguna diarahkan ke detail trip `/trips/[id]`.
Organizer **tidak** bisa join trip sendiri; di detail trip tombol join diganti pesan bahwa user adalah organizer.
### 3. Peserta: mencari trip dan join
1. **Beranda** (`/`) dan **Open Trip** (`/trips`) menampilkan trip dengan status **OPEN** dan tanggal berangkat tidak di masa lalu (`tripService.getOpenTrips` + filter di repository).
2. Filter pencarian (`TripFilter`) mengirim query string; daftar trip disaring di server.
3. Dari kartu trip (`TripCard`), pengguna membuka **detail** `/trips/[id]` (`app/trips/[id]/page.tsx`):
- Peserta aktif = baris `TripParticipant` yang statusnya bukan `CANCELLED`.
- Slot tersisa dan progress bar memakai jumlah peserta aktif tersebut.
4. **Join** (`JoinTripButton` + `joinTripAction`):
- Jika belum login: tautan ke `/login`.
- Jika trip bukan `OPEN` dan user belum join: pendaftaran ditutup (kecuali user sudah terdaftar dan ingin membatalkan, mengikuti logika UI).
- `tripService.joinTrip` memeriksa: trip ada, status `OPEN`, bukan organizer, belum terdaftar aktif, kapasitas belum penuh; lalu menambah atau mengaktifkan kembali partisipasi (lihat bagian perbaikan bug di bawah).
5. Jika jumlah peserta aktif mencapai `maxParticipants`, status trip diperbarui menjadi **FULL**.
6. **Batal ikut** memanggil `cancelJoinAction``tripService.cancelJoin`: partisipasi ditandai `CANCELLED`; jika trip sebelumnya `FULL` dan setelah batal slot kosong lagi, status dikembalikan ke **OPEN**.
### 4. Ringkasan peran data
| Konsep | Penyimpanan |
|--------|-------------|
| Trip | Model `Trip` (judul, gunung, lokasi, tanggal, kuota, harga, status, relasi ke organizer) |
| Peserta | `TripParticipant` unik per `(tripId, userId)` dengan status `CONFIRMED` / `CANCELLED` (default schema juga mengenal `PENDING`; alur UI saat ini memakai `CONFIRMED` saat join) |
## Menjalankan secara lokal
Pastikan PostgreSQL berjalan dan variabel `DATABASE_URL` di `.env` mengarah ke database yang valid.
```bash ```bash
npm install
npx prisma migrate dev
npm run seed # opsional: data contoh
npm run dev npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Buka [http://localhost:3000](http://localhost:3000).
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ## Perbaikan bug (yang relevan dengan join & listing)
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 1. **Join lagi setelah “Batal ikut”**
Satu user hanya boleh satu baris partisipasi per trip (`@@unique([tripId, userId])`). Kode lama mencoba `create` lagi setelah status `CANCELLED`, sehingga bisa gagal dengan pelanggaran unik. Sekarang jika sudah ada baris `CANCELLED`, partisipasi **diaktifkan kembali** (`CONFIRMED`) lewat `participantRepo.reactivate`, bukan insert baru.
2. **Jumlah peserta di kartu / daftar**
`_count.participants` di query listing sebelumnya menghitung semua baris termasuk yang `CANCELLED`, sehingga “slot tersisa” di `TripCard` bisa salah. Count sekarang hanya menghitung peserta dengan status **bukan** `CANCELLED`.
3. **Segar halaman setelah join/batal/buat trip**
Setelah aksi trip, cache halaman `/trips` dan `/` ikut di-`revalidatePath` agar jumlah slot dan daftar di beranda konsisten tanpa harus refresh manual.
## Learn More ## Learn More
To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs)
- [Prisma Documentation](https://www.prisma.io/docs)
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+15 -3
View File
@@ -8,6 +8,7 @@ import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css"; import "react-datepicker/dist/react-datepicker.css";
import { createTripAction } from "@/features/trip/actions"; import { createTripAction } from "@/features/trip/actions";
import { ImageUrlInput } from "@/features/trip/components/image-url-input"; import { ImageUrlInput } from "@/features/trip/components/image-url-input";
import { formatLocalCalendarYmd } from "@/lib/trip-dates";
const SAMPLE_MOUNTAINS = [ const SAMPLE_MOUNTAINS = [
{ name: "Gunung Papandayan", location: "Garut, Jawa Barat" }, { name: "Gunung Papandayan", location: "Garut, Jawa Barat" },
@@ -72,10 +73,15 @@ export default function CreateTripPage() {
setLoading(true); setLoading(true);
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
// Set date values from DatePicker state // Hari kalender lokal → YYYY-MM-DD (bukan toISOString, supaya tidak geser ke UTC)
formData.set("date", startDate.toISOString().split("T")[0]); formData.set("date", formatLocalCalendarYmd(startDate));
if (endDate) { if (endDate) {
formData.set("endDate", endDate.toISOString().split("T")[0]); const startYmd = formatLocalCalendarYmd(startDate);
const endYmd = formatLocalCalendarYmd(endDate);
// Satu hari: tanggal pulang sama dengan berangkat → jangan kirim endDate (trip 1 hari)
if (endYmd !== startYmd) {
formData.set("endDate", endYmd);
}
} }
// Set raw price number // Set raw price number
formData.set("price", parseRupiahInput(priceDisplay)); formData.set("price", parseRupiahInput(priceDisplay));
@@ -222,6 +228,12 @@ export default function CreateTripPage() {
<label className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label className="mb-1.5 block text-sm font-semibold text-neutral-700">
Tanggal Berangkat Pulang Tanggal Berangkat Pulang
</label> </label>
<p className="mb-1.5 text-[11px] leading-snug text-neutral-500 sm:text-xs">
Pilih satu tanggal untuk trip <span className="font-medium">satu hari</span>
. Pilih rentang untuk trip <span className="font-medium">lebih dari satu hari</span>
. Tanggal disimpan sebagai hari kalender yang kamu klik; filter Open Trip memakai{" "}
<span className="font-medium">UTC</span> yang sama.
</p>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400"> <span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg <svg
+5
View File
@@ -27,6 +27,11 @@ export type User = Prisma.UserModel
* *
*/ */
export type Trip = Prisma.TripModel export type Trip = Prisma.TripModel
/**
* Model TripReview
*
*/
export type TripReview = Prisma.TripReviewModel
/** /**
* Model TripImage * Model TripImage
* *
+5
View File
@@ -51,6 +51,11 @@ export type User = Prisma.UserModel
* *
*/ */
export type Trip = Prisma.TripModel export type Trip = Prisma.TripModel
/**
* Model TripReview
*
*/
export type TripReview = Prisma.TripReviewModel
/** /**
* Model TripImage * Model TripImage
* *
File diff suppressed because one or more lines are too long
@@ -386,6 +386,7 @@ type FieldRefInputType<Model, FieldType> = Model extends never ? never : FieldRe
export const ModelName = { export const ModelName = {
User: 'User', User: 'User',
Trip: 'Trip', Trip: 'Trip',
TripReview: 'TripReview',
TripImage: 'TripImage', TripImage: 'TripImage',
TripParticipant: 'TripParticipant' TripParticipant: 'TripParticipant'
} as const } as const
@@ -403,7 +404,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "user" | "trip" | "tripImage" | "tripParticipant" modelProps: "user" | "trip" | "tripReview" | "tripImage" | "tripParticipant"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -555,6 +556,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
TripReview: {
payload: Prisma.$TripReviewPayload<ExtArgs>
fields: Prisma.TripReviewFieldRefs
operations: {
findUnique: {
args: Prisma.TripReviewFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$TripReviewPayload> | null
}
findUniqueOrThrow: {
args: Prisma.TripReviewFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$TripReviewPayload>
}
findFirst: {
args: Prisma.TripReviewFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$TripReviewPayload> | null
}
findFirstOrThrow: {
args: Prisma.TripReviewFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$TripReviewPayload>
}
findMany: {
args: Prisma.TripReviewFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$TripReviewPayload>[]
}
create: {
args: Prisma.TripReviewCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$TripReviewPayload>
}
createMany: {
args: Prisma.TripReviewCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.TripReviewCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$TripReviewPayload>[]
}
delete: {
args: Prisma.TripReviewDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$TripReviewPayload>
}
update: {
args: Prisma.TripReviewUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$TripReviewPayload>
}
deleteMany: {
args: Prisma.TripReviewDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.TripReviewUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.TripReviewUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$TripReviewPayload>[]
}
upsert: {
args: Prisma.TripReviewUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$TripReviewPayload>
}
aggregate: {
args: Prisma.TripReviewAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateTripReview>
}
groupBy: {
args: Prisma.TripReviewGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.TripReviewGroupByOutputType>[]
}
count: {
args: Prisma.TripReviewCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.TripReviewCountAggregateOutputType> | number
}
}
}
TripImage: { TripImage: {
payload: Prisma.$TripImagePayload<ExtArgs> payload: Prisma.$TripImagePayload<ExtArgs>
fields: Prisma.TripImageFieldRefs fields: Prisma.TripImageFieldRefs
@@ -774,6 +849,19 @@ export const TripScalarFieldEnum = {
export type TripScalarFieldEnum = (typeof TripScalarFieldEnum)[keyof typeof TripScalarFieldEnum] export type TripScalarFieldEnum = (typeof TripScalarFieldEnum)[keyof typeof TripScalarFieldEnum]
export const TripReviewScalarFieldEnum = {
id: 'id',
rating: 'rating',
comment: 'comment',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
tripId: 'tripId',
userId: 'userId'
} as const
export type TripReviewScalarFieldEnum = (typeof TripReviewScalarFieldEnum)[keyof typeof TripReviewScalarFieldEnum]
export const TripImageScalarFieldEnum = { export const TripImageScalarFieldEnum = {
id: 'id', id: 'id',
url: 'url', url: 'url',
@@ -1006,6 +1094,7 @@ export type PrismaClientOptions = ({
export type GlobalOmitConfig = { export type GlobalOmitConfig = {
user?: Prisma.UserOmit user?: Prisma.UserOmit
trip?: Prisma.TripOmit trip?: Prisma.TripOmit
tripReview?: Prisma.TripReviewOmit
tripImage?: Prisma.TripImageOmit tripImage?: Prisma.TripImageOmit
tripParticipant?: Prisma.TripParticipantOmit tripParticipant?: Prisma.TripParticipantOmit
} }
@@ -53,6 +53,7 @@ export const AnyNull = runtime.AnyNull
export const ModelName = { export const ModelName = {
User: 'User', User: 'User',
Trip: 'Trip', Trip: 'Trip',
TripReview: 'TripReview',
TripImage: 'TripImage', TripImage: 'TripImage',
TripParticipant: 'TripParticipant' TripParticipant: 'TripParticipant'
} as const } as const
@@ -105,6 +106,19 @@ export const TripScalarFieldEnum = {
export type TripScalarFieldEnum = (typeof TripScalarFieldEnum)[keyof typeof TripScalarFieldEnum] export type TripScalarFieldEnum = (typeof TripScalarFieldEnum)[keyof typeof TripScalarFieldEnum]
export const TripReviewScalarFieldEnum = {
id: 'id',
rating: 'rating',
comment: 'comment',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
tripId: 'tripId',
userId: 'userId'
} as const
export type TripReviewScalarFieldEnum = (typeof TripReviewScalarFieldEnum)[keyof typeof TripReviewScalarFieldEnum]
export const TripImageScalarFieldEnum = { export const TripImageScalarFieldEnum = {
id: 'id', id: 'id',
url: 'url', url: 'url',
+1
View File
@@ -10,6 +10,7 @@
*/ */
export type * from './models/User' export type * from './models/User'
export type * from './models/Trip' export type * from './models/Trip'
export type * from './models/TripReview'
export type * from './models/TripImage' export type * from './models/TripImage'
export type * from './models/TripParticipant' export type * from './models/TripParticipant'
export type * from './commonInputTypes' export type * from './commonInputTypes'
+158
View File
@@ -287,6 +287,7 @@ export type TripWhereInput = {
organizer?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput> organizer?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
participants?: Prisma.TripParticipantListRelationFilter participants?: Prisma.TripParticipantListRelationFilter
images?: Prisma.TripImageListRelationFilter images?: Prisma.TripImageListRelationFilter
reviews?: Prisma.TripReviewListRelationFilter
} }
export type TripOrderByWithRelationInput = { export type TripOrderByWithRelationInput = {
@@ -306,6 +307,7 @@ export type TripOrderByWithRelationInput = {
organizer?: Prisma.UserOrderByWithRelationInput organizer?: Prisma.UserOrderByWithRelationInput
participants?: Prisma.TripParticipantOrderByRelationAggregateInput participants?: Prisma.TripParticipantOrderByRelationAggregateInput
images?: Prisma.TripImageOrderByRelationAggregateInput images?: Prisma.TripImageOrderByRelationAggregateInput
reviews?: Prisma.TripReviewOrderByRelationAggregateInput
} }
export type TripWhereUniqueInput = Prisma.AtLeast<{ export type TripWhereUniqueInput = Prisma.AtLeast<{
@@ -328,6 +330,7 @@ export type TripWhereUniqueInput = Prisma.AtLeast<{
organizer?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput> organizer?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
participants?: Prisma.TripParticipantListRelationFilter participants?: Prisma.TripParticipantListRelationFilter
images?: Prisma.TripImageListRelationFilter images?: Prisma.TripImageListRelationFilter
reviews?: Prisma.TripReviewListRelationFilter
}, "id"> }, "id">
export type TripOrderByWithAggregationInput = { export type TripOrderByWithAggregationInput = {
@@ -386,6 +389,7 @@ export type TripCreateInput = {
organizer: Prisma.UserCreateNestedOneWithoutTripsInput organizer: Prisma.UserCreateNestedOneWithoutTripsInput
participants?: Prisma.TripParticipantCreateNestedManyWithoutTripInput participants?: Prisma.TripParticipantCreateNestedManyWithoutTripInput
images?: Prisma.TripImageCreateNestedManyWithoutTripInput images?: Prisma.TripImageCreateNestedManyWithoutTripInput
reviews?: Prisma.TripReviewCreateNestedManyWithoutTripInput
} }
export type TripUncheckedCreateInput = { export type TripUncheckedCreateInput = {
@@ -404,6 +408,7 @@ export type TripUncheckedCreateInput = {
organizerId: string organizerId: string
participants?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutTripInput participants?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutTripInput
images?: Prisma.TripImageUncheckedCreateNestedManyWithoutTripInput images?: Prisma.TripImageUncheckedCreateNestedManyWithoutTripInput
reviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutTripInput
} }
export type TripUpdateInput = { export type TripUpdateInput = {
@@ -422,6 +427,7 @@ export type TripUpdateInput = {
organizer?: Prisma.UserUpdateOneRequiredWithoutTripsNestedInput organizer?: Prisma.UserUpdateOneRequiredWithoutTripsNestedInput
participants?: Prisma.TripParticipantUpdateManyWithoutTripNestedInput participants?: Prisma.TripParticipantUpdateManyWithoutTripNestedInput
images?: Prisma.TripImageUpdateManyWithoutTripNestedInput images?: Prisma.TripImageUpdateManyWithoutTripNestedInput
reviews?: Prisma.TripReviewUpdateManyWithoutTripNestedInput
} }
export type TripUncheckedUpdateInput = { export type TripUncheckedUpdateInput = {
@@ -440,6 +446,7 @@ export type TripUncheckedUpdateInput = {
organizerId?: Prisma.StringFieldUpdateOperationsInput | string organizerId?: Prisma.StringFieldUpdateOperationsInput | string
participants?: Prisma.TripParticipantUncheckedUpdateManyWithoutTripNestedInput participants?: Prisma.TripParticipantUncheckedUpdateManyWithoutTripNestedInput
images?: Prisma.TripImageUncheckedUpdateManyWithoutTripNestedInput images?: Prisma.TripImageUncheckedUpdateManyWithoutTripNestedInput
reviews?: Prisma.TripReviewUncheckedUpdateManyWithoutTripNestedInput
} }
export type TripCreateManyInput = { export type TripCreateManyInput = {
@@ -620,6 +627,20 @@ export type EnumTripStatusFieldUpdateOperationsInput = {
set?: $Enums.TripStatus set?: $Enums.TripStatus
} }
export type TripCreateNestedOneWithoutReviewsInput = {
create?: Prisma.XOR<Prisma.TripCreateWithoutReviewsInput, Prisma.TripUncheckedCreateWithoutReviewsInput>
connectOrCreate?: Prisma.TripCreateOrConnectWithoutReviewsInput
connect?: Prisma.TripWhereUniqueInput
}
export type TripUpdateOneRequiredWithoutReviewsNestedInput = {
create?: Prisma.XOR<Prisma.TripCreateWithoutReviewsInput, Prisma.TripUncheckedCreateWithoutReviewsInput>
connectOrCreate?: Prisma.TripCreateOrConnectWithoutReviewsInput
upsert?: Prisma.TripUpsertWithoutReviewsInput
connect?: Prisma.TripWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.TripUpdateToOneWithWhereWithoutReviewsInput, Prisma.TripUpdateWithoutReviewsInput>, Prisma.TripUncheckedUpdateWithoutReviewsInput>
}
export type TripCreateNestedOneWithoutImagesInput = { export type TripCreateNestedOneWithoutImagesInput = {
create?: Prisma.XOR<Prisma.TripCreateWithoutImagesInput, Prisma.TripUncheckedCreateWithoutImagesInput> create?: Prisma.XOR<Prisma.TripCreateWithoutImagesInput, Prisma.TripUncheckedCreateWithoutImagesInput>
connectOrCreate?: Prisma.TripCreateOrConnectWithoutImagesInput connectOrCreate?: Prisma.TripCreateOrConnectWithoutImagesInput
@@ -663,6 +684,7 @@ export type TripCreateWithoutOrganizerInput = {
updatedAt?: Date | string updatedAt?: Date | string
participants?: Prisma.TripParticipantCreateNestedManyWithoutTripInput participants?: Prisma.TripParticipantCreateNestedManyWithoutTripInput
images?: Prisma.TripImageCreateNestedManyWithoutTripInput images?: Prisma.TripImageCreateNestedManyWithoutTripInput
reviews?: Prisma.TripReviewCreateNestedManyWithoutTripInput
} }
export type TripUncheckedCreateWithoutOrganizerInput = { export type TripUncheckedCreateWithoutOrganizerInput = {
@@ -680,6 +702,7 @@ export type TripUncheckedCreateWithoutOrganizerInput = {
updatedAt?: Date | string updatedAt?: Date | string
participants?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutTripInput participants?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutTripInput
images?: Prisma.TripImageUncheckedCreateNestedManyWithoutTripInput images?: Prisma.TripImageUncheckedCreateNestedManyWithoutTripInput
reviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutTripInput
} }
export type TripCreateOrConnectWithoutOrganizerInput = { export type TripCreateOrConnectWithoutOrganizerInput = {
@@ -727,6 +750,94 @@ export type TripScalarWhereInput = {
organizerId?: Prisma.StringFilter<"Trip"> | string organizerId?: Prisma.StringFilter<"Trip"> | string
} }
export type TripCreateWithoutReviewsInput = {
id?: string
title: string
description?: string | null
mountain: string
location: string
date: Date | string
endDate?: Date | string | null
maxParticipants: number
price: number
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
organizer: Prisma.UserCreateNestedOneWithoutTripsInput
participants?: Prisma.TripParticipantCreateNestedManyWithoutTripInput
images?: Prisma.TripImageCreateNestedManyWithoutTripInput
}
export type TripUncheckedCreateWithoutReviewsInput = {
id?: string
title: string
description?: string | null
mountain: string
location: string
date: Date | string
endDate?: Date | string | null
maxParticipants: number
price: number
status?: $Enums.TripStatus
createdAt?: Date | string
updatedAt?: Date | string
organizerId: string
participants?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutTripInput
images?: Prisma.TripImageUncheckedCreateNestedManyWithoutTripInput
}
export type TripCreateOrConnectWithoutReviewsInput = {
where: Prisma.TripWhereUniqueInput
create: Prisma.XOR<Prisma.TripCreateWithoutReviewsInput, Prisma.TripUncheckedCreateWithoutReviewsInput>
}
export type TripUpsertWithoutReviewsInput = {
update: Prisma.XOR<Prisma.TripUpdateWithoutReviewsInput, Prisma.TripUncheckedUpdateWithoutReviewsInput>
create: Prisma.XOR<Prisma.TripCreateWithoutReviewsInput, Prisma.TripUncheckedCreateWithoutReviewsInput>
where?: Prisma.TripWhereInput
}
export type TripUpdateToOneWithWhereWithoutReviewsInput = {
where?: Prisma.TripWhereInput
data: Prisma.XOR<Prisma.TripUpdateWithoutReviewsInput, Prisma.TripUncheckedUpdateWithoutReviewsInput>
}
export type TripUpdateWithoutReviewsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
title?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
organizer?: Prisma.UserUpdateOneRequiredWithoutTripsNestedInput
participants?: Prisma.TripParticipantUpdateManyWithoutTripNestedInput
images?: Prisma.TripImageUpdateManyWithoutTripNestedInput
}
export type TripUncheckedUpdateWithoutReviewsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
title?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
mountain?: Prisma.StringFieldUpdateOperationsInput | string
location?: Prisma.StringFieldUpdateOperationsInput | string
date?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
endDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
maxParticipants?: Prisma.IntFieldUpdateOperationsInput | number
price?: Prisma.IntFieldUpdateOperationsInput | number
status?: Prisma.EnumTripStatusFieldUpdateOperationsInput | $Enums.TripStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
organizerId?: Prisma.StringFieldUpdateOperationsInput | string
participants?: Prisma.TripParticipantUncheckedUpdateManyWithoutTripNestedInput
images?: Prisma.TripImageUncheckedUpdateManyWithoutTripNestedInput
}
export type TripCreateWithoutImagesInput = { export type TripCreateWithoutImagesInput = {
id?: string id?: string
title: string title: string
@@ -742,6 +853,7 @@ export type TripCreateWithoutImagesInput = {
updatedAt?: Date | string updatedAt?: Date | string
organizer: Prisma.UserCreateNestedOneWithoutTripsInput organizer: Prisma.UserCreateNestedOneWithoutTripsInput
participants?: Prisma.TripParticipantCreateNestedManyWithoutTripInput participants?: Prisma.TripParticipantCreateNestedManyWithoutTripInput
reviews?: Prisma.TripReviewCreateNestedManyWithoutTripInput
} }
export type TripUncheckedCreateWithoutImagesInput = { export type TripUncheckedCreateWithoutImagesInput = {
@@ -759,6 +871,7 @@ export type TripUncheckedCreateWithoutImagesInput = {
updatedAt?: Date | string updatedAt?: Date | string
organizerId: string organizerId: string
participants?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutTripInput participants?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutTripInput
reviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutTripInput
} }
export type TripCreateOrConnectWithoutImagesInput = { export type TripCreateOrConnectWithoutImagesInput = {
@@ -792,6 +905,7 @@ export type TripUpdateWithoutImagesInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
organizer?: Prisma.UserUpdateOneRequiredWithoutTripsNestedInput organizer?: Prisma.UserUpdateOneRequiredWithoutTripsNestedInput
participants?: Prisma.TripParticipantUpdateManyWithoutTripNestedInput participants?: Prisma.TripParticipantUpdateManyWithoutTripNestedInput
reviews?: Prisma.TripReviewUpdateManyWithoutTripNestedInput
} }
export type TripUncheckedUpdateWithoutImagesInput = { export type TripUncheckedUpdateWithoutImagesInput = {
@@ -809,6 +923,7 @@ export type TripUncheckedUpdateWithoutImagesInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
organizerId?: Prisma.StringFieldUpdateOperationsInput | string organizerId?: Prisma.StringFieldUpdateOperationsInput | string
participants?: Prisma.TripParticipantUncheckedUpdateManyWithoutTripNestedInput participants?: Prisma.TripParticipantUncheckedUpdateManyWithoutTripNestedInput
reviews?: Prisma.TripReviewUncheckedUpdateManyWithoutTripNestedInput
} }
export type TripCreateWithoutParticipantsInput = { export type TripCreateWithoutParticipantsInput = {
@@ -826,6 +941,7 @@ export type TripCreateWithoutParticipantsInput = {
updatedAt?: Date | string updatedAt?: Date | string
organizer: Prisma.UserCreateNestedOneWithoutTripsInput organizer: Prisma.UserCreateNestedOneWithoutTripsInput
images?: Prisma.TripImageCreateNestedManyWithoutTripInput images?: Prisma.TripImageCreateNestedManyWithoutTripInput
reviews?: Prisma.TripReviewCreateNestedManyWithoutTripInput
} }
export type TripUncheckedCreateWithoutParticipantsInput = { export type TripUncheckedCreateWithoutParticipantsInput = {
@@ -843,6 +959,7 @@ export type TripUncheckedCreateWithoutParticipantsInput = {
updatedAt?: Date | string updatedAt?: Date | string
organizerId: string organizerId: string
images?: Prisma.TripImageUncheckedCreateNestedManyWithoutTripInput images?: Prisma.TripImageUncheckedCreateNestedManyWithoutTripInput
reviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutTripInput
} }
export type TripCreateOrConnectWithoutParticipantsInput = { export type TripCreateOrConnectWithoutParticipantsInput = {
@@ -876,6 +993,7 @@ export type TripUpdateWithoutParticipantsInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
organizer?: Prisma.UserUpdateOneRequiredWithoutTripsNestedInput organizer?: Prisma.UserUpdateOneRequiredWithoutTripsNestedInput
images?: Prisma.TripImageUpdateManyWithoutTripNestedInput images?: Prisma.TripImageUpdateManyWithoutTripNestedInput
reviews?: Prisma.TripReviewUpdateManyWithoutTripNestedInput
} }
export type TripUncheckedUpdateWithoutParticipantsInput = { export type TripUncheckedUpdateWithoutParticipantsInput = {
@@ -893,6 +1011,7 @@ export type TripUncheckedUpdateWithoutParticipantsInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
organizerId?: Prisma.StringFieldUpdateOperationsInput | string organizerId?: Prisma.StringFieldUpdateOperationsInput | string
images?: Prisma.TripImageUncheckedUpdateManyWithoutTripNestedInput images?: Prisma.TripImageUncheckedUpdateManyWithoutTripNestedInput
reviews?: Prisma.TripReviewUncheckedUpdateManyWithoutTripNestedInput
} }
export type TripCreateManyOrganizerInput = { export type TripCreateManyOrganizerInput = {
@@ -925,6 +1044,7 @@ export type TripUpdateWithoutOrganizerInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
participants?: Prisma.TripParticipantUpdateManyWithoutTripNestedInput participants?: Prisma.TripParticipantUpdateManyWithoutTripNestedInput
images?: Prisma.TripImageUpdateManyWithoutTripNestedInput images?: Prisma.TripImageUpdateManyWithoutTripNestedInput
reviews?: Prisma.TripReviewUpdateManyWithoutTripNestedInput
} }
export type TripUncheckedUpdateWithoutOrganizerInput = { export type TripUncheckedUpdateWithoutOrganizerInput = {
@@ -942,6 +1062,7 @@ export type TripUncheckedUpdateWithoutOrganizerInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
participants?: Prisma.TripParticipantUncheckedUpdateManyWithoutTripNestedInput participants?: Prisma.TripParticipantUncheckedUpdateManyWithoutTripNestedInput
images?: Prisma.TripImageUncheckedUpdateManyWithoutTripNestedInput images?: Prisma.TripImageUncheckedUpdateManyWithoutTripNestedInput
reviews?: Prisma.TripReviewUncheckedUpdateManyWithoutTripNestedInput
} }
export type TripUncheckedUpdateManyWithoutOrganizerInput = { export type TripUncheckedUpdateManyWithoutOrganizerInput = {
@@ -967,11 +1088,13 @@ export type TripUncheckedUpdateManyWithoutOrganizerInput = {
export type TripCountOutputType = { export type TripCountOutputType = {
participants: number participants: number
images: number images: number
reviews: number
} }
export type TripCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type TripCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
participants?: boolean | TripCountOutputTypeCountParticipantsArgs participants?: boolean | TripCountOutputTypeCountParticipantsArgs
images?: boolean | TripCountOutputTypeCountImagesArgs images?: boolean | TripCountOutputTypeCountImagesArgs
reviews?: boolean | TripCountOutputTypeCountReviewsArgs
} }
/** /**
@@ -998,6 +1121,13 @@ export type TripCountOutputTypeCountImagesArgs<ExtArgs extends runtime.Types.Ext
where?: Prisma.TripImageWhereInput where?: Prisma.TripImageWhereInput
} }
/**
* TripCountOutputType without action
*/
export type TripCountOutputTypeCountReviewsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.TripReviewWhereInput
}
export type TripSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type TripSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -1016,6 +1146,7 @@ export type TripSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
organizer?: boolean | Prisma.UserDefaultArgs<ExtArgs> organizer?: boolean | Prisma.UserDefaultArgs<ExtArgs>
participants?: boolean | Prisma.Trip$participantsArgs<ExtArgs> participants?: boolean | Prisma.Trip$participantsArgs<ExtArgs>
images?: boolean | Prisma.Trip$imagesArgs<ExtArgs> images?: boolean | Prisma.Trip$imagesArgs<ExtArgs>
reviews?: boolean | Prisma.Trip$reviewsArgs<ExtArgs>
_count?: boolean | Prisma.TripCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.TripCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["trip"]> }, ExtArgs["result"]["trip"]>
@@ -1074,6 +1205,7 @@ export type TripInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
organizer?: boolean | Prisma.UserDefaultArgs<ExtArgs> organizer?: boolean | Prisma.UserDefaultArgs<ExtArgs>
participants?: boolean | Prisma.Trip$participantsArgs<ExtArgs> participants?: boolean | Prisma.Trip$participantsArgs<ExtArgs>
images?: boolean | Prisma.Trip$imagesArgs<ExtArgs> images?: boolean | Prisma.Trip$imagesArgs<ExtArgs>
reviews?: boolean | Prisma.Trip$reviewsArgs<ExtArgs>
_count?: boolean | Prisma.TripCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.TripCountOutputTypeDefaultArgs<ExtArgs>
} }
export type TripIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type TripIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1089,6 +1221,7 @@ export type $TripPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
organizer: Prisma.$UserPayload<ExtArgs> organizer: Prisma.$UserPayload<ExtArgs>
participants: Prisma.$TripParticipantPayload<ExtArgs>[] participants: Prisma.$TripParticipantPayload<ExtArgs>[]
images: Prisma.$TripImagePayload<ExtArgs>[] images: Prisma.$TripImagePayload<ExtArgs>[]
reviews: Prisma.$TripReviewPayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1501,6 +1634,7 @@ export interface Prisma__TripClient<T, Null = never, ExtArgs extends runtime.Typ
organizer<T extends Prisma.UserDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.UserDefaultArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> organizer<T extends Prisma.UserDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.UserDefaultArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
participants<T extends Prisma.Trip$participantsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Trip$participantsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TripParticipantPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> participants<T extends Prisma.Trip$participantsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Trip$participantsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TripParticipantPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
images<T extends Prisma.Trip$imagesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Trip$imagesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TripImagePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> images<T extends Prisma.Trip$imagesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Trip$imagesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TripImagePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
reviews<T extends Prisma.Trip$reviewsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Trip$reviewsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TripReviewPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1991,6 +2125,30 @@ export type Trip$imagesArgs<ExtArgs extends runtime.Types.Extensions.InternalArg
distinct?: Prisma.TripImageScalarFieldEnum | Prisma.TripImageScalarFieldEnum[] distinct?: Prisma.TripImageScalarFieldEnum | Prisma.TripImageScalarFieldEnum[]
} }
/**
* Trip.reviews
*/
export type Trip$reviewsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the TripReview
*/
select?: Prisma.TripReviewSelect<ExtArgs> | null
/**
* Omit specific fields from the TripReview
*/
omit?: Prisma.TripReviewOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.TripReviewInclude<ExtArgs> | null
where?: Prisma.TripReviewWhereInput
orderBy?: Prisma.TripReviewOrderByWithRelationInput | Prisma.TripReviewOrderByWithRelationInput[]
cursor?: Prisma.TripReviewWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.TripReviewScalarFieldEnum | Prisma.TripReviewScalarFieldEnum[]
}
/** /**
* Trip without action * Trip without action
*/ */
File diff suppressed because it is too large Load Diff
+130
View File
@@ -200,6 +200,7 @@ export type UserWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
trips?: Prisma.TripListRelationFilter trips?: Prisma.TripListRelationFilter
participations?: Prisma.TripParticipantListRelationFilter participations?: Prisma.TripParticipantListRelationFilter
tripReviews?: Prisma.TripReviewListRelationFilter
} }
export type UserOrderByWithRelationInput = { export type UserOrderByWithRelationInput = {
@@ -212,6 +213,7 @@ export type UserOrderByWithRelationInput = {
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
trips?: Prisma.TripOrderByRelationAggregateInput trips?: Prisma.TripOrderByRelationAggregateInput
participations?: Prisma.TripParticipantOrderByRelationAggregateInput participations?: Prisma.TripParticipantOrderByRelationAggregateInput
tripReviews?: Prisma.TripReviewOrderByRelationAggregateInput
} }
export type UserWhereUniqueInput = Prisma.AtLeast<{ export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -227,6 +229,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
trips?: Prisma.TripListRelationFilter trips?: Prisma.TripListRelationFilter
participations?: Prisma.TripParticipantListRelationFilter participations?: Prisma.TripParticipantListRelationFilter
tripReviews?: Prisma.TripReviewListRelationFilter
}, "id" | "email"> }, "id" | "email">
export type UserOrderByWithAggregationInput = { export type UserOrderByWithAggregationInput = {
@@ -265,6 +268,7 @@ export type UserCreateInput = {
updatedAt?: Date | string updatedAt?: Date | string
trips?: Prisma.TripCreateNestedManyWithoutOrganizerInput trips?: Prisma.TripCreateNestedManyWithoutOrganizerInput
participations?: Prisma.TripParticipantCreateNestedManyWithoutUserInput participations?: Prisma.TripParticipantCreateNestedManyWithoutUserInput
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
} }
export type UserUncheckedCreateInput = { export type UserUncheckedCreateInput = {
@@ -277,6 +281,7 @@ export type UserUncheckedCreateInput = {
updatedAt?: Date | string updatedAt?: Date | string
trips?: Prisma.TripUncheckedCreateNestedManyWithoutOrganizerInput trips?: Prisma.TripUncheckedCreateNestedManyWithoutOrganizerInput
participations?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutUserInput participations?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutUserInput
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
} }
export type UserUpdateInput = { export type UserUpdateInput = {
@@ -289,6 +294,7 @@ export type UserUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUpdateManyWithoutOrganizerNestedInput trips?: Prisma.TripUpdateManyWithoutOrganizerNestedInput
participations?: Prisma.TripParticipantUpdateManyWithoutUserNestedInput participations?: Prisma.TripParticipantUpdateManyWithoutUserNestedInput
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
} }
export type UserUncheckedUpdateInput = { export type UserUncheckedUpdateInput = {
@@ -301,6 +307,7 @@ export type UserUncheckedUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUncheckedUpdateManyWithoutOrganizerNestedInput trips?: Prisma.TripUncheckedUpdateManyWithoutOrganizerNestedInput
participations?: Prisma.TripParticipantUncheckedUpdateManyWithoutUserNestedInput participations?: Prisma.TripParticipantUncheckedUpdateManyWithoutUserNestedInput
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
} }
export type UserCreateManyInput = { export type UserCreateManyInput = {
@@ -394,6 +401,20 @@ export type UserUpdateOneRequiredWithoutTripsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutTripsInput, Prisma.UserUpdateWithoutTripsInput>, Prisma.UserUncheckedUpdateWithoutTripsInput> update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutTripsInput, Prisma.UserUpdateWithoutTripsInput>, Prisma.UserUncheckedUpdateWithoutTripsInput>
} }
export type UserCreateNestedOneWithoutTripReviewsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutTripReviewsInput, Prisma.UserUncheckedCreateWithoutTripReviewsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutTripReviewsInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutTripReviewsNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutTripReviewsInput, Prisma.UserUncheckedCreateWithoutTripReviewsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutTripReviewsInput
upsert?: Prisma.UserUpsertWithoutTripReviewsInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutTripReviewsInput, Prisma.UserUpdateWithoutTripReviewsInput>, Prisma.UserUncheckedUpdateWithoutTripReviewsInput>
}
export type UserCreateNestedOneWithoutParticipationsInput = { export type UserCreateNestedOneWithoutParticipationsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutParticipationsInput, Prisma.UserUncheckedCreateWithoutParticipationsInput> create?: Prisma.XOR<Prisma.UserCreateWithoutParticipationsInput, Prisma.UserUncheckedCreateWithoutParticipationsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutParticipationsInput connectOrCreate?: Prisma.UserCreateOrConnectWithoutParticipationsInput
@@ -417,6 +438,7 @@ export type UserCreateWithoutTripsInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
participations?: Prisma.TripParticipantCreateNestedManyWithoutUserInput participations?: Prisma.TripParticipantCreateNestedManyWithoutUserInput
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
} }
export type UserUncheckedCreateWithoutTripsInput = { export type UserUncheckedCreateWithoutTripsInput = {
@@ -428,6 +450,7 @@ export type UserUncheckedCreateWithoutTripsInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
participations?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutUserInput participations?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutUserInput
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
} }
export type UserCreateOrConnectWithoutTripsInput = { export type UserCreateOrConnectWithoutTripsInput = {
@@ -455,6 +478,7 @@ export type UserUpdateWithoutTripsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
participations?: Prisma.TripParticipantUpdateManyWithoutUserNestedInput participations?: Prisma.TripParticipantUpdateManyWithoutUserNestedInput
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutTripsInput = { export type UserUncheckedUpdateWithoutTripsInput = {
@@ -466,6 +490,71 @@ export type UserUncheckedUpdateWithoutTripsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
participations?: Prisma.TripParticipantUncheckedUpdateManyWithoutUserNestedInput participations?: Prisma.TripParticipantUncheckedUpdateManyWithoutUserNestedInput
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutTripReviewsInput = {
id?: string
name: string
email: string
password: string
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
trips?: Prisma.TripCreateNestedManyWithoutOrganizerInput
participations?: Prisma.TripParticipantCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutTripReviewsInput = {
id?: string
name: string
email: string
password: string
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
trips?: Prisma.TripUncheckedCreateNestedManyWithoutOrganizerInput
participations?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutTripReviewsInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutTripReviewsInput, Prisma.UserUncheckedCreateWithoutTripReviewsInput>
}
export type UserUpsertWithoutTripReviewsInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutTripReviewsInput, Prisma.UserUncheckedUpdateWithoutTripReviewsInput>
create: Prisma.XOR<Prisma.UserCreateWithoutTripReviewsInput, Prisma.UserUncheckedCreateWithoutTripReviewsInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutTripReviewsInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutTripReviewsInput, Prisma.UserUncheckedUpdateWithoutTripReviewsInput>
}
export type UserUpdateWithoutTripReviewsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUpdateManyWithoutOrganizerNestedInput
participations?: Prisma.TripParticipantUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutTripReviewsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUncheckedUpdateManyWithoutOrganizerNestedInput
participations?: Prisma.TripParticipantUncheckedUpdateManyWithoutUserNestedInput
} }
export type UserCreateWithoutParticipationsInput = { export type UserCreateWithoutParticipationsInput = {
@@ -477,6 +566,7 @@ export type UserCreateWithoutParticipationsInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
trips?: Prisma.TripCreateNestedManyWithoutOrganizerInput trips?: Prisma.TripCreateNestedManyWithoutOrganizerInput
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
} }
export type UserUncheckedCreateWithoutParticipationsInput = { export type UserUncheckedCreateWithoutParticipationsInput = {
@@ -488,6 +578,7 @@ export type UserUncheckedCreateWithoutParticipationsInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
trips?: Prisma.TripUncheckedCreateNestedManyWithoutOrganizerInput trips?: Prisma.TripUncheckedCreateNestedManyWithoutOrganizerInput
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
} }
export type UserCreateOrConnectWithoutParticipationsInput = { export type UserCreateOrConnectWithoutParticipationsInput = {
@@ -515,6 +606,7 @@ export type UserUpdateWithoutParticipationsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUpdateManyWithoutOrganizerNestedInput trips?: Prisma.TripUpdateManyWithoutOrganizerNestedInput
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutParticipationsInput = { export type UserUncheckedUpdateWithoutParticipationsInput = {
@@ -526,6 +618,7 @@ export type UserUncheckedUpdateWithoutParticipationsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trips?: Prisma.TripUncheckedUpdateManyWithoutOrganizerNestedInput trips?: Prisma.TripUncheckedUpdateManyWithoutOrganizerNestedInput
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
} }
@@ -536,11 +629,13 @@ export type UserUncheckedUpdateWithoutParticipationsInput = {
export type UserCountOutputType = { export type UserCountOutputType = {
trips: number trips: number
participations: number participations: number
tripReviews: number
} }
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
trips?: boolean | UserCountOutputTypeCountTripsArgs trips?: boolean | UserCountOutputTypeCountTripsArgs
participations?: boolean | UserCountOutputTypeCountParticipationsArgs participations?: boolean | UserCountOutputTypeCountParticipationsArgs
tripReviews?: boolean | UserCountOutputTypeCountTripReviewsArgs
} }
/** /**
@@ -567,6 +662,13 @@ export type UserCountOutputTypeCountParticipationsArgs<ExtArgs extends runtime.T
where?: Prisma.TripParticipantWhereInput where?: Prisma.TripParticipantWhereInput
} }
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountTripReviewsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.TripReviewWhereInput
}
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -578,6 +680,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
updatedAt?: boolean updatedAt?: boolean
trips?: boolean | Prisma.User$tripsArgs<ExtArgs> trips?: boolean | Prisma.User$tripsArgs<ExtArgs>
participations?: boolean | Prisma.User$participationsArgs<ExtArgs> participations?: boolean | Prisma.User$participationsArgs<ExtArgs>
tripReviews?: boolean | Prisma.User$tripReviewsArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]> }, ExtArgs["result"]["user"]>
@@ -615,6 +718,7 @@ export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = run
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
trips?: boolean | Prisma.User$tripsArgs<ExtArgs> trips?: boolean | Prisma.User$tripsArgs<ExtArgs>
participations?: boolean | Prisma.User$participationsArgs<ExtArgs> participations?: boolean | Prisma.User$participationsArgs<ExtArgs>
tripReviews?: boolean | Prisma.User$tripReviewsArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
} }
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {} export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
@@ -625,6 +729,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
objects: { objects: {
trips: Prisma.$TripPayload<ExtArgs>[] trips: Prisma.$TripPayload<ExtArgs>[]
participations: Prisma.$TripParticipantPayload<ExtArgs>[] participations: Prisma.$TripParticipantPayload<ExtArgs>[]
tripReviews: Prisma.$TripReviewPayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1030,6 +1135,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
readonly [Symbol.toStringTag]: "PrismaPromise" readonly [Symbol.toStringTag]: "PrismaPromise"
trips<T extends Prisma.User$tripsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$tripsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TripPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> trips<T extends Prisma.User$tripsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$tripsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TripPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
participations<T extends Prisma.User$participationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$participationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TripParticipantPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> participations<T extends Prisma.User$participationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$participationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TripParticipantPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
tripReviews<T extends Prisma.User$tripReviewsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$tripReviewsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$TripReviewPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1506,6 +1612,30 @@ export type User$participationsArgs<ExtArgs extends runtime.Types.Extensions.Int
distinct?: Prisma.TripParticipantScalarFieldEnum | Prisma.TripParticipantScalarFieldEnum[] distinct?: Prisma.TripParticipantScalarFieldEnum | Prisma.TripParticipantScalarFieldEnum[]
} }
/**
* User.tripReviews
*/
export type User$tripReviewsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the TripReview
*/
select?: Prisma.TripReviewSelect<ExtArgs> | null
/**
* Omit specific fields from the TripReview
*/
omit?: Prisma.TripReviewOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.TripReviewInclude<ExtArgs> | null
where?: Prisma.TripReviewWhereInput
orderBy?: Prisma.TripReviewOrderByWithRelationInput | Prisma.TripReviewOrderByWithRelationInput[]
cursor?: Prisma.TripReviewWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.TripReviewScalarFieldEnum | Prisma.TripReviewScalarFieldEnum[]
}
/** /**
* User without action * User without action
*/ */
+19 -4
View File
@@ -1,13 +1,19 @@
"use client"; "use client";
import { useState } from "react"; import { useState, Suspense } from "react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
export default function LoginPage() { function safeInternalPath(raw: string | null): string {
if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return "/";
return raw;
}
function LoginForm() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -31,7 +37,8 @@ export default function LoginPage() {
if (result?.error) { if (result?.error) {
setError(result.error); setError(result.error);
} else { } else {
router.push("/"); const next = safeInternalPath(searchParams.get("callbackUrl"));
router.push(next);
router.refresh(); router.refresh();
} }
} }
@@ -126,3 +133,11 @@ export default function LoginPage() {
</div> </div>
); );
} }
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
);
}
+217
View File
@@ -0,0 +1,217 @@
import { redirect } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { profileService } from "@/server/services/profile.service";
import { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
export default async function ProfilePage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login?callbackUrl=/profile");
}
const data = await profileService.getProfileDashboard(session.user.id);
const { user, organizedTrips, activeJoined, cancelledJoined, reviewable } =
data;
const memberSince = new Intl.DateTimeFormat("id-ID", {
month: "long",
year: "numeric",
}).format(user.createdAt);
return (
<div className="mx-auto max-w-4xl px-4 py-6 sm:py-8">
<div className="mb-6 flex flex-col gap-4 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:flex-row sm:items-center sm:justify-between sm:p-6">
<div className="flex items-center gap-4">
{user.image ? (
<Image
src={user.image}
alt=""
width={72}
height={72}
className="h-[72px] w-[72px] rounded-full object-cover"
/>
) : (
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-full bg-primary-600 text-2xl font-bold text-white">
{user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0">
<h1 className="text-xl font-bold text-neutral-800 sm:text-2xl">
{user.name}
</h1>
<p className="mt-0.5 truncate text-sm text-neutral-500">{user.email}</p>
<p className="mt-1 text-xs text-neutral-400">
Anggota sejak {memberSince}
</p>
<p className="mt-2 text-xs text-neutral-500">
Di sini kamu bisa lihat trip yang kamu buat sebagai{" "}
<span className="font-semibold text-primary-700">organizer</span>,
trip yang kamu{" "}
<span className="font-semibold text-secondary-700">ikuti</span>{" "}
sebagai peserta, dan{" "}
<span className="font-semibold text-amber-700">ulasan</span> untuk
trip yang sudah selesai (lewat halaman trip).
</p>
</div>
</div>
<Link
href="/create-trip"
className="shrink-0 rounded-xl bg-primary-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-md hover:bg-primary-700"
>
+ Buat trip
</Link>
</div>
{/* Trip selesai — akses ulasan (trip ini tidak muncul di Open Trip) */}
{reviewable.length > 0 && (
<section className="mb-8 rounded-2xl border border-amber-200 bg-amber-50/60 p-4 sm:p-5">
<h2 className="mb-1 text-base font-bold text-amber-900 sm:text-lg">
Trip selesai & ulasan
</h2>
<p className="mb-4 text-xs text-amber-900/80 sm:text-sm">
Trip yang sudah lewat tidak tampil di daftar Open Trip. Buka trip di
bawah ini lalu scroll ke bagian ulasan di halaman detail untuk
memberi atau mengubah rating.
</p>
<ul className="space-y-2">
{reviewable.map((p) => {
const t = p.trip;
const hasReview = t.reviews.length > 0;
return (
<li key={p.id}>
<ProfileTripRow
href={`/trips/${t.id}`}
title={t.title}
mountain={t.mountain}
date={t.date}
endDate={t.endDate}
rightSlot={
<span
className={
hasReview
? "text-secondary-700"
: "font-bold text-amber-800"
}
>
{hasReview ? "Ubah ulasan →" : "Beri ulasan →"}
</span>
}
/>
</li>
);
})}
</ul>
</section>
)}
<div className="grid gap-8 lg:grid-cols-2">
<section>
<h2 className="mb-3 text-base font-bold text-neutral-800 sm:text-lg">
Trip yang kamu buat
<span className="ml-2 text-sm font-normal text-neutral-400">
({organizedTrips.length})
</span>
</h2>
{organizedTrips.length === 0 ? (
<p className="rounded-xl border border-dashed border-neutral-200 bg-neutral-50 px-4 py-6 text-center text-sm text-neutral-500">
Belum ada trip.{" "}
<Link href="/create-trip" className="font-semibold text-primary-600">
Buat trip pertama
</Link>
</p>
) : (
<div className="space-y-4">
{organizedTrips.map((trip) => (
<TripCard
key={trip.id}
id={trip.id}
title={trip.title}
mountain={trip.mountain}
location={trip.location}
date={trip.date}
endDate={trip.endDate}
price={trip.price}
maxParticipants={trip.maxParticipants}
participantCount={trip._count.participants}
organizerName={`${user.name} (Kamu)`}
status={trip.status}
coverImage={trip.images[0]?.url}
/>
))}
</div>
)}
</section>
<section>
<h2 className="mb-3 text-base font-bold text-neutral-800 sm:text-lg">
Trip yang kamu ikuti
<span className="ml-2 text-sm font-normal text-neutral-400">
({activeJoined.length})
</span>
</h2>
{activeJoined.length === 0 ? (
<p className="rounded-xl border border-dashed border-neutral-200 bg-neutral-50 px-4 py-6 text-center text-sm text-neutral-500">
Belum join trip.{" "}
<Link href="/trips" className="font-semibold text-primary-600">
Cari open trip
</Link>
</p>
) : (
<ul className="space-y-2">
{activeJoined.map((p) => {
const t = p.trip;
return (
<li key={p.id}>
<ProfileTripRow
href={`/trips/${t.id}`}
title={t.title}
mountain={t.mountain}
date={t.date}
endDate={t.endDate}
rightSlot={
<span className="text-neutral-400">
{p.status === "CONFIRMED" ? "Terdaftar" : p.status}
</span>
}
/>
</li>
);
})}
</ul>
)}
{cancelledJoined.length > 0 && (
<div className="mt-6">
<h3 className="mb-2 text-sm font-semibold text-neutral-500">
Riwayat batal ({cancelledJoined.length})
</h3>
<ul className="space-y-2 opacity-80">
{cancelledJoined.map((p) => {
const t = p.trip;
return (
<li key={p.id}>
<ProfileTripRow
href={`/trips/${t.id}`}
title={t.title}
mountain={t.mountain}
date={t.date}
endDate={t.endDate}
rightSlot={
<span className="text-red-500/90">Dibatalkan</span>
}
/>
</li>
);
})}
</ul>
</div>
)}
</section>
</div>
</div>
);
}
+44
View File
@@ -6,6 +6,11 @@ import { tripService } from "@/server/services/trip.service";
import { formatRupiah, formatDateRange } from "@/lib/utils"; import { formatRupiah, formatDateRange } from "@/lib/utils";
import { JoinTripButton } from "@/features/trip/components/join-trip-button"; import { JoinTripButton } from "@/features/trip/components/join-trip-button";
import { ImageGallery } from "@/features/trip/components/image-gallery"; import { ImageGallery } from "@/features/trip/components/image-gallery";
import { TripReviewSection } from "@/features/review/components/trip-review-section";
import {
isPastTripLastDayForReview,
isTripDepartureDayPast,
} from "@/lib/trip-dates";
export default async function TripDetailPage({ export default async function TripDetailPage({
params, params,
@@ -38,6 +43,26 @@ export default async function TripDetailPage({
) )
: null; : null;
const isDeparturePast = isTripDepartureDayPast(trip.date);
const canReview =
!!session?.user &&
!isOrganizer &&
currentParticipation?.status === "CONFIRMED" &&
isPastTripLastDayForReview(trip.date, trip.endDate);
const myReview = session?.user
? trip.reviews.find((r) => r.userId === session.user.id) ?? null
: null;
const averageRating =
trip.reviews.length > 0
? Math.round(
(trip.reviews.reduce((s, r) => s + r.rating, 0) /
trip.reviews.length) *
10
) / 10
: null;
return ( return (
<div className="mx-auto max-w-3xl px-4 py-4 sm:py-8"> <div className="mx-auto max-w-3xl px-4 py-4 sm:py-8">
{/* Breadcrumb */} {/* Breadcrumb */}
@@ -180,6 +205,25 @@ export default async function TripDetailPage({
isJoined={!!currentParticipation} isJoined={!!currentParticipation}
isFull={spotsLeft <= 0} isFull={spotsLeft <= 0}
tripStatus={trip.status} tripStatus={trip.status}
isDeparturePast={isDeparturePast}
/>
<TripReviewSection
tripId={trip.id}
reviews={trip.reviews.map((r) => ({
id: r.id,
rating: r.rating,
comment: r.comment,
createdAt: r.createdAt,
user: r.user,
}))}
averageRating={averageRating}
canReview={canReview}
myReview={
myReview
? { rating: myReview.rating, comment: myReview.comment }
: null
}
/> />
{/* Participants List */} {/* Participants List */}
+32 -8
View File
@@ -44,13 +44,26 @@ export function Navbar() {
> >
Buat Trip Buat Trip
</Link> </Link>
<Link
href="/profile"
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"
>
Profil
</Link>
<div className="ml-2 flex items-center gap-2"> <div className="ml-2 flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-600 text-xs font-bold text-white"> <Link
href="/profile"
className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-600 text-xs font-bold text-white hover:ring-2 hover:ring-primary-300"
title="Profil"
>
{session.user.name?.charAt(0).toUpperCase()} {session.user.name?.charAt(0).toUpperCase()}
</div> </Link>
<span className="text-sm font-medium text-neutral-700"> <Link
href="/profile"
className="max-w-[140px] truncate text-sm font-medium text-neutral-700 hover:text-primary-600"
>
{session.user.name} {session.user.name}
</span> </Link>
<button <button
onClick={() => signOut()} onClick={() => signOut()}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-700" className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-700"
@@ -116,15 +129,26 @@ export function Navbar() {
> >
Buat Trip Buat Trip
</Link> </Link>
<Link
href="/profile"
onClick={() => setMenuOpen(false)}
className="rounded-lg px-3 py-2.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
>
Profil
</Link>
<div className="mt-2 flex items-center justify-between rounded-lg bg-neutral-50 px-3 py-2.5"> <div className="mt-2 flex items-center justify-between rounded-lg bg-neutral-50 px-3 py-2.5">
<div className="flex items-center gap-2"> <Link
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-600 text-xs font-bold text-white"> href="/profile"
onClick={() => setMenuOpen(false)}
className="flex min-w-0 flex-1 items-center gap-2"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-600 text-xs font-bold text-white">
{session.user.name?.charAt(0).toUpperCase()} {session.user.name?.charAt(0).toUpperCase()}
</div> </div>
<span className="text-sm font-medium text-neutral-700"> <span className="truncate text-sm font-medium text-neutral-700">
{session.user.name} {session.user.name}
</span> </span>
</div> </Link>
<button <button
onClick={() => signOut()} onClick={() => signOut()}
className="rounded-lg px-3 py-1.5 text-sm font-medium text-red-500 transition-colors hover:bg-red-50" className="rounded-lg px-3 py-1.5 text-sm font-medium text-red-500 transition-colors hover:bg-red-50"
+24 -5
View File
@@ -1,14 +1,33 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { LIMITS } from "@/lib/limits";
export const loginSchema = z.object({ export const loginSchema = z.object({
email: z.email("Email tidak valid"), email: z
password: z.string().min(6, "Password minimal 6 karakter"), .string()
.trim()
.min(1, "Email harus diisi")
.pipe(z.email("Email tidak valid")),
password: z
.string()
.min(6, "Password minimal 6 karakter")
.max(LIMITS.MAX_PASSWORD_LENGTH, "Password terlalu panjang"),
}); });
export const registerSchema = z.object({ export const registerSchema = z.object({
name: z.string().min(2, "Nama minimal 2 karakter"), name: z
email: z.email("Email tidak valid"), .string()
password: z.string().min(6, "Password minimal 6 karakter"), .trim()
.min(2, "Nama minimal 2 karakter")
.max(LIMITS.MAX_NAME_LENGTH, `Nama maksimal ${LIMITS.MAX_NAME_LENGTH} karakter`),
email: z
.string()
.trim()
.min(1, "Email harus diisi")
.pipe(z.email("Email tidak valid")),
password: z
.string()
.min(6, "Password minimal 6 karakter")
.max(LIMITS.MAX_PASSWORD_LENGTH, "Password terlalu panjang (maks. 72 karakter)"),
confirmPassword: z.string(), confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, { }).refine((data) => data.password === data.confirmPassword, {
message: "Password tidak cocok", message: "Password tidak cocok",
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { formatDateRange } from "@/lib/utils";
interface ProfileTripRowProps {
href: string;
title: string;
mountain: string;
date: Date;
endDate: Date | null;
rightSlot?: ReactNode;
}
export function ProfileTripRow({
href,
title,
mountain,
date,
endDate,
rightSlot,
}: ProfileTripRowProps) {
return (
<Link
href={href}
className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 transition-colors hover:border-primary-200 hover:bg-primary-50/40 sm:px-4 sm:py-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-800">{title}</p>
<p className="truncate text-xs text-neutral-500">{mountain}</p>
<p className="mt-0.5 text-[11px] text-neutral-400 sm:text-xs">
{formatDateRange(date, endDate)}
</p>
</div>
{rightSlot && (
<div className="shrink-0 text-right text-xs font-medium">{rightSlot}</div>
)}
</Link>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use server";
import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { upsertReviewSchema } from "./schemas";
import { reviewService } from "@/server/services/review.service";
export async function upsertTripReviewAction(formData: FormData) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
const raw = {
tripId: formData.get("tripId") as string,
rating: formData.get("rating") as string,
comment: String(formData.get("comment") ?? ""),
};
const parsed = upsertReviewSchema.safeParse(raw);
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? "Data tidak valid" };
}
try {
await reviewService.upsertReview(
parsed.data.tripId,
session.user.id,
{
rating: parsed.data.rating,
comment: parsed.data.comment,
}
);
revalidatePath(`/trips/${parsed.data.tripId}`);
revalidatePath("/profile");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
@@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { upsertTripReviewAction } from "@/features/review/actions";
type ReviewUser = { id: string; name: string; image: string | null };
export type TripReviewItem = {
id: string;
rating: number;
comment: string | null;
createdAt: Date;
user: ReviewUser;
};
interface TripReviewSectionProps {
tripId: string;
reviews: TripReviewItem[];
averageRating: number | null;
canReview: boolean;
myReview: { rating: number; comment: string | null } | null;
}
export function TripReviewSection({
tripId,
reviews,
averageRating,
canReview,
myReview,
}: TripReviewSectionProps) {
const router = useRouter();
const [rating, setRating] = useState(myReview?.rating ?? 5);
const [comment, setComment] = useState(myReview?.comment ?? "");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
setLoading(true);
const fd = new FormData();
fd.set("tripId", tripId);
fd.set("rating", String(rating));
fd.set("comment", comment);
const result = await upsertTripReviewAction(fd);
setLoading(false);
if (result.error) {
setError(result.error);
} else {
router.refresh();
}
}
return (
<div className="rounded-xl border border-neutral-200 bg-white p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-sm font-bold text-neutral-800 sm:text-base">
Ulasan peserta
</h2>
{averageRating != null && reviews.length > 0 && (
<p className="text-sm text-neutral-600">
<span className="font-bold text-amber-600">{averageRating}</span>
<span className="text-neutral-400"> /5</span>
<span className="text-neutral-400"> · {reviews.length} ulasan</span>
</p>
)}
</div>
{reviews.length === 0 && !canReview && (
<p className="text-xs text-neutral-500 sm:text-sm">
Belum ada ulasan. Peserta bisa menulis ulasan setelah trip selesai.
</p>
)}
{reviews.length > 0 && (
<ul className="mb-5 space-y-3 border-b border-neutral-100 pb-5">
{reviews.map((r) => (
<li
key={r.id}
className="rounded-lg bg-neutral-50 px-3 py-2.5 sm:px-4 sm:py-3"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-neutral-800 sm:text-sm">
{r.user.name}
</span>
<span className="text-xs font-bold text-amber-600">
{r.rating}/5
</span>
</div>
{r.comment && (
<p className="mt-1.5 text-xs leading-relaxed text-neutral-600 sm:text-sm">
{r.comment}
</p>
)}
</li>
))}
</ul>
)}
{canReview && (
<div>
<p className="mb-3 text-xs text-neutral-500 sm:text-sm">
{myReview
? "Ubah ulasan kamu untuk trip ini."
: "Bagikan pengalamanmu setelah trip selesai."}
</p>
{error && (
<div className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-600">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="mb-1.5 block text-xs font-semibold text-neutral-700 sm:text-sm">
Rating
</label>
<div className="flex flex-wrap gap-1.5">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => setRating(n)}
className={`rounded-lg px-2.5 py-1.5 text-xs font-bold sm:px-3 sm:text-sm ${
rating === n
? "bg-primary-600 text-white"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
}`}
>
{n}
</button>
))}
</div>
</div>
<div>
<label
htmlFor="review-comment"
className="mb-1.5 block text-xs font-semibold text-neutral-700 sm:text-sm"
>
Komentar (opsional)
</label>
<textarea
id="review-comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
maxLength={500}
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs text-neutral-800 placeholder:text-neutral-400 focus:bg-white sm:text-sm"
placeholder="Ceritakan pengalaman trip..."
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-secondary-600 py-2.5 text-xs font-bold text-white shadow-md hover:bg-secondary-700 disabled:opacity-50 sm:text-sm"
>
{loading ? "Menyimpan..." : myReview ? "Perbarui ulasan" : "Kirim ulasan"}
</button>
</form>
</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod/v4";
import { LIMITS } from "@/lib/limits";
export const upsertReviewSchema = z.object({
tripId: z.string().min(1, "Trip tidak valid"),
rating: z.coerce
.number()
.int("Rating harus bilangan bulat")
.min(1, "Rating minimal 1")
.max(5, "Rating maksimal 5"),
comment: z
.string()
.max(
LIMITS.MAX_REVIEW_COMMENT,
`Komentar maksimal ${LIMITS.MAX_REVIEW_COMMENT} karakter`
)
.transform((s) => {
const t = s.trim();
return t === "" ? undefined : t;
}),
});
export type UpsertReviewInput = z.infer<typeof upsertReviewSchema>;
+28 -5
View File
@@ -2,9 +2,10 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { createTripSchema } from "./schemas"; import { createTripSchema, tripImageUrlsSchema } from "./schemas";
import { tripService } from "@/server/services/trip.service"; import { tripService } from "@/server/services/trip.service";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
export async function createTripAction(formData: FormData) { export async function createTripAction(formData: FormData) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -28,21 +29,37 @@ export async function createTripAction(formData: FormData) {
return { error: result.error.issues[0].message }; return { error: result.error.issues[0].message };
} }
// Collect image URLs from form (multiple inputs named "imageUrls") const imageUrlsRaw = formData
const imageUrls = formData
.getAll("imageUrls") .getAll("imageUrls")
.map((v) => (v as string).trim()) .map((v) => (v as string).trim())
.filter(Boolean); .filter(Boolean);
const imagesParsed = tripImageUrlsSchema.safeParse(imageUrlsRaw);
if (!imagesParsed.success) {
return { error: imagesParsed.error.issues[0].message };
}
const imageUrls = imagesParsed.data;
const date = tripStoredInstantFromYmd(result.data.date);
let endDate = result.data.endDate
? tripStoredInstantFromYmd(result.data.endDate)
: undefined;
if (endDate && endDate.getTime() === date.getTime()) {
endDate = undefined;
}
try { try {
const trip = await tripService.createTrip({ const trip = await tripService.createTrip({
...result.data, ...result.data,
date: new Date(result.data.date), date,
endDate: result.data.endDate ? new Date(result.data.endDate) : undefined, endDate,
organizerId: session.user.id, organizerId: session.user.id,
imageUrls: imageUrls.length > 0 ? imageUrls : undefined, imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
}); });
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true, tripId: trip.id }; return { success: true, tripId: trip.id };
} catch (err) { } catch (err) {
return { error: (err as Error).message }; return { error: (err as Error).message };
@@ -58,6 +75,9 @@ export async function joinTripAction(tripId: string) {
try { try {
await tripService.joinTrip(tripId, session.user.id); await tripService.joinTrip(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
return { error: (err as Error).message }; return { error: (err as Error).message };
@@ -73,6 +93,9 @@ export async function cancelJoinAction(tripId: string) {
try { try {
await tripService.cancelJoin(tripId, session.user.id); await tripService.cancelJoin(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`); revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
return { error: (err as Error).message }; return { error: (err as Error).message };
@@ -12,6 +12,8 @@ interface JoinTripButtonProps {
isJoined: boolean; isJoined: boolean;
isFull: boolean; isFull: boolean;
tripStatus: string; tripStatus: string;
/** Tanggal berangkat sudah lewat (hari kalender UTC) */
isDeparturePast?: boolean;
} }
export function JoinTripButton({ export function JoinTripButton({
@@ -21,6 +23,7 @@ export function JoinTripButton({
isJoined, isJoined,
isFull, isFull,
tripStatus, tripStatus,
isDeparturePast,
}: JoinTripButtonProps) { }: JoinTripButtonProps) {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -45,6 +48,24 @@ export function JoinTripButton({
); );
} }
if (isJoined && isDeparturePast) {
return (
<div className="rounded-xl bg-neutral-100 px-3 py-3 text-center text-sm font-medium leading-relaxed text-neutral-600">
Kamu terdaftar di trip ini. Setelah tanggal berangkat lewat,{" "}
<span className="font-semibold text-neutral-700">pembatalan ditutup</span>
. Jika trip sudah selesai, isi ulasan di bagian bawah halaman.
</div>
);
}
if (isDeparturePast && !isJoined) {
return (
<div className="rounded-xl bg-neutral-100 py-3 text-center text-sm font-medium text-neutral-500">
Trip sudah lewat tanggal berangkat
</div>
);
}
if (tripStatus !== "OPEN" && !isJoined) { if (tripStatus !== "OPEN" && !isJoined) {
return ( return (
<div className="rounded-xl bg-neutral-100 py-3 text-center text-sm font-medium text-neutral-500"> <div className="rounded-xl bg-neutral-100 py-3 text-center text-sm font-medium text-neutral-500">
+5 -1
View File
@@ -90,8 +90,12 @@ export function TripFilter() {
{/* Date range */} {/* Date range */}
<div className="sm:w-64"> <div className="sm:w-64">
<label className="mb-1.5 block text-xs font-medium text-neutral-500"> <label className="mb-1.5 block text-xs font-medium text-neutral-500">
Rentang tanggal berangkat Rentang tanggal (UTC)
</label> </label>
<p className="mb-1 text-[10px] leading-snug text-neutral-400 sm:text-xs">
Menampilkan trip yang jadwalnya overlap rentang ini: multi hari pakai
tanggal pulang; satu hari pakai tanggal berangkat saja.
</p>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400"> <span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
<svg <svg
+106 -13
View File
@@ -1,17 +1,110 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { LIMITS } from "@/lib/limits";
import {
isTripDepartureDayPast,
tripStoredInstantFromYmd,
} from "@/lib/trip-dates";
export const createTripSchema = z.object({ export const tripImageUrlsSchema = z
title: z.string().min(3, "Judul minimal 3 karakter"), .array(
description: z.string().optional(), z
mountain: z.string().min(2, "Nama gunung harus diisi"), .string()
location: z.string().min(2, "Lokasi harus diisi"), .trim()
date: z.string().refine((val) => !isNaN(Date.parse(val)), "Tanggal berangkat tidak valid"), .max(LIMITS.MAX_URL_LENGTH, "URL terlalu panjang")
endDate: z .url("Setiap URL gambar harus valid (http/https)")
.string() )
.optional() .max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`);
.refine((val) => !val || !isNaN(Date.parse(val)), "Tanggal pulang tidak valid"),
maxParticipants: z.coerce.number().min(1, "Minimal 1 peserta"), export const createTripSchema = z
price: z.coerce.number().min(0, "Harga tidak valid"), .object({
}); title: z
.string()
.trim()
.min(3, "Judul minimal 3 karakter")
.max(
LIMITS.MAX_TITLE_LENGTH,
`Judul maksimal ${LIMITS.MAX_TITLE_LENGTH} karakter`
),
description: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.max(
LIMITS.MAX_DESCRIPTION_LENGTH,
`Deskripsi maksimal ${LIMITS.MAX_DESCRIPTION_LENGTH} karakter`
)
.optional()
),
mountain: z
.string()
.trim()
.min(2, "Nama gunung harus diisi")
.max(
LIMITS.MAX_MOUNTAIN_LENGTH,
`Nama gunung maksimal ${LIMITS.MAX_MOUNTAIN_LENGTH} karakter`
),
location: z
.string()
.trim()
.min(2, "Lokasi harus diisi")
.max(
LIMITS.MAX_LOCATION_LENGTH,
`Lokasi maksimal ${LIMITS.MAX_LOCATION_LENGTH} karakter`
),
date: z
.string()
.refine((val) => !Number.isNaN(Date.parse(val)), "Tanggal berangkat tidak valid"),
endDate: z
.string()
.optional()
.refine(
(val) => !val || !Number.isNaN(Date.parse(val)),
"Tanggal pulang tidak valid"
),
maxParticipants: z.coerce
.number()
.int("Jumlah peserta harus bilangan bulat")
.min(
LIMITS.MIN_PARTICIPANTS,
`Minimal ${LIMITS.MIN_PARTICIPANTS} peserta`
)
.max(
LIMITS.MAX_PARTICIPANTS,
`Maksimal ${LIMITS.MAX_PARTICIPANTS} peserta`
),
price: z.coerce
.number()
.int("Harga harus bilangan bulat (tanpa desimal)")
.min(0, "Harga tidak valid")
.max(
LIMITS.MAX_PRICE_IDR,
`Harga maksimal Rp ${LIMITS.MAX_PRICE_IDR.toLocaleString("id-ID")}`
),
})
.superRefine((data, ctx) => {
const dep = tripStoredInstantFromYmd(data.date);
if (!Number.isNaN(dep.getTime()) && isTripDepartureDayPast(dep)) {
ctx.addIssue({
code: "custom",
message: "Tanggal berangkat tidak boleh di masa lalu",
path: ["date"],
});
}
if (data.endDate) {
const startY = data.date.slice(0, 10);
const endY = data.endDate.slice(0, 10);
if (endY < startY) {
ctx.addIssue({
code: "custom",
message: "Tanggal pulang tidak boleh sebelum tanggal berangkat",
path: ["endDate"],
});
}
}
});
export type CreateTripInput = z.infer<typeof createTripSchema>; export type CreateTripInput = z.infer<typeof createTripSchema>;
+17
View File
@@ -0,0 +1,17 @@
/** Batasan bisnis & input (Phase 2) */
export const LIMITS = {
/** Maks trip yang bisa dibuat satu organizer per hari kalender (UTC) */
MAX_TRIPS_PER_ORGANIZER_PER_DAY: 15,
MIN_PARTICIPANTS: 1,
MAX_PARTICIPANTS: 99,
MAX_PRICE_IDR: 100_000_000,
MAX_TITLE_LENGTH: 120,
MAX_MOUNTAIN_LENGTH: 100,
MAX_LOCATION_LENGTH: 120,
MAX_DESCRIPTION_LENGTH: 5000,
MAX_REVIEW_COMMENT: 500,
MAX_IMAGE_URLS: 5,
MAX_URL_LENGTH: 2048,
MAX_NAME_LENGTH: 80,
MAX_PASSWORD_LENGTH: 72,
} as const;
+83
View File
@@ -0,0 +1,83 @@
/** Bantu perbandingan tanggal trip (hari kalender UTC). */
/** Parse `YYYY-MM-DD` sebagai awal hari UTC (hindari ambiguitas `new Date(string)` di berbagai TZ). */
export function utcDayStartFromYmd(ymd: string): Date {
const parts = ymd.split("-").map(Number);
const y = parts[0];
const m = parts[1];
const d = parts[2];
if (!y || !m || !d) {
return utcStartOfDay(new Date());
}
return new Date(Date.UTC(y, m - 1, d, 0, 0, 0, 0));
}
/** Akhir hari UTC untuk `YYYY-MM-DD`. */
export function utcDayEndFromYmd(ymd: string): Date {
const parts = ymd.split("-").map(Number);
const y = parts[0];
const m = parts[1];
const d = parts[2];
if (!y || !m || !d) {
return utcStartOfDay(new Date());
}
return new Date(Date.UTC(y, m - 1, d, 23, 59, 59, 999));
}
export function maxUtcDate(a: Date, b: Date): Date {
return a.getTime() >= b.getTime() ? a : b;
}
/**
* Tanggal dari DatePicker = hari kalender **lokal** browser.
* Encode `YYYY-MM-DD` dengan `getFullYear/getMonth/getDate` (bukan `toISOString()`)
* supaya tidak bergeser ke hari UTC lain (mis. WIB malam → UTC hari sebelumnya).
*/
export function formatLocalCalendarYmd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/**
* Simpan `trip.date` / `trip.endDate`: string `YYYY-MM-DD` diartikan sebagai
* **hari kalender UTC** yang sama (selaras dengan filter Open Trip).
*/
export function tripStoredInstantFromYmd(ymd: string): Date {
return utcDayStartFromYmd(ymd.trim().slice(0, 10));
}
export function utcStartOfDay(d: Date): Date {
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
);
}
/** true jika hari berangkat trip sudah lewat dibanding hari ini (UTC). */
export function isTripDepartureDayPast(departureDate: Date): boolean {
const today = utcStartOfDay(new Date());
const dep = utcStartOfDay(departureDate);
return dep.getTime() < today.getTime();
}
/**
* true jika sudah lewat akhir hari terakhir trip (tanggal pulang atau tanggal berangkat).
* Dipakai untuk membuka fitur ulasan.
*/
export function isPastTripLastDayForReview(
tripDate: Date,
tripEndDate: Date | null
): boolean {
const last = tripEndDate ?? tripDate;
const endMs = Date.UTC(
last.getUTCFullYear(),
last.getUTCMonth(),
last.getUTCDate(),
23,
59,
59,
999
);
return Date.now() > endMs;
}
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "TripReview" (
"id" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"tripId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "TripReview_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TripReview_tripId_userId_key" ON "TripReview"("tripId", "userId");
-- AddForeignKey
ALTER TABLE "TripReview" ADD CONSTRAINT "TripReview_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripReview" ADD CONSTRAINT "TripReview_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TripReview" ALTER COLUMN "updatedAt" DROP DEFAULT;
+18
View File
@@ -18,6 +18,7 @@ model User {
trips Trip[] trips Trip[]
participations TripParticipant[] participations TripParticipant[]
tripReviews TripReview[]
} }
model Trip { model Trip {
@@ -39,6 +40,23 @@ model Trip {
participants TripParticipant[] participants TripParticipant[]
images TripImage[] images TripImage[]
reviews TripReview[]
}
model TripReview {
id String @id @default(cuid())
rating Int
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tripId String
trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([tripId, userId])
} }
model TripImage { model TripImage {
+33 -16
View File
@@ -12,6 +12,7 @@ async function main() {
console.log("🌱 Seeding database...\n"); console.log("🌱 Seeding database...\n");
// Clean existing data (order matters for FK) // Clean existing data (order matters for FK)
await prisma.tripReview.deleteMany();
await prisma.tripParticipant.deleteMany(); await prisma.tripParticipant.deleteMany();
await prisma.tripImage.deleteMany(); await prisma.tripImage.deleteMany();
await prisma.trip.deleteMany(); await prisma.trip.deleteMany();
@@ -93,11 +94,18 @@ async function main() {
console.log(" Password semua: password123\n"); console.log(" Password semua: password123\n");
// ==================== TRIPS + IMAGES ==================== // ==================== TRIPS + IMAGES ====================
/**
* Tanggal disimpan eksplisit di UTC agar filter `from`/`to` (YYYY-MM-DD UTC)
* cocok dengan yang tampil di daftar.
*
* - Multi hari: isi `endDate` = hari terakhir trip (UTC).
* - Satu hari / night hike satu malam: `endDate` null — filter memakai instan `date`
* dalam rentang hari UTC yang sama (jam tetap masuk hari itu).
*/
const utc = (y: number, m0: number, d: number, h = 12, min = 0) =>
new Date(Date.UTC(y, m0, d, h, min, 0, 0));
const now = new Date(); // --- Trip 1: Papandayan (by Dede Inoen) — 2 hari ---
const day = 24 * 60 * 60 * 1000;
// --- Trip 1: Papandayan (by Dede Inoen) ---
const trip1 = await prisma.trip.create({ const trip1 = await prisma.trip.create({
data: { data: {
title: "Open Trip Papandayan Weekend", title: "Open Trip Papandayan Weekend",
@@ -112,7 +120,8 @@ Itinerary:
- Minggu: Sunrise → Turun → Pulang`, - Minggu: Sunrise → Turun → Pulang`,
mountain: "Gunung Papandayan", mountain: "Gunung Papandayan",
location: "Garut, Jawa Barat", location: "Garut, Jawa Barat",
date: new Date(now.getTime() + 3 * day), date: utc(2026, 3, 23, 8, 0),
endDate: utc(2026, 3, 24, 18, 0),
maxParticipants: 10, maxParticipants: 10,
price: 250000, price: 250000,
status: "OPEN", status: "OPEN",
@@ -127,7 +136,7 @@ Itinerary:
}, },
}); });
// --- Trip 2: Ciremai (by Panji Petualang) --- // --- Trip 2: Ciremai (by Panji Petualang) — 2 hari ---
const trip2 = await prisma.trip.create({ const trip2 = await prisma.trip.create({
data: { data: {
title: "Pendakian Ciremai via Apuy", title: "Pendakian Ciremai via Apuy",
@@ -142,7 +151,8 @@ Itinerary:
- Hari 2: Summit attack → Turun → Pulang`, - Hari 2: Summit attack → Turun → Pulang`,
mountain: "Gunung Ciremai", mountain: "Gunung Ciremai",
location: "Kuningan, Jawa Barat", location: "Kuningan, Jawa Barat",
date: new Date(now.getTime() + 5 * day), date: utc(2026, 3, 25, 4, 0),
endDate: utc(2026, 3, 26, 18, 0),
maxParticipants: 8, maxParticipants: 8,
price: 350000, price: 350000,
status: "OPEN", status: "OPEN",
@@ -157,7 +167,7 @@ Itinerary:
}, },
}); });
// --- Trip 3: Gede-Pangrango (by Fiersa Besari) --- // --- Trip 3: Gede-Pangrango (by Fiersa Besari) — 2 hari ---
const trip3 = await prisma.trip.create({ const trip3 = await prisma.trip.create({
data: { data: {
title: "Sunrise Trip Gede-Pangrango", title: "Sunrise Trip Gede-Pangrango",
@@ -170,7 +180,8 @@ Itinerary:
Start malam, summit saat sunrise. View epic dijamin!`, Start malam, summit saat sunrise. View epic dijamin!`,
mountain: "Gunung Gede", mountain: "Gunung Gede",
location: "Bogor/Cianjur, Jawa Barat", location: "Bogor/Cianjur, Jawa Barat",
date: new Date(now.getTime() + 6 * day), date: utc(2026, 3, 27, 22, 0),
endDate: utc(2026, 3, 28, 16, 0),
maxParticipants: 12, maxParticipants: 12,
price: 280000, price: 280000,
status: "OPEN", status: "OPEN",
@@ -186,7 +197,7 @@ Start malam, summit saat sunrise. View epic dijamin!`,
}, },
}); });
// --- Trip 4: Tangkuban Parahu (by Dede Inoen) --- // --- Trip 4: Tangkuban Parahu (by Dede Inoen) — 1 hari ---
const trip4 = await prisma.trip.create({ const trip4 = await prisma.trip.create({
data: { data: {
title: "Trip Hemat Tangkuban Parahu", title: "Trip Hemat Tangkuban Parahu",
@@ -199,7 +210,8 @@ Start malam, summit saat sunrise. View epic dijamin!`,
Explore Kawah Ratu, Kawah Domas, foto-foto, terus makan sate maranggi!`, Explore Kawah Ratu, Kawah Domas, foto-foto, terus makan sate maranggi!`,
mountain: "Gunung Tangkuban Parahu", mountain: "Gunung Tangkuban Parahu",
location: "Bandung, Jawa Barat", location: "Bandung, Jawa Barat",
date: new Date(now.getTime() + 2 * day), date: utc(2026, 3, 22, 0, 0),
endDate: null,
maxParticipants: 15, maxParticipants: 15,
price: 120000, price: 120000,
status: "OPEN", status: "OPEN",
@@ -213,7 +225,7 @@ Explore Kawah Ratu, Kawah Domas, foto-foto, terus makan sate maranggi!`,
}, },
}); });
// --- Trip 5: Malabar (by Fiersa Besari) --- // --- Trip 5: Malabar (by Fiersa Besari) — 1 hari (night hike, `endDate` null) ---
const trip5 = await prisma.trip.create({ const trip5 = await prisma.trip.create({
data: { data: {
title: "Malabar Night Hike", title: "Malabar Night Hike",
@@ -226,7 +238,8 @@ Explore Kawah Ratu, Kawah Domas, foto-foto, terus makan sate maranggi!`,
Trip ringan, 3-4 jam naik. Cocok buat yang mau healing malam-malam.`, Trip ringan, 3-4 jam naik. Cocok buat yang mau healing malam-malam.`,
mountain: "Gunung Malabar", mountain: "Gunung Malabar",
location: "Bandung, Jawa Barat", location: "Bandung, Jawa Barat",
date: new Date(now.getTime() + 4 * day), date: utc(2026, 3, 20, 14, 0),
endDate: null,
maxParticipants: 10, maxParticipants: 10,
price: 150000, price: 150000,
status: "OPEN", status: "OPEN",
@@ -241,7 +254,7 @@ Trip ringan, 3-4 jam naik. Cocok buat yang mau healing malam-malam.`,
}, },
}); });
// --- Trip 6: Guntur (by Panji Petualang) --- // --- Trip 6: Guntur (by Panji Petualang) — 2 hari ---
const trip6 = await prisma.trip.create({ const trip6 = await prisma.trip.create({
data: { data: {
title: "Guntur Challenge Trip", title: "Guntur Challenge Trip",
@@ -254,7 +267,8 @@ Trip ringan, 3-4 jam naik. Cocok buat yang mau healing malam-malam.`,
Buat yang suka challenge. Pemandangan kawah aktif dari dekat!`, Buat yang suka challenge. Pemandangan kawah aktif dari dekat!`,
mountain: "Gunung Guntur", mountain: "Gunung Guntur",
location: "Garut, Jawa Barat", location: "Garut, Jawa Barat",
date: new Date(now.getTime() + 10 * day), date: utc(2026, 3, 30, 4, 0),
endDate: utc(2026, 4, 1, 18, 0),
maxParticipants: 8, maxParticipants: 8,
price: 300000, price: 300000,
status: "OPEN", status: "OPEN",
@@ -269,7 +283,10 @@ Buat yang suka challenge. Pemandangan kawah aktif dari dekat!`,
}, },
}); });
console.log("✅ 6 Trips + images created\n"); console.log("✅ 6 Trips + images created (tanggal UTC + endDate untuk trip multi hari)\n");
console.log(
` Guntur: ${trip6.title} ${trip6.date.toISOString().slice(0, 10)}${trip6.endDate?.toISOString().slice(0, 10) ?? "-"}\n`
);
// ==================== PARTICIPANTS ==================== // ==================== PARTICIPANTS ====================
+27
View File
@@ -25,4 +25,31 @@ export const participantRepo = {
data: { status: "CANCELLED" }, data: { status: "CANCELLED" },
}); });
}, },
async reactivate(tripId: string, userId: string) {
return prisma.tripParticipant.update({
where: { tripId_userId: { tripId, userId } },
data: { status: "CONFIRMED" },
});
},
/** Partisipasi user beserta trip (untuk profil & riwayat). */
async findWithTripForProfile(userId: string) {
return prisma.tripParticipant.findMany({
where: { userId },
include: {
trip: {
include: {
organizer: { select: { id: true, name: true } },
images: { orderBy: { order: "asc" }, take: 1 },
reviews: {
where: { userId },
select: { id: true, rating: true },
},
},
},
},
orderBy: { createdAt: "desc" },
});
},
}; };
+26
View File
@@ -0,0 +1,26 @@
import { prisma } from "@/lib/prisma";
export const reviewRepo = {
async upsert(data: {
tripId: string;
userId: string;
rating: number;
comment: string | null;
}) {
return prisma.tripReview.upsert({
where: {
tripId_userId: { tripId: data.tripId, userId: data.userId },
},
create: {
tripId: data.tripId,
userId: data.userId,
rating: data.rating,
comment: data.comment,
},
update: {
rating: data.rating,
comment: data.comment,
},
});
},
};
+88 -22
View File
@@ -1,5 +1,11 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Prisma } from "@/app/generated/prisma/client"; import { Prisma } from "@/app/generated/prisma/client";
import {
utcStartOfDay,
utcDayStartFromYmd,
utcDayEndFromYmd,
maxUtcDate,
} from "@/lib/trip-dates";
export const tripRepo = { export const tripRepo = {
async findAll() { async findAll() {
@@ -7,44 +13,76 @@ export const tripRepo = {
include: { include: {
organizer: { select: { id: true, name: true, image: true } }, organizer: { select: { id: true, name: true, image: true } },
images: { orderBy: { order: "asc" }, take: 1 }, images: { orderBy: { order: "asc" }, take: 1 },
_count: { select: { participants: true } }, _count: {
select: {
participants: { where: { status: { not: "CANCELLED" } } },
},
},
}, },
orderBy: { date: "asc" }, orderBy: { date: "asc" },
}); });
}, },
async findOpen(filters?: { q?: string; from?: string; to?: string }) { async findOpen(filters?: { q?: string; from?: string; to?: string }) {
const where: Prisma.TripWhereInput = { const todayStart = utcStartOfDay(new Date());
status: "OPEN",
date: { gte: new Date() }, const andParts: Prisma.TripWhereInput[] = [{ status: "OPEN" }];
};
if (!filters?.from && !filters?.to) {
andParts.push({ date: { gte: todayStart } });
} else {
const userRangeStart = filters.from
? utcDayStartFromYmd(filters.from)
: todayStart;
const userRangeEnd = filters.to
? utcDayEndFromYmd(filters.to)
: utcDayEndFromYmd("2099-12-31");
const rangeStart = maxUtcDate(todayStart, userRangeStart);
const rangeEnd = userRangeEnd;
andParts.push({
OR: [
{
AND: [
{ endDate: { not: null } },
{ date: { lte: rangeEnd } },
{ endDate: { gte: rangeStart } },
],
},
{
AND: [
{ endDate: null },
{ date: { gte: rangeStart } },
{ date: { lte: rangeEnd } },
],
},
],
});
}
if (filters?.q) { if (filters?.q) {
where.OR = [ andParts.push({
{ title: { contains: filters.q, mode: "insensitive" } }, OR: [
{ mountain: { contains: filters.q, mode: "insensitive" } }, { title: { contains: filters.q, mode: "insensitive" } },
{ location: { contains: filters.q, mode: "insensitive" } }, { mountain: { contains: filters.q, mode: "insensitive" } },
]; { location: { contains: filters.q, mode: "insensitive" } },
],
});
} }
if (filters?.from || filters?.to) { const where: Prisma.TripWhereInput = { AND: andParts };
const dateFilter: Prisma.DateTimeFilter = { gte: new Date() };
if (filters.from) {
const fromDate = new Date(filters.from);
if (fromDate > new Date()) dateFilter.gte = fromDate;
}
if (filters.to) {
dateFilter.lte = new Date(filters.to + "T23:59:59.999Z");
}
where.date = dateFilter;
}
return prisma.trip.findMany({ return prisma.trip.findMany({
where, where,
include: { include: {
organizer: { select: { id: true, name: true, image: true } }, organizer: { select: { id: true, name: true, image: true } },
images: { orderBy: { order: "asc" }, take: 1 }, images: { orderBy: { order: "asc" }, take: 1 },
_count: { select: { participants: true } }, _count: {
select: {
participants: { where: { status: { not: "CANCELLED" } } },
},
},
}, },
orderBy: { date: "asc" }, orderBy: { date: "asc" },
}); });
@@ -59,10 +97,38 @@ export const tripRepo = {
participants: { participants: {
include: { user: { select: { id: true, name: true, image: true } } }, include: { user: { select: { id: true, name: true, image: true } } },
}, },
reviews: {
orderBy: { createdAt: "desc" },
include: {
user: { select: { id: true, name: true, image: true } },
},
},
}, },
}); });
}, },
async countByOrganizerSince(organizerId: string, since: Date) {
return prisma.trip.count({
where: { organizerId, createdAt: { gte: since } },
});
},
/** Semua trip yang dibuat user (semua status), terbaru dulu — untuk profil. */
async findByOrganizerId(organizerId: string) {
return prisma.trip.findMany({
where: { organizerId },
include: {
images: { orderBy: { order: "asc" }, take: 1 },
_count: {
select: {
participants: { where: { status: { not: "CANCELLED" } } },
},
},
},
orderBy: { date: "desc" },
});
},
async create(data: Prisma.TripCreateInput) { async create(data: Prisma.TripCreateInput) {
return prisma.trip.create({ data }); return prisma.trip.create({ data });
}, },
+14
View File
@@ -10,6 +10,20 @@ export const userRepo = {
return prisma.user.findUnique({ where: { id } }); return prisma.user.findUnique({ where: { id } });
}, },
/** Profil publik (tanpa password) untuk halaman akun. */
async findPublicProfileById(id: string) {
return prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
},
});
},
async create(data: Prisma.UserCreateInput) { async create(data: Prisma.UserCreateInput) {
return prisma.user.create({ data }); return prisma.user.create({ data });
}, },
+51
View File
@@ -0,0 +1,51 @@
import { userRepo } from "@/server/repositories/user.repo";
import { tripRepo } from "@/server/repositories/trip.repo";
import { participantRepo } from "@/server/repositories/participant.repo";
import { isPastTripLastDayForReview } from "@/lib/trip-dates";
export const profileService = {
async getProfileDashboard(userId: string) {
const user = await userRepo.findPublicProfileById(userId);
if (!user) {
throw new Error("Pengguna tidak ditemukan");
}
const [organizedTrips, participations] = await Promise.all([
tripRepo.findByOrganizerId(userId),
participantRepo.findWithTripForProfile(userId),
]);
const activeJoined = participations
.filter((p) => p.status !== "CANCELLED")
.sort(
(a, b) =>
new Date(b.trip.date).getTime() - new Date(a.trip.date).getTime()
);
const cancelledJoined = participations
.filter((p) => p.status === "CANCELLED")
.sort(
(a, b) =>
new Date(b.trip.date).getTime() - new Date(a.trip.date).getTime()
);
const reviewable = activeJoined
.filter((p) => {
if (p.status !== "CONFIRMED") return false;
const t = p.trip;
if (t.organizerId === userId) return false;
return isPastTripLastDayForReview(t.date, t.endDate);
})
.sort(
(a, b) =>
new Date(b.trip.date).getTime() - new Date(a.trip.date).getTime()
);
return {
user,
organizedTrips,
activeJoined,
cancelledJoined,
reviewable,
};
},
};
+44
View File
@@ -0,0 +1,44 @@
import { tripRepo } from "@/server/repositories/trip.repo";
import { participantRepo } from "@/server/repositories/participant.repo";
import { reviewRepo } from "@/server/repositories/review.repo";
import { isPastTripLastDayForReview } from "@/lib/trip-dates";
export const reviewService = {
async upsertReview(
tripId: string,
userId: string,
input: { rating: number; comment?: string | null }
) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (trip.organizerId === userId) {
throw new Error("Organizer tidak bisa mengulas trip sendiri");
}
const participation = await participantRepo.findByTripAndUser(
tripId,
userId
);
if (!participation || participation.status !== "CONFIRMED") {
throw new Error(
"Hanya peserta yang terdaftar (aktif) yang bisa memberi ulasan"
);
}
if (!isPastTripLastDayForReview(trip.date, trip.endDate)) {
throw new Error(
"Ulasan bisa diberikan setelah tanggal selesai trip (hari terakhir pendakian)"
);
}
return reviewRepo.upsert({
tripId,
userId,
rating: input.rating,
comment: input.comment ?? null,
});
},
};
+43 -3
View File
@@ -1,5 +1,7 @@
import { tripRepo } from "@/server/repositories/trip.repo"; import { tripRepo } from "@/server/repositories/trip.repo";
import { participantRepo } from "@/server/repositories/participant.repo"; import { participantRepo } from "@/server/repositories/participant.repo";
import { LIMITS } from "@/lib/limits";
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
interface CreateTripInput { interface CreateTripInput {
title: string; title: string;
@@ -32,6 +34,25 @@ export const tripService = {
}, },
async createTrip(input: CreateTripInput) { async createTrip(input: CreateTripInput) {
const since = utcStartOfDay(new Date());
const todayCount = await tripRepo.countByOrganizerSince(
input.organizerId,
since
);
if (todayCount >= LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY) {
throw new Error(
`Batas harian: maksimal ${LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY} trip per hari (UTC). Coba lagi besok.`
);
}
if (isTripDepartureDayPast(input.date)) {
throw new Error("Tanggal berangkat tidak boleh di masa lalu");
}
if (input.endDate && input.endDate.getTime() < input.date.getTime()) {
throw new Error("Tanggal pulang tidak boleh sebelum tanggal berangkat");
}
const images = input.imageUrls?.length const images = input.imageUrls?.length
? { ? {
create: input.imageUrls.map((url, i) => ({ url, order: i })), create: input.imageUrls.map((url, i) => ({ url, order: i })),
@@ -62,6 +83,12 @@ export const tripService = {
throw new Error("Trip tidak tersedia untuk pendaftaran"); throw new Error("Trip tidak tersedia untuk pendaftaran");
} }
if (isTripDepartureDayPast(trip.date)) {
throw new Error(
"Trip sudah melewati tanggal berangkat, tidak bisa mendaftar"
);
}
if (trip.organizerId === userId) { if (trip.organizerId === userId) {
throw new Error("Organizer tidak bisa join trip sendiri"); throw new Error("Organizer tidak bisa join trip sendiri");
} }
@@ -77,7 +104,10 @@ export const tripService = {
throw new Error("Trip sudah penuh"); throw new Error("Trip sudah penuh");
} }
const participant = await participantRepo.create(tripId, userId); const participant =
existing?.status === "CANCELLED"
? await participantRepo.reactivate(tripId, userId)
: await participantRepo.create(tripId, userId);
const newCount = await participantRepo.countByTrip(tripId); const newCount = await participantRepo.countByTrip(tripId);
if (newCount >= trip.maxParticipants) { if (newCount >= trip.maxParticipants) {
@@ -88,6 +118,17 @@ export const tripService = {
}, },
async cancelJoin(tripId: string, userId: string) { async cancelJoin(tripId: string, userId: string) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (isTripDepartureDayPast(trip.date)) {
throw new Error(
"Tanggal berangkat trip sudah lewat — pendaftaran tidak bisa dibatalkan. Jika trip sudah selesai, gunakan bagian ulasan."
);
}
const existing = await participantRepo.findByTripAndUser(tripId, userId); const existing = await participantRepo.findByTripAndUser(tripId, userId);
if (!existing || existing.status === "CANCELLED") { if (!existing || existing.status === "CANCELLED") {
throw new Error("Kamu tidak terdaftar di trip ini"); throw new Error("Kamu tidak terdaftar di trip ini");
@@ -95,8 +136,7 @@ export const tripService = {
const result = await participantRepo.cancel(tripId, userId); const result = await participantRepo.cancel(tripId, userId);
const trip = await tripRepo.findById(tripId); if (trip.status === "FULL") {
if (trip && trip.status === "FULL") {
const count = await participantRepo.countByTrip(tripId); const count = await participantRepo.countByTrip(tripId);
if (count < trip.maxParticipants) { if (count < trip.maxParticipants) {
await tripRepo.updateStatus(tripId, "OPEN"); await tripRepo.updateStatus(tripId, "OPEN");