diff --git a/apps/api/drizzle/0015_oem_votes.sql b/apps/api/drizzle/0015_oem_votes.sql deleted file mode 100644 index 4c875c7..0000000 --- a/apps/api/drizzle/0015_oem_votes.sql +++ /dev/null @@ -1,37 +0,0 @@ --- 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"); diff --git a/apps/api/drizzle/0016_oem_suggestions.sql b/apps/api/drizzle/0016_oem_suggestions.sql deleted file mode 100644 index a240357..0000000 --- a/apps/api/drizzle/0016_oem_suggestions.sql +++ /dev/null @@ -1,18 +0,0 @@ --- 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"); diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index eaf72ec..1e0c38c 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -106,20 +106,6 @@ "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 } ] } \ No newline at end of file diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d8d2917..e25ba89 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -34,8 +34,6 @@ 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"; @@ -95,8 +93,6 @@ import { VehiclesModule } from "./vehicles/vehicles.module"; TranslationsModule, AdminModule, AnalyticsModule, - OemVotesModule, - OemSuggestionsModule, CatalogModule, ChangelogModule, ChatwootModule, diff --git a/apps/api/src/categories/categories.service.spec.ts b/apps/api/src/categories/categories.service.spec.ts index 71eebd7..ec03ae2 100644 --- a/apps/api/src/categories/categories.service.spec.ts +++ b/apps/api/src/categories/categories.service.spec.ts @@ -320,84 +320,6 @@ 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 = {}; - 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 = {}; - 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", () => { diff --git a/apps/api/src/categories/categories.service.ts b/apps/api/src/categories/categories.service.ts index 45afa64..2df9041 100644 --- a/apps/api/src/categories/categories.service.ts +++ b/apps/api/src/categories/categories.service.ts @@ -120,10 +120,6 @@ 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 @@ -185,10 +181,6 @@ 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, })); @@ -1131,39 +1123,6 @@ 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 diff --git a/apps/api/src/database/schema/core.ts b/apps/api/src/database/schema/core.ts index c344bd0..48117a2 100644 --- a/apps/api/src/database/schema/core.ts +++ b/apps/api/src/database/schema/core.ts @@ -655,90 +655,3 @@ 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 -// ", 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), - ], -); diff --git a/apps/api/src/oem-suggestions/oem-suggestions.controller.ts b/apps/api/src/oem-suggestions/oem-suggestions.controller.ts deleted file mode 100644 index ca2fead..0000000 --- a/apps/api/src/oem-suggestions/oem-suggestions.controller.ts +++ /dev/null @@ -1,30 +0,0 @@ -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) }; - } -} diff --git a/apps/api/src/oem-suggestions/oem-suggestions.dto.ts b/apps/api/src/oem-suggestions/oem-suggestions.dto.ts deleted file mode 100644 index e8c9d73..0000000 --- a/apps/api/src/oem-suggestions/oem-suggestions.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -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; diff --git a/apps/api/src/oem-suggestions/oem-suggestions.logic.spec.ts b/apps/api/src/oem-suggestions/oem-suggestions.logic.spec.ts deleted file mode 100644 index 5704d53..0000000 --- a/apps/api/src/oem-suggestions/oem-suggestions.logic.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -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); - }); -}); diff --git a/apps/api/src/oem-suggestions/oem-suggestions.logic.ts b/apps/api/src/oem-suggestions/oem-suggestions.logic.ts deleted file mode 100644 index 782f68c..0000000 --- a/apps/api/src/oem-suggestions/oem-suggestions.logic.ts +++ /dev/null @@ -1,45 +0,0 @@ -// 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(); - 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); -} diff --git a/apps/api/src/oem-suggestions/oem-suggestions.module.ts b/apps/api/src/oem-suggestions/oem-suggestions.module.ts deleted file mode 100644 index 47ce35e..0000000 --- a/apps/api/src/oem-suggestions/oem-suggestions.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 {} diff --git a/apps/api/src/oem-suggestions/oem-suggestions.service.ts b/apps/api/src/oem-suggestions/oem-suggestions.service.ts deleted file mode 100644 index 56645cd..0000000 --- a/apps/api/src/oem-suggestions/oem-suggestions.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 { - 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 { - 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); - } -} diff --git a/apps/api/src/oem-votes/oem-votes.controller.ts b/apps/api/src/oem-votes/oem-votes.controller.ts deleted file mode 100644 index ef4e57d..0000000 --- a/apps/api/src/oem-votes/oem-votes.controller.ts +++ /dev/null @@ -1,36 +0,0 @@ -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); - } -} diff --git a/apps/api/src/oem-votes/oem-votes.dto.ts b/apps/api/src/oem-votes/oem-votes.dto.ts deleted file mode 100644 index b97c142..0000000 --- a/apps/api/src/oem-votes/oem-votes.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -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; - -export const lookupOemVotesSchema = z.object({ - codes: z.array(z.string().trim().min(1).max(100)).min(1).max(400), -}); -export type LookupOemVotesInput = z.infer; diff --git a/apps/api/src/oem-votes/oem-votes.logic.spec.ts b/apps/api/src/oem-votes/oem-votes.logic.spec.ts deleted file mode 100644 index 11a4208..0000000 --- a/apps/api/src/oem-votes/oem-votes.logic.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -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"); - }); -}); diff --git a/apps/api/src/oem-votes/oem-votes.logic.ts b/apps/api/src/oem-votes/oem-votes.logic.ts deleted file mode 100644 index 28ad2a6..0000000 --- a/apps/api/src/oem-votes/oem-votes.logic.ts +++ /dev/null @@ -1,37 +0,0 @@ -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])}`; -} diff --git a/apps/api/src/oem-votes/oem-votes.module.ts b/apps/api/src/oem-votes/oem-votes.module.ts deleted file mode 100644 index 7b40646..0000000 --- a/apps/api/src/oem-votes/oem-votes.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 {} diff --git a/apps/api/src/oem-votes/oem-votes.service.ts b/apps/api/src/oem-votes/oem-votes.service.ts deleted file mode 100644 index e775f29..0000000 --- a/apps/api/src/oem-votes/oem-votes.service.ts +++ /dev/null @@ -1,200 +0,0 @@ -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[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 { - 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> { - 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 = {}; - 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`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 { - 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; - } -} diff --git a/apps/web/src/components/catalog/__tests__/oem-suggestions-section.test.tsx b/apps/web/src/components/catalog/__tests__/oem-suggestions-section.test.tsx deleted file mode 100644 index 8b9158c..0000000 --- a/apps/web/src/components/catalog/__tests__/oem-suggestions-section.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -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 } }) => ( - {children} - ), -})); - -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(); - - 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(); - 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(); - }); - }); -}); diff --git a/apps/web/src/components/catalog/__tests__/oem-vote-card.test.tsx b/apps/web/src/components/catalog/__tests__/oem-vote-card.test.tsx deleted file mode 100644 index 929e526..0000000 --- a/apps/web/src/components/catalog/__tests__/oem-vote-card.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -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 }) => ( - {children} - ), -})); - -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(); - - 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(); - - 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"); - }); -}); diff --git a/apps/web/src/components/catalog/oem-suggestions-section.tsx b/apps/web/src/components/catalog/oem-suggestions-section.tsx deleted file mode 100644 index 3f84d4c..0000000 --- a/apps/web/src/components/catalog/oem-suggestions-section.tsx +++ /dev/null @@ -1,158 +0,0 @@ -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(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 ( -
-
-
-

Topluluk muadil önerileri

-

- Bu kodun muadilini biliyorsanız marka + parça kodu olarak ekleyin. -

-
- -
- - {formOpen && ( -
- setBrand(e.target.value)} - placeholder="Marka (ör. Bosch)" - required - minLength={2} - maxLength={80} - className="sm:max-w-48" - /> - setCode(e.target.value)} - placeholder="Parça kodu" - required - minLength={2} - maxLength={100} - className="font-mono" - /> - -
- )} - - {suggestions === null ? ( - - ) : suggestions.length === 0 ? ( -

- Henüz öneri yok — muadilini biliyorsanız ilk öneriyi siz ekleyin. -

- ) : ( -
    - {suggestions.map((s) => ( -
  • - - {s.brand}{" "} - - {s.code} - - {s.mine && ( - - sizin öneriniz - - )} - - - - {s.count} - -
  • - ))} -
- )} -
- ); -} diff --git a/apps/web/src/components/catalog/oem-vote-card.tsx b/apps/web/src/components/catalog/oem-vote-card.tsx deleted file mode 100644 index 65d33b3..0000000 --- a/apps/web/src/components/catalog/oem-vote-card.tsx +++ /dev/null @@ -1,142 +0,0 @@ -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(null); - const [pending, setPending] = useState(false); - - useEffect(() => { - let cancelled = false; - setSummary(null); - api - .post<{ votes: Record }>("/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("/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 ( -
-
-
-

Topluluk uyumluluk oyu

-

- Bu OEM kodu sizce doğru mu? Oyunuz diğer parça satıcılarına yol gösterir. -

-
-
- {options.map(({ vote, icon: Icon, label, count, activeClass }) => { - const isActive = summary?.myVote === vote; - return ( - - ); - })} -
-
-

- Her oy puan kazandırır ·{" "} - - Parça Uzmanları sıralaması - -

-
- ); -} diff --git a/apps/web/src/components/gamification/leaderboard-podium.tsx b/apps/web/src/components/gamification/leaderboard-podium.tsx deleted file mode 100644 index bf65323..0000000 --- a/apps/web/src/components/gamification/leaderboard-podium.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// 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 ( -
    - {podiumOrder.map((ranking) => { - const config = PODIUM_CONFIG[ranking.rank as 1 | 2 | 3]; - if (!config) return null; - const displayName = ranking.userName || "Üye"; - - return ( -
  • - - - - {displayName} - - - {showValue && ( - - {ranking.value.toLocaleString("tr-TR")} - - )} - - -
  • - ); - })} -
- ); -} diff --git a/apps/web/src/components/gamification/leaderboard-rankings.tsx b/apps/web/src/components/gamification/leaderboard-rankings.tsx deleted file mode 100644 index 79ee3bb..0000000 --- a/apps/web/src/components/gamification/leaderboard-rankings.tsx +++ /dev/null @@ -1,166 +0,0 @@ -// 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 ( -
-
    - {pagedRankings.map((ranking) => { - const displayName = ranking.userName || "Üye"; - const showCrown = ranking.rank <= 3; - const crownColor = crownColorMap[ranking.rank as 1 | 2 | 3]; - - return ( -
  • -
    - {ranking.rank} - {showCrown ? ( -
    - -
    - {displayName.charAt(0).toLocaleUpperCase("tr-TR")} -
    - -
    -

    - {displayName} - {ranking.isCurrentUser && ( - (Siz) - )} -

    - {ranking.byline ? ( -

    {ranking.byline}

    - ) : null} -
    - -

    - {formatLeaderboardValue(ranking.value)} -

    -
  • - ); - })} -
- - {showPagination ? ( -
-
- - -
- -
- - - - Sayfa {currentPage} / {totalPages} - - -
-
- ) : null} -
- ); -} diff --git a/apps/web/src/components/gamification/points-badge.tsx b/apps/web/src/components/gamification/points-badge.tsx deleted file mode 100644 index 56b954d..0000000 --- a/apps/web/src/components/gamification/points-badge.tsx +++ /dev/null @@ -1,44 +0,0 @@ -// 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 ( - -
- - {displayValue} -
- {name} -
- ); -} diff --git a/apps/web/src/components/schema/__tests__/parts-panel.test.tsx b/apps/web/src/components/schema/__tests__/parts-panel.test.tsx index dc14c3c..405f253 100644 --- a/apps/web/src/components/schema/__tests__/parts-panel.test.tsx +++ b/apps/web/src/components/schema/__tests__/parts-panel.test.tsx @@ -27,7 +27,6 @@ vi.mock("@/stores/schema.store", () => ({ }), })); -import { api } from "@/lib/api-client"; import { PartsPanel } from "../parts-panel"; const buildPart = (overrides: Partial = {}): Part => ({ @@ -50,7 +49,6 @@ beforeEach(() => { captureMock.mockClear(); setSelectedGroupMock.mockClear(); setHighlightedGroupMock.mockClear(); - vi.mocked(api.post).mockReset().mockResolvedValue({}); }); afterEach(() => { @@ -150,25 +148,4 @@ 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(); - - 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(); - - 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"]); - }); }); diff --git a/apps/web/src/components/schema/parts-panel.tsx b/apps/web/src/components/schema/parts-panel.tsx index 0ea6073..1a2fabd 100644 --- a/apps/web/src/components/schema/parts-panel.tsx +++ b/apps/web/src/components/schema/parts-panel.tsx @@ -37,6 +37,34 @@ export function PartsPanel({ const viewedKeyRef = useRef(null); const [copiedId, setCopiedId] = useState(null); const [resolvingCode, setResolvingCode] = useState(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>(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) → @@ -237,6 +265,7 @@ export function PartsPanel({ # Parça Adı OEM Kodu + Adet Pozisyon {hasPrices && Fiyat} @@ -261,7 +290,7 @@ export function PartsPanel({ return ( {part.hotspotIndex} - + {label && {label}} {refs.map((ref) => { @@ -364,13 +393,14 @@ export function PartsPanel({ )} )} - {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. + {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.quantity} {part.position} {hasPrices && ( diff --git a/apps/web/src/components/settings/changelog-tab.tsx b/apps/web/src/components/settings/changelog-tab.tsx new file mode 100644 index 0000000..aab57d2 --- /dev/null +++ b/apps/web/src/components/settings/changelog-tab.tsx @@ -0,0 +1,118 @@ +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 ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ + +
+ +
+
+ ))} +
+ ); +} + +function ChangelogEmpty({ t }: { t: (key: string) => string }) { + return ( + + + + {t("settings.changelog.emptyTitle")} + {t("settings.changelog.emptyDescription")} + + + ); +} + +export function ChangelogTab() { + const { data: entries, isLoading } = useChangelog(); + const { t, locale } = useTranslation(); + + if (isLoading) { + return ; + } + + if (!entries || entries.length === 0) { + return ; + } + + return ( + + + {t("settings.changelog.title")} + {t("settings.changelog.description")} + + + + {entries.map((entry) => ( + +
+ +
+
+ + {changeTypeLabel(entry.changeType, t)} + + {entry.title} +
+ + {formatDate(entry.publishedAt, locale)} + +
+
+ +
+ {entry.description} +
+
+ + ))} + + + + ); +} diff --git a/apps/web/src/hooks/use-changelog.ts b/apps/web/src/hooks/use-changelog.ts new file mode 100644 index 0000000..709bb68 --- /dev/null +++ b/apps/web/src/hooks/use-changelog.ts @@ -0,0 +1,11 @@ +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("/changelog"), + staleTime: 30 * 60 * 1000, // 30 min — matches Redis cache TTL + }); +} diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 497c466..abe28b3 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -74,7 +74,6 @@ "dashboard": "Dashboard", "search": "Search", "history": "History", - "experts": "Parts Experts", "catalog": "Catalog", "subscription": "Subscription", "billing": "Payment", @@ -83,6 +82,7 @@ "logout": "Log Out", "contact": "Contact", "blog": "Blog", + "changelog": "What's New", "account": "Account", "sectionMain": "Main Menu", "sectionAccount": "Account", @@ -522,6 +522,7 @@ "connections": "Connections", "referral": "Referral", "account": "Account", + "changelog": "Changelog", "notifications": "Notifications" }, "preferences": { @@ -592,6 +593,17 @@ "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." diff --git a/apps/web/src/messages/tr.json b/apps/web/src/messages/tr.json index 4f98a02..e5fa89a 100644 --- a/apps/web/src/messages/tr.json +++ b/apps/web/src/messages/tr.json @@ -74,7 +74,6 @@ "dashboard": "Gösterge Paneli", "search": "Arama", "history": "Geçmiş", - "experts": "Parça Uzmanları", "catalog": "Katalog", "subscription": "Abonelik", "billing": "Ödeme", @@ -83,6 +82,7 @@ "logout": "Çıkış Yap", "contact": "İletişim", "blog": "Blog", + "changelog": "Yenilikler", "account": "Hesap", "sectionMain": "Ana Menü", "sectionAccount": "Hesap", @@ -522,6 +522,7 @@ "connections": "Bağlantılar", "referral": "Referans", "account": "Hesap", + "changelog": "Değişiklik Günlüğü", "notifications": "Bildirimler" }, "preferences": { @@ -592,6 +593,17 @@ "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." diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index eea79a4..ec1220c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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,11 +117,6 @@ 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', @@ -147,6 +142,11 @@ 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,13 +712,6 @@ 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' @@ -754,6 +747,13 @@ 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, diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx index ffdc118..26f26cc 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/dashboard.tsx @@ -31,6 +31,7 @@ import { Link, Outlet, createFileRoute, useNavigate, useRouterState } from "@tan import { BarChart3, BookOpen, + CalendarDays, ChevronsUpDown, Copy, CreditCard, @@ -50,7 +51,6 @@ import { Shield, Sparkles, Sun, - Trophy, Users, } from "lucide-react"; import { useEffect, useState } from "react"; @@ -72,7 +72,6 @@ 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[] = [ @@ -82,6 +81,7 @@ 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 }, ]; diff --git a/apps/web/src/routes/dashboard/changelog.tsx b/apps/web/src/routes/dashboard/changelog.tsx new file mode 100644 index 0000000..5a2a22b --- /dev/null +++ b/apps/web/src/routes/dashboard/changelog.tsx @@ -0,0 +1,14 @@ +import { ChangelogTab } from "@/components/settings/changelog-tab"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/dashboard/changelog")({ + component: ChangelogPage, +}); + +function ChangelogPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/routes/dashboard/oem.$code.tsx b/apps/web/src/routes/dashboard/oem.$code.tsx index 458e5fa..5360db7 100644 --- a/apps/web/src/routes/dashboard/oem.$code.tsx +++ b/apps/web/src/routes/dashboard/oem.$code.tsx @@ -1,5 +1,3 @@ -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"; @@ -195,9 +193,6 @@ function OemDetailPage() {
- {/* ─── Topluluk uyumluluk oyu (P eşleşmesinden bağımsız) ───────────── */} - - {/* ─── Loading ────────────────────────────────────────────────────── */} {isLoading && (
@@ -226,8 +221,7 @@ function OemDetailPage() {

Bu OEM kodu için uyumlu parça bulunamadı

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. Muadilini biliyorsanız aşağıdan - önerin — diğer parça satıcılarına yol gösterir. + olmayabilir. Katalog büyüdükçe eşleşme oranı artar.

)} @@ -345,9 +339,6 @@ function OemDetailPage() {
)} - {/* ─── Topluluk muadil önerileri (eşleşme olmasa da toplanır) ──────── */} - {!isLoading && } - {/* ─── Reverse catalog: your vehicles that use this code ───────────── */} {catalogVehicles && catalogVehicles.length > 0 && (
diff --git a/apps/web/src/routes/dashboard/uzmanlar.tsx b/apps/web/src/routes/dashboard/uzmanlar.tsx deleted file mode 100644 index 238d0c1..0000000 --- a/apps/web/src/routes/dashboard/uzmanlar.tsx +++ /dev/null @@ -1,135 +0,0 @@ -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("/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 ( -
-
-

- - Parça Uzmanları -

-

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

-
- - {isLoading ? ( -
- {SKELETON_KEYS.map((k) => ( - - ))} -
- ) : entries.length === 0 ? ( - - - -

Sıralama henüz boş.

-

- İlk uzman siz olun: katalogdan bir OEM kodunu değerlendirin, açılış puanlarını siz - kapın. -

- - Şase sorgula ve oylamaya başla - -
-
- ) : ( - <> - {/* Kendi durumum */} - {me && ( -
- - (v > 0 ? `#${v}` : "—")} - /> - -
- )} - - {/* İlk 3 kürsüsü */} - {podiumRankings.length > 0 && ( - - - - - - )} - - {/* Tam sıralama */} - 25} /> - - )} - - {/* Puan kuralları yalnız burada yaşar — küçük punto (computeVoteAward ile aynı kural). */} -

- Oy ver +1 · çoğunluğu tuttur{" "} - +2 bonus · kodu ilk - değerlendiren 3 puanı kapar -

-
- ); -}