feat(streckkod): okand produkt -> fota forpackning -> spara i delad katalog

Okand streckkod erbjuder nu 'Fota forpackningen' direkt i streckkodsvyn.
Fotot lases av (naring/allergener/innehall) och produkten UPSERTas i den
DELADE products-katalogen kopplat till GTIN (dataSource=user_scanned,
verificationStatus=unverified, createdByUserId=anvandaren) sa nasta person
som skannar samma kod far den direkt. Produkten laggs INTE till i lagret.

Naringsdeklaration-knappen tas bort som egen ingang - flodet vavs in i
streckkodsskanningen. product_package/nutrition_label -> READ_NUTRITION_LABEL
avslutar som 'completed' med result.product (ingen awaiting_confirmation).

- validation: barcode-falt i createScanInput.context
- worker: ny gren i processScanJob som skriver till products + returnerar tidigt
- mobil: inline contributePhoto (fota -> ladda upp -> polla -> visa info)
- scan: ta bort naringsdeklaration-knappen
- i18n: barcode.contributing.* + uppdaterad unknownBody pa 12 sprak

Verifierat: typecheck (validation/worker/api/mobil), worker 23/23, api 104/104,
och repro mot testdatabas (products-upsert, ingen inventory, idempotent aterskann).
This commit is contained in:
Claude
2026-08-20 11:57:58 +00:00
parent 3fa5a878f1
commit 2f596060b4
16 changed files with 256 additions and 22 deletions
+3 -2
View File
@@ -21,7 +21,9 @@ import { useAnalytics } from "@/lib/analytics";
import { scanStarted } from "@app/analytics";
import { FirstScanCoach, useFirstScanCoach } from "@/components/FirstScanCoach";
/** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum, näringsdeklaration. */
/** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum.
* Näringsdeklaration är INGEN egen knapp den vävs in i streckkodsflödet: okänd kod → fota
* förpackningen → produkten läses av och sparas i den delade katalogen (se barcode.tsx). */
const SCAN_TYPES = [
{ type: "fridge", glyph: "🧊", labelKey: "scan.fridge" },
@@ -32,7 +34,6 @@ const SCAN_TYPES = [
{ type: "receipt", glyph: "🧾", labelKey: "scan.receipt" },
{ type: "barcode", glyph: "🏷️", labelKey: "scan.barcode" },
{ type: "expiry_date", glyph: "📅", labelKey: "scan.expiry" },
{ type: "nutrition_label", glyph: "🔬", labelKey: "scan.nutrition" },
] as const;
/**
+98 -7
View File
@@ -1,9 +1,10 @@
import { useRef, useState } from "react";
import { ScrollView, StyleSheet, View } from "react-native";
import { router } from "expo-router";
import * as ImagePicker from "expo-image-picker";
import { CameraView, useCameraPermissions } from "expo-camera";
import { ALLERGEN_LABELS_SV, type Allergen } from "@app/shared-types";
import { api } from "@/lib/api";
import { api, uploadImage, ApiError } from "@/lib/api";
import { t } from "@/lib/i18n";
import { Body, Button, Card, Heading, Row, Screen, Small, Spacer } from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
@@ -48,6 +49,30 @@ interface ProductInfo {
interface BarcodeScanResponse {
product: ProductInfo | null;
}
interface CreateScanResponse {
scan: { id: string };
uploads: Array<{ key: string; uploadUrl: string; headers: Record<string, string> }>;
}
interface ScanJobResponse {
status: string;
result: { product: ProductInfo | null } | null;
error?: string | null;
}
/**
* Pollar en produktskanning tills AAMOS läst av förpackningen. Vid "completed"
* returneras produkten (redan sparad i delade katalogen av workern), vid
* "failed"/timeout returneras null. Max ~60 s (40 × 1,5 s).
*/
async function pollScan(id: string): Promise<ProductInfo | null> {
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 1500));
const job = await api<ScanJobResponse>(`/v1/scans/${id}`);
if (job.status === "completed") return job.result?.product ?? null;
if (job.status === "failed") return null;
}
return null;
}
const fmtG = (n: number) => `${(Math.round(n * 10) / 10).toString().replace(".", ",")} g`;
const allergenLabels = (codes: string[] | null | undefined): string =>
@@ -61,15 +86,19 @@ export default function BarcodeScreen() {
const [busy, setBusy] = useState(false);
const [product, setProduct] = useState<ProductInfo | null>(null);
const [unknown, setUnknown] = useState(false);
const [contributing, setContributing] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// Synkron lås: hindrar att kamerans upprepade träffar startar parallella uppslag.
const locked = useRef(false);
// Senast avlästa streckkod används när användaren fotar en okänd produkt.
const lastGtin = useRef<string | null>(null);
const scanning = !busy && !product && !unknown && !errorMsg;
const onScanned = async (gtin: string) => {
if (locked.current) return;
locked.current = true;
lastGtin.current = gtin;
setBusy(true);
setErrorMsg(null);
try {
@@ -86,11 +115,66 @@ export default function BarcodeScreen() {
}
};
/**
* Okänd produkt → fota förpackningen. Bilden läses av (näring/allergener/
* innehåll) och produkten sparas i den DELADE katalogen kopplat till
* streckkoden så nästa person som skannar samma kod får den direkt.
* Lägger INTE till något bland användarens egna varor (spec §11).
*/
const contributePhoto = async () => {
const gtin = lastGtin.current;
if (!gtin) return;
try {
// Fota förpackningen (kamera, med galleri-fallback som i skanningsflödet).
const perm = await ImagePicker.requestCameraPermissionsAsync();
const picked = perm.granted
? await ImagePicker.launchCameraAsync({ quality: 0.7 })
: await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
const asset = picked.assets?.[0];
if (picked.canceled || !asset) return; // avbröt stanna kvar i "okänd"-vyn
setContributing(true);
setErrorMsg(null);
// Produktskanning kopplad till streckkoden delad katalog, inte lagret.
const created = await api<CreateScanResponse>("/v1/scans", {
method: "POST",
body: {
scanType: "product_package",
imageCount: 1,
contentType: "image/jpeg",
context: { barcode: gtin },
},
});
const upload = created.uploads[0];
if (!upload) throw new Error(t("common.error"));
await uploadImage(upload, asset.uri);
await api(`/v1/scans/${created.scan.id}/start`, { method: "POST" });
const found = await pollScan(created.scan.id);
if (found) {
setUnknown(false);
setProduct(found);
} else {
setErrorMsg(t("barcode.contributing.failed"));
}
} catch (err) {
if (err instanceof ApiError && err.code === "QUOTA_EXCEEDED") {
router.push("/paywall");
} else {
setErrorMsg(err instanceof Error ? err.message : t("common.error"));
}
} finally {
setContributing(false);
}
};
const scanAgain = () => {
setProduct(null);
setUnknown(false);
setErrorMsg(null);
locked.current = false;
lastGtin.current = null;
};
if (!permission) return <Screen><Body>{t("scan.analyzing")}</Body></Screen>;
@@ -203,23 +287,30 @@ export default function BarcodeScreen() {
</View>
)}
{/* Okänd produkt */}
{unknown && (
{/* Okänd produkt → erbjud att fota förpackningen (bidra till delade katalogen) */}
{unknown && !contributing && !errorMsg && (
<View style={styles.panel}>
<View style={{ padding: spacing.lg }}>
<Heading>{t("barcode.unknownTitle")}</Heading>
<Body muted>{t("barcode.unknownBody")}</Body>
<Spacer size={spacing.md} />
<Button
label={t("barcode.photoPackage")}
onPress={() => router.replace("/(tabs)/scan")}
/>
<Button label={t("barcode.photoPackage")} onPress={() => void contributePhoto()} />
<Spacer size={spacing.xs} />
<Button label={t("barcode.info.scanAgain")} variant="ghost" onPress={scanAgain} />
</View>
</View>
)}
{/* Läser av fotad förpackning */}
{contributing && (
<View style={styles.panel}>
<View style={{ padding: spacing.lg }}>
<Heading>{t("barcode.contributing.title")}</Heading>
<Body muted>{t("barcode.contributing.body")}</Body>
</View>
</View>
)}
{/* Fel */}
{errorMsg && (
<View style={styles.panel}>