add user profile, profile vibe and trip vibe and social signal

This commit is contained in:
2026-05-08 19:20:27 +07:00
parent 3228ef712f
commit 7f419638b5
39 changed files with 1361 additions and 192 deletions
+1
View File
@@ -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"
+102
View File
@@ -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>
);
}
+9
View File
@@ -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>;