trust roadmap
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import type { OrganizerTrust } from "@/server/services/trust.service";
|
||||
|
||||
interface OrganizerStatsPanelProps {
|
||||
trust: OrganizerTrust;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel reputasi organizer untuk halaman profil publik /u/[id].
|
||||
* Tidak render kalau user belum punya history sebagai organizer.
|
||||
*/
|
||||
export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
|
||||
const {
|
||||
isVerified,
|
||||
isTripLeader,
|
||||
tripsCreated,
|
||||
tripsCompleted,
|
||||
totalParticipantsServed,
|
||||
completionRate,
|
||||
avgRating,
|
||||
reviewCount,
|
||||
ratingBreakdown,
|
||||
} = trust;
|
||||
|
||||
if (tripsCreated === 0 && reviewCount === 0 && !isVerified) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxBreakdown = Math.max(
|
||||
ratingBreakdown[1],
|
||||
ratingBreakdown[2],
|
||||
ratingBreakdown[3],
|
||||
ratingBreakdown[4],
|
||||
ratingBreakdown[5],
|
||||
1
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="mt-5 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<h2 className="mb-3 text-sm font-bold text-neutral-700 sm:text-base">
|
||||
Reputasi sebagai organizer
|
||||
</h2>
|
||||
|
||||
{(isVerified || isTripLeader) && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{isVerified && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
|
||||
title="Identitas organizer telah diverifikasi (KTP & rekening)"
|
||||
>
|
||||
✅ Verified Organizer
|
||||
</span>
|
||||
)}
|
||||
{isTripLeader && (
|
||||
<span className="inline-flex items-center rounded-full bg-secondary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-secondary-900">
|
||||
Trip leader
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
<Stat
|
||||
label="Trip selesai"
|
||||
value={tripsCompleted.toString()}
|
||||
tone="primary"
|
||||
/>
|
||||
<Stat
|
||||
label="Peserta dilayani"
|
||||
value={totalParticipantsServed.toString()}
|
||||
tone="secondary"
|
||||
/>
|
||||
<Stat
|
||||
label="Completion rate"
|
||||
value={
|
||||
completionRate != null
|
||||
? `${Math.round(completionRate * 100)}%`
|
||||
: "—"
|
||||
}
|
||||
subtitle={
|
||||
completionRate == null ? "Belum cukup data" : undefined
|
||||
}
|
||||
tone="neutral"
|
||||
/>
|
||||
<Stat
|
||||
label="Rating"
|
||||
value={avgRating != null ? `${avgRating} ★` : "—"}
|
||||
subtitle={
|
||||
reviewCount > 0
|
||||
? `${reviewCount} ulasan`
|
||||
: "Belum ada ulasan"
|
||||
}
|
||||
tone="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{reviewCount > 0 && (
|
||||
<div className="mt-4 border-t border-neutral-100 pt-4">
|
||||
<h3 className="mb-2 text-xs font-semibold text-neutral-600">
|
||||
Distribusi rating
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{([5, 4, 3, 2, 1] as const).map((star) => {
|
||||
const count = ratingBreakdown[star];
|
||||
const percent = (count / maxBreakdown) * 100;
|
||||
return (
|
||||
<div
|
||||
key={star}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<span className="w-8 shrink-0 font-medium text-neutral-600">
|
||||
{star} ★
|
||||
</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-neutral-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-400 transition-all"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 shrink-0 text-right text-neutral-500">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const TONE_CLASSES = {
|
||||
primary: { bg: "bg-primary-50", value: "text-primary-700" },
|
||||
secondary: { bg: "bg-secondary-50", value: "text-secondary-700" },
|
||||
neutral: { bg: "bg-neutral-50", value: "text-neutral-800" },
|
||||
amber: { bg: "bg-amber-50", value: "text-amber-700" },
|
||||
} as const;
|
||||
|
||||
interface StatProps {
|
||||
label: string;
|
||||
value: string;
|
||||
subtitle?: string;
|
||||
tone: keyof typeof TONE_CLASSES;
|
||||
}
|
||||
|
||||
function Stat({ label, value, subtitle, tone }: StatProps) {
|
||||
const cls = TONE_CLASSES[tone];
|
||||
return (
|
||||
<div className={`rounded-xl px-3 py-2.5 ${cls.bg}`}>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-neutral-500 sm:text-[11px]">
|
||||
{label}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-lg font-bold sm:text-xl ${cls.value}`}>
|
||||
{value}
|
||||
</p>
|
||||
{subtitle && (
|
||||
<p className="text-[10px] text-neutral-400">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { OrganizerReviewItem } from "@/server/services/review.service";
|
||||
|
||||
interface OrganizerReviewsListProps {
|
||||
reviews: OrganizerReviewItem[];
|
||||
/// Total review keseluruhan organizer (bisa lebih besar dari `reviews.length`
|
||||
/// kalau di-limit di service). Kalau tidak diberikan, fallback ke `reviews.length`.
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
export function OrganizerReviewsList({
|
||||
reviews,
|
||||
totalCount,
|
||||
}: OrganizerReviewsListProps) {
|
||||
if (reviews.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = totalCount ?? reviews.length;
|
||||
const showingMore = total > reviews.length;
|
||||
|
||||
return (
|
||||
<section className="mt-5 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="text-sm font-bold text-neutral-700 sm:text-base">
|
||||
Ulasan dari peserta
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{showingMore
|
||||
? `${reviews.length} terbaru dari ${total} ulasan`
|
||||
: `${total} ulasan`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{reviews.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="rounded-xl border border-neutral-100 bg-neutral-50/60 px-3 py-3 sm:px-4 sm:py-3.5"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{r.user.image ? (
|
||||
<Image
|
||||
src={r.user.image}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary-600 text-xs font-bold text-white">
|
||||
{r.user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
|
||||
<Link
|
||||
href={`/u/${r.user.id}`}
|
||||
className="text-xs font-semibold text-neutral-800 hover:text-primary-700 sm:text-sm"
|
||||
>
|
||||
{r.user.name}
|
||||
</Link>
|
||||
<span className="text-xs font-bold text-amber-600">
|
||||
{"★".repeat(r.rating)}
|
||||
<span className="text-neutral-300">
|
||||
{"★".repeat(5 - r.rating)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-neutral-500 sm:text-xs">
|
||||
via{" "}
|
||||
<Link
|
||||
href={`/trips/${r.trip.id}`}
|
||||
className="text-neutral-600 underline-offset-2 hover:text-primary-700 hover:underline"
|
||||
>
|
||||
{r.trip.title}
|
||||
</Link>
|
||||
<span className="text-neutral-400">
|
||||
{" "}
|
||||
· {formatReviewDate(r.createdAt)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{r.comment && (
|
||||
<p className="mt-2 whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
|
||||
{r.comment}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatReviewDate(date: Date): string {
|
||||
return new Date(date).toLocaleDateString("id-ID", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
@@ -248,15 +248,21 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="itinerary" className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
<label htmlFor="itinerary" className="mb-1 block text-sm font-semibold text-neutral-700">
|
||||
Itinerary
|
||||
</label>
|
||||
<p className="mb-1.5 text-[11px] text-neutral-500">
|
||||
Tulis per hari supaya peserta tahu alur — itinerary lengkap bikin
|
||||
trust naik drastis.
|
||||
</p>
|
||||
<textarea
|
||||
id="itinerary"
|
||||
name="itinerary"
|
||||
rows={5}
|
||||
rows={6}
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
|
||||
placeholder={"Hari 1: …\nHari 2: …"}
|
||||
placeholder={
|
||||
"Hari 1: 05:00 kumpul di meeting point\n07:00 berangkat\n12:00 ishoma di rest area\n16:00 sampai basecamp, briefing\n\nHari 2: 04:00 summit attack\n08:00 kembali ke basecamp\n..."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,10 +56,23 @@ export function OrganizerTrustPanel({
|
||||
<div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0">
|
||||
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
|
||||
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
|
||||
Trip dibuat
|
||||
Trip selesai
|
||||
</p>
|
||||
<p className="text-lg font-bold text-neutral-800">
|
||||
{trust.tripsCreated}
|
||||
<p className="text-lg font-bold text-primary-700">
|
||||
{trust.tripsCompleted}
|
||||
</p>
|
||||
{trust.tripsCreated > trust.tripsCompleted && (
|
||||
<p className="text-[10px] text-neutral-400">
|
||||
+ {trust.tripsCreated - trust.tripsCompleted} berjalan
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
|
||||
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
|
||||
Peserta dilayani
|
||||
</p>
|
||||
<p className="text-lg font-bold text-secondary-700">
|
||||
{trust.totalParticipantsServed}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
|
||||
|
||||
Reference in New Issue
Block a user