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
+55 -1
View File
@@ -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
+90 -8
View File
@@ -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)}
+131 -3
View File
@@ -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: "1120" },
{ 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">