Merge pull request 'dev' (#130) from dev into main

Reviewed-on: #130
This commit was merged in pull request #130.
This commit is contained in:
2026-06-11 21:45:35 +00:00
37 changed files with 1860 additions and 232 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

@@ -106,6 +106,20 @@
"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
}
]
}

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,90 @@ 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),
],
);

View File

@@ -0,0 +1,30 @@
import { BadRequestException, Body, Controller, Get, Post, Query } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { createOemSuggestionSchema } from "./oem-suggestions.dto";
import { OemSuggestionsService } from "./oem-suggestions.service";
@Controller("oem-suggestions")
export class OemSuggestionsController {
constructor(private readonly oemSuggestionsService: OemSuggestionsService) {}
// Spam koruması: dakikada en fazla 10 öneri.
@Post()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async create(@CurrentUser("id") userId: string, @Body() body: unknown) {
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,10 @@
import { Module } from "@nestjs/common";
import { OemSuggestionsController } from "./oem-suggestions.controller";
import { OemSuggestionsService } from "./oem-suggestions.service";
@Module({
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,36 @@
import { BadRequestException, Body, Controller, Get, Post } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { castOemVoteSchema, lookupOemVotesSchema } from "./oem-votes.dto";
import { OemVotesService } from "./oem-votes.service";
@Controller("oem-votes")
export class OemVotesController {
constructor(private readonly oemVotesService: OemVotesService) {}
// 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) {
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,69 @@
import { describe, expect, it } from "vitest";
import { computeVoteAward, maskExpertName } 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");
});
});

View File

@@ -0,0 +1,37 @@
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 };
}
// 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,10 @@
import { Module } from "@nestjs/common";
import { OemVotesController } from "./oem-votes.controller";
import { OemVotesService } from "./oem-votes.service";
@Module({
controllers: [OemVotesController],
providers: [OemVotesService],
exports: [OemVotesService],
})
export class OemVotesModule {}

View File

@@ -0,0 +1,200 @@
import { Inject, Injectable } from "@nestjs/common";
import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { oemVotePoints, oemVotes, users } from "../database/schema/core";
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ı: puana göre herkes, adlar maskeli ("S*** Y***"). Yanıt
// userId sızdırmaz; istek sahibi kendi satırını isMe ile bulur.
async leaderboard(userId: string): Promise<{
entries: LeaderboardEntry[];
me: { rank: number | null; points: number; votes: number };
}> {
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))
.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 },
};
}
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

@@ -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,100 @@
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 for the code and shows the counts", async () => {
vi.mocked(api.post).mockResolvedValue({
votes: { "8V0615423E": { compatible: 4, incompatible: 1, myVote: "compatible" } },
});
render(<OemVoteCard oemCode="8V0615423E" />);
const upButton = screen.getByRole("button", { name: /Uyumlu/ });
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
await waitFor(() => {
expect(api.post).toHaveBeenCalledWith("/oem-votes/lookup", { codes: ["8V0615423E"] });
expect(upButton.textContent).toContain("4");
expect(downButton.textContent).toContain("1");
});
expect(upButton).toHaveAttribute("aria-pressed", "true");
});
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", {
oemCode: "X1",
vote: "incompatible",
});
expect(toastSuccessMock).toHaveBeenCalledWith("+3 puan kazandınız!", expect.anything());
expect(captureMock).toHaveBeenCalledWith(
"oem_vote_cast",
expect.objectContaining({ oem_code: "X1", vote: "incompatible", surface: "oem_detail" }),
);
});
expect(downButton.textContent).toContain("1");
expect(downButton).toHaveAttribute("aria-pressed", "true");
});
});

View File

@@ -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,142 @@
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 { 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;
}
const EMPTY_SUMMARY: OemVoteSummary = { compatible: 0, incompatible: 0, myVote: null };
// OEM detay sayfasındaki topluluk oyu kartı: uyumlu/uyumsuz + sayaçlar.
// Kendi durumunu yönetir; oy puanları toast ile bildirilir (oy +1, çoğunluk
// +2, ilk oy 3 — kural API'deki computeVoteAward'da).
export function OemVoteCard({ oemCode }: { oemCode: string }) {
const [summary, setSummary] = useState<OemVoteSummary | null>(null);
const [pending, setPending] = useState(false);
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 });
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,
surface: "oem_detail",
});
} catch {
toast.error("Oy kaydedilemedi", { description: "Lütfen tekrar deneyin." });
} finally {
setPending(false);
}
};
const options = [
{
vote: "compatible" as const,
icon: ThumbsUp,
label: "Uyumlu",
count: summary?.compatible ?? 0,
activeClass: "border-green-500/50 bg-green-500/10 text-green-500",
},
{
vote: "incompatible" as const,
icon: ThumbsDown,
label: "Uyumsuz",
count: summary?.incompatible ?? 0,
activeClass: "border-red-500/50 bg-red-500/10 text-red-500",
},
];
return (
<section className="rounded-xl border border-border bg-background p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold">Topluluk uyumluluk oyu</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Bu OEM kodu sizce doğru mu? Oyunuz diğer parça satıcılarına yol gösterir.
</p>
</div>
<div className="flex gap-2">
{options.map(({ vote, icon: Icon, label, count, activeClass }) => {
const isActive = summary?.myVote === vote;
return (
<button
key={vote}
type="button"
disabled={pending || summary === null}
aria-pressed={isActive}
onClick={() => cast(vote)}
className={cn(
"inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50",
isActive
? activeClass
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Icon className="size-4 shrink-0" fill={isActive ? "currentColor" : "none"} />
{label}
<span className="font-semibold tabular-nums">{count}</span>
</button>
);
})}
</div>
</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,25 @@ 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" });
expect(link).toHaveAttribute("href", "/dashboard/oem/OEM-1");
// 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

@@ -37,34 +37,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 +237,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 +261,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,14 +364,13 @@ 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)}`}
title="Uyumlu parça kodlarını gör"
@@ -428,7 +398,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

@@ -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

@@ -31,7 +31,6 @@ import { Link, Outlet, createFileRoute, useNavigate, useRouterState } from "@tan
import {
BarChart3,
BookOpen,
CalendarDays,
ChevronsUpDown,
Copy,
CreditCard,
@@ -51,6 +50,7 @@ import {
Shield,
Sparkles,
Sun,
Trophy,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -72,6 +72,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 +82,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 },
];

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,5 @@
import { OemSuggestionsSection } from "@/components/catalog/oem-suggestions-section";
import { OemVoteCard } from "@/components/catalog/oem-vote-card";
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { Badge, Button, Input, Skeleton } from "@sase/ui";
@@ -193,6 +195,9 @@ function OemDetailPage() {
</div>
</header>
{/* ─── Topluluk uyumluluk oyu (P eşleşmesinden bağımsız) ───────────── */}
<OemVoteCard oemCode={code} />
{/* ─── Loading ────────────────────────────────────────────────────── */}
{isLoading && (
<div className="space-y-4">
@@ -221,7 +226,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 +345,9 @@ function OemDetailPage() {
</div>
)}
{/* ─── Topluluk muadil önerileri (eşleşme olmasa da toplanır) ──────── */}
{!isLoading && <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,135 @@
import { LeaderboardPodium } from "@/components/gamification/leaderboard-podium";
import {
type LeaderboardRankingItem,
LeaderboardRankings,
} from "@/components/gamification/leaderboard-rankings";
import { PointsBadge } from "@/components/gamification/points-badge";
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 };
}
export const Route = createFileRoute("/dashboard/uzmanlar")({
component: ExpertsPage,
});
const SKELETON_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5"] as const;
function ExpertsPage() {
const { data, isLoading } = useQuery({
queryKey: ["oem-leaderboard"],
queryFn: () => api.get<LeaderboardResponse>("/oem-votes/leaderboard"),
});
useEffect(() => {
capture("experts_leaderboard_viewed");
}, []);
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>
{isLoading ? (
<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
</p>
</div>
);
}