Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import Constants from "expo-constants";
|
||||
import { useAuth } from "./auth";
|
||||
|
||||
/**
|
||||
* API-klient. Mobilappen pratar ENDAST med Food API – aldrig direkt med
|
||||
* modelleverantörer, och innehåller inga AI-hemligheter (spec §31, §61.13).
|
||||
* 401 → automatisk refresh med roterande token → retry en gång.
|
||||
*/
|
||||
|
||||
const API_BASE =
|
||||
(Constants.expoConfig?.extra as { apiBaseUrl?: string } | undefined)?.apiBaseUrl ??
|
||||
"http://localhost:4000";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function tryRefresh(): Promise<boolean> {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
const { getRefreshToken, setSession, logout } = useAuth.getState();
|
||||
const refreshToken = await getRefreshToken();
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/v1/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await logout();
|
||||
return false;
|
||||
}
|
||||
const json = (await res.json()) as { accessToken: string; refreshToken: string };
|
||||
await setSession(
|
||||
{ accessToken: json.accessToken, refreshToken: json.refreshToken },
|
||||
useAuth.getState().user,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
refreshPromise = null;
|
||||
}, 100);
|
||||
}
|
||||
})();
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(
|
||||
path: string,
|
||||
options: { method?: string; body?: unknown; retry?: boolean } = {},
|
||||
): Promise<T> {
|
||||
const { accessToken } = useAuth.getState();
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
||||
},
|
||||
body: options.body != null ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 401 && options.retry !== false) {
|
||||
const refreshed = await tryRefresh();
|
||||
if (refreshed) return api<T>(path, { ...options, retry: false });
|
||||
}
|
||||
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
error?: { code?: string; message?: string; details?: unknown };
|
||||
};
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
json.error?.code ?? "UNKNOWN",
|
||||
json.error?.message ?? `HTTP ${res.status}`,
|
||||
json.error?.details,
|
||||
);
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
/** Ladda upp en bild till en presignad URL (mock-S3 i dev, riktig S3 i prod). */
|
||||
export async function uploadImage(
|
||||
upload: { uploadUrl: string; headers: Record<string, string> },
|
||||
localUri: string,
|
||||
): Promise<void> {
|
||||
const blob = await (await fetch(localUri)).blob();
|
||||
const res = await fetch(upload.uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: upload.headers,
|
||||
body: blob,
|
||||
});
|
||||
if (!res.ok) throw new ApiError(res.status, "UPLOAD_FAILED", "Bilduppladdningen misslyckades.");
|
||||
}
|
||||
|
||||
export { API_BASE };
|
||||
@@ -0,0 +1,88 @@
|
||||
import { create } from "zustand";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { BRAND } from "./brand";
|
||||
import { detectDeviceLanguageTag, setLocale } from "./i18n";
|
||||
|
||||
/**
|
||||
* Auth-state. Tokens lagras i SecureStore (spec §44: lokal känslig data skyddas).
|
||||
* Access-token hålls i minne; refresh-token endast i SecureStore.
|
||||
*/
|
||||
|
||||
const ACCESS_KEY = `${BRAND.slug}_access`;
|
||||
const REFRESH_KEY = `${BRAND.slug}_refresh`;
|
||||
const LANGUAGE_KEY = `${BRAND.slug}_language`;
|
||||
|
||||
/** Spara + aktivera språk (anropas från profilens språkval och vid inloggning). */
|
||||
export async function persistLanguageTag(languageTag: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(LANGUAGE_KEY, languageTag);
|
||||
setLocale(languageTag);
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
hydrated: boolean;
|
||||
accessToken: string | null;
|
||||
user: AuthUser | null;
|
||||
onboardingCompleted: boolean;
|
||||
hydrate: () => Promise<void>;
|
||||
setSession: (
|
||||
tokens: { accessToken: string; refreshToken: string },
|
||||
user: AuthUser | null,
|
||||
) => Promise<void>;
|
||||
setAccessToken: (token: string) => void;
|
||||
setOnboardingCompleted: (done: boolean) => void;
|
||||
getRefreshToken: () => Promise<string | null>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>((set) => ({
|
||||
hydrated: false,
|
||||
accessToken: null,
|
||||
user: null,
|
||||
onboardingCompleted: true,
|
||||
|
||||
hydrate: async () => {
|
||||
try {
|
||||
const accessToken = await SecureStore.getItemAsync(ACCESS_KEY);
|
||||
set({ accessToken: accessToken ?? null, hydrated: true });
|
||||
// Språk (D-031): sparat val vinner; annars enhetens språk (stött → det,
|
||||
// ostött → engelska). Spanjoren som laddar ner appen får spanska direkt.
|
||||
const savedTag = await SecureStore.getItemAsync(LANGUAGE_KEY);
|
||||
if (savedTag) {
|
||||
setLocale(savedTag);
|
||||
} else {
|
||||
const deviceTag = detectDeviceLanguageTag();
|
||||
if (deviceTag) setLocale(deviceTag);
|
||||
}
|
||||
} catch {
|
||||
set({ hydrated: true });
|
||||
}
|
||||
},
|
||||
|
||||
setSession: async (tokens, user) => {
|
||||
await SecureStore.setItemAsync(ACCESS_KEY, tokens.accessToken);
|
||||
await SecureStore.setItemAsync(REFRESH_KEY, tokens.refreshToken);
|
||||
set({ accessToken: tokens.accessToken, user });
|
||||
},
|
||||
|
||||
setAccessToken: (token) => {
|
||||
set({ accessToken: token });
|
||||
void SecureStore.setItemAsync(ACCESS_KEY, token);
|
||||
},
|
||||
|
||||
setOnboardingCompleted: (done) => set({ onboardingCompleted: done }),
|
||||
|
||||
getRefreshToken: () => SecureStore.getItemAsync(REFRESH_KEY),
|
||||
|
||||
logout: async () => {
|
||||
await SecureStore.deleteItemAsync(ACCESS_KEY);
|
||||
await SecureStore.deleteItemAsync(REFRESH_KEY);
|
||||
set({ accessToken: null, user: null });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,13 @@
|
||||
import brandConfig from "../../../../brand.config.json";
|
||||
|
||||
/** Varumärket – enda källan är brand.config.json i repo-roten (i18n-spec §28). */
|
||||
export const BRAND = brandConfig as {
|
||||
name: string;
|
||||
slug: string;
|
||||
urlScheme: string;
|
||||
iosBundleId: string;
|
||||
androidPackage: string;
|
||||
apiDomain: string;
|
||||
adminDomain: string;
|
||||
supportEmail: string;
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* i18n via i18next (i18n-spec §5, §7–8; M4 – beslut D-023, D-031).
|
||||
*
|
||||
* SPRÅKPOLICY (D-031):
|
||||
* 1. Första start utan sparat val → enhetens språk om det stöds
|
||||
* (spanjoren som laddar ner appen får spanska direkt).
|
||||
* 2. Stöds inte enhetens språk → engelska (internationell fallback),
|
||||
* svenska är alltid sista fallback för saknade nycklar.
|
||||
* 3. Manuellt val i profilen vinner ALLTID över enhetens språk och synkas
|
||||
* till backend (mejl, notiser och innehåll följer samma val) –
|
||||
* spanjoren i Sverige kan köra spanska, svensken i Spanien svenska.
|
||||
*
|
||||
* Nytt språk = en JSON-fil + rad i SUPPORTED_LANGUAGES + seed-rader för
|
||||
* enheter/ingredienser. Paritet vaktas av test (alla filer, samma nycklar).
|
||||
*/
|
||||
import i18next from "i18next";
|
||||
import { create } from "zustand";
|
||||
import { BRAND } from "./brand";
|
||||
import sv from "../locales/sv/common.json";
|
||||
import en from "../locales/en/common.json";
|
||||
import es from "../locales/es/common.json";
|
||||
import it from "../locales/it/common.json";
|
||||
import de from "../locales/de/common.json";
|
||||
import fr from "../locales/fr/common.json";
|
||||
import da from "../locales/da/common.json";
|
||||
import nb from "../locales/nb/common.json";
|
||||
import fi from "../locales/fi/common.json";
|
||||
import nl from "../locales/nl/common.json";
|
||||
import pl from "../locales/pl/common.json";
|
||||
import pt from "../locales/pt/common.json";
|
||||
|
||||
export type TranslationKey = string;
|
||||
|
||||
/** Stödda språk – renderar språkväljaren och styr detekteringen. */
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{ code: "sv", tag: "sv-SE", labelKey: "profile.language.sv" },
|
||||
{ code: "en", tag: "en-US", labelKey: "profile.language.en" },
|
||||
{ code: "es", tag: "es-ES", labelKey: "profile.language.es" },
|
||||
{ code: "it", tag: "it-IT", labelKey: "profile.language.it" },
|
||||
{ code: "de", tag: "de-DE", labelKey: "profile.language.de" },
|
||||
{ code: "fr", tag: "fr-FR", labelKey: "profile.language.fr" },
|
||||
{ code: "da", tag: "da-DK", labelKey: "profile.language.da" },
|
||||
{ code: "nb", tag: "nb-NO", labelKey: "profile.language.nb" },
|
||||
{ code: "fi", tag: "fi-FI", labelKey: "profile.language.fi" },
|
||||
{ code: "nl", tag: "nl-NL", labelKey: "profile.language.nl" },
|
||||
{ code: "pl", tag: "pl-PL", labelKey: "profile.language.pl" },
|
||||
{ code: "pt", tag: "pt-PT", labelKey: "profile.language.pt" },
|
||||
] as const;
|
||||
|
||||
const SUPPORTED_CODES = new Set<string>(SUPPORTED_LANGUAGES.map((l) => l.code));
|
||||
|
||||
void i18next.init({
|
||||
lng: "sv",
|
||||
fallbackLng: "sv",
|
||||
resources: {
|
||||
sv: { common: sv },
|
||||
en: { common: en },
|
||||
es: { common: es },
|
||||
it: { common: it },
|
||||
de: { common: de },
|
||||
fr: { common: fr },
|
||||
da: { common: da },
|
||||
nb: { common: nb },
|
||||
fi: { common: fi },
|
||||
nl: { common: nl },
|
||||
pl: { common: pl },
|
||||
pt: { common: pt },
|
||||
},
|
||||
defaultNS: "common",
|
||||
// Platta nycklar – "home.title" är EN nyckel, inte nästling.
|
||||
keySeparator: false,
|
||||
nsSeparator: false,
|
||||
interpolation: {
|
||||
prefix: "{",
|
||||
suffix: "}",
|
||||
escapeValue: false,
|
||||
defaultVariables: { brand: BRAND.name },
|
||||
},
|
||||
returnNull: false,
|
||||
initAsync: false, // synkron init – resurserna är bundlade
|
||||
});
|
||||
|
||||
interface I18nState {
|
||||
version: number;
|
||||
languageTag: string;
|
||||
bump: (languageTag: string) => void;
|
||||
}
|
||||
const useI18nStore = create<I18nState>((set) => ({
|
||||
version: 0,
|
||||
languageTag: "sv-SE",
|
||||
bump: (languageTag) => set((s) => ({ version: s.version + 1, languageTag })),
|
||||
}));
|
||||
|
||||
/** Rotlayouten läser denna och re-monterar trädet vid språkbyte. */
|
||||
export function useI18nVersion(): number {
|
||||
return useI18nStore((s) => s.version);
|
||||
}
|
||||
|
||||
/** Byt språk utifrån BCP 47-tagg ("es-MX" -> resurser "es", Intl "es-MX"). */
|
||||
export function setLocale(languageTag: string): void {
|
||||
const primary = languageTag.split("-")[0] ?? "sv";
|
||||
const lng = SUPPORTED_CODES.has(primary) ? primary : "sv";
|
||||
void i18next.changeLanguage(lng);
|
||||
useI18nStore.getState().bump(lng === primary ? languageTag : "sv-SE");
|
||||
}
|
||||
|
||||
/** Full BCP 47-tagg för Intl-formattering (pengar, datum, tal). */
|
||||
export function getLanguageTag(): string {
|
||||
return useI18nStore.getState().languageTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhetens språk vid FÖRSTA start (D-031 steg 1–2): stött språk vinner,
|
||||
* annars engelska. expo-localization saknas i testmiljö → null och sv-default.
|
||||
*/
|
||||
export function detectDeviceLanguageTag(): string | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Localization = require("expo-localization") as {
|
||||
getLocales(): { languageTag: string; languageCode: string | null }[];
|
||||
};
|
||||
const locales = Localization.getLocales();
|
||||
for (const locale of locales) {
|
||||
const code =
|
||||
locale.languageCode === "no" || locale.languageCode === "nn"
|
||||
? "nb" // norska varianter -> bokmål
|
||||
: locale.languageCode;
|
||||
if (code && SUPPORTED_CODES.has(code)) {
|
||||
return code === locale.languageCode ? locale.languageTag : "nb-NO";
|
||||
}
|
||||
}
|
||||
return locales.length > 0 ? "en-US" : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Översätt. Plural: skicka `count` (i18next väljer _one/_other via
|
||||
* Intl.PluralRules). Alla {var} interpoleras; {brand} finns alltid.
|
||||
*/
|
||||
export function t(key: TranslationKey, params?: Record<string, string | number>): string {
|
||||
return i18next.t(key, params);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Pengavisning i appen (i18n-spec §20–21): belopp kommer ALLTID från API:t som
|
||||
* minor units (heltal) + ISO 4217-valuta. Visning formatteras med Intl enligt
|
||||
* användarens språk. Ingen aritmetik på flyttalsbelopp i appen.
|
||||
*/
|
||||
import { getLanguageTag } from "./i18n";
|
||||
|
||||
const MINOR_DIGITS: Record<string, number> = { ISK: 0, JPY: 0, KWD: 3 };
|
||||
|
||||
function minorDigits(currency: string): number {
|
||||
return MINOR_DIGITS[currency.toUpperCase()] ?? 2;
|
||||
}
|
||||
|
||||
/** "7900" + "SEK" -> "79,00 kr" (sv-SE) / "SEK 79.00" (en-US). */
|
||||
export function formatMinor(amountMinor: number, currency: string): string {
|
||||
const value = amountMinor / 10 ** minorDigits(currency);
|
||||
try {
|
||||
return new Intl.NumberFormat(getLanguageTag(), {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
maximumFractionDigits: Number.isInteger(value) ? 0 : 2,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return `${value} ${currency.toUpperCase()}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** plattformens designtokens. Grön bas (mat, hållbarhet), varm accent. */
|
||||
export const colors = {
|
||||
background: "#F7F8F5",
|
||||
surface: "#FFFFFF",
|
||||
surfaceAlt: "#EFF3EC",
|
||||
text: "#17241B",
|
||||
textMuted: "#5C6B60",
|
||||
primary: "#16803C",
|
||||
primaryDark: "#0F5C2B",
|
||||
primarySoft: "#DCF2E3",
|
||||
accent: "#E2712E",
|
||||
accentSoft: "#FCE9DC",
|
||||
danger: "#B91C1C",
|
||||
dangerSoft: "#FEE2E2",
|
||||
warning: "#B45309",
|
||||
warningSoft: "#FEF3C7",
|
||||
border: "#E3E8E0",
|
||||
overlay: "rgba(15, 36, 23, 0.55)",
|
||||
} as const;
|
||||
|
||||
export const spacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
} as const;
|
||||
|
||||
export const radius = {
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
full: 999,
|
||||
} as const;
|
||||
|
||||
export const typography = {
|
||||
title: { fontSize: 26, fontWeight: "700" as const, color: colors.text },
|
||||
heading: { fontSize: 19, fontWeight: "700" as const, color: colors.text },
|
||||
body: { fontSize: 15, color: colors.text },
|
||||
small: { fontSize: 13, color: colors.textMuted },
|
||||
} as const;
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Enhetsvisning och -tolkning i appen (i18n-spec §9, §11).
|
||||
* Canonical koder (GRAM, TABLESPOON …) kommer från API:t; här översätts de till
|
||||
* lokala förkortningar och tolkas tillbaka från användarens fritext.
|
||||
* Måttsystem-konvertering (M5) sker via displayQuantity i shared-types.
|
||||
* DB-tabellen unit_translations är källan för fler språk (API: /v1/i18n/units).
|
||||
*/
|
||||
import { displayQuantity, type MeasurementSystem, type Unit } from "@app/shared-types";
|
||||
|
||||
const LABELS: Record<string, Record<string, string>> = {
|
||||
sv: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "tsk",
|
||||
TABLESPOON: "msk",
|
||||
CUP_US: "cup",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "st",
|
||||
PORTION: "portion",
|
||||
PINCH: "krm",
|
||||
SLICE: "skiva",
|
||||
CLOVE: "klyfta",
|
||||
CAN: "burk",
|
||||
PACKAGE: "paket",
|
||||
},
|
||||
en: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "tsp",
|
||||
TABLESPOON: "tbsp",
|
||||
CUP_US: "cup",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "pcs",
|
||||
PORTION: "serving",
|
||||
PINCH: "pinch",
|
||||
SLICE: "slice",
|
||||
CLOVE: "clove",
|
||||
CAN: "can",
|
||||
PACKAGE: "pack",
|
||||
},
|
||||
es: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "cdta",
|
||||
TABLESPOON: "cda",
|
||||
CUP_US: "taza",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "ud",
|
||||
PORTION: "ración",
|
||||
PINCH: "pizca",
|
||||
SLICE: "rebanada",
|
||||
CLOVE: "diente",
|
||||
CAN: "lata",
|
||||
PACKAGE: "paquete",
|
||||
},
|
||||
it: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "cucchiaino",
|
||||
TABLESPOON: "cucchiaio",
|
||||
CUP_US: "tazza",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "pz",
|
||||
PORTION: "porzione",
|
||||
PINCH: "pizzico",
|
||||
SLICE: "fetta",
|
||||
CLOVE: "spicchio",
|
||||
CAN: "lattina",
|
||||
PACKAGE: "confezione",
|
||||
},
|
||||
de: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "TL",
|
||||
TABLESPOON: "EL",
|
||||
CUP_US: "Cup",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "Stk",
|
||||
PORTION: "Portion",
|
||||
PINCH: "Prise",
|
||||
SLICE: "Scheibe",
|
||||
CLOVE: "Zehe",
|
||||
CAN: "Dose",
|
||||
PACKAGE: "Packung",
|
||||
},
|
||||
fr: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "c. à c.",
|
||||
TABLESPOON: "c. à s.",
|
||||
CUP_US: "cup",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "pcs",
|
||||
PORTION: "portion",
|
||||
PINCH: "pincée",
|
||||
SLICE: "tranche",
|
||||
CLOVE: "gousse",
|
||||
CAN: "boîte",
|
||||
PACKAGE: "paquet",
|
||||
},
|
||||
da: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "tsk",
|
||||
TABLESPOON: "spsk",
|
||||
CUP_US: "cup",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "stk",
|
||||
PORTION: "portion",
|
||||
PINCH: "knsp",
|
||||
SLICE: "skive",
|
||||
CLOVE: "fed",
|
||||
CAN: "dåse",
|
||||
PACKAGE: "pakke",
|
||||
},
|
||||
nb: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "ts",
|
||||
TABLESPOON: "ss",
|
||||
CUP_US: "cup",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "stk",
|
||||
PORTION: "porsjon",
|
||||
PINCH: "knivsodd",
|
||||
SLICE: "skive",
|
||||
CLOVE: "båt",
|
||||
CAN: "boks",
|
||||
PACKAGE: "pakke",
|
||||
},
|
||||
fi: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "tl",
|
||||
TABLESPOON: "rkl",
|
||||
CUP_US: "cup",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "kpl",
|
||||
PORTION: "annos",
|
||||
PINCH: "hyppysellinen",
|
||||
SLICE: "viipale",
|
||||
CLOVE: "kynsi",
|
||||
CAN: "tölkki",
|
||||
PACKAGE: "paketti",
|
||||
},
|
||||
nl: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "tl",
|
||||
TABLESPOON: "el",
|
||||
CUP_US: "cup",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "st",
|
||||
PORTION: "portie",
|
||||
PINCH: "snufje",
|
||||
SLICE: "plak",
|
||||
CLOVE: "teentje",
|
||||
CAN: "blik",
|
||||
PACKAGE: "pak",
|
||||
},
|
||||
pl: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "łyżeczka",
|
||||
TABLESPOON: "łyżka",
|
||||
CUP_US: "cup",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "szt.",
|
||||
PORTION: "porcja",
|
||||
PINCH: "szczypta",
|
||||
SLICE: "plaster",
|
||||
CLOVE: "ząbek",
|
||||
CAN: "puszka",
|
||||
PACKAGE: "opakowanie",
|
||||
},
|
||||
pt: {
|
||||
GRAM: "g",
|
||||
KILOGRAM: "kg",
|
||||
MILLILITER: "ml",
|
||||
DECILITER: "dl",
|
||||
LITER: "l",
|
||||
TEASPOON: "c. chá",
|
||||
TABLESPOON: "c. sopa",
|
||||
CUP_US: "cup",
|
||||
FLUID_OUNCE_US: "fl oz",
|
||||
OUNCE: "oz",
|
||||
POUND: "lb",
|
||||
COUNT: "un",
|
||||
PORTION: "dose",
|
||||
PINCH: "pitada",
|
||||
SLICE: "fatia",
|
||||
CLOVE: "dente",
|
||||
CAN: "lata",
|
||||
PACKAGE: "embalagem",
|
||||
},
|
||||
};
|
||||
|
||||
let currentLanguage = "sv";
|
||||
export function setUnitLanguage(language: string): void {
|
||||
currentLanguage = LABELS[language] ? language : "sv";
|
||||
}
|
||||
|
||||
/** Kod → lokal etikett ("GRAM" → "g", "TABLESPOON" → "msk"). */
|
||||
export function unitLabel(unitCode: string): string {
|
||||
return LABELS[currentLanguage]?.[unitCode] ?? LABELS.sv?.[unitCode] ?? unitCode.toLowerCase();
|
||||
}
|
||||
|
||||
/** Fritext → kod ("msk" → TABLESPOON, "tbsp" → TABLESPOON, "st" → COUNT …). */
|
||||
const PARSE: Record<string, string> = {
|
||||
g: "GRAM",
|
||||
gram: "GRAM",
|
||||
grams: "GRAM",
|
||||
kg: "KILOGRAM",
|
||||
kilo: "KILOGRAM",
|
||||
ml: "MILLILITER",
|
||||
milliliter: "MILLILITER",
|
||||
dl: "DECILITER",
|
||||
deciliter: "DECILITER",
|
||||
l: "LITER",
|
||||
liter: "LITER",
|
||||
litre: "LITER",
|
||||
tsk: "TEASPOON",
|
||||
tsp: "TEASPOON",
|
||||
teaspoon: "TEASPOON",
|
||||
msk: "TABLESPOON",
|
||||
tbsp: "TABLESPOON",
|
||||
tablespoon: "TABLESPOON",
|
||||
krm: "MILLILITER", // 1 krm = 1 ml (dokumenterad standard)
|
||||
cup: "CUP_US",
|
||||
cups: "CUP_US",
|
||||
"fl oz": "FLUID_OUNCE_US",
|
||||
floz: "FLUID_OUNCE_US",
|
||||
oz: "OUNCE",
|
||||
ounce: "OUNCE",
|
||||
lb: "POUND",
|
||||
lbs: "POUND",
|
||||
pound: "POUND",
|
||||
st: "COUNT",
|
||||
styck: "COUNT",
|
||||
pcs: "COUNT",
|
||||
pc: "COUNT",
|
||||
stk: "COUNT",
|
||||
portion: "PORTION",
|
||||
portioner: "PORTION",
|
||||
serving: "PORTION",
|
||||
nypa: "PINCH",
|
||||
pinch: "PINCH",
|
||||
skiva: "SLICE",
|
||||
skivor: "SLICE",
|
||||
slice: "SLICE",
|
||||
klyfta: "CLOVE",
|
||||
klyftor: "CLOVE",
|
||||
clove: "CLOVE",
|
||||
burk: "CAN",
|
||||
can: "CAN",
|
||||
paket: "PACKAGE",
|
||||
pkt: "PACKAGE",
|
||||
pack: "PACKAGE",
|
||||
package: "PACKAGE",
|
||||
};
|
||||
|
||||
export function parseUnitInput(text: string): string | null {
|
||||
const key = text.trim().toLowerCase();
|
||||
if (!key) return null;
|
||||
if (LABELS.sv?.[text.trim().toUpperCase()]) return text.trim().toUpperCase();
|
||||
return PARSE[key] ?? null;
|
||||
}
|
||||
|
||||
/** Användarens måttsystem (i18n M5) – sätts från locale-preferenserna. */
|
||||
let currentSystem: MeasurementSystem = "METRIC";
|
||||
export function setMeasurementSystem(system: MeasurementSystem): void {
|
||||
currentSystem = system;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kanonisk mängd -> visningssträng i användarens måttsystem (i18n-spec §9–10).
|
||||
* Lagringen är alltid kanonisk; endast visningen konverterar. "≈" markerar
|
||||
* konverterade (approximativa) värden – ärlighet före skenprecision.
|
||||
*/
|
||||
export function formatQuantity(quantity: number, unitCode: string): string {
|
||||
const display = KNOWN_UNITS.has(unitCode)
|
||||
? displayQuantity(quantity, unitCode as Unit, currentSystem)
|
||||
: { value: quantity, unit: unitCode, approximate: false };
|
||||
const v = display.value;
|
||||
const rounded = Number.isInteger(v) ? String(v) : String(Math.round(v * 100) / 100);
|
||||
const prefix = display.approximate ? "≈" : "";
|
||||
return `${prefix}${rounded} ${unitLabel(String(display.unit))}`;
|
||||
}
|
||||
|
||||
const KNOWN_UNITS = new Set(Object.keys(LABELS.sv ?? {}));
|
||||
Reference in New Issue
Block a user