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 ılıyor.
</p>
<p className="max-w-md text-sm text-muted-foreground">
Program, kataloğu aktif kullanan üyelere otomatik ılır şase sorgulamaya ve OEM
kodlarıyla çalışmaya devam edin, sıranız geldiğinde burada olacak.
</p>
</CardContent>
</Card>
</div>
);
}
const entries = data?.entries ?? [];
const me = data?.me;
// 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, ı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>
);
}