185 lines
5.5 KiB
TypeScript
185 lines
5.5 KiB
TypeScript
import { BadgeCheck, Star } from "lucide-react";
|
|
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)"
|
|
>
|
|
<BadgeCheck size={12} strokeWidth={2} aria-hidden />
|
|
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 ? (
|
|
<span className="inline-flex items-center gap-1">
|
|
{avgRating}
|
|
<Star
|
|
size={14}
|
|
strokeWidth={2}
|
|
fill="currentColor"
|
|
aria-hidden
|
|
/>
|
|
</span>
|
|
) : (
|
|
"—"
|
|
)
|
|
}
|
|
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="flex w-8 shrink-0 items-center gap-0.5 font-medium text-neutral-600">
|
|
{star}
|
|
<Star
|
|
size={11}
|
|
strokeWidth={2}
|
|
fill="currentColor"
|
|
aria-hidden
|
|
className="text-amber-500"
|
|
/>
|
|
</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: React.ReactNode;
|
|
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>
|
|
);
|
|
}
|