create review and profile
This commit is contained in:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user