Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion frontend/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1096,7 +1096,10 @@
"allCountries": "Alle Länder",
"gs1Mismatch": "GS1-Barcode deutet auf {gs1Country}, aber Einreichung zielt auf {effectiveCountry}",
"regionMismatch": "Gescannt in {scanCountry}, aber vorgeschlagen für {suggestedCountry}",
"crossCountryProducts": "Gleiche EAN existiert in {countries} ({count} Produkt(e))"
"crossCountryProducts": "Gleiche EAN existiert in {countries} ({count} Produkt(e))",
"noCountry": "Kein Land",
"noCountryHint": "Eingereicht vor der Länder-Erfassung",
"gs1InfoHint": "GS1-Barcode registriert in {gs1Country} (informativ)"
},
"monitoring": {
"title": "Systemüberwachung",
Expand Down
5 changes: 4 additions & 1 deletion frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1096,7 +1096,10 @@
"allCountries": "All countries",
"gs1Mismatch": "GS1 barcode suggests {gs1Country} but submission targets {effectiveCountry}",
"regionMismatch": "Scanned in {scanCountry} but suggested for {suggestedCountry}",
"crossCountryProducts": "Same EAN exists in {countries} ({count} product(s))"
"crossCountryProducts": "Same EAN exists in {countries} ({count} product(s))",
"noCountry": "No country",
"noCountryHint": "Submitted before country tracking was added",
"gs1InfoHint": "GS1 barcode registered in {gs1Country} (informational)"
},
"monitoring": {
"title": "System Monitoring",
Expand Down
5 changes: 4 additions & 1 deletion frontend/messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1096,7 +1096,10 @@
"allCountries": "Wszystkie kraje",
"gs1Mismatch": "Kod GS1 wskazuje na {gs1Country}, ale zgłoszenie dotyczy {effectiveCountry}",
"regionMismatch": "Zeskanowano w {scanCountry}, ale zasugerowano dla {suggestedCountry}",
"crossCountryProducts": "Ten sam EAN istnieje w {countries} ({count} produkt(ów))"
"crossCountryProducts": "Ten sam EAN istnieje w {countries} ({count} produkt(ów))",
"noCountry": "Brak kraju",
"noCountryHint": "Zgłoszone przed dodaniem śledzenia krajów",
"gs1InfoHint": "Kod GS1 zarejestrowany w {gs1Country} (informacyjnie)"
},
"monitoring": {
"title": "Monitoring systemu",
Expand Down
129 changes: 125 additions & 4 deletions frontend/src/app/app/admin/submissions/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ vi.mock("@/components/common/skeletons", () => ({
}));

vi.mock("@/components/common/CountryChip", () => ({
CountryChip: ({ country }: { country: string | null }) =>
country ? <span data-testid="country-chip">{country}</span> : null,
CountryChip: ({ country, nullLabel }: { country: string | null; nullLabel?: string }) =>
country ? (
<span data-testid="country-chip">{country}</span>
) : nullLabel ? (
<span data-testid="country-chip" data-null-country="">{nullLabel}</span>
) : null,
}));

// ─── Helpers ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -581,7 +585,7 @@ describe("AdminSubmissionsPage", () => {
expect(chip).toHaveTextContent("DE");
});

it("does not render country chip when both countries are null", async () => {
it("shows fallback country chip when both countries are null", async () => {
mockCallRpc.mockImplementation((_client: unknown, fnName: string) => {
if (fnName === "api_admin_get_submissions") {
return Promise.resolve({
Expand All @@ -606,7 +610,9 @@ describe("AdminSubmissionsPage", () => {
await waitFor(() => {
expect(screen.getByText("Unknown Origin")).toBeInTheDocument();
});
expect(screen.queryByTestId("country-chip")).not.toBeInTheDocument();
const chip = screen.getByTestId("country-chip");
expect(chip).toBeInTheDocument();
expect(chip).toHaveAttribute("data-null-country");
});

it("renders country filter dropdown", async () => {
Expand Down Expand Up @@ -857,4 +863,119 @@ describe("AdminSubmissionsPage", () => {
});
expect(screen.queryByTestId("cross-country-badge")).not.toBeInTheDocument();
});

// ─── Legacy Null-Country UX Tests ─────────────────────────────────────────

it("shows legacy help text for null-country submission", async () => {
mockCallRpc.mockImplementation((_client: unknown, fnName: string) => {
if (fnName === "api_admin_get_submissions") {
return Promise.resolve({
ok: true,
data: {
submissions: [
makeSubmission({
scan_country: null,
suggested_country: null,
product_name: "Legacy Product",
}),
],
page: 1,
pages: 1,
total: 1,
},
});
}
return Promise.resolve({ ok: true, data: {} });
});
render(<AdminSubmissionsPage />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId("no-country-info")).toBeInTheDocument();
});
});

it("shows GS1 informational hint when country is null but gs1_hint exists", async () => {
mockCallRpc.mockImplementation((_client: unknown, fnName: string) => {
if (fnName === "api_admin_get_submissions") {
return Promise.resolve({
ok: true,
data: {
submissions: [
makeSubmission({
scan_country: null,
suggested_country: null,
gs1_hint: { code: "DE", name: "Germany", confidence: "high", prefix: "400" },
product_name: "GS1 Hint Legacy",
}),
],
page: 1,
pages: 1,
total: 1,
},
});
}
return Promise.resolve({ ok: true, data: {} });
});
render(<AdminSubmissionsPage />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId("gs1-info-hint")).toBeInTheDocument();
});
expect(screen.queryByTestId("gs1-mismatch-badge")).not.toBeInTheDocument();
});

it("renders No country option in country filter", async () => {
render(<AdminSubmissionsPage />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId("country-filter")).toBeInTheDocument();
});
expect(screen.getByText("No country")).toBeInTheDocument();
});

it("sends p_country __none__ when No country filter is selected", async () => {
render(<AdminSubmissionsPage />, { wrapper: createWrapper() });
const user = userEvent.setup();

await waitFor(() => {
expect(screen.getByTestId("country-filter")).toBeInTheDocument();
});

await user.selectOptions(screen.getByTestId("country-filter"), "__none__");

await waitFor(() => {
expect(mockCallRpc).toHaveBeenCalledWith(
expect.anything(),
"api_admin_get_submissions",
expect.objectContaining({ p_country: "__none__" }),
);
});
});

it("suppresses mismatch badges for null-country submissions", async () => {
mockCallRpc.mockImplementation((_client: unknown, fnName: string) => {
if (fnName === "api_admin_get_submissions") {
return Promise.resolve({
ok: true,
data: {
submissions: [
makeSubmission({
scan_country: null,
suggested_country: null,
gs1_hint: { code: "DE", name: "Germany", confidence: "high", prefix: "400" },
product_name: "No Mismatch Legacy",
}),
],
page: 1,
pages: 1,
total: 1,
},
});
}
return Promise.resolve({ ok: true, data: {} });
});
render(<AdminSubmissionsPage />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText("No Mismatch Legacy")).toBeInTheDocument();
});
expect(screen.queryByTestId("gs1-mismatch-badge")).not.toBeInTheDocument();
expect(screen.queryByTestId("region-mismatch-badge")).not.toBeInTheDocument();
});
});
66 changes: 41 additions & 25 deletions frontend/src/app/app/admin/submissions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,31 @@ import { CountryChip } from "@/components/common/CountryChip";
import { EmptyStateIllustration } from "@/components/common/EmptyStateIllustration";
import { SubmissionsSkeleton } from "@/components/common/skeletons";
import { Breadcrumbs } from "@/components/layout/Breadcrumbs";
import { useTranslation } from "@/lib/i18n";
import { COUNTRIES } from "@/lib/constants";
import { useTranslation } from "@/lib/i18n";
import { callRpc } from "@/lib/rpc";
import { createClient } from "@/lib/supabase/client";
import { showToast } from "@/lib/toast";
import type {
AdminBatchRejectResponse,
AdminReviewResponse,
AdminSubmission,
AdminSubmissionsResponse,
AdminVelocityResponse,
RpcResult,
AdminBatchRejectResponse,
AdminReviewResponse,
AdminSubmission,
AdminSubmissionsResponse,
AdminVelocityResponse,
RpcResult,
} from "@/lib/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Activity,
Ban,
CheckCircle,
Clock,
FileText,
Link2,
RefreshCw,
ShieldAlert,
ShieldCheck,
XCircle,
Activity,
Ban,
CheckCircle,
Clock,
FileText,
Link2,
RefreshCw,
ShieldAlert,
ShieldCheck,
XCircle,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";

Expand Down Expand Up @@ -284,6 +284,7 @@ export default function AdminSubmissionsPage() {
{c.code}
</option>
))}
<option value="__none__">{t("admin.noCountry")}</option>
</select>
Comment on lines 284 to 288
</div>

Expand Down Expand Up @@ -408,6 +409,7 @@ function AdminSubmissionCard({
const { t } = useTranslation();
const date = new Date(submission.created_at).toLocaleString();
const canReview = submission.status === "pending";
const effectiveCountry = submission.suggested_country ?? submission.scan_country;

return (
<li className="card">
Expand All @@ -432,10 +434,9 @@ function AdminSubmissionCard({
</div>
<div className="flex items-center gap-2">
<CountryChip
country={
submission.suggested_country ?? submission.scan_country
}
country={effectiveCountry}
size="sm"
nullLabel={t("admin.noCountry")}
/>
{submission.user_trust_score !== null &&
submission.user_trust_score !== undefined && (
Expand All @@ -459,6 +460,13 @@ function AdminSubmissionCard({
</p>
)}

{/* Legacy help text for null-country submissions */}
{!effectiveCountry && (
<p className="text-xs italic text-foreground-muted" data-testid="no-country-info">
ℹ {t("admin.noCountryHint")}
</p>
)}

{submission.existing_product_match && (
<p className="text-xs text-warning-text">
⚠ Possible duplicate — existing product #{submission.existing_product_match.product_id}{" "}
Expand All @@ -470,18 +478,26 @@ function AdminSubmissionCard({
{submission.gs1_hint &&
submission.gs1_hint.code !== "UNKNOWN" &&
submission.gs1_hint.code !== "STORE" &&
(submission.suggested_country ?? submission.scan_country) &&
submission.gs1_hint.code !==
(submission.suggested_country ?? submission.scan_country) && (
effectiveCountry &&
submission.gs1_hint.code !== effectiveCountry && (
<p className="text-xs text-warning-text" data-testid="gs1-mismatch-badge">
⚠ {t("admin.gs1Mismatch", {
gs1Country: submission.gs1_hint.name,
effectiveCountry:
(submission.suggested_country ?? submission.scan_country)!,
effectiveCountry: effectiveCountry,
})}
</p>
)}

{/* GS1 informational hint for legacy null-country submissions */}
{!effectiveCountry &&
submission.gs1_hint &&
submission.gs1_hint.code !== "UNKNOWN" &&
submission.gs1_hint.code !== "STORE" && (
<p className="text-xs text-foreground-secondary" data-testid="gs1-info-hint">
ℹ {t("admin.gs1InfoHint", { gs1Country: submission.gs1_hint.name })}
</p>
)}

{/* Region mismatch badge (#929) */}
{submission.scan_country &&
submission.suggested_country &&
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/components/common/CountryChip.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CountryChip } from "./CountryChip";

// ─── Mocks ────────────────────────────────────────────────────────────────────
Expand All @@ -24,6 +24,14 @@ describe("CountryChip", () => {
expect(container.innerHTML).toBe("");
});

it("renders fallback chip with nullLabel when country is null", () => {
render(<CountryChip country={null} nullLabel="No country" />);
const chip = screen.getByRole("img");
expect(chip).toBeTruthy();
expect(chip.getAttribute("aria-label")).toBe("No country");
expect(screen.getByText("No country")).toBeTruthy();
});

// ─── SVG flag rendering ────────────────────────────────────────────────

it("renders SVG flag for PL (not emoji)", () => {
Expand Down
21 changes: 19 additions & 2 deletions frontend/src/components/common/CountryChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
// ─── Types ──────────────────────────────────────────────────────────────────

interface CountryChipProps {
/** ISO 3166-1 alpha-2 country code. Null → render nothing. */
/** ISO 3166-1 alpha-2 country code. Null → render nothing (unless nullLabel provided). */
country: string | null;
/** Show full country name instead of 2-letter code. */
showLabel?: boolean;
/** Badge size variant. */
size?: "sm" | "md";
/** Label to show when country is null. If omitted, null renders nothing. */
nullLabel?: string;
className?: string;
}

Expand Down Expand Up @@ -95,11 +97,26 @@
country,
showLabel = false,
size = "md",
nullLabel,
className = "",
}: Readonly<CountryChipProps>) {
const { t } = useTranslation();

if (!country) return null;
if (!country && !nullLabel) return null;

if (!country) {
const cfg = SIZE_CONFIG[size];
return (
<span
role="img"
aria-label={nullLabel!}

Check warning on line 112 in frontend/src/components/common/CountryChip.tsx

View workflow job for this annotation

GitHub Actions / Typecheck & Lint

Forbidden non-null assertion
className={`inline-flex items-center ${cfg.gap} rounded-full border border-border bg-surface-muted ${cfg.px} ${cfg.text} font-medium text-foreground-muted ${className}`}
>
<FallbackFlag size={cfg.flag} />
<span>{nullLabel}</span>
</span>
);
}

const meta = COUNTRIES.find((c) => c.code === country);
const name = meta?.name ?? country;
Expand Down
Loading