feat: integrate barcode scanning functionality and enhance Appwrite setup
- Added a new `barcode_products` collection to the Appwrite setup for managing barcode data. - Implemented barcode scanning feature with a dedicated modal for scanning and adding products. - Introduced new components for barcode product preview and management. - Updated the setup script to seed verified barcode products from a JSON file. - Enhanced the application state management to handle barcode-related actions and user interactions.
This commit is contained in:
@@ -9,6 +9,7 @@ export const appwriteConfig = {
|
||||
databaseId: env.VITE_APPWRITE_DATABASE_ID || "redbull_tracker",
|
||||
collectionId: env.VITE_APPWRITE_COLLECTION_ID || "intake_entries",
|
||||
chatCollectionId: env.VITE_APPWRITE_CHAT_COLLECTION_ID || "coach_chats",
|
||||
barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products",
|
||||
oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL),
|
||||
oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Models } from "appwrite";
|
||||
import type { BarcodeLookupCatalog, BarcodeProductDraft, BarcodeSeedProduct, UserBarcodeMapping } from "../types";
|
||||
import { appwriteConfig, ID, Permission, Query, Role, tablesDB } from "./appwrite";
|
||||
import { normalizeBarcode } from "./barcodeLookup";
|
||||
|
||||
type BarcodeRowScope = "verified" | "user";
|
||||
|
||||
type BarcodeRow = Models.Row & {
|
||||
scope: BarcodeRowScope;
|
||||
ownerUserId?: string;
|
||||
barcode: string;
|
||||
flavourName: string;
|
||||
sizeMl: number;
|
||||
pricePerCan: number;
|
||||
sugarFree: boolean;
|
||||
caffeineMgPerCan?: number;
|
||||
verifiedBy?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
variant?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export async function listBarcodeCatalog(): Promise<BarcodeLookupCatalog> {
|
||||
const verifiedProducts: Record<string, BarcodeSeedProduct> = {};
|
||||
const userMappings: UserBarcodeMapping[] = [];
|
||||
const limit = 200;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const response = await tablesDB.listRows<BarcodeRow>({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.barcodeCollectionId,
|
||||
queries: [Query.orderAsc("barcode"), Query.limit(limit), Query.offset(offset)],
|
||||
});
|
||||
|
||||
response.rows.forEach((row) => {
|
||||
if (row.scope === "verified") {
|
||||
verifiedProducts[row.barcode] = fromVerifiedRow(row);
|
||||
return;
|
||||
}
|
||||
userMappings.push(fromUserRow(row));
|
||||
});
|
||||
|
||||
if (response.rows.length < limit) break;
|
||||
offset += limit;
|
||||
}
|
||||
|
||||
return { verifiedProducts, userMappings };
|
||||
}
|
||||
|
||||
export async function upsertCloudUserBarcodeMapping(
|
||||
userId: string,
|
||||
barcodeValue: string,
|
||||
product: BarcodeProductDraft,
|
||||
) {
|
||||
const barcode = normalizeBarcode(barcodeValue);
|
||||
const existing = await findUserBarcodeRow(userId, barcode);
|
||||
const data = toUserRowData(userId, barcode, product);
|
||||
|
||||
if (existing) {
|
||||
const row = await tablesDB.updateRow<BarcodeRow>({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.barcodeCollectionId,
|
||||
rowId: existing.$id,
|
||||
data,
|
||||
permissions: userRowPermissions(userId),
|
||||
});
|
||||
return fromUserRow(row);
|
||||
}
|
||||
|
||||
const row = await tablesDB.createRow<BarcodeRow>({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.barcodeCollectionId,
|
||||
rowId: ID.unique(),
|
||||
data,
|
||||
permissions: userRowPermissions(userId),
|
||||
});
|
||||
return fromUserRow(row);
|
||||
}
|
||||
|
||||
async function findUserBarcodeRow(userId: string, barcode: string) {
|
||||
const response = await tablesDB.listRows<BarcodeRow>({
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
tableId: appwriteConfig.barcodeCollectionId,
|
||||
queries: [
|
||||
Query.equal("scope", "user"),
|
||||
Query.equal("ownerUserId", userId),
|
||||
Query.equal("barcode", barcode),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
|
||||
return response.rows[0] ?? null;
|
||||
}
|
||||
|
||||
function fromVerifiedRow(row: BarcodeRow): BarcodeSeedProduct {
|
||||
return {
|
||||
flavourName: row.flavourName,
|
||||
sizeMl: row.sizeMl,
|
||||
pricePerCan: row.pricePerCan,
|
||||
sugarFree: row.sugarFree,
|
||||
caffeineMgPerCan: row.caffeineMgPerCan,
|
||||
verifiedBy: row.verifiedBy || "Verified source",
|
||||
sourceName: row.sourceName,
|
||||
sourceUrl: row.sourceUrl,
|
||||
variant: row.variant,
|
||||
notes: row.notes,
|
||||
};
|
||||
}
|
||||
|
||||
function fromUserRow(row: BarcodeRow): UserBarcodeMapping {
|
||||
return {
|
||||
barcode: row.barcode,
|
||||
flavourName: row.flavourName,
|
||||
sizeMl: row.sizeMl,
|
||||
pricePerCan: row.pricePerCan,
|
||||
sugarFree: row.sugarFree,
|
||||
caffeineMgPerCan: row.caffeineMgPerCan,
|
||||
createdAt: row.$createdAt,
|
||||
updatedAt: row.$updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toUserRowData(userId: string, barcode: string, product: BarcodeProductDraft) {
|
||||
return {
|
||||
scope: "user" as const,
|
||||
ownerUserId: userId,
|
||||
barcode,
|
||||
flavourName: product.flavourName,
|
||||
sizeMl: product.sizeMl,
|
||||
pricePerCan: product.pricePerCan,
|
||||
sugarFree: Boolean(product.sugarFree),
|
||||
caffeineMgPerCan: product.caffeineMgPerCan,
|
||||
verifiedBy: "User saved mapping",
|
||||
sourceName: "",
|
||||
sourceUrl: "",
|
||||
variant: "user",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
function userRowPermissions(userId: string) {
|
||||
const role = Role.user(userId);
|
||||
return [Permission.read(role), Permission.update(role), Permission.delete(role)];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { BUILT_IN_BARCODE_PRODUCTS } from "../data/barcodes";
|
||||
import { BUILT_IN_FLAVOURS, flavourMeta } from "../data/flavours";
|
||||
import { caffeinePerCan } from "./metrics";
|
||||
import type {
|
||||
BarcodeLookupCatalog,
|
||||
BarcodeLookupResult,
|
||||
BarcodeProductDraft,
|
||||
ResolvedBarcodeProduct,
|
||||
UserBarcodeMapping,
|
||||
EntryDraft,
|
||||
} from "../types";
|
||||
|
||||
const knownFlavourNames = new Set(BUILT_IN_FLAVOURS.map((flavour) => flavour.name));
|
||||
|
||||
export function normalizeBarcode(value: string) {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
export function lookupBarcode(
|
||||
rawBarcode: string,
|
||||
catalogOrUserMappings: BarcodeLookupCatalog | UserBarcodeMapping[] = [],
|
||||
): BarcodeLookupResult {
|
||||
const catalog = Array.isArray(catalogOrUserMappings)
|
||||
? { userMappings: catalogOrUserMappings }
|
||||
: catalogOrUserMappings;
|
||||
const userMappings = catalog.userMappings ?? [];
|
||||
const verifiedProducts = catalog.verifiedProducts ?? BUILT_IN_BARCODE_PRODUCTS;
|
||||
const barcode = normalizeBarcode(rawBarcode);
|
||||
if (!barcode) {
|
||||
return { status: "unknown", barcode: rawBarcode.trim() };
|
||||
}
|
||||
|
||||
const userMapping = userMappings.find((mapping) => mapping.barcode === barcode);
|
||||
if (userMapping) {
|
||||
return { status: "user", barcode, product: resolveProduct(userMapping, "user") };
|
||||
}
|
||||
|
||||
const seedProduct = verifiedProducts[barcode];
|
||||
if (!seedProduct) {
|
||||
return { status: "unknown", barcode };
|
||||
}
|
||||
|
||||
if (!knownFlavourNames.has(seedProduct.flavourName)) {
|
||||
return {
|
||||
status: "partial",
|
||||
barcode,
|
||||
product: seedProduct,
|
||||
reason: "This barcode has product data, but its flavour is not in the built-in Red Bull list yet.",
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "known", barcode, product: resolveProduct(seedProduct, "built-in") };
|
||||
}
|
||||
|
||||
export function resolveProduct(
|
||||
product: BarcodeProductDraft,
|
||||
source: ResolvedBarcodeProduct["source"],
|
||||
): ResolvedBarcodeProduct {
|
||||
const meta = flavourMeta(product.flavourName);
|
||||
return {
|
||||
...product,
|
||||
flavourAccent: meta.accent,
|
||||
sugarFree: product.sugarFree ?? Boolean(meta.sugarFree),
|
||||
caffeineMgPerCan: product.caffeineMgPerCan,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
export function barcodeProductToEntryDraft(
|
||||
product: ResolvedBarcodeProduct,
|
||||
barcode: string,
|
||||
): EntryDraft {
|
||||
return {
|
||||
cans: 1,
|
||||
flavour: product.flavourName,
|
||||
flavourAccent: product.flavourAccent,
|
||||
sizeMl: product.sizeMl,
|
||||
pricePerCan: product.pricePerCan,
|
||||
dateTime: new Date().toISOString(),
|
||||
notes: `Barcode scan: ${barcode}`,
|
||||
store: "",
|
||||
sugarFree: Boolean(product.sugarFree),
|
||||
caffeineMgPerCan: product.caffeineMgPerCan,
|
||||
source: "manual",
|
||||
};
|
||||
}
|
||||
|
||||
export function productCaffeineMg(product: BarcodeProductDraft) {
|
||||
return caffeinePerCan(product.sizeMl, product.caffeineMgPerCan);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
BarcodeFormat,
|
||||
BrowserCodeReader,
|
||||
BrowserMultiFormatReader,
|
||||
type IScannerControls,
|
||||
} from "@zxing/browser";
|
||||
import { normalizeBarcode } from "./barcodeLookup";
|
||||
|
||||
export type BarcodeScannerErrorCode =
|
||||
| "camera-denied"
|
||||
| "no-camera"
|
||||
| "unsupported"
|
||||
| "camera-in-use"
|
||||
| "unknown";
|
||||
|
||||
export type BarcodeScannerError = {
|
||||
code: BarcodeScannerErrorCode;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type BarcodeScanResult = {
|
||||
value: string;
|
||||
format: string;
|
||||
};
|
||||
|
||||
export type BarcodeScannerController = {
|
||||
mode: "native" | "zxing";
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
type NativeBarcode = {
|
||||
rawValue?: string;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
type NativeBarcodeDetector = {
|
||||
detect: (source: HTMLVideoElement) => Promise<NativeBarcode[]>;
|
||||
};
|
||||
|
||||
type NativeBarcodeDetectorConstructor = new (options?: {
|
||||
formats?: string[];
|
||||
}) => NativeBarcodeDetector;
|
||||
|
||||
type WindowWithBarcodeDetector = Window & {
|
||||
BarcodeDetector?: NativeBarcodeDetectorConstructor & {
|
||||
getSupportedFormats?: () => Promise<string[]>;
|
||||
};
|
||||
};
|
||||
|
||||
const NATIVE_FORMATS = ["ean_13", "ean_8", "upc_a", "upc_e"];
|
||||
const ZXING_FORMATS = [
|
||||
BarcodeFormat.EAN_13,
|
||||
BarcodeFormat.EAN_8,
|
||||
BarcodeFormat.UPC_A,
|
||||
BarcodeFormat.UPC_E,
|
||||
];
|
||||
const SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
||||
video: {
|
||||
facingMode: { ideal: "environment" },
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
},
|
||||
audio: false,
|
||||
};
|
||||
|
||||
export async function startBarcodeScanner(
|
||||
videoElement: HTMLVideoElement,
|
||||
onResult: (result: BarcodeScanResult) => void,
|
||||
onError: (error: BarcodeScannerError) => void,
|
||||
): Promise<BarcodeScannerController> {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw toScannerError(new Error("Camera access is not supported in this browser."));
|
||||
}
|
||||
|
||||
if (await supportsNativeBarcodeDetector()) {
|
||||
try {
|
||||
return await startNativeBarcodeScanner(videoElement, onResult);
|
||||
} catch (error) {
|
||||
stopVideoStream(videoElement);
|
||||
if (isCameraAccessError(error)) {
|
||||
throw toScannerError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return startZxingBarcodeScanner(videoElement, onResult, onError);
|
||||
}
|
||||
|
||||
export function stopVideoStream(videoElement: HTMLVideoElement | null) {
|
||||
if (!videoElement) return;
|
||||
const stream = videoElement.srcObject;
|
||||
if (stream instanceof MediaStream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
videoElement.pause();
|
||||
videoElement.removeAttribute("src");
|
||||
videoElement.srcObject = null;
|
||||
videoElement.load();
|
||||
}
|
||||
|
||||
export function scannerErrorMessage(code: BarcodeScannerErrorCode) {
|
||||
switch (code) {
|
||||
case "camera-denied":
|
||||
return "Camera permission was denied. Allow camera access, then try scanning again.";
|
||||
case "no-camera":
|
||||
return "No camera was found on this device. You can type the barcode instead.";
|
||||
case "camera-in-use":
|
||||
return "The camera looks busy in another app or browser tab. Close it there, then try again.";
|
||||
case "unsupported":
|
||||
return "Barcode scanning is not supported in this browser. You can type the barcode instead.";
|
||||
case "unknown":
|
||||
default:
|
||||
return "The scanner could not start. You can type the barcode instead.";
|
||||
}
|
||||
}
|
||||
|
||||
function startNativeBarcodeScanner(
|
||||
videoElement: HTMLVideoElement,
|
||||
onResult: (result: BarcodeScanResult) => void,
|
||||
): Promise<BarcodeScannerController> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stopped = false;
|
||||
let animationFrame = 0;
|
||||
let stream: MediaStream | null = null;
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia(SCAN_CONSTRAINTS);
|
||||
videoElement.srcObject = stream;
|
||||
videoElement.setAttribute("playsinline", "true");
|
||||
videoElement.muted = true;
|
||||
await videoElement.play();
|
||||
|
||||
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
|
||||
if (!Detector) {
|
||||
throw new Error("Native barcode detector unavailable.");
|
||||
}
|
||||
const detector = new Detector({ formats: NATIVE_FORMATS });
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
stopVideoStream(videoElement);
|
||||
};
|
||||
|
||||
const scan = async () => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
if (videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
const barcodes = await detector.detect(videoElement);
|
||||
const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? ""));
|
||||
if (barcode?.rawValue) {
|
||||
onResult({
|
||||
value: normalizeBarcode(barcode.rawValue),
|
||||
format: barcode.format ?? "unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!stopped) animationFrame = window.requestAnimationFrame(() => void scan());
|
||||
}
|
||||
};
|
||||
|
||||
animationFrame = window.requestAnimationFrame(() => void scan());
|
||||
resolve({ mode: "native", stop });
|
||||
} catch (error) {
|
||||
if (stream) stream.getTracks().forEach((track) => track.stop());
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
void start();
|
||||
});
|
||||
}
|
||||
|
||||
async function startZxingBarcodeScanner(
|
||||
videoElement: HTMLVideoElement,
|
||||
onResult: (result: BarcodeScanResult) => void,
|
||||
onError: (error: BarcodeScannerError) => void,
|
||||
): Promise<BarcodeScannerController> {
|
||||
const reader = new BrowserMultiFormatReader();
|
||||
reader.possibleFormats = ZXING_FORMATS;
|
||||
|
||||
try {
|
||||
const controls = await reader.decodeFromConstraints(
|
||||
SCAN_CONSTRAINTS,
|
||||
videoElement,
|
||||
(result, error) => {
|
||||
if (result) {
|
||||
onResult({
|
||||
value: normalizeBarcode(result.getText()),
|
||||
format: BarcodeFormat[result.getBarcodeFormat()] ?? "unknown",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (error && !/not.?found/i.test(error.name) && !/not.?found/i.test(error.message)) {
|
||||
onError(toScannerError(error));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
mode: "zxing",
|
||||
stop: () => stopZxingScanner(controls, videoElement),
|
||||
};
|
||||
} catch (error) {
|
||||
stopVideoStream(videoElement);
|
||||
BrowserCodeReader.releaseAllStreams();
|
||||
throw toScannerError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function stopZxingScanner(controls: IScannerControls, videoElement: HTMLVideoElement) {
|
||||
controls.stop();
|
||||
BrowserCodeReader.releaseAllStreams();
|
||||
stopVideoStream(videoElement);
|
||||
}
|
||||
|
||||
async function supportsNativeBarcodeDetector() {
|
||||
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
|
||||
if (!Detector) return false;
|
||||
if (!Detector.getSupportedFormats) return true;
|
||||
|
||||
try {
|
||||
const formats = await Detector.getSupportedFormats();
|
||||
return NATIVE_FORMATS.some((format) => formats.includes(format));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCameraAccessError(error: unknown) {
|
||||
if (!(error instanceof DOMException)) return false;
|
||||
return ["NotAllowedError", "NotFoundError", "NotReadableError", "OverconstrainedError"].includes(error.name);
|
||||
}
|
||||
|
||||
function toScannerError(error: unknown): BarcodeScannerError {
|
||||
if (error instanceof DOMException) {
|
||||
if (error.name === "NotAllowedError" || error.name === "SecurityError") {
|
||||
return { code: "camera-denied", message: scannerErrorMessage("camera-denied") };
|
||||
}
|
||||
if (error.name === "NotFoundError" || error.name === "OverconstrainedError") {
|
||||
return { code: "no-camera", message: scannerErrorMessage("no-camera") };
|
||||
}
|
||||
if (error.name === "NotReadableError" || error.name === "TrackStartError") {
|
||||
return { code: "camera-in-use", message: scannerErrorMessage("camera-in-use") };
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error && /not.?found|video input|requested device/i.test(error.message)) {
|
||||
return { code: "no-camera", message: scannerErrorMessage("no-camera") };
|
||||
}
|
||||
|
||||
if (error instanceof Error && /not.?allowed|permission|denied/i.test(error.message)) {
|
||||
return { code: "camera-denied", message: scannerErrorMessage("camera-denied") };
|
||||
}
|
||||
|
||||
if (error instanceof Error && /in use|busy|could not start video source/i.test(error.message)) {
|
||||
return { code: "camera-in-use", message: scannerErrorMessage("camera-in-use") };
|
||||
}
|
||||
|
||||
if (error instanceof Error && /not supported|unsupported|barcode detector unavailable/i.test(error.message)) {
|
||||
return { code: "unsupported", message: scannerErrorMessage("unsupported") };
|
||||
}
|
||||
|
||||
return { code: "unknown", message: scannerErrorMessage("unknown") };
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export function useCoachSession(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user.$id]);
|
||||
|
||||
const upsertChatState = useCallback((chat: CoachChat) => {
|
||||
@@ -240,7 +241,7 @@ export function useCoachSession(
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[activeChat, busy, dashboard, entries, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, withAssistantMessage],
|
||||
[activeChat, busy, dashboard, entries, limitCheck, patchAssistantMessage, persistChat, storageReady, upsertChatState, user, userLimits, withAssistantMessage],
|
||||
);
|
||||
|
||||
const queuePrompt = useCallback((prompt: string) => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { normalizeBarcode } from "./barcodeLookup";
|
||||
import type { BarcodeProductDraft, UserBarcodeMapping } from "../types";
|
||||
|
||||
const STORAGE_PREFIX = "red-bull-barcode-mappings:v1";
|
||||
|
||||
export function loadUserBarcodeMappings(userId: string) {
|
||||
const raw = localStorage.getItem(storageKey(userId));
|
||||
if (!raw) return [];
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(isUserBarcodeMapping);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveUserBarcodeMappings(userId: string, mappings: UserBarcodeMapping[]) {
|
||||
localStorage.setItem(storageKey(userId), JSON.stringify(mappings));
|
||||
}
|
||||
|
||||
export function upsertUserBarcodeMapping(
|
||||
userId: string,
|
||||
barcodeValue: string,
|
||||
product: BarcodeProductDraft,
|
||||
) {
|
||||
const barcode = normalizeBarcode(barcodeValue);
|
||||
const now = new Date().toISOString();
|
||||
const mappings = loadUserBarcodeMappings(userId);
|
||||
const existing = mappings.find((mapping) => mapping.barcode === barcode);
|
||||
const nextMapping: UserBarcodeMapping = {
|
||||
...product,
|
||||
barcode,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const nextMappings = existing
|
||||
? mappings.map((mapping) => (mapping.barcode === barcode ? nextMapping : mapping))
|
||||
: [...mappings, nextMapping];
|
||||
|
||||
saveUserBarcodeMappings(userId, nextMappings);
|
||||
return nextMapping;
|
||||
}
|
||||
|
||||
function storageKey(userId: string) {
|
||||
return `${STORAGE_PREFIX}:${userId}`;
|
||||
}
|
||||
|
||||
function isUserBarcodeMapping(value: unknown): value is UserBarcodeMapping {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const mapping = value as Partial<UserBarcodeMapping>;
|
||||
return (
|
||||
typeof mapping.barcode === "string" &&
|
||||
typeof mapping.flavourName === "string" &&
|
||||
typeof mapping.sizeMl === "number" &&
|
||||
typeof mapping.pricePerCan === "number" &&
|
||||
typeof mapping.createdAt === "string" &&
|
||||
typeof mapping.updatedAt === "string" &&
|
||||
(mapping.sugarFree === undefined || typeof mapping.sugarFree === "boolean") &&
|
||||
(mapping.caffeineMgPerCan === undefined || typeof mapping.caffeineMgPerCan === "number")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user