23 Commits

Author SHA1 Message Date
7d10107f4b fix(backfill): run hourly scan on an in-process timer (cron marker gets buried)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The BullMQ cron scan never fired on prod (ic frozen at 28 for days, 0
scans completed) even after the lifo change. Root cause, confirmed live:
a cron scheduler enqueues a DELAYED marker, and BullMQ promotes delayed
jobs to the wait-list HEAD via LPUSH — `promoteDelayedJobs` ignores lifo —
while the worker pops from the tail. Behind the ~444k deep-drill backlog
the scan marker sat at wait position 1 (head) with ~444k jobs ahead of it
→ unreachable for weeks. So scheduler-level lifo cannot work; the marker
must not go through the queue at all.

Run the scan on an in-process hourly timer in PrefetchWorkerService
instead (kickoff 60s after boot, then hourly), prod-host gated and
single-fired via a Redis NX lock. The scan's Phase-1 still enqueues
NON-delayed `lifo` init jobs, which DO jump the wait list (RPUSH tail →
popped next), so newly-decoded / zero-parts vehicles get onboarded ahead
of the backlog while it drains. jobs.module now removes the old cron
scheduler so stale markers stop being produced; the backfill-scan job
handler stays so any already-queued legacy marker runs harmlessly.

Tested: spec covers the NX lock (run when acquired / skip when held) plus
the existing lifo + Phase-1/Phase-2 gating; full api suite 272 passed,
`pnpm build` + biome green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:14:09 +03:00
a304420456 Merge pull request 'promote: sezon kazananları admin'e maillenir (otomatik üyelik uzatma kaldırıldı)' (#135) from dev into main 2026-06-12 08:57:03 +00:00
2c848df97d change(gamification): sezon ödülleri otomatik tanımlanmaz — kazananlar admin'e maillenir
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
expert-rewards cron'u artık üyelik uzatmıyor: ay kapanışında ilk 3'ü
hesaplayıp admin@sase.tr'ye Postal üzerinden kazanan listesini yollar
(sıra, ad, e-posta, puan, ödül 30/15/7 gün, kullanıcı ID) ve
oem_expert_rewards'a denetim satırı yazar; günleri admin elle tanımlar.
Dev DB'den atılan mailler "[dev]" konu önekiyle ayrışır. Mail-önce-satır-
sonra sırası retry'da mail kaybını önler (nadir çift mail kabul edilir).
Abonelik/kredi dokunuşu tamamen kaldırıldı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:53:02 +03:00
33e59c9554 Merge pull request 'fix(backfill): nest-build spec typing (unblocks #133 deploy)' (#134) from fix/backfill-lifo-fast-lane into main 2026-06-12 08:47:25 +00:00
cbdff3311f Merge pull request 'perf(backfill): fast-lane Phase-1 via lifo + run scan despite deep backlog' (#133) from fix/backfill-lifo-fast-lane into main
Reviewed-on: #133
2026-06-12 08:39:41 +00:00
137d0c07a6 Merge pull request 'promote: Parça Uzmanları programı (flag-gated) + revert düzeltmesi' (#132) from promote-expert-program into main 2026-06-12 08:25:35 +00:00
8c1d247c45 Merge remote-tracking branch 'origin/dev'
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
2026-06-12 11:16:18 +03:00
579ee8d275 tweak(gamification): uzman programı kriteri 2/2 → 5/5
En az 5 FARKLI başarılı VIN decode VE en az 5 FARKLI OEM kodu kopyası.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:16:01 +03:00
4df06ab3a1 Merge remote-tracking branch 'origin/dev' 2026-06-12 11:05:41 +03:00
58a0faeb99 Reapply "Merge pull request 'dev' (#130) from dev into main"
This reverts commit 5c7803e12a.
2026-06-12 11:05:41 +03:00
6c414f9a59 feat(gamification): Parça Uzmanları programı feature-flag + kullanım kriteri kapısı
Program (oylama + muadil önerisi + liderlik) artık iki katmanlı kapının
arkasında: PostHog "oem-expert-program" ana şalteri (local-eval, fail-open)
VE kullanım kriteri — en az 2 FARKLI başarılı VIN decode VE en az 2 FARKLI
OEM kodu kopyası. Karar sunucuda tek noktada (ExpertAccessService.check);
web GET /oem-votes/access ile bir kez sorar, kriterler istemciye sızmaz.

Kapalıyken: sidebar nav linki görünmez, OEM detayındaki oy kartı + öneri
bölümü render edilmez, /dashboard/uzmanlar kademeli-açılış mesajı gösterir
ve liderlik sorgusu atılmaz. Yazma uçları (oy, öneri) sunucu tarafında da
ForbiddenException ile korunur — UI gizlemek tek başına güven sınırı değil.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:04:20 +03:00
fe35bbd826 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>
2026-06-12 10:48:36 +03:00
55894bec0e polish(web): oy butonlarından sayaçları kaldır
Uyumlu/Uyumsuz butonlarında oy sayıları artık görünmüyor (çoğunluğu ele
vermek oyu yönlendiriyordu); özet yine çekilir — kullanıcının kendi oyu
butonda işaretli kalır.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:34:44 +03:00
4068091a21 Merge pull request 'Revert PR #130 (erken prod merge geri alındı — dev'de kalacak, sonra promote)' (#131) from revert-pr130 into main 2026-06-11 21:50:14 +00:00
5c7803e12a Revert "Merge pull request 'dev' (#130) from dev into main"
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
This reverts commit eccecf73e6, reversing
changes made to 4bcac424e0.
2026-06-12 00:48:58 +03:00
24e668ea01 polish(web): oy kartı header'a — OEM kodunun sağ simetriğine, araç+parça bağlamıyla
Topluluk oyu kartının koyu dolgusu kalktı (sayfa arkaplanıyla aynı, yalnız
çerçeve); desktop'ta başlıkta OEM kodunun sağına yerleşti, mobilde kodun
altına iner. Katalogdan gelişte araç etiketi + parça adı search-param'la
taşınır (v/p/vid) ve kartta butonların üstünde "araç · parça" satırı olarak
gösterilir — neyle neyin uyumlu olduğu tek alanda. vid oy kaydına analitik
bağlam olarak geri eklendi; doğrudan ziyarette genel soru metnine düşülür.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:47:06 +03:00
eccecf73e6 Merge pull request 'dev' (#130) from dev into main
Reviewed-on: #130
2026-06-11 21:45:35 +00:00
6575c611e8 polish(web): puan kuralları yalnız Uzmanlar sayfası altında, oyunlaştırılmış dille
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Üstteki üç-kartlı kural bloğu ve oy kartındaki kural cümlesi kaldırıldı;
kurallar tek satır küçük puntoyla sayfanın en altında ("Oy ver +1 ·
çoğunluğu tuttur +2 bonus · kodu ilk değerlendiren 3 puanı kapar").
Başlık ve boş-durum metinleri de oyunlaştırıldı; oy kartında yalnız
"Her oy puan kazandırır · Parça Uzmanları sıralaması" linki kaldı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:33:37 +03:00
4072c6e736 feat(catalog): OEM oyları detay sayfasına taşındı + topluluk muadil önerileri
Parça tablosu sadeleşti: Uyum ve Adet kolonları kalktı, liste açılışındaki
toplu istekler (/oem-votes/lookup ve /p/matched) tamamen kaldırıldı. Her OEM
kodu artık koşulsuz /dashboard/oem/$code'a linklenir — P eşleşmesi olmayan
kodda da sayfa dolu: topluluk oyu kartı, muadil önerileri ve ters katalog.

OEM detay sayfası: OemVoteCard (uyumlu/uyumsuz, sayaçlar, puan toast'ı,
puanlama özeti + Parça Uzmanları linki) ve OemSuggestionsSection — eşleşme
bulunamayan kodlar için kullanıcıdan marka + parça kodu önerisi toplar.
Öneriler oem_suggestions tablosunda (kullanıcı+kod+normalize öneri başına
tek satır, ON CONFLICT yutulur), markaya+normalize koda göre gruplanıp
"× N kullanıcı" rozetiyle listelenir; önerilen kod kendi detayına linklenir.
Şimdilik öneri puan kazandırmaz; status kolonu moderasyon kancası.

API: oem-suggestions modülü (POST 10/dk throttle, GET ?code=), migration
0016_oem_suggestions. Eski tablo-içi OemVoteButtons bileşeni silindi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:26:20 +03:00
d922fb7a02 test(catalog): cover P4 VIN group-node gate (children + loadError)
Mirrors the PSA VIN-scope tests for the new …group.action parent gate: a Ford
json-vin-sub-group.action node with a positional link_wid routes to children
(not a silent empty leaf), and an empty drill surfaces loadError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:02:50 +03:00
8fddd09087 feat(catalog): OEM uyumluluk oylaması + Parça Uzmanları liderlik tablosu
Parça satırlarına topluluk oyu eklendi: her OEM kodu için uyumlu/uyumsuz
ikilisi (👍/👎, sayaçlı). Oylar oem_votes'a (kullanıcı+kod başına tek oy,
fikir değişikliği günceller, puan üretmez), ödüller oem_vote_points
ledger'ına yazılır: oy +1, kesin çoğunlukla aynı yönde +2 (kodu ilk
oylayan her zaman 3 alır); ödüller oy anında kesinleşir, çoğunluk sonra
dönse de geri alınmaz. Aynı koda eşzamanlı oylar advisory lock ile
sıralanır.

/dashboard/uzmanlar: Trophy Gamification UI Kit'ten (ui.trophy.so, MIT)
uyarlanan kürsü + sıralama + puan rozetiyle "Parça Uzmanları" liderlik
sayfası; adlar KVKK-maskeli (S*** Y***), cevap kullanıcı id sızdırmaz.

Not: sidebar nav linki, tr/en i18n anahtarları ve routeTree 09a9487'de
gitmişti; bu commit eksik kalan rota/bileşen/API dosyalarını tamamlayarak
dev build'ini düzeltir. Migration: 0015_oem_votes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:00:51 +03:00
09a9487564 remove changelog page and sidebar link
Drop /dashboard/changelog route, ChangelogTab component, useChangelog
hook, the sidebar nav item, and the related tr/en translation keys.
API-side changelog schemas are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:51:54 +03:00
e6c8c9c32e fix(catalog): drill P4 VIN group nodes on live click (Ford/Nissan/Opel parent-gate gap)
getCategoryWithPartsInner had parent gates for PSA (psa::/json-vin-main-groups),
pcat (hasSubgroups), pl24 link_wid *Group* tables, Volvo (vin-group.action) and
emex — but P4 VIN/legacy group nodes whose link_wid is a positional code
("1","2","CAP1"…) and whose path is a *…group.action* drill (Ford
json-vin-sub-group, Nissan/Opel json-sub-group, etc.) matched none of them. They
fell through to the leaf parts path, fetched no parts and rendered a silent empty
panel — the dominant current '0 parça' cluster (mekanik/kaporta/Elektrikli aksam…
across many vehicles; PostHog-confirmed). Add a gate that drills any pl24
…group.action node (excluding Volvo's own vin-group.action branch) → subgroups or
retryable loadError. Lazy-heals existing flag-less rows, no reseed.

Also set has_subgroups at seed time: PSA scopes → true (always parents);
P4-legacy cats → true when linkPath is a …group.action drill. Fixes folder
signposting + lets prefetch pre-drill them instead of treating them as leaves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:48:04 +03:00
50 changed files with 2587 additions and 313 deletions

View File

@@ -0,0 +1,37 @@
-- OEM community voting + gamification ledger.
-- oem_votes: one row per (user, oem_code) uyumlu/uyumsuz verdict cast from the
-- catalog part rows. Votes pool globally per code; part/vehicle/category are
-- analytics breadcrumbs only (catalog routes pass catalog_vehicles ids → no FK,
-- mirrors oem_code_copies). Re-votes update the row in place.
-- oem_vote_points: append-only, exactly one row per vote (vote_id unique),
-- written in the same transaction. points = 1 (vote) + 2 (agreed with strict
-- majority at cast time; first voter always 3). Never retro-adjusted.
CREATE TABLE "oem_votes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"vote" varchar(12) NOT NULL,
"part_id" uuid,
"vehicle_id" uuid,
"category_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "oem_vote_points" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"vote_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"points" integer NOT NULL,
"correct" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oem_votes" ADD CONSTRAINT "oem_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_vote_id_oem_votes_id_fk" FOREIGN KEY ("vote_id") REFERENCES "public"."oem_votes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "oem_votes_user_oem_idx" ON "oem_votes" USING btree ("user_id","oem_code");--> statement-breakpoint
CREATE INDEX "oem_votes_oem_code_idx" ON "oem_votes" USING btree ("oem_code");--> statement-breakpoint
CREATE UNIQUE INDEX "oem_vote_points_vote_id_idx" ON "oem_vote_points" USING btree ("vote_id");--> statement-breakpoint
CREATE INDEX "oem_vote_points_user_id_idx" ON "oem_vote_points" USING btree ("user_id");

View File

@@ -0,0 +1,18 @@
-- Community OEM cross-reference suggestions, collected on the OEM detail page
-- ("muadil parça öner": brand + code). Primarily for codes the P snapshot has
-- no match for. suggested_code_norm (upper alphanumerics) powers grouping and
-- the per-user dedup; status is a moderation hook (active|hidden).
CREATE TABLE "oem_suggestions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"oem_code" varchar(100) NOT NULL,
"brand" varchar(80) NOT NULL,
"suggested_code" varchar(100) NOT NULL,
"suggested_code_norm" varchar(100) NOT NULL,
"status" varchar(16) DEFAULT 'active' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "oem_suggestions" ADD CONSTRAINT "oem_suggestions_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_suggestions_user_oem_code_idx" ON "oem_suggestions" USING btree ("user_id","oem_code","suggested_code_norm");--> statement-breakpoint
CREATE INDEX "oem_suggestions_oem_code_idx" ON "oem_suggestions" USING btree ("oem_code");

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

@@ -106,6 +106,27 @@
"when": 1781136000000,
"tag": "0014_proxy_logs",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1781222400000,
"tag": "0015_oem_votes",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1781308800000,
"tag": "0016_oem_suggestions",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1781395200000,
"tag": "0017_oem_expert_rewards",
"breakpoints": true
}
]
}

View File

@@ -34,6 +34,8 @@ import { InternalAdminModule } from "./internal-admin/internal-admin.module";
import { JobsModule } from "./jobs/jobs.module";
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
import { NotificationsModule } from "./notifications/notifications.module";
import { OemSuggestionsModule } from "./oem-suggestions/oem-suggestions.module";
import { OemVotesModule } from "./oem-votes/oem-votes.module";
import { PartsModule } from "./parts/parts.module";
import { PaymentsModule } from "./payments/payments.module";
import { PlansModule } from "./plans/plans.module";
@@ -93,6 +95,8 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
TranslationsModule,
AdminModule,
AnalyticsModule,
OemVotesModule,
OemSuggestionsModule,
CatalogModule,
ChangelogModule,
ChatwootModule,

View File

@@ -320,6 +320,84 @@ describe("CategoriesService", () => {
"drill-load-error",
);
});
it("routes a P4 Ford VIN node (json-vin-sub-group.action, positional link_wid) to children, not an empty leaf", async () => {
// link_wid "1" is a positional code (not a *Group* table) so the link_wid
// group-drill branch misses it; the path is …group.action (not /psa/, not
// vin-group.action) so the PSA / Volvo branches miss it too. Before the
// dedicated gate these fell through to the leaf parts path → silent empty
// panel (Ford/Nissan/Opel analog of the PSA-parent-gate gap).
const category = {
id: "ford1",
name: "elektrik sistemi",
nameOriginal: "elektrik sistemi",
parentId: null,
vehicleId: "v1",
source: "pl24",
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=CAP1&vin=WF0FXX",
linkWid: "1",
hasSubgroups: null,
hasParts: null,
};
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const c: Record<string, any> = {};
c.from = vi.fn().mockReturnValue(c);
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
c.limit = vi.fn().mockReturnValue([category]);
return c;
}),
execute: vi.fn().mockResolvedValue([]),
};
const { service } = createService(db);
const discovered = [{ id: "sg1", name: "kablolar", children: undefined }];
vi.spyOn(service, "getChildren").mockResolvedValue(discovered as any);
const result = await service.getCategoryWithParts("ford1");
expect(service.getChildren).toHaveBeenCalledWith("ford1");
expect(result.parts).toEqual([]);
expect((result as { children?: unknown }).children).toEqual(discovered);
});
it("surfaces loadError when a P4 group.action drill comes back empty (not a silent leaf)", async () => {
const category = {
id: "ford2",
name: "kaporta",
nameOriginal: "kaporta",
parentId: null,
vehicleId: "v1",
source: "pl24",
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=GDB&vin=WF0FXX",
linkWid: "2",
hasSubgroups: null,
hasParts: null,
};
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const c: Record<string, any> = {};
c.from = vi.fn().mockReturnValue(c);
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
c.limit = vi.fn().mockReturnValue([category]);
return c;
}),
execute: vi.fn().mockResolvedValue([]),
};
const { service } = createService(db);
vi.spyOn(service, "getChildren").mockResolvedValue([] as any);
const result = await service.getCategoryWithParts("ford2");
expect(service.getChildren).toHaveBeenCalledWith("ford2");
expect((result as { loadError?: boolean }).loadError).toBe(true);
expect((result as { children?: unknown }).children).toBeUndefined();
});
});
describe("getAncestors", () => {

View File

@@ -120,6 +120,10 @@ export class CategoriesService {
externalId: s.code,
linkPath: s.linkPath || null,
linkWid: null as string | null,
// PSA scopes are always parents (they hold main-groups, never direct
// parts) — flag them so the tree UI signposts them as folders and the
// prefetch worker pre-drills them instead of treating them as leaves.
hasSubgroups: true,
source: "pl24" as const,
}));
dbCategories = await this.db
@@ -181,6 +185,10 @@ export class CategoriesService {
externalId: c.code,
linkPath: c.linkPath || null,
linkWid: c.linkWid || null,
// Mark P4 VIN/legacy group-drill nodes (…group.action) as parents so
// the tree UI shows them as folders and prefetch pre-drills them. Leaf
// pages (image-board.action/bom) stay null (unknown→leaf downstream).
hasSubgroups: c.linkPath?.includes("group.action") ? true : null,
source: "pl24" as const,
}));
@@ -1123,6 +1131,39 @@ export class CategoriesService {
// No subgroups → leaf; fall through to the parts path below.
}
// PL24 P4 VIN / legacy group node whose link_wid is a positional code
// ("1","2","CAP1"…) rather than a *Group* table, so the link_wid branch above
// misses it, and which is neither PSA- nor Volvo-special. Its linkPath is a
// sub-group / main-group drill action (…group.action — never a parts leaf like
// image-board.action). This is the Ford / Nissan / Opel / Hyundai-Kia analog of
// the PSA-parent-gate gap: without it these VIN scopes ("mekanik", "kaporta",
// "Elektrikli aksam"…) fell through every parent branch to the leaf parts path,
// fetched no parts and rendered a silent empty panel — confirmed the dominant
// current "0 parça" cluster. Volvo's vin-group.action keeps its own branch above
// (it may legitimately bottom out in parts), so exclude it here. Drill on demand:
// subgroups → parent; an empty drill is a transient upstream failure → retryable
// load error (a group node is never legitimately a parts leaf).
if (
category.source === "pl24" &&
category.linkPath?.includes("group.action") &&
!category.linkPath.includes("vin-group.action") &&
category.vehicleId
) {
const p4Children = await this.getChildren(categoryId);
const base = {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: [],
schemaPics: [],
hotspots: [],
};
return p4Children.length > 0
? { ...base, children: p4Children }
: { ...base, loadError: true };
}
// EMEX Vehicle.aspx group node (linkWid="emex-group") — a parent, never a
// parts leaf. Return its children (seeded sub-groups, or Unit leaves drilled
// on demand by getChildren). Mirrors the pl24 group-node branch above; an

View File

@@ -655,3 +655,118 @@ export const proxyLogs = pgTable(
index("proxy_logs_banned_created_at_idx").on(table.banned, table.createdAt),
],
);
// ─── OEM Votes (community compatibility verdicts) ─────
// One row per (user, oemCode): a parts seller's uyumlu/uyumsuz verdict on an
// OEM code, cast from the catalog part rows. Votes pool globally per code —
// the part/vehicle/category columns are analytics breadcrumbs only (mirrors
// oem_code_copies: catalog routes pass catalog_vehicles ids here, so no FK).
// Re-voting updates `vote` in place; points are only ever granted on the
// first insert (see oem_vote_points).
export const oemVotes = pgTable(
"oem_votes",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
// compatible | incompatible
vote: varchar("vote", { length: 12 }).notNull(),
partId: uuid("part_id"),
vehicleId: uuid("vehicle_id"),
categoryId: uuid("category_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_votes_user_oem_idx").on(table.userId, table.oemCode),
index("oem_votes_oem_code_idx").on(table.oemCode),
],
);
// ─── OEM Vote Points (gamification ledger) ────────────
// Append-only: exactly one row per oem_votes row (vote_id unique), written in
// the same transaction as the vote insert. `points` = 1 for voting +2 when
// the vote agreed with the strict majority at cast time (first voter on a
// code always agrees with themselves → 3). Awards are never retro-adjusted
// when the majority later flips. Leaderboard = SUM(points) per user.
export const oemVotePoints = pgTable(
"oem_vote_points",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
voteId: uuid("vote_id")
.notNull()
.references(() => oemVotes.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
points: integer("points").notNull(),
correct: boolean("correct").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_vote_points_vote_id_idx").on(table.voteId),
index("oem_vote_points_user_id_idx").on(table.userId),
],
);
// ─── OEM Suggestions (community cross-references) ─────
// Crowd-sourced equivalents for an OEM code: "this code's compatible part is
// <brand> <suggestedCode>", collected on the OEM detail page — primarily for
// codes the P snapshot can't match. suggestedCodeNorm (upper, alphanumerics
// only) powers grouping and the per-user dedup constraint; display keeps the
// submitted casing. `status` is a moderation hook (active|hidden) — no rows
// are hidden automatically today.
export const oemSuggestions = pgTable(
"oem_suggestions",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
brand: varchar("brand", { length: 80 }).notNull(),
suggestedCode: varchar("suggested_code", { length: 100 }).notNull(),
suggestedCodeNorm: varchar("suggested_code_norm", { length: 100 }).notNull(),
status: varchar("status", { length: 16 }).default("active").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("oem_suggestions_user_oem_code_idx").on(
table.userId,
table.oemCode,
table.suggestedCodeNorm,
),
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, e-mails the top 3 voters to
// admin@sase.tr (name/e-mail/points + prize 30/15/7 days — EXPERT_REWARD_
// LADDER) and records them here. Subscription days are NOT granted
// automatically — the admin applies them manually from the mail. The unique
// index doubles as the run-once guard: a second run for the same period
// inserts nothing and sends no second mail.
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

@@ -1,13 +1,14 @@
import { Inject, Module, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { Queue } from "bullmq";
import { CategoriesModule } from "../categories/categories.module";
import { isCatalogBackfillEnabled, isLifecycleEmailEnabled } from "./prefetch-utils";
import { isLifecycleEmailEnabled } from "./prefetch-utils";
import { PrefetchWorkerService } from "./prefetch-worker.service";
import {
CATALOG_PREFETCH_QUEUE,
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() {
@@ -100,36 +104,35 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
console.log("[jobs] Skipped lifecycle-email cron (not prod host)");
}
// Catalog backfill: every hour at :00. Scans for decoded vehicles whose catalog
// isn't fully prefetched and queues them. Self-throttled (queue-depth guard),
// cooldown- and business-hours-aware — handled by PrefetchWorkerService.
// PRODUCTION (sase.tr) ONLY: dev.sase.tr is a separate DB and must not
// sweep/scrape. NODE_ENV is "production" on BOTH, so gate on the prod host.
if (isCatalogBackfillEnabled()) {
await this.catalogPrefetchQueue.upsertJobScheduler(
"catalog-backfill-hourly",
{ pattern: "0 * * * *" },
{
name: "backfill-scan",
data: {},
opts: {
removeOnComplete: { count: 48 },
removeOnFail: { count: 100 },
// lifo so the scan job lands at the tail of the wait list and runs on
// the next tick instead of queueing behind the deep-drill backlog —
// otherwise the hourly scan is buried for days and never fires. BullMQ
// drains wait (RPOPLPUSH from the tail) before the prioritized ZSET, so
// lifo — not `priority` — is what jumps an already-deep wait queue.
lifo: true,
},
// Catalog backfill now runs on an IN-PROCESS hourly timer inside
// PrefetchWorkerService — NOT a BullMQ cron. A cron marker is a delayed job that
// BullMQ promotes to the wait-list HEAD with LPUSH (lifo ignored), and the worker
// pops from the tail, so behind a deep prefetch backlog the scan was buried for
// days and never fired. Remove any previously-registered cron scheduler here so
// stale markers stop being produced (the in-process timer self-gates on the prod
// host). The "backfill-scan" job handler is kept so any already-queued legacy
// marker still runs harmlessly when reached.
await this.catalogPrefetchQueue.removeJobScheduler("catalog-backfill-hourly").catch(() => {});
console.log("[jobs] catalog-backfill cron removed (scan runs in-process)");
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 Türkiye saati —
// biten ayın ilk 3'ünü admin@sase.tr'ye mailler (üyelik günlerini admin
// elle tanımlar) + denetim satırı yazar. Dev'de de çalışır; dev maili
// "[dev]" konu önekiyle ayrışır, (period, rank) unique index'i aynı
// dönem için ikinci maili/satırı 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 catalog-backfill cron: 0 * * * *");
} else {
// Clean up any stale scheduler (e.g. if NODE_ENV changed) and stay idle.
await this.catalogPrefetchQueue.removeJobScheduler("catalog-backfill-hourly").catch(() => {});
console.log("[jobs] Skipped catalog-backfill cron (not prod host)");
}
},
);
console.log("[jobs] Registered expert-rewards cron: 0 0 1 * * (Europe/Istanbul)");
}
async onModuleDestroy() {
@@ -138,6 +141,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
this.queryCleanupQueue.close(),
this.catalogPrefetchQueue.close(),
this.lifecycleEmailQueue.close(),
this.expertRewardsQueue.close(),
]);
}
}

View File

@@ -27,6 +27,7 @@ function makeDeps(opts: { waiting: number; limitResults: unknown[][] }) {
exists: vi.fn(async () => false), // no source cooldown, no in-flight guard
get: vi.fn(async () => null), // no no-result residue
set: vi.fn(async () => undefined),
setNx: vi.fn(async () => true), // scan lock acquired by default
getJson: vi.fn(async () => null), // Phase-2 cursor empty
setJson: vi.fn(async () => undefined),
};
@@ -54,8 +55,9 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
describe("enqueueInit", () => {
it("sets lifo + data.fast when fast", async () => {
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
await (service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> })
.enqueueInit("v1", "emex", true);
await (
service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> }
).enqueueInit("v1", "emex", true);
const [name, data, jobOpts] = queue.add.mock.calls[0];
expect(name).toBe("prefetch-init");
expect(data).toMatchObject({ vehicleId: "v1", source: "emex", fast: true });
@@ -64,8 +66,9 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
it("omits lifo when not fast", async () => {
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
await (service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> })
.enqueueInit("v1", "emex", false);
await (
service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> }
).enqueueInit("v1", "emex", false);
const [, data, jobOpts] = queue.add.mock.calls[0];
expect(data).toMatchObject({ fast: false });
expect((jobOpts as { lifo?: boolean }).lifo).toBeUndefined();
@@ -77,7 +80,14 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
await (service as never as { addJob: (n: string, d: unknown) => Promise<void> }).addJob(
"prefetch-children",
{ vehicleId: "v1", categoryId: "c1", source: "emex", action: "children", depth: 1, fast: true },
{
vehicleId: "v1",
categoryId: "c1",
source: "emex",
action: "children",
depth: 1,
fast: true,
},
);
const [, , jobOpts] = queue.add.mock.calls[0];
expect(jobOpts).toMatchObject({ lifo: true });
@@ -102,7 +112,9 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
waiting: 50_000,
limitResults: [[{ id: "empty1", source: "emex" }]], // Phase-1 query result
});
await (service as never as { processBackfillScan: () => Promise<void> }).processBackfillScan();
await (
service as never as { processBackfillScan: () => Promise<void> }
).processBackfillScan();
expect(queue.add).toHaveBeenCalledTimes(1);
const [name, data, jobOpts] = queue.add.mock.calls[0];
@@ -123,7 +135,9 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
[{ id: "stale1", source: "emex", createdAt: new Date("2026-01-01T00:00:00Z") }], // Phase-2
],
});
await (service as never as { processBackfillScan: () => Promise<void> }).processBackfillScan();
await (
service as never as { processBackfillScan: () => Promise<void> }
).processBackfillScan();
expect(queue.add).toHaveBeenCalledTimes(1);
const [, data, jobOpts] = queue.add.mock.calls[0];
@@ -136,7 +150,28 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
it("skips entirely when the backfill gate is off (not prod host)", async () => {
process.env.CATALOG_BACKFILL_ENABLED = "false";
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
await (service as never as { processBackfillScan: () => Promise<void> }).processBackfillScan();
await (
service as never as { processBackfillScan: () => Promise<void> }
).processBackfillScan();
expect(queue.add).not.toHaveBeenCalled();
});
});
describe("runBackfillScan (in-process timer entry, NX-locked)", () => {
it("runs the scan when the lock is acquired", async () => {
const { service, queue, redis } = makeDeps({
waiting: 50_000,
limitResults: [[{ id: "empty1", source: "emex" }]],
});
await (service as never as { runBackfillScan: () => Promise<void> }).runBackfillScan();
expect(redis.setNx).toHaveBeenCalledWith("prefetch:backfill:lock", "1", expect.any(Number));
expect(queue.add).toHaveBeenCalledTimes(1); // Phase-1 init enqueued
});
it("skips the scan when the lock is already held", async () => {
const { service, queue, redis } = makeDeps({ waiting: 50_000, limitResults: [] });
redis.setNx.mockResolvedValueOnce(false);
await (service as never as { runBackfillScan: () => Promise<void> }).runBackfillScan();
expect(queue.add).not.toHaveBeenCalled();
});
});

View File

@@ -45,6 +45,14 @@ const BACKFILL_SOURCES = ["pl24", "emex", "parts-catalogs"];
/** Redis key holding the rolling rescan cursor (last createdAt seen). */
const BACKFILL_CURSOR_KEY = "prefetch:backfill:cursor";
// ── In-process scan timer (replaces the BullMQ cron, which gets buried) ──
/** How often the in-process backfill scan runs. */
const BACKFILL_SCAN_INTERVAL_MS = 60 * 60 * 1000;
/** Delay before the first scan after boot, so startup settles first. */
const BACKFILL_SCAN_KICKOFF_MS = 60 * 1000;
/** NX-lock TTL (seconds) — just under the interval so each cycle re-acquires. */
const BACKFILL_SCAN_LOCK_TTL_S = 55 * 60;
// ── Throughput knobs (env-tunable so prod can ramp without a redeploy) ──
/**
* Concurrent jobs the worker runs. emex/pl24 fetches parallelise well, so >1
@@ -75,6 +83,8 @@ const NORESULT_TTL_S = (Number(process.env.PREFETCH_NORESULT_TTL_DAYS) || 7) * 8
export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrefetchWorkerService.name);
private worker: Worker | null = null;
private backfillKickoff?: ReturnType<typeof setTimeout>;
private backfillInterval?: ReturnType<typeof setInterval>;
constructor(
@Inject(CATALOG_PREFETCH_QUEUE) private queue: Queue,
@@ -110,15 +120,48 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
this.logger.log(
`[prefetch] Worker started (concurrency=${WORKER_CONCURRENCY}, ${WORKER_RATE_MAX} jobs/min, pcatPace=${PCAT_PACE_MS}ms)`,
);
// Hourly backfill scan — run IN-PROCESS, not as a BullMQ cron job. A cron
// scheduler enqueues a delayed marker; BullMQ promotes delayed jobs to the
// wait-list HEAD with LPUSH (lifo is ignored on delayed promotion) while the
// worker pops from the tail, so behind a deep prefetch backlog the scan marker
// is buried for days and never fires. An in-process timer sidesteps the queue
// entirely; the scan's Phase-1 then enqueues `lifo` init jobs that DO jump the
// wait list. Prod-host gated (same as the scan body); single-fired via a Redis
// NX lock so restarts / multiple instances don't double-scan.
if (isCatalogBackfillEnabled()) {
const run = () =>
backfillContext.run(true, () =>
this.runBackfillScan().catch((err) =>
this.logger.error(`[backfill] scan crashed: ${(err as Error).message}`),
),
);
this.backfillKickoff = setTimeout(run, BACKFILL_SCAN_KICKOFF_MS);
this.backfillInterval = setInterval(run, BACKFILL_SCAN_INTERVAL_MS);
this.logger.log("[backfill] in-process hourly scan enabled");
}
}
async onModuleDestroy() {
if (this.backfillKickoff) clearTimeout(this.backfillKickoff);
if (this.backfillInterval) clearInterval(this.backfillInterval);
if (this.worker) {
await this.worker.close();
this.worker = null;
}
}
/**
* Hourly scan entry point used by the in-process timer. A Redis NX lock keeps it
* single-fire across restarts and (future) multiple instances; processBackfillScan
* is idempotent, so a missed cycle is harmless.
*/
private async runBackfillScan(): Promise<void> {
const got = await this.redis.setNx("prefetch:backfill:lock", "1", BACKFILL_SCAN_LOCK_TTL_S);
if (!got) return;
await this.processBackfillScan();
}
private async process(job: Job, token?: string): Promise<void> {
try {
const data = job.data as { source?: string };

View File

@@ -0,0 +1,158 @@
import { Job } from "bullmq";
import { and, asc, desc, eq, gte, lt, sql } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { oemExpertRewards, oemVotePoints, users } from "../../database/schema/core";
import { EXPERT_REWARD_LADDER, previousTrMonthWindow } from "../../oem-votes/expert-period";
type Database = PostgresJsDatabase<Record<string, unknown>>;
const ADMIN_EMAIL = "admin@sase.tr";
interface SeasonWinner {
rank: number;
days: number;
userId: string;
name: string;
email: string;
points: number;
}
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 TR'de biten ayın ilk 3
// oylayıcısını hesaplar, kazananları admin@sase.tr'ye MAİLLER ve
// oem_expert_rewards'a denetim satırı yazar. Üyelik uzatması OTOMATİK
// YAPILMAZ — günleri (30/15/7) admin maildeki listeye göre elle tanımlar.
// (period, rank) unique index run-once garantisidir. Sıra bilinçli: önce
// mail, sonra satırlar — satırlar önce yazılsaydı mail hatasındaki retry
// alreadyGranted'a takılır, mail hiç gitmezdi (nadir çift mail, kayıp
// mailden iyidir).
export async function processExpertRewards(
job: Job,
db: Database,
): Promise<{ period: string; winners: 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, winners: 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,
name: users.name,
email: users.email,
points: totalPoints,
})
.from(oemVotePoints)
.innerJoin(users, eq(users.id, oemVotePoints.userId))
.where(and(gte(oemVotePoints.createdAt, start), lt(oemVotePoints.createdAt, end)))
.groupBy(oemVotePoints.userId, users.name, users.email)
.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 report`);
return { period: periodLabel, winners: 0, skipped: false };
}
const winners: SeasonWinner[] = top.map((row, i) => ({
rank: EXPERT_REWARD_LADDER[i].rank,
days: EXPERT_REWARD_LADDER[i].days,
userId: row.userId,
name: row.name,
email: row.email,
points: row.points,
}));
await sendWinnersMail(start, winners);
for (const winner of winners) {
await db
.insert(oemExpertRewards)
.values({
periodStart: start,
userId: winner.userId,
rank: winner.rank,
points: winner.points,
rewardDays: winner.days,
})
.onConflictDoNothing();
}
console.log(
`[expert-rewards] Period ${periodLabel}: mailed ${winners.length} winner(s) to ${ADMIN_EMAIL}`,
);
return { period: periodLabel, winners: winners.length, skipped: false };
}
const escapeHtml = (value: string) =>
value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Worker Nest DI'sız çalıştığı için EmailService kullanılamıyor — aynı Postal
// HTTP çağrısı (email.service.ts ile aynı payload) env üzerinden yapılır.
async function sendWinnersMail(periodStart: Date, winners: SeasonWinner[]): Promise<void> {
const apiUrl = process.env.POSTAL_API_URL;
const apiKey = process.env.POSTAL_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error("POSTAL_API_URL/POSTAL_API_KEY yok — kazanan maili gönderilemiyor");
}
const fromAddress = process.env.POSTAL_FROM_ADDRESS ?? "noreply@sase.tr";
const fromName = process.env.POSTAL_FROM_NAME ?? "Sase.tr";
const monthLabel = new Intl.DateTimeFormat("tr-TR", {
month: "long",
year: "numeric",
timeZone: "Europe/Istanbul",
}).format(periodStart);
// dev DB'den atılan deneme mailleri prod kapanışlarıyla karışmasın.
const isDev = (process.env.DATABASE_URL ?? "").includes("sase_dev");
const subject = `${isDev ? "[dev] " : ""}Parça Uzmanları ${monthLabel} kazananları — üyelik günlerini tanımlayın`;
const rows = winners
.map(
(w) =>
`<tr><td style="padding:6px 10px">${w.rank}.</td><td style="padding:6px 10px">${escapeHtml(w.name)}</td><td style="padding:6px 10px">${escapeHtml(w.email)}</td><td style="padding:6px 10px;text-align:right">${w.points}</td><td style="padding:6px 10px"><b>${w.days} gün</b></td><td style="padding:6px 10px;font-family:monospace;font-size:12px">${w.userId}</td></tr>`,
)
.join("");
const html = `
<h2>Parça Uzmanları — ${monthLabel} sezonu kapandı</h2>
<p>Kazananlara üyelik uzatmasını panelden tanımlayın:</p>
<table border="1" cellspacing="0" style="border-collapse:collapse;border-color:#ddd">
<tr><th style="padding:6px 10px">Sıra</th><th style="padding:6px 10px">Ad</th><th style="padding:6px 10px">E-posta</th><th style="padding:6px 10px">Puan</th><th style="padding:6px 10px">Ödül</th><th style="padding:6px 10px">Kullanıcı ID</th></tr>
${rows}
</table>
<p style="color:#888;font-size:12px">Bu mail expert-rewards cron'undan otomatik gönderildi; üyelik günleri otomatik tanımlanmaz.</p>`;
const text = [
`Parça Uzmanları ${monthLabel} kazananları:`,
...winners.map(
(w) => `${w.rank}. ${w.name} <${w.email}> — ${w.points} puan → ${w.days} gün (${w.userId})`,
),
].join("\n");
const response = await fetch(`${apiUrl}/api/v1/send/message`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Server-API-Key": apiKey },
body: JSON.stringify({
to: [ADMIN_EMAIL],
from: `${fromName} <${fromAddress}>`,
subject,
html_body: html,
plain_body: text,
tag: "expert-rewards",
}),
});
const result = (await response.json()) as { status?: string };
if (result.status !== "success") {
throw new Error(`Postal kazanan maili gönderemedi: ${JSON.stringify(result)}`);
}
}

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

@@ -0,0 +1,35 @@
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,
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");
}
return this.oemSuggestionsService.create(userId, parsed.data);
}
@Get()
async list(@CurrentUser("id") userId: string, @Query("code") code: string) {
const oemCode = (code ?? "").trim();
if (oemCode.length < 2 || oemCode.length > 100) {
throw new BadRequestException("OEM kodu geçersiz");
}
return { suggestions: await this.oemSuggestionsService.list(userId, oemCode) };
}
}

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
export const createOemSuggestionSchema = z.object({
oemCode: z.string().trim().min(2, "OEM kodu geçersiz").max(100, "OEM kodu çok uzun"),
brand: z.string().trim().min(2, "Marka en az 2 karakter olmalı").max(80, "Marka çok uzun"),
suggestedCode: z
.string()
.trim()
.min(2, "Parça kodu en az 2 karakter olmalı")
.max(100, "Parça kodu çok uzun")
.refine((value) => /[a-zA-Z0-9]{2,}/.test(value), "Parça kodu geçersiz"),
});
export type CreateOemSuggestionInput = z.infer<typeof createOemSuggestionSchema>;

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { aggregateSuggestions, normalizeSuggestedCode } from "./oem-suggestions.logic";
describe("normalizeSuggestedCode", () => {
it("boşluk/tire söker, büyütür", () => {
expect(normalizeSuggestedCode("1j0 973-702")).toBe("1J0973702");
expect(normalizeSuggestedCode("8V0 615 423 E")).toBe("8V0615423E");
});
});
describe("aggregateSuggestions", () => {
const row = (userId: string, brand: string, code: string) => ({
userId,
brand,
suggestedCode: code,
suggestedCodeNorm: normalizeSuggestedCode(code),
});
it("aynı marka+normalize kodu tek satırda toplar, çok önerilen üste çıkar", () => {
const rows = [
row("u1", "Bosch", "0 986 478 123"),
row("u2", "bosch", "0986478123"),
row("u3", "TRW", "DF4823"),
];
const out = aggregateSuggestions(rows, "u3");
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({ brand: "Bosch", code: "0 986 478 123", count: 2, mine: false });
expect(out[1]).toMatchObject({ brand: "TRW", count: 1, mine: true });
});
it("görünen yazım ilk gönderenin yazımıdır", () => {
const out = aggregateSuggestions(
[row("u1", "VALEO", "PHC123"), row("u2", "Valeo", "phc-123")],
"x",
);
expect(out[0].brand).toBe("VALEO");
expect(out[0].code).toBe("PHC123");
expect(out[0].count).toBe(2);
});
it("farklı marka aynı kod ayrı satırdır", () => {
const out = aggregateSuggestions([row("u1", "Bosch", "X1"), row("u2", "TRW", "X1")], "u1");
expect(out).toHaveLength(2);
});
});

View File

@@ -0,0 +1,45 @@
// Kod normalizasyonu: gruplama ve kullanıcı-başına dedup anahtarı. Görselde
// kullanıcının yazdığı hâl korunur ("1J0 973 702" ↔ "1j0973702" aynı öneri).
export function normalizeSuggestedCode(code: string): string {
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
}
export interface SuggestionRow {
userId: string;
brand: string;
suggestedCode: string;
suggestedCodeNorm: string;
}
export interface AggregatedSuggestion {
brand: string;
code: string;
count: number;
mine: boolean;
}
// Aynı (marka, normalize kod) önerisini tek satırda toplar: en çok önerilen
// üstte, eşitlikte ilk gönderilen önde (rows createdAt sıralı gelir). Görünen
// marka/kod ilk gönderenin yazımıdır.
export function aggregateSuggestions(
rows: SuggestionRow[],
userId: string,
): AggregatedSuggestion[] {
const groups = new Map<string, AggregatedSuggestion>();
for (const row of rows) {
const key = `${row.brand.trim().toLocaleLowerCase("tr-TR")}|${row.suggestedCodeNorm}`;
const existing = groups.get(key);
if (existing) {
existing.count += 1;
existing.mine ||= row.userId === userId;
} else {
groups.set(key, {
brand: row.brand.trim(),
code: row.suggestedCode.trim(),
count: 1,
mine: row.userId === userId,
});
}
}
return [...groups.values()].sort((a, b) => b.count - a.count);
}

View File

@@ -0,0 +1,12 @@
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],
})
export class OemSuggestionsModule {}

View File

@@ -0,0 +1,60 @@
import { Inject, Injectable } from "@nestjs/common";
import { and, asc, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { oemSuggestions } from "../database/schema/core";
import {
type AggregatedSuggestion,
aggregateSuggestions,
normalizeSuggestedCode,
} from "./oem-suggestions.logic";
export interface CreateSuggestionResult {
created: boolean; // false = bu kullanıcı aynı öneriyi zaten göndermiş
suggestions: AggregatedSuggestion[];
}
@Injectable()
export class OemSuggestionsService {
constructor(@Inject(DATABASE) private db: Database) {}
async create(
userId: string,
input: { oemCode: string; brand: string; suggestedCode: string },
): Promise<CreateSuggestionResult> {
const oemCode = input.oemCode.trim();
// Aynı kullanıcının aynı (kod, normalize öneri) tekrarı sessizce yutulur —
// sayaç şişirme yok; farklı kullanıcılar aynı öneriyi destekleyerek büyütür.
const inserted = await this.db
.insert(oemSuggestions)
.values({
userId,
oemCode,
brand: input.brand.trim(),
suggestedCode: input.suggestedCode.trim(),
suggestedCodeNorm: normalizeSuggestedCode(input.suggestedCode),
})
.onConflictDoNothing()
.returning({ id: oemSuggestions.id });
return {
created: inserted.length > 0,
suggestions: await this.list(userId, oemCode),
};
}
async list(userId: string, oemCode: string): Promise<AggregatedSuggestion[]> {
const rows = await this.db
.select({
userId: oemSuggestions.userId,
brand: oemSuggestions.brand,
suggestedCode: oemSuggestions.suggestedCode,
suggestedCodeNorm: oemSuggestions.suggestedCodeNorm,
})
.from(oemSuggestions)
.where(and(eq(oemSuggestions.oemCode, oemCode.trim()), eq(oemSuggestions.status, "active")))
.orderBy(asc(oemSuggestions.createdAt))
.limit(500);
return aggregateSuggestions(rows, userId);
}
}

View File

@@ -0,0 +1,49 @@
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 5 FARKLI başarılı VIN
// decode + en az 5 FARKLI OEM kodu kopyası (kayıtlı, kataloğu gerçekten
// kullanan üyeler — eşikler oem-votes.logic.ts'te). 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

@@ -0,0 +1,47 @@
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,
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");
}
return this.oemVotesService.castVote(userId, parsed.data);
}
// Kod listesi URL sınırına sığmayacak kadar uzayabildiği için POST (bkz. /p/matched).
@Post("lookup")
async lookup(@CurrentUser("id") userId: string, @Body() body: unknown) {
const parsed = lookupOemVotesSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz kod listesi");
}
return { votes: await this.oemVotesService.lookup(userId, parsed.data.codes) };
}
@Get("leaderboard")
async leaderboard(@CurrentUser("id") userId: string) {
return this.oemVotesService.leaderboard(userId);
}
}

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
export const castOemVoteSchema = z.object({
oemCode: z.string().trim().min(2, "OEM kodu geçersiz").max(100, "OEM kodu çok uzun"),
vote: z.enum(["compatible", "incompatible"]),
// Analitik bağlamı — katalog rotaları catalog_vehicles id'si yollayabilir,
// UUID olmayan değerler serviste sessizce düşürülür (oy yine kaydedilir).
partId: z.string().max(100).optional(),
vehicleId: z.string().max(100).optional(),
categoryId: z.string().max(100).optional(),
});
export type CastOemVoteInput = z.infer<typeof castOemVoteSchema>;
export const lookupOemVotesSchema = z.object({
codes: z.array(z.string().trim().min(1).max(100)).min(1).max(400),
});
export type LookupOemVotesInput = z.infer<typeof lookupOemVotesSchema>;

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { computeVoteAward, maskExpertName, meetsExpertCriteria } from "./oem-votes.logic";
describe("computeVoteAward", () => {
it("ilk oy: kimse oylamamışsa 3 puan (1 oy + 2 doğru)", () => {
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "compatible")).toEqual({
points: 3,
correct: true,
});
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "incompatible")).toEqual({
points: 3,
correct: true,
});
});
it("1 kişi uyumlu demişken uyumsuz diyen 1 puan, uyumlu diyen 3 puan alır", () => {
const prior = { compatible: 1, incompatible: 0 };
expect(computeVoteAward(prior, "incompatible")).toEqual({ points: 1, correct: false });
expect(computeVoteAward(prior, "compatible")).toEqual({ points: 3, correct: true });
});
it("beraberliği bozan oy çoğunluğu kendi tarafına çevirdiği için 3 puan alır", () => {
expect(computeVoteAward({ compatible: 1, incompatible: 1 }, "compatible")).toEqual({
points: 3,
correct: true,
});
});
it("açık çoğunluğa karşı oy yalnız taban puanı alır", () => {
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "incompatible")).toEqual({
points: 1,
correct: false,
});
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "compatible")).toEqual({
points: 3,
correct: true,
});
});
it("oyumla beraberlik oluşuyorsa çoğunluk sağlanmaz, 1 puan", () => {
// 2-1 iken uyumsuz oyu → 2-2: kesin çoğunluk yok.
expect(computeVoteAward({ compatible: 2, incompatible: 1 }, "incompatible")).toEqual({
points: 1,
correct: false,
});
});
});
describe("maskExpertName", () => {
it("ad ve soyadın yalnız ilk harfini açık bırakır", () => {
expect(maskExpertName("Semih Yılmaz")).toBe("S*** Y***");
});
it("ara adları düşürür, soyad olarak son kelimeyi alır", () => {
expect(maskExpertName("Ali Rıza Demir")).toBe("A*** D***");
});
it("tek kelimelik adı maskeler", () => {
expect(maskExpertName("Semih")).toBe("S***");
});
it("Türkçe karakterleri doğru büyütür", () => {
expect(maskExpertName("ismail çelik")).toBe("İ*** Ç***");
});
it("boş ada güvenli düşer", () => {
expect(maskExpertName(" ")).toBe("Üye");
});
});
describe("meetsExpertCriteria", () => {
it("en az 5 farklı VIN VE en az 5 farklı OEM kopyası ister", () => {
expect(meetsExpertCriteria(5, 5)).toBe(true);
expect(meetsExpertCriteria(12, 7)).toBe(true);
expect(meetsExpertCriteria(4, 5)).toBe(false);
expect(meetsExpertCriteria(5, 4)).toBe(false);
expect(meetsExpertCriteria(0, 0)).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
export type OemVoteChoice = "compatible" | "incompatible";
export interface VoteCounts {
compatible: number;
incompatible: number;
}
export interface VoteAward {
points: number;
correct: boolean;
}
export const VOTE_BASE_POINTS = 1;
export const VOTE_MAJORITY_BONUS = 2;
// "Doğru oy" = kendi oyu da sayıldığında kesin çoğunlukla aynı tarafta olmak.
// İlk oy (0-0 → 1-0) ve beraberliği bozan oy çoğunluğu kendi tarafına
// çevirdiği için doğrudur (3 puan); mevcut çoğunluğa karşı oy yalnız taban
// puanı alır (1). Ödül oy anında kesinleşir, çoğunluk sonradan dönse bile
// geriye dönük düzeltilmez.
export function computeVoteAward(prior: VoteCounts, vote: OemVoteChoice): VoteAward {
const mine = (vote === "compatible" ? prior.compatible : prior.incompatible) + 1;
const other = vote === "compatible" ? prior.incompatible : prior.compatible;
const correct = mine > other;
return { points: VOTE_BASE_POINTS + (correct ? VOTE_MAJORITY_BONUS : 0), correct };
}
// Program kitle kriteri: kataloğu gerçekten kullanan kayıtlı üyeler —
// en az 5 FARKLI başarılı VIN decode VE en az 5 FARKLI OEM kodu kopyası.
export const MIN_DISTINCT_VINS = 5;
export const MIN_DISTINCT_OEM_COPIES = 5;
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.
export function maskExpertName(fullName: string): string {
const words = fullName.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "Üye";
const maskWord = (w: string) => `${w.charAt(0).toLocaleUpperCase("tr-TR")}***`;
if (words.length === 1) return maskWord(words[0]);
return `${maskWord(words[0])} ${maskWord(words[words.length - 1])}`;
}

View File

@@ -0,0 +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, ExpertAccessService],
exports: [OemVotesService, ExpertAccessService],
})
export class OemVotesModule {}

View File

@@ -0,0 +1,209 @@
import { Inject, Injectable } from "@nestjs/common";
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,
computeVoteAward,
maskExpertName,
} from "./oem-votes.logic";
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidOrNull = (value?: string) => (value && UUID_RE.test(value) ? value : null);
export interface CastVoteResult {
oemCode: string;
myVote: OemVoteChoice;
counts: VoteCounts;
pointsAwarded: number;
correct: boolean | null;
isNew: boolean;
}
export interface OemVoteSummary extends VoteCounts {
myVote: OemVoteChoice | null;
}
export interface LeaderboardEntry {
rank: number;
name: string;
points: number;
votes: number;
isMe: boolean;
}
@Injectable()
export class OemVotesService {
constructor(@Inject(DATABASE) private db: Database) {}
async castVote(
userId: string,
input: {
oemCode: string;
vote: OemVoteChoice;
partId?: string;
vehicleId?: string;
categoryId?: string;
},
): Promise<CastVoteResult> {
const oemCode = input.oemCode.trim();
const context = {
partId: uuidOrNull(input.partId),
vehicleId: uuidOrNull(input.vehicleId),
categoryId: uuidOrNull(input.categoryId),
};
return this.db.transaction(async (tx) => {
// Aynı kod üzerindeki eşzamanlı oyları sıraya sok: ilk-oy bonusu ve
// çoğunluk hesabı yarışsız, deterministik kalır. Kod bazlı kilit —
// farklı kodlar birbirini bekletmez, tx sonunda otomatik bırakılır.
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`oem-vote:${oemCode}`}))`);
const [existing] = await tx
.select({ id: oemVotes.id, vote: oemVotes.vote })
.from(oemVotes)
.where(and(eq(oemVotes.userId, userId), eq(oemVotes.oemCode, oemCode)))
.limit(1);
if (existing) {
// Fikir değişikliği oyu günceller ama puan üretmez (puan çiftliği yok).
if (existing.vote !== input.vote) {
await tx
.update(oemVotes)
.set({ vote: input.vote, ...context, updatedAt: new Date() })
.where(eq(oemVotes.id, existing.id));
}
return {
oemCode,
myVote: input.vote,
counts: await this.countVotes(tx, oemCode),
pointsAwarded: 0,
correct: null,
isNew: false,
};
}
const prior = await this.countVotes(tx, oemCode);
const award = computeVoteAward(prior, input.vote);
const [inserted] = await tx
.insert(oemVotes)
.values({ userId, oemCode, vote: input.vote, ...context })
.returning({ id: oemVotes.id });
await tx.insert(oemVotePoints).values({
userId,
voteId: inserted.id,
oemCode,
points: award.points,
correct: award.correct,
});
return {
oemCode,
myVote: input.vote,
counts: {
compatible: prior.compatible + (input.vote === "compatible" ? 1 : 0),
incompatible: prior.incompatible + (input.vote === "incompatible" ? 1 : 0),
},
pointsAwarded: award.points,
correct: award.correct,
isNew: true,
};
});
}
// Parça listesi açılırken görünen kodların oy özetleri — tek toplu sorgu.
async lookup(userId: string, codes: string[]): Promise<Record<string, OemVoteSummary>> {
const unique = [...new Set(codes.map((c) => c.trim()).filter(Boolean))];
if (unique.length === 0) return {};
const [tallies, mine] = await Promise.all([
this.db
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote, total: count() })
.from(oemVotes)
.where(inArray(oemVotes.oemCode, unique))
.groupBy(oemVotes.oemCode, oemVotes.vote),
this.db
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote })
.from(oemVotes)
.where(and(eq(oemVotes.userId, userId), inArray(oemVotes.oemCode, unique))),
]);
const result: Record<string, OemVoteSummary> = {};
const entry = (code: string) => {
result[code] ??= { compatible: 0, incompatible: 0, myVote: null };
return result[code];
};
for (const row of tallies) {
const summary = entry(row.oemCode);
if (row.vote === "compatible") summary.compatible = row.total;
else summary.incompatible = row.total;
}
for (const row of mine) {
entry(row.oemCode).myVote = row.vote as OemVoteChoice;
}
return result;
}
// 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({
userId: oemVotePoints.userId,
name: users.name,
points: totalPoints,
votes: count(),
})
.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);
const entries = rows.map((row, i) => ({
rank: i + 1,
name: maskExpertName(row.name),
points: row.points,
votes: row.votes,
isMe: row.userId === userId,
}));
const my = entries.find((e) => e.isMe);
return {
entries,
me: { rank: my?.rank ?? null, points: my?.points ?? 0, votes: my?.votes ?? 0 },
periodStart: periodStart.toISOString(),
};
}
private async countVotes(tx: Tx, oemCode: string): Promise<VoteCounts> {
const rows = await tx
.select({ vote: oemVotes.vote, total: count() })
.from(oemVotes)
.where(eq(oemVotes.oemCode, oemCode))
.groupBy(oemVotes.vote);
const counts: VoteCounts = { compatible: 0, incompatible: 0 };
for (const row of rows) {
if (row.vote === "compatible") counts.compatible = row.total;
else counts.incompatible = row.total;
}
return counts;
}
}

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

@@ -0,0 +1,102 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const captureMock = vi.fn();
const toastSuccessMock = vi.fn();
vi.mock("@/lib/posthog", () => ({
capture: (...args: unknown[]) => captureMock(...args),
}));
vi.mock("@/lib/api-client", () => ({
api: {
post: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("@/lib/toast", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({
children,
to,
params,
}: { children: React.ReactNode; to: string; params?: { code: string } }) => (
<a href={params ? to.replace("$code", params.code) : to}>{children}</a>
),
}));
import { api } from "@/lib/api-client";
import { OemSuggestionsSection } from "../oem-suggestions-section";
beforeEach(() => {
captureMock.mockClear();
toastSuccessMock.mockClear();
vi.mocked(api.get).mockReset().mockResolvedValue({ suggestions: [] });
vi.mocked(api.post).mockReset().mockResolvedValue({});
});
afterEach(() => {
cleanup();
});
describe("OemSuggestionsSection", () => {
it("lists aggregated suggestions with supporter counts", async () => {
vi.mocked(api.get).mockResolvedValue({
suggestions: [
{ brand: "Bosch", code: "0 986 478 123", count: 3, mine: true },
{ brand: "TRW", code: "DF4823", count: 1, mine: false },
],
});
render(<OemSuggestionsSection oemCode="8V0615423E" />);
await waitFor(() => {
expect(api.get).toHaveBeenCalledWith("/oem-suggestions?code=8V0615423E");
expect(screen.getByText("Bosch")).toBeInTheDocument();
});
expect(screen.getByRole("link", { name: "0 986 478 123" })).toBeInTheDocument();
expect(screen.getByText("sizin öneriniz")).toBeInTheDocument();
});
it("submits a brand + code suggestion and refreshes the list", async () => {
vi.mocked(api.post).mockResolvedValue({
created: true,
suggestions: [{ brand: "Valeo", code: "PHC123", count: 1, mine: true }],
});
render(<OemSuggestionsSection oemCode="X1" />);
await waitFor(() => expect(api.get).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: /Muadil öner/ }));
fireEvent.change(screen.getByPlaceholderText("Marka (ör. Bosch)"), {
target: { value: "Valeo" },
});
fireEvent.change(screen.getByPlaceholderText("Parça kodu"), {
target: { value: "PHC123" },
});
fireEvent.click(screen.getByRole("button", { name: "Gönder" }));
await waitFor(() => {
expect(api.post).toHaveBeenCalledWith("/oem-suggestions", {
oemCode: "X1",
brand: "Valeo",
suggestedCode: "PHC123",
});
expect(toastSuccessMock).toHaveBeenCalled();
expect(captureMock).toHaveBeenCalledWith(
"oem_suggestion_submitted",
expect.objectContaining({ oem_code: "X1", brand: "Valeo", created: true }),
);
expect(screen.getByText("Valeo")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,105 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const captureMock = vi.fn();
const toastSuccessMock = vi.fn();
vi.mock("@/lib/posthog", () => ({
capture: (...args: unknown[]) => captureMock(...args),
}));
vi.mock("@/lib/api-client", () => ({
api: {
post: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("@/lib/toast", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
}));
import { api } from "@/lib/api-client";
import { OemVoteCard } from "../oem-vote-card";
beforeEach(() => {
captureMock.mockClear();
toastSuccessMock.mockClear();
vi.mocked(api.post).mockReset().mockResolvedValue({});
});
afterEach(() => {
cleanup();
});
describe("OemVoteCard", () => {
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" 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).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 () => {
const postMock = vi.mocked(api.post);
postMock.mockImplementation((path: string) => {
if (path === "/oem-votes") {
return Promise.resolve({
oemCode: "X1",
myVote: "incompatible",
counts: { compatible: 0, incompatible: 1 },
pointsAwarded: 3,
correct: true,
isNew: true,
});
}
return Promise.resolve({ votes: {} });
});
render(<OemVoteCard oemCode="X1" />);
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
await waitFor(() => expect(downButton).toBeEnabled());
fireEvent.click(downButton);
await waitFor(() => {
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).toHaveAttribute("aria-pressed", "true");
});
});

View File

@@ -0,0 +1,158 @@
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { Badge, Button, Input, Skeleton } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { Plus, Users } from "lucide-react";
import { useEffect, useState } from "react";
export interface AggregatedSuggestion {
brand: string;
code: string;
count: number;
mine: boolean;
}
// Topluluk muadil önerileri: P kataloğunda eşleşmesi olmayan (ya da eksik
// kalan) OEM kodları için parça satıcılarından marka + kod toplar. Aynı
// öneriyi veren kullanıcı sayısı rozetle gösterilir; önerilen kod kendi OEM
// detay sayfasına linklenir.
export function OemSuggestionsSection({ oemCode }: { oemCode: string }) {
const [suggestions, setSuggestions] = useState<AggregatedSuggestion[] | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [brand, setBrand] = useState("");
const [code, setCode] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let cancelled = false;
setSuggestions(null);
api
.get<{ suggestions: AggregatedSuggestion[] }>(
`/oem-suggestions?code=${encodeURIComponent(oemCode)}`,
)
.then((res) => {
if (!cancelled) setSuggestions(res?.suggestions ?? []);
})
.catch(() => {
if (!cancelled) setSuggestions([]);
});
return () => {
cancelled = true;
};
}, [oemCode]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
const res = await api.post<{ created: boolean; suggestions: AggregatedSuggestion[] }>(
"/oem-suggestions",
{ oemCode, brand, suggestedCode: code },
);
setSuggestions(res.suggestions);
if (res.created) {
toast.success("Öneriniz eklendi, teşekkürler!", {
description: "Aynı öneriyi veren satıcı arttıkça öneri güçlenir.",
});
} else {
toast.info("Bu öneriyi zaten eklemişsiniz.");
}
capture("oem_suggestion_submitted", {
oem_code: oemCode,
brand,
created: res.created,
});
setBrand("");
setCode("");
setFormOpen(false);
} catch {
toast.error("Öneri kaydedilemedi", { description: "Lütfen tekrar deneyin." });
} finally {
setSubmitting(false);
}
};
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold">Topluluk muadil önerileri</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Bu kodun muadilini biliyorsanız marka + parça kodu olarak ekleyin.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setFormOpen((o) => !o)}>
<Plus className="mr-1.5 size-4" />
Muadil öner
</Button>
</div>
{formOpen && (
<form
onSubmit={submit}
className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3 sm:flex-row"
>
<Input
value={brand}
onChange={(e) => setBrand(e.target.value)}
placeholder="Marka (ör. Bosch)"
required
minLength={2}
maxLength={80}
className="sm:max-w-48"
/>
<Input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Parça kodu"
required
minLength={2}
maxLength={100}
className="font-mono"
/>
<Button type="submit" disabled={submitting}>
{submitting ? "Gönderiliyor…" : "Gönder"}
</Button>
</form>
)}
{suggestions === null ? (
<Skeleton className="h-14 w-full rounded-xl" />
) : suggestions.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
Henüz öneri yok — muadilini biliyorsanız ilk öneriyi siz ekleyin.
</p>
) : (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border">
{suggestions.map((s) => (
<li
key={`${s.brand.toLocaleLowerCase("tr-TR")}|${s.code}`}
className="flex items-center justify-between gap-3 bg-background px-4 py-2.5"
>
<span className="min-w-0">
<span className="text-sm font-medium">{s.brand}</span>{" "}
<Link
to="/dashboard/oem/$code"
params={{ code: s.code }}
className="font-mono text-xs underline decoration-dotted underline-offset-2 hover:decoration-solid"
>
{s.code}
</Link>
{s.mine && (
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-primary">
sizin öneriniz
</span>
)}
</span>
<Badge variant="secondary" title="Bu muadili öneren kullanıcı sayısı">
<Users className="mr-1 size-3" />
{s.count}
</Badge>
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,170 @@
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { cn } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { Car, ThumbsDown, ThumbsUp } from "lucide-react";
import { useEffect, useState } from "react";
export type OemVoteChoice = "compatible" | "incompatible";
export interface OemVoteSummary {
compatible: number;
incompatible: number;
myVote: OemVoteChoice | null;
}
export interface CastOemVoteResult {
oemCode: string;
myVote: OemVoteChoice;
counts: { compatible: number; incompatible: number };
pointsAwarded: number;
correct: boolean | null;
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 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);
api
.post<{ votes: Record<string, OemVoteSummary> }>("/oem-votes/lookup", { codes: [oemCode] })
.then((res) => {
if (!cancelled) setSummary(res?.votes?.[oemCode] ?? EMPTY_SUMMARY);
})
.catch(() => {
if (!cancelled) setSummary(EMPTY_SUMMARY);
});
return () => {
cancelled = true;
};
}, [oemCode]);
const cast = async (vote: OemVoteChoice) => {
setPending(true);
try {
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;
toast.success(`+${result.pointsAwarded} puan kazandınız!`, {
description: isFirstVote
? "Bu OEM kodunu ilk değerlendiren sizsiniz."
: result.correct
? "Çoğunluk görüşüyle aynı yöndesiniz."
: "Oyunuz kaydedildi — çoğunluk şimdilik farklı görüşte.",
});
} else {
toast.info("Oyunuz güncellendi.");
}
capture("oem_vote_cast", {
oem_code: oemCode,
vote,
points_awarded: result.pointsAwarded,
correct: result.correct,
is_new: result.isNew,
vehicle_id: vehicleId,
surface: "oem_detail",
});
} catch {
toast.error("Oy kaydedilemedi", { description: "Lütfen tekrar deneyin." });
} finally {
setPending(false);
}
};
// 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",
activeClass: "border-green-500/50 bg-green-500/10 text-green-500",
},
{
vote: "incompatible" as const,
icon: ThumbsDown,
label: "Uyumsuz",
activeClass: "border-red-500/50 bg-red-500/10 text-red-500",
},
];
return (
<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 ·{" "}
<Link to="/dashboard/uzmanlar" className="font-medium text-primary hover:underline">
Parça Uzmanları sıralaması
</Link>
</p>
</section>
);
}

View File

@@ -0,0 +1,96 @@
// Trophy Gamification UI Kit'in leaderboard-podium bileşeni (MIT, ui.trophy.so)
// sase'ye uyarlandı: shadcn yerine @sase/ui, cva bağımlılığı söküldü, rank
// renk token'ları somut Tailwind renklerine bağlandı, avatar servisi yerine
// maskeli adın ilk harfi gösteriliyor (liderlik listesi KVKK-maskeli).
import { cn } from "@sase/ui";
import { Crown } from "lucide-react";
export interface PodiumRanking {
userId: string;
userName: string | null;
rank: number;
value: number;
}
const PODIUM_CONFIG = {
1: { color: "text-amber-400", bg: "bg-amber-400/50", height: "h-32" },
2: { color: "text-zinc-400", bg: "bg-zinc-400/30", height: "h-24" },
3: { color: "text-orange-700", bg: "bg-orange-700/40", height: "h-20" },
} as const;
interface LeaderboardPodiumProps {
/** İlk 3 sıra (rank 1-3 beklenir) */
rankings: PodiumRanking[];
showValue?: boolean;
className?: string;
}
export function LeaderboardPodium({
rankings,
showValue = true,
className,
}: LeaderboardPodiumProps) {
// Kürsü dizilimi: 2. — 1. — 3.
const top3 = rankings.slice(0, 3);
const podiumOrder = [
top3.find((r) => r.rank === 2),
top3.find((r) => r.rank === 1),
top3.find((r) => r.rank === 3),
].filter((r): r is PodiumRanking => Boolean(r));
if (podiumOrder.length === 0) return null;
return (
<ul
className={cn("flex items-end justify-center gap-4", className)}
aria-label="İlk 3 sıralama"
>
{podiumOrder.map((ranking) => {
const config = PODIUM_CONFIG[ranking.rank as 1 | 2 | 3];
if (!config) return null;
const displayName = ranking.userName || "Üye";
return (
<li
key={ranking.userId}
aria-label={`Sıra ${ranking.rank}: ${displayName}${showValue ? `, ${ranking.value.toLocaleString("tr-TR")} puan` : ""}`}
className="flex flex-col items-center"
>
<div className="relative mb-2" aria-hidden="true">
<div
className={cn(
"flex h-14 w-14 items-center justify-center rounded-full text-lg font-semibold",
config.bg,
)}
>
{displayName.charAt(0)}
</div>
<div className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-background shadow-sm">
<Crown className={cn("h-4 w-4", config.color)} />
</div>
</div>
<span className="max-w-20 truncate text-center text-sm font-medium" title={displayName}>
{displayName}
</span>
{showValue && (
<span className="text-sm tabular-nums text-muted-foreground">
{ranking.value.toLocaleString("tr-TR")}
</span>
)}
<div
className={cn("mt-2 w-22 rounded-t-lg", config.height, config.bg)}
aria-hidden="true"
>
<div className={cn("flex h-8 items-center justify-center font-bold", config.color)}>
{ranking.rank}
</div>
</div>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,166 @@
// Trophy Gamification UI Kit'in leaderboard-rankings bileşeni (MIT, ui.trophy.so)
// sase'ye uyarlandı: @sase/ui importları, Türkçe metinler, avatar yerine
// maskeli adın ilk harfi, currentUserId karşılaştırması yerine isCurrentUser
// bayrağı (liderlik cevabı kullanıcı id'si sızdırmaz).
import { Button, cn } from "@sase/ui";
import { ChevronLeft, ChevronRight, Crown } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
export interface LeaderboardRankingItem {
userId: string;
userName: string | null;
rank: number;
value: number;
byline?: string | null;
isCurrentUser?: boolean;
}
interface LeaderboardRankingsProps {
rankings: LeaderboardRankingItem[];
showPagination?: boolean;
defaultPageSize?: 10 | 25 | 50 | 100;
className?: string;
}
const crownColorMap = {
1: "text-amber-400",
2: "text-zinc-400",
3: "text-orange-700",
} as const;
const pageSizeOptions = [10, 25, 50, 100] as const;
function formatLeaderboardValue(value: number) {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
return value.toLocaleString("tr-TR");
}
export function LeaderboardRankings({
rankings,
showPagination = false,
defaultPageSize = 25,
className,
}: LeaderboardRankingsProps) {
const [pageSize, setPageSize] = useState<10 | 25 | 50 | 100>(defaultPageSize);
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.max(1, Math.ceil(rankings.length / pageSize));
useEffect(() => {
setCurrentPage(1);
}, []);
useEffect(() => {
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages]);
const pagedRankings = useMemo(
() =>
showPagination
? rankings.slice((currentPage - 1) * pageSize, currentPage * pageSize)
: rankings,
[rankings, showPagination, currentPage, pageSize],
);
return (
<div className={cn("w-full rounded-xl border bg-card", className)}>
<ul aria-label="Parça uzmanları sıralaması" className="divide-y divide-border">
{pagedRankings.map((ranking) => {
const displayName = ranking.userName || "Üye";
const showCrown = ranking.rank <= 3;
const crownColor = crownColorMap[ranking.rank as 1 | 2 | 3];
return (
<li
key={ranking.userId}
className={cn(
"flex items-center gap-2 px-4 py-2",
ranking.isCurrentUser && "rounded-md border-2 border-primary bg-muted",
)}
>
<div className="flex w-12 items-center gap-1">
<span className="w-4 text-sm font-semibold tabular-nums">{ranking.rank}</span>
{showCrown ? (
<Crown className={cn("h-5 w-5", crownColor)} aria-hidden="true" />
) : null}
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
{displayName.charAt(0).toLocaleUpperCase("tr-TR")}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
{displayName}
{ranking.isCurrentUser && (
<span className="ml-2 text-xs font-semibold text-primary">(Siz)</span>
)}
</p>
{ranking.byline ? (
<p className="truncate text-sm text-muted-foreground">{ranking.byline}</p>
) : null}
</div>
<p className="font-semibold leading-none tabular-nums">
{formatLeaderboardValue(ranking.value)}
</p>
</li>
);
})}
</ul>
{showPagination ? (
<div className="flex items-center justify-between gap-3 border-t px-4 py-2">
<div className="flex items-center gap-2">
<label htmlFor="leaderboard-page-size" className="text-sm text-muted-foreground">
Göster
</label>
<select
id="leaderboard-page-size"
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value) as 10 | 25 | 50 | 100);
setCurrentPage(1);
}}
className="rounded-md border bg-background px-2 py-1 text-sm text-muted-foreground"
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
aria-label="Önceki sayfa"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="rounded-md border p-1.5 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground">
Sayfa {currentPage} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
aria-label="Sonraki sayfa"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="rounded-md border p-1.5 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,44 @@
// Trophy Gamification UI Kit'in points-badge bileşeni (MIT, ui.trophy.so)
// sase'ye uyarlandı: @sase/ui importları, cva bağımlılığı söküldü.
import { cn } from "@sase/ui";
import { Sparkle } from "lucide-react";
interface PointsBadgeProps {
name: string;
total: number;
icon?: React.ComponentType<{ className?: string }>;
formatValue?: (value: number) => string;
className?: string;
}
export function PointsBadge({
name,
total,
icon: CustomIcon,
formatValue,
className,
}: PointsBadgeProps) {
const Icon = CustomIcon ?? Sparkle;
const displayValue = formatValue ? formatValue(total) : total.toLocaleString("tr-TR");
return (
<output
aria-label={`${displayValue} ${name}`}
className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-4 transition-colors",
className,
)}
>
<div className="flex items-center gap-2">
<div
aria-hidden="true"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10"
>
<Icon className="h-5 w-5 text-primary" />
</div>
<span className="text-xl font-bold tabular-nums">{displayValue}</span>
</div>
<span className="truncate text-muted-foreground">{name}</span>
</output>
);
}

View File

@@ -27,6 +27,7 @@ vi.mock("@/stores/schema.store", () => ({
}),
}));
import { api } from "@/lib/api-client";
import { PartsPanel } from "../parts-panel";
const buildPart = (overrides: Partial<Part> = {}): Part => ({
@@ -49,6 +50,7 @@ beforeEach(() => {
captureMock.mockClear();
setSelectedGroupMock.mockClear();
setHighlightedGroupMock.mockClear();
vi.mocked(api.post).mockReset().mockResolvedValue({});
});
afterEach(() => {
@@ -148,4 +150,27 @@ describe("PartsPanel", () => {
parts_count: parts.length,
});
});
it("links every OEM code to the detail page without a /p/matched gate", () => {
const postMock = vi.mocked(api.post);
render(<PartsPanel parts={[buildPart()]} vehicleId="v1" categoryId="c1" />);
const link = screen.getByRole("link", { name: "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();
});
it("does not render Uyum or Adet columns", () => {
render(<PartsPanel parts={[buildPart({ quantity: 4 })]} />);
expect(screen.queryByText("Adet")).not.toBeInTheDocument();
expect(screen.queryByText("Uyum")).not.toBeInTheDocument();
const headers = screen.getAllByRole("columnheader").map((th) => th.textContent);
expect(headers).toEqual(["#", "Parça Adı", "OEM Kodu", "Pozisyon"]);
});
});

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,
@@ -37,34 +48,6 @@ export function PartsPanel({
const viewedKeyRef = useRef<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [resolvingCode, setResolvingCode] = useState<string | null>(null);
// OEM codes that resolve to a non-empty cross-reference page. Only these are
// rendered as links — unmatched codes stay plain text so a click never lands
// on an empty "no equivalents" page. One batch lookup per parts list.
const [matchedOemCodes, setMatchedOemCodes] = useState<Set<string>>(new Set());
const oemCodes = useMemo(
() => [...new Set(parts.map((p) => p.oemCode).filter((c) => c && c !== "N/A"))],
[parts],
);
useEffect(() => {
if (oemCodes.length === 0) {
setMatchedOemCodes(new Set());
return;
}
let cancelled = false;
api
.post<{ matched: string[] }>("/p/matched", { codes: oemCodes })
.then((res) => {
if (!cancelled) setMatchedOemCodes(new Set(res?.matched ?? []));
})
.catch(() => {
if (!cancelled) setMatchedOemCodes(new Set());
});
return () => {
cancelled = true;
};
}, [oemCodes]);
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the
// target illustration directly. Unresolved (target branch not seeded yet) →
@@ -265,7 +248,6 @@ export function PartsPanel({
<th className="px-3 py-2 w-10">#</th>
<th className="px-3 py-2">Parça Adı</th>
<th className="px-3 py-2">OEM Kodu</th>
<th className="px-3 py-2 w-14 text-center">Adet</th>
<th className="px-3 py-2">Pozisyon</th>
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
</tr>
@@ -290,7 +272,7 @@ export function PartsPanel({
return (
<tr key={part.id} className="border-b border-border/50 bg-muted/20">
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
<td className="px-3 py-2" colSpan={hasPrices ? 5 : 4}>
<td className="px-3 py-2" colSpan={hasPrices ? 4 : 3}>
{label && <span className="font-medium">{label}</span>}
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
{refs.map((ref) => {
@@ -393,16 +375,19 @@ export function PartsPanel({
)}
</button>
)}
{part.oemCode &&
part.oemCode !== "N/A" &&
matchedOemCodes.has(part.oemCode) ? (
// Link ONLY codes with a real cross-reference. Plain
// left-click → in-app client navigation (no full SPA
// reload — the new-tab boot was the slow part). Real href
// kept so ctrl/cmd/middle-click still opens a new tab.
// Unmatched codes fall through to plain text.
{part.oemCode && part.oemCode !== "N/A" ? (
// Every code links to the OEM detail page — without a P
// cross-reference the page still carries the community
// vote, equivalent suggestions and the reverse catalog.
// Plain left-click → in-app client navigation (no full
// 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) => {
@@ -418,6 +403,7 @@ export function PartsPanel({
navigate({
to: "/dashboard/oem/$code",
params: { code: part.oemCode },
search: { v: vehicleLabel, p: part.name, vid: vehicleId },
});
}}
>
@@ -428,7 +414,6 @@ export function PartsPanel({
)}
</span>
</td>
<td className="px-3 py-2 text-center">{part.quantity}</td>
<td className="px-3 py-2 text-muted-foreground">{part.position}</td>
{hasPrices && (
<td className="px-3 py-2 text-right text-xs">

View File

@@ -1,118 +0,0 @@
import { useChangelog } from "@/hooks/use-changelog";
import { useTranslation } from "@/lib/i18n";
import type { ChangelogEntry } from "@sase/shared";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Skeleton,
} from "@sase/ui";
import { CalendarDays } from "lucide-react";
function formatDate(iso: string, locale: "tr" | "en"): string {
const date = new Date(iso);
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
};
return date.toLocaleDateString(locale === "tr" ? "tr-TR" : "en-US", options);
}
function changeTypeLabel(
changeType: ChangelogEntry["changeType"],
t: (key: string) => string,
): string {
return t(`settings.changelog.changeType.${changeType}`);
}
function ChangelogSkeleton() {
return (
<div className="space-y-0">
{[1, 2, 3].map((i) => (
<div key={i} className="relative pb-6 pl-8">
<div className="absolute left-0 top-1.5 h-2.5 w-2.5 rounded-full bg-muted-foreground/20" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-md" />
<Skeleton className="h-5 w-48" />
</div>
<Skeleton className="h-4 w-28" />
</div>
</div>
))}
</div>
);
}
function ChangelogEmpty({ t }: { t: (key: string) => string }) {
return (
<Card className="border-dashed">
<CardHeader className="text-center">
<CalendarDays className="mx-auto h-10 w-10 text-muted-foreground" />
<CardTitle>{t("settings.changelog.emptyTitle")}</CardTitle>
<CardDescription>{t("settings.changelog.emptyDescription")}</CardDescription>
</CardHeader>
</Card>
);
}
export function ChangelogTab() {
const { data: entries, isLoading } = useChangelog();
const { t, locale } = useTranslation();
if (isLoading) {
return <ChangelogSkeleton />;
}
if (!entries || entries.length === 0) {
return <ChangelogEmpty t={t} />;
}
return (
<Card>
<CardHeader>
<CardTitle>{t("settings.changelog.title")}</CardTitle>
<CardDescription>{t("settings.changelog.description")}</CardDescription>
</CardHeader>
<CardContent>
<Accordion
type="single"
collapsible
className="relative border-l-2 border-muted-foreground/20"
>
{entries.map((entry) => (
<AccordionItem key={entry.id} value={entry.id} className="border-b-0 pl-6">
<div className="absolute left-[-5px] mt-6 h-2.5 w-2.5 rounded-full border-2 border-background bg-foreground dark:bg-primary" />
<AccordionTrigger className="py-3 hover:no-underline">
<div className="flex flex-col items-start gap-1.5 text-left">
<div className="flex flex-wrap items-center gap-2">
<Badge changeType={entry.changeType}>
{changeTypeLabel(entry.changeType, t)}
</Badge>
<span className="font-medium">{entry.title}</span>
</div>
<span className="text-xs text-muted-foreground">
{formatDate(entry.publishedAt, locale)}
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
{entry.description}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
);
}

View File

@@ -1,11 +0,0 @@
import { api } from "@/lib/api-client";
import type { ChangelogEntry } from "@sase/shared";
import { useQuery } from "@tanstack/react-query";
export function useChangelog() {
return useQuery({
queryKey: ["changelog"],
queryFn: () => api.get<ChangelogEntry[]>("/changelog"),
staleTime: 30 * 60 * 1000, // 30 min — matches Redis cache TTL
});
}

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

@@ -74,6 +74,7 @@
"dashboard": "Dashboard",
"search": "Search",
"history": "History",
"experts": "Parts Experts",
"catalog": "Catalog",
"subscription": "Subscription",
"billing": "Payment",
@@ -82,7 +83,6 @@
"logout": "Log Out",
"contact": "Contact",
"blog": "Blog",
"changelog": "What's New",
"account": "Account",
"sectionMain": "Main Menu",
"sectionAccount": "Account",
@@ -522,7 +522,6 @@
"connections": "Connections",
"referral": "Referral",
"account": "Account",
"changelog": "Changelog",
"notifications": "Notifications"
},
"preferences": {
@@ -593,17 +592,6 @@
"typeConfirm": "Type 'DELETE' to confirm",
"confirmWord": "DELETE"
},
"changelog": {
"title": "Changelog",
"description": "Latest platform updates, new features, and bug fixes.",
"emptyTitle": "No updates yet",
"emptyDescription": "Platform updates will appear here soon.",
"changeType": {
"fix": "Fix",
"feature": "New Feature",
"improvement": "Improvement"
}
},
"notifications": {
"title": "Notification preferences",
"description": "Choose which lifecycle e-mails you want to receive. Account security and payment notifications keep coming."

View File

@@ -74,6 +74,7 @@
"dashboard": "Gösterge Paneli",
"search": "Arama",
"history": "Geçmiş",
"experts": "Parça Uzmanları",
"catalog": "Katalog",
"subscription": "Abonelik",
"billing": "Ödeme",
@@ -82,7 +83,6 @@
"logout": "Çıkış Yap",
"contact": "İletişim",
"blog": "Blog",
"changelog": "Yenilikler",
"account": "Hesap",
"sectionMain": "Ana Menü",
"sectionAccount": "Hesap",
@@ -522,7 +522,6 @@
"connections": "Bağlantılar",
"referral": "Referans",
"account": "Hesap",
"changelog": "Değişiklik Günlüğü",
"notifications": "Bildirimler"
},
"preferences": {
@@ -593,17 +592,6 @@
"typeConfirm": "Onaylamak için 'SİL' yazın",
"confirmWord": "SİL"
},
"changelog": {
"title": "Değişiklik Günlüğü",
"description": "Platformdaki son güncellemeler, yeni özellikler ve hata düzeltmeleri.",
"emptyTitle": "Henüz güncelleme yok",
"emptyDescription": "Yakında platform güncellemeleri burada görünecek.",
"changeType": {
"fix": "Düzeltme",
"feature": "Yeni Özellik",
"improvement": "Geliştirme"
}
},
"notifications": {
"title": "Bildirim tercihleri",
"description": "Hangi lifecycle maillerini almak istediğini seç. Hesap güvenliği ve ödeme bildirimleri her zaman gelmeye devam eder."

View File

@@ -21,12 +21,12 @@ import { Route as AboutRouteImport } from './routes/about'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DashboardIndexRouteImport } from './routes/dashboard/index'
import { Route as DashboardUzmanlarRouteImport } from './routes/dashboard/uzmanlar'
import { Route as DashboardSettingsRouteImport } from './routes/dashboard/settings'
import { Route as DashboardServiceTestRouteImport } from './routes/dashboard/service-test'
import { Route as DashboardSearchRouteImport } from './routes/dashboard/search'
import { Route as DashboardHistoryRouteImport } from './routes/dashboard/history'
import { Route as DashboardContactRouteImport } from './routes/dashboard/contact'
import { Route as DashboardChangelogRouteImport } from './routes/dashboard/changelog'
import { Route as DashboardBlogRouteImport } from './routes/dashboard/blog'
import { Route as DashboardBillingRouteImport } from './routes/dashboard/billing'
import { Route as BlogSlugRouteImport } from './routes/blog_/$slug'
@@ -117,6 +117,11 @@ const DashboardIndexRoute = DashboardIndexRouteImport.update({
path: '/',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardUzmanlarRoute = DashboardUzmanlarRouteImport.update({
id: '/uzmanlar',
path: '/uzmanlar',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardSettingsRoute = DashboardSettingsRouteImport.update({
id: '/settings',
path: '/settings',
@@ -142,11 +147,6 @@ const DashboardContactRoute = DashboardContactRouteImport.update({
path: '/contact',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardChangelogRoute = DashboardChangelogRouteImport.update({
id: '/changelog',
path: '/changelog',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardBlogRoute = DashboardBlogRouteImport.update({
id: '/blog',
path: '/blog',
@@ -331,12 +331,12 @@ export interface FileRoutesByFullPath {
'/blog/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
'/dashboard/settings': typeof DashboardSettingsRoute
'/dashboard/uzmanlar': typeof DashboardUzmanlarRoute
'/dashboard/': typeof DashboardIndexRoute
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
@@ -379,12 +379,12 @@ export interface FileRoutesByTo {
'/blog/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
'/dashboard/settings': typeof DashboardSettingsRoute
'/dashboard/uzmanlar': typeof DashboardUzmanlarRoute
'/dashboard': typeof DashboardIndexRoute
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
@@ -430,12 +430,12 @@ export interface FileRoutesById {
'/blog_/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
'/dashboard/settings': typeof DashboardSettingsRoute
'/dashboard/uzmanlar': typeof DashboardUzmanlarRoute
'/dashboard/': typeof DashboardIndexRoute
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
@@ -481,12 +481,12 @@ export interface FileRouteTypes {
| '/blog/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
| '/dashboard/settings'
| '/dashboard/uzmanlar'
| '/dashboard/'
| '/dashboard/admin/analytics'
| '/dashboard/admin/copy-logs'
@@ -529,12 +529,12 @@ export interface FileRouteTypes {
| '/blog/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
| '/dashboard/settings'
| '/dashboard/uzmanlar'
| '/dashboard'
| '/dashboard/admin/analytics'
| '/dashboard/admin/copy-logs'
@@ -579,12 +579,12 @@ export interface FileRouteTypes {
| '/blog_/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
| '/dashboard/settings'
| '/dashboard/uzmanlar'
| '/dashboard/'
| '/dashboard/admin/analytics'
| '/dashboard/admin/copy-logs'
@@ -712,6 +712,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardIndexRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/uzmanlar': {
id: '/dashboard/uzmanlar'
path: '/uzmanlar'
fullPath: '/dashboard/uzmanlar'
preLoaderRoute: typeof DashboardUzmanlarRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/settings': {
id: '/dashboard/settings'
path: '/settings'
@@ -747,13 +754,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardContactRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/changelog': {
id: '/dashboard/changelog'
path: '/changelog'
fullPath: '/dashboard/changelog'
preLoaderRoute: typeof DashboardChangelogRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/blog': {
id: '/dashboard/blog'
path: '/blog'
@@ -988,12 +988,12 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface DashboardRouteChildren {
DashboardBillingRoute: typeof DashboardBillingRoute
DashboardBlogRoute: typeof DashboardBlogRoute
DashboardChangelogRoute: typeof DashboardChangelogRoute
DashboardContactRoute: typeof DashboardContactRoute
DashboardHistoryRoute: typeof DashboardHistoryRoute
DashboardSearchRoute: typeof DashboardSearchRoute
DashboardServiceTestRoute: typeof DashboardServiceTestRoute
DashboardSettingsRoute: typeof DashboardSettingsRoute
DashboardUzmanlarRoute: typeof DashboardUzmanlarRoute
DashboardIndexRoute: typeof DashboardIndexRoute
DashboardAdminAnalyticsRoute: typeof DashboardAdminAnalyticsRoute
DashboardAdminCopyLogsRoute: typeof DashboardAdminCopyLogsRoute
@@ -1021,12 +1021,12 @@ interface DashboardRouteChildren {
const DashboardRouteChildren: DashboardRouteChildren = {
DashboardBillingRoute: DashboardBillingRoute,
DashboardBlogRoute: DashboardBlogRoute,
DashboardChangelogRoute: DashboardChangelogRoute,
DashboardContactRoute: DashboardContactRoute,
DashboardHistoryRoute: DashboardHistoryRoute,
DashboardSearchRoute: DashboardSearchRoute,
DashboardServiceTestRoute: DashboardServiceTestRoute,
DashboardSettingsRoute: DashboardSettingsRoute,
DashboardUzmanlarRoute: DashboardUzmanlarRoute,
DashboardIndexRoute: DashboardIndexRoute,
DashboardAdminAnalyticsRoute: DashboardAdminAnalyticsRoute,
DashboardAdminCopyLogsRoute: DashboardAdminCopyLogsRoute,

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";
@@ -31,7 +32,6 @@ import { Link, Outlet, createFileRoute, useNavigate, useRouterState } from "@tan
import {
BarChart3,
BookOpen,
CalendarDays,
ChevronsUpDown,
Copy,
CreditCard,
@@ -51,6 +51,7 @@ import {
Shield,
Sparkles,
Sun,
Trophy,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -72,6 +73,7 @@ const mainMenuItems: readonly NavItem[] = [
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search },
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
{ to: "/dashboard/history", labelKey: "nav.history", icon: History },
{ to: "/dashboard/uzmanlar", labelKey: "nav.experts", icon: Trophy },
];
const accountItems: readonly NavItem[] = [
@@ -81,7 +83,6 @@ const accountItems: readonly NavItem[] = [
];
const supportItems: readonly NavItem[] = [
{ to: "/dashboard/changelog", labelKey: "nav.changelog", icon: CalendarDays },
{ to: "/dashboard/contact", labelKey: "nav.contact", icon: Mail },
{ to: "/dashboard/blog", labelKey: "nav.blog", icon: BookOpen },
];
@@ -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,14 +0,0 @@
import { ChangelogTab } from "@/components/settings/changelog-tab";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/changelog")({
component: ChangelogPage,
});
function ChangelogPage() {
return (
<div className="mx-auto max-w-3xl">
<ChangelogTab />
</div>
);
}

View File

@@ -1,3 +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";
@@ -114,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],
@@ -169,28 +185,40 @@ 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>
{/* ─── Loading ────────────────────────────────────────────────────── */}
@@ -221,7 +249,8 @@ function OemDetailPage() {
<p className="font-medium">Bu OEM kodu için uyumlu parça bulunamadı</p>
<p className="max-w-md text-sm text-muted-foreground">
Bağlantı parçaları, klipsler ve bazı orijinal kodların muadili henüz kataloğumuzda
olmayabilir. Katalog büyüdükçe eşleşme oranı artar.
olmayabilir. Katalog büyüdükçe eşleşme oranı artar. Muadilini biliyorsanız aşağıdan
önerin — diğer parça satıcılarına yol gösterir.
</p>
</div>
)}
@@ -339,6 +368,9 @@ function OemDetailPage() {
</div>
)}
{/* ─── Topluluk muadil önerileri (eşleşme olmasa da toplanır) ──────── */}
{!isLoading && expertEnabled && <OemSuggestionsSection oemCode={code} />}
{/* ─── Reverse catalog: your vehicles that use this code ───────────── */}
{catalogVehicles && catalogVehicles.length > 0 && (
<section className="space-y-3">

View File

@@ -0,0 +1,198 @@
import { LeaderboardPodium } from "@/components/gamification/leaderboard-podium";
import {
type LeaderboardRankingItem,
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";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Medal, ThumbsUp, Trophy } from "lucide-react";
import { useEffect } from "react";
interface LeaderboardEntry {
rank: number;
name: string;
points: number;
votes: number;
isMe: boolean;
}
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")({
component: ExpertsPage,
});
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 açılıyor.
</p>
<p className="max-w-md text-sm text-muted-foreground">
Program, kataloğu aktif kullanan üyelere otomatik açı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;
// Liderlik cevabı kullanıcı id'si taşımaz; satır anahtarı sıradan türetilir.
const podiumRankings = entries.slice(0, 3).map((e) => ({
userId: `rank-${e.rank}`,
userName: e.name,
rank: e.rank,
value: e.points,
}));
const rankingItems: LeaderboardRankingItem[] = entries.map((e) => ({
userId: `rank-${e.rank}`,
userName: e.name,
rank: e.rank,
value: e.points,
byline: `${e.votes} oy`,
isCurrentUser: e.isMe,
}));
return (
<div className="mx-auto max-w-5xl space-y-6">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold">
<Trophy className="size-6 text-amber-400" />
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.
</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 || accessLoading ? (
<div className="space-y-3">
{SKELETON_KEYS.map((k) => (
<Skeleton key={k} className="h-14 w-full" />
))}
</div>
) : entries.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
<Trophy className="size-10 text-muted-foreground/50" />
<p className="text-sm font-medium">Sıralama henüz boş.</p>
<p className="max-w-md text-sm text-muted-foreground">
İlk uzman siz olun: katalogdan bir OEM kodunu değerlendirin, açılış puanlarını siz
kapın.
</p>
<Link to="/dashboard/search" className="text-sm font-semibold text-primary underline">
Şase sorgula ve oylamaya başla
</Link>
</CardContent>
</Card>
) : (
<>
{/* Kendi durumum */}
{me && (
<div className="grid gap-3 sm:grid-cols-3">
<PointsBadge name="puanınız" total={me.points} />
<PointsBadge
name="sıralamanız"
total={me.rank ?? 0}
icon={Medal}
formatValue={(v) => (v > 0 ? `#${v}` : "—")}
/>
<PointsBadge name="oyunuz" total={me.votes} icon={ThumbsUp} />
</div>
)}
{/* İlk 3 kürsüsü */}
{podiumRankings.length > 0 && (
<Card>
<CardContent className="pt-6">
<LeaderboardPodium rankings={podiumRankings} />
</CardContent>
</Card>
)}
{/* Tam sıralama */}
<LeaderboardRankings rankings={rankingItems} showPagination={entries.length > 25} />
</>
)}
{/* Puan kuralları yalnız burada yaşar — küçük punto (computeVoteAward ile aynı kural). */}
<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 ·
sıralama her ayın 1'i 00:00'da sıfırlanır, biten ayın ilk 3'üne üyelik uzatması tanımlanır
</p>
</div>
);
}