Reapply "Merge pull request 'dev' (#130) from dev into main"
This reverts commit 5c7803e12a.
This commit is contained in:
37
apps/api/drizzle/0015_oem_votes.sql
Normal file
37
apps/api/drizzle/0015_oem_votes.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- OEM community voting + gamification ledger.
|
||||||
|
-- oem_votes: one row per (user, oem_code) uyumlu/uyumsuz verdict cast from the
|
||||||
|
-- catalog part rows. Votes pool globally per code; part/vehicle/category are
|
||||||
|
-- analytics breadcrumbs only (catalog routes pass catalog_vehicles ids → no FK,
|
||||||
|
-- mirrors oem_code_copies). Re-votes update the row in place.
|
||||||
|
-- oem_vote_points: append-only, exactly one row per vote (vote_id unique),
|
||||||
|
-- written in the same transaction. points = 1 (vote) + 2 (agreed with strict
|
||||||
|
-- majority at cast time; first voter always 3). Never retro-adjusted.
|
||||||
|
CREATE TABLE "oem_votes" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"oem_code" varchar(100) NOT NULL,
|
||||||
|
"vote" varchar(12) NOT NULL,
|
||||||
|
"part_id" uuid,
|
||||||
|
"vehicle_id" uuid,
|
||||||
|
"category_id" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "oem_vote_points" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"vote_id" uuid NOT NULL,
|
||||||
|
"oem_code" varchar(100) NOT NULL,
|
||||||
|
"points" integer NOT NULL,
|
||||||
|
"correct" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_votes" ADD CONSTRAINT "oem_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_vote_id_oem_votes_id_fk" FOREIGN KEY ("vote_id") REFERENCES "public"."oem_votes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "oem_votes_user_oem_idx" ON "oem_votes" USING btree ("user_id","oem_code");--> statement-breakpoint
|
||||||
|
CREATE INDEX "oem_votes_oem_code_idx" ON "oem_votes" USING btree ("oem_code");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "oem_vote_points_vote_id_idx" ON "oem_vote_points" USING btree ("vote_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "oem_vote_points_user_id_idx" ON "oem_vote_points" USING btree ("user_id");
|
||||||
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");
|
||||||
@@ -106,6 +106,20 @@
|
|||||||
"when": 1781136000000,
|
"when": 1781136000000,
|
||||||
"tag": "0014_proxy_logs",
|
"tag": "0014_proxy_logs",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,8 @@ import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
|||||||
import { JobsModule } from "./jobs/jobs.module";
|
import { JobsModule } from "./jobs/jobs.module";
|
||||||
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
||||||
import { NotificationsModule } from "./notifications/notifications.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 { PartsModule } from "./parts/parts.module";
|
||||||
import { PaymentsModule } from "./payments/payments.module";
|
import { PaymentsModule } from "./payments/payments.module";
|
||||||
import { PlansModule } from "./plans/plans.module";
|
import { PlansModule } from "./plans/plans.module";
|
||||||
@@ -93,6 +95,8 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
|||||||
TranslationsModule,
|
TranslationsModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
|
OemVotesModule,
|
||||||
|
OemSuggestionsModule,
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
ChangelogModule,
|
ChangelogModule,
|
||||||
ChatwootModule,
|
ChatwootModule,
|
||||||
|
|||||||
@@ -320,6 +320,84 @@ describe("CategoriesService", () => {
|
|||||||
"drill-load-error",
|
"drill-load-error",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes a P4 Ford VIN node (json-vin-sub-group.action, positional link_wid) to children, not an empty leaf", async () => {
|
||||||
|
// link_wid "1" is a positional code (not a *Group* table) so the link_wid
|
||||||
|
// group-drill branch misses it; the path is …group.action (not /psa/, not
|
||||||
|
// vin-group.action) so the PSA / Volvo branches miss it too. Before the
|
||||||
|
// dedicated gate these fell through to the leaf parts path → silent empty
|
||||||
|
// panel (Ford/Nissan/Opel analog of the PSA-parent-gate gap).
|
||||||
|
const category = {
|
||||||
|
id: "ford1",
|
||||||
|
name: "elektrik sistemi",
|
||||||
|
nameOriginal: "elektrik sistemi",
|
||||||
|
parentId: null,
|
||||||
|
vehicleId: "v1",
|
||||||
|
source: "pl24",
|
||||||
|
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=CAP1&vin=WF0FXX",
|
||||||
|
linkWid: "1",
|
||||||
|
hasSubgroups: null,
|
||||||
|
hasParts: null,
|
||||||
|
};
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
|
||||||
|
c.limit = vi.fn().mockReturnValue([category]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
execute: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
const discovered = [{ id: "sg1", name: "kablolar", children: undefined }];
|
||||||
|
vi.spyOn(service, "getChildren").mockResolvedValue(discovered as any);
|
||||||
|
|
||||||
|
const result = await service.getCategoryWithParts("ford1");
|
||||||
|
|
||||||
|
expect(service.getChildren).toHaveBeenCalledWith("ford1");
|
||||||
|
expect(result.parts).toEqual([]);
|
||||||
|
expect((result as { children?: unknown }).children).toEqual(discovered);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces loadError when a P4 group.action drill comes back empty (not a silent leaf)", async () => {
|
||||||
|
const category = {
|
||||||
|
id: "ford2",
|
||||||
|
name: "kaporta",
|
||||||
|
nameOriginal: "kaporta",
|
||||||
|
parentId: null,
|
||||||
|
vehicleId: "v1",
|
||||||
|
source: "pl24",
|
||||||
|
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=GDB&vin=WF0FXX",
|
||||||
|
linkWid: "2",
|
||||||
|
hasSubgroups: null,
|
||||||
|
hasParts: null,
|
||||||
|
};
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
|
||||||
|
c.limit = vi.fn().mockReturnValue([category]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
execute: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
vi.spyOn(service, "getChildren").mockResolvedValue([] as any);
|
||||||
|
|
||||||
|
const result = await service.getCategoryWithParts("ford2");
|
||||||
|
|
||||||
|
expect(service.getChildren).toHaveBeenCalledWith("ford2");
|
||||||
|
expect((result as { loadError?: boolean }).loadError).toBe(true);
|
||||||
|
expect((result as { children?: unknown }).children).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getAncestors", () => {
|
describe("getAncestors", () => {
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ export class CategoriesService {
|
|||||||
externalId: s.code,
|
externalId: s.code,
|
||||||
linkPath: s.linkPath || null,
|
linkPath: s.linkPath || null,
|
||||||
linkWid: null as string | 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,
|
source: "pl24" as const,
|
||||||
}));
|
}));
|
||||||
dbCategories = await this.db
|
dbCategories = await this.db
|
||||||
@@ -181,6 +185,10 @@ export class CategoriesService {
|
|||||||
externalId: c.code,
|
externalId: c.code,
|
||||||
linkPath: c.linkPath || null,
|
linkPath: c.linkPath || null,
|
||||||
linkWid: c.linkWid || 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,
|
source: "pl24" as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1123,6 +1131,39 @@ export class CategoriesService {
|
|||||||
// No subgroups → leaf; fall through to the parts path below.
|
// 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
|
// EMEX Vehicle.aspx group node (linkWid="emex-group") — a parent, never a
|
||||||
// parts leaf. Return its children (seeded sub-groups, or Unit leaves drilled
|
// parts leaf. Return its children (seeded sub-groups, or Unit leaves drilled
|
||||||
// on demand by getChildren). Mirrors the pl24 group-node branch above; an
|
// on demand by getChildren). Mirrors the pl24 group-node branch above; an
|
||||||
|
|||||||
@@ -655,3 +655,90 @@ export const proxyLogs = pgTable(
|
|||||||
index("proxy_logs_banned_created_at_idx").on(table.banned, table.createdAt),
|
index("proxy_logs_banned_created_at_idx").on(table.banned, table.createdAt),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── OEM Votes (community compatibility verdicts) ─────
|
||||||
|
// One row per (user, oemCode): a parts seller's uyumlu/uyumsuz verdict on an
|
||||||
|
// OEM code, cast from the catalog part rows. Votes pool globally per code —
|
||||||
|
// the part/vehicle/category columns are analytics breadcrumbs only (mirrors
|
||||||
|
// oem_code_copies: catalog routes pass catalog_vehicles ids here, so no FK).
|
||||||
|
// Re-voting updates `vote` in place; points are only ever granted on the
|
||||||
|
// first insert (see oem_vote_points).
|
||||||
|
export const oemVotes = pgTable(
|
||||||
|
"oem_votes",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||||
|
// compatible | incompatible
|
||||||
|
vote: varchar("vote", { length: 12 }).notNull(),
|
||||||
|
partId: uuid("part_id"),
|
||||||
|
vehicleId: uuid("vehicle_id"),
|
||||||
|
categoryId: uuid("category_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("oem_votes_user_oem_idx").on(table.userId, table.oemCode),
|
||||||
|
index("oem_votes_oem_code_idx").on(table.oemCode),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── OEM Vote Points (gamification ledger) ────────────
|
||||||
|
// Append-only: exactly one row per oem_votes row (vote_id unique), written in
|
||||||
|
// the same transaction as the vote insert. `points` = 1 for voting +2 when
|
||||||
|
// the vote agreed with the strict majority at cast time (first voter on a
|
||||||
|
// code always agrees with themselves → 3). Awards are never retro-adjusted
|
||||||
|
// when the majority later flips. Leaderboard = SUM(points) per user.
|
||||||
|
export const oemVotePoints = pgTable(
|
||||||
|
"oem_vote_points",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
voteId: uuid("vote_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => oemVotes.id, { onDelete: "cascade" }),
|
||||||
|
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||||
|
points: integer("points").notNull(),
|
||||||
|
correct: boolean("correct").default(false).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("oem_vote_points_vote_id_idx").on(table.voteId),
|
||||||
|
index("oem_vote_points_user_id_idx").on(table.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── OEM Suggestions (community cross-references) ─────
|
||||||
|
// Crowd-sourced equivalents for an OEM code: "this code's compatible part is
|
||||||
|
// <brand> <suggestedCode>", collected on the OEM detail page — primarily for
|
||||||
|
// codes the P snapshot can't match. suggestedCodeNorm (upper, alphanumerics
|
||||||
|
// only) powers grouping and the per-user dedup constraint; display keeps the
|
||||||
|
// submitted casing. `status` is a moderation hook (active|hidden) — no rows
|
||||||
|
// are hidden automatically today.
|
||||||
|
export const oemSuggestions = pgTable(
|
||||||
|
"oem_suggestions",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||||
|
brand: varchar("brand", { length: 80 }).notNull(),
|
||||||
|
suggestedCode: varchar("suggested_code", { length: 100 }).notNull(),
|
||||||
|
suggestedCodeNorm: varchar("suggested_code_norm", { length: 100 }).notNull(),
|
||||||
|
status: varchar("status", { length: 16 }).default("active").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("oem_suggestions_user_oem_code_idx").on(
|
||||||
|
table.userId,
|
||||||
|
table.oemCode,
|
||||||
|
table.suggestedCodeNorm,
|
||||||
|
),
|
||||||
|
index("oem_suggestions_oem_code_idx").on(table.oemCode),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/api/src/oem-votes/oem-votes.controller.ts
Normal file
36
apps/api/src/oem-votes/oem-votes.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Get, Post } from "@nestjs/common";
|
||||||
|
import { Throttle } from "@nestjs/throttler";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { castOemVoteSchema, lookupOemVotesSchema } from "./oem-votes.dto";
|
||||||
|
import { OemVotesService } from "./oem-votes.service";
|
||||||
|
|
||||||
|
@Controller("oem-votes")
|
||||||
|
export class OemVotesController {
|
||||||
|
constructor(private readonly oemVotesService: OemVotesService) {}
|
||||||
|
|
||||||
|
// Puan çiftliğine karşı insan-hızı sınırı: dakikada en fazla 30 oy.
|
||||||
|
@Post()
|
||||||
|
@Throttle({ default: { limit: 30, ttl: 60_000 } })
|
||||||
|
async cast(@CurrentUser("id") userId: string, @Body() body: unknown) {
|
||||||
|
const parsed = castOemVoteSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz oy verisi");
|
||||||
|
}
|
||||||
|
return this.oemVotesService.castVote(userId, parsed.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kod listesi URL sınırına sığmayacak kadar uzayabildiği için POST (bkz. /p/matched).
|
||||||
|
@Post("lookup")
|
||||||
|
async lookup(@CurrentUser("id") userId: string, @Body() body: unknown) {
|
||||||
|
const parsed = lookupOemVotesSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz kod listesi");
|
||||||
|
}
|
||||||
|
return { votes: await this.oemVotesService.lookup(userId, parsed.data.codes) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("leaderboard")
|
||||||
|
async leaderboard(@CurrentUser("id") userId: string) {
|
||||||
|
return this.oemVotesService.leaderboard(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/oem-votes/oem-votes.dto.ts
Normal file
17
apps/api/src/oem-votes/oem-votes.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const castOemVoteSchema = z.object({
|
||||||
|
oemCode: z.string().trim().min(2, "OEM kodu geçersiz").max(100, "OEM kodu çok uzun"),
|
||||||
|
vote: z.enum(["compatible", "incompatible"]),
|
||||||
|
// Analitik bağlamı — katalog rotaları catalog_vehicles id'si yollayabilir,
|
||||||
|
// UUID olmayan değerler serviste sessizce düşürülür (oy yine kaydedilir).
|
||||||
|
partId: z.string().max(100).optional(),
|
||||||
|
vehicleId: z.string().max(100).optional(),
|
||||||
|
categoryId: z.string().max(100).optional(),
|
||||||
|
});
|
||||||
|
export type CastOemVoteInput = z.infer<typeof castOemVoteSchema>;
|
||||||
|
|
||||||
|
export const lookupOemVotesSchema = z.object({
|
||||||
|
codes: z.array(z.string().trim().min(1).max(100)).min(1).max(400),
|
||||||
|
});
|
||||||
|
export type LookupOemVotesInput = z.infer<typeof lookupOemVotesSchema>;
|
||||||
69
apps/api/src/oem-votes/oem-votes.logic.spec.ts
Normal file
69
apps/api/src/oem-votes/oem-votes.logic.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { computeVoteAward, maskExpertName } from "./oem-votes.logic";
|
||||||
|
|
||||||
|
describe("computeVoteAward", () => {
|
||||||
|
it("ilk oy: kimse oylamamışsa 3 puan (1 oy + 2 doğru)", () => {
|
||||||
|
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "compatible")).toEqual({
|
||||||
|
points: 3,
|
||||||
|
correct: true,
|
||||||
|
});
|
||||||
|
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "incompatible")).toEqual({
|
||||||
|
points: 3,
|
||||||
|
correct: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("1 kişi uyumlu demişken uyumsuz diyen 1 puan, uyumlu diyen 3 puan alır", () => {
|
||||||
|
const prior = { compatible: 1, incompatible: 0 };
|
||||||
|
expect(computeVoteAward(prior, "incompatible")).toEqual({ points: 1, correct: false });
|
||||||
|
expect(computeVoteAward(prior, "compatible")).toEqual({ points: 3, correct: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("beraberliği bozan oy çoğunluğu kendi tarafına çevirdiği için 3 puan alır", () => {
|
||||||
|
expect(computeVoteAward({ compatible: 1, incompatible: 1 }, "compatible")).toEqual({
|
||||||
|
points: 3,
|
||||||
|
correct: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("açık çoğunluğa karşı oy yalnız taban puanı alır", () => {
|
||||||
|
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "incompatible")).toEqual({
|
||||||
|
points: 1,
|
||||||
|
correct: false,
|
||||||
|
});
|
||||||
|
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "compatible")).toEqual({
|
||||||
|
points: 3,
|
||||||
|
correct: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("oyumla beraberlik oluşuyorsa çoğunluk sağlanmaz, 1 puan", () => {
|
||||||
|
// 2-1 iken uyumsuz oyu → 2-2: kesin çoğunluk yok.
|
||||||
|
expect(computeVoteAward({ compatible: 2, incompatible: 1 }, "incompatible")).toEqual({
|
||||||
|
points: 1,
|
||||||
|
correct: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("maskExpertName", () => {
|
||||||
|
it("ad ve soyadın yalnız ilk harfini açık bırakır", () => {
|
||||||
|
expect(maskExpertName("Semih Yılmaz")).toBe("S*** Y***");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ara adları düşürür, soyad olarak son kelimeyi alır", () => {
|
||||||
|
expect(maskExpertName("Ali Rıza Demir")).toBe("A*** D***");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tek kelimelik adı maskeler", () => {
|
||||||
|
expect(maskExpertName("Semih")).toBe("S***");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Türkçe karakterleri doğru büyütür", () => {
|
||||||
|
expect(maskExpertName("ismail çelik")).toBe("İ*** Ç***");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("boş ada güvenli düşer", () => {
|
||||||
|
expect(maskExpertName(" ")).toBe("Üye");
|
||||||
|
});
|
||||||
|
});
|
||||||
37
apps/api/src/oem-votes/oem-votes.logic.ts
Normal file
37
apps/api/src/oem-votes/oem-votes.logic.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export type OemVoteChoice = "compatible" | "incompatible";
|
||||||
|
|
||||||
|
export interface VoteCounts {
|
||||||
|
compatible: number;
|
||||||
|
incompatible: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoteAward {
|
||||||
|
points: number;
|
||||||
|
correct: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VOTE_BASE_POINTS = 1;
|
||||||
|
export const VOTE_MAJORITY_BONUS = 2;
|
||||||
|
|
||||||
|
// "Doğru oy" = kendi oyu da sayıldığında kesin çoğunlukla aynı tarafta olmak.
|
||||||
|
// İlk oy (0-0 → 1-0) ve beraberliği bozan oy çoğunluğu kendi tarafına
|
||||||
|
// çevirdiği için doğrudur (3 puan); mevcut çoğunluğa karşı oy yalnız taban
|
||||||
|
// puanı alır (1). Ödül oy anında kesinleşir, çoğunluk sonradan dönse bile
|
||||||
|
// geriye dönük düzeltilmez.
|
||||||
|
export function computeVoteAward(prior: VoteCounts, vote: OemVoteChoice): VoteAward {
|
||||||
|
const mine = (vote === "compatible" ? prior.compatible : prior.incompatible) + 1;
|
||||||
|
const other = vote === "compatible" ? prior.incompatible : prior.compatible;
|
||||||
|
const correct = mine > other;
|
||||||
|
return { points: VOTE_BASE_POINTS + (correct ? VOTE_MAJORITY_BONUS : 0), correct };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liderlik listesi adları KVKK-dostu: yalnız ad ve soyadın ilk harfi açık
|
||||||
|
// ("Semih Yılmaz" → "S*** Y***"). Ara adlar tamamen düşer; tek kelimelik
|
||||||
|
// adlarda o kelimenin ilk harfi kalır.
|
||||||
|
export function maskExpertName(fullName: string): string {
|
||||||
|
const words = fullName.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) return "Üye";
|
||||||
|
const maskWord = (w: string) => `${w.charAt(0).toLocaleUpperCase("tr-TR")}***`;
|
||||||
|
if (words.length === 1) return maskWord(words[0]);
|
||||||
|
return `${maskWord(words[0])} ${maskWord(words[words.length - 1])}`;
|
||||||
|
}
|
||||||
10
apps/api/src/oem-votes/oem-votes.module.ts
Normal file
10
apps/api/src/oem-votes/oem-votes.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { OemVotesController } from "./oem-votes.controller";
|
||||||
|
import { OemVotesService } from "./oem-votes.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [OemVotesController],
|
||||||
|
providers: [OemVotesService],
|
||||||
|
exports: [OemVotesService],
|
||||||
|
})
|
||||||
|
export class OemVotesModule {}
|
||||||
200
apps/api/src/oem-votes/oem-votes.service.ts
Normal file
200
apps/api/src/oem-votes/oem-votes.service.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
|
import { oemVotePoints, oemVotes, users } from "../database/schema/core";
|
||||||
|
import {
|
||||||
|
type OemVoteChoice,
|
||||||
|
type VoteCounts,
|
||||||
|
computeVoteAward,
|
||||||
|
maskExpertName,
|
||||||
|
} from "./oem-votes.logic";
|
||||||
|
|
||||||
|
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const uuidOrNull = (value?: string) => (value && UUID_RE.test(value) ? value : null);
|
||||||
|
|
||||||
|
export interface CastVoteResult {
|
||||||
|
oemCode: string;
|
||||||
|
myVote: OemVoteChoice;
|
||||||
|
counts: VoteCounts;
|
||||||
|
pointsAwarded: number;
|
||||||
|
correct: boolean | null;
|
||||||
|
isNew: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OemVoteSummary extends VoteCounts {
|
||||||
|
myVote: OemVoteChoice | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardEntry {
|
||||||
|
rank: number;
|
||||||
|
name: string;
|
||||||
|
points: number;
|
||||||
|
votes: number;
|
||||||
|
isMe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OemVotesService {
|
||||||
|
constructor(@Inject(DATABASE) private db: Database) {}
|
||||||
|
|
||||||
|
async castVote(
|
||||||
|
userId: string,
|
||||||
|
input: {
|
||||||
|
oemCode: string;
|
||||||
|
vote: OemVoteChoice;
|
||||||
|
partId?: string;
|
||||||
|
vehicleId?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
},
|
||||||
|
): Promise<CastVoteResult> {
|
||||||
|
const oemCode = input.oemCode.trim();
|
||||||
|
const context = {
|
||||||
|
partId: uuidOrNull(input.partId),
|
||||||
|
vehicleId: uuidOrNull(input.vehicleId),
|
||||||
|
categoryId: uuidOrNull(input.categoryId),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.db.transaction(async (tx) => {
|
||||||
|
// Aynı kod üzerindeki eşzamanlı oyları sıraya sok: ilk-oy bonusu ve
|
||||||
|
// çoğunluk hesabı yarışsız, deterministik kalır. Kod bazlı kilit —
|
||||||
|
// farklı kodlar birbirini bekletmez, tx sonunda otomatik bırakılır.
|
||||||
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`oem-vote:${oemCode}`}))`);
|
||||||
|
|
||||||
|
const [existing] = await tx
|
||||||
|
.select({ id: oemVotes.id, vote: oemVotes.vote })
|
||||||
|
.from(oemVotes)
|
||||||
|
.where(and(eq(oemVotes.userId, userId), eq(oemVotes.oemCode, oemCode)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Fikir değişikliği oyu günceller ama puan üretmez (puan çiftliği yok).
|
||||||
|
if (existing.vote !== input.vote) {
|
||||||
|
await tx
|
||||||
|
.update(oemVotes)
|
||||||
|
.set({ vote: input.vote, ...context, updatedAt: new Date() })
|
||||||
|
.where(eq(oemVotes.id, existing.id));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
oemCode,
|
||||||
|
myVote: input.vote,
|
||||||
|
counts: await this.countVotes(tx, oemCode),
|
||||||
|
pointsAwarded: 0,
|
||||||
|
correct: null,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prior = await this.countVotes(tx, oemCode);
|
||||||
|
const award = computeVoteAward(prior, input.vote);
|
||||||
|
|
||||||
|
const [inserted] = await tx
|
||||||
|
.insert(oemVotes)
|
||||||
|
.values({ userId, oemCode, vote: input.vote, ...context })
|
||||||
|
.returning({ id: oemVotes.id });
|
||||||
|
|
||||||
|
await tx.insert(oemVotePoints).values({
|
||||||
|
userId,
|
||||||
|
voteId: inserted.id,
|
||||||
|
oemCode,
|
||||||
|
points: award.points,
|
||||||
|
correct: award.correct,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
oemCode,
|
||||||
|
myVote: input.vote,
|
||||||
|
counts: {
|
||||||
|
compatible: prior.compatible + (input.vote === "compatible" ? 1 : 0),
|
||||||
|
incompatible: prior.incompatible + (input.vote === "incompatible" ? 1 : 0),
|
||||||
|
},
|
||||||
|
pointsAwarded: award.points,
|
||||||
|
correct: award.correct,
|
||||||
|
isNew: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parça listesi açılırken görünen kodların oy özetleri — tek toplu sorgu.
|
||||||
|
async lookup(userId: string, codes: string[]): Promise<Record<string, OemVoteSummary>> {
|
||||||
|
const unique = [...new Set(codes.map((c) => c.trim()).filter(Boolean))];
|
||||||
|
if (unique.length === 0) return {};
|
||||||
|
|
||||||
|
const [tallies, mine] = await Promise.all([
|
||||||
|
this.db
|
||||||
|
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote, total: count() })
|
||||||
|
.from(oemVotes)
|
||||||
|
.where(inArray(oemVotes.oemCode, unique))
|
||||||
|
.groupBy(oemVotes.oemCode, oemVotes.vote),
|
||||||
|
this.db
|
||||||
|
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote })
|
||||||
|
.from(oemVotes)
|
||||||
|
.where(and(eq(oemVotes.userId, userId), inArray(oemVotes.oemCode, unique))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result: Record<string, OemVoteSummary> = {};
|
||||||
|
const entry = (code: string) => {
|
||||||
|
result[code] ??= { compatible: 0, incompatible: 0, myVote: null };
|
||||||
|
return result[code];
|
||||||
|
};
|
||||||
|
for (const row of tallies) {
|
||||||
|
const summary = entry(row.oemCode);
|
||||||
|
if (row.vote === "compatible") summary.compatible = row.total;
|
||||||
|
else summary.incompatible = row.total;
|
||||||
|
}
|
||||||
|
for (const row of mine) {
|
||||||
|
entry(row.oemCode).myVote = row.vote as OemVoteChoice;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parça Uzmanları: puana göre herkes, adlar maskeli ("S*** Y***"). Yanıt
|
||||||
|
// userId sızdırmaz; istek sahibi kendi satırını isMe ile bulur.
|
||||||
|
async leaderboard(userId: string): Promise<{
|
||||||
|
entries: LeaderboardEntry[];
|
||||||
|
me: { rank: number | null; points: number; votes: number };
|
||||||
|
}> {
|
||||||
|
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
userId: oemVotePoints.userId,
|
||||||
|
name: users.name,
|
||||||
|
points: totalPoints,
|
||||||
|
votes: count(),
|
||||||
|
})
|
||||||
|
.from(oemVotePoints)
|
||||||
|
.innerJoin(users, eq(users.id, oemVotePoints.userId))
|
||||||
|
.groupBy(oemVotePoints.userId, users.name)
|
||||||
|
.orderBy(desc(totalPoints), sql`min(${oemVotePoints.createdAt}) asc`)
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
const entries = rows.map((row, i) => ({
|
||||||
|
rank: i + 1,
|
||||||
|
name: maskExpertName(row.name),
|
||||||
|
points: row.points,
|
||||||
|
votes: row.votes,
|
||||||
|
isMe: row.userId === userId,
|
||||||
|
}));
|
||||||
|
const my = entries.find((e) => e.isMe);
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
me: { rank: my?.rank ?? null, points: my?.points ?? 0, votes: my?.votes ?? 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async countVotes(tx: Tx, oemCode: string): Promise<VoteCounts> {
|
||||||
|
const rows = await tx
|
||||||
|
.select({ vote: oemVotes.vote, total: count() })
|
||||||
|
.from(oemVotes)
|
||||||
|
.where(eq(oemVotes.oemCode, oemCode))
|
||||||
|
.groupBy(oemVotes.vote);
|
||||||
|
|
||||||
|
const counts: VoteCounts = { compatible: 0, incompatible: 0 };
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.vote === "compatible") counts.compatible = row.total;
|
||||||
|
else counts.incompatible = row.total;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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">
|
||||||
|
Her oy puan kazandırır ·{" "}
|
||||||
|
<Link to="/dashboard/uzmanlar" className="font-medium text-primary hover:underline">
|
||||||
|
Parça Uzmanları sıralaması
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
apps/web/src/components/gamification/leaderboard-podium.tsx
Normal file
96
apps/web/src/components/gamification/leaderboard-podium.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Trophy Gamification UI Kit'in leaderboard-podium bileşeni (MIT, ui.trophy.so)
|
||||||
|
// sase'ye uyarlandı: shadcn yerine @sase/ui, cva bağımlılığı söküldü, rank
|
||||||
|
// renk token'ları somut Tailwind renklerine bağlandı, avatar servisi yerine
|
||||||
|
// maskeli adın ilk harfi gösteriliyor (liderlik listesi KVKK-maskeli).
|
||||||
|
import { cn } from "@sase/ui";
|
||||||
|
import { Crown } from "lucide-react";
|
||||||
|
|
||||||
|
export interface PodiumRanking {
|
||||||
|
userId: string;
|
||||||
|
userName: string | null;
|
||||||
|
rank: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PODIUM_CONFIG = {
|
||||||
|
1: { color: "text-amber-400", bg: "bg-amber-400/50", height: "h-32" },
|
||||||
|
2: { color: "text-zinc-400", bg: "bg-zinc-400/30", height: "h-24" },
|
||||||
|
3: { color: "text-orange-700", bg: "bg-orange-700/40", height: "h-20" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface LeaderboardPodiumProps {
|
||||||
|
/** İlk 3 sıra (rank 1-3 beklenir) */
|
||||||
|
rankings: PodiumRanking[];
|
||||||
|
showValue?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaderboardPodium({
|
||||||
|
rankings,
|
||||||
|
showValue = true,
|
||||||
|
className,
|
||||||
|
}: LeaderboardPodiumProps) {
|
||||||
|
// Kürsü dizilimi: 2. — 1. — 3.
|
||||||
|
const top3 = rankings.slice(0, 3);
|
||||||
|
const podiumOrder = [
|
||||||
|
top3.find((r) => r.rank === 2),
|
||||||
|
top3.find((r) => r.rank === 1),
|
||||||
|
top3.find((r) => r.rank === 3),
|
||||||
|
].filter((r): r is PodiumRanking => Boolean(r));
|
||||||
|
|
||||||
|
if (podiumOrder.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
className={cn("flex items-end justify-center gap-4", className)}
|
||||||
|
aria-label="İlk 3 sıralama"
|
||||||
|
>
|
||||||
|
{podiumOrder.map((ranking) => {
|
||||||
|
const config = PODIUM_CONFIG[ranking.rank as 1 | 2 | 3];
|
||||||
|
if (!config) return null;
|
||||||
|
const displayName = ranking.userName || "Üye";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={ranking.userId}
|
||||||
|
aria-label={`Sıra ${ranking.rank}: ${displayName}${showValue ? `, ${ranking.value.toLocaleString("tr-TR")} puan` : ""}`}
|
||||||
|
className="flex flex-col items-center"
|
||||||
|
>
|
||||||
|
<div className="relative mb-2" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-14 w-14 items-center justify-center rounded-full text-lg font-semibold",
|
||||||
|
config.bg,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayName.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-background shadow-sm">
|
||||||
|
<Crown className={cn("h-4 w-4", config.color)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="max-w-20 truncate text-center text-sm font-medium" title={displayName}>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{showValue && (
|
||||||
|
<span className="text-sm tabular-nums text-muted-foreground">
|
||||||
|
{ranking.value.toLocaleString("tr-TR")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn("mt-2 w-22 rounded-t-lg", config.height, config.bg)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className={cn("flex h-8 items-center justify-center font-bold", config.color)}>
|
||||||
|
{ranking.rank}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/web/src/components/gamification/leaderboard-rankings.tsx
Normal file
166
apps/web/src/components/gamification/leaderboard-rankings.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// Trophy Gamification UI Kit'in leaderboard-rankings bileşeni (MIT, ui.trophy.so)
|
||||||
|
// sase'ye uyarlandı: @sase/ui importları, Türkçe metinler, avatar yerine
|
||||||
|
// maskeli adın ilk harfi, currentUserId karşılaştırması yerine isCurrentUser
|
||||||
|
// bayrağı (liderlik cevabı kullanıcı id'si sızdırmaz).
|
||||||
|
import { Button, cn } from "@sase/ui";
|
||||||
|
import { ChevronLeft, ChevronRight, Crown } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export interface LeaderboardRankingItem {
|
||||||
|
userId: string;
|
||||||
|
userName: string | null;
|
||||||
|
rank: number;
|
||||||
|
value: number;
|
||||||
|
byline?: string | null;
|
||||||
|
isCurrentUser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardRankingsProps {
|
||||||
|
rankings: LeaderboardRankingItem[];
|
||||||
|
showPagination?: boolean;
|
||||||
|
defaultPageSize?: 10 | 25 | 50 | 100;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const crownColorMap = {
|
||||||
|
1: "text-amber-400",
|
||||||
|
2: "text-zinc-400",
|
||||||
|
3: "text-orange-700",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const pageSizeOptions = [10, 25, 50, 100] as const;
|
||||||
|
|
||||||
|
function formatLeaderboardValue(value: number) {
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`;
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||||
|
return value.toLocaleString("tr-TR");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaderboardRankings({
|
||||||
|
rankings,
|
||||||
|
showPagination = false,
|
||||||
|
defaultPageSize = 25,
|
||||||
|
className,
|
||||||
|
}: LeaderboardRankingsProps) {
|
||||||
|
const [pageSize, setPageSize] = useState<10 | 25 | 50 | 100>(defaultPageSize);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(rankings.length / pageSize));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||||
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
|
const pagedRankings = useMemo(
|
||||||
|
() =>
|
||||||
|
showPagination
|
||||||
|
? rankings.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||||
|
: rankings,
|
||||||
|
[rankings, showPagination, currentPage, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full rounded-xl border bg-card", className)}>
|
||||||
|
<ul aria-label="Parça uzmanları sıralaması" className="divide-y divide-border">
|
||||||
|
{pagedRankings.map((ranking) => {
|
||||||
|
const displayName = ranking.userName || "Üye";
|
||||||
|
const showCrown = ranking.rank <= 3;
|
||||||
|
const crownColor = crownColorMap[ranking.rank as 1 | 2 | 3];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={ranking.userId}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2",
|
||||||
|
ranking.isCurrentUser && "rounded-md border-2 border-primary bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex w-12 items-center gap-1">
|
||||||
|
<span className="w-4 text-sm font-semibold tabular-nums">{ranking.rank}</span>
|
||||||
|
{showCrown ? (
|
||||||
|
<Crown className={cn("h-5 w-5", crownColor)} aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
|
||||||
|
{displayName.charAt(0).toLocaleUpperCase("tr-TR")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium text-foreground">
|
||||||
|
{displayName}
|
||||||
|
{ranking.isCurrentUser && (
|
||||||
|
<span className="ml-2 text-xs font-semibold text-primary">(Siz)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{ranking.byline ? (
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{ranking.byline}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-semibold leading-none tabular-nums">
|
||||||
|
{formatLeaderboardValue(ranking.value)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{showPagination ? (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="leaderboard-page-size" className="text-sm text-muted-foreground">
|
||||||
|
Göster
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="leaderboard-page-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value) as 10 | 25 | 50 | 100);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="rounded-md border bg-background px-2 py-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Önceki sayfa"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="rounded-md border p-1.5 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Sayfa {currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Sonraki sayfa"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="rounded-md border p-1.5 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/web/src/components/gamification/points-badge.tsx
Normal file
44
apps/web/src/components/gamification/points-badge.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Trophy Gamification UI Kit'in points-badge bileşeni (MIT, ui.trophy.so)
|
||||||
|
// sase'ye uyarlandı: @sase/ui importları, cva bağımlılığı söküldü.
|
||||||
|
import { cn } from "@sase/ui";
|
||||||
|
import { Sparkle } from "lucide-react";
|
||||||
|
|
||||||
|
interface PointsBadgeProps {
|
||||||
|
name: string;
|
||||||
|
total: number;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
formatValue?: (value: number) => string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PointsBadge({
|
||||||
|
name,
|
||||||
|
total,
|
||||||
|
icon: CustomIcon,
|
||||||
|
formatValue,
|
||||||
|
className,
|
||||||
|
}: PointsBadgeProps) {
|
||||||
|
const Icon = CustomIcon ?? Sparkle;
|
||||||
|
const displayValue = formatValue ? formatValue(total) : total.toLocaleString("tr-TR");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<output
|
||||||
|
aria-label={`${displayValue} ${name}`}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg border bg-card p-4 transition-colors",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10"
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold tabular-nums">{displayValue}</span>
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-muted-foreground">{name}</span>
|
||||||
|
</output>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ vi.mock("@/stores/schema.store", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
import { PartsPanel } from "../parts-panel";
|
import { PartsPanel } from "../parts-panel";
|
||||||
|
|
||||||
const buildPart = (overrides: Partial<Part> = {}): Part => ({
|
const buildPart = (overrides: Partial<Part> = {}): Part => ({
|
||||||
@@ -49,6 +50,7 @@ beforeEach(() => {
|
|||||||
captureMock.mockClear();
|
captureMock.mockClear();
|
||||||
setSelectedGroupMock.mockClear();
|
setSelectedGroupMock.mockClear();
|
||||||
setHighlightedGroupMock.mockClear();
|
setHighlightedGroupMock.mockClear();
|
||||||
|
vi.mocked(api.post).mockReset().mockResolvedValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -148,4 +150,25 @@ describe("PartsPanel", () => {
|
|||||||
parts_count: parts.length,
|
parts_count: parts.length,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links every OEM code to the detail page without a /p/matched gate", () => {
|
||||||
|
const postMock = vi.mocked(api.post);
|
||||||
|
|
||||||
|
render(<PartsPanel parts={[buildPart()]} vehicleId="v1" categoryId="c1" />);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "OEM-1" });
|
||||||
|
expect(link).toHaveAttribute("href", "/dashboard/oem/OEM-1");
|
||||||
|
// Liste açılışı artık toplu istek atmaz (/p/matched ve oy lookup'ı kalktı);
|
||||||
|
// tek istisna kopyalama anındaki fire-and-forget analytics POST'udur.
|
||||||
|
expect(postMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render Uyum or Adet columns", () => {
|
||||||
|
render(<PartsPanel parts={[buildPart({ quantity: 4 })]} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Adet")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Uyum")).not.toBeInTheDocument();
|
||||||
|
const headers = screen.getAllByRole("columnheader").map((th) => th.textContent);
|
||||||
|
expect(headers).toEqual(["#", "Parça Adı", "OEM Kodu", "Pozisyon"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,34 +37,6 @@ export function PartsPanel({
|
|||||||
const viewedKeyRef = useRef<string | null>(null);
|
const viewedKeyRef = useRef<string | null>(null);
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
const [resolvingCode, setResolvingCode] = useState<string | null>(null);
|
const [resolvingCode, setResolvingCode] = useState<string | null>(null);
|
||||||
// OEM codes that resolve to a non-empty cross-reference page. Only these are
|
|
||||||
// rendered as links — unmatched codes stay plain text so a click never lands
|
|
||||||
// on an empty "no equivalents" page. One batch lookup per parts list.
|
|
||||||
const [matchedOemCodes, setMatchedOemCodes] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const oemCodes = useMemo(
|
|
||||||
() => [...new Set(parts.map((p) => p.oemCode).filter((c) => c && c !== "N/A"))],
|
|
||||||
[parts],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (oemCodes.length === 0) {
|
|
||||||
setMatchedOemCodes(new Set());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
api
|
|
||||||
.post<{ matched: string[] }>("/p/matched", { codes: oemCodes })
|
|
||||||
.then((res) => {
|
|
||||||
if (!cancelled) setMatchedOemCodes(new Set(res?.matched ?? []));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setMatchedOemCodes(new Set());
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [oemCodes]);
|
|
||||||
|
|
||||||
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the
|
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the
|
||||||
// target illustration directly. Unresolved (target branch not seeded yet) →
|
// target illustration directly. Unresolved (target branch not seeded yet) →
|
||||||
@@ -265,7 +237,6 @@ export function PartsPanel({
|
|||||||
<th className="px-3 py-2 w-10">#</th>
|
<th className="px-3 py-2 w-10">#</th>
|
||||||
<th className="px-3 py-2">Parça Adı</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">OEM Kodu</th>
|
||||||
<th className="px-3 py-2 w-14 text-center">Adet</th>
|
|
||||||
<th className="px-3 py-2">Pozisyon</th>
|
<th className="px-3 py-2">Pozisyon</th>
|
||||||
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
|
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -290,7 +261,7 @@ export function PartsPanel({
|
|||||||
return (
|
return (
|
||||||
<tr key={part.id} className="border-b border-border/50 bg-muted/20">
|
<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 text-muted-foreground">{part.hotspotIndex}</td>
|
||||||
<td className="px-3 py-2" colSpan={hasPrices ? 5 : 4}>
|
<td className="px-3 py-2" colSpan={hasPrices ? 4 : 3}>
|
||||||
{label && <span className="font-medium">{label}</span>}
|
{label && <span className="font-medium">{label}</span>}
|
||||||
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
{refs.map((ref) => {
|
{refs.map((ref) => {
|
||||||
@@ -393,14 +364,13 @@ export function PartsPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{part.oemCode &&
|
{part.oemCode && part.oemCode !== "N/A" ? (
|
||||||
part.oemCode !== "N/A" &&
|
// Every code links to the OEM detail page — without a P
|
||||||
matchedOemCodes.has(part.oemCode) ? (
|
// cross-reference the page still carries the community
|
||||||
// Link ONLY codes with a real cross-reference. Plain
|
// vote, equivalent suggestions and the reverse catalog.
|
||||||
// left-click → in-app client navigation (no full SPA
|
// Plain left-click → in-app client navigation (no full
|
||||||
// reload — the new-tab boot was the slow part). Real href
|
// SPA reload); real href kept so ctrl/cmd/middle-click
|
||||||
// kept so ctrl/cmd/middle-click still opens a new tab.
|
// still opens a new tab.
|
||||||
// Unmatched codes fall through to plain text.
|
|
||||||
<a
|
<a
|
||||||
href={`/dashboard/oem/${encodeURIComponent(part.oemCode)}`}
|
href={`/dashboard/oem/${encodeURIComponent(part.oemCode)}`}
|
||||||
title="Uyumlu parça kodlarını gör"
|
title="Uyumlu parça kodlarını gör"
|
||||||
@@ -428,7 +398,6 @@ export function PartsPanel({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center">{part.quantity}</td>
|
|
||||||
<td className="px-3 py-2 text-muted-foreground">{part.position}</td>
|
<td className="px-3 py-2 text-muted-foreground">{part.position}</td>
|
||||||
{hasPrices && (
|
{hasPrices && (
|
||||||
<td className="px-3 py-2 text-right text-xs">
|
<td className="px-3 py-2 text-right text-xs">
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import { useChangelog } from "@/hooks/use-changelog";
|
|
||||||
import { useTranslation } from "@/lib/i18n";
|
|
||||||
import type { ChangelogEntry } from "@sase/shared";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Skeleton,
|
|
||||||
} from "@sase/ui";
|
|
||||||
import { CalendarDays } from "lucide-react";
|
|
||||||
|
|
||||||
function formatDate(iso: string, locale: "tr" | "en"): string {
|
|
||||||
const date = new Date(iso);
|
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
};
|
|
||||||
return date.toLocaleDateString(locale === "tr" ? "tr-TR" : "en-US", options);
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeTypeLabel(
|
|
||||||
changeType: ChangelogEntry["changeType"],
|
|
||||||
t: (key: string) => string,
|
|
||||||
): string {
|
|
||||||
return t(`settings.changelog.changeType.${changeType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChangelogSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-0">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="relative pb-6 pl-8">
|
|
||||||
<div className="absolute left-0 top-1.5 h-2.5 w-2.5 rounded-full bg-muted-foreground/20" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-5 w-16 rounded-md" />
|
|
||||||
<Skeleton className="h-5 w-48" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-4 w-28" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChangelogEmpty({ t }: { t: (key: string) => string }) {
|
|
||||||
return (
|
|
||||||
<Card className="border-dashed">
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<CalendarDays className="mx-auto h-10 w-10 text-muted-foreground" />
|
|
||||||
<CardTitle>{t("settings.changelog.emptyTitle")}</CardTitle>
|
|
||||||
<CardDescription>{t("settings.changelog.emptyDescription")}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChangelogTab() {
|
|
||||||
const { data: entries, isLoading } = useChangelog();
|
|
||||||
const { t, locale } = useTranslation();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <ChangelogSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!entries || entries.length === 0) {
|
|
||||||
return <ChangelogEmpty t={t} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("settings.changelog.title")}</CardTitle>
|
|
||||||
<CardDescription>{t("settings.changelog.description")}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Accordion
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
className="relative border-l-2 border-muted-foreground/20"
|
|
||||||
>
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<AccordionItem key={entry.id} value={entry.id} className="border-b-0 pl-6">
|
|
||||||
<div className="absolute left-[-5px] mt-6 h-2.5 w-2.5 rounded-full border-2 border-background bg-foreground dark:bg-primary" />
|
|
||||||
<AccordionTrigger className="py-3 hover:no-underline">
|
|
||||||
<div className="flex flex-col items-start gap-1.5 text-left">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge changeType={entry.changeType}>
|
|
||||||
{changeTypeLabel(entry.changeType, t)}
|
|
||||||
</Badge>
|
|
||||||
<span className="font-medium">{entry.title}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(entry.publishedAt, locale)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent>
|
|
||||||
<div className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
|
||||||
{entry.description}
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { api } from "@/lib/api-client";
|
|
||||||
import type { ChangelogEntry } from "@sase/shared";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
export function useChangelog() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["changelog"],
|
|
||||||
queryFn: () => api.get<ChangelogEntry[]>("/changelog"),
|
|
||||||
staleTime: 30 * 60 * 1000, // 30 min — matches Redis cache TTL
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -74,6 +74,7 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
|
"experts": "Parts Experts",
|
||||||
"catalog": "Catalog",
|
"catalog": "Catalog",
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
"billing": "Payment",
|
"billing": "Payment",
|
||||||
@@ -82,7 +83,6 @@
|
|||||||
"logout": "Log Out",
|
"logout": "Log Out",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"changelog": "What's New",
|
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"sectionMain": "Main Menu",
|
"sectionMain": "Main Menu",
|
||||||
"sectionAccount": "Account",
|
"sectionAccount": "Account",
|
||||||
@@ -522,7 +522,6 @@
|
|||||||
"connections": "Connections",
|
"connections": "Connections",
|
||||||
"referral": "Referral",
|
"referral": "Referral",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"changelog": "Changelog",
|
|
||||||
"notifications": "Notifications"
|
"notifications": "Notifications"
|
||||||
},
|
},
|
||||||
"preferences": {
|
"preferences": {
|
||||||
@@ -593,17 +592,6 @@
|
|||||||
"typeConfirm": "Type 'DELETE' to confirm",
|
"typeConfirm": "Type 'DELETE' to confirm",
|
||||||
"confirmWord": "DELETE"
|
"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": {
|
"notifications": {
|
||||||
"title": "Notification preferences",
|
"title": "Notification preferences",
|
||||||
"description": "Choose which lifecycle e-mails you want to receive. Account security and payment notifications keep coming."
|
"description": "Choose which lifecycle e-mails you want to receive. Account security and payment notifications keep coming."
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"dashboard": "Gösterge Paneli",
|
"dashboard": "Gösterge Paneli",
|
||||||
"search": "Arama",
|
"search": "Arama",
|
||||||
"history": "Geçmiş",
|
"history": "Geçmiş",
|
||||||
|
"experts": "Parça Uzmanları",
|
||||||
"catalog": "Katalog",
|
"catalog": "Katalog",
|
||||||
"subscription": "Abonelik",
|
"subscription": "Abonelik",
|
||||||
"billing": "Ödeme",
|
"billing": "Ödeme",
|
||||||
@@ -82,7 +83,6 @@
|
|||||||
"logout": "Çıkış Yap",
|
"logout": "Çıkış Yap",
|
||||||
"contact": "İletişim",
|
"contact": "İletişim",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"changelog": "Yenilikler",
|
|
||||||
"account": "Hesap",
|
"account": "Hesap",
|
||||||
"sectionMain": "Ana Menü",
|
"sectionMain": "Ana Menü",
|
||||||
"sectionAccount": "Hesap",
|
"sectionAccount": "Hesap",
|
||||||
@@ -522,7 +522,6 @@
|
|||||||
"connections": "Bağlantılar",
|
"connections": "Bağlantılar",
|
||||||
"referral": "Referans",
|
"referral": "Referans",
|
||||||
"account": "Hesap",
|
"account": "Hesap",
|
||||||
"changelog": "Değişiklik Günlüğü",
|
|
||||||
"notifications": "Bildirimler"
|
"notifications": "Bildirimler"
|
||||||
},
|
},
|
||||||
"preferences": {
|
"preferences": {
|
||||||
@@ -593,17 +592,6 @@
|
|||||||
"typeConfirm": "Onaylamak için 'SİL' yazın",
|
"typeConfirm": "Onaylamak için 'SİL' yazın",
|
||||||
"confirmWord": "SİL"
|
"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": {
|
"notifications": {
|
||||||
"title": "Bildirim tercihleri",
|
"title": "Bildirim tercihleri",
|
||||||
"description": "Hangi lifecycle maillerini almak istediğini seç. Hesap güvenliği ve ödeme bildirimleri her zaman gelmeye devam eder."
|
"description": "Hangi lifecycle maillerini almak istediğini seç. Hesap güvenliği ve ödeme bildirimleri her zaman gelmeye devam eder."
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ import { Route as AboutRouteImport } from './routes/about'
|
|||||||
import { Route as AuthRouteImport } from './routes/_auth'
|
import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as DashboardIndexRouteImport } from './routes/dashboard/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 DashboardSettingsRouteImport } from './routes/dashboard/settings'
|
||||||
import { Route as DashboardServiceTestRouteImport } from './routes/dashboard/service-test'
|
import { Route as DashboardServiceTestRouteImport } from './routes/dashboard/service-test'
|
||||||
import { Route as DashboardSearchRouteImport } from './routes/dashboard/search'
|
import { Route as DashboardSearchRouteImport } from './routes/dashboard/search'
|
||||||
import { Route as DashboardHistoryRouteImport } from './routes/dashboard/history'
|
import { Route as DashboardHistoryRouteImport } from './routes/dashboard/history'
|
||||||
import { Route as DashboardContactRouteImport } from './routes/dashboard/contact'
|
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 DashboardBlogRouteImport } from './routes/dashboard/blog'
|
||||||
import { Route as DashboardBillingRouteImport } from './routes/dashboard/billing'
|
import { Route as DashboardBillingRouteImport } from './routes/dashboard/billing'
|
||||||
import { Route as BlogSlugRouteImport } from './routes/blog_/$slug'
|
import { Route as BlogSlugRouteImport } from './routes/blog_/$slug'
|
||||||
@@ -117,6 +117,11 @@ const DashboardIndexRoute = DashboardIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => DashboardRoute,
|
getParentRoute: () => DashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DashboardUzmanlarRoute = DashboardUzmanlarRouteImport.update({
|
||||||
|
id: '/uzmanlar',
|
||||||
|
path: '/uzmanlar',
|
||||||
|
getParentRoute: () => DashboardRoute,
|
||||||
|
} as any)
|
||||||
const DashboardSettingsRoute = DashboardSettingsRouteImport.update({
|
const DashboardSettingsRoute = DashboardSettingsRouteImport.update({
|
||||||
id: '/settings',
|
id: '/settings',
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
@@ -142,11 +147,6 @@ const DashboardContactRoute = DashboardContactRouteImport.update({
|
|||||||
path: '/contact',
|
path: '/contact',
|
||||||
getParentRoute: () => DashboardRoute,
|
getParentRoute: () => DashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DashboardChangelogRoute = DashboardChangelogRouteImport.update({
|
|
||||||
id: '/changelog',
|
|
||||||
path: '/changelog',
|
|
||||||
getParentRoute: () => DashboardRoute,
|
|
||||||
} as any)
|
|
||||||
const DashboardBlogRoute = DashboardBlogRouteImport.update({
|
const DashboardBlogRoute = DashboardBlogRouteImport.update({
|
||||||
id: '/blog',
|
id: '/blog',
|
||||||
path: '/blog',
|
path: '/blog',
|
||||||
@@ -331,12 +331,12 @@ export interface FileRoutesByFullPath {
|
|||||||
'/blog/$slug': typeof BlogSlugRoute
|
'/blog/$slug': typeof BlogSlugRoute
|
||||||
'/dashboard/billing': typeof DashboardBillingRoute
|
'/dashboard/billing': typeof DashboardBillingRoute
|
||||||
'/dashboard/blog': typeof DashboardBlogRoute
|
'/dashboard/blog': typeof DashboardBlogRoute
|
||||||
'/dashboard/changelog': typeof DashboardChangelogRoute
|
|
||||||
'/dashboard/contact': typeof DashboardContactRoute
|
'/dashboard/contact': typeof DashboardContactRoute
|
||||||
'/dashboard/history': typeof DashboardHistoryRoute
|
'/dashboard/history': typeof DashboardHistoryRoute
|
||||||
'/dashboard/search': typeof DashboardSearchRoute
|
'/dashboard/search': typeof DashboardSearchRoute
|
||||||
'/dashboard/service-test': typeof DashboardServiceTestRoute
|
'/dashboard/service-test': typeof DashboardServiceTestRoute
|
||||||
'/dashboard/settings': typeof DashboardSettingsRoute
|
'/dashboard/settings': typeof DashboardSettingsRoute
|
||||||
|
'/dashboard/uzmanlar': typeof DashboardUzmanlarRoute
|
||||||
'/dashboard/': typeof DashboardIndexRoute
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
|
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
|
||||||
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
|
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
|
||||||
@@ -379,12 +379,12 @@ export interface FileRoutesByTo {
|
|||||||
'/blog/$slug': typeof BlogSlugRoute
|
'/blog/$slug': typeof BlogSlugRoute
|
||||||
'/dashboard/billing': typeof DashboardBillingRoute
|
'/dashboard/billing': typeof DashboardBillingRoute
|
||||||
'/dashboard/blog': typeof DashboardBlogRoute
|
'/dashboard/blog': typeof DashboardBlogRoute
|
||||||
'/dashboard/changelog': typeof DashboardChangelogRoute
|
|
||||||
'/dashboard/contact': typeof DashboardContactRoute
|
'/dashboard/contact': typeof DashboardContactRoute
|
||||||
'/dashboard/history': typeof DashboardHistoryRoute
|
'/dashboard/history': typeof DashboardHistoryRoute
|
||||||
'/dashboard/search': typeof DashboardSearchRoute
|
'/dashboard/search': typeof DashboardSearchRoute
|
||||||
'/dashboard/service-test': typeof DashboardServiceTestRoute
|
'/dashboard/service-test': typeof DashboardServiceTestRoute
|
||||||
'/dashboard/settings': typeof DashboardSettingsRoute
|
'/dashboard/settings': typeof DashboardSettingsRoute
|
||||||
|
'/dashboard/uzmanlar': typeof DashboardUzmanlarRoute
|
||||||
'/dashboard': typeof DashboardIndexRoute
|
'/dashboard': typeof DashboardIndexRoute
|
||||||
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
|
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
|
||||||
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
|
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
|
||||||
@@ -430,12 +430,12 @@ export interface FileRoutesById {
|
|||||||
'/blog_/$slug': typeof BlogSlugRoute
|
'/blog_/$slug': typeof BlogSlugRoute
|
||||||
'/dashboard/billing': typeof DashboardBillingRoute
|
'/dashboard/billing': typeof DashboardBillingRoute
|
||||||
'/dashboard/blog': typeof DashboardBlogRoute
|
'/dashboard/blog': typeof DashboardBlogRoute
|
||||||
'/dashboard/changelog': typeof DashboardChangelogRoute
|
|
||||||
'/dashboard/contact': typeof DashboardContactRoute
|
'/dashboard/contact': typeof DashboardContactRoute
|
||||||
'/dashboard/history': typeof DashboardHistoryRoute
|
'/dashboard/history': typeof DashboardHistoryRoute
|
||||||
'/dashboard/search': typeof DashboardSearchRoute
|
'/dashboard/search': typeof DashboardSearchRoute
|
||||||
'/dashboard/service-test': typeof DashboardServiceTestRoute
|
'/dashboard/service-test': typeof DashboardServiceTestRoute
|
||||||
'/dashboard/settings': typeof DashboardSettingsRoute
|
'/dashboard/settings': typeof DashboardSettingsRoute
|
||||||
|
'/dashboard/uzmanlar': typeof DashboardUzmanlarRoute
|
||||||
'/dashboard/': typeof DashboardIndexRoute
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
|
'/dashboard/admin/analytics': typeof DashboardAdminAnalyticsRoute
|
||||||
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
|
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
|
||||||
@@ -481,12 +481,12 @@ export interface FileRouteTypes {
|
|||||||
| '/blog/$slug'
|
| '/blog/$slug'
|
||||||
| '/dashboard/billing'
|
| '/dashboard/billing'
|
||||||
| '/dashboard/blog'
|
| '/dashboard/blog'
|
||||||
| '/dashboard/changelog'
|
|
||||||
| '/dashboard/contact'
|
| '/dashboard/contact'
|
||||||
| '/dashboard/history'
|
| '/dashboard/history'
|
||||||
| '/dashboard/search'
|
| '/dashboard/search'
|
||||||
| '/dashboard/service-test'
|
| '/dashboard/service-test'
|
||||||
| '/dashboard/settings'
|
| '/dashboard/settings'
|
||||||
|
| '/dashboard/uzmanlar'
|
||||||
| '/dashboard/'
|
| '/dashboard/'
|
||||||
| '/dashboard/admin/analytics'
|
| '/dashboard/admin/analytics'
|
||||||
| '/dashboard/admin/copy-logs'
|
| '/dashboard/admin/copy-logs'
|
||||||
@@ -529,12 +529,12 @@ export interface FileRouteTypes {
|
|||||||
| '/blog/$slug'
|
| '/blog/$slug'
|
||||||
| '/dashboard/billing'
|
| '/dashboard/billing'
|
||||||
| '/dashboard/blog'
|
| '/dashboard/blog'
|
||||||
| '/dashboard/changelog'
|
|
||||||
| '/dashboard/contact'
|
| '/dashboard/contact'
|
||||||
| '/dashboard/history'
|
| '/dashboard/history'
|
||||||
| '/dashboard/search'
|
| '/dashboard/search'
|
||||||
| '/dashboard/service-test'
|
| '/dashboard/service-test'
|
||||||
| '/dashboard/settings'
|
| '/dashboard/settings'
|
||||||
|
| '/dashboard/uzmanlar'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/dashboard/admin/analytics'
|
| '/dashboard/admin/analytics'
|
||||||
| '/dashboard/admin/copy-logs'
|
| '/dashboard/admin/copy-logs'
|
||||||
@@ -579,12 +579,12 @@ export interface FileRouteTypes {
|
|||||||
| '/blog_/$slug'
|
| '/blog_/$slug'
|
||||||
| '/dashboard/billing'
|
| '/dashboard/billing'
|
||||||
| '/dashboard/blog'
|
| '/dashboard/blog'
|
||||||
| '/dashboard/changelog'
|
|
||||||
| '/dashboard/contact'
|
| '/dashboard/contact'
|
||||||
| '/dashboard/history'
|
| '/dashboard/history'
|
||||||
| '/dashboard/search'
|
| '/dashboard/search'
|
||||||
| '/dashboard/service-test'
|
| '/dashboard/service-test'
|
||||||
| '/dashboard/settings'
|
| '/dashboard/settings'
|
||||||
|
| '/dashboard/uzmanlar'
|
||||||
| '/dashboard/'
|
| '/dashboard/'
|
||||||
| '/dashboard/admin/analytics'
|
| '/dashboard/admin/analytics'
|
||||||
| '/dashboard/admin/copy-logs'
|
| '/dashboard/admin/copy-logs'
|
||||||
@@ -712,6 +712,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DashboardIndexRouteImport
|
preLoaderRoute: typeof DashboardIndexRouteImport
|
||||||
parentRoute: typeof DashboardRoute
|
parentRoute: typeof DashboardRoute
|
||||||
}
|
}
|
||||||
|
'/dashboard/uzmanlar': {
|
||||||
|
id: '/dashboard/uzmanlar'
|
||||||
|
path: '/uzmanlar'
|
||||||
|
fullPath: '/dashboard/uzmanlar'
|
||||||
|
preLoaderRoute: typeof DashboardUzmanlarRouteImport
|
||||||
|
parentRoute: typeof DashboardRoute
|
||||||
|
}
|
||||||
'/dashboard/settings': {
|
'/dashboard/settings': {
|
||||||
id: '/dashboard/settings'
|
id: '/dashboard/settings'
|
||||||
path: '/settings'
|
path: '/settings'
|
||||||
@@ -747,13 +754,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DashboardContactRouteImport
|
preLoaderRoute: typeof DashboardContactRouteImport
|
||||||
parentRoute: typeof DashboardRoute
|
parentRoute: typeof DashboardRoute
|
||||||
}
|
}
|
||||||
'/dashboard/changelog': {
|
|
||||||
id: '/dashboard/changelog'
|
|
||||||
path: '/changelog'
|
|
||||||
fullPath: '/dashboard/changelog'
|
|
||||||
preLoaderRoute: typeof DashboardChangelogRouteImport
|
|
||||||
parentRoute: typeof DashboardRoute
|
|
||||||
}
|
|
||||||
'/dashboard/blog': {
|
'/dashboard/blog': {
|
||||||
id: '/dashboard/blog'
|
id: '/dashboard/blog'
|
||||||
path: '/blog'
|
path: '/blog'
|
||||||
@@ -988,12 +988,12 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
|||||||
interface DashboardRouteChildren {
|
interface DashboardRouteChildren {
|
||||||
DashboardBillingRoute: typeof DashboardBillingRoute
|
DashboardBillingRoute: typeof DashboardBillingRoute
|
||||||
DashboardBlogRoute: typeof DashboardBlogRoute
|
DashboardBlogRoute: typeof DashboardBlogRoute
|
||||||
DashboardChangelogRoute: typeof DashboardChangelogRoute
|
|
||||||
DashboardContactRoute: typeof DashboardContactRoute
|
DashboardContactRoute: typeof DashboardContactRoute
|
||||||
DashboardHistoryRoute: typeof DashboardHistoryRoute
|
DashboardHistoryRoute: typeof DashboardHistoryRoute
|
||||||
DashboardSearchRoute: typeof DashboardSearchRoute
|
DashboardSearchRoute: typeof DashboardSearchRoute
|
||||||
DashboardServiceTestRoute: typeof DashboardServiceTestRoute
|
DashboardServiceTestRoute: typeof DashboardServiceTestRoute
|
||||||
DashboardSettingsRoute: typeof DashboardSettingsRoute
|
DashboardSettingsRoute: typeof DashboardSettingsRoute
|
||||||
|
DashboardUzmanlarRoute: typeof DashboardUzmanlarRoute
|
||||||
DashboardIndexRoute: typeof DashboardIndexRoute
|
DashboardIndexRoute: typeof DashboardIndexRoute
|
||||||
DashboardAdminAnalyticsRoute: typeof DashboardAdminAnalyticsRoute
|
DashboardAdminAnalyticsRoute: typeof DashboardAdminAnalyticsRoute
|
||||||
DashboardAdminCopyLogsRoute: typeof DashboardAdminCopyLogsRoute
|
DashboardAdminCopyLogsRoute: typeof DashboardAdminCopyLogsRoute
|
||||||
@@ -1021,12 +1021,12 @@ interface DashboardRouteChildren {
|
|||||||
const DashboardRouteChildren: DashboardRouteChildren = {
|
const DashboardRouteChildren: DashboardRouteChildren = {
|
||||||
DashboardBillingRoute: DashboardBillingRoute,
|
DashboardBillingRoute: DashboardBillingRoute,
|
||||||
DashboardBlogRoute: DashboardBlogRoute,
|
DashboardBlogRoute: DashboardBlogRoute,
|
||||||
DashboardChangelogRoute: DashboardChangelogRoute,
|
|
||||||
DashboardContactRoute: DashboardContactRoute,
|
DashboardContactRoute: DashboardContactRoute,
|
||||||
DashboardHistoryRoute: DashboardHistoryRoute,
|
DashboardHistoryRoute: DashboardHistoryRoute,
|
||||||
DashboardSearchRoute: DashboardSearchRoute,
|
DashboardSearchRoute: DashboardSearchRoute,
|
||||||
DashboardServiceTestRoute: DashboardServiceTestRoute,
|
DashboardServiceTestRoute: DashboardServiceTestRoute,
|
||||||
DashboardSettingsRoute: DashboardSettingsRoute,
|
DashboardSettingsRoute: DashboardSettingsRoute,
|
||||||
|
DashboardUzmanlarRoute: DashboardUzmanlarRoute,
|
||||||
DashboardIndexRoute: DashboardIndexRoute,
|
DashboardIndexRoute: DashboardIndexRoute,
|
||||||
DashboardAdminAnalyticsRoute: DashboardAdminAnalyticsRoute,
|
DashboardAdminAnalyticsRoute: DashboardAdminAnalyticsRoute,
|
||||||
DashboardAdminCopyLogsRoute: DashboardAdminCopyLogsRoute,
|
DashboardAdminCopyLogsRoute: DashboardAdminCopyLogsRoute,
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { Link, Outlet, createFileRoute, useNavigate, useRouterState } from "@tan
|
|||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
CalendarDays,
|
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Copy,
|
Copy,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
@@ -51,6 +50,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Sun,
|
Sun,
|
||||||
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -72,6 +72,7 @@ const mainMenuItems: readonly NavItem[] = [
|
|||||||
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search },
|
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search },
|
||||||
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
|
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
|
||||||
{ to: "/dashboard/history", labelKey: "nav.history", icon: History },
|
{ to: "/dashboard/history", labelKey: "nav.history", icon: History },
|
||||||
|
{ to: "/dashboard/uzmanlar", labelKey: "nav.experts", icon: Trophy },
|
||||||
];
|
];
|
||||||
|
|
||||||
const accountItems: readonly NavItem[] = [
|
const accountItems: readonly NavItem[] = [
|
||||||
@@ -81,7 +82,6 @@ const accountItems: readonly NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const supportItems: readonly NavItem[] = [
|
const supportItems: readonly NavItem[] = [
|
||||||
{ to: "/dashboard/changelog", labelKey: "nav.changelog", icon: CalendarDays },
|
|
||||||
{ to: "/dashboard/contact", labelKey: "nav.contact", icon: Mail },
|
{ to: "/dashboard/contact", labelKey: "nav.contact", icon: Mail },
|
||||||
{ to: "/dashboard/blog", labelKey: "nav.blog", icon: BookOpen },
|
{ to: "/dashboard/blog", labelKey: "nav.blog", icon: BookOpen },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { ChangelogTab } from "@/components/settings/changelog-tab";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/changelog")({
|
|
||||||
component: ChangelogPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
function ChangelogPage() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl">
|
|
||||||
<ChangelogTab />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 { api } from "@/lib/api-client";
|
||||||
import { capture } from "@/lib/posthog";
|
import { capture } from "@/lib/posthog";
|
||||||
import { Badge, Button, Input, Skeleton } from "@sase/ui";
|
import { Badge, Button, Input, Skeleton } from "@sase/ui";
|
||||||
@@ -193,6 +195,9 @@ function OemDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* ─── Topluluk uyumluluk oyu (P eşleşmesinden bağımsız) ───────────── */}
|
||||||
|
<OemVoteCard oemCode={code} />
|
||||||
|
|
||||||
{/* ─── Loading ────────────────────────────────────────────────────── */}
|
{/* ─── Loading ────────────────────────────────────────────────────── */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="space-y-4">
|
<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="font-medium">Bu OEM kodu için uyumlu parça bulunamadı</p>
|
||||||
<p className="max-w-md text-sm text-muted-foreground">
|
<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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -339,6 +345,9 @@ function OemDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ─── Topluluk muadil önerileri (eşleşme olmasa da toplanır) ──────── */}
|
||||||
|
{!isLoading && <OemSuggestionsSection oemCode={code} />}
|
||||||
|
|
||||||
{/* ─── Reverse catalog: your vehicles that use this code ───────────── */}
|
{/* ─── Reverse catalog: your vehicles that use this code ───────────── */}
|
||||||
{catalogVehicles && catalogVehicles.length > 0 && (
|
{catalogVehicles && catalogVehicles.length > 0 && (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
|
|||||||
135
apps/web/src/routes/dashboard/uzmanlar.tsx
Normal file
135
apps/web/src/routes/dashboard/uzmanlar.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { LeaderboardPodium } from "@/components/gamification/leaderboard-podium";
|
||||||
|
import {
|
||||||
|
type LeaderboardRankingItem,
|
||||||
|
LeaderboardRankings,
|
||||||
|
} from "@/components/gamification/leaderboard-rankings";
|
||||||
|
import { PointsBadge } from "@/components/gamification/points-badge";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { capture } from "@/lib/posthog";
|
||||||
|
import { Card, CardContent, Skeleton } from "@sase/ui";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Medal, ThumbsUp, Trophy } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
interface LeaderboardEntry {
|
||||||
|
rank: number;
|
||||||
|
name: string;
|
||||||
|
points: number;
|
||||||
|
votes: number;
|
||||||
|
isMe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardResponse {
|
||||||
|
entries: LeaderboardEntry[];
|
||||||
|
me: { rank: number | null; points: number; votes: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/uzmanlar")({
|
||||||
|
component: ExpertsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const SKELETON_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5"] as const;
|
||||||
|
|
||||||
|
function ExpertsPage() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["oem-leaderboard"],
|
||||||
|
queryFn: () => api.get<LeaderboardResponse>("/oem-votes/leaderboard"),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
capture("experts_leaderboard_viewed");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const entries = data?.entries ?? [];
|
||||||
|
const me = data?.me;
|
||||||
|
|
||||||
|
// Liderlik cevabı kullanıcı id'si taşımaz; satır anahtarı sıradan türetilir.
|
||||||
|
const podiumRankings = entries.slice(0, 3).map((e) => ({
|
||||||
|
userId: `rank-${e.rank}`,
|
||||||
|
userName: e.name,
|
||||||
|
rank: e.rank,
|
||||||
|
value: e.points,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rankingItems: LeaderboardRankingItem[] = entries.map((e) => ({
|
||||||
|
userId: `rank-${e.rank}`,
|
||||||
|
userName: e.name,
|
||||||
|
rank: e.rank,
|
||||||
|
value: e.points,
|
||||||
|
byline: `${e.votes} oy`,
|
||||||
|
isCurrentUser: e.isMe,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||||
|
<Trophy className="size-6 text-amber-400" />
|
||||||
|
Parça Uzmanları
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
OEM kodlarını oyla, puan topla, sıralamada yüksel — doğru bilgi bütün parça
|
||||||
|
satıcılarının işini hızlandırır.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{SKELETON_KEYS.map((k) => (
|
||||||
|
<Skeleton key={k} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
||||||
|
<Trophy className="size-10 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm font-medium">Sıralama henüz boş.</p>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">
|
||||||
|
İlk uzman siz olun: katalogdan bir OEM kodunu değerlendirin, açılış puanlarını siz
|
||||||
|
kapın.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboard/search" className="text-sm font-semibold text-primary underline">
|
||||||
|
Şase sorgula ve oylamaya başla
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Kendi durumum */}
|
||||||
|
{me && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<PointsBadge name="puanınız" total={me.points} />
|
||||||
|
<PointsBadge
|
||||||
|
name="sıralamanız"
|
||||||
|
total={me.rank ?? 0}
|
||||||
|
icon={Medal}
|
||||||
|
formatValue={(v) => (v > 0 ? `#${v}` : "—")}
|
||||||
|
/>
|
||||||
|
<PointsBadge name="oyunuz" total={me.votes} icon={ThumbsUp} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* İlk 3 kürsüsü */}
|
||||||
|
{podiumRankings.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<LeaderboardPodium rankings={podiumRankings} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tam sıralama */}
|
||||||
|
<LeaderboardRankings rankings={rankingItems} showPagination={entries.length > 25} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Puan kuralları yalnız burada yaşar — küçük punto (computeVoteAward ile aynı kural). */}
|
||||||
|
<p className="pt-2 text-center text-[11px] leading-relaxed text-muted-foreground/70">
|
||||||
|
Oy ver <span className="font-semibold text-muted-foreground">+1</span> · çoğunluğu tuttur{" "}
|
||||||
|
<span className="font-semibold text-muted-foreground">+2 bonus</span> · kodu ilk
|
||||||
|
değerlendiren <span className="font-semibold text-muted-foreground">3 puanı</span> kapar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user