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 ·{" "}
|
||||
|
||||
Reference in New Issue
Block a user