create review and profile

This commit is contained in:
arifal
2026-04-20 00:25:05 +07:00
parent 7159e9108f
commit ba5f64ae0e
37 changed files with 3324 additions and 109 deletions
+28 -5
View File
@@ -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">
+5 -1
View File
@@ -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
View File
@@ -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>;