polish(web): oy kartı header'a — OEM kodunun sağ simetriğine, araç+parça bağlamıyla
Topluluk oyu kartının koyu dolgusu kalktı (sayfa arkaplanıyla aynı, yalnız çerçeve); desktop'ta başlıkta OEM kodunun sağına yerleşti, mobilde kodun altına iner. Katalogdan gelişte araç etiketi + parça adı search-param'la taşınır (v/p/vid) ve kartta butonların üstünde "araç · parça" satırı olarak gösterilir — neyle neyin uyumlu olduğu tek alanda. vid oy kaydına analitik bağlam olarak geri eklendi; doğrudan ziyarette genel soru metnine düşülür. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,12 @@ describe("OemVoteCard", () => {
|
||||
votes: { "8V0615423E": { compatible: 4, incompatible: 1, myVote: "compatible" } },
|
||||
});
|
||||
|
||||
render(<OemVoteCard oemCode="8V0615423E" />);
|
||||
render(
|
||||
<OemVoteCard oemCode="8V0615423E" vehicleLabel="Audi A4 (2018)" partName="Fren balatası" />,
|
||||
);
|
||||
|
||||
// bağlam satırı: neyle neyin uyumlu olduğu tek alanda
|
||||
expect(screen.getByText("Audi A4 (2018) · Fren balatası")).toBeInTheDocument();
|
||||
|
||||
const upButton = screen.getByRole("button", { name: /Uyumlu/ });
|
||||
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
|
||||
@@ -84,10 +89,10 @@ describe("OemVoteCard", () => {
|
||||
fireEvent.click(downButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postMock).toHaveBeenCalledWith("/oem-votes", {
|
||||
oemCode: "X1",
|
||||
vote: "incompatible",
|
||||
});
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
"/oem-votes",
|
||||
expect.objectContaining({ oemCode: "X1", vote: "incompatible" }),
|
||||
);
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith("+3 puan kazandınız!", expect.anything());
|
||||
expect(captureMock).toHaveBeenCalledWith(
|
||||
"oem_vote_cast",
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { Car, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type OemVoteChoice = "compatible" | "incompatible";
|
||||
@@ -23,15 +23,34 @@ export interface CastOemVoteResult {
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
interface OemVoteCardProps {
|
||||
oemCode: string;
|
||||
// Katalogdan gelişte taşınan bağlam: "neyle neyin uyumlu olduğu" tek alanda
|
||||
// görünsün diye butonların üstünde araç + parça adı gösterilir. Doğrudan
|
||||
// ziyarette (bağlam yok) genel soru metnine düşülür.
|
||||
vehicleLabel?: string;
|
||||
partName?: string;
|
||||
vehicleId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EMPTY_SUMMARY: OemVoteSummary = { compatible: 0, incompatible: 0, myVote: null };
|
||||
|
||||
// OEM detay 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 }) {
|
||||
// OEM detay başlığındaki topluluk oyu kartı: uyumlu/uyumsuz + sayaçlar.
|
||||
// Arkaplanı sayfayla aynı (dolgu yok, yalnız çerçeve). Puan kuralı yalnız
|
||||
// Parça Uzmanları sayfasında yazılıdır; burada toast geri bildirimi yeter.
|
||||
export function OemVoteCard({
|
||||
oemCode,
|
||||
vehicleLabel,
|
||||
partName,
|
||||
vehicleId,
|
||||
className,
|
||||
}: OemVoteCardProps) {
|
||||
const [summary, setSummary] = useState<OemVoteSummary | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const context = [vehicleLabel, partName].filter(Boolean).join(" · ");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSummary(null);
|
||||
@@ -51,7 +70,11 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
|
||||
const cast = async (vote: OemVoteChoice) => {
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await api.post<CastOemVoteResult>("/oem-votes", { oemCode, vote });
|
||||
const result = await api.post<CastOemVoteResult>("/oem-votes", {
|
||||
oemCode,
|
||||
vote,
|
||||
vehicleId,
|
||||
});
|
||||
setSummary({ ...result.counts, myVote: result.myVote });
|
||||
if (result.isNew) {
|
||||
const isFirstVote = result.counts.compatible + result.counts.incompatible === 1;
|
||||
@@ -71,6 +94,7 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
|
||||
points_awarded: result.pointsAwarded,
|
||||
correct: result.correct,
|
||||
is_new: result.isNew,
|
||||
vehicle_id: vehicleId,
|
||||
surface: "oem_detail",
|
||||
});
|
||||
} catch {
|
||||
@@ -98,38 +122,43 @@ export function OemVoteCard({ oemCode }: { oemCode: string }) {
|
||||
];
|
||||
|
||||
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>
|
||||
<section className={cn("rounded-xl border border-border p-4", className)}>
|
||||
<h2 className="text-sm font-semibold">Topluluk uyumluluk oyu</h2>
|
||||
{context ? (
|
||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Car className="size-3.5 shrink-0" />
|
||||
<span className="truncate" title={context}>
|
||||
{context}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Bu OEM kodu sizce doğru mu? Oyunuz diğer parça satıcılarına yol gösterir.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex gap-2">
|
||||
{options.map(({ vote, icon: Icon, label, 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 flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50",
|
||||
isActive
|
||||
? activeClass
|
||||
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" fill={isActive ? "currentColor" : "none"} />
|
||||
{label}
|
||||
<span className="font-semibold tabular-nums">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Her oy puan kazandırır ·{" "}
|
||||
|
||||
@@ -157,7 +157,9 @@ describe("PartsPanel", () => {
|
||||
render(<PartsPanel parts={[buildPart()]} vehicleId="v1" categoryId="c1" />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "OEM-1" });
|
||||
expect(link).toHaveAttribute("href", "/dashboard/oem/OEM-1");
|
||||
// href bağlam taşır: parça adı + vehicleId (oy kartındaki "araç · parça" satırı)
|
||||
expect(link.getAttribute("href")).toContain("/dashboard/oem/OEM-1");
|
||||
expect(link.getAttribute("href")).toContain("vid=v1");
|
||||
// Liste açılışı artık toplu istek atmaz (/p/matched ve oy lookup'ı kalktı);
|
||||
// tek istisna kopyalama anındaki fire-and-forget analytics POST'udur.
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
|
||||
@@ -22,6 +22,17 @@ interface PartsPanelProps {
|
||||
|
||||
const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as const;
|
||||
|
||||
// OEM detay linki, oy kartının "araç · parça" bağlam satırı için görüntü
|
||||
// bağlamını search-param olarak taşır (v/p/vid; oem.$code validateSearch).
|
||||
function oemDetailHref(code: string, search: { v?: string; p?: string; vid?: string }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (search.v) qs.set("v", search.v);
|
||||
if (search.p) qs.set("p", search.p);
|
||||
if (search.vid) qs.set("vid", search.vid);
|
||||
const tail = qs.toString();
|
||||
return `/dashboard/oem/${encodeURIComponent(code)}${tail ? `?${tail}` : ""}`;
|
||||
}
|
||||
|
||||
export function PartsPanel({
|
||||
parts,
|
||||
vehicleId,
|
||||
@@ -372,7 +383,11 @@ export function PartsPanel({
|
||||
// SPA reload); real href kept so ctrl/cmd/middle-click
|
||||
// still opens a new tab.
|
||||
<a
|
||||
href={`/dashboard/oem/${encodeURIComponent(part.oemCode)}`}
|
||||
href={oemDetailHref(part.oemCode, {
|
||||
v: vehicleLabel,
|
||||
p: part.name,
|
||||
vid: vehicleId,
|
||||
})}
|
||||
title="Uyumlu parça kodlarını gör"
|
||||
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground hover:decoration-solid"
|
||||
onClick={(e) => {
|
||||
@@ -388,6 +403,7 @@ export function PartsPanel({
|
||||
navigate({
|
||||
to: "/dashboard/oem/$code",
|
||||
params: { code: part.oemCode },
|
||||
search: { v: vehicleLabel, p: part.name, vid: vehicleId },
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -116,11 +116,22 @@ function PartThumb({ src, alt }: { src: string | null; alt: string }) {
|
||||
// ─── Route ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const Route = createFileRoute("/dashboard/oem/$code")({
|
||||
// Katalogdan gelişte taşınan görüntü bağlamı: v = araç etiketi, p = parça
|
||||
// adı (oy kartında "neyle neyin uyumlu olduğu" satırı), vid = vehicleId
|
||||
// (oy kaydına analitik bağlam). Doğrudan ziyarette üçü de boş.
|
||||
validateSearch: (
|
||||
search: Record<string, unknown>,
|
||||
): { v?: string; p?: string; vid?: string } => ({
|
||||
v: typeof search.v === "string" && search.v ? search.v : undefined,
|
||||
p: typeof search.p === "string" && search.p ? search.p : undefined,
|
||||
vid: typeof search.vid === "string" && search.vid ? search.vid : undefined,
|
||||
}),
|
||||
component: OemDetailPage,
|
||||
});
|
||||
|
||||
function OemDetailPage() {
|
||||
const { code } = Route.useParams();
|
||||
const { v: vehicleLabel, p: partName, vid: vehicleId } = Route.useSearch();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["p-oem", code],
|
||||
@@ -171,33 +182,40 @@ function OemDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* ─── Header ─────────────────────────────────────────────────────── */}
|
||||
<header className="flex items-start gap-4">
|
||||
<Button asChild variant="ghost" size="icon" className="mt-0.5 shrink-0">
|
||||
<Link to="/dashboard/search" aria-label="Geri">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
OEM kodu
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="font-mono text-2xl font-bold tracking-tight break-all">{code}</h1>
|
||||
<CopyCode
|
||||
code={code}
|
||||
className="rounded-md border border-border px-2 py-1 hover:bg-accent"
|
||||
/>
|
||||
{/* ─── Header: solda OEM kodu, desktop'ta sağ simetriğinde topluluk
|
||||
oyu kartı; mobilde kart kodun altına iner ─────────────────────── */}
|
||||
<header className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-4">
|
||||
<Button asChild variant="ghost" size="icon" className="mt-0.5 shrink-0">
|
||||
<Link to="/dashboard/search" aria-label="Geri">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
OEM kodu
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="font-mono text-2xl font-bold tracking-tight break-all">{code}</h1>
|
||||
<CopyCode
|
||||
code={code}
|
||||
className="rounded-md border border-border px-2 py-1 hover:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Uyumlu parça kodları ve muadil numaralar
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Uyumlu parça kodları ve muadil numaralar
|
||||
</p>
|
||||
</div>
|
||||
<OemVoteCard
|
||||
oemCode={code}
|
||||
vehicleLabel={vehicleLabel}
|
||||
partName={partName}
|
||||
vehicleId={vehicleId}
|
||||
className="w-full lg:w-96 lg:shrink-0"
|
||||
/>
|
||||
</header>
|
||||
|
||||
{/* ─── Topluluk uyumluluk oyu (P eşleşmesinden bağımsız) ───────────── */}
|
||||
<OemVoteCard oemCode={code} />
|
||||
|
||||
{/* ─── Loading ────────────────────────────────────────────────────── */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user