656 lines
24 KiB
TypeScript
656 lines
24 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { notFound } from "next/navigation";
|
|
import { getServerSession } from "next-auth";
|
|
import Link from "next/link";
|
|
import Image from "next/image";
|
|
import { authOptions } from "@/lib/auth";
|
|
import { tripService } from "@/server/services/trip.service";
|
|
import { bookingService } from "@/server/services/booking.service";
|
|
import { bookingRepo } from "@/server/repositories/booking.repo";
|
|
import { trustService } from "@/server/services/trust.service";
|
|
import { formatRupiah } from "@/lib/utils";
|
|
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
|
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
|
|
import { JoinTripButton } from "@/features/trip/components/join-trip-button";
|
|
import { CancelTripButton } from "@/features/trip/components/cancel-trip-button";
|
|
import { CancelBookingButton } from "@/features/booking/components/cancel-booking-button";
|
|
import { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests";
|
|
import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
|
|
import { TripProgramBlock } from "@/features/trip/components/trip-program-block";
|
|
import { ImageGallery } from "@/features/trip/components/image-gallery";
|
|
import { TripReviewSection } from "@/features/review/components/trip-review-section";
|
|
import { RefundPolicySection } from "@/features/refund/components/refund-policy-section";
|
|
import { categoryMeta } from "@/lib/activity-category";
|
|
import { vibeMeta } from "@/lib/vibe";
|
|
import { isFreeTrip } from "@/lib/trip-pricing";
|
|
import {
|
|
isPastTripLastDayForReview,
|
|
isTripDepartureDayPast,
|
|
} from "@/lib/trip-dates";
|
|
import { previewRefund } from "@/lib/refund-policy";
|
|
import {
|
|
MapPin,
|
|
CalendarDays,
|
|
Wallet,
|
|
UserRound,
|
|
Zap,
|
|
Users,
|
|
} from "lucide-react";
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>;
|
|
}): Promise<Metadata> {
|
|
const { id } = await params;
|
|
let trip;
|
|
try {
|
|
trip = await tripService.getTripById(id);
|
|
} catch {
|
|
return {
|
|
title: "Trip tidak ditemukan",
|
|
robots: { index: false, follow: false },
|
|
};
|
|
}
|
|
|
|
const title = `${trip.title} — ${trip.destination}`;
|
|
const fallbackDescription = `Open trip ${trip.destination} di ${trip.location}, ${formatTripCalendarDateRangeLong(trip.date, trip.endDate)}. Harga ${formatRupiah(trip.price)}/orang, max ${trip.maxParticipants} peserta. Gabung di ${siteConfig.name}.`;
|
|
const description =
|
|
trip.description?.replace(/\s+/g, " ").trim().slice(0, 160) ||
|
|
fallbackDescription;
|
|
|
|
// OG/Twitter image otomatis di-inject dari `opengraph-image.tsx` colocated.
|
|
return {
|
|
title,
|
|
description,
|
|
alternates: { canonical: `/trips/${id}` },
|
|
openGraph: {
|
|
type: "article",
|
|
title,
|
|
description,
|
|
url: `/trips/${id}`,
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
title,
|
|
description,
|
|
},
|
|
};
|
|
}
|
|
|
|
export default async function TripDetailPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>;
|
|
}) {
|
|
const { id } = await params;
|
|
const session = await getServerSession(authOptions);
|
|
|
|
let trip;
|
|
try {
|
|
trip = await tripService.getTripById(id);
|
|
} catch {
|
|
notFound();
|
|
}
|
|
|
|
const organizerTrust = await trustService.getOrganizerTrust(
|
|
trip.organizerId
|
|
);
|
|
|
|
const activeParticipants = trip.participants.filter(
|
|
(p) => p.status !== "CANCELLED"
|
|
);
|
|
const confirmedParticipants = activeParticipants.filter(
|
|
(p) => p.status === "CONFIRMED"
|
|
);
|
|
const pendingParticipants = activeParticipants.filter(
|
|
(p) => p.status === "PENDING"
|
|
);
|
|
const participantCount = activeParticipants.length;
|
|
const confirmedCount = confirmedParticipants.length;
|
|
const spotsLeft = trip.maxParticipants - participantCount;
|
|
const fillPercent = Math.min(
|
|
(participantCount / trip.maxParticipants) * 100,
|
|
100
|
|
);
|
|
const isOrganizer = session?.user?.id === trip.organizerId;
|
|
const currentParticipation = session?.user
|
|
? trip.participants.find(
|
|
(p) => p.userId === session.user.id && p.status !== "CANCELLED"
|
|
)
|
|
: null;
|
|
|
|
const isDeparturePast = isTripDepartureDayPast(trip.date);
|
|
const canReview =
|
|
!!session?.user &&
|
|
!isOrganizer &&
|
|
currentParticipation?.status === "CONFIRMED" &&
|
|
isPastTripLastDayForReview(trip.date, trip.endDate);
|
|
|
|
const myReview = session?.user
|
|
? trip.reviews.find((r) => r.userId === session.user.id) ?? null
|
|
: null;
|
|
|
|
const averageRating =
|
|
trip.reviews.length > 0
|
|
? Math.round(
|
|
(trip.reviews.reduce((s, r) => s + r.rating, 0) /
|
|
trip.reviews.length) *
|
|
10
|
|
) / 10
|
|
: null;
|
|
|
|
const tripIsFree = isFreeTrip(trip);
|
|
|
|
// Booking peserta saat ini — dipakai untuk render CancelBookingButton vs
|
|
// tombol "Batal Ikut" biasa. Hanya untuk non-organizer yang ikut trip.
|
|
const myBooking =
|
|
session?.user && !isOrganizer && currentParticipation
|
|
? await bookingService.getByTripAndUser(trip.id, session.user.id)
|
|
: null;
|
|
|
|
// Untuk CancelTripButton: jumlah booking PAID/PARTIALLY_REFUNDED (yang akan
|
|
// auto-refund). Hanya dihitung saat organizer mengakses trip yang masih
|
|
// bisa dibatalkan.
|
|
const canOrganizerCancel =
|
|
isOrganizer &&
|
|
(trip.status === "OPEN" || trip.status === "FULL") &&
|
|
!isDeparturePast;
|
|
const paidBookingCount = canOrganizerCancel
|
|
? await bookingRepo.countSettledForTrip(trip.id)
|
|
: 0;
|
|
|
|
// Preview refund untuk CancelBookingButton (server-side supaya konsisten
|
|
// dengan service yang juga pakai policy yang sama).
|
|
const refundPreview =
|
|
myBooking && myBooking.status === "PAID" && !isDeparturePast
|
|
? previewRefund(myBooking.amount, trip.date)
|
|
: null;
|
|
|
|
const catMeta = categoryMeta(trip.category);
|
|
|
|
const tripUrl = absoluteUrl(`/trips/${trip.id}`);
|
|
const eventStatus =
|
|
trip.status === "OPEN"
|
|
? "https://schema.org/EventScheduled"
|
|
: trip.status === "CLOSED"
|
|
? "https://schema.org/EventCancelled"
|
|
: "https://schema.org/EventScheduled";
|
|
const offerAvailability =
|
|
trip.status === "OPEN"
|
|
? "https://schema.org/InStock"
|
|
: trip.status === "FULL"
|
|
? "https://schema.org/SoldOut"
|
|
: "https://schema.org/Discontinued";
|
|
|
|
const jsonLd = {
|
|
"@context": "https://schema.org",
|
|
"@graph": [
|
|
{
|
|
"@type": "Event",
|
|
"@id": `${tripUrl}#event`,
|
|
name: trip.title,
|
|
description: trip.description ?? undefined,
|
|
startDate: trip.date.toISOString(),
|
|
endDate: (trip.endDate ?? trip.date).toISOString(),
|
|
eventStatus,
|
|
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
|
|
location: {
|
|
"@type": "Place",
|
|
name: trip.destination,
|
|
address: {
|
|
"@type": "PostalAddress",
|
|
addressLocality: trip.location,
|
|
addressCountry: "ID",
|
|
},
|
|
},
|
|
image: trip.images.length
|
|
? trip.images.map((i) => i.url)
|
|
: [absoluteUrl("/images/SeTrip.png")],
|
|
organizer: {
|
|
"@type": "Person",
|
|
name: trip.organizer.name,
|
|
},
|
|
offers: {
|
|
"@type": "Offer",
|
|
url: tripUrl,
|
|
price: trip.price,
|
|
priceCurrency: "IDR",
|
|
availability: offerAvailability,
|
|
validFrom: trip.createdAt.toISOString(),
|
|
},
|
|
maximumAttendeeCapacity: trip.maxParticipants,
|
|
...(averageRating && trip.reviews.length > 0
|
|
? {
|
|
aggregateRating: {
|
|
"@type": "AggregateRating",
|
|
ratingValue: averageRating,
|
|
reviewCount: trip.reviews.length,
|
|
bestRating: 5,
|
|
worstRating: 1,
|
|
},
|
|
}
|
|
: {}),
|
|
isAccessibleForFree: false,
|
|
inLanguage: "id-ID",
|
|
},
|
|
{
|
|
"@type": "BreadcrumbList",
|
|
"@id": `${tripUrl}#breadcrumbs`,
|
|
itemListElement: [
|
|
{ "@type": "ListItem", position: 1, name: "Beranda", item: siteUrl },
|
|
{
|
|
"@type": "ListItem",
|
|
position: 2,
|
|
name: "Open Trip",
|
|
item: absoluteUrl("/trips"),
|
|
},
|
|
{
|
|
"@type": "ListItem",
|
|
position: 3,
|
|
name: trip.destination,
|
|
item: tripUrl,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto max-w-3xl px-4 py-4 sm:py-8">
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
/>
|
|
{/* Breadcrumb */}
|
|
<div className="mb-3 flex items-center gap-2 text-xs text-neutral-500 sm:mb-4 sm:text-sm">
|
|
<Link href="/trips" className="hover:text-primary-600">
|
|
Open Trip
|
|
</Link>
|
|
<span>/</span>
|
|
<span className="truncate text-neutral-700">{trip.destination}</span>
|
|
</div>
|
|
|
|
<div className="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
|
{/* Image Gallery */}
|
|
<ImageGallery images={trip.images} />
|
|
|
|
{/* Title bar */}
|
|
<div className="border-b border-neutral-100 px-4 py-3 sm:px-6 sm:py-4">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<h1 className="text-lg font-bold text-neutral-800 sm:text-xl">
|
|
{trip.title}
|
|
</h1>
|
|
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-sm text-neutral-500">
|
|
<span aria-hidden>{catMeta.icon}</span>
|
|
<span className="rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-neutral-600">
|
|
{catMeta.label}
|
|
</span>
|
|
{trip.vibe && (
|
|
<span
|
|
className="inline-flex items-center gap-0.5 rounded-full bg-secondary-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-secondary-700"
|
|
title={vibeMeta(trip.vibe).description}
|
|
>
|
|
<span aria-hidden>{vibeMeta(trip.vibe).icon}</span>
|
|
<span>{vibeMeta(trip.vibe).label}</span>
|
|
</span>
|
|
)}
|
|
<span className="truncate">{trip.destination}</span>
|
|
</p>
|
|
</div>
|
|
<span
|
|
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-bold sm:px-3 sm:py-1 ${
|
|
trip.status === "OPEN"
|
|
? "bg-primary-100 text-primary-700"
|
|
: trip.status === "FULL"
|
|
? "bg-amber-100 text-amber-700"
|
|
: "bg-neutral-100 text-neutral-500"
|
|
}`}
|
|
>
|
|
{trip.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-5 p-4 sm:space-y-6 sm:p-6">
|
|
{/* Info Grid */}
|
|
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
|
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
|
|
<MapPin
|
|
size={18}
|
|
strokeWidth={1.75}
|
|
aria-hidden
|
|
className="text-secondary-700"
|
|
/>
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Lokasi</p>
|
|
<p className="truncate text-xs font-semibold text-neutral-800 sm:text-sm">{trip.location}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-secondary-100 sm:h-10 sm:w-10">
|
|
<CalendarDays
|
|
size={18}
|
|
strokeWidth={1.75}
|
|
aria-hidden
|
|
className="text-secondary-700"
|
|
/>
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Tanggal</p>
|
|
<p className="truncate text-xs font-semibold text-neutral-800 sm:text-sm">
|
|
{formatTripCalendarDateRangeLong(trip.date, trip.endDate)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 rounded-xl bg-primary-50 p-3 sm:gap-3 sm:p-4">
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-100 sm:h-10 sm:w-10">
|
|
<Wallet
|
|
size={18}
|
|
strokeWidth={1.75}
|
|
aria-hidden
|
|
className="text-primary-700"
|
|
/>
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="text-[10px] font-medium text-primary-600 sm:text-xs">Harga</p>
|
|
<p className="text-base font-bold text-primary-700 sm:text-xl">
|
|
{formatRupiah(trip.price)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-3 sm:gap-3 sm:p-4">
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-200 sm:h-10 sm:w-10">
|
|
<UserRound
|
|
size={18}
|
|
strokeWidth={1.75}
|
|
aria-hidden
|
|
className="text-neutral-600"
|
|
/>
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="text-[10px] font-medium text-neutral-400 sm:text-xs">Organizer</p>
|
|
<Link
|
|
href={`/u/${trip.organizer.id}`}
|
|
className="truncate text-xs font-semibold text-neutral-800 hover:text-primary-700 sm:text-sm"
|
|
>
|
|
{trip.organizer.name}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<OrganizerTrustPanel
|
|
name={trip.organizer.name}
|
|
image={trip.organizer.image}
|
|
trust={organizerTrust}
|
|
/>
|
|
|
|
{/* Participant Progress */}
|
|
<div className="rounded-xl border border-neutral-200 p-3 sm:p-4">
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-semibold text-neutral-700 sm:text-sm">
|
|
Peserta
|
|
</span>
|
|
{spotsLeft > 0 && spotsLeft <= 3 && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
|
|
<Zap size={11} strokeWidth={2} aria-hidden />
|
|
Tinggal {spotsLeft} spot!
|
|
</span>
|
|
)}
|
|
{spotsLeft <= 0 && (
|
|
<span className="rounded-full bg-neutral-200 px-2 py-0.5 text-[10px] font-bold text-neutral-700 sm:text-[11px]">
|
|
Penuh
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-xs font-bold text-neutral-800 sm:text-sm">
|
|
{participantCount}{" "}
|
|
<span className="font-normal text-neutral-400">
|
|
/ {trip.maxParticipants}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-neutral-100 sm:h-2.5">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${
|
|
fillPercent >= 100
|
|
? "bg-amber-500"
|
|
: fillPercent >= 70
|
|
? "bg-secondary-500"
|
|
: "bg-primary-500"
|
|
}`}
|
|
style={{ width: `${fillPercent}%` }}
|
|
/>
|
|
</div>
|
|
<p className="mt-1.5 text-[11px] text-neutral-500 sm:text-xs">
|
|
Maksimal {trip.maxParticipants} orang. Saat ini {participantCount}{" "}
|
|
mendaftar, {confirmedCount} sudah disetujui organizer.
|
|
</p>
|
|
<p className="mt-1 text-[11px] text-neutral-500 sm:text-xs">
|
|
{spotsLeft > 0
|
|
? `Masih ada ${spotsLeft} tempat — yuk gabung!`
|
|
: "Trip sudah penuh"}
|
|
{confirmedCount < participantCount && (
|
|
<>
|
|
{" "}
|
|
· {participantCount - confirmedCount} menunggu persetujuan
|
|
organizer
|
|
</>
|
|
)}
|
|
</p>
|
|
{confirmedCount > 0 && (
|
|
<p className="mt-2 flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-neutral-600 sm:text-xs">
|
|
<Users
|
|
size={13}
|
|
strokeWidth={1.75}
|
|
aria-hidden
|
|
className="text-neutral-400"
|
|
/>
|
|
Sudah join:{" "}
|
|
<span className="font-medium text-neutral-800">
|
|
{confirmedParticipants
|
|
.slice(0, 3)
|
|
.map((p) => p.user.name.split(" ")[0])
|
|
.join(", ")}
|
|
</span>
|
|
{confirmedCount > 3 && (
|
|
<span className="text-neutral-500">
|
|
{" "}
|
|
+{confirmedCount - 3} lainnya
|
|
</span>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<TripProgramBlock
|
|
meetingPoint={trip.meetingPoint}
|
|
itinerary={trip.itinerary}
|
|
itineraryItems={trip.itineraryItems.map((i) => ({
|
|
day: i.day,
|
|
startTime: i.startTime,
|
|
endTime: i.endTime,
|
|
activity: i.activity,
|
|
order: i.order,
|
|
}))}
|
|
whatsIncluded={trip.whatsIncluded}
|
|
whatsExcluded={trip.whatsExcluded}
|
|
/>
|
|
|
|
{/* Description */}
|
|
{trip.description && (
|
|
<div>
|
|
<h2 className="mb-2 text-xs font-bold text-neutral-700 sm:text-sm">
|
|
Deskripsi Trip
|
|
</h2>
|
|
<p className="whitespace-pre-wrap text-xs leading-relaxed text-neutral-600 sm:text-sm">
|
|
{trip.description}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{isOrganizer && pendingParticipants.length > 0 && (
|
|
<OrganizerJoinRequests
|
|
tripId={trip.id}
|
|
pending={pendingParticipants.map((p) => ({
|
|
id: p.id,
|
|
user: p.user,
|
|
}))}
|
|
/>
|
|
)}
|
|
|
|
{/* Action */}
|
|
<JoinTripButton
|
|
tripId={trip.id}
|
|
isLoggedIn={!!session?.user}
|
|
isOrganizer={isOrganizer}
|
|
isJoined={!!currentParticipation}
|
|
isFree={tripIsFree}
|
|
participationStatus={
|
|
currentParticipation?.status === "PENDING" ||
|
|
currentParticipation?.status === "CONFIRMED"
|
|
? currentParticipation.status
|
|
: null
|
|
}
|
|
bookingStatus={myBooking?.status ?? null}
|
|
isFull={spotsLeft <= 0}
|
|
tripStatus={trip.status}
|
|
isDeparturePast={isDeparturePast}
|
|
hideCancelButton={!!refundPreview}
|
|
/>
|
|
|
|
{/* Peserta PAID: cancel + request refund (lewat policy default). */}
|
|
{refundPreview && (
|
|
<CancelBookingButton
|
|
tripId={trip.id}
|
|
preview={{
|
|
days: refundPreview.days,
|
|
refundAmount: refundPreview.refundAmount,
|
|
bookingAmount: refundPreview.bookingAmount,
|
|
tierLabel: refundPreview.tier.label,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Organizer: batalkan trip (auto-refund peserta PAID). */}
|
|
{canOrganizerCancel && (
|
|
<CancelTripButton
|
|
tripId={trip.id}
|
|
paidParticipantCount={paidBookingCount}
|
|
/>
|
|
)}
|
|
|
|
{/* Kebijakan refund — transparency sebelum user cancel. */}
|
|
{!tripIsFree && <RefundPolicySection />}
|
|
|
|
<TripReviewSection
|
|
tripId={trip.id}
|
|
reviews={trip.reviews.map((r) => ({
|
|
id: r.id,
|
|
rating: r.rating,
|
|
comment: r.comment,
|
|
createdAt: r.createdAt,
|
|
user: r.user,
|
|
}))}
|
|
averageRating={averageRating}
|
|
canReview={canReview}
|
|
myReview={
|
|
myReview
|
|
? { rating: myReview.rating, comment: myReview.comment }
|
|
: null
|
|
}
|
|
/>
|
|
|
|
{/* Peserta yang sudah disetujui organizer (publik) */}
|
|
<div>
|
|
<h2 className="mb-1 text-xs font-bold text-neutral-700 sm:text-sm">
|
|
Peserta terkonfirmasi ({confirmedCount})
|
|
</h2>
|
|
<p className="mb-3 text-[11px] text-neutral-500 sm:text-xs">
|
|
Kenalan dulu sebelum berangkat — klik kartu untuk lihat profil.
|
|
</p>
|
|
{confirmedCount === 0 ? (
|
|
<p className="text-xs text-neutral-400 sm:text-sm">
|
|
Belum ada peserta yang dikonfirmasi.{" "}
|
|
{pendingParticipants.length > 0
|
|
? "Cek permintaan join di atas untuk menyetujui peserta."
|
|
: "Jadilah yang pertama mendaftar!"}
|
|
</p>
|
|
) : (
|
|
<ul className="grid gap-2 sm:grid-cols-2">
|
|
{confirmedParticipants.map((p) => {
|
|
const interests = p.user.profile?.interests ?? [];
|
|
const city = p.user.profile?.city;
|
|
return (
|
|
<li key={p.id}>
|
|
<Link
|
|
href={`/u/${p.user.id}`}
|
|
className="flex items-start gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 transition-colors hover:border-primary-300 hover:bg-primary-50/30"
|
|
>
|
|
{p.user.image ? (
|
|
<Image
|
|
src={p.user.image}
|
|
alt=""
|
|
width={40}
|
|
height={40}
|
|
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
|
|
{p.user.name.charAt(0).toUpperCase()}
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-semibold text-neutral-800">
|
|
{p.user.name}
|
|
</p>
|
|
{city && (
|
|
<p className="flex items-center gap-1 truncate text-[11px] text-neutral-500 sm:text-xs">
|
|
<MapPin
|
|
size={11}
|
|
strokeWidth={1.75}
|
|
aria-hidden
|
|
className="shrink-0"
|
|
/>
|
|
{city}
|
|
</p>
|
|
)}
|
|
{interests.length > 0 && (
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
{interests.slice(0, 3).map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="rounded-full bg-secondary-50 px-1.5 py-0.5 text-[10px] font-medium text-secondary-700"
|
|
>
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
{interests.length > 3 && (
|
|
<span className="text-[10px] text-neutral-400">
|
|
+{interests.length - 3}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|