add user profile, profile vibe and trip vibe and social signal
This commit is contained in:
@@ -22,6 +22,7 @@ export async function updateProfileAction(formData: FormData) {
|
||||
city: formData.get("city"),
|
||||
instagram: formData.get("instagram"),
|
||||
interests,
|
||||
vibe: formData.get("vibe"),
|
||||
};
|
||||
|
||||
const parsed = updateProfileSchema.safeParse(raw);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { VIBES, vibeMeta, isVibe } from "@/lib/vibe";
|
||||
import type { Vibe } from "@/app/generated/prisma/enums";
|
||||
|
||||
export function PeopleFilter() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const initialVibe = searchParams.get("vibe");
|
||||
const [vibe, setVibe] = useState<Vibe | null>(
|
||||
isVibe(initialVibe) ? initialVibe : null
|
||||
);
|
||||
const [city, setCity] = useState(searchParams.get("city") ?? "");
|
||||
const [interest, setInterest] = useState(searchParams.get("interest") ?? "");
|
||||
|
||||
function buildParams(overrides?: { vibe?: Vibe | null }) {
|
||||
const params = new URLSearchParams();
|
||||
const nextVibe = overrides && "vibe" in overrides ? overrides.vibe : vibe;
|
||||
if (nextVibe) params.set("vibe", nextVibe);
|
||||
if (city.trim()) params.set("city", city.trim());
|
||||
if (interest.trim()) params.set("interest", interest.trim().toLowerCase());
|
||||
return params;
|
||||
}
|
||||
|
||||
function pushFilters(params: URLSearchParams) {
|
||||
const qs = params.toString();
|
||||
router.push(`/people${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
function handleSelectVibe(next: Vibe | null) {
|
||||
setVibe(next);
|
||||
pushFilters(buildParams({ vibe: next }));
|
||||
}
|
||||
|
||||
function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
pushFilters(buildParams());
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setVibe(null);
|
||||
setCity("");
|
||||
setInterest("");
|
||||
router.push("/people");
|
||||
}
|
||||
|
||||
const hasFilters = vibe || city.trim() || interest.trim();
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSearch}
|
||||
className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
|
||||
Vibe
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectVibe(null)}
|
||||
aria-pressed={vibe === null}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
vibe === null
|
||||
? "border-primary-600 bg-primary-600 text-white"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
Semua
|
||||
</button>
|
||||
{VIBES.map((v) => {
|
||||
const m = vibeMeta(v);
|
||||
const active = vibe === v;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => handleSelectVibe(v)}
|
||||
aria-pressed={active}
|
||||
title={m.description}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "border-primary-600 bg-primary-600 text-white"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden>{m.icon}</span>
|
||||
<span>{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="people-city"
|
||||
className="mb-1.5 block text-xs font-medium text-neutral-500"
|
||||
>
|
||||
Kota
|
||||
</label>
|
||||
<input
|
||||
id="people-city"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Bandung, Jakarta, ..."
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="people-interest"
|
||||
className="mb-1.5 block text-xs font-medium text-neutral-500"
|
||||
>
|
||||
Minat
|
||||
</label>
|
||||
<input
|
||||
id="people-interest"
|
||||
type="text"
|
||||
value={interest}
|
||||
onChange={(e) => setInterest(e.target.value)}
|
||||
placeholder="hiking, fotografi, yoga..."
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 sm:shrink-0">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white shadow-md shadow-primary-600/20 transition-colors hover:bg-primary-700 sm:flex-none"
|
||||
>
|
||||
Cari
|
||||
</button>
|
||||
{hasFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="rounded-xl border border-neutral-200 px-3 py-2.5 text-sm font-medium text-neutral-500 transition-colors hover:bg-neutral-50 hover:text-neutral-700"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateProfileAction } from "@/features/profile/actions";
|
||||
import { LIMITS } from "@/lib/limits";
|
||||
import { VIBES, vibeMeta } from "@/lib/vibe";
|
||||
import type { Vibe } from "@/app/generated/prisma/enums";
|
||||
|
||||
interface ProfileEditorProps {
|
||||
userId: string;
|
||||
@@ -12,6 +14,7 @@ interface ProfileEditorProps {
|
||||
city: string | null;
|
||||
interests: string[];
|
||||
instagram: string | null;
|
||||
vibe: Vibe | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -27,6 +30,7 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
|
||||
const [instagram, setInstagram] = useState(initial?.instagram ?? "");
|
||||
const [interests, setInterests] = useState<string[]>(initial?.interests ?? []);
|
||||
const [interestDraft, setInterestDraft] = useState("");
|
||||
const [vibe, setVibe] = useState<Vibe | null>(initial?.vibe ?? null);
|
||||
|
||||
function addInterest() {
|
||||
const v = interestDraft.trim().toLowerCase();
|
||||
@@ -65,6 +69,7 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
|
||||
if (bio.trim()) formData.set("bio", bio.trim());
|
||||
if (city.trim()) formData.set("city", city.trim());
|
||||
if (instagram.trim()) formData.set("instagram", instagram.trim());
|
||||
if (vibe) formData.set("vibe", vibe);
|
||||
interests.forEach((t) => formData.append("interests", t));
|
||||
|
||||
const result = await updateProfileAction(formData);
|
||||
@@ -257,6 +262,56 @@ export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Vibe jalanmu{" "}
|
||||
<span className="text-xs font-normal text-neutral-400">(opsional)</span>
|
||||
</label>
|
||||
<p className="mb-2 text-[11px] text-neutral-500">
|
||||
Bantu calon teman trip nyambung dengan ritme kamu.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVibe(null)}
|
||||
aria-pressed={vibe === null}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
vibe === null
|
||||
? "border-neutral-700 bg-neutral-800 text-white"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
Belum diisi
|
||||
</button>
|
||||
{VIBES.map((v) => {
|
||||
const m = vibeMeta(v);
|
||||
const active = vibe === v;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setVibe(v)}
|
||||
aria-pressed={active}
|
||||
title={m.description}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "border-primary-600 bg-primary-600 text-white"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden>{m.icon}</span>
|
||||
<span>{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{vibe && (
|
||||
<p className="mt-2 text-[11px] italic text-neutral-500">
|
||||
{vibeMeta(vibe).description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { vibeMeta } from "@/lib/vibe";
|
||||
import type { Vibe } from "@/app/generated/prisma/enums";
|
||||
|
||||
interface UserCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
isVerifiedOrganizer: boolean;
|
||||
profile: {
|
||||
bio: string | null;
|
||||
city: string | null;
|
||||
interests: string[];
|
||||
vibe: Vibe | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function UserCard({
|
||||
id,
|
||||
name,
|
||||
image,
|
||||
isVerifiedOrganizer,
|
||||
profile,
|
||||
}: UserCardProps) {
|
||||
const interests = profile?.interests ?? [];
|
||||
return (
|
||||
<Link
|
||||
href={`/u/${id}`}
|
||||
className="group flex h-full flex-col rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
className="h-14 w-14 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-primary-600 text-lg font-bold text-white">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-neutral-800 group-hover:text-primary-700">
|
||||
{name}
|
||||
</p>
|
||||
{profile?.city && (
|
||||
<p className="truncate text-[11px] text-neutral-500 sm:text-xs">
|
||||
📍 {profile.city}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{isVerifiedOrganizer && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-primary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary-800"
|
||||
title="Organizer terverifikasi"
|
||||
>
|
||||
✅ Organizer
|
||||
</span>
|
||||
)}
|
||||
{profile?.vibe && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-secondary-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-secondary-800"
|
||||
title={vibeMeta(profile.vibe).description}
|
||||
>
|
||||
<span aria-hidden>{vibeMeta(profile.vibe).icon}</span>
|
||||
<span>{vibeMeta(profile.vibe).label}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile?.bio && (
|
||||
<p className="mt-3 line-clamp-2 text-xs leading-relaxed text-neutral-600">
|
||||
{profile.bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{interests.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{interests.slice(0, 5).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-secondary-50 px-2 py-0.5 text-[10px] font-medium text-secondary-700"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{interests.length > 5 && (
|
||||
<span className="text-[10px] text-neutral-400">
|
||||
+{interests.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
import { LIMITS } from "@/lib/limits";
|
||||
import { VIBES } from "@/lib/vibe";
|
||||
|
||||
const optionalTrimmed = (max: number, label: string) =>
|
||||
z.preprocess(
|
||||
@@ -52,6 +53,14 @@ export const updateProfileSchema = z.object({
|
||||
`Maksimal ${LIMITS.MAX_PROFILE_INTERESTS_COUNT} minat`
|
||||
)
|
||||
.default([]),
|
||||
vibe: z.preprocess(
|
||||
(val) => {
|
||||
if (val == null) return undefined;
|
||||
const s = String(val).trim();
|
||||
return s === "" ? undefined : s;
|
||||
},
|
||||
z.enum([...VIBES]).optional()
|
||||
),
|
||||
});
|
||||
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function createTripAction(formData: FormData) {
|
||||
endDate: (formData.get("endDate") as string) || undefined,
|
||||
maxParticipants: formData.get("maxParticipants") as string,
|
||||
price: formData.get("price") as string,
|
||||
vibe: formData.get("vibe"),
|
||||
};
|
||||
|
||||
const result = createTripSchema.safeParse(raw);
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
ACTIVITY_CATEGORIES,
|
||||
categoryMeta,
|
||||
} from "@/lib/activity-category";
|
||||
import type { ActivityCategory } from "@/app/generated/prisma/enums";
|
||||
import { VIBES, vibeMeta } from "@/lib/vibe";
|
||||
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
|
||||
|
||||
function formatRupiahInput(value: string): string {
|
||||
const num = value.replace(/\D/g, "");
|
||||
@@ -32,6 +33,7 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [category, setCategory] = useState<ActivityCategory>("HIKING");
|
||||
const [vibe, setVibe] = useState<Vibe | null>(null);
|
||||
const [startDate, setStartDate] = useState<Date | null>(null);
|
||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||
const [priceDisplay, setPriceDisplay] = useState("");
|
||||
@@ -62,6 +64,7 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
|
||||
}
|
||||
}
|
||||
formData.set("price", parseRupiahInput(priceDisplay));
|
||||
if (vibe) formData.set("vibe", vibe);
|
||||
|
||||
const result = await createTripAction(formData);
|
||||
|
||||
@@ -124,6 +127,57 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
|
||||
<input type="hidden" name="category" value={category} />
|
||||
</div>
|
||||
|
||||
{/* Vibe Chips */}
|
||||
<div className="rounded-xl bg-secondary-50 p-4">
|
||||
<label className="mb-1 block text-sm font-bold text-secondary-900">
|
||||
Vibe Trip{" "}
|
||||
<span className="text-xs font-normal text-secondary-700">(opsional)</span>
|
||||
</label>
|
||||
<p className="mb-2 text-[11px] text-secondary-700/80">
|
||||
Bantu calon peserta menilai apakah ritmenya cocok dengan mereka.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVibe(null)}
|
||||
aria-pressed={vibe === null}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
vibe === null
|
||||
? "border-neutral-700 bg-neutral-800 text-white"
|
||||
: "border-secondary-200 bg-white text-secondary-800 hover:bg-secondary-100"
|
||||
}`}
|
||||
>
|
||||
Belum diisi
|
||||
</button>
|
||||
{VIBES.map((v) => {
|
||||
const m = vibeMeta(v);
|
||||
const active = v === vibe;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setVibe(v)}
|
||||
aria-pressed={active}
|
||||
title={m.description}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "border-secondary-600 bg-secondary-600 text-white"
|
||||
: "border-secondary-200 bg-white text-secondary-800 hover:bg-secondary-100"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden>{m.icon}</span>
|
||||
<span>{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{vibe && (
|
||||
<p className="mt-2 text-[11px] italic text-secondary-700/80">
|
||||
{vibeMeta(vibe).description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
Judul Trip
|
||||
|
||||
@@ -3,12 +3,21 @@ import Link from "next/link";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
import { categoryMeta } from "@/lib/activity-category";
|
||||
import type { ActivityCategory } from "@/app/generated/prisma/enums";
|
||||
import { vibeMeta } from "@/lib/vibe";
|
||||
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
|
||||
|
||||
interface TripCardParticipant {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
interests: string[];
|
||||
}
|
||||
|
||||
interface TripCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
category: ActivityCategory;
|
||||
vibe?: Vibe | null;
|
||||
destination: string;
|
||||
location: string;
|
||||
date: Date | string;
|
||||
@@ -21,12 +30,17 @@ interface TripCardProps {
|
||||
coverImage?: string | null;
|
||||
priority?: boolean;
|
||||
isVerifiedOrganizer?: boolean;
|
||||
/** Daftar peserta CONFIRMED (subset, untuk preview avatar). Optional. */
|
||||
participants?: TripCardParticipant[];
|
||||
/** Interests user yang sedang melihat — untuk hitung overlap. Optional. */
|
||||
viewerInterests?: string[];
|
||||
}
|
||||
|
||||
export function TripCard({
|
||||
id,
|
||||
title,
|
||||
category,
|
||||
vibe,
|
||||
destination,
|
||||
location,
|
||||
date,
|
||||
@@ -39,10 +53,25 @@ export function TripCard({
|
||||
coverImage,
|
||||
priority,
|
||||
isVerifiedOrganizer,
|
||||
participants,
|
||||
viewerInterests,
|
||||
}: TripCardProps) {
|
||||
const spotsLeft = maxParticipants - participantCount;
|
||||
const isSmallGroup = maxParticipants <= 10;
|
||||
const meta = categoryMeta(category);
|
||||
const vMeta = vibe ? vibeMeta(vibe) : null;
|
||||
|
||||
const previewParticipants = participants?.slice(0, 3) ?? [];
|
||||
const moreCount =
|
||||
participants && participants.length > 3 ? participants.length - 3 : 0;
|
||||
|
||||
let overlapCount = 0;
|
||||
if (viewerInterests && viewerInterests.length > 0 && participants) {
|
||||
const viewerSet = new Set(viewerInterests.map((i) => i.toLowerCase()));
|
||||
overlapCount = participants.filter((p) =>
|
||||
p.interests.some((tag) => viewerSet.has(tag.toLowerCase()))
|
||||
).length;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/trips/${id}`} className="group block">
|
||||
@@ -63,13 +92,24 @@ export function TripCard({
|
||||
<span className="text-4xl">{meta.icon}</span>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className="absolute left-3 top-3 inline-flex items-center gap-1 rounded-full bg-white/90 px-2 py-0.5 text-[11px] font-semibold text-neutral-700 shadow-sm backdrop-blur-sm"
|
||||
title={`Kategori: ${meta.label}`}
|
||||
>
|
||||
<span>{meta.icon}</span>
|
||||
<span>{meta.label}</span>
|
||||
</span>
|
||||
<div className="absolute left-3 top-3 flex flex-wrap gap-1">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-white/90 px-2 py-0.5 text-[11px] font-semibold text-neutral-700 shadow-sm backdrop-blur-sm"
|
||||
title={`Kategori: ${meta.label}`}
|
||||
>
|
||||
<span>{meta.icon}</span>
|
||||
<span>{meta.label}</span>
|
||||
</span>
|
||||
{vMeta && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-secondary-600/90 px-2 py-0.5 text-[11px] font-semibold text-white shadow-sm backdrop-blur-sm"
|
||||
title={`Vibe: ${vMeta.label} — ${vMeta.description}`}
|
||||
>
|
||||
<span aria-hidden>{vMeta.icon}</span>
|
||||
<span>{vMeta.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`absolute right-3 top-3 rounded-full px-2.5 py-0.5 text-xs font-bold backdrop-blur-sm ${
|
||||
status === "OPEN"
|
||||
@@ -120,6 +160,48 @@ export function TripCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(previewParticipants.length > 0 || overlapCount > 0) && (
|
||||
<div className="mt-3 flex items-center gap-2 border-t border-neutral-100 pt-3">
|
||||
{previewParticipants.length > 0 && (
|
||||
<div className="flex -space-x-2">
|
||||
{previewParticipants.map((p) =>
|
||||
p.image ? (
|
||||
<Image
|
||||
key={p.id}
|
||||
src={p.image}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="h-6 w-6 rounded-full border-2 border-white object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-white bg-primary-600 text-[10px] font-bold text-white"
|
||||
title={p.name}
|
||||
>
|
||||
{p.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{moreCount > 0 && (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-white bg-neutral-200 text-[10px] font-bold text-neutral-600">
|
||||
+{moreCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{overlapCount > 0 && (
|
||||
<span
|
||||
className="rounded-full bg-secondary-50 px-2 py-0.5 text-[11px] font-semibold text-secondary-700"
|
||||
title="Peserta dengan minimal 1 minat sama dengan kamu"
|
||||
>
|
||||
✨ {overlapCount} peserta sama minat
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center justify-between border-t border-neutral-100 pt-3">
|
||||
<span className="text-lg font-bold text-primary-600">
|
||||
{formatRupiah(price)}
|
||||
|
||||
@@ -10,16 +10,38 @@ import {
|
||||
categoryMeta,
|
||||
isActivityCategory,
|
||||
} from "@/lib/activity-category";
|
||||
import type { ActivityCategory } from "@/app/generated/prisma/enums";
|
||||
import { VIBES, vibeMeta, isVibe } from "@/lib/vibe";
|
||||
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
|
||||
|
||||
type GroupSize = "SMALL" | "MEDIUM" | "LARGE";
|
||||
const GROUP_SIZES: { value: GroupSize; label: string; hint: string }[] = [
|
||||
{ value: "SMALL", label: "Small", hint: "≤10 — paling akrab" },
|
||||
{ value: "MEDIUM", label: "Medium", hint: "11–20" },
|
||||
{ value: "LARGE", label: "Large", hint: "21+" },
|
||||
];
|
||||
function isGroupSize(value: unknown): value is GroupSize {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
GROUP_SIZES.some((g) => g.value === value)
|
||||
);
|
||||
}
|
||||
|
||||
export function TripFilter() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const initialCategory = searchParams.get("category");
|
||||
const initialVibe = searchParams.get("vibe");
|
||||
const initialGroup = searchParams.get("groupSize");
|
||||
const [category, setCategory] = useState<ActivityCategory | null>(
|
||||
isActivityCategory(initialCategory) ? initialCategory : null
|
||||
);
|
||||
const [vibe, setVibe] = useState<Vibe | null>(
|
||||
isVibe(initialVibe) ? initialVibe : null
|
||||
);
|
||||
const [groupSize, setGroupSize] = useState<GroupSize | null>(
|
||||
isGroupSize(initialGroup) ? initialGroup : null
|
||||
);
|
||||
const [query, setQuery] = useState(searchParams.get("q") ?? "");
|
||||
const [startDate, setStartDate] = useState<Date | null>(
|
||||
searchParams.get("from") ? new Date(searchParams.get("from")!) : null
|
||||
@@ -28,11 +50,20 @@ export function TripFilter() {
|
||||
searchParams.get("to") ? new Date(searchParams.get("to")!) : null
|
||||
);
|
||||
|
||||
function buildParams(overrides?: { category?: ActivityCategory | null }) {
|
||||
function buildParams(overrides?: {
|
||||
category?: ActivityCategory | null;
|
||||
vibe?: Vibe | null;
|
||||
groupSize?: GroupSize | null;
|
||||
}) {
|
||||
const params = new URLSearchParams();
|
||||
const nextCategory =
|
||||
overrides && "category" in overrides ? overrides.category : category;
|
||||
const nextVibe = overrides && "vibe" in overrides ? overrides.vibe : vibe;
|
||||
const nextGroup =
|
||||
overrides && "groupSize" in overrides ? overrides.groupSize : groupSize;
|
||||
if (nextCategory) params.set("category", nextCategory);
|
||||
if (nextVibe) params.set("vibe", nextVibe);
|
||||
if (nextGroup) params.set("groupSize", nextGroup);
|
||||
if (query.trim()) params.set("q", query.trim());
|
||||
if (startDate) params.set("from", formatLocalCalendarYmd(startDate));
|
||||
if (endDate) params.set("to", formatLocalCalendarYmd(endDate));
|
||||
@@ -49,6 +80,16 @@ export function TripFilter() {
|
||||
pushFilters(buildParams({ category: next }));
|
||||
}
|
||||
|
||||
function handleSelectVibe(next: Vibe | null) {
|
||||
setVibe(next);
|
||||
pushFilters(buildParams({ vibe: next }));
|
||||
}
|
||||
|
||||
function handleSelectGroupSize(next: GroupSize | null) {
|
||||
setGroupSize(next);
|
||||
pushFilters(buildParams({ groupSize: next }));
|
||||
}
|
||||
|
||||
function handleDateChange(dates: [Date | null, Date | null]) {
|
||||
const [start, end] = dates;
|
||||
setStartDate(start);
|
||||
@@ -57,6 +98,8 @@ export function TripFilter() {
|
||||
if (!start && !end) {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.set("category", category);
|
||||
if (vibe) params.set("vibe", vibe);
|
||||
if (groupSize) params.set("groupSize", groupSize);
|
||||
if (query.trim()) params.set("q", query.trim());
|
||||
pushFilters(params);
|
||||
}
|
||||
@@ -69,13 +112,15 @@ export function TripFilter() {
|
||||
|
||||
function handleReset() {
|
||||
setCategory(null);
|
||||
setVibe(null);
|
||||
setGroupSize(null);
|
||||
setQuery("");
|
||||
setStartDate(null);
|
||||
setEndDate(null);
|
||||
router.push("/trips");
|
||||
}
|
||||
|
||||
const hasFilters = category || query || startDate || endDate;
|
||||
const hasFilters = category || vibe || groupSize || query || startDate || endDate;
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -123,6 +168,89 @@ export function TripFilter() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vibe & Ukuran grup */}
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
|
||||
Vibe
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectVibe(null)}
|
||||
aria-pressed={vibe === null}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
vibe === null
|
||||
? "border-secondary-600 bg-secondary-600 text-white"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
Semua
|
||||
</button>
|
||||
{VIBES.map((v) => {
|
||||
const m = vibeMeta(v);
|
||||
const active = vibe === v;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => handleSelectVibe(v)}
|
||||
aria-pressed={active}
|
||||
title={m.description}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "border-secondary-600 bg-secondary-600 text-white"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden>{m.icon}</span>
|
||||
<span>{m.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
|
||||
Ukuran grup
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectGroupSize(null)}
|
||||
aria-pressed={groupSize === null}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
groupSize === null
|
||||
? "border-primary-600 bg-primary-600 text-white"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
Semua
|
||||
</button>
|
||||
{GROUP_SIZES.map((g) => {
|
||||
const active = groupSize === g.value;
|
||||
return (
|
||||
<button
|
||||
key={g.value}
|
||||
type="button"
|
||||
onClick={() => handleSelectGroupSize(g.value)}
|
||||
aria-pressed={active}
|
||||
title={g.hint}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "border-primary-600 bg-primary-600 text-white"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
{g.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-3">
|
||||
{/* Search input */}
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
import { LIMITS } from "@/lib/limits";
|
||||
import { ACTIVITY_CATEGORIES } from "@/lib/activity-category";
|
||||
import { VIBES } from "@/lib/vibe";
|
||||
import type { ActivityCategory } from "@/app/generated/prisma/enums";
|
||||
import {
|
||||
isTripDepartureDayPast,
|
||||
@@ -146,6 +147,14 @@ export const createTripSchema = z
|
||||
)
|
||||
.optional()
|
||||
),
|
||||
vibe: z.preprocess(
|
||||
(val) => {
|
||||
if (val == null) return undefined;
|
||||
const s = String(val).trim();
|
||||
return s === "" ? undefined : s;
|
||||
},
|
||||
z.enum([...VIBES]).optional()
|
||||
),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const dep = tripStoredInstantFromYmd(data.date);
|
||||
|
||||
Reference in New Issue
Block a user