trust roadmap

This commit is contained in:
arifal
2026-05-09 00:55:40 +07:00
parent 5e0232d909
commit 54cd984a7e
14 changed files with 628 additions and 281 deletions
@@ -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>
);
}