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
1 change: 1 addition & 0 deletions frontend/src/app/app/scan/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,7 @@ describe("ScanPage", () => {
expect(mockRecordScan).toHaveBeenCalledWith(
expect.anything(),
"5901234123457",
undefined,
);
});
});
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/app/app/scan/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { useAnalytics } from "@/hooks/use-analytics";
import { useBarcodeScanner } from "@/hooks/use-barcode-scanner";
import { recordScan } from "@/lib/api";
import { usePreferences } from "@/components/common/RouteGuard";
import { NUTRI_COLORS } from "@/lib/constants";
import { eventBus } from "@/lib/events";
import { useTranslation } from "@/lib/i18n";
Expand Down Expand Up @@ -51,6 +52,8 @@ export default function ScanPage() {
const queryClient = useQueryClient();
const { track } = useAnalytics();
const { t } = useTranslation();
const prefs = usePreferences();
const userCountry = prefs?.country ?? undefined;
const [ean, setEan] = useState("");
const [manualEan, setManualEan] = useState("");
const [mode, setMode] = useState<"camera" | "manual">("camera");
Expand All @@ -70,7 +73,7 @@ export default function ScanPage() {

const scanMutation = useMutation({
mutationFn: async (scanEan: string) => {
const result = await recordScan(supabase, scanEan);
const result = await recordScan(supabase, scanEan, userCountry);
if (!result.ok) throw new Error(result.error.message);
return result.data;
},
Expand Down Expand Up @@ -200,6 +203,7 @@ export default function ScanPage() {
ean={ean}
scanResult={scanResult as RecordScanNotFoundResponse}
onReset={() => handleReset()}
country={userCountry}
/>
);
}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/app/app/scan/submit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ import { FileText } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { submitProduct } from "@/lib/api";
import { useTranslation } from "@/lib/i18n";
import { usePreferences } from "@/components/common/RouteGuard";
import type { FormSubmitEvent } from "@/lib/types";

export default function SubmitProductPage() {
const supabase = createClient();
const router = useRouter();
const searchParams = useSearchParams();
const prefillEan = searchParams.get("ean") ?? "";
const urlCountry = searchParams.get("country") ?? undefined;
const prefs = usePreferences();
const scanCountry = urlCountry ?? prefs?.country ?? undefined;
Comment on lines 24 to +27

const [ean, setEan] = useState(prefillEan);
const [productName, setProductName] = useState("");
Expand All @@ -37,6 +41,8 @@ export default function SubmitProductPage() {
brand: brand || undefined,
category: category || undefined,
notes: notes || undefined,
scanCountry,
suggestedCountry: scanCountry,
});
if (!result.ok) throw new Error(result.error.message);
if (result.data.error) throw new Error(result.data.error);
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/components/scan/ScanMissSubmitCTA.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ describe("ScanMissSubmitCTA", () => {
);
});

it("includes country in submit link when provided", () => {
render(<ScanMissSubmitCTA ean="5901234123457" country="DE" />);
const link = screen.getByRole("link");
expect(link.getAttribute("href")).toBe(
"/app/scan/submit?ean=5901234123457&country=DE"
);
});

it("renders hint text below the button", () => {
render(<ScanMissSubmitCTA ean="5901234123457" />);
expect(screen.getByText("scan.helpAddHint")).toBeTruthy();
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/components/scan/ScanMissSubmitCTA.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { Clock, FileText } from "lucide-react";
interface ScanMissSubmitCTAProps {
ean: string;
hasPendingSubmission?: boolean;
country?: string;
}

/** CTA shown when a scanned barcode is not found in the database. */
export function ScanMissSubmitCTA({
ean,
hasPendingSubmission = false,
country,
}: ScanMissSubmitCTAProps) {
const { t } = useTranslation();

Expand All @@ -29,10 +31,14 @@ export function ScanMissSubmitCTA({
);
}

const submitHref = country
? `/app/scan/submit?ean=${ean}&country=${country}`
: `/app/scan/submit?ean=${ean}`;
Comment on lines +34 to +36

return (
<div className="space-y-2">
<ButtonLink
href={`/app/scan/submit?ean=${ean}`}
href={submitHref}
fullWidth
icon={<FileText size={16} aria-hidden="true" />}
>
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/components/scan/ScanResultView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ vi.mock("@/components/scan/ScanMissSubmitCTA", () => ({
ScanMissSubmitCTA: ({
ean,
hasPendingSubmission,
country,
}: {
ean: string;
hasPendingSubmission?: boolean;
country?: string;
}) => (
<div data-testid="scan-miss-submit-cta" data-ean={ean} data-pending={String(!!hasPendingSubmission)} />
<div data-testid="scan-miss-submit-cta" data-ean={ean} data-pending={String(!!hasPendingSubmission)} data-country={country ?? ""} />
),
}));

Expand Down Expand Up @@ -183,6 +185,19 @@ describe("ScanNotFoundView", () => {
expect(cta).toHaveAttribute("data-pending", "true");
});

it("passes country prop to ScanMissSubmitCTA", () => {
render(
<ScanNotFoundView
ean="5901234123457"
scanResult={{ api_version: "v1", found: false, ean: "5901234123457", has_pending_submission: false }}
onReset={onReset}
country="DE"
/>,
);
const cta = screen.getByTestId("scan-miss-submit-cta");
expect(cta).toHaveAttribute("data-country", "DE");
});

it("renders scan-another and history links", () => {
render(
<ScanNotFoundView
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/scan/ScanResultView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,14 @@ interface ScanNotFoundProps {
ean: string;
scanResult: RecordScanNotFoundResponse;
onReset: () => void;
country?: string;
}

export function ScanNotFoundView({
ean,
scanResult,
onReset,
country,
}: ScanNotFoundProps) {
const { t } = useTranslation();

Expand All @@ -101,6 +103,7 @@ export function ScanNotFoundView({
<ScanMissSubmitCTA
ean={ean}
hasPendingSubmission={scanResult.has_pending_submission}
country={country}
/>

<div className="flex gap-2">
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/hooks/use-submissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export function useSubmitProduct() {
category?: string;
photoUrl?: string;
notes?: string;
scanCountry?: string;
suggestedCountry?: string;
}) => {
const result = await submitProduct(supabase, params);
if (!result.ok) throw new Error(result.error.message);
Expand Down
21 changes: 19 additions & 2 deletions frontend/src/lib/api-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,25 @@ describe("recordScanViaGateway", () => {
const result = await recordScanViaGateway(fakeSupabase, "5901234123457");

expect(mockInvoke).toHaveBeenCalledWith("api-gateway", {
body: { action: "record-scan", ean: "5901234123457" },
body: { action: "record-scan", ean: "5901234123457", scan_country: null },
});
expect(result).toEqual({ ok: true, data: { scan_id: 42 } });
});

it("should pass scan_country to gateway when provided", async () => {
mockInvoke.mockResolvedValue({
data: { ok: true, data: { scan_id: 43 } },
error: null,
});

const result = await recordScanViaGateway(fakeSupabase, "5901234123457", "PL");

expect(mockInvoke).toHaveBeenCalledWith("api-gateway", {
body: { action: "record-scan", ean: "5901234123457", scan_country: "PL" },
});
expect(result).toEqual({ ok: true, data: { scan_id: 43 } });
});

it("should return gateway error response as-is", async () => {
const gatewayError = {
ok: false,
Expand Down Expand Up @@ -253,6 +267,7 @@ describe("recordScanViaGateway", () => {

expect(mockRpc).toHaveBeenCalledWith("api_record_scan", {
p_ean: "5901234123457",
p_scan_country: null,
});
expect(result).toEqual({ ok: true, data: { scan_id: 99 } });
});
Expand Down Expand Up @@ -444,6 +459,8 @@ describe("submitProductViaGateway", () => {
p_category: "Chips",
p_photo_url: null,
p_notes: null,
p_scan_country: null,
p_suggested_country: null,
});
expect(result.ok).toBe(true);
});
Expand Down Expand Up @@ -609,7 +626,7 @@ describe("createApiGateway", () => {
const result = await gateway.recordScan("5901234123457");

expect(mockInvoke).toHaveBeenCalledWith("api-gateway", {
body: { action: "record-scan", ean: "5901234123457" },
body: { action: "record-scan", ean: "5901234123457", scan_country: null },
});
expect(result).toEqual({ ok: true, data: { scan_id: 1 } });
});
Expand Down
16 changes: 13 additions & 3 deletions frontend/src/lib/api-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,19 @@ async function invokeGateway<T = unknown>(
export async function recordScanViaGateway(
supabase: SupabaseClient,
ean: string,
scanCountry?: string,
): Promise<GatewayResult> {
const result = await invokeGateway(supabase, "record-scan", { ean });
const result = await invokeGateway(supabase, "record-scan", {
ean,
scan_country: scanCountry ?? null,
Comment on lines +125 to +127

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward scan country in reachable gateway path

recordScanViaGateway now takes scanCountry, but when the gateway is reachable the request is handled by handleRecordScan, which still calls api_record_scan with only p_ean (supabase/functions/api-gateway/index.ts:383-386). That means scan_country is only written in the gateway_unreachable fallback branch, so normal gateway traffic silently loses country context and skews country-aware scan metrics.

Useful? React with 👍 / 👎.

});
Comment on lines +125 to +128

// Graceful degradation: if gateway is unreachable, fall back to direct RPC
if (!result.ok && result.error === "gateway_unreachable") {
try {
const { data, error } = await supabase.rpc("api_record_scan", {
p_ean: ean,
p_scan_country: scanCountry ?? null,
});
if (error) {
return {
Expand Down Expand Up @@ -155,6 +160,8 @@ export interface SubmitProductParams {
category?: string | null;
photo_url?: string | null;
notes?: string | null;
scan_country?: string | null;
suggested_country?: string | null;
/** Turnstile CAPTCHA token. Required when trust is low or velocity is high. */
turnstile_token?: string | null;
}
Expand All @@ -180,6 +187,8 @@ export async function submitProductViaGateway(
p_category: params.category ?? null,
p_photo_url: params.photo_url ?? null,
p_notes: params.notes ?? null,
p_scan_country: params.scan_country ?? null,
p_suggested_country: params.suggested_country ?? null,
Comment on lines 187 to +191
Comment on lines +190 to +191

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve submit countries outside gateway fallback

These new country fields are only guaranteed in the direct-RPC fallback branch here; in the normal gateway-success path, handleSubmitProduct forwards only p_ean, p_product_name, p_brand, p_category, p_photo_url, and p_notes (supabase/functions/api-gateway/index.ts:494-500). When the gateway is up, scan_country and suggested_country are dropped, which leaves country-targeted submission data incomplete.

Useful? React with 👍 / 👎.

});
if (error) {
return {
Expand Down Expand Up @@ -287,7 +296,7 @@ export async function saveSearchViaGateway(
// ─── Gateway Factory ────────────────────────────────────────────────────────

export interface ApiGateway {
recordScan: (ean: string) => Promise<GatewayResult>;
recordScan: (ean: string, scanCountry?: string) => Promise<GatewayResult>;
submitProduct: (params: SubmitProductParams) => Promise<GatewayResult>;
trackEvent: (params: TrackEventParams) => Promise<GatewayResult>;
saveSearch: (params: SaveSearchParams) => Promise<GatewayResult>;
Expand All @@ -309,7 +318,8 @@ export interface ApiGateway {
*/
export function createApiGateway(supabase: SupabaseClient): ApiGateway {
return {
recordScan: (ean: string) => recordScanViaGateway(supabase, ean),
recordScan: (ean: string, scanCountry?: string) =>
recordScanViaGateway(supabase, ean, scanCountry),
submitProduct: (params: SubmitProductParams) =>
submitProductViaGateway(supabase, params),
trackEvent: (params: TrackEventParams) =>
Expand Down
36 changes: 35 additions & 1 deletion frontend/src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,11 +684,21 @@ describe("Product Comparisons API functions", () => {
describe("Scanner & Submissions API functions", () => {
beforeEach(() => vi.clearAllMocks());

it("recordScan passes ean", async () => {
it("recordScan passes ean with null country by default", async () => {
mockCallRpc.mockResolvedValue({ ok: true, data: { scan_id: "s1" } });
await recordScan(fakeSupabase, "5901234123457");
expect(mockCallRpc).toHaveBeenCalledWith(fakeSupabase, "api_record_scan", {
p_ean: "5901234123457",
p_scan_country: null,
});
});

it("recordScan passes scan country when provided", async () => {
mockCallRpc.mockResolvedValue({ ok: true, data: { scan_id: "s2" } });
await recordScan(fakeSupabase, "5901234123457", "PL");
expect(mockCallRpc).toHaveBeenCalledWith(fakeSupabase, "api_record_scan", {
p_ean: "5901234123457",
p_scan_country: "PL",
});
});

Expand Down Expand Up @@ -722,6 +732,8 @@ describe("Scanner & Submissions API functions", () => {
p_category: null,
p_photo_url: null,
p_notes: null,
p_scan_country: null,
p_suggested_country: null,
});
});

Expand All @@ -742,6 +754,28 @@ describe("Scanner & Submissions API functions", () => {
p_category: "Chips",
p_photo_url: "https://example.com/img.jpg",
p_notes: "Found at Żabka",
p_scan_country: null,
p_suggested_country: null,
});
});

it("submitProduct passes scan and suggested country when provided", async () => {
mockCallRpc.mockResolvedValue({ ok: true, data: { submission_id: "sub3" } });
await submitProduct(fakeSupabase, {
ean: "123",
productName: "Test",
scanCountry: "DE",
suggestedCountry: "DE",
});
expect(mockCallRpc).toHaveBeenCalledWith(fakeSupabase, "api_submit_product", {
p_ean: "123",
p_product_name: "Test",
p_brand: null,
p_category: null,
p_photo_url: null,
p_notes: null,
p_scan_country: "DE",
p_suggested_country: "DE",
});
});

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,9 +723,11 @@ export function deleteComparison(
export function recordScan(
supabase: SupabaseClient,
ean: string,
scanCountry?: string,
): Promise<RpcResult<RecordScanResponse>> {
return callRpc<RecordScanResponse>(supabase, "api_record_scan", {
p_ean: ean,
p_scan_country: scanCountry ?? null,
});
Comment on lines 723 to 731
}

Expand All @@ -751,6 +753,8 @@ export function submitProduct(
category?: string;
photoUrl?: string;
notes?: string;
scanCountry?: string;
suggestedCountry?: string;
},
): Promise<RpcResult<SubmitProductResponse>> {
return callRpc<SubmitProductResponse>(supabase, "api_submit_product", {
Expand All @@ -760,6 +764,8 @@ export function submitProduct(
p_category: params.category ?? null,
p_photo_url: params.photoUrl ?? null,
p_notes: params.notes ?? null,
p_scan_country: params.scanCountry ?? null,
p_suggested_country: params.suggestedCountry ?? null,
});
}

Expand Down
Loading