-
-
- {t("submit.title")}
-
-
- {t("submit.subtitle")}
-
-
-
- {t("submit.backToScanner")}
-
+
+
+ {t("submit.title")}
+
+
+ {t("submit.subtitle")}
+
@@ -164,14 +218,79 @@ export default function SubmitProductPage() {
>
{t("submit.categoryLabel")}
- setCategory(e.target.value)}
className="input-field"
- placeholder={t("submit.categoryPlaceholder")}
- maxLength={100}
+ >
+
+ {FOOD_CATEGORIES.map((cat) => (
+
+ ))}
+
+
+
+ {/* Country hint */}
+ {scanCountry && (
+
+
+ {t("submit.countryLabel")}
+
+
+ {getCountryFlag(scanCountry)}
+ {getCountryName(scanCountry)}
+ {gs1Hint && gs1Hint.code !== scanCountry && (
+
+ ({t("scan.gs1Hint", { country: gs1Hint.name })})
+
+ )}
+
+
+ )}
+
+ {/* Photo */}
+
+
+ {photoPreview && photoPreview.startsWith("blob:") ? (
+
+

+
+
+ ) : (
+
+ )}
+
diff --git a/frontend/src/components/scan/ScanResultView.test.tsx b/frontend/src/components/scan/ScanResultView.test.tsx
index 5fcb173b..024f0fb0 100644
--- a/frontend/src/components/scan/ScanResultView.test.tsx
+++ b/frontend/src/components/scan/ScanResultView.test.tsx
@@ -77,6 +77,14 @@ vi.mock("@/lib/score-utils", () => ({
},
}));
+vi.mock("@/lib/gs1", () => ({
+ gs1CountryHint: (ean: string) => {
+ if (ean.startsWith("590")) return { code: "PL", name: "Poland" };
+ if (ean.startsWith("400")) return { code: "DE", name: "Germany" };
+ return null;
+ },
+}));
+
vi.mock("@/lib/constants", () => ({
NUTRI_COLORS: {
A: "bg-green-600",
@@ -233,6 +241,29 @@ describe("ScanNotFoundView", () => {
"/app/scan/history",
);
});
+
+ it("shows GS1 country hint when EAN has recognised prefix", () => {
+ render(
+
,
+ );
+ expect(screen.getByText("🇵🇱")).toBeInTheDocument();
+ expect(screen.getByText(/scan\.gs1Hint/)).toBeInTheDocument();
+ });
+
+ it("does not show GS1 country hint when prefix is unrecognised", () => {
+ render(
+
,
+ );
+ expect(screen.queryByText(/scan\.gs1Hint/)).toBeNull();
+ });
});
// ─── ScanLookingUpView ──────────────────────────────────────────────────────
diff --git a/frontend/src/components/scan/ScanResultView.tsx b/frontend/src/components/scan/ScanResultView.tsx
index 0a5a2eb4..980d3b29 100644
--- a/frontend/src/components/scan/ScanResultView.tsx
+++ b/frontend/src/components/scan/ScanResultView.tsx
@@ -6,6 +6,7 @@ import { Button, ButtonLink } from "@/components/common/Button";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { ScanMissSubmitCTA } from "@/components/scan/ScanMissSubmitCTA";
import { getCountryFlag, getCountryName, NUTRI_COLORS } from "@/lib/constants";
+import { gs1CountryHint } from "@/lib/gs1";
import { useTranslation } from "@/lib/i18n";
import { getScoreBand, toTryVitScore } from "@/lib/score-utils";
import type {
@@ -81,6 +82,7 @@ export function ScanNotFoundView({
country,
}: ScanNotFoundProps) {
const { t } = useTranslation();
+ const gs1Hint = gs1CountryHint(ean);
return (
@@ -98,6 +100,12 @@ export function ScanNotFoundView({
{t("scan.notFoundMessage", { ean })}
+ {gs1Hint && (
+
+ {getCountryFlag(gs1Hint.code)}
+ {t("scan.gs1Hint", { country: gs1Hint.name })}
+
+ )}
{
+ it("returns Poland for 590 prefix", () => {
+ expect(gs1CountryHint("5901234123457")).toEqual({
+ code: "PL",
+ name: "Poland",
+ });
+ });
+
+ it("returns Germany for 400–440 prefix range", () => {
+ expect(gs1CountryHint("4000000000000")).toEqual({
+ code: "DE",
+ name: "Germany",
+ });
+ expect(gs1CountryHint("4400000000000")).toEqual({
+ code: "DE",
+ name: "Germany",
+ });
+ expect(gs1CountryHint("4200000000000")).toEqual({
+ code: "DE",
+ name: "Germany",
+ });
+ });
+
+ it("returns France for 300–379 prefix", () => {
+ expect(gs1CountryHint("3000000000000")).toEqual({
+ code: "FR",
+ name: "France",
+ });
+ expect(gs1CountryHint("3790000000000")).toEqual({
+ code: "FR",
+ name: "France",
+ });
+ });
+
+ it("returns United Kingdom for 500–509 prefix", () => {
+ expect(gs1CountryHint("5000000000000")).toEqual({
+ code: "GB",
+ name: "United Kingdom",
+ });
+ });
+
+ it("returns Italy for 800–839 prefix", () => {
+ expect(gs1CountryHint("8000000000000")).toEqual({
+ code: "IT",
+ name: "Italy",
+ });
+ expect(gs1CountryHint("8390000000000")).toEqual({
+ code: "IT",
+ name: "Italy",
+ });
+ });
+
+ it("returns Spain for 840–849 prefix", () => {
+ expect(gs1CountryHint("8400000000000")).toEqual({
+ code: "ES",
+ name: "Spain",
+ });
+ });
+
+ it("returns United States for 000–139 prefixes", () => {
+ expect(gs1CountryHint("0000000000000")).toEqual({
+ code: "US",
+ name: "United States",
+ });
+ expect(gs1CountryHint("0600000000000")).toEqual({
+ code: "US",
+ name: "United States",
+ });
+ expect(gs1CountryHint("1390000000000")).toEqual({
+ code: "US",
+ name: "United States",
+ });
+ });
+
+ it("works with 12-digit UPC-A codes", () => {
+ expect(gs1CountryHint("012345678901")).toEqual({
+ code: "US",
+ name: "United States",
+ });
+ });
+
+ it("returns null for EAN-8 codes", () => {
+ expect(gs1CountryHint("12345678")).toBeNull();
+ });
+
+ it("returns null for unrecognised prefixes", () => {
+ expect(gs1CountryHint("9990000000000")).toBeNull();
+ });
+
+ it("returns null for empty string", () => {
+ expect(gs1CountryHint("")).toBeNull();
+ });
+
+ it("returns null for short strings", () => {
+ expect(gs1CountryHint("123")).toBeNull();
+ });
+});
diff --git a/frontend/src/lib/gs1.ts b/frontend/src/lib/gs1.ts
new file mode 100644
index 00000000..0ca4d943
--- /dev/null
+++ b/frontend/src/lib/gs1.ts
@@ -0,0 +1,146 @@
+// ─── GS1 prefix → country-of-registration hint ─────────────────────────────
+// Reference: https://www.gs1.org/standards/id-keys/company-prefix
+// NOTE: GS1 prefix indicates where the barcode was *registered*, not where the
+// product was manufactured. Imported products often carry their origin-country prefix.
+
+interface Gs1CountryHint {
+ code: string;
+ name: string;
+}
+
+/**
+ * GS1 prefix ranges mapped to country codes and names.
+ * Each entry is [startPrefix, endPrefix, countryCode, countryName].
+ * Ranges are inclusive on both ends. Prefixes are compared as 3-digit strings.
+ */
+const GS1_RANGES: readonly [string, string, string, string][] = [
+ ["000", "019", "US", "United States"],
+ ["020", "029", "US", "United States"],
+ ["030", "039", "US", "United States"],
+ ["060", "099", "US", "United States"],
+ ["100", "139", "US", "United States"],
+ ["300", "379", "FR", "France"],
+ ["380", "380", "BG", "Bulgaria"],
+ ["383", "383", "SI", "Slovenia"],
+ ["385", "385", "HR", "Croatia"],
+ ["387", "387", "BA", "Bosnia and Herzegovina"],
+ ["389", "389", "ME", "Montenegro"],
+ ["400", "440", "DE", "Germany"],
+ ["450", "459", "JP", "Japan"],
+ ["460", "469", "RU", "Russia"],
+ ["470", "470", "KG", "Kyrgyzstan"],
+ ["471", "471", "TW", "Taiwan"],
+ ["474", "474", "EE", "Estonia"],
+ ["475", "475", "LV", "Latvia"],
+ ["476", "476", "AZ", "Azerbaijan"],
+ ["477", "477", "LT", "Lithuania"],
+ ["478", "478", "UZ", "Uzbekistan"],
+ ["480", "480", "PH", "Philippines"],
+ ["481", "481", "BY", "Belarus"],
+ ["482", "482", "UA", "Ukraine"],
+ ["484", "484", "MD", "Moldova"],
+ ["485", "485", "AM", "Armenia"],
+ ["486", "486", "GE", "Georgia"],
+ ["487", "487", "KZ", "Kazakhstan"],
+ ["488", "488", "TJ", "Tajikistan"],
+ ["489", "489", "HK", "Hong Kong"],
+ ["490", "499", "JP", "Japan"],
+ ["500", "509", "GB", "United Kingdom"],
+ ["520", "521", "GR", "Greece"],
+ ["528", "528", "LB", "Lebanon"],
+ ["529", "529", "CY", "Cyprus"],
+ ["530", "530", "AL", "Albania"],
+ ["531", "531", "MK", "North Macedonia"],
+ ["535", "535", "MT", "Malta"],
+ ["539", "539", "IE", "Ireland"],
+ ["540", "549", "BE", "Belgium / Luxembourg"],
+ ["560", "560", "PT", "Portugal"],
+ ["569", "569", "IS", "Iceland"],
+ ["570", "579", "DK", "Denmark"],
+ ["590", "590", "PL", "Poland"],
+ ["594", "594", "RO", "Romania"],
+ ["599", "599", "HU", "Hungary"],
+ ["600", "601", "ZA", "South Africa"],
+ ["608", "608", "BH", "Bahrain"],
+ ["609", "609", "MU", "Mauritius"],
+ ["611", "611", "MA", "Morocco"],
+ ["613", "613", "DZ", "Algeria"],
+ ["615", "615", "NG", "Nigeria"],
+ ["616", "616", "KE", "Kenya"],
+ ["618", "618", "CI", "Ivory Coast"],
+ ["619", "619", "TN", "Tunisia"],
+ ["620", "620", "TZ", "Tanzania"],
+ ["621", "621", "SY", "Syria"],
+ ["622", "622", "EG", "Egypt"],
+ ["624", "624", "LY", "Libya"],
+ ["625", "625", "JO", "Jordan"],
+ ["626", "626", "IR", "Iran"],
+ ["627", "627", "KW", "Kuwait"],
+ ["628", "628", "SA", "Saudi Arabia"],
+ ["629", "629", "AE", "United Arab Emirates"],
+ ["640", "649", "FI", "Finland"],
+ ["690", "699", "CN", "China"],
+ ["700", "709", "NO", "Norway"],
+ ["729", "729", "IL", "Israel"],
+ ["730", "739", "SE", "Sweden"],
+ ["740", "740", "GT", "Guatemala"],
+ ["741", "741", "SV", "El Salvador"],
+ ["742", "742", "HN", "Honduras"],
+ ["743", "743", "NI", "Nicaragua"],
+ ["744", "744", "CR", "Costa Rica"],
+ ["745", "745", "PA", "Panama"],
+ ["746", "746", "DO", "Dominican Republic"],
+ ["750", "750", "MX", "Mexico"],
+ ["754", "755", "CA", "Canada"],
+ ["759", "759", "VE", "Venezuela"],
+ ["760", "769", "CH", "Switzerland"],
+ ["770", "771", "CO", "Colombia"],
+ ["773", "773", "UY", "Uruguay"],
+ ["775", "775", "PE", "Peru"],
+ ["777", "777", "BO", "Bolivia"],
+ ["778", "779", "AR", "Argentina"],
+ ["780", "780", "CL", "Chile"],
+ ["784", "784", "PY", "Paraguay"],
+ ["786", "786", "EC", "Ecuador"],
+ ["789", "790", "BR", "Brazil"],
+ ["800", "839", "IT", "Italy"],
+ ["840", "849", "ES", "Spain"],
+ ["850", "850", "CU", "Cuba"],
+ ["858", "858", "SK", "Slovakia"],
+ ["859", "859", "CZ", "Czech Republic"],
+ ["860", "860", "RS", "Serbia"],
+ ["865", "865", "MN", "Mongolia"],
+ ["867", "867", "KP", "North Korea"],
+ ["868", "869", "TR", "Turkey"],
+ ["870", "879", "NL", "Netherlands"],
+ ["880", "880", "KR", "South Korea"],
+ ["884", "884", "KH", "Cambodia"],
+ ["885", "885", "TH", "Thailand"],
+ ["888", "888", "SG", "Singapore"],
+ ["890", "890", "IN", "India"],
+ ["893", "893", "VN", "Vietnam"],
+ ["896", "896", "PK", "Pakistan"],
+ ["899", "899", "ID", "Indonesia"],
+ ["900", "919", "AT", "Austria"],
+ ["930", "939", "AU", "Australia"],
+ ["940", "949", "NZ", "New Zealand"],
+ ["955", "955", "MY", "Malaysia"],
+];
+
+/**
+ * Extract a GS1 country-of-registration hint from an EAN-13 barcode prefix.
+ * Returns null for EAN-8 codes (no reliable country mapping) or unrecognised prefixes.
+ */
+export function gs1CountryHint(ean: string): Gs1CountryHint | null {
+ if (ean.length !== 13 && ean.length !== 12) return null;
+
+ const prefix = ean.slice(0, 3);
+
+ for (const [start, end, code, name] of GS1_RANGES) {
+ if (prefix >= start && prefix <= end) {
+ return { code, name };
+ }
+ }
+
+ return null;
+}
diff --git a/frontend/src/lib/validation.test.ts b/frontend/src/lib/validation.test.ts
index 09d58d42..2c9dfe52 100644
--- a/frontend/src/lib/validation.test.ts
+++ b/frontend/src/lib/validation.test.ts
@@ -1,10 +1,12 @@
-import { describe, it, expect } from "vitest";
import {
- sanitizeRedirect,
- isValidEan,
- stripNonDigits,
- formatSlug,
+ computeEanCheckDigit,
+ formatSlug,
+ isValidEan,
+ isValidEanChecksum,
+ sanitizeRedirect,
+ stripNonDigits,
} from "@/lib/validation";
+import { describe, expect, it } from "vitest";
// ─── sanitizeRedirect ───────────────────────────────────────────────────────
@@ -67,8 +69,8 @@ describe("isValidEan", () => {
expect(isValidEan("123456789")).toBe(false);
});
- it("rejects 12 digits (UPC)", () => {
- expect(isValidEan("012345678901")).toBe(false);
+ it("accepts 12 digits (UPC-A)", () => {
+ expect(isValidEan("012345678901")).toBe(true);
});
it("rejects non-digit characters", () => {
@@ -84,6 +86,53 @@ describe("isValidEan", () => {
});
});
+// ─── computeEanCheckDigit ────────────────────────────────────────────────────
+
+describe("computeEanCheckDigit", () => {
+ it("computes correct check digit for EAN-13", () => {
+ // 5901234123457 → check digit 7
+ expect(computeEanCheckDigit("5901234123457")).toBe(7);
+ });
+
+ it("computes correct check digit for EAN-8", () => {
+ // 96385074 → check digit 4
+ expect(computeEanCheckDigit("96385074")).toBe(4);
+ });
+
+ it("computes correct check digit for UPC-A", () => {
+ // 036000291452 → check digit 2
+ expect(computeEanCheckDigit("036000291452")).toBe(2);
+ });
+});
+
+// ─── isValidEanChecksum ─────────────────────────────────────────────────────
+
+describe("isValidEanChecksum", () => {
+ it("returns true for valid EAN-13 checksum", () => {
+ expect(isValidEanChecksum("5901234123457")).toBe(true);
+ });
+
+ it("returns true for valid EAN-8 checksum", () => {
+ expect(isValidEanChecksum("96385074")).toBe(true);
+ });
+
+ it("returns true for valid UPC-A checksum", () => {
+ expect(isValidEanChecksum("036000291452")).toBe(true);
+ });
+
+ it("returns false for invalid check digit", () => {
+ expect(isValidEanChecksum("5901234123450")).toBe(false);
+ });
+
+ it("returns false for non-barcode strings", () => {
+ expect(isValidEanChecksum("abc")).toBe(false);
+ });
+
+ it("returns false for empty string", () => {
+ expect(isValidEanChecksum("")).toBe(false);
+ });
+});
+
// ─── stripNonDigits ─────────────────────────────────────────────────────────
describe("stripNonDigits", () => {
diff --git a/frontend/src/lib/validation.ts b/frontend/src/lib/validation.ts
index 14498a2a..5d45899f 100644
--- a/frontend/src/lib/validation.ts
+++ b/frontend/src/lib/validation.ts
@@ -15,10 +15,41 @@ export function sanitizeRedirect(
}
/**
- * Returns true if `code` is a valid EAN‑8 or EAN‑13 string.
+ * Returns true if `code` is a valid EAN‑8, UPC‑A (12), or EAN‑13 string.
*/
export function isValidEan(code: string): boolean {
- return /^\d{8}$|^\d{13}$/.test(code);
+ return /^\d{8}$|^\d{12,13}$/.test(code);
+}
+
+/**
+ * Compute the GS1 check digit for an EAN-8, UPC-A, or EAN-13 barcode.
+ * Pass the full code (including check digit position) or just the payload digits.
+ * Returns the expected check digit (0–9).
+ */
+export function computeEanCheckDigit(digits: string): number {
+ const stripped = digits.replace(/\D/g, "");
+ // Use up to 12 (EAN-13) or 7 (EAN-8) payload digits
+ const payload = stripped.length >= 12 ? stripped.slice(0, 12) : stripped.slice(0, 7);
+ let sum = 0;
+ const isEan13 = payload.length >= 12;
+ for (let i = 0; i < payload.length; i++) {
+ const digit = Number(payload[i]);
+ // EAN-13/UPC-A: positions 0,2,4… weight 1; positions 1,3,5… weight 3
+ // EAN-8: positions 0,2,4,6 weight 3; positions 1,3,5 weight 1
+ const weight = isEan13 ? (i % 2 === 0 ? 1 : 3) : (i % 2 === 0 ? 3 : 1);
+ sum += digit * weight;
+ }
+ return (10 - (sum % 10)) % 10;
+}
+
+/**
+ * Validate the check digit of a full EAN-8, UPC-A, or EAN-13 barcode.
+ * Returns true if the last digit matches the computed check digit.
+ */
+export function isValidEanChecksum(code: string): boolean {
+ if (!/^\d{8}$|^\d{12,13}$/.test(code)) return false;
+ const expected = computeEanCheckDigit(code);
+ return Number(code[code.length - 1]) === expected;
}
/**