Merge remote-tracking branch 'origin/dev'

This commit is contained in:
2026-06-12 11:05:41 +03:00
27 changed files with 653 additions and 110 deletions

View File

@@ -0,0 +1,18 @@
-- Monthly Parça Uzmanları prizes: one row per (TR-month, rank). Written by the
-- expert-rewards cron (1st of month 00:00 Europe/Istanbul) when it closes the
-- finished season and grants the top 3 voters a subscription extension
-- (30/15/7 days). The unique index is the run-once guard — a re-run for the
-- same period inserts nothing, so days are never granted twice.
CREATE TABLE "oem_expert_rewards" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"period_start" timestamp with time zone NOT NULL,
"user_id" uuid NOT NULL,
"rank" integer NOT NULL,
"points" integer NOT NULL,
"reward_days" integer NOT NULL,
"granted_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oem_expert_rewards" ADD CONSTRAINT "oem_expert_rewards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "oem_expert_rewards_period_rank_idx" ON "oem_expert_rewards" USING btree ("period_start","rank");--> statement-breakpoint
CREATE INDEX "oem_expert_rewards_user_id_idx" ON "oem_expert_rewards" USING btree ("user_id");

View File

@@ -120,6 +120,13 @@
"when": 1781308800000,
"tag": "0016_oem_suggestions",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1781395200000,
"tag": "0017_oem_expert_rewards",
"breakpoints": true
}
]
}

View File

@@ -742,3 +742,31 @@ export const oemSuggestions = pgTable(
index("oem_suggestions_oem_code_idx").on(table.oemCode),
],
);
// ─── OEM Expert Rewards (monthly leaderboard prizes) ──
// One row per (TR-month, rank): the expert-rewards cron (1st of month 00:00
// Europe/Istanbul) closes the finished season and grants the top 3 voters a
// subscription extension (30/15/7 days — EXPERT_REWARD_LADDER). The unique
// index doubles as the run-once guard: a second run for the same period
// inserts nothing, so days are never granted twice. Granting mirrors the
// referral mechanic: extend a live active/trial sub, else bank the days in
// users.referral_credit_days (consumed at next trial/activation).
export const oemExpertRewards = pgTable(
"oem_expert_rewards",
{
id: uuid("id").primaryKey().defaultRandom(),
// TR-ayının başlangıcı (UTC timestamptz) — ödüllendirilen sezon.
periodStart: timestamp("period_start", { withTimezone: true }).notNull(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
rank: integer("rank").notNull(),
points: integer("points").notNull(),
rewardDays: integer("reward_days").notNull(),
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_expert_rewards_period_rank_idx").on(table.periodStart, table.rank),
index("oem_expert_rewards_user_id_idx").on(table.userId),
],
);

View File

@@ -27,4 +27,5 @@ export const QUEUE_NAMES = {
CATALOG_PREFETCH: "catalog-prefetch",
TRANSLATION: "translation",
LIFECYCLE_EMAIL: "lifecycle-email",
EXPERT_REWARDS: "expert-rewards",
} as const;

View File

@@ -8,6 +8,7 @@ import {
CatalogPrefetchQueueProvider,
} from "./queues/catalog-prefetch.queue";
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
import { EXPERT_REWARDS_QUEUE, ExpertRewardsQueueProvider } from "./queues/expert-rewards.queue";
import { LIFECYCLE_EMAIL_QUEUE, LifecycleEmailQueueProvider } from "./queues/lifecycle-email.queue";
import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue";
import {
@@ -23,6 +24,7 @@ import {
QueryCleanupQueueProvider,
CatalogPrefetchQueueProvider,
LifecycleEmailQueueProvider,
ExpertRewardsQueueProvider,
PrefetchWorkerService,
],
exports: [
@@ -31,6 +33,7 @@ import {
QUERY_CLEANUP_QUEUE,
CATALOG_PREFETCH_QUEUE,
LIFECYCLE_EMAIL_QUEUE,
EXPERT_REWARDS_QUEUE,
],
})
export class JobsModule implements OnModuleInit, OnModuleDestroy {
@@ -39,6 +42,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
@Inject(QUERY_CLEANUP_QUEUE) private queryCleanupQueue: Queue,
@Inject(CATALOG_PREFETCH_QUEUE) private catalogPrefetchQueue: Queue,
@Inject(LIFECYCLE_EMAIL_QUEUE) private lifecycleEmailQueue: Queue,
@Inject(EXPERT_REWARDS_QUEUE) private expertRewardsQueue: Queue,
) {}
async onModuleInit() {
@@ -124,6 +128,24 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
await this.catalogPrefetchQueue.removeJobScheduler("catalog-backfill-hourly").catch(() => {});
console.log("[jobs] Skipped catalog-backfill cron (not prod host)");
}
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 Türkiye saati —
// biten ayın ilk 3 oylayıcısına üyelik uzatması (30/15/7 gün). Ödül
// yalnız DB'ye yazar (dış yan etki yok), o yüzden dev'de de çalışır;
// (period, rank) unique index'i çift vermeyi zaten engeller.
await this.expertRewardsQueue.upsertJobScheduler(
"expert-rewards-monthly",
{ pattern: "0 0 1 * *", tz: "Europe/Istanbul" },
{
name: "expert-rewards-grant",
data: {},
opts: {
removeOnComplete: { count: 24 },
removeOnFail: { count: 50 },
},
},
);
console.log("[jobs] Registered expert-rewards cron: 0 0 1 * * (Europe/Istanbul)");
}
async onModuleDestroy() {
@@ -132,6 +154,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
this.queryCleanupQueue.close(),
this.catalogPrefetchQueue.close(),
this.lifecycleEmailQueue.close(),
this.expertRewardsQueue.close(),
]);
}
}

View File

@@ -0,0 +1,114 @@
import { Job } from "bullmq";
import { and, asc, desc, eq, gte, lt, or, sql } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import {
oemExpertRewards,
oemVotePoints,
userSubscriptions,
users,
} from "../../database/schema/core";
import { EXPERT_REWARD_LADDER, previousTrMonthWindow } from "../../oem-votes/expert-period";
type Database = PostgresJsDatabase<Record<string, unknown>>;
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 TR'de biten ayın ilk 3
// oylayıcısına üyelik uzatması (30/15/7 gün). oem_expert_rewards'taki
// (period, rank) unique index run-once garantisidir — yeniden çalıştırma
// (retry, elle tetik) hiçbir şeyi ikinci kez vermez.
export async function processExpertRewards(
job: Job,
db: Database,
): Promise<{ period: string; granted: number; skipped: boolean }> {
const { start, end } = previousTrMonthWindow(new Date());
const periodLabel = start.toISOString();
console.log(`[expert-rewards] Processing job ${job.id} for period ${periodLabel}`);
const [alreadyGranted] = await db
.select({ id: oemExpertRewards.id })
.from(oemExpertRewards)
.where(eq(oemExpertRewards.periodStart, start))
.limit(1);
if (alreadyGranted) {
console.log(`[expert-rewards] Period ${periodLabel} already granted — skipping`);
return { period: periodLabel, granted: 0, skipped: true };
}
// Biten sezonun ilk 3'ü — liderlik tablosuyla aynı sıralama: puan desc,
// eşitlikte puana daha erken ulaşan önde.
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
const top = await db
.select({ userId: oemVotePoints.userId, points: totalPoints })
.from(oemVotePoints)
.where(and(gte(oemVotePoints.createdAt, start), lt(oemVotePoints.createdAt, end)))
.groupBy(oemVotePoints.userId)
.orderBy(desc(totalPoints), asc(sql`min(${oemVotePoints.createdAt})`))
.limit(EXPERT_REWARD_LADDER.length);
if (top.length === 0) {
console.log(`[expert-rewards] No votes in period ${periodLabel} — nothing to grant`);
return { period: periodLabel, granted: 0, skipped: false };
}
let granted = 0;
for (let i = 0; i < top.length; i++) {
const winner = top[i];
const { rank, days } = EXPERT_REWARD_LADDER[i];
await db.transaction(async (tx) => {
const inserted = await tx
.insert(oemExpertRewards)
.values({
periodStart: start,
userId: winner.userId,
rank,
points: winner.points,
rewardDays: days,
})
.onConflictDoNothing()
.returning({ id: oemExpertRewards.id });
// Yarış/yeniden-deneme: kayıt zaten varsa gün de verilmiş demektir.
if (inserted.length === 0) return;
// Referral ödül mekaniğinin birebir kopyası (worker Nest DI'sız çalıştığı
// için ReferralsService.grantRewardDays buradan çağrılamıyor): canlı
// active/trial aboneliği uzat, yoksa günleri krediye banka et — kredi bir
// sonraki trial/aktivasyonda otomatik tüketilir.
const [sub] = await tx
.select({ id: userSubscriptions.id, endDate: userSubscriptions.endDate })
.from(userSubscriptions)
.where(
and(
eq(userSubscriptions.userId, winner.userId),
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
),
)
.orderBy(desc(userSubscriptions.endDate))
.limit(1);
if (sub?.endDate) {
const newEnd = new Date(sub.endDate);
newEnd.setDate(newEnd.getDate() + days);
await tx
.update(userSubscriptions)
.set({ endDate: newEnd, updatedAt: new Date() })
.where(eq(userSubscriptions.id, sub.id));
} else {
await tx
.update(users)
.set({
referralCreditDays: sql`${users.referralCreditDays} + ${days}`,
updatedAt: new Date(),
})
.where(eq(users.id, winner.userId));
}
granted++;
console.log(
`[expert-rewards] rank=${rank} user=${winner.userId} points=${winner.points} +${days}d`,
);
});
}
console.log(`[expert-rewards] Period ${periodLabel}: granted ${granted} reward(s)`);
return { period: periodLabel, granted, skipped: false };
}

View File

@@ -67,17 +67,11 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
.innerJoin(users, eq(userSubscriptions.userId, users.id))
.leftJoin(
emailPreferences,
and(
eq(emailPreferences.userId, users.id),
eq(emailPreferences.workflow, "trial-ending"),
),
and(eq(emailPreferences.userId, users.id), eq(emailPreferences.workflow, "trial-ending")),
)
.leftJoin(
lifecycleEmailSent,
and(
eq(lifecycleEmailSent.userId, users.id),
eq(lifecycleEmailSent.workflow, "trial-ending"),
),
and(eq(lifecycleEmailSent.userId, users.id), eq(lifecycleEmailSent.workflow, "trial-ending")),
)
.where(
and(
@@ -96,11 +90,7 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
{ subscriberId: r.userId, email: r.email, firstName: firstNameOf(r.name) },
{
daysLeft: 3,
ctaUrl: buildTrackedUrl(
"trial-ending",
r.email,
webUrl("/dashboard/subscription"),
),
ctaUrl: buildTrackedUrl("trial-ending", r.email, webUrl("/dashboard/subscription")),
...(trackPixel ? { trackPixel } : {}),
},
);
@@ -134,17 +124,11 @@ async function sendWinBack(db: Database, now: Date): Promise<number> {
.innerJoin(users, eq(userSubscriptions.userId, users.id))
.leftJoin(
emailPreferences,
and(
eq(emailPreferences.userId, users.id),
eq(emailPreferences.workflow, "win-back"),
),
and(eq(emailPreferences.userId, users.id), eq(emailPreferences.workflow, "win-back")),
)
.leftJoin(
lifecycleEmailSent,
and(
eq(lifecycleEmailSent.userId, users.id),
eq(lifecycleEmailSent.workflow, "win-back"),
),
and(eq(lifecycleEmailSent.userId, users.id), eq(lifecycleEmailSent.workflow, "win-back")),
)
.where(
and(

View File

@@ -0,0 +1,25 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
export const EXPERT_REWARDS_QUEUE = "EXPERT_REWARDS_QUEUE";
export const ExpertRewardsQueueProvider: Provider = {
provide: EXPERT_REWARDS_QUEUE,
useFactory: () => {
const telemetry = getBullTelemetry();
return new Queue(QUEUE_NAMES.EXPERT_REWARDS, {
connection: getBullConnection(),
...(telemetry ? { telemetry } : {}),
defaultJobOptions: {
attempts: 3,
backoff: {
type: "exponential",
delay: 10000,
},
removeOnComplete: { count: 100 },
removeOnFail: { count: 200 },
},
});
},
};

View File

@@ -1,17 +1,22 @@
import { BadRequestException, Body, Controller, Get, Post, Query } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ExpertAccessService } from "../oem-votes/expert-access.service";
import { createOemSuggestionSchema } from "./oem-suggestions.dto";
import { OemSuggestionsService } from "./oem-suggestions.service";
@Controller("oem-suggestions")
export class OemSuggestionsController {
constructor(private readonly oemSuggestionsService: OemSuggestionsService) {}
constructor(
private readonly oemSuggestionsService: OemSuggestionsService,
private readonly expertAccess: ExpertAccessService,
) {}
// Spam koruması: dakikada en fazla 10 öneri.
@Post()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async create(@CurrentUser("id") userId: string, @Body() body: unknown) {
await this.expertAccess.assert(userId);
const parsed = createOemSuggestionSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz öneri");

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { OemVotesModule } from "../oem-votes/oem-votes.module";
import { OemSuggestionsController } from "./oem-suggestions.controller";
import { OemSuggestionsService } from "./oem-suggestions.service";
@Module({
imports: [OemVotesModule],
controllers: [OemSuggestionsController],
providers: [OemSuggestionsService],
exports: [OemSuggestionsService],

View File

@@ -0,0 +1,48 @@
import { ForbiddenException, Inject, Injectable } from "@nestjs/common";
import { and, countDistinct, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { oemCodeCopies, queryLogs } from "../database/schema/core";
import { PostHogService } from "../posthog/posthog.service";
import { meetsExpertCriteria } from "./oem-votes.logic";
// Parça Uzmanları programının ana şalteri (PostHog, local-eval). Kapatınca
// program herkesten gizlenir; yüzdeli rollout gerekirse oradan daraltılır.
export const EXPERT_PROGRAM_FLAG = "oem-expert-program";
@Injectable()
export class ExpertAccessService {
constructor(
@Inject(DATABASE) private db: Database,
private readonly posthog: PostHogService,
) {}
// Erişim = flag AÇIK VE kullanım kriteri: en az 2 FARKLI başarılı VIN
// decode + en az 2 FARKLI OEM kodu kopyası (kayıtlı, kataloğu gerçekten
// kullanan üyeler). Flag local-eval edilemezse fail-open — kitleyi kriter
// zaten daraltır, PostHog kesintisi programı söndürmesin.
async check(userId: string): Promise<{ enabled: boolean }> {
const flagOn = await this.posthog.isEnabled(EXPERT_PROGRAM_FLAG, userId, true);
if (!flagOn) return { enabled: false };
const [vinRows, copyRows] = await Promise.all([
this.db
.select({ n: countDistinct(queryLogs.vin) })
.from(queryLogs)
.where(and(eq(queryLogs.userId, userId), eq(queryLogs.success, true))),
this.db
.select({ n: countDistinct(oemCodeCopies.oemCode) })
.from(oemCodeCopies)
.where(eq(oemCodeCopies.userId, userId)),
]);
return { enabled: meetsExpertCriteria(vinRows[0]?.n ?? 0, copyRows[0]?.n ?? 0) };
}
// Yazma uçları (oy, öneri) UI gizlense bile doğrudan istekle delinemesin.
async assert(userId: string): Promise<void> {
const { enabled } = await this.check(userId);
if (!enabled) {
throw new ForbiddenException("Parça Uzmanları programı hesabınızda henüz aktif değil");
}
}
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { EXPERT_REWARD_LADDER, previousTrMonthWindow, trMonthStart } from "./expert-period";
describe("trMonthStart", () => {
it("ay ortasında TR-ayının başını UTC olarak verir (TR 00:00 = UTC 21:00 önceki gün)", () => {
const now = new Date("2026-06-15T10:00:00Z");
expect(trMonthStart(now).toISOString()).toBe("2026-05-31T21:00:00.000Z");
});
it("UTC gece TR'de yeni aya geçmişse TR gününü baz alır", () => {
// UTC 30 Haziran 22:30 = TR 1 Temmuz 01:30 → içinde bulunulan TR-ayı Temmuz
const now = new Date("2026-06-30T22:30:00Z");
expect(trMonthStart(now).toISOString()).toBe("2026-06-30T21:00:00.000Z");
expect(trMonthStart(now, 1).toISOString()).toBe("2026-05-31T21:00:00.000Z");
});
it("yıl devrini doğru çözer (TR ocak → geriye aralık)", () => {
const now = new Date("2026-01-10T12:00:00Z");
expect(trMonthStart(now, 1).toISOString()).toBe("2025-11-30T21:00:00.000Z");
});
});
describe("previousTrMonthWindow", () => {
it("cron anında (TR 1'i 00:00) biten ayı kapsar", () => {
// TR 1 Temmuz 00:00 = UTC 30 Haziran 21:00
const cronMoment = new Date("2026-06-30T21:00:00Z");
const { start, end } = previousTrMonthWindow(cronMoment);
expect(start.toISOString()).toBe("2026-05-31T21:00:00.000Z"); // TR 1 Haziran 00:00
expect(end.toISOString()).toBe("2026-06-30T21:00:00.000Z"); // TR 1 Temmuz 00:00
});
});
describe("EXPERT_REWARD_LADDER", () => {
it("1. → 30 gün, 2. → 15 gün, 3. → 7 gün", () => {
expect(EXPERT_REWARD_LADDER.map((r) => r.days)).toEqual([30, 15, 7]);
});
});

View File

@@ -0,0 +1,23 @@
// Parça Uzmanları aylık sezon takvimi. Sınırlar Türkiye saatine göredir
// (Europe/Istanbul, 2016'dan beri sabit UTC+3 — DST yok); sıralama her ayın
// 1'i 00:00 TR'de sıfırlanır, biten ayın ilk 3'üne üyelik uzatması verilir.
const TR_OFFSET_MS = 3 * 60 * 60 * 1000;
// Ödül merdiveni: 1. → 1 ay (30 gün), 2. → 15 gün, 3. → 7 gün üyelik.
export const EXPERT_REWARD_LADDER = [
{ rank: 1, days: 30 },
{ rank: 2, days: 15 },
{ rank: 3, days: 7 },
] as const;
// `monthsBack` ay geriden TR-ayının başlangıcını UTC Date olarak döndürür
// (0 = içinde bulunulan ay). Date.UTC ay taşmalarını (ocak-1 → aralık) çözer.
export function trMonthStart(now: Date, monthsBack = 0): Date {
const tr = new Date(now.getTime() + TR_OFFSET_MS);
return new Date(Date.UTC(tr.getUTCFullYear(), tr.getUTCMonth() - monthsBack, 1) - TR_OFFSET_MS);
}
// Biten sezonun penceresi: [önceki TR-ay başı, bu TR-ay başı).
export function previousTrMonthWindow(now: Date): { start: Date; end: Date } {
return { start: trMonthStart(now, 1), end: trMonthStart(now, 0) };
}

View File

@@ -1,17 +1,28 @@
import { BadRequestException, Body, Controller, Get, Post } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ExpertAccessService } from "./expert-access.service";
import { castOemVoteSchema, lookupOemVotesSchema } from "./oem-votes.dto";
import { OemVotesService } from "./oem-votes.service";
@Controller("oem-votes")
export class OemVotesController {
constructor(private readonly oemVotesService: OemVotesService) {}
constructor(
private readonly oemVotesService: OemVotesService,
private readonly expertAccess: ExpertAccessService,
) {}
// Program kapısı: flag + kullanım kriteri (web açılışta bir kez sorar).
@Get("access")
async access(@CurrentUser("id") userId: string) {
return this.expertAccess.check(userId);
}
// Puan çiftliğine karşı insan-hızı sınırı: dakikada en fazla 30 oy.
@Post()
@Throttle({ default: { limit: 30, ttl: 60_000 } })
async cast(@CurrentUser("id") userId: string, @Body() body: unknown) {
await this.expertAccess.assert(userId);
const parsed = castOemVoteSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz oy verisi");

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computeVoteAward, maskExpertName } from "./oem-votes.logic";
import { computeVoteAward, maskExpertName, meetsExpertCriteria } from "./oem-votes.logic";
describe("computeVoteAward", () => {
it("ilk oy: kimse oylamamışsa 3 puan (1 oy + 2 doğru)", () => {
@@ -67,3 +67,13 @@ describe("maskExpertName", () => {
expect(maskExpertName(" ")).toBe("Üye");
});
});
describe("meetsExpertCriteria", () => {
it("en az 2 farklı VIN VE en az 2 farklı OEM kopyası ister", () => {
expect(meetsExpertCriteria(2, 2)).toBe(true);
expect(meetsExpertCriteria(5, 3)).toBe(true);
expect(meetsExpertCriteria(1, 2)).toBe(false);
expect(meetsExpertCriteria(2, 1)).toBe(false);
expect(meetsExpertCriteria(0, 0)).toBe(false);
});
});

View File

@@ -25,6 +25,15 @@ export function computeVoteAward(prior: VoteCounts, vote: OemVoteChoice): VoteAw
return { points: VOTE_BASE_POINTS + (correct ? VOTE_MAJORITY_BONUS : 0), correct };
}
// Program kitle kriteri: kataloğu gerçekten kullanan kayıtlı üyeler —
// en az 2 FARKLI başarılı VIN decode VE en az 2 FARKLI OEM kodu kopyası.
export const MIN_DISTINCT_VINS = 2;
export const MIN_DISTINCT_OEM_COPIES = 2;
export function meetsExpertCriteria(distinctVins: number, distinctOemCopies: number): boolean {
return distinctVins >= MIN_DISTINCT_VINS && distinctOemCopies >= MIN_DISTINCT_OEM_COPIES;
}
// Liderlik listesi adları KVKK-dostu: yalnız ad ve soyadın ilk harfi açık
// ("Semih Yılmaz" → "S*** Y***"). Ara adlar tamamen düşer; tek kelimelik
// adlarda o kelimenin ilk harfi kalır.

View File

@@ -1,10 +1,11 @@
import { Module } from "@nestjs/common";
import { ExpertAccessService } from "./expert-access.service";
import { OemVotesController } from "./oem-votes.controller";
import { OemVotesService } from "./oem-votes.service";
@Module({
controllers: [OemVotesController],
providers: [OemVotesService],
exports: [OemVotesService],
providers: [OemVotesService, ExpertAccessService],
exports: [OemVotesService, ExpertAccessService],
})
export class OemVotesModule {}

View File

@@ -1,7 +1,8 @@
import { Inject, Injectable } from "@nestjs/common";
import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
import { and, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { oemVotePoints, oemVotes, users } from "../database/schema/core";
import { trMonthStart } from "./expert-period";
import {
type OemVoteChoice,
type VoteCounts,
@@ -149,12 +150,18 @@ export class OemVotesService {
return result;
}
// Parça Uzmanları: puana göre herkes, adlar maskeli ("S*** Y***"). Yanıt
// userId sızdırmaz; istek sahibi kendi satırını isMe ile bulur.
// Parça Uzmanları: içinde bulunulan TR-ayının (sezonun) sıralaması — her
// ayın 1'i 00:00 TR'de sıfırlanır, biten ayın ilk 3'üne expert-rewards
// cron'u üyelik uzatması verir. Puana göre herkes, adlar maskeli
// ("S*** Y***"); yanıt userId sızdırmaz, istek sahibi kendi satırını isMe
// ile bulur. Sıralama ödül job'ıyla birebir aynı: puan desc, eşitlikte
// puana daha erken ulaşan önde.
async leaderboard(userId: string): Promise<{
entries: LeaderboardEntry[];
me: { rank: number | null; points: number; votes: number };
periodStart: string;
}> {
const periodStart = trMonthStart(new Date());
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
const rows = await this.db
.select({
@@ -165,6 +172,7 @@ export class OemVotesService {
})
.from(oemVotePoints)
.innerJoin(users, eq(users.id, oemVotePoints.userId))
.where(gte(oemVotePoints.createdAt, periodStart))
.groupBy(oemVotePoints.userId, users.name)
.orderBy(desc(totalPoints), sql`min(${oemVotePoints.createdAt}) asc`)
.limit(500);
@@ -180,6 +188,7 @@ export class OemVotesService {
return {
entries,
me: { rank: my?.rank ?? null, points: my?.points ?? 0, votes: my?.votes ?? 0 },
periodStart: periodStart.toISOString(),
};
}

View File

@@ -9,6 +9,7 @@ import OpenAI from "openai";
import postgres from "postgres";
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "./jobs/bull.config";
import { processEmexScrape } from "./jobs/processors/emex-scrape.processor";
import { processExpertRewards } from "./jobs/processors/expert-rewards.processor";
import { processLifecycleEmails } from "./jobs/processors/lifecycle-email.processor";
import { processQueryCleanup } from "./jobs/processors/query-cleanup.processor";
import { processSubscriptionExpiry } from "./jobs/processors/subscription-expiry.processor";
@@ -159,6 +160,32 @@ lifecycleEmailWorker.on("failed", (job, err) => {
workers.push(lifecycleEmailWorker);
// Expert Rewards Worker (monthly Parça Uzmanları top-3 subscription prizes)
const expertRewardsWorker = new Worker(
QUEUE_NAMES.EXPERT_REWARDS,
async (job) => {
return processExpertRewards(job, db);
},
{
connection,
concurrency: 1,
...(telemetry ? { telemetry } : {}),
},
);
expertRewardsWorker.on("completed", (job) => {
console.log(`[worker] expert-rewards job ${job.id} completed`);
});
expertRewardsWorker.on("failed", (job, err) => {
console.error(`[worker] expert-rewards job ${job?.id} failed: ${err.message}`);
Sentry.captureException(err, {
tags: { queue: QUEUE_NAMES.EXPERT_REWARDS, jobId: job?.id },
});
});
workers.push(expertRewardsWorker);
// Translation Worker (async LLM translation for new EMEX/PCAT terms)
const openrouterApiKey = process.env.OPENROUTER_API_KEY;
if (openrouterApiKey) {

View File

@@ -44,21 +44,27 @@ afterEach(() => {
});
describe("OemVoteCard", () => {
it("loads the summary for the code and shows the counts", async () => {
it("loads the summary, marks my vote and hides the tallies", async () => {
vi.mocked(api.post).mockResolvedValue({
votes: { "8V0615423E": { compatible: 4, incompatible: 1, myVote: "compatible" } },
});
render(<OemVoteCard oemCode="8V0615423E" />);
render(
<OemVoteCard oemCode="8V0615423E" vehicleLabel="Audi A4 (2018)" partName="Fren balatası" />,
);
// bağlam satırı: neyle neyin uyumlu olduğu tek alanda
expect(screen.getByText("Audi A4 (2018) · Fren balatası")).toBeInTheDocument();
const upButton = screen.getByRole("button", { name: /Uyumlu/ });
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
await waitFor(() => {
expect(api.post).toHaveBeenCalledWith("/oem-votes/lookup", { codes: ["8V0615423E"] });
expect(upButton.textContent).toContain("4");
expect(downButton.textContent).toContain("1");
expect(upButton).toHaveAttribute("aria-pressed", "true");
});
expect(upButton).toHaveAttribute("aria-pressed", "true");
// sayaçlar butonlarda görünmez (çoğunluk oyu yönlendirmesin)
expect(upButton.textContent).not.toContain("4");
expect(downButton.textContent).not.toContain("1");
});
it("casts a vote, updates the counts and reports the points", async () => {
@@ -84,17 +90,16 @@ describe("OemVoteCard", () => {
fireEvent.click(downButton);
await waitFor(() => {
expect(postMock).toHaveBeenCalledWith("/oem-votes", {
oemCode: "X1",
vote: "incompatible",
});
expect(postMock).toHaveBeenCalledWith(
"/oem-votes",
expect.objectContaining({ oemCode: "X1", vote: "incompatible" }),
);
expect(toastSuccessMock).toHaveBeenCalledWith("+3 puan kazandınız!", expect.anything());
expect(captureMock).toHaveBeenCalledWith(
"oem_vote_cast",
expect.objectContaining({ oem_code: "X1", vote: "incompatible", surface: "oem_detail" }),
);
});
expect(downButton.textContent).toContain("1");
expect(downButton).toHaveAttribute("aria-pressed", "true");
});
});

View File

@@ -3,7 +3,7 @@ import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { cn } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { ThumbsDown, ThumbsUp } from "lucide-react";
import { Car, ThumbsDown, ThumbsUp } from "lucide-react";
import { useEffect, useState } from "react";
export type OemVoteChoice = "compatible" | "incompatible";
@@ -23,15 +23,34 @@ export interface CastOemVoteResult {
isNew: boolean;
}
interface OemVoteCardProps {
oemCode: string;
// Katalogdan gelişte taşınan bağlam: "neyle neyin uyumlu olduğu" tek alanda
// görünsün diye butonların üstünde araç + parça adı gösterilir. Doğrudan
// ziyarette (bağlam yok) genel soru metnine düşülür.
vehicleLabel?: string;
partName?: string;
vehicleId?: string;
className?: string;
}
const EMPTY_SUMMARY: OemVoteSummary = { compatible: 0, incompatible: 0, myVote: null };
// OEM detay sayfasındaki topluluk oyu kartı: uyumlu/uyumsuz + sayaçlar.
// Kendi durumunu yönetir; oy puanları toast ile bildirilir (oy +1, çoğunluk
// +2, ilk oy 3 — kural API'deki computeVoteAward'da).
export function OemVoteCard({ oemCode }: { oemCode: string }) {
// OEM detay başlığındaki topluluk oyu kartı: uyumlu/uyumsuz + sayaçlar.
// Arkaplanı sayfayla aynı (dolgu yok, yalnız çerçeve). Puan kuralı yalnız
// Parça Uzmanları sayfasında yazılıdır; burada toast geri bildirimi yeter.
export function OemVoteCard({
oemCode,
vehicleLabel,
partName,
vehicleId,
className,
}: OemVoteCardProps) {
const [summary, setSummary] = useState<OemVoteSummary | null>(null);
const [pending, setPending] = useState(false);
const context = [vehicleLabel, partName].filter(Boolean).join(" · ");
useEffect(() => {
let cancelled = false;
setSummary(null);
@@ -51,7 +70,11 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
const cast = async (vote: OemVoteChoice) => {
setPending(true);
try {
const result = await api.post<CastOemVoteResult>("/oem-votes", { oemCode, vote });
const result = await api.post<CastOemVoteResult>("/oem-votes", {
oemCode,
vote,
vehicleId,
});
setSummary({ ...result.counts, myVote: result.myVote });
if (result.isNew) {
const isFirstVote = result.counts.compatible + result.counts.incompatible === 1;
@@ -71,6 +94,7 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
points_awarded: result.pointsAwarded,
correct: result.correct,
is_new: result.isNew,
vehicle_id: vehicleId,
surface: "oem_detail",
});
} catch {
@@ -80,56 +104,60 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
}
};
// Sayaçlar bilinçli olarak gösterilmiyor (çoğunluğu ele vermek oyu yönlendirir);
// özet yine de çekilir — kullanıcının kendi oyu butonda işaretli kalır.
const options = [
{
vote: "compatible" as const,
icon: ThumbsUp,
label: "Uyumlu",
count: summary?.compatible ?? 0,
activeClass: "border-green-500/50 bg-green-500/10 text-green-500",
},
{
vote: "incompatible" as const,
icon: ThumbsDown,
label: "Uyumsuz",
count: summary?.incompatible ?? 0,
activeClass: "border-red-500/50 bg-red-500/10 text-red-500",
},
];
return (
<section className="rounded-xl border border-border bg-background p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold">Topluluk uyumluluk oyu</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Bu OEM kodu sizce doğru mu? Oyunuz diğer parça satıcılarına yol gösterir.
</p>
</div>
<div className="flex gap-2">
{options.map(({ vote, icon: Icon, label, count, activeClass }) => {
const isActive = summary?.myVote === vote;
return (
<button
key={vote}
type="button"
disabled={pending || summary === null}
aria-pressed={isActive}
onClick={() => cast(vote)}
className={cn(
"inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50",
isActive
? activeClass
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Icon className="size-4 shrink-0" fill={isActive ? "currentColor" : "none"} />
{label}
<span className="font-semibold tabular-nums">{count}</span>
</button>
);
})}
</div>
<section className={cn("rounded-xl border border-border p-4", className)}>
<h2 className="text-sm font-semibold">Topluluk uyumluluk oyu</h2>
{context ? (
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<Car className="size-3.5 shrink-0" />
<span className="truncate" title={context}>
{context}
</span>
</p>
) : (
<p className="mt-1 text-xs text-muted-foreground">
Bu OEM kodu sizce doğru mu? Oyunuz diğer parça satıcılarına yol gösterir.
</p>
)}
<div className="mt-3 flex gap-2">
{options.map(({ vote, icon: Icon, label, activeClass }) => {
const isActive = summary?.myVote === vote;
return (
<button
key={vote}
type="button"
disabled={pending || summary === null}
aria-pressed={isActive}
onClick={() => cast(vote)}
className={cn(
"inline-flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50",
isActive
? activeClass
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Icon className="size-4 shrink-0" fill={isActive ? "currentColor" : "none"} />
{label}
</button>
);
})}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Her oy puan kazandırır ·{" "}

View File

@@ -157,7 +157,9 @@ describe("PartsPanel", () => {
render(<PartsPanel parts={[buildPart()]} vehicleId="v1" categoryId="c1" />);
const link = screen.getByRole("link", { name: "OEM-1" });
expect(link).toHaveAttribute("href", "/dashboard/oem/OEM-1");
// href bağlam taşır: parça adı + vehicleId (oy kartındaki "araç · parça" satırı)
expect(link.getAttribute("href")).toContain("/dashboard/oem/OEM-1");
expect(link.getAttribute("href")).toContain("vid=v1");
// Liste açılışı artık toplu istek atmaz (/p/matched ve oy lookup'ı kalktı);
// tek istisna kopyalama anındaki fire-and-forget analytics POST'udur.
expect(postMock).not.toHaveBeenCalled();

View File

@@ -22,6 +22,17 @@ interface PartsPanelProps {
const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as const;
// OEM detay linki, oy kartının "araç · parça" bağlam satırı için görüntü
// bağlamını search-param olarak taşır (v/p/vid; oem.$code validateSearch).
function oemDetailHref(code: string, search: { v?: string; p?: string; vid?: string }) {
const qs = new URLSearchParams();
if (search.v) qs.set("v", search.v);
if (search.p) qs.set("p", search.p);
if (search.vid) qs.set("vid", search.vid);
const tail = qs.toString();
return `/dashboard/oem/${encodeURIComponent(code)}${tail ? `?${tail}` : ""}`;
}
export function PartsPanel({
parts,
vehicleId,
@@ -372,7 +383,11 @@ export function PartsPanel({
// SPA reload); real href kept so ctrl/cmd/middle-click
// still opens a new tab.
<a
href={`/dashboard/oem/${encodeURIComponent(part.oemCode)}`}
href={oemDetailHref(part.oemCode, {
v: vehicleLabel,
p: part.name,
vid: vehicleId,
})}
title="Uyumlu parça kodlarını gör"
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground hover:decoration-solid"
onClick={(e) => {
@@ -388,6 +403,7 @@ export function PartsPanel({
navigate({
to: "/dashboard/oem/$code",
params: { code: part.oemCode },
search: { v: vehicleLabel, p: part.name, vid: vehicleId },
});
}}
>

View File

@@ -0,0 +1,15 @@
import { api } from "@/lib/api-client";
import { useQuery } from "@tanstack/react-query";
// Parça Uzmanları programı kapısı: PostHog ana şalteri + kullanım kriteri
// sunucuda birlikte değerlendirilir; web yalnız sonucu sorar. Kapalıysa
// program yüzeyleri (nav linki, oy kartı, öneri bölümü, uzmanlar sayfası)
// hiç render edilmez — kriterler istemciye sızdırılmaz.
export function useExpertAccess(enabled = true) {
return useQuery({
queryKey: ["expert-access"],
queryFn: () => api.get<{ enabled: boolean }>("/oem-votes/access"),
staleTime: 5 * 60_000,
enabled,
});
}

View File

@@ -3,6 +3,7 @@ import { SiteFooter } from "@/components/site-footer";
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
import { TrialValueUpsell } from "@/components/trial-value-upsell";
import { useAuth } from "@/hooks/use-auth";
import { useExpertAccess } from "@/hooks/use-expert-access";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { KEYS_5 } from "@/lib/keys";
@@ -180,6 +181,13 @@ function DashboardLayout() {
});
const hasActivePlan = subData?.subscription?.status === "active";
// Parça Uzmanları programı flag+kriter kapısı: kapalıyken nav linki hiç
// görünmez (program yüzeyleri sunucu tarafında da kapalıdır).
const { data: expertAccess } = useExpertAccess(!!user);
const visibleMainMenuItems = mainMenuItems.filter(
(item) => item.to !== "/dashboard/uzmanlar" || expertAccess?.enabled === true,
);
// Redirect unauthenticated users without mutating router state during render.
useEffect(() => {
if (!isLoading && !user) {
@@ -355,7 +363,7 @@ function DashboardLayout() {
return (
<>
<NavSection title={t("nav.sectionMain")} collapsed={isCollapsed} />
{mainMenuItems.map((item) => (
{visibleMainMenuItems.map((item) => (
<NavLink
key={item.to}
to={item.to}

View File

@@ -1,5 +1,6 @@
import { OemSuggestionsSection } from "@/components/catalog/oem-suggestions-section";
import { OemVoteCard } from "@/components/catalog/oem-vote-card";
import { useExpertAccess } from "@/hooks/use-expert-access";
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { Badge, Button, Input, Skeleton } from "@sase/ui";
@@ -116,11 +117,24 @@ function PartThumb({ src, alt }: { src: string | null; alt: string }) {
// ─── Route ───────────────────────────────────────────────────────────────────
export const Route = createFileRoute("/dashboard/oem/$code")({
// Katalogdan gelişte taşınan görüntü bağlamı: v = araç etiketi, p = parça
// adı (oy kartında "neyle neyin uyumlu olduğu" satırı), vid = vehicleId
// (oy kaydına analitik bağlam). Doğrudan ziyarette üçü de boş.
validateSearch: (search: Record<string, unknown>): { v?: string; p?: string; vid?: string } => ({
v: typeof search.v === "string" && search.v ? search.v : undefined,
p: typeof search.p === "string" && search.p ? search.p : undefined,
vid: typeof search.vid === "string" && search.vid ? search.vid : undefined,
}),
component: OemDetailPage,
});
function OemDetailPage() {
const { code } = Route.useParams();
const { v: vehicleLabel, p: partName, vid: vehicleId } = Route.useSearch();
// Parça Uzmanları programı kapısı — kapalıyken oy kartı ve öneri bölümü
// hiç render edilmez (cross-ref içeriği herkese açık kalır).
const { data: expertAccess } = useExpertAccess();
const expertEnabled = expertAccess?.enabled === true;
const { data, isLoading, error } = useQuery({
queryKey: ["p-oem", code],
@@ -171,33 +185,42 @@ function OemDetailPage() {
return (
<div className="mx-auto max-w-4xl space-y-6">
{/* ─── Header ─────────────────────────────────────────────────────── */}
<header className="flex items-start gap-4">
<Button asChild variant="ghost" size="icon" className="mt-0.5 shrink-0">
<Link to="/dashboard/search" aria-label="Geri">
<ArrowLeft className="size-5" />
</Link>
</Button>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
OEM kodu
</p>
<div className="mt-1 flex flex-wrap items-center gap-3">
<h1 className="font-mono text-2xl font-bold tracking-tight break-all">{code}</h1>
<CopyCode
code={code}
className="rounded-md border border-border px-2 py-1 hover:bg-accent"
/>
{/* ─── Header: solda OEM kodu, desktop'ta sağ simetriğinde topluluk
oyu kartı; mobilde kart kodun altına iner ─────────────────────── */}
<header className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-4">
<Button asChild variant="ghost" size="icon" className="mt-0.5 shrink-0">
<Link to="/dashboard/search" aria-label="Geri">
<ArrowLeft className="size-5" />
</Link>
</Button>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
OEM kodu
</p>
<div className="mt-1 flex flex-wrap items-center gap-3">
<h1 className="font-mono text-2xl font-bold tracking-tight break-all">{code}</h1>
<CopyCode
code={code}
className="rounded-md border border-border px-2 py-1 hover:bg-accent"
/>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Uyumlu parça kodları ve muadil numaralar
</p>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Uyumlu parça kodları ve muadil numaralar
</p>
</div>
{expertEnabled && (
<OemVoteCard
oemCode={code}
vehicleLabel={vehicleLabel}
partName={partName}
vehicleId={vehicleId}
className="w-full lg:w-96 lg:shrink-0"
/>
)}
</header>
{/* ─── Topluluk uyumluluk oyu (P eşleşmesinden bağımsız) ───────────── */}
<OemVoteCard oemCode={code} />
{/* ─── Loading ────────────────────────────────────────────────────── */}
{isLoading && (
<div className="space-y-4">
@@ -346,7 +369,7 @@ function OemDetailPage() {
)}
{/* ─── Topluluk muadil önerileri (eşleşme olmasa da toplanır) ──────── */}
{!isLoading && <OemSuggestionsSection oemCode={code} />}
{!isLoading && expertEnabled && <OemSuggestionsSection oemCode={code} />}
{/* ─── Reverse catalog: your vehicles that use this code ───────────── */}
{catalogVehicles && catalogVehicles.length > 0 && (

View File

@@ -4,6 +4,7 @@ import {
LeaderboardRankings,
} from "@/components/gamification/leaderboard-rankings";
import { PointsBadge } from "@/components/gamification/points-badge";
import { useExpertAccess } from "@/hooks/use-expert-access";
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { Card, CardContent, Skeleton } from "@sase/ui";
@@ -23,6 +24,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")({
@@ -32,15 +51,41 @@ export const Route = createFileRoute("/dashboard/uzmanlar")({
const SKELETON_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5"] as const;
function ExpertsPage() {
// Program kapısı: flag + kullanım kriteri. Kapalıyken liderlik sorgusu hiç
// atılmaz, sayfa kademeli-açılış mesajı gösterir (nav linki de gizlidir;
// burası yalnız doğrudan URL ile gelenler için).
const { data: access, isLoading: accessLoading } = useExpertAccess();
const expertEnabled = access?.enabled === true;
const { data, isLoading } = useQuery({
queryKey: ["oem-leaderboard"],
queryFn: () => api.get<LeaderboardResponse>("/oem-votes/leaderboard"),
enabled: expertEnabled,
});
useEffect(() => {
capture("experts_leaderboard_viewed");
}, []);
if (!accessLoading && !expertEnabled) {
return (
<div className="mx-auto max-w-5xl">
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<Trophy className="size-10 text-muted-foreground/50" />
<p className="text-sm font-medium">
Parça Uzmanları programı kademeli olarak ılıyor.
</p>
<p className="max-w-md text-sm text-muted-foreground">
Program, kataloğu aktif kullanan üyelere otomatik ılır şase sorgulamaya ve OEM
kodlarıyla çalışmaya devam edin, sıranız geldiğinde burada olacak.
</p>
</CardContent>
</Card>
</div>
);
}
const entries = data?.entries ?? [];
const me = data?.me;
@@ -69,12 +114,29 @@ 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>
{isLoading ? (
{/* 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 || accessLoading ? (
<div className="space-y-3">
{SKELETON_KEYS.map((k) => (
<Skeleton key={k} className="h-14 w-full" />
@@ -128,7 +190,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>
);