diff --git a/frontend/messages/de.json b/frontend/messages/de.json index c67f6d36..60825206 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -298,9 +298,9 @@ "off": "Aus", "restart": "Neustart", "cameraHint": "Richten Sie die Kamera auf einen Barcode. Unterstützt EAN-13, EAN-8, UPC-A, UPC-E.", - "manualPlaceholder": "EAN-Barcode eingeben (8 oder 13 Ziffern)", + "manualPlaceholder": "Barcode eingeben", "lookUp": "Nachschlagen", - "digitHint": "Geben Sie 8 Ziffern (EAN-8) oder 13 Ziffern (EAN-13) ein", + "digitHint": "EAN-8 (8 Ziffern), UPC-A (12) oder EAN-13 (13)", "scannedCount": "Gescannt ({count})", "doneScan": "Scannen beenden", "lookupFailed": "Suche fehlgeschlagen", @@ -328,7 +328,7 @@ "noCameraHint": "Dieses Gerät hat keine Kamera. Verwenden Sie die manuelle Eingabe, um Produkte nachzuschlagen.", "torchNotSupported": "Taschenlampe wird auf diesem Gerät nicht unterstützt", "torchError": "Taschenlampe konnte nicht umgeschaltet werden", - "invalidBarcode": "Bitte geben Sie einen gültigen 8- oder 13-stelligen Barcode ein", + "invalidBarcode": "Bitte geben Sie einen gültigen 8-, 12- oder 13-stelligen Barcode ein", "lookingUp": "Suche nach {ean}…", "submissionsSubtitle": "Von Ihnen zur Überprüfung eingereichte Produkte", "submissionsLoadFailed": "Einreichungen konnten nicht geladen werden.", @@ -353,7 +353,9 @@ "viewDetails": "Details anzeigen", "scanNext": "Nächstes scannen", "pasteBarcode": "Einfügen", - "crossCountryBadge": "Aus dem {country}-Katalog" + "crossCountryBadge": "Aus dem {country}-Katalog", + "gs1Hint": "Barcode registriert in {country}", + "checksumWarning": "Pr\u00fcfziffer stimmt nicht \u2014 Barcode \u00fcberpr\u00fcfen" }, "scannerError": { "permissionDenied": "Kamerazugriff verweigert. Erlauben Sie den Kamerazugriff in Ihren Browsereinstellungen und versuchen Sie es erneut.", @@ -383,13 +385,19 @@ "subtitle": "Helfen Sie uns, ein fehlendes Produkt hinzuzufügen", "backToScanner": "← Zurück zum Scanner", "eanLabel": "EAN-Barcode *", - "eanPlaceholder": "8 oder 13 Ziffern", + "eanPlaceholder": "8, 12 oder 13 Ziffern", "nameLabel": "Produktname *", - "namePlaceholder": "z. B. Lay's Paprika Chips 150g", + "namePlaceholder": "z. B. Haribo Goldbären 200g", "brandLabel": "Marke", - "brandPlaceholder": "z. B. Lay's", + "brandPlaceholder": "z. B. Haribo", "categoryLabel": "Kategorie", - "categoryPlaceholder": "z. B. Chips, Getränke, Müsli", + "categoryPlaceholder": "Kategorie wählen", + "photoLabel": "Produktfoto", + "photoHint": "Fotografieren Sie die Vorderseite", + "photoRemove": "Foto entfernen", + "photoInvalidType": "Bitte wählen Sie eine Bilddatei (JPEG, PNG oder WebP)", + "photoTooLarge": "Das Foto muss kleiner als 5 MB sein", + "countryLabel": "Land", "notesLabel": "Anmerkungen", "notesPlaceholder": "Zusätzliche Informationen zu diesem Produkt…", "submitting": "Wird eingereicht…", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1944a0d3..419127dc 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -298,9 +298,9 @@ "off": "Off", "restart": "Restart", "cameraHint": "Point camera at a barcode. Supports EAN-13, EAN-8, UPC-A, UPC-E.", - "manualPlaceholder": "Enter EAN barcode (8 or 13 digits)", + "manualPlaceholder": "Enter barcode", "lookUp": "Look up", - "digitHint": "Enter 8 digits (EAN-8) or 13 digits (EAN-13)", + "digitHint": "EAN-8 (8 digits), UPC-A (12) or EAN-13 (13)", "scannedCount": "Scanned ({count})", "doneScan": "Done scanning", "lookupFailed": "Lookup failed", @@ -328,7 +328,7 @@ "noCameraHint": "This device does not have a camera. Use manual entry to look up products.", "torchNotSupported": "Torch not supported on this device", "torchError": "Could not toggle torch", - "invalidBarcode": "Please enter a valid 8 or 13 digit barcode", + "invalidBarcode": "Please enter a valid 8, 12 or 13 digit barcode", "lookingUp": "Looking up {ean}…", "submissionsSubtitle": "Products you've submitted for review", "submissionsLoadFailed": "Failed to load submissions.", @@ -353,7 +353,9 @@ "viewDetails": "View Details", "scanNext": "Scan Next", "pasteBarcode": "Paste", - "crossCountryBadge": "From the {country} catalog" + "crossCountryBadge": "From the {country} catalog", + "gs1Hint": "Barcode registered in {country}", + "checksumWarning": "Check digit doesn\u2019t match \u2014 verify the barcode" }, "scannerError": { "permissionDenied": "Camera permission denied. Allow camera access in your browser settings, then try again.", @@ -383,13 +385,19 @@ "subtitle": "Help us add a missing product", "backToScanner": "← Back to Scanner", "eanLabel": "EAN Barcode *", - "eanPlaceholder": "8 or 13 digits", + "eanPlaceholder": "8, 12 or 13 digits", "nameLabel": "Product Name *", "namePlaceholder": "e.g. Lay's Paprika Chips 150g", "brandLabel": "Brand", "brandPlaceholder": "e.g. Lay's", "categoryLabel": "Category", - "categoryPlaceholder": "e.g. chips, drinks, cereal", + "categoryPlaceholder": "Select a category", + "photoLabel": "Product Photo", + "photoHint": "Take a photo of the front label", + "photoRemove": "Remove photo", + "photoInvalidType": "Please select an image file (JPEG, PNG, or WebP)", + "photoTooLarge": "Photo must be smaller than 5 MB", + "countryLabel": "Country", "notesLabel": "Notes", "notesPlaceholder": "Any additional info about this product…", "submitting": "Submitting…", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 5fab5bdf..c17d3412 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -298,9 +298,9 @@ "off": "Wył.", "restart": "Restart", "cameraHint": "Skieruj kamerę na kod kreskowy. Obsługiwane: EAN-13, EAN-8, UPC-A, UPC-E.", - "manualPlaceholder": "Wpisz kod EAN (8 lub 13 cyfr)", + "manualPlaceholder": "Wpisz kod kreskowy", "lookUp": "Wyszukaj", - "digitHint": "Wpisz 8 cyfr (EAN-8) lub 13 cyfr (EAN-13)", + "digitHint": "EAN-8 (8 cyfr), UPC-A (12) lub EAN-13 (13)", "scannedCount": "Zeskanowane ({count})", "doneScan": "Zakończ skanowanie", "lookupFailed": "Wyszukiwanie nie powiodło się", @@ -328,7 +328,7 @@ "noCameraHint": "To urządzenie nie posiada kamery. Użyj trybu ręcznego, aby wyszukać produkty.", "torchNotSupported": "Latarka nie jest obsługiwana na tym urządzeniu", "torchError": "Nie udało się przełączyć latarki", - "invalidBarcode": "Wprowadź prawidłowy 8- lub 13-cyfrowy kod kreskowy", + "invalidBarcode": "Wprowadź prawidłowy 8-, 12- lub 13-cyfrowy kod kreskowy", "lookingUp": "Wyszukiwanie {ean}…", "submissionsSubtitle": "Produkty zgłoszone przez Ciebie do weryfikacji", "submissionsLoadFailed": "Nie udało się załadować zgłoszeń.", @@ -353,7 +353,9 @@ "viewDetails": "Zobacz szczegóły", "scanNext": "Skanuj następny", "pasteBarcode": "Wklej", - "crossCountryBadge": "Z katalogu: {country}" + "crossCountryBadge": "Z katalogu: {country}", + "gs1Hint": "Kod kreskowy zarejestrowany w {country}", + "checksumWarning": "Cyfra kontrolna nie zgadza si\u0119 \u2014 sprawd\u017a kod kreskowy" }, "scannerError": { "permissionDenied": "Odmowa dostępu do kamery. Zezwól na dostęp do kamery w ustawieniach przeglądarki i spróbuj ponownie.", @@ -383,13 +385,19 @@ "subtitle": "Pomóż nam dodać brakujący produkt", "backToScanner": "← Wróć do skanera", "eanLabel": "Kod kreskowy EAN *", - "eanPlaceholder": "8 lub 13 cyfr", + "eanPlaceholder": "8, 12 lub 13 cyfr", "nameLabel": "Nazwa produktu *", - "namePlaceholder": "np. Lay's Papryka Chipsy 150g", + "namePlaceholder": "np. Wedel Ptasie Mleczko 380g", "brandLabel": "Marka", - "brandPlaceholder": "np. Lay's", + "brandPlaceholder": "np. Wedel", "categoryLabel": "Kategoria", - "categoryPlaceholder": "np. chipsy, napoje, płatki", + "categoryPlaceholder": "Wybierz kategorię", + "photoLabel": "Zdjęcie produktu", + "photoHint": "Zrób zdjęcie przedniej etykiety", + "photoRemove": "Usuń zdjęcie", + "photoInvalidType": "Wybierz plik graficzny (JPEG, PNG lub WebP)", + "photoTooLarge": "Zdjęcie musi być mniejsze niż 5 MB", + "countryLabel": "Kraj", "notesLabel": "Uwagi", "notesPlaceholder": "Dodatkowe informacje o produkcie…", "submitting": "Wysyłanie…", diff --git a/frontend/src/app/app/scan/page.test.tsx b/frontend/src/app/app/scan/page.test.tsx index 2a322452..fd9bea27 100644 --- a/frontend/src/app/app/scan/page.test.tsx +++ b/frontend/src/app/app/scan/page.test.tsx @@ -65,7 +65,9 @@ vi.mock("@/lib/api", () => ({ })); vi.mock("@/lib/validation", () => ({ - isValidEan: (ean: string) => ean.length === 8 || ean.length === 13, + isValidEan: (ean: string) => + ean.length === 8 || ean.length === 12 || ean.length === 13, + isValidEanChecksum: () => true, stripNonDigits: (s: string) => s.replace(/\D/g, ""), })); @@ -202,7 +204,7 @@ describe("ScanPage", () => { // Camera mode is active by default — manual input not visible expect( - screen.queryByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.queryByPlaceholderText("Enter barcode"), ).not.toBeInTheDocument(); // Camera tab should be selected expect(screen.getByText("Camera")).toBeInTheDocument(); @@ -215,7 +217,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); const input = screen.getByPlaceholderText( - "Enter EAN barcode (8 or 13 digits)", + "Enter barcode", ); await user.type(input, "123"); @@ -229,7 +231,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); const input = screen.getByPlaceholderText( - "Enter EAN barcode (8 or 13 digits)", + "Enter barcode", ); await user.type(input, "12345678"); @@ -244,7 +246,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); const input = screen.getByPlaceholderText( - "Enter EAN barcode (8 or 13 digits)", + "Enter barcode", ); await user.type(input, "5901234123457"); await user.click(screen.getByText("Look up")); @@ -267,7 +269,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); const input = screen.getByPlaceholderText( - "Enter EAN barcode (8 or 13 digits)", + "Enter barcode", ); await user.type(input, "5901234123457"); await user.click(screen.getByText("Look up")); @@ -293,7 +295,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -313,7 +315,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -332,7 +334,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -360,7 +362,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -381,7 +383,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); const input = screen.getByPlaceholderText( - "Enter EAN barcode (8 or 13 digits)", + "Enter barcode", ); // Type exactly 9 digits — not valid (not 8 or 13) await user.type(input, "123456789"); @@ -402,7 +404,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); const input = screen.getByPlaceholderText( - "Enter EAN barcode (8 or 13 digits)", + "Enter barcode", ); await user.type(input, "590-123-412"); @@ -437,7 +439,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -458,7 +460,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); expect( - screen.getByText("Enter 8 digits (EAN-8) or 13 digits (EAN-13)"), + screen.getByText("EAN-8 (8 digits), UPC-A (12) or EAN-13 (13)"), ).toBeInTheDocument(); }); @@ -469,7 +471,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "1234", ); @@ -483,7 +485,7 @@ describe("ScanPage", () => { await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "12345678", ); @@ -507,7 +509,7 @@ describe("ScanPage", () => { render(, { wrapper: createWrapper() }); await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -529,7 +531,7 @@ describe("ScanPage", () => { render(, { wrapper: createWrapper() }); await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -560,7 +562,7 @@ describe("ScanPage", () => { // Switch to manual and scan await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -593,7 +595,7 @@ describe("ScanPage", () => { await user.click(screen.getByLabelText(/Batch mode/)); await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -623,7 +625,7 @@ describe("ScanPage", () => { await user.click(screen.getByLabelText(/Batch mode/)); await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -655,7 +657,7 @@ describe("ScanPage", () => { await user.click(screen.getByLabelText(/Batch mode/)); await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -687,7 +689,7 @@ describe("ScanPage", () => { render(, { wrapper: createWrapper() }); await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -872,7 +874,7 @@ describe("ScanPage", () => { await user.click(screen.getByLabelText(/Batch mode/)); await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -1013,7 +1015,7 @@ describe("ScanPage", () => { // Should switch to manual mode — show EAN input field expect( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), ).toBeInTheDocument(); }); @@ -1040,7 +1042,7 @@ describe("ScanPage", () => { render(, { wrapper: createWrapper() }); await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -1059,7 +1061,7 @@ describe("ScanPage", () => { render(, { wrapper: createWrapper() }); await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -1082,7 +1084,7 @@ describe("ScanPage", () => { render(, { wrapper: createWrapper() }); await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); @@ -1102,7 +1104,7 @@ describe("ScanPage", () => { render(, { wrapper: createWrapper() }); await user.click(screen.getByText("Manual")); await user.type( - screen.getByPlaceholderText("Enter EAN barcode (8 or 13 digits)"), + screen.getByPlaceholderText("Enter barcode"), "5901234123457", ); await user.click(screen.getByText("Look up")); diff --git a/frontend/src/app/app/scan/page.tsx b/frontend/src/app/app/scan/page.tsx index 8b608044..c2b59bb9 100644 --- a/frontend/src/app/app/scan/page.tsx +++ b/frontend/src/app/app/scan/page.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/common/Button"; import { PullToRefresh } from "@/components/common/PullToRefresh"; +import { usePreferences } from "@/components/common/RouteGuard"; import { Breadcrumbs } from "@/components/layout/Breadcrumbs"; import { ScannerErrorState } from "@/components/scan/ScannerErrorState"; import { @@ -18,7 +19,6 @@ 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"; @@ -29,7 +29,7 @@ import type { RecordScanFoundResponse, RecordScanNotFoundResponse, } from "@/lib/types"; -import { isValidEan, stripNonDigits } from "@/lib/validation"; +import { isValidEan, isValidEanChecksum, stripNonDigits } from "@/lib/validation"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Camera, @@ -65,6 +65,7 @@ export default function ScanPage() { ); const [scanTimeout, setScanTimeout] = useState(false); const [foundProduct, setFoundProduct] = useState(null); + const [checksumWarn, setChecksumWarn] = useState(false); const timeoutRef = useRef | null>(null); const streamReadyTimeRef = useRef(0); @@ -172,6 +173,7 @@ export default function ScanPage() { setScanResult(null); setFoundProduct(null); setScanTimeout(false); + setChecksumWarn(false); if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; @@ -401,7 +403,11 @@ export default function ScanPage() { setManualEan(stripNonDigits(e.target.value))} + onChange={(e) => { + const v = stripNonDigits(e.target.value); + setManualEan(v); + setChecksumWarn(v.length >= 8 && isValidEan(v) && !isValidEanChecksum(v)); + }} placeholder={t("scan.manualPlaceholder")} aria-label={t("scan.manualPlaceholder")} className="input-field min-w-0 flex-1 text-center text-lg tracking-widest" @@ -424,9 +430,14 @@ export default function ScanPage() { icon={