feat(gamification): aylık uzman sezonları — ilk 3'e otomatik üyelik uzatması

Parça Uzmanları sıralaması artık aylık sezon: liderlik tablosu içinde
bulunulan TR-ayının (Europe/Istanbul) puanlarını gösterir ve her ayın
1'i 00:00 TR'de kendiliğinden sıfırlanır. expert-rewards cron'u
(BullMQ scheduler, "0 0 1 * *" tz=Europe/Istanbul) aynı anda biten
sezonu kapatır ve ilk 3 oylayıcıya üyelik uzatması verir:
1. → 30 gün, 2. → 15 gün, 3. → 7 gün (EXPERT_REWARD_LADDER).

Ödül mekaniği referral'la birebir: canlı active/trial abonelik endDate
+gün uzar, yoksa günler users.referral_credit_days'e bankalanır (sonraki
trial/aktivasyonda tüketilir). oem_expert_rewards (migration 0017,
period+rank UNIQUE) hem denetim kaydı hem run-once garantisi — retry ya
da elle tetik çift ödül veremez. Sıralama ölçütü job ve leaderboard'da
birebir aynı (puan desc, eşitlikte puana erken ulaşan önde).

Web: uzmanlar sayfasına sezon şeridi ("Haziran 2026 sezonu" + 🥇1 ay ·
🥈15 gün · 🥉7 gün rozetleri); alt küçük-punto kural satırına aylık
sıfırlama + ödül notu eklendi. Leaderboard cevabına periodStart eklendi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:48:36 +03:00
parent 55894bec0e
commit fe35bbd826
13 changed files with 360 additions and 27 deletions

View File

@@ -23,6 +23,24 @@ interface LeaderboardEntry {
interface LeaderboardResponse {
entries: LeaderboardEntry[];
me: { rank: number | null; points: number; votes: number };
// İçinde bulunulan TR-ayı sezonunun başlangıcı (ISO) — sezon etiketi bundan üretilir.
periodStart?: string;
}
// Aylık ödül merdiveni (API'deki EXPERT_REWARD_LADDER ile aynı).
const SEASON_PRIZES = [
{ key: "r1", medal: "🥇", label: "1 ay üyelik" },
{ key: "r2", medal: "🥈", label: "15 gün" },
{ key: "r3", medal: "🥉", label: "7 gün" },
] as const;
function seasonLabel(periodStart?: string): string {
const date = periodStart ? new Date(periodStart) : new Date();
return new Intl.DateTimeFormat("tr-TR", {
month: "long",
year: "numeric",
timeZone: "Europe/Istanbul",
}).format(date);
}
export const Route = createFileRoute("/dashboard/uzmanlar")({
@@ -69,11 +87,28 @@ function ExpertsPage() {
Parça Uzmanları
</h1>
<p className="mt-1 text-sm text-muted-foreground">
OEM kodlarını oyla, puan topla, sıralamada yüksel doğru bilgi bütün parça
satıcılarının işini hızlandırır.
OEM kodlarını oyla, puan topla, sıralamada yüksel doğru bilgi bütün parça satıcılarının
işini hızlandırır.
</p>
</div>
{/* Sezon şeridi: ayın ilk 3 uzmanı üyelik kazanır */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl border border-amber-400/30 bg-amber-400/5 px-4 py-3 text-sm">
<span className="font-semibold capitalize">{seasonLabel(data?.periodStart)} sezonu</span>
<span className="text-muted-foreground">ayın ilk 3 uzmanına üyelik:</span>
<span className="flex flex-wrap items-center gap-2">
{SEASON_PRIZES.map(({ key, medal, label }) => (
<span
key={key}
className="inline-flex items-center gap-1 rounded-full border border-border bg-background px-2.5 py-0.5 text-xs font-medium"
>
<span aria-hidden="true">{medal}</span>
{label}
</span>
))}
</span>
</div>
{isLoading ? (
<div className="space-y-3">
{SKELETON_KEYS.map((k) => (
@@ -128,7 +163,9 @@ function ExpertsPage() {
<p className="pt-2 text-center text-[11px] leading-relaxed text-muted-foreground/70">
Oy ver <span className="font-semibold text-muted-foreground">+1</span> · çoğunluğu tuttur{" "}
<span className="font-semibold text-muted-foreground">+2 bonus</span> · kodu ilk
değerlendiren <span className="font-semibold text-muted-foreground">3 puanı</span> kapar
değerlendiren <span className="font-semibold text-muted-foreground">3 puanı</span> kapar ·
sıralama her ayın 1'i 00:00'da sıfırlanır, biten ayın ilk 3'üne üyelik uzatması otomatik
tanımlanır
</p>
</div>
);