create review and profile
This commit is contained in:
@@ -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
|
||||
npm install
|
||||
npx prisma migrate dev
|
||||
npm run seed # opsional: data contoh
|
||||
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
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [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.
|
||||
- [Next.js Documentation](https://nextjs.org/docs)
|
||||
- [Prisma Documentation](https://www.prisma.io/docs)
|
||||
|
||||
@@ -8,6 +8,7 @@ import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import { createTripAction } from "@/features/trip/actions";
|
||||
import { ImageUrlInput } from "@/features/trip/components/image-url-input";
|
||||
import { formatLocalCalendarYmd } from "@/lib/trip-dates";
|
||||
|
||||
const SAMPLE_MOUNTAINS = [
|
||||
{ name: "Gunung Papandayan", location: "Garut, Jawa Barat" },
|
||||
@@ -72,10 +73,15 @@ export default function CreateTripPage() {
|
||||
setLoading(true);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
// Set date values from DatePicker state
|
||||
formData.set("date", startDate.toISOString().split("T")[0]);
|
||||
// Hari kalender lokal → YYYY-MM-DD (bukan toISOString, supaya tidak geser ke UTC)
|
||||
formData.set("date", formatLocalCalendarYmd(startDate));
|
||||
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
|
||||
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">
|
||||
Tanggal Berangkat — Pulang
|
||||
</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">
|
||||
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
|
||||
<svg
|
||||
|
||||
@@ -27,6 +27,11 @@ export type User = Prisma.UserModel
|
||||
*
|
||||
*/
|
||||
export type Trip = Prisma.TripModel
|
||||
/**
|
||||
* Model TripReview
|
||||
*
|
||||
*/
|
||||
export type TripReview = Prisma.TripReviewModel
|
||||
/**
|
||||
* Model TripImage
|
||||
*
|
||||
|
||||
@@ -51,6 +51,11 @@ export type User = Prisma.UserModel
|
||||
*
|
||||
*/
|
||||
export type Trip = Prisma.TripModel
|
||||
/**
|
||||
* Model TripReview
|
||||
*
|
||||
*/
|
||||
export type TripReview = Prisma.TripReviewModel
|
||||
/**
|
||||
* 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 = {
|
||||
User: 'User',
|
||||
Trip: 'Trip',
|
||||
TripReview: 'TripReview',
|
||||
TripImage: 'TripImage',
|
||||
TripParticipant: 'TripParticipant'
|
||||
} as const
|
||||
@@ -403,7 +404,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "user" | "trip" | "tripImage" | "tripParticipant"
|
||||
modelProps: "user" | "trip" | "tripReview" | "tripImage" | "tripParticipant"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
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: {
|
||||
payload: Prisma.$TripImagePayload<ExtArgs>
|
||||
fields: Prisma.TripImageFieldRefs
|
||||
@@ -774,6 +849,19 @@ export const 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 = {
|
||||
id: 'id',
|
||||
url: 'url',
|
||||
@@ -1006,6 +1094,7 @@ export type PrismaClientOptions = ({
|
||||
export type GlobalOmitConfig = {
|
||||
user?: Prisma.UserOmit
|
||||
trip?: Prisma.TripOmit
|
||||
tripReview?: Prisma.TripReviewOmit
|
||||
tripImage?: Prisma.TripImageOmit
|
||||
tripParticipant?: Prisma.TripParticipantOmit
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export const AnyNull = runtime.AnyNull
|
||||
export const ModelName = {
|
||||
User: 'User',
|
||||
Trip: 'Trip',
|
||||
TripReview: 'TripReview',
|
||||
TripImage: 'TripImage',
|
||||
TripParticipant: 'TripParticipant'
|
||||
} as const
|
||||
@@ -105,6 +106,19 @@ export const 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 = {
|
||||
id: 'id',
|
||||
url: 'url',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
export type * from './models/User'
|
||||
export type * from './models/Trip'
|
||||
export type * from './models/TripReview'
|
||||
export type * from './models/TripImage'
|
||||
export type * from './models/TripParticipant'
|
||||
export type * from './commonInputTypes'
|
||||
@@ -287,6 +287,7 @@ export type TripWhereInput = {
|
||||
organizer?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
|
||||
participants?: Prisma.TripParticipantListRelationFilter
|
||||
images?: Prisma.TripImageListRelationFilter
|
||||
reviews?: Prisma.TripReviewListRelationFilter
|
||||
}
|
||||
|
||||
export type TripOrderByWithRelationInput = {
|
||||
@@ -306,6 +307,7 @@ export type TripOrderByWithRelationInput = {
|
||||
organizer?: Prisma.UserOrderByWithRelationInput
|
||||
participants?: Prisma.TripParticipantOrderByRelationAggregateInput
|
||||
images?: Prisma.TripImageOrderByRelationAggregateInput
|
||||
reviews?: Prisma.TripReviewOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type TripWhereUniqueInput = Prisma.AtLeast<{
|
||||
@@ -328,6 +330,7 @@ export type TripWhereUniqueInput = Prisma.AtLeast<{
|
||||
organizer?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
|
||||
participants?: Prisma.TripParticipantListRelationFilter
|
||||
images?: Prisma.TripImageListRelationFilter
|
||||
reviews?: Prisma.TripReviewListRelationFilter
|
||||
}, "id">
|
||||
|
||||
export type TripOrderByWithAggregationInput = {
|
||||
@@ -386,6 +389,7 @@ export type TripCreateInput = {
|
||||
organizer: Prisma.UserCreateNestedOneWithoutTripsInput
|
||||
participants?: Prisma.TripParticipantCreateNestedManyWithoutTripInput
|
||||
images?: Prisma.TripImageCreateNestedManyWithoutTripInput
|
||||
reviews?: Prisma.TripReviewCreateNestedManyWithoutTripInput
|
||||
}
|
||||
|
||||
export type TripUncheckedCreateInput = {
|
||||
@@ -404,6 +408,7 @@ export type TripUncheckedCreateInput = {
|
||||
organizerId: string
|
||||
participants?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutTripInput
|
||||
images?: Prisma.TripImageUncheckedCreateNestedManyWithoutTripInput
|
||||
reviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutTripInput
|
||||
}
|
||||
|
||||
export type TripUpdateInput = {
|
||||
@@ -422,6 +427,7 @@ export type TripUpdateInput = {
|
||||
organizer?: Prisma.UserUpdateOneRequiredWithoutTripsNestedInput
|
||||
participants?: Prisma.TripParticipantUpdateManyWithoutTripNestedInput
|
||||
images?: Prisma.TripImageUpdateManyWithoutTripNestedInput
|
||||
reviews?: Prisma.TripReviewUpdateManyWithoutTripNestedInput
|
||||
}
|
||||
|
||||
export type TripUncheckedUpdateInput = {
|
||||
@@ -440,6 +446,7 @@ export type TripUncheckedUpdateInput = {
|
||||
organizerId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
participants?: Prisma.TripParticipantUncheckedUpdateManyWithoutTripNestedInput
|
||||
images?: Prisma.TripImageUncheckedUpdateManyWithoutTripNestedInput
|
||||
reviews?: Prisma.TripReviewUncheckedUpdateManyWithoutTripNestedInput
|
||||
}
|
||||
|
||||
export type TripCreateManyInput = {
|
||||
@@ -620,6 +627,20 @@ export type EnumTripStatusFieldUpdateOperationsInput = {
|
||||
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 = {
|
||||
create?: Prisma.XOR<Prisma.TripCreateWithoutImagesInput, Prisma.TripUncheckedCreateWithoutImagesInput>
|
||||
connectOrCreate?: Prisma.TripCreateOrConnectWithoutImagesInput
|
||||
@@ -663,6 +684,7 @@ export type TripCreateWithoutOrganizerInput = {
|
||||
updatedAt?: Date | string
|
||||
participants?: Prisma.TripParticipantCreateNestedManyWithoutTripInput
|
||||
images?: Prisma.TripImageCreateNestedManyWithoutTripInput
|
||||
reviews?: Prisma.TripReviewCreateNestedManyWithoutTripInput
|
||||
}
|
||||
|
||||
export type TripUncheckedCreateWithoutOrganizerInput = {
|
||||
@@ -680,6 +702,7 @@ export type TripUncheckedCreateWithoutOrganizerInput = {
|
||||
updatedAt?: Date | string
|
||||
participants?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutTripInput
|
||||
images?: Prisma.TripImageUncheckedCreateNestedManyWithoutTripInput
|
||||
reviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutTripInput
|
||||
}
|
||||
|
||||
export type TripCreateOrConnectWithoutOrganizerInput = {
|
||||
@@ -727,6 +750,94 @@ export type TripScalarWhereInput = {
|
||||
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 = {
|
||||
id?: string
|
||||
title: string
|
||||
@@ -742,6 +853,7 @@ export type TripCreateWithoutImagesInput = {
|
||||
updatedAt?: Date | string
|
||||
organizer: Prisma.UserCreateNestedOneWithoutTripsInput
|
||||
participants?: Prisma.TripParticipantCreateNestedManyWithoutTripInput
|
||||
reviews?: Prisma.TripReviewCreateNestedManyWithoutTripInput
|
||||
}
|
||||
|
||||
export type TripUncheckedCreateWithoutImagesInput = {
|
||||
@@ -759,6 +871,7 @@ export type TripUncheckedCreateWithoutImagesInput = {
|
||||
updatedAt?: Date | string
|
||||
organizerId: string
|
||||
participants?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutTripInput
|
||||
reviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutTripInput
|
||||
}
|
||||
|
||||
export type TripCreateOrConnectWithoutImagesInput = {
|
||||
@@ -792,6 +905,7 @@ export type TripUpdateWithoutImagesInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
organizer?: Prisma.UserUpdateOneRequiredWithoutTripsNestedInput
|
||||
participants?: Prisma.TripParticipantUpdateManyWithoutTripNestedInput
|
||||
reviews?: Prisma.TripReviewUpdateManyWithoutTripNestedInput
|
||||
}
|
||||
|
||||
export type TripUncheckedUpdateWithoutImagesInput = {
|
||||
@@ -809,6 +923,7 @@ export type TripUncheckedUpdateWithoutImagesInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
organizerId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
participants?: Prisma.TripParticipantUncheckedUpdateManyWithoutTripNestedInput
|
||||
reviews?: Prisma.TripReviewUncheckedUpdateManyWithoutTripNestedInput
|
||||
}
|
||||
|
||||
export type TripCreateWithoutParticipantsInput = {
|
||||
@@ -826,6 +941,7 @@ export type TripCreateWithoutParticipantsInput = {
|
||||
updatedAt?: Date | string
|
||||
organizer: Prisma.UserCreateNestedOneWithoutTripsInput
|
||||
images?: Prisma.TripImageCreateNestedManyWithoutTripInput
|
||||
reviews?: Prisma.TripReviewCreateNestedManyWithoutTripInput
|
||||
}
|
||||
|
||||
export type TripUncheckedCreateWithoutParticipantsInput = {
|
||||
@@ -843,6 +959,7 @@ export type TripUncheckedCreateWithoutParticipantsInput = {
|
||||
updatedAt?: Date | string
|
||||
organizerId: string
|
||||
images?: Prisma.TripImageUncheckedCreateNestedManyWithoutTripInput
|
||||
reviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutTripInput
|
||||
}
|
||||
|
||||
export type TripCreateOrConnectWithoutParticipantsInput = {
|
||||
@@ -876,6 +993,7 @@ export type TripUpdateWithoutParticipantsInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
organizer?: Prisma.UserUpdateOneRequiredWithoutTripsNestedInput
|
||||
images?: Prisma.TripImageUpdateManyWithoutTripNestedInput
|
||||
reviews?: Prisma.TripReviewUpdateManyWithoutTripNestedInput
|
||||
}
|
||||
|
||||
export type TripUncheckedUpdateWithoutParticipantsInput = {
|
||||
@@ -893,6 +1011,7 @@ export type TripUncheckedUpdateWithoutParticipantsInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
organizerId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
images?: Prisma.TripImageUncheckedUpdateManyWithoutTripNestedInput
|
||||
reviews?: Prisma.TripReviewUncheckedUpdateManyWithoutTripNestedInput
|
||||
}
|
||||
|
||||
export type TripCreateManyOrganizerInput = {
|
||||
@@ -925,6 +1044,7 @@ export type TripUpdateWithoutOrganizerInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
participants?: Prisma.TripParticipantUpdateManyWithoutTripNestedInput
|
||||
images?: Prisma.TripImageUpdateManyWithoutTripNestedInput
|
||||
reviews?: Prisma.TripReviewUpdateManyWithoutTripNestedInput
|
||||
}
|
||||
|
||||
export type TripUncheckedUpdateWithoutOrganizerInput = {
|
||||
@@ -942,6 +1062,7 @@ export type TripUncheckedUpdateWithoutOrganizerInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
participants?: Prisma.TripParticipantUncheckedUpdateManyWithoutTripNestedInput
|
||||
images?: Prisma.TripImageUncheckedUpdateManyWithoutTripNestedInput
|
||||
reviews?: Prisma.TripReviewUncheckedUpdateManyWithoutTripNestedInput
|
||||
}
|
||||
|
||||
export type TripUncheckedUpdateManyWithoutOrganizerInput = {
|
||||
@@ -967,11 +1088,13 @@ export type TripUncheckedUpdateManyWithoutOrganizerInput = {
|
||||
export type TripCountOutputType = {
|
||||
participants: number
|
||||
images: number
|
||||
reviews: number
|
||||
}
|
||||
|
||||
export type TripCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
participants?: boolean | TripCountOutputTypeCountParticipantsArgs
|
||||
images?: boolean | TripCountOutputTypeCountImagesArgs
|
||||
reviews?: boolean | TripCountOutputTypeCountReviewsArgs
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -998,6 +1121,13 @@ export type TripCountOutputTypeCountImagesArgs<ExtArgs extends runtime.Types.Ext
|
||||
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<{
|
||||
id?: boolean
|
||||
@@ -1016,6 +1146,7 @@ export type TripSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
||||
organizer?: boolean | Prisma.UserDefaultArgs<ExtArgs>
|
||||
participants?: boolean | Prisma.Trip$participantsArgs<ExtArgs>
|
||||
images?: boolean | Prisma.Trip$imagesArgs<ExtArgs>
|
||||
reviews?: boolean | Prisma.Trip$reviewsArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.TripCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["trip"]>
|
||||
|
||||
@@ -1074,6 +1205,7 @@ export type TripInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
organizer?: boolean | Prisma.UserDefaultArgs<ExtArgs>
|
||||
participants?: boolean | Prisma.Trip$participantsArgs<ExtArgs>
|
||||
images?: boolean | Prisma.Trip$imagesArgs<ExtArgs>
|
||||
reviews?: boolean | Prisma.Trip$reviewsArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.TripCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
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>
|
||||
participants: Prisma.$TripParticipantPayload<ExtArgs>[]
|
||||
images: Prisma.$TripImagePayload<ExtArgs>[]
|
||||
reviews: Prisma.$TripReviewPayload<ExtArgs>[]
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
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>
|
||||
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>
|
||||
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.
|
||||
* @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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -200,6 +200,7 @@ export type UserWhereInput = {
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
trips?: Prisma.TripListRelationFilter
|
||||
participations?: Prisma.TripParticipantListRelationFilter
|
||||
tripReviews?: Prisma.TripReviewListRelationFilter
|
||||
}
|
||||
|
||||
export type UserOrderByWithRelationInput = {
|
||||
@@ -212,6 +213,7 @@ export type UserOrderByWithRelationInput = {
|
||||
updatedAt?: Prisma.SortOrder
|
||||
trips?: Prisma.TripOrderByRelationAggregateInput
|
||||
participations?: Prisma.TripParticipantOrderByRelationAggregateInput
|
||||
tripReviews?: Prisma.TripReviewOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
@@ -227,6 +229,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
trips?: Prisma.TripListRelationFilter
|
||||
participations?: Prisma.TripParticipantListRelationFilter
|
||||
tripReviews?: Prisma.TripReviewListRelationFilter
|
||||
}, "id" | "email">
|
||||
|
||||
export type UserOrderByWithAggregationInput = {
|
||||
@@ -265,6 +268,7 @@ export type UserCreateInput = {
|
||||
updatedAt?: Date | string
|
||||
trips?: Prisma.TripCreateNestedManyWithoutOrganizerInput
|
||||
participations?: Prisma.TripParticipantCreateNestedManyWithoutUserInput
|
||||
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateInput = {
|
||||
@@ -277,6 +281,7 @@ export type UserUncheckedCreateInput = {
|
||||
updatedAt?: Date | string
|
||||
trips?: Prisma.TripUncheckedCreateNestedManyWithoutOrganizerInput
|
||||
participations?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutUserInput
|
||||
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
|
||||
}
|
||||
|
||||
export type UserUpdateInput = {
|
||||
@@ -289,6 +294,7 @@ export type UserUpdateInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
trips?: Prisma.TripUpdateManyWithoutOrganizerNestedInput
|
||||
participations?: Prisma.TripParticipantUpdateManyWithoutUserNestedInput
|
||||
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateInput = {
|
||||
@@ -301,6 +307,7 @@ export type UserUncheckedUpdateInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
trips?: Prisma.TripUncheckedUpdateManyWithoutOrganizerNestedInput
|
||||
participations?: Prisma.TripParticipantUncheckedUpdateManyWithoutUserNestedInput
|
||||
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
|
||||
}
|
||||
|
||||
export type UserCreateManyInput = {
|
||||
@@ -394,6 +401,20 @@ export type UserUpdateOneRequiredWithoutTripsNestedInput = {
|
||||
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 = {
|
||||
create?: Prisma.XOR<Prisma.UserCreateWithoutParticipationsInput, Prisma.UserUncheckedCreateWithoutParticipationsInput>
|
||||
connectOrCreate?: Prisma.UserCreateOrConnectWithoutParticipationsInput
|
||||
@@ -417,6 +438,7 @@ export type UserCreateWithoutTripsInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
participations?: Prisma.TripParticipantCreateNestedManyWithoutUserInput
|
||||
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateWithoutTripsInput = {
|
||||
@@ -428,6 +450,7 @@ export type UserUncheckedCreateWithoutTripsInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
participations?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutUserInput
|
||||
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
|
||||
}
|
||||
|
||||
export type UserCreateOrConnectWithoutTripsInput = {
|
||||
@@ -455,6 +478,7 @@ export type UserUpdateWithoutTripsInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
participations?: Prisma.TripParticipantUpdateManyWithoutUserNestedInput
|
||||
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateWithoutTripsInput = {
|
||||
@@ -466,6 +490,71 @@ export type UserUncheckedUpdateWithoutTripsInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
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 = {
|
||||
@@ -477,6 +566,7 @@ export type UserCreateWithoutParticipationsInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
trips?: Prisma.TripCreateNestedManyWithoutOrganizerInput
|
||||
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateWithoutParticipationsInput = {
|
||||
@@ -488,6 +578,7 @@ export type UserUncheckedCreateWithoutParticipationsInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
trips?: Prisma.TripUncheckedCreateNestedManyWithoutOrganizerInput
|
||||
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
|
||||
}
|
||||
|
||||
export type UserCreateOrConnectWithoutParticipationsInput = {
|
||||
@@ -515,6 +606,7 @@ export type UserUpdateWithoutParticipationsInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
trips?: Prisma.TripUpdateManyWithoutOrganizerNestedInput
|
||||
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateWithoutParticipationsInput = {
|
||||
@@ -526,6 +618,7 @@ export type UserUncheckedUpdateWithoutParticipationsInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
trips?: Prisma.TripUncheckedUpdateManyWithoutOrganizerNestedInput
|
||||
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
|
||||
}
|
||||
|
||||
|
||||
@@ -536,11 +629,13 @@ export type UserUncheckedUpdateWithoutParticipationsInput = {
|
||||
export type UserCountOutputType = {
|
||||
trips: number
|
||||
participations: number
|
||||
tripReviews: number
|
||||
}
|
||||
|
||||
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
trips?: boolean | UserCountOutputTypeCountTripsArgs
|
||||
participations?: boolean | UserCountOutputTypeCountParticipationsArgs
|
||||
tripReviews?: boolean | UserCountOutputTypeCountTripReviewsArgs
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -567,6 +662,13 @@ export type UserCountOutputTypeCountParticipationsArgs<ExtArgs extends runtime.T
|
||||
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<{
|
||||
id?: boolean
|
||||
@@ -578,6 +680,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
||||
updatedAt?: boolean
|
||||
trips?: boolean | Prisma.User$tripsArgs<ExtArgs>
|
||||
participations?: boolean | Prisma.User$participationsArgs<ExtArgs>
|
||||
tripReviews?: boolean | Prisma.User$tripReviewsArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, 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> = {
|
||||
trips?: boolean | Prisma.User$tripsArgs<ExtArgs>
|
||||
participations?: boolean | Prisma.User$participationsArgs<ExtArgs>
|
||||
tripReviews?: boolean | Prisma.User$tripReviewsArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
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: {
|
||||
trips: Prisma.$TripPayload<ExtArgs>[]
|
||||
participations: Prisma.$TripParticipantPayload<ExtArgs>[]
|
||||
tripReviews: Prisma.$TripReviewPayload<ExtArgs>[]
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
id: string
|
||||
@@ -1030,6 +1135,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
|
||||
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>
|
||||
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.
|
||||
* @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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
+19
-4
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, Suspense } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
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 searchParams = useSearchParams();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -31,7 +37,8 @@ export default function LoginPage() {
|
||||
if (result?.error) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
router.push("/");
|
||||
const next = safeInternalPath(searchParams.get("callbackUrl"));
|
||||
router.push(next);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
@@ -126,3 +133,11 @@ export default function LoginPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import { tripService } from "@/server/services/trip.service";
|
||||
import { formatRupiah, formatDateRange } from "@/lib/utils";
|
||||
import { JoinTripButton } from "@/features/trip/components/join-trip-button";
|
||||
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({
|
||||
params,
|
||||
@@ -38,6 +43,26 @@ export default async function TripDetailPage({
|
||||
)
|
||||
: 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 (
|
||||
<div className="mx-auto max-w-3xl px-4 py-4 sm:py-8">
|
||||
{/* Breadcrumb */}
|
||||
@@ -180,6 +205,25 @@ export default async function TripDetailPage({
|
||||
isJoined={!!currentParticipation}
|
||||
isFull={spotsLeft <= 0}
|
||||
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 */}
|
||||
|
||||
@@ -44,13 +44,26 @@ export function Navbar() {
|
||||
>
|
||||
Buat Trip
|
||||
</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="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()}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
</Link>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="max-w-[140px] truncate text-sm font-medium text-neutral-700 hover:text-primary-600"
|
||||
>
|
||||
{session.user.name}
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
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"
|
||||
@@ -116,15 +129,26 @@ export function Navbar() {
|
||||
>
|
||||
Buat Trip
|
||||
</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="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"
|
||||
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()}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
<span className="truncate text-sm font-medium text-neutral-700">
|
||||
{session.user.name}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => signOut()}
|
||||
className="rounded-lg px-3 py-1.5 text-sm font-medium text-red-500 transition-colors hover:bg-red-50"
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { z } from "zod/v4";
|
||||
import { LIMITS } from "@/lib/limits";
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.email("Email tidak valid"),
|
||||
password: z.string().min(6, "Password minimal 6 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"),
|
||||
});
|
||||
|
||||
export const registerSchema = z.object({
|
||||
name: z.string().min(2, "Nama minimal 2 karakter"),
|
||||
email: z.email("Email tidak valid"),
|
||||
password: z.string().min(6, "Password minimal 6 karakter"),
|
||||
name: z
|
||||
.string()
|
||||
.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(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { createTripSchema } from "./schemas";
|
||||
import { createTripSchema, tripImageUrlsSchema } from "./schemas";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { tripStoredInstantFromYmd } from "@/lib/trip-dates";
|
||||
|
||||
export async function createTripAction(formData: FormData) {
|
||||
const session = await getServerSession(authOptions);
|
||||
@@ -28,21 +29,37 @@ export async function createTripAction(formData: FormData) {
|
||||
return { error: result.error.issues[0].message };
|
||||
}
|
||||
|
||||
// Collect image URLs from form (multiple inputs named "imageUrls")
|
||||
const imageUrls = formData
|
||||
const imageUrlsRaw = formData
|
||||
.getAll("imageUrls")
|
||||
.map((v) => (v as string).trim())
|
||||
.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 {
|
||||
const trip = await tripService.createTrip({
|
||||
...result.data,
|
||||
date: new Date(result.data.date),
|
||||
endDate: result.data.endDate ? new Date(result.data.endDate) : undefined,
|
||||
date,
|
||||
endDate,
|
||||
organizerId: session.user.id,
|
||||
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
||||
});
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/profile");
|
||||
return { success: true, tripId: trip.id };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
@@ -58,6 +75,9 @@ export async function joinTripAction(tripId: string) {
|
||||
try {
|
||||
await tripService.joinTrip(tripId, session.user.id);
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/profile");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
@@ -73,6 +93,9 @@ export async function cancelJoinAction(tripId: string) {
|
||||
try {
|
||||
await tripService.cancelJoin(tripId, session.user.id);
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/profile");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
|
||||
@@ -12,6 +12,8 @@ interface JoinTripButtonProps {
|
||||
isJoined: boolean;
|
||||
isFull: boolean;
|
||||
tripStatus: string;
|
||||
/** Tanggal berangkat sudah lewat (hari kalender UTC) */
|
||||
isDeparturePast?: boolean;
|
||||
}
|
||||
|
||||
export function JoinTripButton({
|
||||
@@ -21,6 +23,7 @@ export function JoinTripButton({
|
||||
isJoined,
|
||||
isFull,
|
||||
tripStatus,
|
||||
isDeparturePast,
|
||||
}: JoinTripButtonProps) {
|
||||
const router = useRouter();
|
||||
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) {
|
||||
return (
|
||||
<div className="rounded-xl bg-neutral-100 py-3 text-center text-sm font-medium text-neutral-500">
|
||||
|
||||
@@ -90,8 +90,12 @@ export function TripFilter() {
|
||||
{/* Date range */}
|
||||
<div className="sm:w-64">
|
||||
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
|
||||
Rentang tanggal berangkat
|
||||
Rentang tanggal (UTC)
|
||||
</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">
|
||||
<span className="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
|
||||
<svg
|
||||
|
||||
+106
-13
@@ -1,17 +1,110 @@
|
||||
import { z } from "zod/v4";
|
||||
import { LIMITS } from "@/lib/limits";
|
||||
import {
|
||||
isTripDepartureDayPast,
|
||||
tripStoredInstantFromYmd,
|
||||
} from "@/lib/trip-dates";
|
||||
|
||||
export const createTripSchema = z.object({
|
||||
title: z.string().min(3, "Judul minimal 3 karakter"),
|
||||
description: z.string().optional(),
|
||||
mountain: z.string().min(2, "Nama gunung harus diisi"),
|
||||
location: z.string().min(2, "Lokasi harus diisi"),
|
||||
date: z.string().refine((val) => !isNaN(Date.parse(val)), "Tanggal berangkat tidak valid"),
|
||||
endDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => !val || !isNaN(Date.parse(val)), "Tanggal pulang tidak valid"),
|
||||
maxParticipants: z.coerce.number().min(1, "Minimal 1 peserta"),
|
||||
price: z.coerce.number().min(0, "Harga tidak valid"),
|
||||
});
|
||||
export const tripImageUrlsSchema = z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(LIMITS.MAX_URL_LENGTH, "URL terlalu panjang")
|
||||
.url("Setiap URL gambar harus valid (http/https)")
|
||||
)
|
||||
.max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`);
|
||||
|
||||
export const createTripSchema = z
|
||||
.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>;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,6 +18,7 @@ model User {
|
||||
|
||||
trips Trip[]
|
||||
participations TripParticipant[]
|
||||
tripReviews TripReview[]
|
||||
}
|
||||
|
||||
model Trip {
|
||||
@@ -39,6 +40,23 @@ model Trip {
|
||||
|
||||
participants TripParticipant[]
|
||||
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 {
|
||||
|
||||
+33
-16
@@ -12,6 +12,7 @@ async function main() {
|
||||
console.log("🌱 Seeding database...\n");
|
||||
|
||||
// Clean existing data (order matters for FK)
|
||||
await prisma.tripReview.deleteMany();
|
||||
await prisma.tripParticipant.deleteMany();
|
||||
await prisma.tripImage.deleteMany();
|
||||
await prisma.trip.deleteMany();
|
||||
@@ -93,11 +94,18 @@ async function main() {
|
||||
console.log(" Password semua: password123\n");
|
||||
|
||||
// ==================== 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();
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
|
||||
// --- Trip 1: Papandayan (by Dede Inoen) ---
|
||||
// --- Trip 1: Papandayan (by Dede Inoen) — 2 hari ---
|
||||
const trip1 = await prisma.trip.create({
|
||||
data: {
|
||||
title: "Open Trip Papandayan Weekend",
|
||||
@@ -112,7 +120,8 @@ Itinerary:
|
||||
- Minggu: Sunrise → Turun → Pulang`,
|
||||
mountain: "Gunung Papandayan",
|
||||
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,
|
||||
price: 250000,
|
||||
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({
|
||||
data: {
|
||||
title: "Pendakian Ciremai via Apuy",
|
||||
@@ -142,7 +151,8 @@ Itinerary:
|
||||
- Hari 2: Summit attack → Turun → Pulang`,
|
||||
mountain: "Gunung Ciremai",
|
||||
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,
|
||||
price: 350000,
|
||||
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({
|
||||
data: {
|
||||
title: "Sunrise Trip Gede-Pangrango",
|
||||
@@ -170,7 +180,8 @@ Itinerary:
|
||||
Start malam, summit saat sunrise. View epic dijamin!`,
|
||||
mountain: "Gunung Gede",
|
||||
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,
|
||||
price: 280000,
|
||||
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({
|
||||
data: {
|
||||
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!`,
|
||||
mountain: "Gunung Tangkuban Parahu",
|
||||
location: "Bandung, Jawa Barat",
|
||||
date: new Date(now.getTime() + 2 * day),
|
||||
date: utc(2026, 3, 22, 0, 0),
|
||||
endDate: null,
|
||||
maxParticipants: 15,
|
||||
price: 120000,
|
||||
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({
|
||||
data: {
|
||||
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.`,
|
||||
mountain: "Gunung Malabar",
|
||||
location: "Bandung, Jawa Barat",
|
||||
date: new Date(now.getTime() + 4 * day),
|
||||
date: utc(2026, 3, 20, 14, 0),
|
||||
endDate: null,
|
||||
maxParticipants: 10,
|
||||
price: 150000,
|
||||
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({
|
||||
data: {
|
||||
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!`,
|
||||
mountain: "Gunung Guntur",
|
||||
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,
|
||||
price: 300000,
|
||||
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 ====================
|
||||
|
||||
|
||||
@@ -25,4 +25,31 @@ export const participantRepo = {
|
||||
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" },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,11 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
import {
|
||||
utcStartOfDay,
|
||||
utcDayStartFromYmd,
|
||||
utcDayEndFromYmd,
|
||||
maxUtcDate,
|
||||
} from "@/lib/trip-dates";
|
||||
|
||||
export const tripRepo = {
|
||||
async findAll() {
|
||||
@@ -7,44 +13,76 @@ export const tripRepo = {
|
||||
include: {
|
||||
organizer: { select: { id: true, name: true, image: true } },
|
||||
images: { orderBy: { order: "asc" }, take: 1 },
|
||||
_count: { select: { participants: true } },
|
||||
_count: {
|
||||
select: {
|
||||
participants: { where: { status: { not: "CANCELLED" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
},
|
||||
|
||||
async findOpen(filters?: { q?: string; from?: string; to?: string }) {
|
||||
const where: Prisma.TripWhereInput = {
|
||||
status: "OPEN",
|
||||
date: { gte: new Date() },
|
||||
};
|
||||
const todayStart = utcStartOfDay(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) {
|
||||
where.OR = [
|
||||
{ title: { contains: filters.q, mode: "insensitive" } },
|
||||
{ mountain: { contains: filters.q, mode: "insensitive" } },
|
||||
{ location: { contains: filters.q, mode: "insensitive" } },
|
||||
];
|
||||
andParts.push({
|
||||
OR: [
|
||||
{ title: { contains: filters.q, mode: "insensitive" } },
|
||||
{ mountain: { contains: filters.q, mode: "insensitive" } },
|
||||
{ location: { contains: filters.q, mode: "insensitive" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (filters?.from || filters?.to) {
|
||||
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;
|
||||
}
|
||||
const where: Prisma.TripWhereInput = { AND: andParts };
|
||||
|
||||
return prisma.trip.findMany({
|
||||
where,
|
||||
include: {
|
||||
organizer: { select: { id: true, name: true, image: true } },
|
||||
images: { orderBy: { order: "asc" }, take: 1 },
|
||||
_count: { select: { participants: true } },
|
||||
_count: {
|
||||
select: {
|
||||
participants: { where: { status: { not: "CANCELLED" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
@@ -59,10 +97,38 @@ export const tripRepo = {
|
||||
participants: {
|
||||
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) {
|
||||
return prisma.trip.create({ data });
|
||||
},
|
||||
|
||||
@@ -10,6 +10,20 @@ export const userRepo = {
|
||||
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) {
|
||||
return prisma.user.create({ data });
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { tripRepo } from "@/server/repositories/trip.repo";
|
||||
import { participantRepo } from "@/server/repositories/participant.repo";
|
||||
import { LIMITS } from "@/lib/limits";
|
||||
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||
|
||||
interface CreateTripInput {
|
||||
title: string;
|
||||
@@ -32,6 +34,25 @@ export const tripService = {
|
||||
},
|
||||
|
||||
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
|
||||
? {
|
||||
create: input.imageUrls.map((url, i) => ({ url, order: i })),
|
||||
@@ -62,6 +83,12 @@ export const tripService = {
|
||||
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) {
|
||||
throw new Error("Organizer tidak bisa join trip sendiri");
|
||||
}
|
||||
@@ -77,7 +104,10 @@ export const tripService = {
|
||||
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);
|
||||
if (newCount >= trip.maxParticipants) {
|
||||
@@ -88,6 +118,17 @@ export const tripService = {
|
||||
},
|
||||
|
||||
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);
|
||||
if (!existing || existing.status === "CANCELLED") {
|
||||
throw new Error("Kamu tidak terdaftar di trip ini");
|
||||
@@ -95,8 +136,7 @@ export const tripService = {
|
||||
|
||||
const result = await participantRepo.cancel(tripId, userId);
|
||||
|
||||
const trip = await tripRepo.findById(tripId);
|
||||
if (trip && trip.status === "FULL") {
|
||||
if (trip.status === "FULL") {
|
||||
const count = await participantRepo.countByTrip(tripId);
|
||||
if (count < trip.maxParticipants) {
|
||||
await tripRepo.updateStatus(tripId, "OPEN");
|
||||
|
||||
Reference in New Issue
Block a user