feat(catalog): OEM oyları detay sayfasına taşındı + topluluk muadil önerileri
Parça tablosu sadeleşti: Uyum ve Adet kolonları kalktı, liste açılışındaki toplu istekler (/oem-votes/lookup ve /p/matched) tamamen kaldırıldı. Her OEM kodu artık koşulsuz /dashboard/oem/$code'a linklenir — P eşleşmesi olmayan kodda da sayfa dolu: topluluk oyu kartı, muadil önerileri ve ters katalog. OEM detay sayfası: OemVoteCard (uyumlu/uyumsuz, sayaçlar, puan toast'ı, puanlama özeti + Parça Uzmanları linki) ve OemSuggestionsSection — eşleşme bulunamayan kodlar için kullanıcıdan marka + parça kodu önerisi toplar. Öneriler oem_suggestions tablosunda (kullanıcı+kod+normalize öneri başına tek satır, ON CONFLICT yutulur), markaya+normalize koda göre gruplanıp "× N kullanıcı" rozetiyle listelenir; önerilen kod kendi detayına linklenir. Şimdilik öneri puan kazandırmaz; status kolonu moderasyon kancası. API: oem-suggestions modülü (POST 10/dk throttle, GET ?code=), migration 0016_oem_suggestions. Eski tablo-içi OemVoteButtons bileşeni silindi. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
18
apps/api/drizzle/0016_oem_suggestions.sql
Normal file
18
apps/api/drizzle/0016_oem_suggestions.sql
Normal 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");
|
||||
@@ -113,6 +113,13 @@
|
||||
"when": 1781222400000,
|
||||
"tag": "0015_oem_votes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1781308800000,
|
||||
"tag": "0016_oem_suggestions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,6 +34,7 @@ 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";
|
||||
@@ -95,6 +96,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
AdminModule,
|
||||
AnalyticsModule,
|
||||
OemVotesModule,
|
||||
OemSuggestionsModule,
|
||||
CatalogModule,
|
||||
ChangelogModule,
|
||||
ChatwootModule,
|
||||
|
||||
@@ -711,3 +711,34 @@ export const oemVotePoints = pgTable(
|
||||
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),
|
||||
],
|
||||
);
|
||||
|
||||
30
apps/api/src/oem-suggestions/oem-suggestions.controller.ts
Normal file
30
apps/api/src/oem-suggestions/oem-suggestions.controller.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
13
apps/api/src/oem-suggestions/oem-suggestions.dto.ts
Normal file
13
apps/api/src/oem-suggestions/oem-suggestions.dto.ts
Normal 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>;
|
||||
45
apps/api/src/oem-suggestions/oem-suggestions.logic.spec.ts
Normal file
45
apps/api/src/oem-suggestions/oem-suggestions.logic.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
apps/api/src/oem-suggestions/oem-suggestions.logic.ts
Normal file
45
apps/api/src/oem-suggestions/oem-suggestions.logic.ts
Normal 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);
|
||||
}
|
||||
10
apps/api/src/oem-suggestions/oem-suggestions.module.ts
Normal file
10
apps/api/src/oem-suggestions/oem-suggestions.module.ts
Normal 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 {}
|
||||
60
apps/api/src/oem-suggestions/oem-suggestions.service.ts
Normal file
60
apps/api/src/oem-suggestions/oem-suggestions.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
100
apps/web/src/components/catalog/__tests__/oem-vote-card.test.tsx
Normal file
100
apps/web/src/components/catalog/__tests__/oem-vote-card.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
158
apps/web/src/components/catalog/oem-suggestions-section.tsx
Normal file
158
apps/web/src/components/catalog/oem-suggestions-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { cn } from "@sase/ui";
|
||||
import { ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
|
||||
export type OemVoteChoice = "compatible" | "incompatible";
|
||||
|
||||
export interface OemVoteSummary {
|
||||
compatible: number;
|
||||
incompatible: number;
|
||||
myVote: OemVoteChoice | null;
|
||||
}
|
||||
|
||||
export interface CastOemVoteResult {
|
||||
oemCode: string;
|
||||
myVote: OemVoteChoice;
|
||||
counts: { compatible: number; incompatible: number };
|
||||
pointsAwarded: number;
|
||||
correct: boolean | null;
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
interface OemVoteButtonsProps {
|
||||
summary?: OemVoteSummary;
|
||||
pending?: boolean;
|
||||
onVote: (vote: OemVoteChoice) => void;
|
||||
}
|
||||
|
||||
// Parça satırındaki topluluk oyu ikilisi: uyumlu (👍) / uyumsuz (👎).
|
||||
// Satır tıklaması hotspot grubunu seçtiği için tıklamalar satıra taşmaz.
|
||||
export function OemVoteButtons({ summary, pending, onVote }: OemVoteButtonsProps) {
|
||||
const myVote = summary?.myVote ?? null;
|
||||
const options = [
|
||||
{
|
||||
vote: "compatible" as const,
|
||||
icon: ThumbsUp,
|
||||
count: summary?.compatible ?? 0,
|
||||
title: "Uyumlu — bu OEM kodu doğru",
|
||||
activeClass: "text-green-500",
|
||||
},
|
||||
{
|
||||
vote: "incompatible" as const,
|
||||
icon: ThumbsDown,
|
||||
count: summary?.incompatible ?? 0,
|
||||
title: "Uyumsuz — bu OEM kodu hatalı",
|
||||
activeClass: "text-red-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{options.map(({ vote, icon: Icon, count, title, activeClass }) => {
|
||||
const isActive = myVote === vote;
|
||||
return (
|
||||
<button
|
||||
key={vote}
|
||||
type="button"
|
||||
disabled={pending}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-pressed={isActive}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVote(vote);
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1.5 py-1 text-xs transition-colors disabled:opacity-50",
|
||||
isActive
|
||||
? cn(activeClass, "font-semibold")
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" fill={isActive ? "currentColor" : "none"} />
|
||||
<span className="tabular-nums">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
142
apps/web/src/components/catalog/oem-vote-card.tsx
Normal file
142
apps/web/src/components/catalog/oem-vote-card.tsx
Normal 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">
|
||||
Oy +1 puan, çoğunlukla aynı görüş +2 puan, ilk oy 3 puan ·{" "}
|
||||
<Link to="/dashboard/uzmanlar" className="font-medium text-primary hover:underline">
|
||||
Parça Uzmanları sıralaması
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Part } from "@/hooks/use-parts";
|
||||
@@ -6,7 +6,6 @@ import type { Part } from "@/hooks/use-parts";
|
||||
const captureMock = vi.fn();
|
||||
const setSelectedGroupMock = vi.fn();
|
||||
const setHighlightedGroupMock = vi.fn();
|
||||
const toastSuccessMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capture: (...args: unknown[]) => captureMock(...args),
|
||||
@@ -19,15 +18,6 @@ vi.mock("@/lib/api-client", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/schema.store", () => ({
|
||||
useSchemaStore: () => ({
|
||||
highlightedGroup: null,
|
||||
@@ -60,7 +50,6 @@ beforeEach(() => {
|
||||
captureMock.mockClear();
|
||||
setSelectedGroupMock.mockClear();
|
||||
setHighlightedGroupMock.mockClear();
|
||||
toastSuccessMock.mockClear();
|
||||
vi.mocked(api.post).mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
@@ -162,45 +151,24 @@ describe("PartsPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("casts an oem vote from the Uyum column without selecting the row", async () => {
|
||||
it("links every OEM code to the detail page without a /p/matched gate", () => {
|
||||
const postMock = vi.mocked(api.post);
|
||||
postMock.mockImplementation((path: string) => {
|
||||
if (path === "/oem-votes") {
|
||||
return Promise.resolve({
|
||||
oemCode: "OEM-1",
|
||||
myVote: "compatible",
|
||||
counts: { compatible: 1, incompatible: 0 },
|
||||
pointsAwarded: 3,
|
||||
correct: true,
|
||||
isNew: true,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
render(<PartsPanel parts={[buildPart()]} vehicleId="v1" categoryId="c1" />);
|
||||
|
||||
const upButton = screen.getByRole("button", { name: "Uyumlu — bu OEM kodu doğru" });
|
||||
fireEvent.click(upButton);
|
||||
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();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
"/oem-votes",
|
||||
expect.objectContaining({ oemCode: "OEM-1", vote: "compatible", vehicleId: "v1" }),
|
||||
);
|
||||
});
|
||||
// stopPropagation: oy tıklaması satır (hotspot grubu) seçimini tetiklememeli
|
||||
expect(setSelectedGroupMock).not.toHaveBeenCalled();
|
||||
it("does not render Uyum or Adet columns", () => {
|
||||
render(<PartsPanel parts={[buildPart({ quantity: 4 })]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith("+3 puan kazandınız!", expect.anything());
|
||||
expect(captureMock).toHaveBeenCalledWith(
|
||||
"oem_vote_cast",
|
||||
expect.objectContaining({ oem_code: "OEM-1", vote: "compatible", points_awarded: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
// cevap sayaçları butona yansır (1 uyumlu)
|
||||
expect(upButton.textContent).toContain("1");
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import {
|
||||
type CastOemVoteResult,
|
||||
OemVoteButtons,
|
||||
type OemVoteChoice,
|
||||
type OemVoteSummary,
|
||||
} from "@/components/catalog/oem-vote-buttons";
|
||||
import { ReportCatalogIssueButton } from "@/components/catalog/report-catalog-issue";
|
||||
import type { Part } from "@/hooks/use-parts";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
@@ -44,107 +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]);
|
||||
|
||||
// Topluluk uyumluluk oyları: listedeki kodların özetini tek toplu istekle
|
||||
// çek (lookup dto sınırı 400 kod). Sınır dışında kalan nadir kodlar 0
|
||||
// sayaçla başlar; oy verilince cast cevabı gerçek sayıları getirir.
|
||||
const [voteSummaries, setVoteSummaries] = useState<Record<string, OemVoteSummary>>({});
|
||||
const [votePendingCode, setVotePendingCode] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (oemCodes.length === 0) {
|
||||
setVoteSummaries({});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api
|
||||
.post<{ votes: Record<string, OemVoteSummary> }>("/oem-votes/lookup", {
|
||||
codes: oemCodes.slice(0, 400),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled) setVoteSummaries(res?.votes ?? {});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setVoteSummaries({});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [oemCodes]);
|
||||
|
||||
const castVote = useCallback(
|
||||
async (part: Part, vote: OemVoteChoice) => {
|
||||
setVotePendingCode(part.oemCode);
|
||||
try {
|
||||
const result = await api.post<CastOemVoteResult>("/oem-votes", {
|
||||
oemCode: part.oemCode,
|
||||
vote,
|
||||
partId: part.id,
|
||||
vehicleId,
|
||||
categoryId,
|
||||
});
|
||||
setVoteSummaries((prev) => ({
|
||||
...prev,
|
||||
[result.oemCode]: { ...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: part.oemCode,
|
||||
vote,
|
||||
points_awarded: result.pointsAwarded,
|
||||
correct: result.correct,
|
||||
is_new: result.isNew,
|
||||
part_id: part.id,
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Oy kaydedilemedi", { description: "Lütfen tekrar deneyin." });
|
||||
} finally {
|
||||
setVotePendingCode(null);
|
||||
}
|
||||
},
|
||||
[vehicleId, categoryId],
|
||||
);
|
||||
|
||||
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the
|
||||
// target illustration directly. Unresolved (target branch not seeded yet) →
|
||||
@@ -345,8 +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-24 text-center">Uyum</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>
|
||||
@@ -371,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 ? 6 : 5}>
|
||||
<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) => {
|
||||
@@ -474,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"
|
||||
@@ -509,16 +398,6 @@ export function PartsPanel({
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{part.oemCode && part.oemCode !== "N/A" && (
|
||||
<OemVoteButtons
|
||||
summary={voteSummaries[part.oemCode]}
|
||||
pending={votePendingCode === part.oemCode}
|
||||
onVote={(vote) => castVote(part, vote)}
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user